diff --git a/.github/ISSUE_TEMPLATE/runtime_error_report.yml b/.github/ISSUE_TEMPLATE/runtime_error_report.yml new file mode 100644 index 00000000..f321fc80 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/runtime_error_report.yml @@ -0,0 +1,58 @@ +name: Runtime error report +description: Report an error that HARP showed while loading or running a model +title: "HARP runtime error report" +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + HARP fills in the error, endpoint, and environment fields for you when + this form is opened from its error popup. Please add what you were doing + and how to reproduce it. If you can, attach the log file, which the + popup's Open Logs button reveals. + + - type: textarea + id: summary + attributes: + label: Summary + description: The error reported by HARP. + validations: + required: true + + - type: input + id: endpoint + attributes: + label: Endpoint + description: The model or Space involved, if any. + + - type: textarea + id: notes + attributes: + label: User notes + description: What were you doing when this happened? + + - type: textarea + id: environment + attributes: + label: Environment + description: HARP version, operating system, and local time. + render: markdown + + - type: textarea + id: reproduction + attributes: + label: Reproduction steps + value: | + 1. + 2. + 3. + + - type: textarea + id: expected + attributes: + label: Expected behavior + + - type: textarea + id: actual + attributes: + label: Actual behavior diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..23ea27d1 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,23 @@ +## Summary + + + +## Related Issues + + + +## How to Test + + + +## Author Checklist + +- [ ] The title and description make the intent clear +- [ ] Relevant issues are linked +- [ ] This pull request addresses one issue +- [ ] I read my own diff end to end and reviewed it as though someone else wrote it +- [ ] I built and tested this branch, including functionality the change could affect +- [ ] Code is formatted and CI passes + +Reviewers: see the [Reviewing Guidelines](https://github.com/TEAMuP-dev/HARP/blob/main/docs/REVIEWING.md) +for how to review this and for the reviewer checklist. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..81998f81 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,177 @@ +# Builds and packages HARP for each platform, and publishes a GitHub release for v* tags. +# The macOS and Windows packages are code signed with the credentials described in docs/SIGNING.md. +# Originally adapted from pamplejuce (https://github.com/sudara/pamplejuce, MIT License). + +name: HARP + +on: + push: + paths-ignore: + - '**.md' + - 'website/**' + - 'model_validation/**' + - '.github/ISSUE_TEMPLATE/**' + - '.github/workflows/model_validation.yml' + pull_request: + workflow_dispatch: + +permissions: + contents: read + +# Cancel running builds of a branch when new commits are pushed to it +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: bash + +env: + # Forks cannot access the signing credentials, so their packages are left unsigned + SIGN_MACOS: ${{ secrets.MACOS_CERTIFICATE != '' }} + SIGN_WINDOWS: ${{ secrets.AZURE_CLIENT_SECRET != '' }} + +jobs: + build: + name: ${{ matrix.name }} + # Pull requests from branches of this repository are already built on push + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork + runs-on: ${{ matrix.os }} + timeout-minutes: 60 + strategy: + fail-fast: false # show all errors for each platform (vs. cancel jobs on error) + matrix: + include: + - name: macOS + os: macos-26 + cache: ccache + # Universal binary, for both Apple silicon and Intel Macs + cmake-args: '-DCMAKE_OSX_ARCHITECTURES="arm64;x86_64"' + - name: Windows-x64 + os: windows-2025 + cache: sccache + # Linux binaries require at least the glibc version used to build them, so build on the oldest Ubuntu + - name: Linux-x64 + os: ubuntu-22.04 + cache: ccache + - name: Linux-arm64 + os: ubuntu-22.04-arm + cache: ccache + + steps: + - uses: actions/checkout@v7 + with: + submodules: recursive + + # See JUCE/docs/Linux Dependencies.md (WebKit, OpenGL and JACK are not used) + - name: Install Linux dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libasound2-dev libcurl4-openssl-dev libfontconfig1-dev libfreetype-dev \ + libx11-dev libxcomposite-dev libxcursor-dev libxext-dev libxinerama-dev libxrandr-dev libxrender-dev + + - name: Set up MSVC + if: runner.os == 'Windows' + uses: TheMrMilchmann/setup-msvc-dev@v4 + with: + arch: x64 + + - name: Set up compiler cache + uses: hendrikmuhs/ccache-action@v1.2.24 + with: + key: ${{ matrix.name }} + variant: ${{ matrix.cache }} + + - name: Configure + run: | + cmake -B Builds -G Ninja -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_C_COMPILER_LAUNCHER=${{ matrix.cache }} -DCMAKE_CXX_COMPILER_LAUNCHER=${{ matrix.cache }} \ + ${{ matrix.cmake-args }} + + - name: Build + run: cmake --build Builds + + - name: Set package name + run: | + echo "PACKAGE=HARP-$(cat VERSION)-${{ matrix.name }}" >> "$GITHUB_ENV" + echo "ARTEFACTS=Builds/HARP_artefacts/Release" >> "$GITHUB_ENV" + + - name: Import Developer ID certificate (macOS) + if: runner.os == 'macOS' && env.SIGN_MACOS == 'true' + uses: apple-actions/import-codesign-certs@v7 + with: + p12-file-base64: ${{ secrets.MACOS_CERTIFICATE }} + p12-password: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} + + - name: Package (macOS) + if: runner.os == 'macOS' + env: + # The imported certificate is the only identity on the runner, so part of its name selects it + MACOS_SIGNING_IDENTITY: ${{ env.SIGN_MACOS == 'true' && 'Developer ID Application' || '' }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }} + run: | + pipx install dmgbuild==1.6.7 + packaging/package.sh "$ARTEFACTS/HARP.app" "dist/$PACKAGE.dmg" + + - name: Sign (Windows) + if: runner.os == 'Windows' && env.SIGN_WINDOWS == 'true' + uses: azure/artifact-signing-action@v2 + with: + azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }} + azure-client-id: ${{ secrets.AZURE_CLIENT_ID }} + azure-client-secret: ${{ secrets.AZURE_CLIENT_SECRET }} + endpoint: ${{ vars.AZURE_SIGNING_ENDPOINT }} + signing-account-name: ${{ vars.AZURE_SIGNING_ACCOUNT }} + certificate-profile-name: ${{ vars.AZURE_CERTIFICATE_PROFILE }} + files: ${{ github.workspace }}\Builds\HARP_artefacts\Release\HARP.exe + file-digest: SHA256 + timestamp-rfc3161: http://timestamp.acs.microsoft.com + timestamp-digest: SHA256 + + - name: Package (Windows) + if: runner.os == 'Windows' + run: | + mkdir -p dist + 7z a "dist/$PACKAGE.zip" "$ARTEFACTS/HARP.exe" + + # A tarball keeps the executable permission, which zip archives made here would lose + - name: Package (Linux) + if: runner.os == 'Linux' + run: | + mkdir -p dist + tar -czf "dist/$PACKAGE.tar.gz" -C "$ARTEFACTS" HARP + + - name: Upload package + uses: actions/upload-artifact@v7 + with: + path: dist/* + archive: false # the package is already compressed + if-no-files-found: error + overwrite: true + + release: + if: startsWith(github.ref, 'refs/tags/v') + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + # For RELEASE.md + - uses: actions/checkout@v7 + + - uses: actions/download-artifact@v8 + with: + path: dist + merge-multiple: true + + # If the release already exists (e.g. on a re-run), its packages are replaced instead + - name: Publish release + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "$GITHUB_REF_NAME" dist/* --title "HARP $GITHUB_REF_NAME" --notes-file RELEASE.md \ + || gh release upload "$GITHUB_REF_NAME" dist/* --clobber diff --git a/.github/workflows/cmake_ctest.yml b/.github/workflows/cmake_ctest.yml deleted file mode 100644 index 25a7d9e9..00000000 --- a/.github/workflows/cmake_ctest.yml +++ /dev/null @@ -1,246 +0,0 @@ -# borrowed from pamplejuce: https://github.com/sudara/pamplejuce -# MIT License - -# Copyright (c) 2022 Sudara Williams - -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: - -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. - -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. - -# follow this: https://melatonin.dev/blog/how-to-code-sign-and-notarize-macos-audio-plugins-in-ci/ -name: HARP - -on: - workflow_dispatch: # lets you run a build from the UI - push: - -# When pushing new commits, cancel any running builds on that branch -concurrency: - group: ${{ github.ref }} - cancel-in-progress: true - -env: - PROJECT_NAME: HARP - BUILD_TYPE: Release - BUILD_DIR: Builds - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - DISPLAY: :0 # Linux pluginval needs this - CMAKE_BUILD_PARALLEL_LEVEL: 3 # Use up to 3 cpus to build juceaide, etc - HOMEBREW_NO_INSTALL_CLEANUP: 1 - -# jobs are run in paralell on different machines -# all steps run in series -jobs: - build_and_test: - name: ${{ matrix.name }} - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false # show all errors for each platform (vs. cancel jobs on error) - matrix: - include: - - name: Linux - os: ubuntu-22.04 - pluginval-binary: ./pluginval - ccache: ccache - - name: Windows - os: windows-latest - pluginval-binary: ./pluginval.exe - ccache: sccache - - name: macOS - os: macos-14 - pluginval-binary: pluginval.app/Contents/MacOS/pluginval - ccache: ccache - - steps: - # Setup MSVC toolchain and developer command prompt (Windows) - - uses: ilammy/msvc-dev-cmd@v1 - - # This also starts up our "fake" display Xvfb, needed for pluginval - - name: Install JUCE's Linux Deps - if: runner.os == 'Linux' - # Thanks to McMartin & co https://forum.juce.com/t/list-of-juce-dependencies-under-linux/15121/44 - run: | - sudo apt-get update && sudo apt install libasound2-dev libx11-dev libxinerama-dev libxext-dev libfreetype6-dev libwebkit2gtk-4.0-dev libglu1-mesa-dev xvfb ninja-build libcurl4-openssl-dev - sudo /usr/bin/Xvfb $DISPLAY & - - - name: Install IPP (Windows) - if: runner.os == 'Windows' - run: | - nuget install intelipp.static.win-x64 -Version 2022.2.0.575 - - - name: Install Ninja (Windows) - if: runner.os == 'Windows' - run: choco install ninja - - - name: Install macOS Deps - if: ${{ matrix.name == 'macOS' }} - run: brew install ninja osxutils - - - name: Checkout code - uses: actions/checkout@v4 - with: - submodules: recursive # Get JUCE populated - - - name: Setup Common Environment Variables - shell: bash - run: | - VERSION=$(cat VERSION) - echo "ARTIFACTS_PATH=${{ env.BUILD_DIR }}/${{ env.PROJECT_NAME }}_artefacts/${{ env.BUILD_TYPE }}" >> $GITHUB_ENV - echo "PRODUCT_NAME=${{ env.PROJECT_NAME }}-$VERSION-${{ matrix.name }}" >> $GITHUB_ENV - - - name: Setup Environment Variables for macOS - if: matrix.name == 'macOS' - shell: bash - run: | - echo "STANDALONE_PATH=${{ env.PROJECT_NAME }}_artefacts/${{ env.BUILD_TYPE }}/${{ env.PROJECT_NAME }}.app" >> $GITHUB_ENV - - - name: Setup Environment Variables for Windows - if: matrix.name == 'Windows' - shell: bash - run: | - echo "STANDALONE_PATH=${{ env.PROJECT_NAME }}_artefacts/${{ env.BUILD_TYPE }}/${{ env.PROJECT_NAME }}.exe" >> $GITHUB_ENV - - - name: Setup Environment Variables for Linux - if: matrix.name == 'Linux' - shell: bash - run: | - echo "STANDALONE_PATH=${{ env.PROJECT_NAME }}_artefacts/${{ env.BUILD_TYPE }}/${{ env.PROJECT_NAME }}" >> $GITHUB_ENV - - - name: ccache - uses: hendrikmuhs/ccache-action@main - with: - key: v3-${{ matrix.os }}-${{ matrix.type }} - variant: ${{ matrix.ccache }} - - - name: Import Certificates (macOS) - uses: apple-actions/import-codesign-certs@v1 - if: ${{ matrix.name == 'macOS' }} - with: - p12-file-base64: ${{ secrets.DEV_ID_APP_CERT }} - p12-password: ${{ secrets.DEV_ID_APP_PASSWORD }} - - # - name: Set up Python ${{ matrix.python-version }} (Windows) - # if: matrix.name == 'Windows' - # uses: actions/setup-python@v5 - # with: - # python-version: '3.10' - - - name: Configure - shell: bash - run: cmake -B ${{ env.BUILD_DIR }} -G Ninja -DCMAKE_BUILD_TYPE=${{ env.BUILD_TYPE}} -DCMAKE_C_COMPILER_LAUNCHER=${{ matrix.ccache }} -DCMAKE_CXX_COMPILER_LAUNCHER=${{ matrix.ccache }} -DCMAKE_OSX_ARCHITECTURES="arm64" . - # dummy configure - # run: mkdir -p ${{ env.BUILD_DIR }} - - name: Build - shell: bash - run: cmake --build ${{ env.BUILD_DIR }} --config ${{ env.BUILD_TYPE }} --parallel 4 - # dummy build - # run: touch dummy - - # why the python3 -m pip install packaging? see this issue: https://github.com/nodejs/node-gyp/issues/2869 - - name: Install Python dependencies for MACOS dmg - if: ${{ matrix.name == 'macOS' }} - run: python3 -m pip install --upgrade packaging pip setuptools --break-system-packages --user - - - name: Create DMG, Notarize and Staple (macOS) - if: ${{ matrix.name == 'macOS' }} - run: | - /usr/local/bin/python3 -m pip install packaging - chmod +x ./packaging/package.sh - ./packaging/package.sh "${{ secrets.DEVELOPER_ID_APPLICATION}}" "${{ env.ARTIFACTS_PATH }}" "${{ env.PROJECT_NAME }}" "${{ env.PRODUCT_NAME }}" "${{ secrets.NOTARIZATION_USERNAME }}" "${{ secrets.NOTARIZATION_PASSWORD }}" "${{ secrets.TEAM_ID }}" - - - name: Upload Zip (Windows) - if: ${{ matrix.name == 'Windows' }} - uses: actions/upload-artifact@v4 - with: - name: ${{ env.PRODUCT_NAME }} - path: ${{ env.ARTIFACTS_PATH }} - - - name: Upload Zip (Linux) - if: ${{ matrix.name == 'Linux' }} - uses: actions/upload-artifact@v4 - with: - name: ${{ env.PRODUCT_NAME }} - path: ${{ env.ARTIFACTS_PATH }} - - - name: Upload DMG (macOS) - if: ${{ matrix.name == 'macOS' }} - uses: actions/upload-artifact@v4 - with: - name: ${{ env.PRODUCT_NAME }} - path: packaging/${{ env.PRODUCT_NAME }}.dmg - - - release: - if: contains(github.ref, 'tags/v') - runs-on: ubuntu-latest - needs: build_and_test - - steps: - - # Needed to access the RELEASE.md file - - name: Checkout code - uses: actions/checkout@v4 - with: - ref: ${{ github.ref }} - fetch-depth: 1 - - - name: Download Artifacts - uses: actions/download-artifact@v4 - with: - name: ${{ env.PRODUCT_NAME }} - path: ./artifacts - - - # - name: Breakpoint if tests failed - # # if: failure() - # uses: namespacelabs/breakpoint-action@v0 - # with: - # duration: 30m - # authorized-users: xribene - - # zip each of the folders in the artifacts folder - - name: Zip Artifacts - run: | - for d in ./artifacts/*; do - if [ -d "$d" ]; then - echo $d - pushd "$d" > /dev/null - echo $PWD - zip -r ../$(basename "$PWD").zip * - cd .. - rm -rf $(basename "$d") - popd > /dev/null - fi - done - - # - name: Display structure of downloaded and zipped files - # run: ls -R - - - name: Create Release - id: create_release - uses: ncipollo/release-action@v1 - with: - tag: ${{ github.ref_name }} - name: ${{ github.event.repository.name}} ${{ github.ref_name }} - # body: | - # ## Changes - # - Description of changes - bodyFile: ./RELEASE.md - artifacts: ./artifacts/* - token: ${{ secrets.GITHUB_TOKEN }} - allowUpdates: true - replacesArtifacts: true \ No newline at end of file diff --git a/.github/workflows/model_validation.yml b/.github/workflows/model_validation.yml index 136ec625..68655f88 100644 --- a/.github/workflows/model_validation.yml +++ b/.github/workflows/model_validation.yml @@ -1,26 +1,24 @@ -# Daily validation of HARP model deployments. -# -# Two tiers: -# 1. examples - local pyharp examples run inside the CI runner; no Hugging -# Face infrastructure involved. A failure here means pyharp -# or gradio itself is broken - fix that before debugging -# individual spaces. -# 2. spaces - every Hugging Face Space under teamup-tech, validated -# end-to-end (/controls + /process) via gradio_client. +# Daily validation of HARP model deployments, in two tiers: +# 1. examples: the pyharp examples, run locally inside the runner with no +# Hugging Face infrastructure involved. A failure here means pyharp or +# gradio itself is broken, which is worth fixing before debugging +# individual Spaces. +# 2. spaces: every Hugging Face Space under teamup-tech, validated end to +# end (/controls and /process) through gradio_client. # # Results are published to the run summary and uploaded as artifacts. # -# Model failures do NOT fail the workflow, since deployments are not yet -# stable enough for a daily red run to be actionable; they appear as warning -# annotations instead. Only infrastructure errors (bad token, discovery -# failure, ...) turn the run red, since those mean validation itself has -# stopped working. To get notified on model failures later, remove the -# exit-code handling in the two Validate steps so exit code 1 propagates. +# Model failures do not fail the workflow, since deployments are not yet +# stable enough for a daily red run to be actionable. They appear as warning +# annotations instead. Only infrastructure errors (e.g., a bad token, a +# discovery failure, or a configuration mistake) turn the run red, since those +# mean validation itself has stopped working. To be notified of model failures +# later, remove the exit-code handling in the two Validate steps so that exit +# code 1 propagates. # -# Requires the repository secret HF_TOKEN: a Hugging Face token with read -# access to the teamup-tech spaces (write access if you want the workflow to -# auto-restart crashed spaces). NEVER commit the token; GitHub masks secrets -# in logs automatically. +# Requires the repository secret HF_TOKEN, a Hugging Face token with read +# access to the teamup-tech Spaces (and write access to restart crashed ones). +# Never commit the token. GitHub masks secrets in logs. name: Model Validation @@ -56,11 +54,11 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 45 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: submodules: true - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 with: python-version: '3.11' cache: pip @@ -74,6 +72,10 @@ jobs: pip install -e ./pyharp pip install -r model_validation/requirements.txt + # The harness's own checks, including config.yml, which need no token or network + - name: Test the harness + run: python -m unittest discover -s model_validation/tests -v + - name: Validate local examples run: | set +e @@ -92,7 +94,7 @@ jobs: - name: Upload report if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: report-examples # Each example's app.log holds the app's own traceback, which is what @@ -107,9 +109,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 180 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 with: python-version: '3.11' cache: pip @@ -153,7 +155,7 @@ jobs: - name: Upload report if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: report-spaces path: reports/spaces/*/report.* diff --git a/.gitignore b/.gitignore index b41816c2..36170ec0 100644 --- a/.gitignore +++ b/.gitignore @@ -31,7 +31,7 @@ *.out *.app -# build +# Build build*/ .vscode/ libtorch/ @@ -53,12 +53,11 @@ py/client/dist/ py/client/*.spec dist*/ - dist/ Miniconda*/ Miniconda*.sh -# website +# Website node_modules temp cache @@ -66,6 +65,16 @@ dist testproj.RPP +# Python +__pycache__/ +*.pyc +*.egg-info/ +venv/ +.venv/ + # Ignore all the *.md files in website/HARP website/content/HARP/*.md website/content/pyHARP/*.md + +# Model validation output +model_validation/reports/ diff --git a/CMakeLists.txt b/CMakeLists.txt index c2efdf26..6b7305f2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,102 +1,83 @@ -# The first line of any CMake project should be a call to `cmake_minimum_required`, which checks -# that the installed CMake will be able to understand the following CMakeLists, and ensures that -# CMake's behaviour is compatible with the named version. This is a standard CMake command, so more -# information can be found in the CMake docs. -cmake_minimum_required(VERSION 3.21) -set(PROJECT_NAME "HARP") +cmake_minimum_required(VERSION 3.22) -set(CMAKE_CXX_STANDARD 20) -set(CMAKE_CXX_STANDARD_REQUIRED ON) +# Must be set before project(). This is the oldest macOS that current Xcode can target. +set(CMAKE_OSX_DEPLOYMENT_TARGET "11.0" CACHE STRING "Minimum macOS version") file(STRINGS VERSION CURRENT_VERSION) -# add_definitions(-DAPP_VERSION="${CURRENT_VERSION}") -# add_definitions(-DAPP_NAME="${PROJECT_NAME}") -# add_definitions(-DAPP_COPYRIGHT="TEAMuP") - - - -# The top-level CMakeLists.txt file for a project must contain a literal, direct call to the -# `project()` command. `project()` semats up some helpful variables that describe source/binary -# directories, and the current project version. This is a standard CMake command. -project(${PROJECT_NAME} VERSION ${CURRENT_VERSION}) +project(HARP VERSION ${CURRENT_VERSION}) -# Add these lines to suppress warnings for JUCE -# ------------------------------------------ -# For macOS Xcode deprecation warnings -set(JUCE_SILENCE_XCODE_DEPRECATION_WARNINGS ON) +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) -add_subdirectory(JUCE) # If you've put JUCE in a subdirectory called JUCE +# Link the MSVC runtime statically, so HARP.exe runs without the Visual C++ Redistributable +set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") -# Reset warning levels -if(CMAKE_CXX_COMPILER_ID MATCHES "Clang|AppleClang|GNU") - add_compile_options(-Wall -Wextra) -elseif(CMAKE_CXX_COMPILER_ID MATCHES "MSVC") - add_compile_options(/W4) -endif() +set(COMPANY_NAME "TEAMuP") +set(COPYRIGHT "Copyright 2026 TEAMuP. All rights reserved.") -# `juce_add_gui_app` adds an executable target with the name passed as the first argument -# (${PROJECT_NAME} here). This target is a normal CMake target, but has a lot of extra properties set -# up by default. This function accepts many optional arguments. Check the readme at -# `docs/CMake API.md` in the JUCE repo for the full list. -# include(cmake/gradio_client.cmake) +add_subdirectory(JUCE) +# See JUCE/docs/CMake API.md for all options juce_add_gui_app(${PROJECT_NAME} - # VERSION ... # Set this if the app version is different to the project version - # ICON_* arguments specify a path to an image file to use as an icon - ICON_BIG "${CMAKE_SOURCE_DIR}/resources/icons/harp_logo_1.png" # Specify a big icon for the app - ICON_SMALL "${CMAKE_SOURCE_DIR}/resources/icons/harp_logo_1.png" # Specify a small icon for the app - - DOCUMENT_EXTENSIONS wav mp3 aiff # Specify file extensions that should be associated with this app - COMPANY_NAME "TEAMuP" # Specify the name of the app's author - PRODUCT_NAME "HARP") # The name of the final executable, which can differ from the target name - -# `juce_generate_juce_header` will create a JuceHeader.h for a given target, which will be generated -# into your build tree. This should be included with `#include `. The include path for -# this header will be automatically added to the target. The main function of the JuceHeader is to -# include all your JUCE module headers; if you're happy to include module headers directly, you -# probably don't need to call this. - + PRODUCT_NAME "${PROJECT_NAME}" + COMPANY_NAME "${COMPANY_NAME}" + COMPANY_COPYRIGHT "${COPYRIGHT}" + BUNDLE_ID "com.TEAMuP.HARP" + ICON_BIG "${CMAKE_SOURCE_DIR}/resources/icons/harp_logo_1.png" + DOCUMENT_EXTENSIONS wav mp3 aiff) + +# Creates JuceHeader.h, which includes all linked JUCE modules juce_generate_juce_header(${PROJECT_NAME}) -# `target_sources` adds source files to a target. We pass the target that needs the sources as the -# first argument, then a visibility parameter for the sources which should normally be PRIVATE. -# Finally, we supply a list of source files that will be built into the target. This is a standard -# CMake command. - target_sources(${PROJECT_NAME} PRIVATE src/Main.cpp src/Application.cpp src/MainComponent.cpp + src/HomeTab.h + src/ModelTabContainer.h src/ModelTab.h src/Model.h src/windows/AboutWindow.h - src/windows/WelcomeWindow.h + src/windows/CustomPathWindow.h + + src/windows/tutorial/TutorialTargets.h + src/windows/tutorial/TutorialOverlay.h + src/windows/tutorial/TutorialWindow.h + src/windows/settings/SettingsWindow.h src/windows/settings/GeneralSettingsTab.cpp src/windows/settings/LoginTab.cpp - src/widgets/ModelSelectionWidget.h src/widgets/ModelInfoWidget.h + src/widgets/ModelStyle.h src/widgets/ControlAreaWidget.h src/widgets/TrackAreaWidget.h src/widgets/StatusAreaWidget.h src/widgets/MediaClipboardWidget.h + src/widgets/PreviewPaneWidget.h src/gui/MultiButton.cpp + src/gui/RoundIconButton.h + src/gui/ResizeEdge.h + src/gui/Palette.h src/gui/HoverableLabel.h src/gui/TextBoxWithLabel.h + src/gui/NumberBoxWithLabel.h src/gui/SliderWithLabel.h src/gui/ComboBoxWithLabel.h + src/gui/MultiSelectWithLabel.h src/gui/HoverHandler.h src/gui/ControlComponent.h src/gui/ToggleWithLabel.h + src/gui/FileChooserWithLabel.h src/media/MediaDisplayComponent.cpp src/media/AudioDisplayComponent.cpp src/media/MidiDisplayComponent.cpp + src/media/FileDisplayComponent.cpp src/media/OutputLabelComponent.cpp src/media/pianoroll/KeyboardComponent.cpp @@ -106,11 +87,15 @@ target_sources(${PROJECT_NAME} src/clients/Client.h src/clients/GradioClient.h - src/clients/GradioClient.h + src/clients/providers/stability/StabilityClient.h src/utils/Logging.h + src/utils/Messages.h + src/utils/Providers.h src/utils/Settings.h src/utils/Interface.h + src/utils/ModelCatalog.h + src/utils/ModelTags.h src/utils/Controls.h src/utils/Labels.h src/utils/Clients.h @@ -130,145 +115,57 @@ target_sources(${PROJECT_NAME} src/external/fontaudio/data/FontAudioIcons.h ) -# `target_compile_definitions` adds some preprocessor definitions to our target. In a Projucer -# project, these might be passed in the 'Preprocessor Definitions' field. JUCE modules also make use -# of compile definitions to switch certain features on/off, so if there's a particular feature you -# need that's not on by default, check the module header for the correct flag to set here. These -# definitions will be visible both to your code, and also the JUCE module code, so for new -# definitions, pick unique names that are unlikely to collide! This is a standard CMake command. +if(APPLE) + target_sources(${PROJECT_NAME} PRIVATE src/utils/copy/CopyMacOS.mm) +elseif(WIN32) + target_sources(${PROJECT_NAME} PRIVATE src/utils/copy/CopyWindows.cpp) +else() + target_sources(${PROJECT_NAME} PRIVATE src/utils/copy/CopyLinux.cpp) + + find_package(X11 REQUIRED) + target_link_libraries(${PROJECT_NAME} PRIVATE X11::X11) +endif() +# These are visible to both our code and the JUCE modules (see each module's header for flags) target_compile_definitions(${PROJECT_NAME} PRIVATE - # JUCE_WEB_BROWSER and JUCE_USE_CURL would be on by default, but you might not need them. - JUCE_WEB_BROWSER=0 # If you remove this, add `NEEDS_WEB_BROWSER TRUE` to the `juce_add_gui_app` call - JUCE_USE_CURL=1 # only needed in Linux + # Unused, and would otherwise require WebKit/GTK on Linux + JUCE_WEB_BROWSER=0 + # Load libcurl (Linux only) at runtime, so the binary does not link a specific version JUCE_LOAD_CURL_SYMBOLS_LAZILY=1 + # Off by default, unlike FLAC and Ogg Vorbis + JUCE_USE_MP3AUDIOFORMAT=1 JUCE_APPLICATION_NAME_STRING="$" JUCE_APPLICATION_VERSION_STRING="$" - JUCE_USE_FLAC=1 - JUCE_USE_OGGVORBIS=1 - JUCE_USE_MP3AUDIOFORMAT=1 - JUCE_USE_WINDOWS_MEDIA_FORMAT=1 - PUBLIC - APP_VERSION="${CURRENT_VERSION}" - APP_COMPANY="TEAMuP" - APP_NAME="HARP" - APP_COPYRIGHT="Copyright 2026 TEAMuP. All rights reserved." + APP_VERSION="${PROJECT_VERSION}" + APP_COMPANY="${COMPANY_NAME}" + APP_NAME="${PROJECT_NAME}" + APP_COPYRIGHT="${COPYRIGHT}" ) -# If your target needs extra binary assets, you can add them here. The first argument is the name of -# a new static library target that will include all the binary resources. There is an optional -# `NAMESPACE` argument that can specify the namespace of the generated binary data class. Finally, -# the SOURCES argument should be followed by a list of source files that should be built into the -# static library. These source files can be of any kind (wav data, images, fonts, icons etc.). -# Conversion to binary-data will happen when your target is built. - juce_add_binary_data(stability_controls SOURCES ${CMAKE_SOURCE_DIR}/src/clients/providers/stability/models/text-to-audio.json ${CMAKE_SOURCE_DIR}/src/clients/providers/stability/models/audio-to-audio.json ) -# `target_link_libraries` links libraries and JUCE modules to other libraries or executables. Here, -# we're linking our executable target to the `juce::juce_gui_extra` module. Inter-module -# dependencies are resolved automatically, so `juce_core`, `juce_events` and so on will also be -# linked automatically. If we'd generated a binary data target above, we would need to link to it -# here too. This is a standard CMake command. +# The model taxonomy is defined once, in pyharp, where models declare their tags +juce_add_binary_data(model_taxonomy + HEADER_NAME TaxonomyData.h + NAMESPACE TaxonomyData + SOURCES + ${CMAKE_SOURCE_DIR}/pyharp/pyharp/taxonomy.json +) +# Dependencies between JUCE modules are resolved automatically target_link_libraries(${PROJECT_NAME} PRIVATE - juce::juce_gui_extra - juce::juce_audio_basics - juce::juce_audio_devices - juce::juce_audio_formats - juce::juce_audio_processors juce::juce_audio_utils - juce::juce_core - juce::juce_data_structures - juce::juce_dsp - juce::juce_events - juce::juce_gui_basics juce::juce_gui_extra stability_controls + model_taxonomy PUBLIC juce::juce_recommended_config_flags juce::juce_recommended_lto_flags juce::juce_recommended_warning_flags ) - -# C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Redist\MSVC\14.36.32532\x64\Microsoft.VC143.CRT\msvcp140.dll -# C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Redist\MSVC\14.36.32532\x64\Microsoft.VC143.CRT\vcruntime140_1.dll -# C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Redist\MSVC\14.36.32532\x64\Microsoft.VC143.CRT\vcruntime140.dll -if (WIN32) - # Function to find the specific runtime DLLs - function(find_vc_runtime_dlls out_var) - message(STATUS "Searching for specific runtime DLLs...") - - # Define the target DLLs - set(target_dlls - "msvcp140.dll" - "vcruntime140_1.dll" - "vcruntime140.dll" - ) - - # Set the base path for Visual Studio 2022 redistributable DLLs - set(VS_CRT_BASE_PATH "C:/Program Files/Microsoft Visual Studio/2022/*/VC/Redist/MSVC/*/x64/Microsoft.VC143.CRT") - - # Search for the DLLs in the specified base path - file(GLOB_RECURSE all_runtime_dlls - LIST_DIRECTORIES false - "${VS_CRT_BASE_PATH}/*.dll" - ) - - set(runtime_dlls) - - foreach(dll ${all_runtime_dlls}) - get_filename_component(dll_name ${dll} NAME) - if(dll_name IN_LIST target_dlls) - list(APPEND runtime_dlls ${dll}) - endif() - endforeach() - - if(runtime_dlls) - message(STATUS "Found specific runtime DLLs:") - foreach(dll ${runtime_dlls}) - message(STATUS "${dll}") - endforeach() - set(${out_var} ${runtime_dlls} PARENT_SCOPE) - else() - message(FATAL_ERROR "Required specific runtime DLLs not found") - endif() - endfunction() - - # Call the function to find the specific DLLs - find_vc_runtime_dlls(RUNTIME_DLLS) - - # Add a post-build step to copy the specific DLLs to the output directory - add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD - COMMAND ${CMAKE_COMMAND} -E copy_if_different - ${RUNTIME_DLLS} - $) -endif() -# copy the pyinstaller tools to the bundle -# if (APPLE) -# add_custom_command(TARGET ${PROJECT_NAME} -# POST_BUILD -# COMMAND ${CMAKE_COMMAND} -E copy_directory -# ${CMAKE_SOURCE_DIR}/py/client/dist/ -# "$/../Resources") -# else() -# add_custom_command(TARGET ${PROJECT_NAME} -# POST_BUILD -# COMMAND ${CMAKE_COMMAND} -E copy_directory -# ${CMAKE_SOURCE_DIR}/py/client/dist/ -# "$/Resources") -# endif() - -# this fixes the RPATH to be relative to the executable -# in MacOS. Now, all we need to do is copy the -# dynamic libraries to the executable directories -if (APPLE) - set_property(TARGET ${PROJECT_NAME} PROPERTY BUILD_RPATH "@loader_path/../Frameworks" ) - - set(CMAKE_SKIP_RPATH "NO" CACHE INTERNAL "") -endif(APPLE) diff --git a/JUCE b/JUCE index 9f643254..501c0767 160000 --- a/JUCE +++ b/JUCE @@ -1 +1 @@ -Subproject commit 9f64325446ec0baf11f0f5f99c8484a15bbd1ab0 +Subproject commit 501c07674e1ad693085a7e7c398f205c2677f5da diff --git a/README.md b/README.md index 9d6414df..2819df66 100644 --- a/README.md +++ b/README.md @@ -1,243 +1,236 @@ # HARP - -![herofig_revised](https://github.com/TEAMuP-dev/HARP/assets/26678616/c4f5cdbb-aaff-4196-b9d2-3b6f69130856) + - + - + -HARP is a sample editor for **h**osted, **a**synchronous, **r**emote **p**rocessing of audio with machine learning. HARP operates as a standalone application or a plugin-like editor within your DAW, and routes audio, MIDI, and metadata through [Gradio](https://www.gradio.app) endpoints for processing. Gradio apps can be hosted locally or remotely (_e.g._, [HuggingFace Spaces](https://huggingface.co/spaces)), allowing users of DAWs (_e.g._ [REAPER](https://www.reaper.fm), [Logic Pro X](https://www.apple.com/logic-pro/), [Ableton Live](https://www.ableton.com/en/live/)) to access powerful cloud-based models seamlessly from within their DAW. +HARP is a sample editor for **h**osted, **a**synchronous, **r**emote **p**rocessing of audio with +machine learning. It runs as a standalone application or as a plugin-like editor within a DAW, and +sends audio, MIDI, and metadata to [Gradio](https://www.gradio.app) endpoints for processing. + +Most machine learning models for audio are impractical to run alongside a session. They expect a +GPU, a particular version of PyTorch, and weights measured in gigabytes, and they take far longer +than a buffer period to produce a result. HARP leaves each model where it is already installed, +whether that is a [Hugging Face Space](https://huggingface.co/spaces) or a machine on your own +network, and returns the result to the editor. Processing is asynchronous and happens outside the +audio thread, so a model that runs for a minute delays nothing but itself. + +This gives users of DAWs that support external sample editors, among them +[REAPER](https://www.reaper.fm), [Logic Pro](https://www.apple.com/logic-pro/), and +[Ableton Live](https://www.ableton.com/en/live/), access to these models without leaving the +session. ## Table of Contents + * **[Installation](#installation)** * **[Usage](#usage)** - * **[Warning!](#warning)** - * **[Models](#models)** - * **[Workflow](#workflow)** * **[Contributing](#contributing)** - * **[Version Compatibility](#version-compatibility)** - * **[Adding Models with pyharp](#adding-models-with-pyharp)** - * **[Building Harp](#building-harp)** - * **[Debugging](#debugging)** - * **[Distribution](#distribution)** * **[Citations](#citations)** -For more information on HARP, please see [our website](https://harp3.netlify.app/) or our most recent [NeurIPS workshop paper](https://openreview.net/pdf?id=Ha2z921r51). - +Further documentation is on [our website](https://harp3.netlify.app/). + + # Installation -HARP has been tested on the following operating systems: - -| OS | ![macOS ARM](https://img.shields.io/badge/mac%20os%20(ARM)-000000?style=for-the-badge&logo=macos&logoColor=F0F0F0) | ![macOS x86](https://img.shields.io/badge/mac%20os%20(x86)-000000?style=for-the-badge&logo=macos&logoColor=F0F0F0) | ![Windows](https://img.shields.io/badge/Windows-0078D6?style=for-the-badge&logo=windows&logoColor=white) | ![Ubuntu](https://img.shields.io/badge/Ubuntu-E95420?style=for-the-badge&logo=ubuntu&logoColor=white) | -| :-: | :-: | :-: | :-: | :-: | -| Versions | 13.0, 13.4, 14.2.1, 14.5 | 10.15 | 10, 11 | 22.04 | +Download a package from the [latest release](https://github.com/TEAMuP-dev/HARP/releases). Every +push is built for each platform below. - - -Please visit [our website](https://harp3.netlify.app/content/install/macos.html) for instructions on installing HARP and setting it up as an external sample editor for specific operating systems and DAWs. +| Platform | Package | Requires | +| :-- | :-- | :-- | +| macOS | Universal binary, signed and notarized | macOS 11 or newer, on Apple silicon or Intel | +| Windows | x64 executable | Windows 10 or 11 | +| Linux | x64 and arm64 archives | glibc 2.35 or newer | +The Linux packages are built on the oldest supported Ubuntu, which sets that glibc requirement. They +run on Ubuntu 22.04, Debian 12, Fedora 36, RHEL 10, openSUSE Leap 16, and Arch, or anything newer. +At runtime HARP needs ALSA, FreeType, and X11, and loads libcurl for network requests, all of +which a desktop installation provides. Under Wayland it runs through XWayland, which GNOME and +KDE enable by default. -# Usage +Note that the Windows executable is not code signed, so Windows will warn before running it the first +time. -HARP can be used to apply deep learning models to your audio either as a standalone or within any DAW (e.g. Logic Pro) that supports external sample editors. +Our website has [per-platform instructions](https://harp3.netlify.app/content/install/macos.html) +for installing HARP and configuring it as an external sample editor in a particular DAW. -If you use it standalone, just load a file, load a model and apply the model to the audio file. When you're happy with the result, save the output. +# Usage -In a DAW, select the excerpt you want to process, open it in HARP, process it, and click the _Save_ icon on the track display or media clipboard. This will open a file browser for saving the selected file. +As a standalone application, open a file, open a model, process, and save the result. Within a DAW, +select the region to process, open it in HARP, and either return the result with the _Send-to-DAW_ +icon or write it to disk with the _Save_ icon. -## Warning! +## Warning -**HARP is a _destructive_ file editor.** When operating HARP as a standalone application, use _Save As_ to avoid overwriting input files. If you save your outputs via the _Send-to-DAW_ feature, HARP will overwrite input regions. By creating a duplicate / bounce / alternate take of the region you want to edit with HARP, you can ensure the original region remains unaffected. +**HARP is a _destructive_ file editor.** When running it as a standalone, use _Save As_ +to avoid overwriting the input. _Send-to-DAW_ overwrites the region the audio came from. Working on +a duplicate, a bounce, or an alternate take leaves the original intact. -### Processing just a portion of a track - -HARP processes full regions in the DAW. Therefore, to edit a portion of an audio or MIDI region: - -- Split the region to obtain the excerpt you want to edit as a separate region -- (Optional) Create a duplicate / bounce / alternate take of the region you want to edit -- Open with HARP to process - - -## Models - -Please visit [our website](https://harp3.netlify.app/content/usage/models.html) for a full list of supported models. +HARP processes whole regions, so editing part of one means splitting it first, to obtain the excerpt +as a region of its own. ## Workflow -HARP supports a simple workflow: pick an existing model for processing or provide a URL to your own, load the model and it's corresponding interface, tweak controls to your liking, and process. - -To get started: +- Open HARP, either standalone or from a DAW. +- On the `Home` tab, browse or search the models hosted by + [TEAMuP on Hugging Face](https://huggingface.co/teamup-tech), which are grouped by function, + or choose `Custom Path...` and supply the URL of any HARP-compatible Gradio endpoint. A custom + path keeps a card on the `Home` tab until it is removed with the X button on that card. +- Select a model to open it in a tab of its own. Several models can be open at once. +- Load audio or MIDI with the folder icon of an input track, or by dragging a file onto the track. +- Set the controls, then select `Process`. Results are rendered in place as they arrive. +- Save the result with the `Save` icon, or return it to the DAW with `Send-to-DAW`. + +### Tracks + +- Click or drag within a track to move its playback position, during playback as well. The play + icon becomes a pause icon while the track plays, and a paused track resumes where it stopped. The + stop icon ends playback and returns to the start. +- Scroll over a track to zoom in time, or hold `Shift` while scrolling to pan. On a MIDI track, + hold `Ctrl` (`Cmd` on macOS) while scrolling to zoom in pitch, or `Ctrl+Shift` to move through + pitch. +- Drag a track onto another track, into the media clipboard, or out of HARP into a DAW. +- Save an output track with its `Save` icon, or copy it with its `Copy` icon and paste it into a DAW + or a file browser. + +### Media Clipboard + +The media clipboard holds tracks independently of any one model, so a result from one model can be +carried into another as its input. Show or hide it with `View > Media Clipboard`. It also opens on +its own when a file is added with `File > Open`. + +- Add a file with the folder icon, or drag one in from a track or from elsewhere. +- Select a track to play it in the preview pane beneath the list, which has its own play, pause, and + stop icons. Drag the top edge of the pane to resize it, or minimize or close it from its title + bar. `View > Preview Pane` shows it again, as does double-clicking a track's waveform or piano roll. +- Double-click a track's name to rename it. +- Drag a track up or down to reorder the list, or onto a model's track to use it as input. +- Drag the left edge of the clipboard to change its width. HARP keeps the clipboard's width and the + preview pane's height and state for the next session. +- When a DAW opened HARP with a file, the send icon overwrites that file with the selected track, + which brings the result back into the DAW project. + + + + -- Open HARP as a standalone application or within your DAW -- Select an existing model using the drop-down menu at the top of the screen, or select `custom path...` and provide a URL to any HARP-compatible Gradio endpoint -- Load the selected model (and its corresponding interface) using the `Load` button. -- Import audio or MIDI data to process with the model either via the `Open File` button or by dragging and dropping a file into HARP -- Adjust controls to taste in the interface -- Click `Process` to run the model; outputs will automatically be rendered in HARP -- To save your outputs, click the `Save` icon (output track or media clipboard) or the `Send-to-DAW` icon (orange circle with arrow in media clipboard) + +## Models -text-to-audio +Our website lists [every model we host](https://harp3.netlify.app/content/usage/models.html). +Each model is deployed under its original license, which HARP displays on a badge of its own beside +the model, so that the terms are apparent before the output is used. Authors who would rather their +model were not featured, or whose license we have stated incorrectly, are asked to +[open an issue](https://github.com/TEAMuP-dev/HARP/issues) so that we can correct or remove it +promptly. # Contributing -To get started building and deploying models for others to use in HARP, see [Adding Models with pyharp](#adding-models-with-pyharp). To get started developing the HARP app itself, see [Building HARP](#building-harp). Please also see our [Developer Notes](https://github.com/TEAMuP-dev/HARP/blob/main/DEVNOTES.md) for various conventions and suggestions. +There are two ways to contribute, depending on what you intend to build. - -## Version Compatibility +**To deploy a model for others to use in HARP**, begin with [pyharp](https://github.com/TEAMuP-dev/pyharp), +which wraps arbitrary Python code in a HARP-compatible Gradio app. Its README covers deployment, and our +[deployment guidelines](https://github.com/TEAMuP-dev/HARP/blob/main/docs/DEPLOYMENT.md) describe +the conventions our own models follow: licensing, naming and tagging, sourcing weights, reporting +errors, preserving the input format, and choosing hardware. -The currently available versions of HARP and pyharp are mutually compatible. +[![PyHARP](https://gh-card.dev/repos/TEAMuP-dev/pyharp.svg)](https://github.com/TEAMuP-dev/pyharp) -| HARP | pyharp | -| :-: | :-: | -| 3.1.0 | 0.3.0 | -| 2.2.0 | 0.2.1 | +**To work on the application itself**, see [Building HARP](#building-harp) below, together with our +guidelines for [developing](https://github.com/TEAMuP-dev/HARP/blob/main/docs/DEVELOPING.md), +[reviewing](https://github.com/TEAMuP-dev/HARP/blob/main/docs/REVIEWING.md), and +[code signing](https://github.com/TEAMuP-dev/HARP/blob/main/docs/SIGNING.md). - -# Adding Models with pyharp - -[![ReadMe Card](https://github-readme-stats.vercel.app/api/pin/?username=TEAMuP-dev&repo=pyharp)](https://github.com/TEAMuP-dev/pyharp) - -pyharp provides a lightweight API to build HARP-compatible [Gradio](https://www.gradio.app) apps. It allows researchers to easily create DAW-friendly interfaces for any audio processing code with minimal Python wrapping. + +HARP and pyharp are released in mutually compatible pairs: HARP 4.0.0 with pyharp 0.4.2, HARP 3.1.0 +with pyharp 0.3.0, and HARP 2.2.0 with pyharp 0.2.1. The root `VERSION` file sets the application +version through CMake. -# Building HARP +## Building HARP -HARP can be built from scratch with the following steps: - -### 1. Clone Repository +The same commands build HARP on macOS, Windows, and Linux: ```bash git clone --recurse-submodules https://github.com/TEAMuP-dev/HARP +cd HARP +cmake -B build -DCMAKE_BUILD_TYPE=Debug . +cmake --build build --config Debug --parallel ``` -### 2. Enter Project +The binary lands in `build/HARP_artefacts/Debug/`, as `HARP.app` on macOS, `HARP.exe` on Windows, +and `HARP` on Linux. It accepts media files as arguments, and can be invoked from the command line: ```bash -cd HARP/ +./build/HARP_artefacts/Debug/HARP resources/media/test.wav resources/media/test.mid ``` -### 3. Configure - -```bash -mkdir build -cd build -cmake .. -DCMAKE_BUILD_TYPE=Debug -``` - -#### ARM vs. x86 MacOS - -The OSX architecture for the build can be specified explicitly by setting `CMAKE_OSX_ARCHITECTURES` to either `arm64` or `x86_64`: -```bash -cmake .. -DCMAKE_OSX_ARCHITECTURES=x86_64 -``` - -#### Linux - -Ensure your system satisfies all [JUCE dependencies](https://github.com/juce-framework/JUCE/blob/master/docs/Linux%20Dependencies.md). - -### 4. Build -#### MacOS/Linux - -```bash -make -j -``` - -#### Windows - -```bash -cmake --build . --config Debug -j -``` +Two platforms require additional steps. On macOS, `-DCMAKE_OSX_ARCHITECTURES` set to `arm64`, `x86_64`, +or `"arm64;x86_64"` selects the architecture, the last of these producing a universal binary. On +Linux, the +[JUCE dependencies](https://github.com/juce-framework/JUCE/blob/master/docs/Linux%20Dependencies.md) +must be installed first. There are no C++ unit tests, so a change is verified by running the +application. ## Debugging -We provide instructions for debugging your HARP build in [Visual Studio Code](https://code.visualstudio.com/): - -1. Download [Visual Studio Code](https://code.visualstudio.com/Download). -2. Install the C/C++ extension from Microsoft. -3. Open the _Run and Debug_ tab in VS Code and click _create a launch.json file_ using _CMake Debugger_. -4. Create a configuration to attach to the process (see the following example code to be placed in `launch.json`). +In [Visual Studio Code](https://code.visualstudio.com/), install Microsoft's C/C++ extension, then +add a configuration to `launch.json` pointing at the built binary, uncommenting the line for the +platform in use: ```json5 { - "version": "0.2.0", - "configurations": [ - { - "name": "Standalone HARP", - "type": "cppdbg", - "request": "launch", - "program": "${workspaceFolder}/build/HARP_artefacts/Debug/HARP.app", // macOS - //"program": "${workspaceFolder}/build/HARP_artefacts/Debug/HARP.exe", // Windows - //"program": "${workspaceFolder}/build/HARP_artefacts/Debug/HARP", // Linux - "args": ["../resources/media/test.wav", "../resources/media/test.mid"], - "cwd": "${fileDirname}", - "MIMode": "lldb" // macOS - }, - { - "name": "Attach to HARP", - "type": "cppdbg", - "request": "attach", - "program": "${workspaceFolder}/build/HARP_artefacts/Debug/HARP.app", // macOS - //"program": "${workspaceFolder}/build/HARP_artefacts/Debug/HARP.exe", // Windows - //"program": "${workspaceFolder}/build/HARP_artefacts/Debug/HARP", // Linux - "processId": "${command:pickProcess}", - "MIMode": "lldb" // macOS - } - ] + "name": "Standalone HARP", + "type": "cppdbg", + "request": "launch", + "program": "${workspaceFolder}/build/HARP_artefacts/Debug/HARP.app", // macOS + //"program": "${workspaceFolder}/build/HARP_artefacts/Debug/HARP.exe", // Windows + //"program": "${workspaceFolder}/build/HARP_artefacts/Debug/HARP", // Linux + "args": ["../resources/media/test.wav", "../resources/media/test.mid"], + "cwd": "${fileDirname}", + "MIMode": "lldb" // macOS } ``` -5. Build the plugin using the flag `-DCMAKE_BUILD_TYPE=Debug` (see the following configure / build commands for macOS). - -```bash -# CMake Configure Command: -/opt/homebrew/bin/cmake -DCMAKE_BUILD_TYPE:STRING=Debug -DCMAKE_EXPORT_COMPILE_COMMANDS:BOOL=TRUE -DCMAKE_C_COMPILER:FILEPATH=/usr/bin/clang -DCMAKE_CXX_COMPILER:FILEPATH=/usr/bin/clang++ --no-warn-unused-cli -S /Users//Projects/HARP -B /Users//Projects/HARP/build -G Ninja -``` - -```bash -# CMake Build Command: -/opt/homebrew/bin/cmake --build /Users//Projects/HARP/build --config Debug --target all -- -``` - -6. Add break points and run the debugger. +An alternative configuration using `"request": "attach"` with `"processId": "${command:pickProcess}"` +attaches to an instance of HARP that is already running. This can be used to debug HARP after launching it from a DAW. ## Distribution -### MacOS +[GitHub Actions](https://github.com/TEAMuP-dev/HARP/blob/main/.github/workflows/build.yml) builds +and packages every push. Pushing a tag of the form `v*` also publishes a release, taking +`RELEASE.md` as its notes. -Codesigning and packaging for distribution is done through the script located at `packaging/package.sh`. -You'll need to set up a developer account with Apple and create a certificate in order to sign the plugin. -For more information on codesigning and notarization for macOS, please refer to the [pamplejuce](https://github.com/sudara/pamplejuce) template. - -The script requires the following variables to be passed: -``` -# Retrieve values from either environment variables or command-line arguments -DEV_ID_APPLICATION # Developer ID Application certificate -ARTIFACTS_PATH # should be packaging/dmg/HARP.app -PROJECT_NAME # "HARP" -PRODUCT_NAME # "HARP" -NOTARIZATION_USERNAME # Apple ID -NOTARIZATION_PASSWORD # App-specific password for notarization -TEAM_ID # Team ID for notarization -``` +A macOS build can be packaged locally into a DMG with +[dmgbuild](https://github.com/dmgbuild/dmgbuild): -Usage: ```bash -./HARP/packaging/package.sh +packaging/package.sh build/HARP_artefacts/Release/HARP.app HARP.dmg ``` -After running `package.sh`, you should have a signed and notarized `dmg` file in the `packaging/` directory. +Signing and notarizing that DMG in CI requires the `MACOS_CERTIFICATE` and +`MACOS_CERTIFICATE_PASSWORD` secrets for the signing certificate, and `APPLE_ID` with +`APPLE_APP_PASSWORD` for notarization. +[SIGNING.md](https://github.com/TEAMuP-dev/HARP/blob/main/docs/SIGNING.md) covers both, along with +signing a build on your own machine. ## Citations diff --git a/RELEASE.md b/RELEASE.md index b0a9b1aa..ece2ca6a 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,32 +1,52 @@ -## v3.1.0 +## v4.0.0 ### Overview - - HARP 3.1.0 introduces significantly refactored code, major features, and many small improvements. - - ### Significant Refactoring - - **Decoupling & Organization**: - - The entire codebase has been decoupled and reorganized for improved modularity and readability. - - **Token Management**: - - Tokens are now managed through a shared resource accessible through a single settings tab. - - **Logging & Error Reporting**: - - Utilities for logging, error reporting, and more have been broadened and improved. + - HARP 4.0.0 replaces the model dropdown with a browsable catalog, opens each model in a tab of + its own, adds a media clipboard with a preview pane, and reports errors far more precisely. ### Major Features - - **Welcome / Walkthrough**: - - Upon startup a Welcome / Walkthrough dialog appears to provide an overview of HARP and assist new users. - - **Model Tags for Loading Failures**: - - Upon loading failures, model paths are now added to dropdown with an appropriate tag. + - **Home Tab and Model Catalog**: + - Every Space of the [TEAMuP organization](https://huggingface.co/teamup-tech) is listed on the + Home tab, grouped by category and searchable by name, task, tag, or path. + - Cards show each model's description, tags, license, and deployment (e.g., ZeroGPU, sleeping). + Spaces that cannot currently load are hidden, with the reason given on hover. + - The listing is cached, so the Home tab populates at once and still works offline. + - Custom paths (Spaces, Gradio URLs, or local addresses) are kept as cards until removed. + - **Model Tabs**: + - Each model opens in a tab of its own, and several can be open at once. + - A loaded model's card is shown at the top of its tab, with a link to its page. + - **Media Clipboard**: + - Holds files independently of any model, so the output of one model can be the input of + another. Tracks can be added, removed, renamed, reordered, and resized. + - A preview pane plays the selected track. + - Tracks can be dragged between the clipboard, model tracks, and other applications, and + copied for pasting into a DAW or a file browser. + - Send-to-DAW converts audio to the format of the file it replaces. + - **Controls and Tracks**: + - New controls for number boxes, multiselect dropdowns, and generic file inputs. + - Generic file outputs, for models that return something other than audio or MIDI. + - Optional inputs are marked as such. + - A time axis on every track, pause and resume, and zoom and pan by scrolling. + - Drum notes are drawn distinctly in MIDI tracks. + - **Error Reporting**: + - Errors say what went wrong and what to do about it, distinguishing, e.g., a sleeping Space + from a broken one, and GPU quota from a runtime error (on Gradio 6.13 or newer). + - Error popups can open the model's page, the log, the API key settings, or a prefilled GitHub + issue. + - API keys are kept out of logs, and are only sent to Hugging Face and the local machine. + - **Tutorial**: + - A step-by-step tutorial highlights each part of the interface as it describes it. ### Other Improvements - - **Multiple Window Interactions**: - - Tracks are now added to current window instead of main window when "Current Window" is selected. - - **Status Panel Visibility**: - - Toggleable visibility of status panel. - - **Help File Menu Tab**: - - A "Help" tab invoking About / Welcome windows has been added to file menu. - - **Control Sizing**: - - Fixed control sizing and enforcement of minimum window size. - - **MIDI Colors**: - - MIDI notes in pianoroll displays are now colored according to instrument number. + - **Cancellation**: Canceling stops only the job it was meant for, even when several clients + share a Space, and no longer blocks the interface. + - **Stability**: Fixed crashes and hangs when closing tabs or quitting during a request, when + loading MIDI files without notes, and when receiving unrecognized labels. + - **Performance**: All tracks share one audio device, and text is measured once rather than on + every repaint. + - **Window Sizing**: Minimum window sizes are enforced on Linux, and each tab scrolls when the + window is too small for its contents. + - **Builds**: macOS builds are universal binaries for Apple silicon and Intel, and Linux builds + are available for arm64 as well as x64. ### Requirements - - Supports models deployed with [`pyharp` **v0.3.0**](https://github.com/TEAMuP-dev/pyharp/releases/tag/v0.3.0) and `gradio` **v5.x.x**. + - Supports models deployed with [`pyharp` **v0.4.2**](https://github.com/TEAMuP-dev/pyharp/releases/tag/v0.4.2) and `gradio` **v6.13** or newer. diff --git a/VERSION b/VERSION index fd2a0186..fcdb2e10 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.1.0 +4.0.0 diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 00000000..5f4f5cbc --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -0,0 +1,942 @@ +# Deployment + +Guidelines for deploying a model into the HARP ecosystem as a Hugging Face Space. These are the +conventions the featured models under [`teamup-tech`](https://huggingface.co/teamup-tech/spaces) +follow, and the shape any deployment should take. +[PyHARP's README](https://github.com/TEAMuP-dev/pyharp/blob/develop/README.md) documents how to +build an app, and this document covers what to decide while doing it. + +## Table of Contents + +- [Licensing](#licensing) +- [Model Card](#model-card) +- [Space README](#space-readme) +- [Versions and Compatibility](#versions-and-compatibility) +- [Structure of app.py](#structure-of-apppy) +- [Interface](#interface) +- [Model Weights](#model-weights) +- [Error Reporting](#error-reporting) +- [Preserving the Input Format](#preserving-the-input-format) +- [Hardware Portability](#hardware-portability) +- [Dependency Conflicts](#dependency-conflicts) +- [Calling Another Space](#calling-another-space) +- [Keeping a Space Lightweight](#keeping-a-space-lightweight) + +## Licensing + +Settle this first, since it decides whether a model can be deployed at all. + +**Work with no license cannot be deployed.** Anything unlicensed is all rights reserved by default, +whatever its visibility. A public GitHub repository with no `LICENSE` file grants nothing, and the +fact that weights are downloadable is not permission to redistribute or deploy them. Ask the +authors, and record the permission when it is granted. + +**Set `license` in the Space README frontmatter to the license of the original code.** + +Where the terms are custom, commit them as a `LICENSE` file and set `license: other` with a +`license_name`. This covers a permission the authors granted in writing, which is itself the terms. +Prefer it to leaving the field blank, since an omission is indistinguishable from an oversight. + +HARP reads that field and shows it with the model, so a user sees the terms before running it. + +**A missing `LICENSE` file is not the same as an unlicensed project.** Read the README's own +license section, and the other branches, before concluding that nothing was granted: a license is +often stated in prose, or committed only on the branch the released model was built from. + +**Where code and weights carry different terms, declare the more restrictive of the two**, since +that is what binds whoever uses the output, and say in the description which part carries which. +Permissively licensed code loading non-commercial weights is common, and declaring only the code's +license misstates what a user may do with the result. Checking the license of every weight +repository a Space downloads from is part of deploying it. + +### What Can Be Deployed + +The categories below are a practical guide (not legal advice) for deciding whether a model can +be deployed through PyHARP. Where a license is unusual, restrictive, or ambiguous, confirm +what it permits before deploying. + +| Category | Examples | Deploying | +|---|---|---| +| Permissive | `mit`, `apache-2.0`, `bsd-3-clause`, `isc`, `cc0-1.0`, `unlicense` | No concerns. | +| Weak copyleft | `lgpl-3.0`, `mpl-2.0`, `epl-2.0` | Fine. Obligations attach to modifications of the licensed code, not to a Space that calls it. | +| Strong copyleft | `gpl-3.0`, `agpl-3.0` | Fine to host. `agpl-3.0` treats network use as distribution, so the Space's source must be offered, which a public Space repository already satisfies. | +| Use-restricted | `openrail`, `openrail++`, `creativeml-openrail-m` | Fine, but the behavioral restrictions pass through to users. | +| Non-commercial | `cc-by-nc-4.0`, `fair-noncommercial-research-license` | Deployable as a demonstration, but users cannot use the output commercially. State this in the description. | +| No derivatives | `cc-by-nd-4.0` | Confirm what is permitted before deploying. Redistribution is allowed only unmodified, so the weights cannot be converted or repackaged and then hosted. | +| Community and gated | `llama3.x`, `gemma` | Acceptable, but do not mirror the weights. Download from the gated upstream repository so the terms are accepted by whoever runs it. | +| Unstated | no license at all | **Do not deploy.** See above. | + +## Model Card + +The `ModelCard` is what HARP shows in its model info panel. + +### Name + +Prefer the model's original name, as its authors published it (_e.g._ `Whisper`, `Demucs`, `Bark`). +A searchable name connects the Space to the paper and repository behind it. + +Where the work has no name of its own, title it by what it does: + +```python +name="Historical Recording Denoiser" +``` + +### Author + +Credit the original authors of any relevant paper or work, if applicable. Otherwise, credit the +GitHub or Hugging Face account that first published the model. + +### Description + +Describe what the model **does**, in terms of the audio going in and coming out. This is a +producer-facing field, so what the model *is* and how it works belong in a linked paper rather +than here. + +Include any detail of how the model was trained that could affect its performance: + +- The sample rate it expects, and whether it resamples +- Whether it is mono or handles channels independently +- The material it was trained on, where that limits it (solo piano, speech, drum stems) +- Any hard limit on input length, and what happens past it + +Link the paper and the original repository. + +### Tags + +HARP's Home tab categorizes and searches by a model's tags. PyHARP's +[Tags](https://github.com/TEAMuP-dev/pyharp/blob/develop/README.md#tags) section documents the kinds +and lists the taxonomy. The conventions: + +- **Place the model in the taxonomy with `Category` and `Subcategory`**, listing the most specific + entry that applies. A `Subcategory` implies its `Category`, so listing both is unnecessary. +- **Tag every task the model performs**, so that a user searching for either finds it. +- **Declare `SampleRate` and `Channels`** whenever the model is fixed to one. Nothing can infer + them, and they tell a user whether the model suits their session. +- **The `input:` and `output:` tags are inferred** from the Gradio components. Providing one by + hand is an error. +- **Describe anything else with a custom tag**, such as the model family or a notable feature. + These are free-form strings, searched like any other tag. + +## Space README + +The Space `README.md` carries YAML frontmatter that decides how the Space builds and how it +appears on the Hugging Face Hub. +[UI-Tester](https://huggingface.co/spaces/teamup-tech/UI-Tester) is the reference: + +```yaml +--- +title: UI Tester +emoji: 🧪 +colorFrom: indigo +colorTo: gray +sdk: gradio +sdk_version: 6.24.0 +app_file: app.py +pinned: false +license: mit +short_description: Exercises every control, track, and label type in HARP. +tags: + - category:utility + - input:audio + - input:midi + - input:file/txt|csv|json|nam + - input:text + - output:audio + - output:midi + - output:labels + - output:file + - example + - test +--- + +A PyHARP example which exercises every input control, track type, and output label type supported by HARP. Performs no real processing. +``` + +Field by field: + +- **`title`** matches the `ModelCard` name, so that the same model is recognizable in both places. +- **`emoji`**, **`colorFrom`**, **`colorTo`** are the Space's thumbnail. Choose an emoji suggesting + the task, and colors that are not jarring. +- **`sdk_version`** is pinned to `6.24.0`. This is the version the Space actually deploys with, and + it must be set even though `gradio` does not appear in `requirements.txt`. Gradio `4.x` and + earlier are incompatible with HARP, and versions before `6.13.0` cannot forward error messages at + all. Where a dependency rules `6.24.0` out, hold the newest release it allows and say why in the + README body. Ten Spaces hold `6.17.3`, the newest whose `huggingface-hub` floor admits the + transformers 4.x their models need. +- **`sdk`** is `gradio`, or `docker` for the layout under + [Dependency Conflicts](#dependency-conflicts). +- **`app_file`** is `app.py`, and **`pinned`** is `false`. +- **`license`** is covered under [Licensing](#licensing) above. +- **`short_description`** and **`tags`** are covered under + [Listing on the Home Tab](#listing-on-the-home-tab) below. With + `title`, they are the only frontmatter HARP itself reads. + +Below the frontmatter is the README body, which the Hub renders on the Space's own page. One or +two sentences is enough, since HARP does not use it. + +### Listing on the Home Tab + +`short_description` and `tags` place the model on the Home tab correctly. HARP lists every public +Space in the organization without waking them, so these two fields are all it has until a model is +loaded. Without them a sleeping Space still appears, under "Other" and by name alone. A Space the +Hub reports as private or disabled is left out of the listing entirely. + +- **`short_description`** summarizes the `ModelCard` description within Hugging Face's limit of 60 + characters. +- **`tags`** is the model's full tag list in string form: its own tags and the inferred `input:` + and `output:` ones. + +**Copy that list rather than retyping it**, with `build_endpoint(..., show_controls=True)` and the +"View Controls" button, which shows it under `card`. Nothing reconciles these tags with the model +card, and unlike the card, a typo here raises no error. It leaves the model uncategorized for +anyone browsing. + +## Versions and Compatibility + +HARP and PyHARP are released as compatible pairs. **Pin `pyharp` to a release tag** in +`requirements.txt`, never to a branch: + +``` +git+https://github.com/TEAMuP-dev/pyharp.git@ +``` + +A branch such as `develop` moves under the Space, so a rebuild can change how it behaves with +nothing in the repository having changed. **A released tag never moves**: a fix ships as a new patch +release instead. pip caches the wheel it built for a URL, so a Space pinned to a tag that is later +moved goes on installing whatever the tag named when the Space was last built, through rebuilds and +factory reboots alike, and nothing in the installed metadata disagrees. + +Should a tag ever move, repin the Spaces built against it to the commit it now names. That is a new +URL, which forces a fresh install, and naming the release in a comment above it keeps the file +readable. + +Gradio is pinned separately, by `sdk_version` in the [Space README](#space-readme), and the two have +to agree. The latest PyHARP requires `gradio>=6.13.0,<7`, below which Gradio discards the payload +carrying the reason a job failed (see [Error Reporting](#error-reporting)). + +So `requirements.txt` holds `pyharp` pinned to a release, `spaces` (see +[Hardware Portability](#hardware-portability)), and the model's own dependencies, kept to what the +app imports (see [Keeping a Space Lightweight](#keeping-a-space-lightweight)). It does not hold +`gradio`. + +**Pin the package that implements the model** to an exact version, since `app.py` calls its API and +a rebuild otherwise installs whatever is newest. + +A package that another dependency requires by a Git URL is the exception. pip refuses one package +named by two different references, even when both resolve to the same commit, so pinning it in +`requirements.txt` fails the build. Leave it as the dependency names it, with a comment saying why +it is unpinned. + +HARP keeps any tag it cannot place and shows it as a custom tag, so [tags](#tags) degrade rather +than break. A Space deployed with a PyHARP older than the taxonomy still appears and still shows +whatever tags it declares, uncategorized under "Other", until it is redeployed with taxonomy tags. +The same holds in reverse, since HARP embeds `taxonomy.json` from PyHARP when it is built: a +category from a newer taxonomy shows as a custom tag on an older HARP. + +## Structure of app.py + +Follow the layout in +[PyHARP's README](https://github.com/TEAMuP-dev/pyharp/blob/develop/README.md), in this order: + +1. Imports +2. `ModelCard` +3. Model loading at module scope +4. `process_fn` +5. `if __name__ == "__main__":`, and inside it: + 1. `gr.Blocks` with `input_components`, `output_components`, and `build_endpoint` + 2. `demo.queue().launch(...)` + +Someone reviewing or fixing one Space should be able to open any other and find the same things in +the same places. + +The requirements that are easy to miss, all of which HARP depends on: + +- Every `gr.Audio` sets `type="filepath"`. +- Every `gr.File` carrying MIDI sets `type="filepath"` and `file_types=[".mid", ".midi"]`, or HARP + renders it as a generic file picker rather than a MIDI track. +- The order of `input_components` matches the arguments of `process_fn`, and the order of + `output_components` matches its return values. +- `demo.queue()` is called, or a running job cannot be canceled from HARP. +- `show_error=True` is set. See [Error Reporting](#error-reporting). +- A `gr.Audio` output sets `format` only where the model fixes the output format, since Gradio + converts the result to it. Leaving it unset returns whatever `process_fn` wrote, which is the + behavior [Preserving the Input Format](#preserving-the-input-format) requires. +- A generic `gr.File` output lists `file_types` where it returns one of a known set. PyHARP + enforces them, raising a `gr.Error` if `process_fn` returns anything else. + +### Worker Processes + +PyHARP runs `process_fn` in a separate worker process, so that Cancel in HARP stops the work rather +than leaving it to finish on the Space. The worker reaches `process_fn` by importing `app.py`, +which runs everything at module scope a second time. That is why step 5 above puts the Gradio code +behind `if __name__ == "__main__":`, with `process_fn` defined above it. PyHARP's +[Worker Processes](https://github.com/TEAMuP-dev/pyharp/blob/develop/README.md#worker-processes) +section covers what this asks of `process_fn`. What it changes for a deployment: + +- **Load the model in the worker alone.** Module scope runs in both processes, but only the worker + runs `process_fn`, so the app has no use for the model. Loading it there as well holds it twice, + and where both processes download the same file at once, one can read it before the other has + finished writing it. Load when the module is imported as anything other than + `__main__`, which the worker's import is: + + ```python + if __name__ != "__main__": + model = load_model() + ``` + + The app still binds its port at once, since it skips the load, and the worker, which starts with + the Space (see below), finishes loading before `process_fn` can reserve a GPU. On ZeroGPU, place + the model on CUDA here, on the worker's main thread. + + Many Spaces written before PyHARP 0.4.2 load in both processes, at module scope or in a background + thread that the worker joins. Loading at module scope is safe, if wasteful, because the app + finishes it before it starts the worker. A thread is safe only while everything it downloads goes + through the Hub's cache, which locks each file. Anything else, such as a URL fetched to a fixed + path, is still being written by the app's thread when the worker's own thread starts. +- **The worker starts as the Space comes up.** Since PyHARP 0.4.2, `build_endpoint` starts it at + once (`warm_start`), so its import and every model it loads are done before the first request in + most cases. A request that arrives while the worker is still loading waits for it, and on ZeroGPU + a wait of more than about two minutes outlasts the request's token: a worker that spent 2 min 15 s + loading reached the GPU with the token gone, and the request failed with "Expired ZeroGPU proxy + token". +- **`build_endpoint` takes `timeout_s`**, defaulting to 900 seconds. Raise it for a model that + legitimately runs longer. +- **A Space that shells out should use `subprocess.run`.** The worker leads its own process group, + so a cancellation then tears down what the job started instead of orphaning it. +- **A process pool that `process_fn` starts uses spawn.** The worker was itself spawned, which makes + spawn its default start method, so each process of a `multiprocessing` pool imports `app.py` and + the library afresh. A patch that another package applies on import, such as a compatibility shim + for an older library, never reaches them. Fix such a library in its own files at build time, so + that it imports on its own. + +## Interface + +**Expose every parameter the model accepts.** One the model reads but the Space does not offer is a +choice made on the user's behalf, with no way to change it. Where a parameter has no useful range +to offer, fix it in `process_fn` and say so in the model card description rather than leaving a +control nobody can interpret. + +**Describe every control.** HARP shows the description in its instruction panel while the control +is hovered, so the label has to stand on its own and the description carries the detail. GUI +controls take Gradio's `info`, while tracks and generic files take PyHARP's `.set_info(...)`: + +```python +input_components = [ + gr.Audio( + type="filepath", + label="Reference Vocal", + ).set_info("A dry solo vocal. Room tone or backing will be modeled along with the voice."), + gr.Slider( + minimum=0.0, + maximum=10.0, + value=3.0, + label="Guidance Strength", + info="How closely the result follows the prompt. Higher values track the text more " + "literally and tend to sound less natural.", + ), +] +``` + +This matters most where a name comes from the model's own code and means nothing outside it. Label +the control with what it does, and keep the original name in the description, where someone +comparing against the paper will look for it. + +State the units, and what the extremes do. A range given without either leaves a producer guessing +at both. + +**Label each output with what it holds.** HARP tells results apart by their file names, which +matters most in its media clipboard, where results from many runs and models sit side by side. A +name like `tmp3kx9q.wav` or `output.wav` is soon lost among them. PyHARP renames every file +`process_fn` returns as `_`, from the model card's name and the output's label (see +[Output File Names](https://github.com/TEAMuP-dev/pyharp/blob/develop/README.md#output-file-names)). +The label is the part a Space chooses, so make it say what the result is: "Vocals", "Accompaniment", +or "Restored Audio", never "Output Audio" or "Result". Return each result as a file rather than as +an array, and a `gr.Audio` result in the `format` it declares, since Gradio names any audio it has +to write itself `audio.wav`. + +**Return labels as a `LabelList`, over a track.** HARP reads every `gr.JSON` output as a PyHARP +`LabelList` and fails the whole request on anything else, so a plain dictionary of results shows +the user an error after the model has done its work. It also draws labels only over an audio, MIDI +or file output the model returns. An analysis model therefore returns its input as an audio output +alongside the labels, as Beat-This and S-KEY do, and a whole-clip result such as a tag, a score or +a loudness reading becomes a label spanning the clip. + +PyHARP's README covers the rest of the interface: +[Gradio Endpoint](https://github.com/TEAMuP-dev/pyharp/blob/develop/README.md#gradio-endpoint) +lists the components HARP supports as controls and documents marking an input optional with +`.harp_required(False)`, +[MIDI Inputs & Outputs](https://github.com/TEAMuP-dev/pyharp/blob/develop/README.md#midi-inputs--outputs) +covers MIDI tracks, and +[Output Labels](https://github.com/TEAMuP-dev/pyharp/blob/develop/README.md#output-labels) +covers drawing labels over a track. + +## Model Weights + +Weights do not belong in a Space repository by default. Where they come from instead depends on +where they already live, how large they are, and whether their license permits redistribution. The +options below are in rough order of preference. + +Whichever is used, resolve the weights **at module scope rather than inside `process_fn`**, so that +no job pays to fetch them, a failure to fetch surfaces when the Space boots, and a replacement +[worker](#worker-processes) is warm before the next request. On ZeroGPU a fetch inside `process_fn` +also runs inside the GPU reservation, which a large download can outlast, and it can fail outright. + +Load the weights in the worker alone, as [Worker Processes](#worker-processes) describes. A Space +that loads them at module scope in both the app and the worker holds the model twice: host memory +in either case, and device memory as well on a dedicated GPU, where `.to(device)` also runs in both. +Size such a Space's hardware for two copies. + +### Upstream Hub Repository + +The default when the model is published on the Hub. Fetch from the original repository rather than +copying it, so that the publisher stays authoritative and a gated model is obtained under the terms +its publisher set: + +```python +from huggingface_hub import hf_hub_download + +MODEL_PATH = hf_hub_download( + repo_id="amaai-lab/text2midi", + filename="pytorch_model.bin", + revision="", +) +``` + +**Pin a `revision`.** Without one, the Space silently changes behavior whenever upstream moves, +which is invisible until a result changes. Prefer a commit SHA to a tag, since a tag can be moved. + +Use `snapshot_download` where a model is several files that must agree with one another. + +### Upstream Library + +Many projects ship their own downloader, in which case the weights are a side effect of +constructing the model: + +```python +from demucs import pretrained + +model = pretrained.get_model("htdemucs") +``` + +This is the least code, and it keeps the weights consistent with the code that expects them. The +tradeoff is that the version is whatever the package pins, so pin the package itself in +`requirements.txt`. Such downloads usually happen on first use, so construct the model at module +scope to keep that cost out of the first request. + +A library that downloads from outside the Hub also ties the Space to that server. +The fleet reads the same file from a +[mirror](#mirrored-model-repository) instead, linked where the library looks before it downloads: + +```python +checkpoint = Path(torch.hub.get_dir()) / "checkpoints" / "beat_this-final0.ckpt" + +if not checkpoint.exists(): + checkpoint.parent.mkdir(parents=True, exist_ok=True) + checkpoint.symlink_to(hf_hub_download("teamup-tech/Beat-This-weights", "final0.ckpt", + revision="")) +``` + +### Mirrored Model Repository + +Mirror into a model repository when the weights are not on the Hub at all, such as a lab web +server, a Google Drive link, or a GitHub release asset. A model repository keeps large files out of +the Space, can be pinned by revision, and lets several Spaces share one copy. + +Do not mirror weights whose license forbids redistribution. See [Licensing](#licensing). + +### Storage Bucket + +[Buckets](https://huggingface.co/docs/hub/storage-buckets) are S3-like object storage on the Hub, +separate from Git repositories. Use one when a weight set is large enough that retaining every +version of it is impractical, since a Git repository keeps every file it has ever held, and +repeated updates to a multi-gigabyte checkpoint grow it without bound. Our +[`teamup-tech/jukebox-weights`](https://huggingface.co/teamup-tech/buckets), at 10.3 GB, is the +existing example. + +```python +from huggingface_hub import download_bucket_files + +download_bucket_files( + "teamup-tech/jukebox-weights", + files=[("prior/model.pt", "./weights/prior.pt")], +) +``` + +Bucket files are also addressable as `hf://buckets/teamup-tech//` through any +fsspec-aware library. + +**Buckets are non-versioned and mutable.** There is no `revision` to pin, overwriting a file +replaces it in place, and deletions are immediate and permanent. A Space reading from a bucket +reads whatever is in it now. Treat the contents as append-only, and give each set of weights its +own prefix rather than overwriting one in place: + +``` +jukebox-weights/ + 5b-lyrics-v1/model.pt + 5b-lyrics-v2/model.pt +``` + +Prefer a model repository whenever the size makes it practical, and a bucket only when it does +not. + +### Committed to the Space + +Reasonable only for small assets, roughly under 50 MB, with no home of their own. The asset is +then versioned with the Space itself. The Hub rejects binary files pushed directly to Git, and +`.gitattributes` has to be in place **before** the file is staged. PyHARP's README covers the +failure modes in its +[Binary Files](https://github.com/TEAMuP-dev/pyharp/blob/develop/README.md#binary-files) section. + +### Persistent Storage + +A paid Space feature that keeps files across rebuilds. The contents are not versioned and a +rebuild does not reset them, so the Space becomes harder to reproduce. Avoid it unless nothing +else works. + +### Multiple Weight Sets + +Where a Space offers a choice of checkpoints, fetch every one when the worker starts, so that no +request waits on a download, which on ZeroGPU would run inside its GPU reservation. Building them is +a separate choice. Build them all at startup when they fit in memory together, which keeps every +request fast and, on ZeroGPU, is the only way a build is not repeated on every call. Otherwise build +each the first time it is selected and cache it: + +```python +REVISIONS = {"small": "", "large": ""} +WEIGHTS = {} +LOADED_MODELS = {} + +if __name__ != "__main__": + for size, revision in REVISIONS.items(): + WEIGHTS[size] = hf_hub_download(f"owner/model-{size}", "model.safetensors", + revision=revision) + +def get_cached_model(size: str): + if size not in LOADED_MODELS: + LOADED_MODELS[size] = load_model(WEIGHTS[size]).eval().to(device) + return LOADED_MODELS[size] +``` + +Expose the choice as a `gr.Dropdown` whose options are named the way the upstream project names +them. + +## Error Reporting + +**Always launch with `show_error=True`:** + +```python +demo.queue().launch(share=True, show_error=True, pwa=True) +``` + +Gradio forwards the text of an exception only when `show_error=True` is set, or when the exception +is a `gr.Error`. Without either, HARP can report that the model failed but nothing about why. + +**Raise `gr.Error` for failures a user can act on.** Its message is forwarded regardless of +`show_error`, and it reads as deliberate rather than as a crash: + +```python +if signal.duration < 3: + raise gr.Error(f"Key detection needs at least 3 seconds of audio, but received {signal.duration:.1f}.") +``` + +Validate inputs at the top of `process_fn`, and say what was wrong and what is expected. Refuse +only what the model cannot use. A sample rate or channel count it does not run at can be converted +instead (see [Preserving the Input Format](#preserving-the-input-format)). + +`gr.Info` and `gr.Warning` never reach HARP. Gradio does not forward them on the endpoint HARP +uses, so they appear only on the Gradio page. Do not rely on them to communicate anything. + +This matters more once `process_fn` runs in a [worker](#worker-processes). A `gr.Error` crosses +back intact, while any other exception arrives as a `RuntimeError` carrying only its message. The +traceback is left in the Space logs. + +## Preserving the Input Format + +**Return audio the way it arrived.** + +`load_audio` preserves whatever the file carried, and `save_audio` writes `.wav` at the signal's +current rate. Preserving the format therefore means capturing the input's properties before +processing and restoring them afterward. + +### Sample Rate + +Always restore it. Capture the rate before anything touches the signal, resample for inference if +the model requires a fixed rate, and resample back before saving: + +```python +signal = load_audio(input_audio_path) + +original_sr = signal.sample_rate + +if signal.sample_rate != MODEL_SAMPLE_RATE: + signal.resample(MODEL_SAMPLE_RATE) + +... # inference + +signal.resample(original_sr) +``` + +### Container + +Preserve the container only when it is **lossless**. HARP accepts `.wav`, `.aiff`, `.flac`, `.ogg`, +and `.mp3`, so a Space can be handed a file that carries one lossy encode already. Processing moves +that encoder's artifacts out from under the maskers that hid them, and writing the result back as +`.mp3` or `.ogg` then adds a second round of loss over a first that has become audible. Return a +lossless format instead: + +```python +from pathlib import Path + +LOSSLESS = {".wav", ".aiff", ".flac"} + +input_ext = Path(input_audio_path).suffix.lower() +output_ext = input_ext if input_ext in LOSSLESS else ".wav" + +output_audio_path = str(save_audio(signal, get_default_path(ext=output_ext))) +``` + +**Leave `format` unset on the `gr.Audio` output when doing this.** Gradio converts the returned +audio to whatever `format` names, so a component declaring `format="wav"` rewrites the `.flac` the +code above selected, and the conversion is invisible from inside `process_fn`. The two are mutually +exclusive: declare `format` where the model fixes its output format, and leave it off where the +output format depends on the input. The tags follow suit, so `output:audio/wav` is a fixed format +and `output:audio` is one that varies. + +### Channels + +Channels cannot simply be restored, because a model that collapses stereo to mono destroys the +stereo image rather than setting it aside. Prefer these in order: + +1. **Run the channels independently and recombine them.** This preserves the stereo image and is + correct whenever the model is channel-agnostic. It costs roughly twice the inference time. +2. **Duplicate the mono result across the input's channel count.** When the model is inherently + mono, this is the correct fallback: + + ```python + original_channels = signal.num_channels + + signal = signal.to_mono() + + ... # inference + + if signal.num_channels != original_channels: + signal.audio_data = signal.audio_data.repeat(1, original_channels, 1) + ``` + + The file then has the channel count the session expects. Two identical channels play back the + same as a mono file on a stereo track. + + It does not make the output stereo. Say so in the model card description, or the duplication + hides that the stereo image was discarded. + +Never do the reverse. If the input is mono and the model genuinely produces two distinct channels, +that is new information, and collapsing it back to mono throws away the model's actual output. + +### MIDI + +The same principle applies. Preserve ticks per beat, tempo, and track structure unless changing +them is the point of the model. + +## Hardware Portability + +**A Space should run unchanged on CPU, on a dedicated GPU, and on ZeroGPU.** Switching hardware in +the Space settings should never require editing `app.py`. + +```python +import spaces +import torch + +device = "cuda" if torch.cuda.is_available() else "cpu" + +model = MyModel.from_pretrained(...) +model.eval() +model.to(device) + +@spaces.GPU(duration=120) +def separate(input_audio_path: str, ...) -> str: + ... +``` + +Three things make this portable: + +- **`@spaces.GPU` is effect-free off ZeroGPU.** The decorator does nothing on a CPU or dedicated GPU + Space, so it can be left in place unconditionally. It goes on the function that does the model's + work, which `process_fn` calls once its checks pass (see [Protecting the Caller's + Allowance](#protecting-the-callers-allowance)). Add `spaces` to `requirements.txt` regardless of + the hardware currently selected. +- **Load the model at module scope and move it to the device there.** This is counterintuitive on + ZeroGPU, where a real GPU exists only inside the decorated function, but Hugging Face enables a + CUDA emulation mode outside it precisely so that module-level placement works. Lazy-loading or + calling `.to("cuda")` inside the decorated function is explicitly discouraged and is measurably + slower, since CUDA transfers are optimized for placement during startup. ZeroGPU runs each call + in a fresh fork, so a model built inside the function is built again on every call. The one + exception found so far is a model holding a sparse tensor, which the emulation cannot register + ("Cannot access storage of SparseTensorImpl"). +- **Select the device rather than hardcoding it.** `torch.cuda.is_available()` is what keeps the + same line working on a CPU Space, where `.to("cuda")` would fail outright. + +A Space with no model, such as one that only runs signal processing, is the exception. A GPU does +nothing for it, so it belongs on CPU (see [Choosing the Hardware](#choosing-the-hardware)), and a +decorated function would only have ZeroGPU bill its callers for CPU work. It leaves the decorator +out. + +### Choosing the Hardware + +**Put a model on ZeroGPU only when the GPU changes what using it is like.** ZeroGPU is free for the +organization but not for its users: every call spends the caller's daily allowance, can be refused +when that runs short, and pays a cold start whenever a worker is spawned. A call to a `cpu-basic` +Space costs the user nothing. An organization can also run at most **50 Spaces on ZeroGPU**. + +Decide by measurement. Time a warm request with 10 seconds of input on `cpu-basic`, which the +validation harness reports per case (the first case on a fresh worker also pays for loading the +model, so read the one after it): + +- **Faster than real time, under about 10 s for 10 s of input: keep it on CPU.** Analysis models + that make one pass over the audio (key, tempo, emotion, tagging, detection), small effect models, + and light speech synthesis such as Kokoro measured 1 to 7 s. +- **Slower than real time: ZeroGPU,** since a clip of a minute or more would otherwise take minutes. + Diffusion and autoregressive generators, source separators, and large transcription models + measured 11 to 300 s. +- **TensorFlow and other non-PyTorch models stay on CPU,** since ZeroGPU accelerates PyTorch only, + however slow. + +On ZeroGPU, `spaces` has to be imported before anything that initializes CUDA, PyHARP included, +since importing it loads torch. Otherwise the Space fails at startup with "CUDA has been initialized +before importing the `spaces` package", which is why the example above imports it first. + +ZeroGPU requires PyTorch 2.8.0 or newer, and an exact `torch` pin must be a release it accepts, +which at the time of writing are 2.8.0, 2.9.1, 2.10.0, 2.11.0, 2.12.1, and 2.13.0. Any other exact +pin, _e.g._ 2.9.0, stops the Space with a configuration error before it starts, while a lower +bound or no pin passes. It does not support `torch.compile`, so use ahead-of-time compilation +instead. + +ZeroGPU is available only to Gradio Spaces, so a Docker Space has to pay for GPU hardware instead, +or move to the Gradio SDK. + +### Protecting the Caller's Allowance + +Every ZeroGPU call spends the caller's daily allowance: two minutes unauthenticated, five for a free +account, forty for PRO, shared across every ZeroGPU Space. Avoid spending it for a mistaken setting or an input the model cannot use. + +**Check inputs and settings before the GPU is reserved.** Put `@spaces.GPU` on a function that does +only the GPU work, and have an undecorated `process_fn` check everything first and then call it. A +request refused there costs nothing, waits in no GPU queue, and reaches a user with little allowance +left as the real problem rather than as a quota error. Reading a file needs no GPU, so checks of an +input's length, channels, or contents belong there too, as does waiting for a model that is still +loading. Read the file with `soundfile` there rather than `torchaudio`, whose ffmpeg decoding fails +outside the decorated function under ZeroGPU's emulation ("Creating a new Tensor subclass +ChunkTensor"): + +```python +@spaces.GPU(duration=60) +def separate(audio_path: str, model_name: str) -> str: + ... + +def process_fn(audio_path: str, model_name: str) -> str: + try: + sf.info(audio_path) + except Exception as exc: + raise gr.Error(f"Could not read the audio file: {exc}") from exc + + return separate(audio_path, model_name) +``` + +**Refuse settings that cannot give a useful result,** not only invalid ones. + +**Refuse what cannot finish, and nothing more.** Two things stop a request whatever the caller can +afford: an input beyond what the model can hold, such as its context window or the GPU's memory, +and a reservation longer than PyHARP's `timeout_s` (see [Worker Processes](#worker-processes)), +which stops the job anyway. Refuse those before the GPU is reserved. Do not cap an input so that a +free account could afford it. ZeroGPU already refuses a request that the caller's tier or remaining +allowance cannot cover, before reserving anything, with a message saying how to get more, and a PRO +account can run tasks of up to 40 minutes. + +A model that holds its whole input on the GPU at once has a memory limit, which only measurement +finds, and which can sit just past a length that ran in seconds. Process a long input in +overlapping chunks, cross-faded where they meet, where the model allows. Otherwise refuse inputs +longer than the longest measured to fit. + +**Size each reservation to the request.** A call that completes is billed roughly the time it used, +but one that runs past its reservation is stopped and can cost the whole reservation, which is why a +failing Space drains allowance far faster than a working one. Time real requests, the longest an +input allows among them, and compute `duration` from the arguments, padded a little above what was +measured. `@spaces.GPU` takes a function of the same arguments as the function it decorates, so a +short clip reserves, and puts at risk, only what it needs: + +```python +# PyHARP stops a job that runs past its timeout_s, 15 minutes by default +MAX_GPU_SECONDS = 900 + +def gpu_seconds(audio_path: str, *_) -> float: + return 10 + 0.5 * sf.info(audio_path).duration + +@spaces.GPU(duration=gpu_seconds) +def separate(audio_path: str, model_name: str) -> str: + ... + +def process_fn(audio_path: str, model_name: str) -> str: + if gpu_seconds(audio_path) > MAX_GPU_SECONDS: + raise gr.Error("The recording is too long for one request to finish. Trim it.") + + return separate(audio_path, model_name) +``` + +The `spaces` client multiplies `duration` by a factor for the GPU a call lands on, at the time of +writing 1.5 on the RTX PRO 6000 Blackwell nodes and 1 on H200, and ZeroGPU admits the call only when +the caller's remaining allowance covers the result. Keep reservations at 120 s or less where the work allows, since shorter +durations also improve queue priority for everyone using the Space. + +## Dependency Conflicts + +Some models cannot share an environment with current Gradio. The usual cause is a transitive pin +that cannot be satisfied alongside it, such as a package relying on the `numpy.float` alias removed +in `numpy==1.24`. + +### Pin Gradio Before Splitting the Environment + +A model pinned to `transformers` 4.x is the common case, and it looks irreconcilable, since +`transformers` below 5.0 caps `huggingface-hub` below 1.0 while Gradio 6 wants it above. Gradio's +floor moved over the 6.x series, so which Gradio is pinned is what decides whether the two can +coexist: + +| Gradio | Requires | +| --- | --- | +| 6.13 to 6.17 | `huggingface-hub>=0.33.5` | +| 6.18 to 6.20 | `huggingface-hub>=1.2.0` | +| 6.21 and later | `huggingface-hub>=1.16.0` | + +PyHARP requires Gradio 6.13 or newer for the error payload, so 6.13 through 6.17 is the window +where a `transformers` 4.x model and PyHARP resolve together, landing on `huggingface-hub` 0.36. +Pin `sdk_version` there and check the resolution before concluding that an environment has to be +split: + +```bash +pip install --dry-run --ignore-installed -r requirements.txt \ + "gradio==6.17.3" "git+https://github.com/TEAMuP-dev/pyharp.git@v0.4.2" +``` + +A Space pinned inside that window should say why in its README, since it departs from the version +the rest of the fleet runs. + +### Split the Environment + +Keep the two apart rather than patching the model's source. A **frontend** environment runs Gradio +and PyHARP, a **backend** environment runs the model under its own interpreter and pins, and the +frontend invokes the backend as a subprocess, exchanging JSON. Both live in one Docker image, which +a Gradio Space cannot express, so the Space uses the Docker SDK. PyHARP's +[Docker Spaces](https://github.com/TEAMuP-dev/pyharp/blob/develop/README.md#docker-spaces) section +has the layout, and [BeatNet](https://huggingface.co/spaces/teamup-tech/BeatNet) is a working +example. + +ZeroGPU is not available to Docker Spaces, so a model that needs a GPU has to be paid for (see +[Hardware Portability](#hardware-portability)). A model that runs on CPU is unaffected. + +Two things are easy to get wrong: + +- **Invoke the backend with `subprocess.run`.** The worker leads its own process group, so a + cancellation then tears down the model along with the job (see + [Worker Processes](#worker-processes)). +- **Write only the result to stdout.** The frontend parses it as JSON, so logging and progress have + to go to stderr. + +## Calling Another Space + +A Space can forward inference to another Space rather than running the model itself. The fleet runs +one, SoulX-Singer, and no other should be built for a model that can be installed here. Every other +proxy the fleet once ran turned out to be avoidable once these were checked: + +- **Check what the authors' own Space installs.** A Space that already runs on ZeroGPU has solved + the platform's constraints, often by vendoring a fork. MelodyFlow's carries a copy of audiocraft + moved from `torch==2.1.0` to 2.8.0, which dissolves the conflict with ZeroGPU that the published + package has. ACE-Step's carries its source rather than the PyPI package, whose sdist cannot be + built. +- **Vendor that Space's code rather than calling it.** Copy what the inference path imports, along + with the data files it opens (see [Keeping a Space Lightweight](#keeping-a-space-lightweight)), + keep the authors' license file beside it, and call the function their interface calls. +- **Dry-run the combined environment** with the recipe under [Pin Gradio Before Splitting the + Environment](#pin-gradio-before-splitting-the-environment) before concluding that it cannot + resolve. Model size is not a reason either, since weights are fetched at build time whatever the + deployment. + +Calling another Space is the right deployment only where the model cannot be hosted here at all: + +- **Its code or weights cannot be redistributed,** and their owners serve the model themselves, so + calling that deployment is the only lawful route to it. +- **It needs hardware ZeroGPU cannot provide,** such as more GPU memory or a longer job than ZeroGPU + allows, and its owners run that hardware. +- **Running it here has been tried and failed, while its owners run it.** SoulX-Singer is an example of this case. + A reproduction gave poor output, and the authors' code, vendored once its dependencies resolved, + loaded but hung in ZeroGPU's setup inside PyHARP's worker on every call. + +In each case the backend belongs to its owners, who can change its API, let it sleep, or take it +down without notice. A backend is never hosted under `teamup-tech`, both because a model that could +be hosted here should be vendored instead and because HARP's Home tab lists every public Space in +the organization, so a backend there would appear as a model that fails to load. + +### What a Frontend Owes HARP + +SoulX-Singer's `app.py` does each of these, so start a new frontend from a copy of it. + + +- **Forward the caller's `x-ip-token` header.** The Hub adds it to every request a Space receives, + identifying whoever made it, and ZeroGPU bills the allowance of whoever it identifies. Passed on + to the backend, it bills each user their own allowance, as a direct call would, and the frontend + never handles a credential. PyHARP restores the request inside its worker, so `process_fn` can + read the header from there: + + ```python + from gradio.context import LocalContext + from gradio_client import Client + + def process_fn(audio_path): + request = LocalContext.request.get(None) + ip_token = request.headers.get("x-ip-token", "") if request else "" + client = Client("https://owner-backend.hf.space", headers={"x-ip-token": ip_token}) + ... + ``` + + Without it, every call reaches the backend from the frontend's server, and all users share one + anonymous allowance. Embedding a token of the organization's instead bills every user to that one + account. Either way, once that single allowance runs out, the model looks broken to everyone. + Forwarding also leaves a quota error from the backend attributable to the caller, which is what + the validation harness assumes of every Space. See [Calling Spaces from Another + Space](https://huggingface.co/docs/hub/spaces-api-endpoints#calling-spaces-from-another-space). +- **Reach the backend by its URL,** `https://-.hf.space`, as above, not by its + repository id. Given an id, `gradio_client` looks the host up through the Hub's API without a + token, which the Hub rate-limits from the address a Space shares with others. +- **Declare the `zerogpu` tag** when the backend runs on ZeroGPU. The frontend itself runs on + `cpu-basic`, so its hardware says nothing about the allowance a request draws on, and the tag is + what puts the ZeroGPU badge in HARP (see [Tags](#tags)). +- **Say in the description where the work happens** and whose allowance pays for it. +- **Check the backend's API signature at startup,** so that an upstream change surfaces as a build + failure rather than as a puzzle on every request. + +## Keeping a Space Lightweight + +Bring in only what inference needs. Research repositories carry training loops, evaluation +harnesses, dataset preparation, notebooks, and configuration for experiments that were never run. +None of it belongs in a Space. + +- Vendor the inference path rather than the whole repository, or install the upstream package from + a pinned Git revision if it is packaged properly. +- **Work out that path, rather than guessing it.** Walk the imports from the entry point and keep + the closure. Follow relative imports as well as absolute ones: a package that reaches its + submodules with `from .module import name` looks far smaller than it is. +- **Take the data the code opens, not only the code.** Weights are the obvious part, and the + easily missed part is everything else a model reads at runtime: an ONNX graph for a text front + end, a phoneme table, a vocabulary pickle. Filtering a checkout by source extension drops all of + it, and the Space then builds cleanly and fails on its first request. Diff the file list against + the upstream repository and account for each name left over: + + ```python + upstream = set(api.list_repo_files(source, repo_type="space")) + ours = set(api.list_repo_files(destination, repo_type="space")) + print(sorted(upstream - ours)) + ``` + +- Keep `requirements.txt` to what is imported. Every entry adds build time, paid again on every + rebuild, so a heavy dependency tree can leave a Space taking minutes to come back after a + restart. +- Put `apt-get` packages in `packages.txt` rather than shelling out at startup. + +A Space that starts quickly also recovers quickly after sleeping. diff --git a/DEVNOTES.md b/docs/DEVELOPING.md similarity index 79% rename from DEVNOTES.md rename to docs/DEVELOPING.md index 900e34f1..45fa176d 100644 --- a/DEVNOTES.md +++ b/docs/DEVELOPING.md @@ -1,4 +1,4 @@ -# Developer Notes +# Development Guidelines @@ -10,7 +10,7 @@ In file `.clang-format` you can find code formatting rules for the project. You can use `clang-format` to format your code before committing it. We are following the guidelines used by JUCE. If you are using Visual Studio Code (VS Code), you must have the C/C++ extensions from Microsoft installed. -In MacOS, you should also install `clang-format` with `brew`: +On macOS, you should also install `clang-format` with `brew`: ```bash brew install clang-format ``` @@ -35,7 +35,7 @@ If you want to format files that haven't been formatted before, it's better to g When working with collections of objects created with `new`, such as UI components or other heap-allocated data, avoid storing raw pointers (`T*`) directly in standard containers like `juce::Array` or `std::vector`. This requires manual `delete` calls, and can easily lead to memory leaks or crashes if handled imperfectly (_e.g._, in destructors, during removal, with exceptions, etc.). -**Problem Example (from `NoteGridComponent`):** +**Problem Example:** ```cpp // DON'T DO THIS: Requires manual deletion @@ -164,18 +164,19 @@ Use standard C++ smart pointers (`std::unique_ptr`) within a standard container. - Prefer clear separation of concerns: independent components, widgets, and services should live in their own units. - Avoid unnecessary crosstalk between UI, model logic, and infrastructure layers. - If a change touches many unrelated areas, it’s usually a sign to introduce an abstraction (_e.g._ `SharedResourcePointer`, service objects). -- Avoid hard-coded conditionals that branch on specific models, modes (_e.g._, Gradio vs. Stability), or UI states—favor extensibility. +- Avoid hard-coded conditionals that branch on specific models, modes (_e.g._, Gradio vs. Stability), or UI states. Favor extensibility. - Good design should feel “boring”: things fall neatly into place with minimal glue code. ### Efficiency - Avoid unnecessary or repeated calls to `resized()`, `repaint()`, and layout invalidation. This can significantly slow down the application. - Prefer event-driven updates over polling. -- Be mindful of blocking calls on the message thread; long-running work belongs off the UI thread. +- Be mindful of blocking calls on the message thread. Long-running work belongs off the UI thread. - Cache derived values where appropriate, but keep lifetimes explicit. +- Keep text measurement out of `paint()` and `resized()`. JUCE 8 shapes the text on every measurement, which is slow enough to notice in a Debug build. Use `ModelStyle::getTextWidth`, which measures each string once. ### Error Handling - Use `OpResult` for anything with meaningful modes of failure (I/O, network, model execution, parsing, etc.). -- Errors should be propagated, not swallowed—callers decide how to surface them. +- Errors should be propagated, not swallowed. Callers decide how to surface them. - `jassertfalse` is acceptable for truly unreachable states (logic errors, violated invariants). ### Logging & Debugging @@ -188,70 +189,48 @@ Use standard C++ smart pointers (`std::unique_ptr`) within a standard container. - Windows are persistent, user-managed, and represent full workflows or views. - Be consistent about ownership and lifetime: who creates, shows, and destroys. - Launch behavior (modal vs non-modal) should be predictable across the app. +- Do not rely on the button index passed to an `AlertWindow::showAsync` callback. Bind + each button's `onClick`, or pass explicit return values to `AlertWindow::addButton` + (see [AlertWindow Button Return Values](#alertwindow-button-return-values)). ### Buttons & MultiButtons - Buttons should do one thing and emit a clear intent (no hidden side effects). - `MultiButton` should be used when states are mutually exclusive and obvious. - FontAwesome and FontAudio can be used to create buttons with icons rather than text. -- Avoid encoding business logic inside button callbacks—delegate to controllers/services. +- Avoid encoding business logic inside button callbacks. Delegate to controllers or services. ### Sizing Components - Prefer FlexBox layouts over hard-coded sizes and manual positioning. - Components should size themselves based on content where possible. -- Avoid “magic numbers” for spacing—centralize or derive them. +- Avoid “magic numbers” for spacing. Centralize or derive them. - Resizing should be stable and predictable across window sizes. ### External Code - Use submodules for external dependencies that work out-of-the-box. - Organize external code with clear boundaries (_i.e._ `external/`) and treat it as read-only. -- Do not modify third-party code unless absolutely necessary; wrap instead. -- Only include what is needed in build targets—avoid leaking dependencies globally. +- Do not modify third-party code unless absolutely necessary. Wrap it instead. +- Only include what is needed in build targets, and avoid leaking dependencies globally. - +## Known JUCE Behaviors -## TODOs +### AlertWindow Button Return Values -### Upgrading JUCE +JUCE assigns the return values for `MessageBoxOptions` buttons in an order that does not +match the order in which they were added. `LookAndFeel_V2::createAlertWindow` (which +`LookAndFeel_V4` delegates to) maps three buttons to `1`, `2`, `0` and two buttons to +`1`, `0`, so the last button added is always `0`. -If and when we decide to upgrade to JUCE v8, we need to be aware of the button ordering issue affecting the `alertCallback` lambda function of `ModelTab::openErrorPopup`. -```cpp -auto alertCallback = [this, ..., errorPopup](int choice) -{ - enum Choice - { - Choice1, - Choice2, - Choice3 - }; - - /* - TODO - The button indices assigned by MessageBoxOptions do not follow the order in which - they were added. This should be fixed in JUCE v8. The following is a temporary workaround. - - See https://forum.juce.com/t/wrong-callback-value-for-alertwindow-showokcancelbox/55671/2 - - When this is fixed, errorPopup can be removed from the argument list. - */ - { - std::map observedButtonIndicesMap = {}; +This is still the behavior as of JUCE 8.0.12, contrary to the expectation in +[this forum thread](https://forum.juce.com/t/wrong-callback-value-for-alertwindow-showokcancelbox/55671/2). - if (errorPopup.getNumButtons() == 3) - { - observedButtonIndicesMap.insert({ 1, Choice::Choice1 }); - } +No HARP code depends on that mapping any more, and new code should avoid it: - observedButtonIndicesMap.insert( - { errorPopup.getNumButtons() - 1, Choice::Choice2 }); - - observedButtonIndicesMap.insert({ 0, Choice::Choice3 }); - - choice = observedButtonIndicesMap[choice]; - } - - ... -} -``` +- `ModelTab::openErrorPopup` constructs its own `AlertWindow` and assigns each button's + `onClick` via `AlertWindow::getButton`, so the return values are never consulted. +- `Main.cpp` and `MediaClipboardWidget` construct their `AlertWindow` directly and pass + explicit return values to `AlertWindow::addButton`. -Note: upgrading JUCE is likely to be a breaking change for many more reasons. It may not be simple or quick. +Prefer one of those two approaches over reading the index passed to an +`AlertWindow::showAsync` callback. --- diff --git a/docs/REVIEWING.md b/docs/REVIEWING.md new file mode 100644 index 00000000..a1e21213 --- /dev/null +++ b/docs/REVIEWING.md @@ -0,0 +1,165 @@ +# Reviewing Guidelines + +Every pull request on develop requires at least two approvals from other contributors. +The purpose is to catch the routine problems (unclear intent, dead code, broken +behavior, regressions) before the final review, so that review can focus on what remains. + +An approval means you read the changes, built the branch, tested it, and found no problems. +If you did not do all of that, do not approve. Approving code makes you partly responsible +for it. + +## Table of Contents +* **[Reviewing a Pull Request](#reviewing-a-pull-request)** + * **[1. Check the Intent](#1-check-the-intent)** + * **[2. Read the Changes](#2-read-the-changes)** + * **[3. Test the Branch](#3-test-the-branch)** + * **[4. Assess Understanding](#4-assess-understanding)** +* **[Leaving the Review](#leaving-the-review)** +* **[Opening a Pull Request](#opening-a-pull-request)** +* **[Reviewer Checklist](#reviewer-checklist)** + +--- + +## Reviewing a Pull Request + +### 1. Check the Intent + +Read the title and description first. + +- Can you state what the changes do in one or two sentences? +- Are the relevant issues linked or referenced? + +If not, ask the author to clarify before reviewing further. A pull request that cannot be +explained cannot be reviewed. + +### 2. Read the Changes + +Skim every changed file, including the ones that look routine. + +- **Does the change make sense and read clearly?** Follow the flow. You do not need to + understand every line to notice that something is happening in the wrong place. Clear + naming and structure are usually enough on their own, and comments are needed where the + reasoning is not obvious. "I had to reverse-engineer this" is valid feedback. +- **Is there unnecessary complexity?** Nested conditionals that could be early returns, + a new class that duplicates an existing one, hand-written code for something the standard + library or JUCE already provides. +- **Is there unused or leftover code?** Commented-out blocks, debug prints, unused variables, + functions nothing calls, unresolved TODOs. +- **Is it consistent with the rest of the codebase?** New code should resemble the code + around it in naming, structure, error handling, and layout. Code should also be formatted + with `clang-format` against the project `.clang-format` file. +- **Are there accompanying unit tests?** Tests are not required for approval, but they are + strongly recommended. + +You are not expected to catch everything. You are expected to look at everything. + +### 3. Test the Branch + +This is the most important part of the review. + +Check out the branch and build it: + +```bash +git fetch origin +git checkout +git submodule update --init --recursive + +cmake -B build -DCMAKE_BUILD_TYPE=Debug . +cmake --build build --parallel $(nproc) + +./build/HARP_artefacts/Debug/HARP +``` + +Then test it properly. + +- **Verify the change works.** Reproduce the original bug and confirm it is fixed, or + exercise the new feature the way a user would. +- **Try to break it, creatively.** Click things in the wrong order. Cancel partway through. + Load an empty file, a very large file, a file of the wrong type. Resize the window to + extremes. Interrupt the network during a request. Use input the author probably did not + consider. +- **Check functionality that seems unrelated.** Load a model, process audio, process MIDI, + undo, save, use the clipboard. Regressions rarely appear where the diff is, which is why + reading alone does not find them. +- **Watch the log.** Errors and warnings in the console or in the app log file + can sometimes expose problems the interface hides. +- **Test on more than one operating system if you can.** Problems regularly show up on + macOS or Windows that never appear on Linux, and the other way around. If you only have + one platform available, state which one you tested on in your review so the gap is visible + to the next reviewer. + +Surface-level testing, meaning the app launched and the new button worked, is the most common +reason a pull request is approved and then turns out to need more work. + +When something breaks, report exactly what you did, what you expected, and what happened, so +the author can reproduce it without guessing. + +### 4. Assess Understanding + +- **Does the author appear to understand the code they wrote?** Inconsistent approaches, + confused structure, or changes that fix a symptom without an explanation are worth raising. +- **Do you understand it?** If you do not, either the code needs to be clearer or you need to + ask a question. Do not approve code you do not understand. + +--- + +## Leaving the Review + +- **Comment** for questions and optional suggestions. +- **Request changes** for anything that has to be fixed before merging, such as bugs, + regressions, or code you cannot follow. This is a normal outcome, not a criticism of the + author. +- **Approve** only when you would be comfortable merging the branch yourself. + +Copy the [Reviewer Checklist](#reviewer-checklist) into your review and work through it +there. + +Practical points: + +- Be specific. Reference the line, give the steps to reproduce, and suggest an alternative + where you have one. +- Separate blocking issues from optional ones. Differentiate optional remarks. +- Re-check the branch after the author pushes fixes, including a retest if the fix touches + behavior you tested before. + +--- + +## Opening a Pull Request + +- **Write a clear title and description.** State what changed, why, and anything a reviewer + needs in order to test it, such as setup steps or which model to use. +- **Link the corresponding issues.** Use `Closes #123` in the description, or link the issue + from the Development section of the pull request page. Mention + anyone involved in the discussion. +- **Address one issue per pull request.** Small, focused changes get reviewed quickly and + thoroughly. Unrelated cleanups belong in a separate pull request. +- **Run the reviewer checks on your own code first.** Read your own diff on GitHub from top to + bottom, then build and test the branch as though someone else wrote it. + - This step alone catches most problems. + - Repeat until you find no further problems or improvements. + - Do not ask anyone to review code you have not reviewed comprehensively yourself. +- **Format your code and confirm CI passes** before requesting review. +- **Open it as a draft** if you want early feedback on unfinished work, and say what kind of + feedback you are looking for. + +--- + +## Reviewer Checklist + +```markdown +- [ ] Title and description make the intent clear +- [ ] Relevant issues are linked +- [ ] The pull request addresses one issue +- [ ] Skimmed every changed file, and the approach makes sense +- [ ] No unnecessary complexity, unused code, or leftover debug output +- [ ] Code is documented or otherwise self-explanatory +- [ ] Consistent with existing code and formatted +- [ ] Tests included where applicable (optional, recommended) +- [ ] Built and ran the branch locally +- [ ] The feature or fix works as described +- [ ] Tried to break it with unexpected input and interactions +- [ ] Checked unrelated functionality for regressions +- [ ] No new errors or warnings in the log +- [ ] Tested on: +- [ ] I understand the change well enough to be partly responsible for it +``` diff --git a/docs/SIGNING.md b/docs/SIGNING.md new file mode 100644 index 00000000..27519f2b --- /dev/null +++ b/docs/SIGNING.md @@ -0,0 +1,384 @@ +# Code Signing + +Guidelines for code signing HARP's release packages: what each platform requires, how to set up +the credentials, how to hand them off to a new maintainer, and what tends to go wrong. The +[build workflow](../.github/workflows/build.yml) does the signing itself, so the work described +here is mostly creating accounts and certificates, and storing the results as repository secrets. + +## Table of Contents + +- [Overview](#overview) +- [macOS](#macos) + - [Signing and Notarization](#signing-and-notarization) + - [Choosing an Account](#choosing-an-account) + - [Setting Up](#setting-up) + - [Renewing and Handing Off](#renewing-and-handing-off) + - [Troubleshooting](#troubleshooting) +- [Windows](#windows) + - [Artifact Signing](#artifact-signing) + - [Choosing a Service](#choosing-a-service) + - [Setting Up](#setting-up-1) + - [Renewing and Handing Off](#renewing-and-handing-off-1) + - [Troubleshooting](#troubleshooting-1) + +## Overview + +Operating systems treat software downloaded from the internet differently depending on whether it +is signed: + +- **macOS** refuses to open an app unless it is signed with a Developer ID certificate _and_ + notarized by Apple. Users can override this in System Settings → Privacy & Security, but a + release should never require it. +- **Windows** shows a SmartScreen warning ("Windows protected your PC") for an executable from an + unknown publisher. Users can get past it with "More info" → "Run anyway". +- **Linux** does not check signatures, so no setup is needed. + +The credentials are stored in the repository under Settings → Secrets and variables → Actions, +which requires admin access to the repository. + +**Each signing step runs only when its credentials are present.** A build without them, such as one +for a pull request from a fork, still succeeds, and produces unsigned packages. A build with only +some of them either fails or produces packages that are signed but still blocked. + +**Secrets cannot be read back once saved**, not even by an admin. Anything that will be needed +again has to be kept elsewhere (see [Renewing and Handing Off](#renewing-and-handing-off)). + +## macOS + +### Signing and Notarization + +macOS requires two separate things of a downloaded app, and the credentials cover both: + +1. **Signing** proves who made the app and that it has not been modified since. It uses a + _Developer ID Application_ certificate and its private key, which the workflow imports from a + password-protected `.p12` file. +2. **Notarization** has Apple scan the signed app for malware. The workflow uploads the DMG to + Apple's notary service, waits for it to be accepted, and _staples_ the resulting ticket to the + DMG, so that Gatekeeper can verify it even offline. + +Uploading to the notary service requires signing in to Apple as a member of the developer team that +signed the app. The certificate alone is not enough. Apple Accounts require two-factor +authentication, which an automated build cannot complete, so Apple issues an _app-specific +password_ for this purpose in place of the account's real password. + +| Secret | Contents | Used For | +|---|---|---| +| `MACOS_CERTIFICATE` | The Developer ID Application certificate and private key, as a base64-encoded `.p12` file. | Signing | +| `MACOS_CERTIFICATE_PASSWORD` | The password used to export the `.p12` file. | Signing | +| `APPLE_ID` | The email address of an Apple Account on the developer team. | Notarization | +| `APPLE_APP_PASSWORD` | An app-specific password for that Apple Account. | Notarization | + +The signing identity and the team ID are both read from the certificate, so they need no secrets of +their own. + +### Choosing an Account + +Developer ID certificates require a paid membership of the +[Apple Developer Program](https://developer.apple.com/programs/) (US$99 per year), since a free +Apple Account cannot create them. A membership is either individual or organization, which decides +whose name the certificate carries and how easily it changes hands. + +**Individual memberships are the quickest to set up.** One person enrolls with their own Apple +Account, and the certificate names them (_e.g._ `Developer ID Application: Jane Doe (ABCDE12345)`). +The membership belongs to that person, and Apple transfers one only in exceptional circumstances, so +when they leave the project, a successor has to enroll separately and replace the credentials (see +[Renewing and Handing Off](#renewing-and-handing-off)). + +**Organization memberships outlive any one person's involvement.** The certificate carries the +organization's name, and the team can have several members with different +[roles](https://developer.apple.com/help/account/manage-your-team/roles/). The requirements are +stricter (see [Apple's enrollment page](https://developer.apple.com/programs/enroll/)): + +- The organization must be a legal entity that can enter into contracts with Apple. DBAs, trade + names, and branches are not accepted, so an informal group such as a research lab can only enroll + through the institution or company to which it belongs. +- It needs a D-U-N-S number, which Dun & Bradstreet assigns for free (many institutions already + have one), a public website on its own domain, and a work email address on that domain. +- The person enrolling must have the legal authority to bind the organization, and becomes its + _Account Holder_. +- Only the Account Holder can create Developer ID certificates, and the team can have at most five + at a time. The role can be + [transferred](https://developer.apple.com/help/account/manage-your-team/transfer-the-account-holder-role/) + to another member with the same legal authority. +- Nonprofits, accredited educational institutions, and government entities that do not sell anything + through their apps can [request a fee waiver](https://developer.apple.com/support/fee-waiver/). + +An individual membership can also be converted to an organization membership by contacting Apple, +provided its holder founded the organization and it has a D-U-N-S number. Confirm with Apple whether +existing certificates carry over before relying on them. + +A university, or another institution hosting a project, may already have an organization +membership. Its Account Holder would have to create the certificate, and since a Developer ID +certificate can sign anything in the institution's name, they may be reluctant to hand out its +`.p12` file. + +### Setting Up + +These steps are the same for a first setup, a renewal, and a handoff. Steps 2 to 6 have to be done +on the same Mac, since the private key is created there and never leaves it until the export. + +1. Enroll in the Apple Developer Program, and accept any pending agreements at + [developer.apple.com/account](https://developer.apple.com/account). Notarization fails while an + agreement is outstanding. +2. Open Keychain Access (with Spotlight, since recent versions of macOS no longer list it in the + Utilities folder) and choose Keychain Access → Certificate Assistant → Request a Certificate From + a Certificate Authority. Enter the account's email address and select "Saved to disk". +3. As the Account Holder, open the + [new certificate page](https://developer.apple.com/account/resources/certificates/add), choose + **Developer ID Application** with the G2 Sub-CA, and upload the request. Download the certificate + and double-click it to add it to the login keychain. +4. Unless Xcode is installed, also download and double-click Apple's + [Developer ID - G2](https://www.apple.com/certificateauthority/DeveloperIDG2CA.cer) intermediate + certificate, which Xcode installs but the Command Line Tools do not. The certificate cannot sign + anything without it. +5. Check that `security find-identity -v -p codesigning` lists `Developer ID Application: ...` as a + valid identity. If it does not, see [Troubleshooting](#troubleshooting). +6. In Keychain Access, under login → My Certificates, expand the certificate to check that a private + key is underneath. Then right-click the certificate and export it as a `.p12` file with a strong + password. +7. Signed in as the Apple Account that will submit notarizations, create an app-specific password + at [account.apple.com](https://account.apple.com) → Sign-In and Security → App-Specific + Passwords. +8. Package and notarize a build locally (see [Distribution](../README.md#distribution)), which + reports problems sooner and more clearly than the workflow. The first submissions from a new + account can take much longer than usual. +9. Set the four secrets listed under [Signing and Notarization](#signing-and-notarization). The + value of `MACOS_CERTIFICATE` is the output of `base64 -i .p12`. +10. Push a commit, download the DMG from the workflow run, and check it: + + ```bash + spctl -a -vv -t open --context context:primary-signature .dmg + ``` + + The output should include `accepted` and `source=Notarized Developer ID`. +11. Delete the certificate request, the downloaded `.cer` files, and any copy of the base64 text. + Keep the `.p12` file, its password, and the app-specific password in a password manager shared + by the maintainers, or delete them as well (see + [Renewing and Handing Off](#renewing-and-handing-off)). The certificate and private key remain + in the keychain, so a new `.p12` file can be exported from it at any time. + +### Renewing and Handing Off + +**Certificates are valid for five years**, though never beyond the expiry of Apple's intermediate +certificate (September 2031 for G2). Packages signed while the certificate was valid keep working +after it expires, so only new builds need a new one. + +**Revoke a certificate only if its private key has been compromised.** Apple blocks every app signed +with a revoked certificate, including copies users have already installed. Developer ID certificates +cannot be revoked from the developer account. Instead, Apple's +[certificate support page](https://developer.apple.com/support/certificates/) asks for revocation +requests by email to product-security@apple.com. A certificate that is no longer needed, including +one whose private key was lost, can simply be left to expire. + +**Keep the membership active.** If it lapses, existing releases keep working, but no new +certificates can be created, and notarization requires an active membership. + +When signing passes to a new maintainer: + +- **Within an organization membership**, add the new maintainer to the team. They create their own + app-specific password and replace `APPLE_ID` and `APPLE_APP_PASSWORD`. The certificate can stay, + as long as its `.p12` file and password are still available, and the Account Holder role is + transferred if the outgoing maintainer held it. +- **Between individual memberships**, the new maintainer enrolls, creates a certificate, and + replaces all four secrets at once, since the certificate and the Apple Account have to belong to + the same team. Releases signed by the previous team keep working. + +Either way: + +- **Keep the `.p12` file and its password in a password manager shared by the maintainers**, or + delete them and create a new certificate whenever one is needed. GitHub cannot return them. +- **App-specific passwords stop working when the Apple Account's password changes**, so whoever + owns `APPLE_ID` should know that it is in use. +- **Expect users to be asked for permissions again after a change of team.** macOS ties some + permissions, such as access to the Documents folder, to the team that signed the app. + +### Troubleshooting + +- **`0 valid identities found`:** run `security find-identity -p codesigning` without `-v`, which + also lists identities that are not valid. + - If the certificate is listed under "Matching identities", the intermediate certificate is + missing (step 4 of [Setting Up](#setting-up)). Signing then fails with + `unable to build chain to self-signed root`. + - If it is not listed at all, the keychain has the certificate but not its private key. +- **No private key under the certificate:** the private key exists only on the Mac and user account + that made the request, so downloading the certificate again does not restore it. Import a `.p12` + file exported from that Mac, if one exists. Otherwise, delete the certificate from the keychain + and make a new request and certificate. The old certificate is unusable without its key, so leave + it to expire rather than revoking it. It still counts toward the team's limit of five, and + [Developer Program Support](https://developer.apple.com/contact/) can help if that limit is + reached. +- **No Developer ID Application option:** the membership is not active yet, or the signed-in user + is not the Account Holder. +- **Notarization fails with `HTTP status code: 403`:** an agreement needs to be accepted in the + developer account. +- **Notarization fails with `HTTP status code: 401`:** the Apple Account and app-specific password + do not match, or the account is not on the team that signed the app. +- **Notarization finishes with `status: Invalid`:** + `xcrun notarytool log `, with the same credentials as the submission, explains why. +- **`The specified item could not be found in the keychain`:** no certificate in the keychain + matches `MACOS_SIGNING_IDENTITY`. In the workflow, this means `MACOS_CERTIFICATE` is not a + Developer ID Application certificate. +- **`ambiguous`:** several certificates match `MACOS_SIGNING_IDENTITY`, such as an old and a new one + with the same name. Use the SHA-1 hash listed by `security find-identity` instead. + +## Windows + +### Artifact Signing + +Windows has no equivalent of notarization, so signing is the only step. The workflow signs +`HARP.exe` with [Azure Artifact Signing](https://learn.microsoft.com/azure/artifact-signing/) +(formerly Trusted Signing). Rather than handing out a certificate and private key, Artifact Signing +keeps them in Azure and signs on request, with a certificate issued to an individual or organization +that Microsoft has validated. + +Requesting a signature requires signing in to Azure. As with notarization, an automated build cannot +sign in as a person, so the workflow signs in as an _app registration_, an identity for software +created in Microsoft Entra ID, using a _client secret_ in place of a password. + +| Name | Contents | Stored As | +|---|---|---| +| `AZURE_TENANT_ID` | The directory (tenant) ID of the app registration. | Secret | +| `AZURE_CLIENT_ID` | The application (client) ID of the app registration. | Secret | +| `AZURE_CLIENT_SECRET` | A client secret of the app registration. | Secret | +| `AZURE_SIGNING_ENDPOINT` | The endpoint of the signing account's region. | Variable | +| `AZURE_SIGNING_ACCOUNT` | The name of the Artifact Signing account. | Variable | +| `AZURE_CERTIFICATE_PROFILE` | The name of the certificate profile. | Variable | + +The last three only name Azure resources, so they are stored as repository variables, on the +Variables tab next to Secrets. Unlike secrets, variables can be read back. + +### Choosing a Service + +Windows accepts certificates from many providers, which differ in cost, in whose name is shown as the +publisher, and in how well they suit an automated build. + +| Option | Cost | Publisher Shown | Limitations | +|---|---|---|---| +| [Azure Artifact Signing](https://learn.microsoft.com/azure/artifact-signing/) | About US$10 per month | The validated organization or individual | Organizations in certain countries, and individuals in the US and Canada. | +| [Microsoft Store](https://blogs.windows.com/windowsdeveloper/2025/09/10/free-developer-registration-for-individual-developers-on-microsoft-store/) | Free for individuals | The developer | Signs only the copies installed from the Store. | +| [SignPath Foundation](https://signpath.org) | Free for open-source projects | SignPath Foundation | Excludes HARP (see below). | +| Certificate authority | Annual fee | The validated organization or individual | The private key has to be kept on a hardware token or cloud HSM, which is hard to use from a workflow. | + +The workflow uses Artifact Signing, which is the cheapest option that signs the packages it +publishes. Like an Apple Developer Program membership, it can be set up for an individual or an +organization, which decides whose name the certificate carries. Either way, it requires a paid Azure +subscription, since free, trial, and sponsored subscriptions are not supported. + +**Individual validation is the quickest to set up.** A developer in the US or Canada verifies their +government-issued ID with Microsoft Authenticator, and the certificate names them. The Azure billing +account has to be of type Individual, with a legal name and address that match the ID. + +**Organization validation outlives any one person's involvement.** The certificate carries the +organization's legal name, and other people can be given access to the Azure resources. The +requirements are stricter (see +[Microsoft's quickstart](https://learn.microsoft.com/azure/artifact-signing/quickstart)): + +- The organization must be a legal business entity in the US, Canada, the EU, the UK, Australia, New + Zealand, Japan, South Korea, Singapore, Switzerland, Norway, or Israel. +- It needs a website, a business identifier, and a primary email address on its own domain, which + cannot be a distribution list. +- Validation takes 1 to 20 business days and may require further documents. A representative of the + organization also verifies their own ID. + +**There is no free way to sign the packages the workflow publishes:** + +- **The Microsoft Store** has been free for individual developers since September 2025, after an ID + check, and signs apps packaged as MSIX itself. It also gives users one-click installation and + automatic updates. However, only users who install from the Store benefit, and the workflow would + have to build an MSIX package and submit each release for review. An MSIX app is installed into a + protected folder, so a DAW that launches HARP by its path would have to use the app execution + alias the Store creates instead. +- **SignPath Foundation** signs open-source projects for free, with a certificate issued to SignPath + Foundation, a manual approval for every release, and a code signing policy on the project's home + page. Its [terms](https://signpath.org/terms) exclude projects with any component under commercial + dual licensing, which includes JUCE (AGPLv3 or commercial). + +### Setting Up + +These steps are the same for a first setup and a handoff to a different individual or organization. + +1. Create an [Azure](https://portal.azure.com) account with a pay-as-you-go subscription. For an + individual certificate, check that the billing account's legal name and address match a + government-issued ID. +2. Under Subscriptions → (the subscription) → Settings → Resource providers, select + `Microsoft.CodeSigning` and choose **Register**. +3. Search the portal for **Artifact Signing Accounts** and choose **Create**. Create a new resource + group, choose an account name and region (_e.g._ East US), and select the Basic pricing tier. + Note the + [endpoint of the region](https://learn.microsoft.com/azure/artifact-signing/quickstart#azure-regions-that-support-artifact-signing) + (_e.g._ `https://eus.codesigning.azure.net/` for East US). +4. In the new account, open Access control (IAM) and assign yourself the **Artifact Signing Identity + Verifier** role. +5. Under Identity validations, choose **New Identity** → **Public**, as either an organization or an + individual, fill in the form, and follow the instructions sent by email. +6. Once the validation is complete, go to Certificate profiles → **Create** → **Public Trust**, + enter a profile name, and select the validated identity. +7. Create credentials for the workflow under Microsoft Entra ID → App registrations → **New + registration**. Note the Application (client) ID and Directory (tenant) ID, then create a client + secret under Certificates & secrets and copy its value, which is shown only once. +8. In the signing account's Access control (IAM), assign the **Artifact Signing Certificate Profile + Signer** role to the app registration. +9. Set the three secrets and three variables listed under [Artifact Signing](#artifact-signing). +10. Push a commit, download the Windows package from the workflow run, and check `HARP.exe` in + PowerShell: + + ```powershell + Get-AuthenticodeSignature HARP.exe + ``` + + The status should be `Valid`. The signature is also listed under Properties → Digital + Signatures. +11. Delete any copy of the client secret. It is only needed again to restore `AZURE_CLIENT_SECRET`, + and a new one can be created at any time. + +### Renewing and Handing Off + +**Certificates are valid for only three days**, and Artifact Signing renews them automatically. The +workflow timestamps each signature, which keeps it valid after its certificate expires, so releases +keep working. + +**Identity validations expire.** Microsoft sends reminders starting 60 days ahead of the expiration +date. An expired validation stops certificate renewal, and with it signing, until a new identity +validation is associated with the certificate profile. + +**Client secrets expire, after at most 24 months.** Create a new one under Certificates & secrets +and replace `AZURE_CLIENT_SECRET` before the old one expires. + +**Deleting resources does not affect existing releases.** Deleting the signing account or a +certificate profile stops new signing, but files that were already signed keep valid signatures. + +When signing passes to a new maintainer: + +- **Within the same Azure subscription**, give the new maintainer the Owner role on the resource + group under Access control (IAM). The app registration, secrets, and variables can stay as they + are. +- **Between subscriptions**, the new maintainer recreates every resource and replaces all six + secrets and variables, since Artifact Signing resources cannot be moved to another subscription, + tenant, or resource group. + +Either way: + +- **The certificate names whoever passed identity validation.** Signing as a different individual or + organization requires a new identity validation and certificate profile, after which + `AZURE_CERTIFICATE_PROFILE` is updated. +- **Someone has to keep paying for the subscription**, so whoever does should know that it is in + use. + +### Troubleshooting + +- **New Identity is unavailable:** the signed-in user does not have the Artifact Signing Identity + Verifier role (step 4 of [Setting Up](#setting-up-1)). +- **The verification email never arrived, or its link expired:** the address has to accept external + email containing links. The link expires after seven days, and a new identity validation request + is then needed. +- **`You don't have permission to access this page` during individual validation:** sign in with the + email address given in the identity validation request. +- **Signing fails with `401`:** the workflow could not sign in to Azure. Check the tenant and client + IDs, and whether the client secret has expired. +- **Signing fails with `403`:** the app registration does not have the Artifact Signing Certificate + Profile Signer role, the account or profile name is wrong, the endpoint does not match the + account's region, or the identity validation or certificate profile is not active. +- **SmartScreen still warns about a signed release:** SmartScreen judges each file by its own + download history, so the warning stops once a release has been downloaded enough. A file that + keeps triggering it can be submitted to [Microsoft Security Intelligence](https://www.microsoft.com/wdsi) + for review. diff --git a/model_validation/README.md b/model_validation/README.md index ae26a2c0..c0987784 100644 --- a/model_validation/README.md +++ b/model_validation/README.md @@ -9,10 +9,11 @@ interactions the HARP client performs, driven headlessly. Two tiers share the same test harness: -| Tier | Command | What it validates | What a failure means | -|---|---|---|---| -| **Examples** | `--local-examples` | The apps under `pyharp/examples/`, launched locally | pyharp or gradio itself is broken | -| **Spaces** | (default) | Every Hugging Face Space under `--org` | That specific deployment is broken (build error, HF platform change, dependency drift, ...) | +- **Examples** (`--local-examples`) launches the apps under `pyharp/examples/` + locally. A failure means pyharp or gradio itself is broken. +- **Spaces** (the default) validates every Hugging Face Space under `--org`. A + failure means that deployment is broken, by a build error, a platform change + or dependency drift, for example. Key behaviors: @@ -24,37 +25,14 @@ Key behaviors: `--no-restart-failed` to disable the restart. This is also what allows a run to work with a read-only token. - **ZeroGPU time is tracked** across the run and reported after each ZeroGPU - model. Pass `--skip-zerogpu` to skip these models and avoid spending allowance. + model, which includes a frontend on other hardware that declares the + `zerogpu` tag for the backend to which it forwards. Pass `--skip-zerogpu` to skip + these models and avoid spending allowance. - A daily GitHub Action ([model_validation.yml](../.github/workflows/model_validation.yml)) runs both tiers. See [CI Behavior](#ci-behavior) for what it reports and for when a run turns red. -## Code Layout - -The modules under `src/` fall into five groups. Dependencies only ever run -downward, so a module can be read knowing nothing about the groups above it: - -| Group | File | Purpose | -|---|---|---| -| **Entry point** | [src/validate_models.py](src/validate_models.py) | Command line, per-tier orchestration, console output | -| **Tier drivers** | [src/spaces.py](src/spaces.py) | Gets a Space ready: runtime stage, restart, wake, ZeroGPU safeguards | -| | [src/examples.py](src/examples.py) | Launches a local pyharp app and captures its log | -| **Harness** | [src/harness.py](src/harness.py) | Drives /controls + /process against whatever the drivers hand it | -| **Checking** | [src/cases.py](src/cases.py) | Input synthesis, test-case overlay, output validation and inspection | -| | [src/expectations.py](src/expectations.py) | The declarative `expect` rule vocabulary and its checks | -| | [src/validators.py](src/validators.py) | Registry of custom output validators (to be extended) | -| **Support** | [src/assets.py](src/assets.py) | Synthesizes the WAV/MIDI/text/JSON test inputs | -| | [src/audio.py](src/audio.py) | Audio decoding (any libsndfile format) for output checks | -| | [src/midi.py](src/midi.py) | MIDI parsing (via mido) for output checks | -| | [src/quota.py](src/quota.py) | ZeroGPU hardware detection and usage tracking | -| | [src/results.py](src/results.py) | Result records and JSON/markdown report generation | -| | [src/utils.py](src/utils.py) | Token handling, error rendering, config, name qualification, timeouts | - -Alongside the code, [config.yml](config.yml) holds the validation -configuration and [test_data/](test_data/) holds any real input files the -test cases reference. Everything a run produces goes under `reports/`. - ## Token Setup ZeroGPU spaces require an authenticated request to obtain GPU quota, and @@ -256,7 +234,7 @@ An infrastructure fault is retried `TRANSIENT_RETRY_LIMIT` times at a short interval instead. Currently covered are a read timeout, a GPU host ECC error, and a server disconnect. None of these has an expected duration to wait out, which is why they are bounded by a retry count rather than by a deadline, and -the retry usually surfaces the model's real behaviour. +the retry usually surfaces the model's real behavior. This list is `TRANSIENT_MARKERS` in [src/harness.py](src/harness.py), and it is deliberately narrow. A retried `/process` call reserves ZeroGPU allowance @@ -270,9 +248,10 @@ on a retry. Every model automatically gets one **`default`** test case, even with no configuration at all. Its inputs are synthesized from the model's `/controls` -spec. Audio tracks get a sine-sweep clip, MIDI tracks get a short MIDI file, -and sliders, toggles, dropdowns, number boxes, and text boxes get the default -value each one declares. A control that declares no default falls back to +spec. Audio tracks get a clip cut from a recording, MIDI tracks get a MIDI file +(both from [test_data/](test_data/), see below), and sliders, toggles, +dropdowns, number boxes, and text boxes get the default value each one +declares. A control that declares no default falls back to something valid for its type, such as its minimum or its first dropdown choice, so every model can be exercised without configuration. @@ -282,25 +261,41 @@ inputs, or inspecting outputs more deeply. ### Synthesized input properties -The generated inputs default to a 2-second mono 44.1 kHz WAV and a two-note -MIDI file. A `synthesized_inputs` block departs from those defaults. It can be -set at the top level of [config.yml](config.yml), per model under its -`overrides` entry, or per test case, with each level overriding the one before -it. Every property is optional, so a block sets only what it changes. +Without configuration, the generated inputs are a 2-second mono 44.1 kHz sine +sweep and a two-note MIDI file. A `synthesized_inputs` block changes them, at +the top level of [config.yml](config.yml), per model under `overrides`, or per +test case. Each level overrides the one before it, and a block sets only the +properties it names. + +A `source` replaces the sweep or the notes with real material. An audio source +is resampled to `sample_rate`, averaged or copied to `num_channels`, and cut or +repeated to `duration`. A MIDI source is passed as it is. A sweep only shows +that a model returns something. Real material shows whether it did its job, +since a separator then has sources to separate and a transcriber notes to find. + +The shipped config gives every model eight seconds of a band (drums, bass, +piano and a lead) under a speaking voice, at 48 kHz in stereo, and a monophonic +melody for MIDI. Speech models take English speech in mono at 44.1 kHz instead, +so both shapes of input are exercised. A model that returns its input, +processed or not, asserts the input's sample rate and channel count. Few models +run at 48 kHz stereo, so one that writes at its own rate or in mono fails. The +assets, made for this suite, are described at the top of config.yml. ```yaml # config.yml, at the top level, so it applies to every model synthesized_inputs: audio: + source: test_data/test_piano_20s.wav # default: none, a sine sweep sample_rate: 48000 # default 44100 num_channels: 2 # default 1 duration: 5.0 # default 2.0 ext: .flac # default .wav midi: - num_notes: 8 # default 2 - note_duration: 0.25 # default 0.5 - instrument: 24 # default 0 - channel: 0 # default 0 + source: test_data/test_melody_16s.mid # default: none, synthesized notes + num_notes: 8 # default 2, ignored with a source + note_duration: 0.25 # default 0.5, ignored with a source + instrument: 24 # default 0, ignored with a source + channel: 0 # default 0, ignored with a source ``` The MIDI `instrument` is the General MIDI program number, written as a program @@ -344,7 +339,9 @@ file input it is a preference instead, since the component's declared component accepting only other formats is given one it accepts, and one whose accepted types cannot be synthesized at all, such as a bespoke binary format, has its case skipped with a message naming the input. When a specific input -needs a specific file, name it in a test case's `files` entry. +needs a specific file, name it in a test case's `files` entry. A model whose +every case is skipped reports `SKIP` rather than `PASS`, since `/process` was +never called. A file for each distinct set of properties is generated once and reused for the rest of the run. @@ -390,6 +387,17 @@ overrides: Note: once `test_cases` is present, **only** the listed cases run. Include `- name: default` to keep the default synthesized case. +An input the model marks as optional is left empty rather than synthesized, +since that is what a request without it sends. A case that needs one, such as +a reference voice or a melody to follow, names it in `files`. + +A model with several modes gets a case for each mode that runs different code +or loads different weights, since one mode can break while the others work. A +mode that changes only a number on the same path is covered by the default +case. Every case is a `/process` call, so on ZeroGPU each extra case is +weighed against the time and allowance a run spends (see +[CI Behavior](#ci-behavior)). + ### Step 3: check the outputs (optional) Every case already gets structural checks for free. `/process` must not error, @@ -414,7 +422,7 @@ with no rules is still subject to the default structural checks: controls: "Pitch Shift (semitones)": 24 expect: - "Output Audio": + "Shifted Audio": ext: .wav # extension: a string, or a list of accepted ones min_bytes: 10000 # minimum file size num_channels: 1 # exact channel count (1 = mono, 2 = stereo) @@ -437,11 +445,14 @@ The following is the full vocabulary along with which output types each rule cov | `sample_rate` | audio output | Exact sample rate in Hz | | `bit_depth` | audio output | Exact PCM bit depth (16, 24, ...), and errors on compressed formats such as MP3 or OGG | | `min_rms_db` | audio output | RMS level is at least this many dBFS | +| `max_rms_db` | audio output | RMS level is at most this many dBFS | | `min_duration` / `max_duration` | audio or MIDI output | Length in seconds is within bounds | | `min_notes` | MIDI output | At least this many note-on events | | `note_instruments` | MIDI output | Notes use only these General MIDI program numbers (one, or a list of accepted) | | `note_channels` | MIDI output | Notes use only these channels (one, or a list of accepted) | | `min_labels` | JSON (`LabelList`) output | At least this many labels returned | +| `min_length` | text file output | At least this many characters in the file, ignoring surrounding whitespace | +| `excludes` | text file output | The text does not contain any of these (one string, or a list), matched case-insensitively | `note_instruments` and `note_channels` assert coverage rather than presence. Every instrument or channel the notes carry has to be one the rule lists, and @@ -451,10 +462,9 @@ because a MIDI file has no single instrument or channel of its own, only notes that each use one. **Targeting outputs.** A model with several outputs gets one block per output -label, each checked independently. Use `"*"` instead of a label to apply rules -to every output a rule covers. With mixed outputs, `"*"` sends `min_bytes` to -all file outputs, `min_rms_db` only to the audio ones, and `min_notes` only to -the MIDI ones: +label. Use `"*"` instead of a label to apply rules to every output a rule +covers. With mixed outputs, `"*"` sends `min_bytes` to all file outputs, +`min_rms_db` only to the audio ones, and `min_notes` only to the MIDI ones: ```yaml expect: @@ -462,6 +472,11 @@ the MIDI ones: min_bytes: 1000 # every file output must be non-trivial ``` +A rule under an output's own label replaces the same rule under `"*"` for that +output, whichever key comes first. `"*"` can therefore set a rule for every +output while a label makes the exception for one, and the other rules under +`"*"` still reach it. + **Mistakes on a named output are surfaced as configuration mistakes, not as model faults.** An unrecognized rule name, an unknown output label, or a rule aimed at a named output whose type it does not cover (_e.g._ `min_labels` on an audio @@ -480,12 +495,31 @@ program material is above `-40`. RMS is also more robust than a peak measurement, since a single stray click cannot make an otherwise-silent file pass. `min_rms_db: -60` is the usual check for silence. +`max_rms_db` is the converse, for an output that should be near-silent. A +separator's stem for a source its input does not contain is one use-case: it shows +that the model separated rather than copying its input into every stem. +Assert one only on a source the input truly lacks, measured, since a model can +also route part of a source to a neighboring stem. An output given a +`max_rms_db` usually needs `min_rms_db: -.inf` as well, to lift the common +silence floor. + **On `min_labels`:** `min_labels: 0` is meaningful, and is the right rule when a model may legitimately return no labels. It asserts that a well-formed `LabelList` came back while permitting it to be empty. Omitting the rule entirely is weaker, because a missing or `None` label output also passes. Labels are optional in pyharp. +**On text outputs:** a model returning text writes it to a +`gr.File(file_types=[".txt"])`, which pyharp reports as a generic file, since +it renders a `gr.Textbox` as an input control and refuses it as an output. The +structural checks require that file to exist and be non-empty, which a file +holding only whitespace satisfies, so `min_length: 1` is what asserts the +model actually said something. `excludes` covers the deployment that reports +its own failures as text instead of raising `gr.Error`, so that the result +arrives as a success and only its content distinguishes it from one. Neither +rule matches a pattern: a check on what the text *says* belongs in a +validator. + **On audio formats:** audio outputs are decoded with [soundfile](https://python-soundfile.readthedocs.io/), a listed dependency, so audio rules work on any libsndfile format. That covers WAV, FLAC, OGG, MP3, @@ -515,11 +549,10 @@ test case's own fields. A case may run several: A validator is a function taking three arguments: -| Argument | What it holds | -|---|---| -| `outputs` | Each output label mapped to its value. File outputs are local filesystem paths, and JSON outputs are the decoded object. | -| `controls` | The full `/controls` payload (card, inputs, outputs). | -| `params` | The settings nested under this validator's name in the test case. | +- `outputs`: each output label mapped to its value. A file output is a local + path, and a JSON output is the decoded object. +- `controls`: the full `/controls` payload (card, inputs, outputs). +- `params`: the settings nested under this validator's name in the test case. To report a problem, a validator raises `AssertionError`. The message should name the output at fault and say what was wrong with it. To opt out on a model @@ -532,6 +565,32 @@ label falls inside the audio output's timespan, a relationship between two outputs that no single-output rule can express, and it opts out when the model has no audio output or no label output. +Five validators ship with the harness: + +- `audio_decodes`: every audio output decodes and carries frames. Skips a model + that returns no audio. +- `labels_reach_harp`: every JSON output is a pyharp `LabelList`, which HARP + requires, and the model has an audio, MIDI or file output on which the labels + can appear. Skips a model with no JSON output. +- `labels_within_audio`: every label falls inside the audio output's timespan, + within `tolerance`. Skips a model with no audio output or no `LabelList`. +- `merit_scores`: MERIT's three cosine similarities are finite and within + [-1, 1]. Never skips. +- `muq_ranking`: MuQ returns `count` candidates, numbered and sorted by + similarity. Never skips. + +The first three are model-agnostic and run as +[common test cases](#step-4-reuse-a-case-across-models). The last two name +MERIT's and MuQ's own outputs, so they are referenced from those models' +entries. Run the local regression checks for all of them, plus the `expect` +rules and config.yml itself, with: + +```bash +python -m unittest discover -s model_validation/tests -v +``` + +These need no token and no network. + Add custom validators to [validators.py](src/validators.py), registered with the `@validator` decorator under the name test cases will reference: @@ -556,8 +615,16 @@ putting it in a one-off validator. A case under a model's `test_cases` only applies to that model. For a check that should hold for *every* model, such as "no file output is empty" or "audio is never silent", add a `common_test_cases` entry at the top level of -config.yml instead. Common cases run on every model in addition to any model-specific tests, -and their names are shown in the report prefixed with `common:`. +config.yml instead. + +Every case costs a `/process` call, which on ZeroGPU reserves allowance, so a +common case that only checks outputs does not run on its own. Its `expect` +rules and `validators` are merged into each of the model's own cases, and the +model's own settings win where the two conflict. A common case that sends +inputs of its own (`controls`, `files`, or `synthesized_inputs`) is a +different scenario, so it runs separately, named with a `common:` prefix in +the report. It takes whatever inputs the model's first case sends and it does +not set, so a model that needs particular files still gets them. ```yaml common_test_cases: @@ -571,6 +638,23 @@ common_test_cases: min_rms_db: -60 # applies only to models that have audio outputs ``` +Four common cases ship in [config.yml](config.yml), so that a model with no +entry of its own is still asserted against rather than only run. +`audio-decodes` decodes every audio output, which is what catches a +header-only WAV from a model that wrote no samples and reported success, +`labels-within-audio` keeps labels inside the track they annotate, +`labels-reach-harp` fails a JSON output HARP would reject, and +`outputs-carry-signal` fails digital silence and MIDI with no notes. + +The silence floor is set at -80 dBFS on purpose, well below any real level, to +catch digital silence without judging loudness. A real level is asserted per +model instead, where producing signal is part of the contract. A separator's +stem for a source its input does not contain is legitimately quiet, at times +below even this floor, so such a stem lifts the floor by name and asserts its +silence with `max_rms_db` (see above). The same test applies to anything +proposed as a common case: a rule that holds for most models reads as a +failure on the rest. + Keep common cases model-agnostic by targeting outputs with `"*"` rather than a specific label, which will not exist on most models. Because a `"*"` rule is skipped when it matches no compatible output, `audio-not-silent` above checks @@ -608,13 +692,28 @@ A key outside these sets is reported as a configuration error, so a slip such as caught before any model runs, instead of leaving the model on its default case while the file appears to say otherwise. +A model written twice is reported the same way. YAML keeps the last of +several entries sharing a key and discards the rest in silence, so a model +configured in two places would run with only the settings written under the +second entry. + ## CI Behavior - **Schedule:** daily at 06:30 UTC. It can also be run manually from the Actions tab, with options to validate specific spaces, skip inference, or disable the default restart of crashed spaces. - **Reports:** a markdown summary on each run, plus JSON and markdown - artifacts. + artifacts. A failed Space's entry carries the end of its run log, where the + traceback behind the failure usually is. +- **Duration:** approximately 25 minutes for the whole fleet, most of it the + ZeroGPU calls, which run one at a time while the CPU models run alongside + them. Those calls are also what a change to the cases mostly moves. A + successful call is billed for its GPU time, which is less than its wall + time, against the daily allowance of a PRO or team account. The token CI uses + therefore needs that tier, and little other ZeroGPU use on the same day. +- **Harness tests:** the examples job first runs the unit tests under + `tests/`, which also check config.yml, so a configuration mistake turns the + run red before any model runs. - **Failure signal:** model failures keep the run green, because deployments are not yet stable enough for daily failure emails to be useful. They show up as warning annotations and in the run summary and report instead. No diff --git a/model_validation/config.yml b/model_validation/config.yml index ccde4b2a..7cbaaf89 100644 --- a/model_validation/config.yml +++ b/model_validation/config.yml @@ -64,6 +64,19 @@ # channel: 0 # MIDI channel, 0 to 15 (9 = drums) # ext: .mid # +# Either kind also takes a `source`, a path relative to this file. An audio +# source is the recording from which the clip is cut: it is resampled to +# sample_rate, averaged or copied to num_channels, and cut to duration or +# repeated to reach it, so the clip has the properties above with the +# recording's content. A MIDI source is a file passed as it is, and the +# properties that describe the synthesized notes do not apply to it. A source +# set at one level carries down to the levels below it, as every property +# does, so set `source: null` to return a model or case to the sweep or the +# synthesized notes. +# +# audio: +# source: test_data/test_piano_20s.wav +# # This block can also be set per model and per test case, with each level # overriding the one before it. # @@ -80,13 +93,18 @@ # # common_test_cases: # cases run on EVERY model, in addition to its own # # cases. Keep these model-agnostic by targeting -# # outputs with "*" rather than by label. Names -# # appear in the report prefixed with "common:" +# # outputs with "*" rather than by label, or with a +# # validator that skips the models it does not fit. +# # Names appear in the report prefixed with "common:" # - name: outputs-nontrivial # expect: # "*": # min_bytes: 100 # +# A rule written here reaches every model, so it has to be true of every +# model. One that is true of most reads as a failure on the rest. See the +# note above `overrides` below for the case this rules out. +# # # ---- Per-model settings (`overrides`) -------------------------------------- # @@ -133,7 +151,8 @@ # # expect: # output checks, keyed by output label. # # "*" applies each rule to every output -# # the rule covers +# # the rule covers, and a rule under a +# # label replaces it for that output # "Output Audio": # ext: .wav # extension: one, or a list of accepted # min_bytes: 1000 # minimum file size, in bytes @@ -141,6 +160,9 @@ # sample_rate: 44100 # exact sample rate, in Hz # bit_depth: 16 # exact PCM bit depth (PCM formats only) # min_rms_db: -60 # minimum RMS level, in dBFS +# max_rms_db: -60 # maximum RMS level, in dBFS, e.g. for a +# # separator's stem whose source the input +# # lacks # min_duration: 1.5 # minimum length, in seconds # max_duration: 10.0 # maximum length, in seconds # "Output Midi": @@ -151,6 +173,11 @@ # min_labels: 1 # minimum labels in a pyharp LabelList. 0 # # permits an empty list but still requires # # a well-formed one +# "Output Text": # a .txt file, i.e. gr.File(file_types=[".txt"]) +# min_length: 1 # minimum characters in the file, ignoring +# # surrounding whitespace +# excludes: "Error" # text that must not appear: one string, or +# # a list of them, matched case-insensitively # # validators: # only for checks `expect` cannot express, # # such as ones spanning several outputs. @@ -165,10 +192,11 @@ # # ext, min_bytes any file output # num_channels, sample_rate, bit_depth, -# min_rms_db audio outputs +# min_rms_db, max_rms_db audio outputs # min_duration, max_duration audio and MIDI outputs # min_notes, note_instruments, note_channels MIDI outputs # min_labels JSON (LabelList) outputs +# min_length, excludes text file outputs # # Applying a rule to a NAMED output whose type it does not cover fails the # case with a message indicating the configuration mistake, rather than reading @@ -192,7 +220,7 @@ # controls: # "Pitch Shift (semitones)": 12 # expect: -# "Output Audio": +# "Shifted Audio": # ext: .wav # min_rms_db: -60 # shifted audio must still carry signal # @@ -212,10 +240,1638 @@ # # -exclude: [] +# Withdrawn because each one's upstream carries no license, so it cannot be +# deployed (see docs/DEPLOYMENT.md). The Spaces are private, but a token that +# can read them still discovers them, so they are named here rather than left +# to fail a run. Remove an entry once its license is established and the Space +# is published again. +exclude: + - teamup-tech/BUDDy + - teamup-tech/STAGE + - teamup-tech/improvnet include_extra: [] -common_test_cases: [] +# The inputs every model is given unless its entry below says otherwise. A sine +# sweep and two notes could only show that a model returns something. Real +# material shows whether it did its job, since a separator then has sources to +# separate and a transcriber notes to find, and the output checks below are +# written against it. The assets, all made for this suite: +# +# test_band_speech_16s.flac a band (drums, bass, piano and a lead) rendered +# from test_band_16s.mid by the MIDI-Synthesizer +# Space, under a voice reading test_speech_10s.flac +# test_speech_10s.flac English speech from the Kokoro Space: "Every +# morning, the small bakery on the corner opens at +# seven. The smell of fresh bread drifts down the +# street, and a short line forms outside the door." +# test_band_16s.mid the band, with its drums on channel 9 +# test_drums_16s.flac the band's drums alone (kick, snare and closed +# hi-hat), rendered by the MIDI-Synthesizer Space +# test_melody_16s.mid the band's lead line alone, on one track +# +# The audio is cut to 8 seconds and sent at 48 kHz in stereo, a format few +# models run at, so that one returning its result at the model's own rate or in +# mono fails the format assertions below (see &as-given). Speech models below +# take test_speech_10s.flac as mono 44.1 kHz instead, so both shapes of input +# are exercised across the fleet. +synthesized_inputs: + audio: + source: test_data/test_band_speech_16s.flac + sample_rate: 48000 + num_channels: 2 + duration: 8.0 + midi: + source: test_data/test_melody_16s.mid + +# Checks that hold for every deployment, so that a model with no entry under +# `overrides` is still asserted against rather than only being run. Each is +# written to apply to whatever outputs a model has, and to skip models it does +# not fit (see "Keep common cases model-agnostic" in README.md). None sends an +# input of its own, so all of them fold into each model's cases and cost no +# /process call of their own. +common_test_cases: + + # A file that exists and is not empty is already required. Decoding is what + # catches a header-only WAV, which is what a model that wrote no samples and + # reported success returns. + - name: audio-decodes + validators: + audio_decodes: {} + + # A label outside the audio it annotates draws off the end of the track in + # HARP, so it is wrong however well the model performed. + - name: labels-within-audio + validators: + labels_within_audio: {} + + # HARP reads a JSON output as a pyharp LabelList and fails the whole request + # on anything else, and it draws labels only over an audio, MIDI or file + # output. A model returning a plain dictionary passes every other check here + # and shows an error in HARP after every request. + - name: labels-reach-harp + validators: + labels_reach_harp: {} + + # A decodable file can still hold nothing but zeros, which is what a model + # that ran, wrote silence and reported success returns. The floor is set low + # on purpose, to catch digital silence without judging loudness. A real + # level is asserted per model below, where producing signal is part of the + # contract. A separator's stem for a source its input lacks can sit below + # even this floor, and the note above `overrides` covers that case. + # + # min_notes reaches MIDI outputs by the same block: the `*` key applies each + # rule only to the output types it covers, and drops it where none match. + - name: outputs-carry-signal + expect: + "*": + min_rms_db: -80 + min_notes: 1 + +# Keyed by qualified model name and ordered by it, so that one model's +# settings are in one place. A model written twice is reported rather than +# resolved, since the entry read would otherwise be the last one alone. +# +# Four blocks recur, and are written once as YAML anchors where they first +# appear and referenced after that: +# +# &as-given an audio-to-audio model returns the audio it was given: as +# long, at the same sample rate and channel count, and with +# signal in it. A truncated or padded result fails, and so +# does one written at the model's own rate. First used by +# Apollo. +# &speech a model built for speech takes the speech asset, in mono at +# 44.1 kHz. First used by AudioSeal. +# &as-given-speech the same as &as-given, for a model that takes the speech +# asset. First used by DeepFilterNet. +# &speaks a model that synthesizes speech returns a phrase of it, with +# signal in it. First used by Bark. +# +# Any other output that returns its input, processed or as it was, asserts the +# input's sample rate and channel count in its own block. Generators are not +# held to them, since nothing arrived for them to keep. +# +# Separators follow one rule, since what a stem holds depends on the input. A +# stem is asserted to carry signal when the input holds its source and every +# variant of the model clears that assertion comfortably. A stem whose +# source the input lacks is near silent from a working model, at times below +# the common floor, so it lifts that floor and asserts the silence instead +# (`max_rms_db`). That shows the model separated its input rather than copied +# it into every stem. Any other stem keeps the common floor alone. +# +# A model with several modes has a case per mode where the modes run +# different code or load different weights, since one can break while the +# others work. The cases are budgeted, ZeroGPU ones above all, so a mode that +# only changes a number in the same code path is left to the default case. +overrides: + + teamup-tech/ACE-Step: + # Audio2Audio starts from a reference recording, which is optional and so + # is given here. The LoRA loads weights of its own. + test_cases: + - name: default + controls: + # The Textbox declares no default, which would otherwise be sent the + # harness's "test" placeholder. Empty is what the pipeline reads as + # "no override". + "OSS Steps": "" + expect: &ace-step + "Generated Music": + min_duration: 10.0 + min_rms_db: -45 + + - name: audio2audio + controls: + "OSS Steps": "" + "Enable Audio2Audio": true + files: + "Reference Audio (for Audio2Audio)": test_data/test_band_speech_16s.flac + expect: *ace-step + + - name: lora-rap + controls: + "OSS Steps": "" + "Lora Name or Path": "ACE-Step/ACE-Step-v1-chinese-rap-LoRA" + expect: *ace-step + + teamup-tech/Anticipatory-Music-Transformer: + # prompt_1.mid is a four-part vamp under a Voice Oohs melody that runs on + # past it, the shape the model is built for. A working run writes + # accompaniment after the vamp ends, so the output holds far more notes + # than it was given. A run that returns its input unchanged, which is what + # a melody the Space failed to separate produced, does not. Each size has + # its own weights. + test_cases: + - name: accompany-prompt + controls: + "Melody Instrument": "53. Voice Oohs" + files: + "Input MIDI File": test_data/prompt_1.mid + expect: &amt + "With Accompaniment": + min_notes: 150 + min_duration: 18.0 + + - name: accompany-prompt-small + controls: + "Melody Instrument": "53. Voice Oohs" + "Select AMT Model (Faster vs. Higher Quality)": "stanford-crfm/music-small-800k" + files: + "Input MIDI File": test_data/prompt_1.mid + expect: *amt + + - name: accompany-prompt-large + controls: + "Melody Instrument": "53. Voice Oohs" + "Select AMT Model (Faster vs. Higher Quality)": "stanford-crfm/music-large-800k" + files: + "Input MIDI File": test_data/prompt_1.mid + expect: *amt + + teamup-tech/AnyAccomp: + # The Space accepts 3 to 30 seconds. The band's lead line is the part + # accompanied. + synthesized_inputs: + audio: + duration: 5.0 + test_cases: + - name: default + expect: + "*": + min_duration: 4.8 + max_duration: 5.2 + min_rms_db: -45 + + teamup-tech/Apollo: + # A recording longer than 30 seconds is restored in overlapping chunks, + # cross-faded where they meet, since the whole of a long one does not fit + # in GPU memory. + test_cases: + - name: default + expect: &as-given + "*": + min_duration: 7.8 + max_duration: 8.2 + min_rms_db: -45 + sample_rate: 48000 + num_channels: 2 + + - name: chunked + synthesized_inputs: + audio: + duration: 35.0 + expect: + "Restored Audio": + min_duration: 34.8 + max_duration: 35.2 + min_rms_db: -45 + sample_rate: 48000 + num_channels: 2 + + teamup-tech/Audio-Inpainting-Diffusion: + # The default checkpoint is trained on solo piano. The input is cut or + # padded to the checkpoint's own segment, whatever its length, and the two + # checkpoints' segments differ in length. + synthesized_inputs: + audio: + source: test_data/test_piano_20s.wav + test_cases: + - name: default + expect: + "Inpainted Audio": + min_duration: 8.0 + min_rms_db: -45 + sample_rate: 48000 + num_channels: 2 + + - name: musicnet + controls: + "Model": "MusicNet (mixed classical instrumentation)" + expect: + "Inpainted Audio": + min_duration: 4.0 + min_rms_db: -45 + sample_rate: 48000 + num_channels: 2 + + teamup-tech/AudioSR: + # The multiband ensemble is a separate path, run on CPU after the GPU + # call, so its case takes few steps to keep that call short. It always + # returns 48 kHz, its purpose, which the input already is. + test_cases: + - name: default + expect: *as-given + + - name: multiband + controls: + "Multiband Ensemble": true + "DDIM Steps": 10 + expect: *as-given + + teamup-tech/AudioSeal: + synthesized_inputs: &speech + audio: + source: test_data/test_speech_10s.flac + sample_rate: 44100 + num_channels: 1 + test_cases: + - name: default + expect: + "Watermarked Speech": + min_duration: 7.8 + max_duration: 8.2 + min_rms_db: -45 + sample_rate: 44100 + num_channels: 1 + + # Detection returns its input with a confidence label per segment + - name: detect + controls: + "Option": "Detect Watermark" + expect: + "Watermarked Speech": + min_duration: 7.8 + max_duration: 8.2 + min_rms_db: -45 + sample_rate: 44100 + num_channels: 1 + "Watermark Confidence": + min_labels: 1 + + teamup-tech/AudioSep: + test_cases: + - name: default + controls: + "Text Query": "a woman speaking" + expect: *as-given + + teamup-tech/Audiobox-Aesthetics: + # Four scores, each a label spanning the clip, over the input returned as + # it was given. + test_cases: + - name: default + expect: + "Scored Audio": + min_duration: 7.8 + max_duration: 8.2 + sample_rate: 48000 + num_channels: 2 + "Aesthetic Scores": + min_labels: 4 + + teamup-tech/BABE-2: + # Restoration spends GPU time on every diffusion step of every segment, so + # the default steps on a long input are costly. A few steps on one short + # segment check the same path for a small fraction of that. Each singer is + # a checkpoint of its own, and each is given the melody rendered as a voice. + synthesized_inputs: + audio: + duration: 4.0 + test_cases: + - name: few-steps + controls: + "Processing Steps": 10 + expect: &babe-2 + "Restored Audio": + min_duration: 3.8 + max_duration: 4.2 + min_rms_db: -45 + sample_rate: 48000 + num_channels: 2 + + - name: singing-voice + controls: + "Processing Steps": 10 + "Voice / Instrument": "Singing Voice — Generic" + synthesized_inputs: + audio: + source: test_data/test_voice_16s.wav + expect: *babe-2 + + - name: singing-voice-caruso + controls: + "Processing Steps": 10 + "Voice / Instrument": "Singing Voice — Enrico Caruso" + synthesized_inputs: + audio: + source: test_data/test_voice_16s.wav + expect: *babe-2 + + - name: singing-voice-gigli + controls: + "Processing Steps": 10 + "Voice / Instrument": "Singing Voice — Beniamino Gigli" + synthesized_inputs: + audio: + source: test_data/test_voice_16s.wav + expect: *babe-2 + + - name: singing-voice-melba + controls: + "Processing Steps": 10 + "Voice / Instrument": "Singing Voice — Nellie Melba" + synthesized_inputs: + audio: + source: test_data/test_voice_16s.wav + expect: *babe-2 + + - name: singing-voice-patti + controls: + "Processing Steps": 10 + "Voice / Instrument": "Singing Voice — Adelina Patti" + synthesized_inputs: + audio: + source: test_data/test_voice_16s.wav + expect: *babe-2 + + teamup-tech/BACHI: + # The Space reports "No predictions generated." and "Error: Could not + # extract notes from the input file." in its text output rather than + # raising, so both arrive as a successful result and only the content of + # the output distinguishes them from one. Each model type loads its own + # checkpoint. + test_cases: + - name: default + expect: &bachi + "Chord Predictions": + min_length: 1 + excludes: + - "Error:" + - "No predictions generated." + + - name: pop + controls: + "Model Type": "Pop" + expect: *bachi + + teamup-tech/BS-RoFormer: + # Every stem comes back at once. The band holds every source but a guitar, + # so the guitar stem is asserted silent. + test_cases: + - name: default + expect: + "*": + min_duration: 7.8 + max_duration: 8.2 + sample_rate: 48000 + num_channels: 2 + "Vocals": + min_rms_db: -50 + "Drums": + min_rms_db: -50 + "Bass": + min_rms_db: -50 + "Piano": + min_rms_db: -50 + "Other": + min_rms_db: -50 + "Instrumental": + min_rms_db: -50 + "Guitar": + min_rms_db: -.inf + max_rms_db: -60 + + teamup-tech/Banquet: + # The mixture has to be at least 34 seconds, so the band is repeated past + # that, and the query is the piano the separation should find in it. + synthesized_inputs: + audio: + duration: 35.0 + test_cases: + - name: extract-piano + files: + "Query Example": test_data/test_piano_20s.wav + expect: + "Extracted Instrument": + min_duration: 34.0 + min_rms_db: -50 + sample_rate: 48000 + num_channels: 2 + + teamup-tech/Bark: + test_cases: + - name: default + controls: + "Text Prompt": "Hello! This is a short test of the voice, read aloud." + expect: &speaks + "*": + min_duration: 1.5 + min_rms_db: -45 + + teamup-tech/Beat-This: + # The band keeps a steady beat, so the clip holds comfortably more beats + # than are asserted. + test_cases: + - name: default + expect: + "Audio with Beats": + min_duration: 7.8 + max_duration: 8.2 + sample_rate: 48000 + num_channels: 2 + "Beats": + min_labels: 8 + + teamup-tech/BeatNet: + test_cases: + - name: default + expect: + "Audio with Beats": + min_duration: 7.8 + max_duration: 8.2 + sample_rate: 48000 + num_channels: 2 + "Beat Labels": + min_labels: 8 + + teamup-tech/BeatNet-Legacy: + test_cases: + - name: default + expect: + "Audio with Beats": + min_duration: 7.8 + max_duration: 8.2 + sample_rate: 48000 + num_channels: 2 + "Beats": + min_labels: 8 + + teamup-tech/CED-Tiny: + test_cases: + - name: default + expect: + "Audio Tags": + min_labels: 1 + + teamup-tech/Cacophony: + test_cases: + - name: default + expect: + "Generated Caption": + min_length: 10 + + teamup-tech/Chordino: + # The band plays a four-chord loop, so the clip holds several changes. + test_cases: + - name: default + expect: + "Chord Sequence": + min_labels: 2 + + teamup-tech/DDSP: + # Timbre transfer follows one monophonic line, and runs slowly on CPU + # hardware, so it takes a short excerpt of the melody rendered as a voice. + # Each instrument loads its own checkpoint. + synthesized_inputs: + audio: + source: test_data/test_voice_16s.wav + duration: 2.0 + test_cases: + - name: default + expect: &ddsp + "Timbre-transferred audio": + min_duration: 1.9 + max_duration: 2.1 + min_rms_db: -45 + sample_rate: 48000 + num_channels: 2 + + - name: flute + controls: + "Instrument": "Flute" + expect: *ddsp + + - name: flute2 + controls: + "Instrument": "Flute2" + expect: *ddsp + + - name: trumpet + controls: + "Instrument": "Trumpet" + expect: *ddsp + + - name: tenor-saxophone + controls: + "Instrument": "Tenor_Saxophone" + expect: *ddsp + + teamup-tech/DDSP-Guitar: + test_cases: + - name: default + controls: + "Max Duration (seconds)": "5" + expect: + "Synthesized Guitar": + min_duration: 4.5 + min_rms_db: -45 + + teamup-tech/DDSP-Piano: + test_cases: + - name: default + controls: + "Max Duration (seconds)": "5" + expect: + "Synthesized Piano": + min_duration: 4.5 + min_rms_db: -45 + + teamup-tech/DExter: + # The score input is a generic file restricted to MusicXML, which the + # harness has no way to synthesize. Without a real file every case would + # be skipped, and the model would pass having never run /process. A + # performance plays every note of the score. + test_cases: + - name: render-score + files: + "Piano MusicXML score": test_data/test_score.musicxml + expect: + "Expressive MIDI performance": + min_notes: 8 + + teamup-tech/De-limiter: + test_cases: + - name: default + expect: *as-given + + teamup-tech/DeepAFx-ST: + # The Space accepts 1 to 30 seconds. The model runs in mono at its own + # rate, and the result comes back at the input's rate, copied across its + # channels. + process_timeout: 150 + test_cases: + - name: default + expect: + "Styled Output": + ext: .wav + num_channels: 2 + sample_rate: 48000 + min_duration: 7.0 + max_duration: 9.0 + min_rms_db: -60 + + teamup-tech/DeepFilterNet: + synthesized_inputs: *speech + test_cases: + - name: default + expect: &as-given-speech + "*": + min_duration: 7.8 + max_duration: 8.2 + min_rms_db: -45 + sample_rate: 44100 + num_channels: 1 + + teamup-tech/Demucs: + # The band holds all four sources, but mdx_extra leaves its bass too quiet + # for the level asserted on the other stems, so the bass stem keeps the + # floor alone. Each model has its own weights, and mdx_extra_q, which is + # mdx_extra quantized, needs a dependency of its own (diffq). + test_cases: + - name: default + expect: &demucs + "*": + min_duration: 7.8 + max_duration: 8.2 + sample_rate: 48000 + num_channels: 2 + "Drums": + min_rms_db: -50 + "Vocals": + min_rms_db: -50 + "Other": + min_rms_db: -50 + + - name: mdx-extra + controls: + "Demucs Model": "mdx_extra" + expect: *demucs + + - name: mdx-extra-q + controls: + "Demucs Model": "mdx_extra_q" + expect: *demucs + + teamup-tech/Denoising-UNet: + synthesized_inputs: *speech + test_cases: + - name: default + expect: *as-given-speech + + teamup-tech/DiffRhythm2: + test_cases: + - name: default + expect: + "Song": + min_duration: 30.0 + min_rms_db: -45 + + teamup-tech/FAD-MoE: + # It detects spoofed speech. + synthesized_inputs: *speech + test_cases: + - name: default + expect: + "Detection Result": + min_length: 3 + + teamup-tech/FLowHigh: + # Raising the sample rate is its purpose, so the output is at the rate + # chosen, 48 kHz by default. That rate is a resampling step after the + # model, so one other rate covers it. Each channel is extended on its own, + # a path a mono input never takes. + synthesized_inputs: *speech + test_cases: + - name: default + expect: + "Enhanced Audio": + sample_rate: 48000 + num_channels: 1 + min_duration: 7.8 + max_duration: 8.2 + min_rms_db: -45 + + - name: out-96k + controls: + "Output Sample Rate": "96000" + expect: + "Enhanced Audio": + sample_rate: 96000 + num_channels: 1 + min_duration: 7.8 + max_duration: 8.2 + min_rms_db: -45 + + - name: stereo + synthesized_inputs: + audio: + num_channels: 2 + expect: + "Enhanced Audio": + sample_rate: 48000 + num_channels: 2 + min_duration: 7.8 + max_duration: 8.2 + min_rms_db: -45 + + teamup-tech/FST-AI-Music-Detection: + test_cases: + - name: default + expect: + "Detection Result": + min_length: 3 + + teamup-tech/FxNorm-Automix: + # Each stem is padded with wrapped audio for context at the start, so even + # a short input is a long inference. + process_timeout: 300 + test_cases: + - name: default + expect: + "Mix": + min_duration: 7.8 + max_duration: 8.2 + min_rms_db: -45 + sample_rate: 48000 + num_channels: 2 + + teamup-tech/GAME: + # Its input is labeled "Singing Voice", and it transcribes a monophonic + # sung line. test_voice_16s.wav is a monophonic melody rendered from a + # known MIDI, and the model recovers its notes. + test_cases: + - name: transcribe-voice + files: + "Singing Voice": test_data/test_voice_16s.wav + expect: + "Transcribed MIDI": + min_notes: 8 + + teamup-tech/HPSS: + test_cases: + - name: default + expect: *as-given + + teamup-tech/HTS-AT: + test_cases: + - name: default + expect: + "Audio with Sound Tags": + min_duration: 7.8 + max_duration: 8.2 + sample_rate: 48000 + num_channels: 2 + "Detected Sounds": + min_labels: 1 + + teamup-tech/IndexTTS-2: + # Both references take the speech asset. Given a sine sweep for them it + # returns almost nothing, which only an assertion on length catches. + synthesized_inputs: *speech + test_cases: + - name: default + controls: + "Text to Synthesize": "The weather is lovely today, so we are going for a walk in the park." + expect: *speaks + + teamup-tech/Instruct-MusicGen: + test_cases: + - name: default + expect: *as-given + + teamup-tech/Kokoro: + # A UK voice runs the British English pipeline, the US voices the American. + test_cases: + - name: default + controls: + "Text Input": "The weather is lovely today, so we are going for a walk in the park." + expect: *speaks + + - name: uk-voice + controls: + "Text Input": "The weather is lovely today, so we are going for a walk in the park." + "Voice": "[UK-Female] Emma" + expect: *speaks + + teamup-tech/LarsNet: + # It separates a drum mix into its pieces, so it takes the band's drums + # alone. The kit has no toms or cymbals, so the cymbals stem is asserted + # silent. The toms stem picks up the rest of the kit, so it keeps the floor + # alone. + synthesized_inputs: + audio: + source: test_data/test_drums_16s.flac + test_cases: + - name: default + expect: + "*": + min_duration: 7.8 + max_duration: 8.2 + sample_rate: 48000 + num_channels: 2 + "Kick": + min_rms_db: -50 + "Snare": + min_rms_db: -50 + "Hi-Hat": + min_rms_db: -50 + "Cymbals": + min_rms_db: -.inf + max_rms_db: -60 + + teamup-tech/LoopGen: + test_cases: + - name: default + controls: + "Description": "an upbeat funk groove with slap bass and tight drums" + expect: + "Loop": + min_duration: 14.0 + min_rms_db: -45 + + teamup-tech/MEGAMI: + # Both required tracks are synthesized, meeting the "at least 2 tracks" + # check, and each is longer than the minimum the model needs. + synthesized_inputs: + audio: + duration: 15.0 + test_cases: + - name: default + expect: + "MEGAMI Mix": + min_duration: 14.8 + max_duration: 15.2 + min_rms_db: -45 + sample_rate: 48000 + num_channels: 2 + + teamup-tech/MERIT: + # Both inputs are required to be between 5 and 10 seconds. + process_timeout: 150 + synthesized_inputs: + audio: + duration: 6.0 + test_cases: + - name: default + expect: + "Similarity Results": + ext: .json + validators: + merit_scores: {} + + teamup-tech/MIDI-DDSP: + test_cases: + - name: default + expect: + "Synthesized Audio": + min_duration: 8.0 + min_rms_db: -45 + + teamup-tech/MIDI-GPT: + # The Space needs at least 4 bars, which the melody exceeds. + process_timeout: 150 + test_cases: + - name: default + expect: + "Rewritten MIDI": + ext: .mid + min_notes: 1 + min_duration: 8.0 + + teamup-tech/MIDI-Synthesizer: + test_cases: + - name: default + expect: + "Rendered Audio": + min_duration: 15.0 + min_rms_db: -45 + + teamup-tech/MIDI-VAE: + # Reconstruction skips the style swap, and each style pair loads its own + # checkpoint. + test_cases: + - name: default + expect: &midi-vae + "Transformed MIDI": + min_notes: 5 + + - name: reconstruct + controls: + "Mode": "Reconstruct" + expect: *midi-vae + + - name: classical-pop + controls: + "Style Pair": "Classical ↔ Pop" + expect: *midi-vae + + - name: jazz-pop + controls: + "Style Pair": "Jazz ↔ Pop" + expect: *midi-vae + + - name: baroque-modern + controls: + "Style Pair": "Baroque ↔ Modern" + expect: *midi-vae + + teamup-tech/MMT: + # The seed is optional, which the harness leaves empty, so it is given here + # to have the model continue the melody rather than start from nothing. + test_cases: + - name: default + controls: + "Instruments": "piano, bass, drums" + files: + "Seed MIDI (optional)": test_data/test_melody_16s.mid + expect: + "Generated MIDI": + min_notes: 10 + "Audio Preview": + min_rms_db: -45 + + teamup-tech/Magenta-RealTime: + test_cases: + - name: default + expect: + "Generated Music": + min_duration: 3.8 + min_rms_db: -45 + + teamup-tech/Matchering: + # Mastering needs a target and a reference that differ, and one + # synthesized file would be both, which Matchering rejects outright ("The + # TARGET and REFERENCE files are the same"). The band is mastered to sound + # like the solo piano, and comes back at the full length of the file. + test_cases: + - name: default + files: &matchering + "Target Audio": test_data/test_band_speech_16s.flac + "Reference Audio": test_data/test_piano_20s.wav + expect: + "Mastered Audio": + bit_depth: 16 + min_duration: 15.8 + max_duration: 16.2 + min_rms_db: -45 + sample_rate: 48000 + num_channels: 2 + + - name: pcm24 + controls: + "Output Bit Depth": "pcm24" + files: *matchering + expect: + "Mastered Audio": + bit_depth: 24 + min_duration: 15.8 + max_duration: 16.2 + min_rms_db: -45 + sample_rate: 48000 + num_channels: 2 + + teamup-tech/MegaTTS3: + synthesized_inputs: *speech + test_cases: + - name: default + controls: + "Text to Generate": "The weather is lovely today, so we are going for a walk in the park." + expect: *speaks + + teamup-tech/Mel-Band-RoFormer: + test_cases: + - name: default + expect: + "*": + min_duration: 7.8 + max_duration: 8.2 + min_rms_db: -50 + sample_rate: 48000 + num_channels: 2 + + teamup-tech/MelodyFlow: + # Given a recording, which is optional, it edits that recording rather + # than generating from the text alone, and returns as much as it was given. + test_cases: + - name: default + controls: + "Input Text": "an upbeat electronic dance track with a catchy synth lead" + expect: + "*": + min_duration: 29.0 + min_rms_db: -45 + + - name: edit + controls: + "Input Text": "an upbeat electronic dance track with a catchy synth lead" + files: + "File or Microphone": test_data/test_band_speech_16s.flac + expect: + "*": + min_duration: 15.8 + max_duration: 16.2 + min_rms_db: -45 + sample_rate: 48000 + num_channels: 2 + + teamup-tech/MuQ-MuLan: + # The Space accepts 10 to 60 second clips and up to eight descriptions. + process_timeout: 300 + synthesized_inputs: + audio: + duration: 12.0 + test_cases: + - name: default + controls: + "Candidate Descriptions": "a pop song with drums, bass and piano under a speaking voice" + expect: + "Similarity Ranking": + ext: .json + validators: + muq_ranking: + count: 1 + + teamup-tech/MuScriptor: + # test_piano_20s.wav is piano rendered from a known MIDI by the + # MIDI-Synthesizer Space, so this measures the transcription rather than + # the input. Each size has its own weights. + test_cases: + - name: transcribe-piano + files: &muscriptor + "Input Audio": test_data/test_piano_20s.wav + expect: &muscriptor-notes + "Transcription": + min_notes: 20 + + - name: transcribe-piano-small + controls: + "Model Size": "small" + files: *muscriptor + expect: *muscriptor-notes + + - name: transcribe-piano-large + controls: + "Model Size": "large" + files: *muscriptor + expect: *muscriptor-notes + + teamup-tech/MuseMorphose: + # Same situation as DExter: REMI is a token vocabulary, not something the + # harness can generate. This file is the authors' own example, carried + # over from the Space so the case exercises the model rather than being + # skipped into a pass. The notes asserted are those of the example's first + # bar, the one transformed by default. + test_cases: + - name: transform-example + files: + "Upload REMI events (.remi)": test_data/example_511.remi + expect: + "Reference MIDI": + min_notes: 4 + "Generation details": + min_labels: 1 + + teamup-tech/MusiConGen: + test_cases: + - name: default + controls: + "Duration (seconds)": 10 + expect: + "Generated Music": + min_duration: 9.5 + min_rms_db: -45 + + teamup-tech/Music2Emo: + test_cases: + - name: default + expect: + "Emotion Analysis": + min_length: 3 + + teamup-tech/MusicGen: + # A melody is optional, and conditioning on one is a separate path through + # the same model. + test_cases: + - name: default + controls: &musicgen + "Text Description": "a calm acoustic guitar melody with soft percussion" + "Duration (seconds)": "5" + expect: &musicgen-music + "Generated Music": + min_duration: 4.5 + min_rms_db: -45 + + - name: melody + controls: *musicgen + files: + "Melody (optional)": test_data/test_band_speech_16s.flac + expect: *musicgen-music + + teamup-tech/OmniVoice: + # The reference voice is optional, which the harness leaves empty, so it is + # given here with its transcript, to clone the voice. + test_cases: + - name: default + controls: + "Reference transcript": "Every morning, the small bakery on the corner opens at seven. The smell of fresh bread drifts down the street, and a short line forms outside the door." + files: + "Reference voice": test_data/test_speech_10s.flac + expect: *speaks + + teamup-tech/Omnizart: + # Every mode needs at least 6 seconds of audio and loads its own model. The + # band's drums and chord changes give the drum and chord modes plenty to + # transcribe, and its speaking voice stands in for a sung one. + synthesized_inputs: + audio: + duration: 15.0 + test_cases: + - name: default + expect: &omnizart + "Transcription": + min_notes: 10 + + - name: drum + controls: + "Transcription Mode": "drum" + expect: *omnizart + + - name: vocal + controls: + "Transcription Mode": "vocal" + expect: *omnizart + + - name: chord + controls: + "Transcription Mode": "chord" + expect: *omnizart + + teamup-tech/Open-Unmix: + # The band holds all four sources, but umxhq leaves its bass too quiet to + # assert, so the bass stem keeps the floor alone. + test_cases: + - name: default + expect: &open-unmix + "*": + min_duration: 7.8 + max_duration: 8.2 + sample_rate: 48000 + num_channels: 2 + "Vocals": + min_rms_db: -50 + "Drums": + min_rms_db: -50 + "Other": + min_rms_db: -50 + + - name: umxhq + controls: + "Model": "umxhq" + expect: *open-unmix + + teamup-tech/PANAMA: + test_cases: + - name: default + expect: *as-given + + teamup-tech/Pedalboard: + test_cases: + - name: default + expect: *as-given + + teamup-tech/PiCoGen: + # The Space accepts 5 to 30 seconds. + synthesized_inputs: + audio: + duration: 5.0 + test_cases: + - name: default + expect: + "Piano Cover": + min_notes: 5 + + teamup-tech/Piano-Transcription: + # An excerpt of the piano asset, whose notes are known. + synthesized_inputs: + audio: + source: test_data/test_piano_20s.wav + test_cases: + - name: default + expect: + "Transcription": + min_notes: 15 + + teamup-tech/Pocket-TTS: + test_cases: + - name: default + controls: + "English Text": "The weather is lovely today, so we are going for a walk in the park." + expect: *speaks + + teamup-tech/RemFX: + test_cases: + - name: default + expect: *as-given + + teamup-tech/S-KEY: + test_cases: + - name: default + expect: + "Audio with Key": + min_duration: 7.8 + max_duration: 8.2 + sample_rate: 48000 + num_channels: 2 + "Analysis Labels": + min_labels: 1 + + teamup-tech/SongFormer: + # The model requires at least 5 seconds of audio. + synthesized_inputs: + audio: + duration: 6.0 + test_cases: + - name: default + expect: + "Audio with Sections": + min_duration: 5.8 + max_duration: 6.2 + sample_rate: 48000 + num_channels: 2 + "Sections": + min_labels: 1 + + teamup-tech/SongGen: + # The default, mixed mode leaves the vocal stem a silent placeholder. + # Dual-track mode fills it, and is given the optional reference voice. A + # song's length is the model's choice, and varies widely for the same + # lyrics. + test_cases: + - name: default + controls: + "Music Description": "gentle acoustic pop with piano" + "Lyrics": "I walk along the quiet road, the evening light is fading slow" + expect: + "Song": + min_duration: 5.0 + min_rms_db: -45 + "Isolated Vocals": + min_rms_db: -.inf + + - name: dual-track + process_timeout: 300 + controls: + "Music Description": "gentle acoustic pop with piano" + "Lyrics": "I walk along the quiet road, the evening light is fading slow" + "Mode": "Dual-track (vocals + accompaniment)" + files: + "Reference Voice (optional)": test_data/test_speech_10s.flac + expect: + "*": + min_duration: 3.0 + min_rms_db: -45 + + teamup-tech/SonicMaster: + test_cases: + - name: default + expect: *as-given + + teamup-tech/SoulX-Singer: + # A frontend: each call runs on the authors' Space, Soul-AILab/SoulX-Singer, + # on the caller's ZeroGPU allowance. The model transcribes the lyrics of + # both clips, so the synthesized assets cannot drive it. The two clips are + # the authors' own English examples, from example/audio/ in their Space + # (Apache 2.0). Score control follows the melody quantized to notes rather + # than its pitch curve. + skip_common_cases: true + test_cases: + - name: english-example + files: &soulx + "Reference voice": test_data/soulx_en_prompt.mp3 + "Target melody": test_data/soulx_en_target.mp3 + expect: &soulx-singing + "Generated singing": + min_rms_db: -60 + + - name: score-control + controls: + "Control type": "score" + files: *soulx + expect: *soulx-singing + + teamup-tech/Stable-Audio-3: + # The Space accepts 5 to 30 seconds, and each Edit Mode is a separate path + # through process_fn, so each is covered. + process_timeout: 240 + synthesized_inputs: + audio: + duration: 6.0 + test_cases: + - name: restyle-default + controls: + "Edit Mode": "Restyle" + "Prompt": "a warm synthwave groove" + expect: + "Edited Audio": + ext: .wav + bit_depth: 16 + min_duration: 5.8 + max_duration: 6.2 + min_rms_db: -60 + sample_rate: 48000 + num_channels: 2 + + - name: inpaint + controls: + "Edit Mode": "Inpaint" + "Prompt": "a warm synthwave groove" + # The declared defaults fall outside the clip, which the Space + # rejects, so the region is set inside it here. + "Edit Start (seconds)": 1.0 + "Edit End (seconds)": 4.0 + expect: + "Edited Audio": + ext: .wav + bit_depth: 16 + min_duration: 5.8 + max_duration: 6.2 + min_rms_db: -60 + sample_rate: 48000 + num_channels: 2 + + - name: continue + controls: + "Edit Mode": "Continue" + "Prompt": "a warm synthwave groove" + "Continuation Length (seconds)": 4 + expect: + "Edited Audio": + ext: .wav + bit_depth: 16 + min_rms_db: -60 + sample_rate: 48000 + num_channels: 2 + # Length is left unasserted: whether the result carries the input + # ahead of the continuation is the Space's choice, not the input's. + + teamup-tech/SteerMusic: + test_cases: + - name: default + controls: + "Source Prompt": "a pop song with drums, bass and piano" + "Target Prompt": "a jazz trio with upright bass and brushed drums" + "Optimization Steps": 50 + expect: *as-given + + teamup-tech/StemFX: + test_cases: + - name: default + expect: + "Processed Mix": + min_duration: 7.8 + max_duration: 8.2 + min_rms_db: -45 + sample_rate: 48000 + num_channels: 2 + + teamup-tech/TRIA: + test_cases: + - name: default + expect: *as-given + + teamup-tech/Tacotron2: + # Its default text, "Mary had a little lamb", is too short to clear the + # floor for speech reliably, so it reads the sentence the other speech + # models do. + test_cases: + - name: default + controls: + "Text to synthesize": "The weather is lovely today, so we are going for a walk in the park." + expect: *speaks + + teamup-tech/Transkun: + # Its own input is labeled "Upload Solo Piano Audio". Against + # test_piano_20s.wav the model recovers nearly every source note. + test_cases: + - name: transcribe-piano + files: + "Upload Solo Piano Audio": test_data/test_piano_20s.wav + expect: + "Transcription": + min_notes: 20 + + teamup-tech/U-DREAM: + # Each preset loads its own checkpoint. + synthesized_inputs: *speech + test_cases: + - name: default + expect: &u-dream + "Dereverberated Speech": + min_duration: 7.8 + max_duration: 8.3 + min_rms_db: -45 + sample_rate: 44100 + num_channels: 1 + + - name: enhanced + controls: + "Preset": "Enhanced" + expect: *u-dream + + teamup-tech/UNISON: + # Generate mode does not read the input audio. A prompt it can render + # matters, since the placeholder "test" can come back as silence. Edit and + # Clone Voice read the input, which is optional and so is given. An edit + # comes back as long as its input, though the model pads it to a fixed + # window. The high-detail model is a checkpoint of its own, at 16 kHz. + test_cases: + - name: default + controls: + "Prompt": "rain falling on a tin roof" + "Duration (s)": 5.0 + expect: + "Generated Audio": + min_duration: 4.5 + min_rms_db: -45 + + - name: edit + controls: + "Mode": "Edit" + "Prompt": "add heavy rain in the background" + files: + "Input Audio": test_data/test_speech_10s.flac + expect: + "Generated Audio": + min_duration: 9.5 + max_duration: 9.8 + min_rms_db: -45 + sample_rate: 44100 + num_channels: 1 + + - name: high-detail + controls: + "Prompt": "rain falling on a tin roof" + "Duration (s)": 5.0 + "Model": "High detail (16kHz)" + expect: + "Generated Audio": + sample_rate: 16000 + min_duration: 4.5 + min_rms_db: -45 + + - name: clone-voice + controls: + "Mode": "Clone Voice" + "Prompt": "Hello, this is a short test of voice cloning." + "Duration (s)": 8.0 + files: + "Input Audio": test_data/test_speech_10s.flac + expect: *speaks + + teamup-tech/VampNet: + # The fine-tuned models are separate weights, fetched when the Space + # starts. + test_cases: + - name: default + expect: &vampnet + "Generated Audio": + min_duration: 7.8 + max_duration: 8.2 + min_rms_db: -45 + sample_rate: 48000 + num_channels: 2 + + - name: cat10 + controls: + "Model Choice": "cat10" + expect: *vampnet + + - name: lazaro-ros-sep + controls: + "Model Choice": "lazaro-ros-sep" + expect: *vampnet + + - name: supermetroid + controls: + "Model Choice": "supermetroid" + expect: *vampnet + + teamup-tech/Vevo2: + # One case per Task, since each selects a different pipeline, with speech + # as the voice to convert or edit and as the reference. The reference is + # optional, which the harness leaves empty, so the tasks that need one are + # given it. + synthesized_inputs: *speech + test_cases: + - name: voice-conversion + controls: + "Task": "Voice/Singing Conversion" + files: + "Reference Voice": test_data/test_speech_10s.flac + expect: &vevo2-voice + "Generated Voice": + min_duration: 1.5 + min_rms_db: -45 + sample_rate: 44100 + num_channels: 1 + + - name: tts + controls: + "Task": "Text-to-Speech / Text-to-Singing" + "Text / Lyrics": "The weather is lovely today, so we are going for a walk in the park." + expect: *vevo2-voice + + - name: singing-editing + controls: + "Task": "Singing Editing" + "Text / Lyrics": "The weather is lovely today." + expect: *vevo2-voice + + - name: style-conversion + controls: + "Task": "Singing Style Conversion" + "Text / Lyrics": "The weather is lovely today." + files: + "Reference Voice": test_data/test_speech_10s.flac + expect: *vevo2-voice + + - name: melody-control + controls: + "Task": "Melody Control (Humming/Instrument to Singing)" + "Text / Lyrics": "The weather is lovely today." + files: + "Reference Voice": test_data/test_speech_10s.flac + expect: *vevo2-voice + + teamup-tech/VoiceCraft: + # The speech asset's first words are "Every morning, the small bakery". + # The default edit region is rewritten to a new word. + synthesized_inputs: *speech + test_cases: + - name: default + controls: + "Target Transcript": "Every evening, the small bakery on the corner opens at seven. The smell of fresh bread drifts down the street." + expect: + "Edited Speech": + min_duration: 4.0 + min_rms_db: -45 + sample_rate: 44100 + num_channels: 1 + + teamup-tech/VoiceFixer: + # Mode 1 removes the highest frequencies before restoring, a step of its + # own. Mode 2 runs the same network as mode 0 in training mode. + synthesized_inputs: *speech + test_cases: + - name: default + expect: *as-given-speech + + - name: mode-1 + controls: + "Processing Mode": "1" + expect: *as-given-speech + + teamup-tech/Whisper: + synthesized_inputs: *speech + test_cases: + - name: default + expect: + "Transcription": + min_length: 40 + + teamup-tech/Woosh-DFlow: + test_cases: + - name: default + expect: + "Generated audio": + min_rms_db: -45 + + teamup-tech/micro-TCN: + test_cases: + - name: default + expect: *as-given + + teamup-tech/noisereduce: + # Non-stationary reduction and the PyTorch backend are separate paths. + synthesized_inputs: *speech + test_cases: + - name: default + expect: *as-given-speech + + - name: non-stationary + controls: + "Stationary Noise Reduction": false + expect: *as-given-speech + + - name: pytorch + controls: + "Use PyTorch": true + expect: *as-given-speech + + teamup-tech/pyloudnorm: + # The analysis comes back as labels over the input, which is returned as + # it was given. Loudness range is a measurement of its own. + test_cases: + - name: default + expect: &pyloudnorm + "Analyzed Audio": + min_duration: 7.8 + max_duration: 8.2 + sample_rate: 48000 + num_channels: 2 + "Analysis Results": + min_labels: 1 + + - name: loudness-range + controls: + "Analysis Type": "Loudness Range (LU)" + expect: *pyloudnorm -overrides: {} + teamup-tech/text2midi: + test_cases: + - name: default + controls: + "Describe Your Music": "a cheerful pop song with piano, bass and drums" + expect: + "Generated MIDI": + min_notes: 20 diff --git a/model_validation/src/assets.py b/model_validation/src/assets.py index 9510d2e4..c987d57b 100644 --- a/model_validation/src/assets.py +++ b/model_validation/src/assets.py @@ -1,10 +1,13 @@ """ Synthesized test inputs for HARP model validation. -Every input file is generated from scratch, audio with soundfile and MIDI -with the standard library, so validation needs no binary fixtures checked into -the repository. Real-world inputs for specific models belong in test_data/ and -are referenced from a test case's `files` entry in config.yml. +Every input file is generated, audio with soundfile and MIDI with the +standard library. Audio is a sine sweep and MIDI a run of ascending notes +unless a `source` names a recording or a MIDI file in test_data/ to build the +input from instead, which gives a model content it can act on: a separator +something to separate, a transcriber notes to find. Inputs for one specific +model can also be passed as they are, from a test case's `files` entry in +config.yml. Input properties (sample rate, channels, length, format, ...) are configurable via `synthesized_inputs`, whose schema is documented in config.yml. Each @@ -38,6 +41,7 @@ "num_channels": 1, # 1 = mono, 2 = stereo, ... "duration": 2.0, # seconds "ext": ".wav", # container/format to write + "source": None, # recording to build the clip from, or a sine sweep } MIDI_DEFAULTS = { "num_notes": 2, # ascending notes from middle C @@ -45,6 +49,7 @@ "instrument": 0, # General MIDI program number (0 = grand piano) "channel": 0, # MIDI channel (see MIDI_DRUM_CHANNEL) "ext": ".mid", + "source": None, # MIDI file to pass as it is, or synthesized notes } # Channels are numbered 0 to 15 throughout validation, as they are on the @@ -171,7 +176,40 @@ def check_midi_number(prop: str, value, where: str) -> None: f"not {description}. Give a whole number from {low} to {high}") -def check_synthesized_inputs(config: dict) -> None: +def check_source(kind: str, source, where: str, base_dir: Path) -> None: + """ + Reject a `source` that cannot be read as the kind of input it is set for. + + Args: + kind (str): "audio" or "midi". + source: The configured path, relative to config.yml. + where (str): Where it was written, for reporting. + base_dir (Path): Directory containing config.yml. + + Raises: + ValueError: If the file is missing or is not audio or MIDI. + """ + + path = (base_dir / str(source)).resolve() + + if not path.is_file(): + raise ValueError(f"synthesized_inputs {kind} source '{source}' (set in " + f"{where}) is not a file. Give a path relative to " + f"config.yml") + + if kind == "midi" and path.suffix.lower() not in MIDI_EXTS: + raise ValueError(f"synthesized_inputs midi source '{source}' (set in " + f"{where}) is not a MIDI file") + + if kind == "audio": + try: + soundfile.info(str(path)) + except Exception as exc: # noqa: BLE001 - any read failure is fatal + raise ValueError(f"synthesized_inputs audio source '{source}' (set " + f"in {where}) cannot be read: {exc}") from exc + + +def check_synthesized_inputs(config: dict, base_dir: Path) -> None: """ Reject `synthesized_inputs` settings that nothing would act on. @@ -186,9 +224,11 @@ def check_synthesized_inputs(config: dict) -> None: Args: config (dict): Parsed configuration. + base_dir (Path): Directory containing config.yml, against which a + `source` path is resolved. Raises: - ValueError: If a setting is unrecognized or cannot be honoured. + ValueError: If a setting is unrecognized or cannot be honored. """ exts = {} @@ -217,6 +257,9 @@ def check_synthesized_inputs(config: dict) -> None: if (props or {}).get(prop) is not None: check_midi_number(prop, props[prop], where) + if (props or {}).get("source"): + check_source(kind, props["source"], where, base_dir) + unwritable = {ext: where for ext, where in exts.items() if not can_write_audio(ext)} @@ -242,11 +285,15 @@ class Assets: workdir (Path): Directory the generated files are written to. defaults (dict | None): Global `synthesized_inputs` block from config.yml, overriding the built-in property defaults. + base_dir (Path | None): Directory containing config.yml, against which + a `source` path is resolved. The working directory if None. """ - def __init__(self, workdir: Path, defaults: dict | None = None): + def __init__(self, workdir: Path, defaults: dict | None = None, + base_dir: Path | None = None): self.workdir = workdir self.defaults = defaults or {} + self.base_dir = base_dir or Path.cwd() self._cache = {} self._key_locks = {} self._lock = threading.Lock() @@ -350,6 +397,15 @@ def audio(self, overrides: dict | None = None, ext: str | None = None) -> Path | duration = float(props["duration"]) ext = (ext or props["ext"]).lower() + if props["source"]: + source = (self.base_dir / str(props["source"])).resolve() + path = (self.workdir / f"{source.stem}_{sample_rate}hz" + f"_{num_channels}ch_{duration:g}s{ext}") + return self.cached( + ("audio", str(source), sample_rate, num_channels, duration, ext), + lambda: write_audio_from(path, source, duration, sample_rate, + num_channels)) + path = (self.workdir / f"test_input_{sample_rate}hz_{num_channels}ch" f"_{duration:g}s{ext}") @@ -370,11 +426,20 @@ def midi(self, overrides: dict | None = None, ext: str | None = None) -> Path: """ props = self.resolve("midi", overrides) + ext = (ext or props["ext"]).lower() + + # A source is passed as it is, so the properties that describe the + # synthesized notes do not apply to it + if props["source"]: + source = (self.base_dir / str(props["source"])).resolve() + path = self.workdir / f"{source.stem}{ext}" + return self.cached(("midi", str(source), ext), + lambda: write_bytes(path, source.read_bytes())) + num_notes = int(props["num_notes"]) note_duration = float(props["note_duration"]) instrument = int(props["instrument"]) channel = int(props["channel"]) - ext = (ext or props["ext"]).lower() path = (self.workdir / f"test_input_{num_notes}n_{note_duration:g}s" @@ -482,6 +547,51 @@ def write_audio(path: Path, duration: float, sample_rate: int, return path +def write_audio_from(path: Path, source: Path, duration: float, + sample_rate: int, num_channels: int) -> Path: + """ + Write a clip of a recording with the requested properties. + + The recording is resampled by linear interpolation, which is enough for a + test input though not for listening, and is repeated from its start where + it is shorter than the clip. A stereo recording keeps its two channels when + stereo is asked for, and is averaged to one otherwise; a mono recording is + copied to every channel. + + Args: + path (Path): Destination path, whose extension selects the format. + source (Path): The recording, in any format soundfile reads. + duration (float): Length of the clip in seconds. + sample_rate (int): Sample rate in Hz. + num_channels (int): Number of channels to write. + + Returns: + path (Path): The written file, for chaining. + """ + + data, rate = soundfile.read(str(source), dtype="float32", always_2d=True) + + if data.shape[1] != num_channels: + if data.shape[1] > num_channels > 1: + data = data[:, :num_channels] + else: + data = numpy.tile(data.mean(axis=1, keepdims=True), (1, num_channels)) + + if rate != sample_rate: + positions = numpy.arange(int(round(len(data) * sample_rate / rate))) \ + * (rate / sample_rate) + data = numpy.stack([numpy.interp(positions, numpy.arange(len(data)), + data[:, channel]) + for channel in range(data.shape[1])], axis=1) + + n = max(1, int(duration * sample_rate)) + if len(data) < n: + data = numpy.tile(data, (-(-n // len(data)), 1)) + soundfile.write(str(path), data[:n], sample_rate) + + return path + + def write_midi(path: Path, num_notes: int, note_duration: float, instrument: int = 0, channel: int = 0) -> Path: """ diff --git a/model_validation/src/cases.py b/model_validation/src/cases.py index d505eff9..4795ab2b 100644 --- a/model_validation/src/cases.py +++ b/model_validation/src/cases.py @@ -14,7 +14,9 @@ from gradio_client import handle_file from assets import Assets +from audio import read_audio_props from expectations import FILE_TYPES, resolve_expect_targets, check_expectations +from midi import read_midi_props from validators import VALIDATORS, ValidatorNotApplicable @@ -22,7 +24,8 @@ 'synthesize_default_args', 'apply_case', 'validate_outputs', - 'inspect_outputs' + 'inspect_outputs', + 'measure_outputs' ] @@ -245,6 +248,53 @@ def outputs_by_label(result, specs: list) -> dict: return mapped +def measure_outputs(result, controls: dict) -> dict: + """ + Record what each audio or MIDI output actually turned out to be. + + The `expect` rules already decode these files, but only to compare + against a configured value, so what the model returned is discarded + whenever no rule names it. Keeping it makes a property no rule asserts + visible in the report: whether a model preserved the sample rate and + channel count it was given, above all, which is otherwise invisible until + someone listens. + + Measurement never fails a case. A file that cannot be decoded is already + a failure through validate_outputs or the audio_decodes validator, so a + failure here is recorded and nothing more. + + Args: + result: The raw value returned by gradio_client for /process. + controls (dict): The /controls payload. + + Returns: + observed (dict): Output label -> its measured properties, or + {"error": "..."} for one that could not be read. + """ + + specs = controls.get("outputs", []) + out_map = outputs_by_label(result, specs) + readers = {"audio_track": read_audio_props, "midi_track": read_midi_props} + + observed = {} + + for spec in specs: + label, otype = spec.get("label"), spec.get("type") + reader = readers.get(otype) + value = out_map.get(label) + if reader is None or not isinstance(value, str): + continue + try: + props = reader(label, value) + except Exception as exc: # noqa: BLE001 - recording, not asserting + observed[label] = {"error": f"{type(exc).__name__}: {exc}"[:200]} + continue + props["ext"] = os.path.splitext(value)[1].lower() or None + observed[label] = {k: v for k, v in props.items() if v is not None} + + return observed + + def inspect_outputs(result, controls: dict, case: dict) -> None: """ Apply a test case's deeper output checks. diff --git a/model_validation/src/expectations.py b/model_validation/src/expectations.py index 3cba69e8..c2d6b0ec 100644 --- a/model_validation/src/expectations.py +++ b/model_validation/src/expectations.py @@ -27,6 +27,12 @@ FILE_TYPES = {"audio_track", "midi_track", "generic_file"} AUDIO, MIDI, JSON = {"audio_track"}, {"midi_track"}, {"json"} +# A model returning text writes it to a .txt file, which pyharp reports as a +# generic file: it renders a gr.Textbox as an input control and refuses it as +# an output, and has no text track of its own, so the text is read from the +# file these rules receive. +TEXT = {"generic_file"} + # The `expect` vocabulary: rule name -> output types it applies to. Applying # a rule to an output type it does not cover fails the case with a message # naming the configuration mistake, rather than reading as a fault in the @@ -40,10 +46,13 @@ "sample_rate": AUDIO, "bit_depth": AUDIO, "min_rms_db": AUDIO, + "max_rms_db": AUDIO, "min_notes": MIDI, "note_instruments": MIDI, "note_channels": MIDI, "min_labels": JSON, + "min_length": TEXT, + "excludes": TEXT, } # The note_* rules, mapped to the decoded property each reads. Both name the @@ -52,12 +61,15 @@ NOTE_VALUE_RULES = {"note_instruments": "instruments", "note_channels": "channels"} +# The text rules, which read a string output rather than a file +TEXT_RULES = {"min_length", "excludes"} + # Rule groups, by what they need decoded. Duration rules are shared: they read # from whichever of audio/MIDI props matches the output's type. DURATION_RULES = {"min_duration", "max_duration"} DECODED_RULES = { "audio_track": {"num_channels", "sample_rate", "bit_depth", - "min_rms_db"} | DURATION_RULES, + "min_rms_db", "max_rms_db"} | DURATION_RULES, "midi_track": {"min_notes"} | set(NOTE_VALUE_RULES) | DURATION_RULES, } @@ -86,6 +98,29 @@ def require_file(label: str, value) -> str: return value +def read_text(label: str, path: str) -> str: + """ + Read a text output's contents. + + Args: + label (str): Output label, for the error message. + path (str): Path to the text file. + + Returns: + text (str): The file's contents. + + Raises: + AssertionError: If the file cannot be read as text. + """ + + try: + with open(path, encoding="utf-8") as stream: + return stream.read() + except (OSError, UnicodeDecodeError) as exc: + raise AssertionError( + f"output '{label}' could not be read as text: {exc}") from exc + + def resolve_expect_targets(expect: dict, out_types: dict) -> list: """ Resolve an `expect` block into concrete (label, rules) pairs. @@ -97,18 +132,23 @@ def resolve_expect_targets(expect: dict, out_types: dict) -> list: explicit label, by contrast, must exist and must accept the rule - otherwise it is a configuration error, not a model failure. + A rule given under an output's label replaces the same rule under "*" + for that output alone, since it is the more specific statement. This is + what lets a model lift the common silence floor on one stem while every + other output keeps it. + Args: expect (dict): The case's `expect` block. out_types (dict): Output label -> component type from /controls. Returns: - targets (list): (label, rules) pairs to check. + targets (list): (label, rules) pairs to check, one per output. Raises: ValueError: If a label, rule name, or rule/output pairing is invalid. """ - targets = [] + targets, named = {}, {} for key, rules in (expect or {}).items(): rules = rules or {} @@ -121,13 +161,11 @@ def resolve_expect_targets(expect: dict, out_types: dict) -> list: if key == ALL_OUTPUTS: # Fan each rule out to the outputs whose type it covers. A rule # matching nothing is dropped (lenient, so generic cases apply) - per_label = {} for rule, value in rules.items(): applicable = EXPECT_RULES[rule] for label, otype in out_types.items(): if otype in applicable: - per_label.setdefault(label, {})[rule] = value - targets.extend(per_label.items()) + targets.setdefault(label, {})[rule] = value continue if key not in out_types: @@ -142,9 +180,14 @@ def resolve_expect_targets(expect: dict, out_types: dict) -> list: f"of type '{out_types[key]}'. It applies to " f"{sorted(applicable)} outputs") - targets.append((key, rules)) + named[key] = rules + + # Applied after every "*" key, so that the order of the keys in the + # configuration does not decide which rule wins + for label, rules in named.items(): + targets.setdefault(label, {}).update(rules) - return targets + return list(targets.items()) def check_note_values(label: str, rule: str, found: list, allowed) -> None: @@ -268,6 +311,11 @@ def check_expectations(label: str, otype: str, value, rules: dict) -> None: (f"output '{label}' appears silent (RMS {props['rms_db']:.1f} dBFS " f"< {rules['min_rms_db']} dBFS)") + if "max_rms_db" in rules: + assert props["rms_db"] <= rules["max_rms_db"], \ + (f"output '{label}' carries more signal than expected (RMS " + f"{props['rms_db']:.1f} dBFS > {rules['max_rms_db']} dBFS)") + if props is not None and otype == "midi_track": if "min_notes" in rules: assert props["num_notes"] >= rules["min_notes"], \ @@ -281,6 +329,24 @@ def check_expectations(label: str, otype: str, value, rules: dict) -> None: if props is not None: check_duration(label, props["duration"], rules) + # --- Text outputs -------------------------------------------------------- + if set(rules) & TEXT_RULES: + text = read_text(label, require_file(label, value)) + + if "min_length" in rules: + length = len(text.strip()) + assert length >= rules["min_length"], \ + (f"output '{label}' holds {length} character(s) of text, " + f"expected at least {rules['min_length']}") + + if "excludes" in rules: + excluded = rules["excludes"] + excluded = [excluded] if isinstance(excluded, str) else list(excluded) + found = [bad for bad in excluded if bad.lower() in text.lower()] + assert not found, \ + (f"output '{label}' contains {found}, which the model returns " + f"in place of a result: {text.strip()[:100]!r}") + # --- JSON / LabelList outputs -------------------------------------------- if "min_labels" in rules: labels = value.get("labels") if isinstance(value, dict) else None diff --git a/model_validation/src/harness.py b/model_validation/src/harness.py index e4336809..e29b4bbd 100644 --- a/model_validation/src/harness.py +++ b/model_validation/src/harness.py @@ -20,9 +20,9 @@ from gradio_client import Client from assets import Assets, merge_specs -from cases import synthesize_default_args, apply_case, validate_outputs, inspect_outputs -from quota import is_zerogpu -from results import ModelResult, CaseResult, PASS, FAIL +from cases import (synthesize_default_args, apply_case, validate_outputs, + inspect_outputs, measure_outputs) +from results import ModelResult, CaseResult, PASS, FAIL, SKIP from utils import run_with_timeout, describe_exception @@ -55,7 +55,7 @@ # The following are substrings marking an infrastructure fault rather than a # fault in the model. These come from the connection or the GPU host. The same -# call typically succeeds on a retry, revealing the model's real behaviour. +# call typically succeeds on a retry, revealing the model's real behavior. # Retried a bounded number of times, since unlike a warm-up they have no # expected duration to wait out. # @@ -185,7 +185,7 @@ def wait_out_queue(job, queue_deadline: float, api_name: str) -> None: Args: job: A gradio_client Job handle. queue_deadline (float): time.time() by which the job must leave the - queue, or it is cancelled. + queue, or it is canceled. api_name (str): Endpoint name, for the timeout message. Raises: @@ -274,7 +274,7 @@ def run_process(): try: return job.result(timeout=process_timeout) except concurrent.futures.TimeoutError: - # Cancelling ends the client's event stream for a job we have + # Canceling ends the client's event stream for a job we have # stopped waiting on, so its worker thread does not linger for # the rest of the run. The upstream function itself keeps # running, as gradio can only call off a job still in the queue. @@ -290,6 +290,7 @@ def run_process(): case_result.ok = False case_result.error = f"/process output invalid: {error}" else: + case_result.observed = measure_outputs(output, controls) inspect_outputs(output, controls, case) case_result.ok = True except Exception as exc: # noqa: BLE001 - any exception fails the case @@ -302,6 +303,80 @@ def run_process(): return case_result +# A common case that sends nothing of its own is a set of checks rather than a +# scenario, so what it asserts is folded into the cases the model already runs. +INPUT_KEYS = ("controls", "files", "synthesized_inputs") + + +def merge_common_cases(common_cases: list, own_cases: list) -> list: + """ + Combine the common cases with a model's own, sharing /process calls. + + Every case costs one /process call, and on ZeroGPU each call reserves + allowance from a daily budget that the fleet exhausts well before it is + through. A common case that declares no inputs of its own asks the same + question of the same outputs as the model's own cases, so running it + separately buys no coverage and triples the cost: its `expect` rules and + `validators` are merged into each own case instead. + + A common case that does declare inputs is a scenario in its own right and + still runs separately, with its name namespaced for the report. It inherits + whatever the model's first own case sends and does not override, so a model + that needs particular inputs keeps them. + + A model's own settings win on a conflict, since they were written for that + model specifically. + + Args: + common_cases (list): `common_test_cases` from the configuration. + own_cases (list): The model's own cases, or a lone default. + + Returns: + cases (list): Cases to run, scenarios first. + """ + + shared_expect, shared_validators, scenarios = {}, {}, [] + + # What the model sends by default, which a common scenario varies rather + # than replaces: a model that has to be given particular files (Matchering + # needs two different recordings) still needs them in every scenario + base = own_cases[0] if own_cases else {} + + for case in common_cases: + if any(case.get(key) for key in INPUT_KEYS): + scenario = dict(case, name=f"common:{case.get('name', 'unnamed')}") + for key in INPUT_KEYS: + if key not in case and base.get(key): + scenario[key] = base[key] + scenarios.append(scenario) + continue + # Merged per output, so that two common cases targeting the same + # output (e.g. "*") both keep their rules + for label, rules in (case.get("expect") or {}).items(): + shared_expect.setdefault(label, {}).update(rules or {}) + shared_validators.update(case.get("validators") or {}) + + merged = [] + for case in own_cases: + case = dict(case) + # The model's own rules are applied over the shared ones, per output + # for `expect` so that a shared rule on one output survives a model + # rule on another + expect = {label: dict(rules or {}) + for label, rules in shared_expect.items()} + for label, rules in (case.get("expect") or {}).items(): + expect.setdefault(label, {}).update(rules or {}) + if expect: + case["expect"] = expect + validators = dict(shared_validators) + validators.update(case.get("validators") or {}) + if validators: + case["validators"] = validators + merged.append(case) + + return scenarios + merged + + def run_endpoint_tests(client: Client, result: ModelResult, assets: Assets, overrides: dict, opts: argparse.Namespace) -> ModelResult: """ @@ -324,7 +399,7 @@ def run_endpoint_tests(client: Client, result: ModelResult, assets: Assets, # ZeroGPU models default to a lower process timeout, since their jobs are # short once running. A per-model process_timeout override still wins. default_process_timeout = (opts.zerogpu_process_timeout - if is_zerogpu(result.hardware) + if result.zerogpu else opts.process_timeout) process_timeout = overrides.get("process_timeout", default_process_timeout) connect_timeout = overrides.get("connect_timeout", opts.connect_timeout) @@ -355,16 +430,13 @@ def run_endpoint_tests(client: Client, result: ModelResult, assets: Assets, return result # --- /process test cases ------------------------------------------------- - # Common cases (generic, applied to every model) run alongside this - # model's own cases. Their names are namespaced so both are legible in - # the report. A model can opt out with `skip_common_cases`. + # Common cases (generic, applied to every model) are merged into this + # model's own cases, or run beside them when they send inputs of their own + # (see merge_common_cases). A model can opt out with `skip_common_cases`. own_cases = overrides.get("test_cases") or [{"name": "default"}] - if overrides.get("skip_common_cases"): - common_cases = [] - else: - common_cases = [dict(c, name=f"common:{c.get('name', 'unnamed')}") - for c in (opts.common_test_cases or [])] - cases = common_cases + own_cases + common_cases = ([] if overrides.get("skip_common_cases") + else list(opts.common_test_cases or [])) + cases = merge_common_cases(common_cases, own_cases) for case in cases: result.cases.append(run_case( @@ -375,6 +447,15 @@ def run_endpoint_tests(client: Client, result: ModelResult, assets: Assets, result.error = "; ".join( f"[{c.name}] {c.error}" for c in result.cases if c.ok is False) result.status = FAIL + elif not any(c.ok for c in result.cases): + # Every case was skipped, so /process was never called. Passing here + # would vouch for the model on the strength of /controls alone, which + # is the thing these cases exist to go beyond. It reports as a skip + # rather than a failure because the gap is in the configuration, not + # the deployment: a model whose inputs cannot be synthesized needs a + # `files` entry naming real ones. + result.error = "; ".join(f"[{c.name}] {c.error}" for c in result.cases) + result.status = SKIP else: result.status = PASS diff --git a/model_validation/src/quota.py b/model_validation/src/quota.py index d00bea74..8fdc22a3 100644 --- a/model_validation/src/quota.py +++ b/model_validation/src/quota.py @@ -11,7 +11,8 @@ __all__ = [ 'ZeroGPUTracker', - 'is_zerogpu' + 'is_zerogpu', + 'spends_zerogpu' ] @@ -32,6 +33,25 @@ def is_zerogpu(hardware: str) -> bool: return bool(hardware) and hardware.lower().startswith("zero") +def spends_zerogpu(hardware: str, tags) -> bool: + """ + Whether calling a space consumes ZeroGPU quota. + + A space on ZeroGPU hardware does. So does a frontend on other hardware that + forwards to a ZeroGPU backend, which declares this with the "zerogpu" tag, + since each call it forwards still spends the caller's allowance. + + Args: + hardware (str): HF hardware id, possibly empty. + tags (list | None): The tags in the space's README. + + Returns: + zerogpu (bool): True when a call draws on ZeroGPU allowance. + """ + + return is_zerogpu(hardware) or "zerogpu" in (tags or []) + + class ZeroGPUTracker: """ Tracks ZeroGPU work done during a run. diff --git a/model_validation/src/results.py b/model_validation/src/results.py index a586e128..fe18d9a8 100644 --- a/model_validation/src/results.py +++ b/model_validation/src/results.py @@ -34,6 +34,10 @@ class CaseResult: # which reserves ZeroGPU allowance. A retried # call reserves another time and counts again error: str = "" + observed: dict = dataclasses.field(default_factory=dict) + # Output label -> measured properties, for + # properties no rule asserts (see + # cases.measure_outputs) @dataclasses.dataclass @@ -46,11 +50,15 @@ class ModelResult: # error set is recorded as a failure stage: str = "" # HF runtime stage, or "LOCAL" hardware: str = "" # HF hardware (e.g. "zero-a10g", "cpu-basic") + zerogpu: bool = False # spends ZeroGPU allowance, on its own hardware + # or a backend's (see quota.spends_zerogpu) controls_ok: bool = False cases: list = dataclasses.field(default_factory=list) duration: float = 0.0 error: str = "" model_name: str = "" # from the model card + log_tail: list = dataclasses.field(default_factory=list) + # the end of a failed space's run log def status_emoji(result: ModelResult) -> str: @@ -173,5 +181,8 @@ def render_markdown(results: list, payload: dict) -> str: if c.error: lines += [f"- **{c.name}** {case_emoji(c.ok)}", "", "```", c.error, "```", ""] + if r.log_tail: + lines += ["
End of the space's log", "", + "```", *r.log_tail, "```", "", "
", ""] return "\n".join(lines) + "\n" diff --git a/model_validation/src/spaces.py b/model_validation/src/spaces.py index 2dc7c97b..68cf6caf 100644 --- a/model_validation/src/spaces.py +++ b/model_validation/src/spaces.py @@ -9,6 +9,7 @@ import argparse import contextlib import inspect +import json import time import traceback @@ -18,7 +19,7 @@ from assets import Assets from harness import (run_endpoint_tests, close_client, is_quota_exhausted, CONNECT_RETRY_INTERVAL) -from quota import is_zerogpu +from quota import spends_zerogpu from results import ModelResult, FAIL, SKIP from utils import run_with_timeout, scrub, describe_exception @@ -32,8 +33,11 @@ # wakes on the first request SERVABLE_STAGES = {"RUNNING", "SLEEPING"} # Stages that resolve on their own if we wait. SLEEPING is not one of them, -# since a sleeping space only starts once something asks it to (see wake_space) -TRANSIENT_STAGES = {"BUILDING", "RUNNING_BUILDING", "APP_STARTING"} +# since a sleeping space only starts once something asks it to (see wake_space). +# RUNNING_APP_STARTING is a new revision starting while the old one serves, so +# waiting it out validates the revision just deployed +TRANSIENT_STAGES = {"BUILDING", "RUNNING_BUILDING", "APP_STARTING", + "RUNNING_APP_STARTING"} # Stages a restart can recover from. DELETING is excluded, as there is # nothing left to restart RESTARTABLE_STAGES = {"BUILD_ERROR", "RUNTIME_ERROR", "CONFIG_ERROR", @@ -50,6 +54,18 @@ WAKE_ATTEMPTS = 3 # asks before giving up on a space that stays asleep CONNECT_RESERVE = 60 # budget held back from the wake, to connect with +# How much of a failed space's run log the report carries, and for how long it +# is read. The log is a live stream that never ends, so it is read for a fixed +# time: Hugging Face replays the recent history first, then waits for more. +LOG_TAIL_LINES = 25 +LOG_LISTEN_SECONDS = 8 + +# Shutdown noise that nearly every space logs, from asyncio closing an event +# loop, which says nothing about why a case failed +LOG_NOISE = ("Invalid file descriptor", "BaseEventLoop.__del__", "Exception ignored in", + "selectors.py", "_close_self_pipe", "_remove_reader", "_fileobj_to_fd", + "_fileobj_lookup", "return mapping[fileobj]", "key = self._selector") + def wait_for_runtime(api, space_id: str, deadline: float, unsettled: set = TRANSIENT_STAGES): @@ -103,6 +119,25 @@ def resolve_host(api, space_id: str) -> tuple: return None, f"could not look up its host: {describe_exception(exc)}" +def declared_tags(api, space_id: str) -> list: + """ + The tags in a space's README. + + Args: + api (HfApi): Authenticated Hugging Face API client. + space_id (str): The space to look up. + + Returns: + tags (list): The declared tags, or an empty list when the lookup fails. + """ + + try: + card = api.space_info(space_id).card_data + return list(getattr(card, "tags", None) or []) + except Exception: # noqa: BLE001 - no tags is the safe reading + return [] + + def wake_space(host: str, timeout: float) -> tuple: """ Ask a sleeping space to start, and wait for it to answer. @@ -208,6 +243,51 @@ def current_stage(api, space_id: str, fallback: str) -> str: return fallback +def recent_log_lines(space_id: str, token: str) -> list: + """ + Read the end of a space's run log, for the report on a failure. + + A failure's cause is usually a traceback in the space's own log rather + than anything the API returns, so the report carries the lines logged + since the app last started, without the shutdown noise. + + Args: + space_id (str): The space whose log to read. + token (str): Hugging Face access token with access to the space. + + Returns: + lines (list): Up to LOG_TAIL_LINES lines, oldest first, or an empty + list when the log cannot be read. + """ + + lines = [] + deadline = time.time() + LOG_LISTEN_SECONDS + + try: + with httpx.stream("GET", f"https://huggingface.co/api/spaces/{space_id}/logs/run", + headers={"Authorization": f"Bearer {token}"}, + timeout=httpx.Timeout(LOG_LISTEN_SECONDS, read=2.0)) as response: + if response.status_code != 200: + return [] + for raw in response.iter_lines(): + if raw.startswith("data:"): + try: + text = json.loads(raw[len("data:"):])["data"] + except (ValueError, KeyError, TypeError): + text = "" + for line in text.splitlines(): + if "===== Application Startup" in line: + lines = [] + if line.strip() and not any(noise in line for noise in LOG_NOISE): + lines.append(line.rstrip()) + if time.time() > deadline: + break + except Exception: # noqa: BLE001 - a read timeout is how the replay ends, + pass # and a diagnostic must never fail the run + + return lines[-LOG_TAIL_LINES:] + + def test_space(space_id: str, token: str, assets: Assets, opts: argparse.Namespace, overrides: dict, quota_exhausted=None, zerogpu_limiter=None) -> ModelResult: @@ -254,7 +334,8 @@ def follow_runtime(deadline: float): # requested_hardware reflects the space's configuration even while # it is sleeping or stopped (hardware itself is only set when live) result.hardware = runtime.requested_hardware or runtime.hardware or "" - zerogpu = is_zerogpu(result.hardware) + tags = declared_tags(api, space_id) + zerogpu = result.zerogpu = spends_zerogpu(result.hardware, tags) # Hardware is occasionally unreported for a sleeping or starting space. # Throttle those as if they were ZeroGPU: being wrong only costs run # time, whereas leaving a real ZeroGPU model unthrottled overlaps GPU @@ -265,7 +346,7 @@ def follow_runtime(deadline: float): # Skip ZeroGPU models before doing any work that would spend quota if zerogpu and opts.skip_zerogpu: result.status = SKIP - result.error = "skipped: ZeroGPU hardware (--skip-zerogpu)" + result.error = "skipped: spends ZeroGPU allowance (--skip-zerogpu)" return result if zerogpu and quota_exhausted is not None and quota_exhausted.is_set(): result.status = SKIP @@ -354,7 +435,7 @@ def follow_runtime(deadline: float): # Now that the hardware is known, throttle on fact rather than # on the earlier assumption, so a CPU model no longer queues # behind ZeroGPU ones - zerogpu = is_zerogpu(result.hardware) + zerogpu = result.zerogpu = spends_zerogpu(result.hardware, tags) throttle = zerogpu or not result.hardware except Exception: # noqa: BLE001 - hardware stays unknown pass @@ -389,3 +470,5 @@ def follow_runtime(deadline: float): finally: result.duration = round(time.time() - start, 1) close_client(client) + if result.status == FAIL and not is_quota_exhausted(result.error): + result.log_tail = recent_log_lines(space_id, token) diff --git a/model_validation/src/utils.py b/model_validation/src/utils.py index 069e1c3c..34915dba 100644 --- a/model_validation/src/utils.py +++ b/model_validation/src/utils.py @@ -284,6 +284,48 @@ def target(): return outcome.get("result") +class UniqueKeyLoader(yaml.SafeLoader): + """ + A YAML loader that rejects a mapping holding the same key twice. + + PyYAML keeps the last of several entries sharing a key and discards the + rest in silence. In `overrides` that means a model configured twice runs + with only the settings written under the second entry, and nothing + downstream can tell that from a complete one. Two branches that each + configured the same model produce exactly this on merge, so the + duplicate is reported rather than resolved. + """ + + def construct_mapping(self, node, deep=False): + """ + Build a mapping, refusing one whose keys are not unique. + + Args: + node (yaml.MappingNode): The mapping being constructed. + deep (bool): Whether to construct nested objects eagerly. + + Returns: + mapping (dict): The constructed mapping. + + Raises: + yaml.constructor.ConstructorError: If a key is written twice. + """ + + seen = set() + + for key_node, _ in node.value: + key = self.construct_object(key_node, deep=deep) + if key in seen: + raise yaml.constructor.ConstructorError( + None, None, + f"duplicate key '{key}'. The settings under the first " + f"entry would be discarded, so write one entry holding " + f"everything that applies", key_node.start_mark) + seen.add(key) + + return super().construct_mapping(node, deep) + + def load_config(path: Path) -> dict: """ Load the validation configuration (see config.yml for the schema). @@ -293,12 +335,18 @@ def load_config(path: Path) -> dict: Returns: config (dict): Parsed configuration, empty when the file is absent. + + Raises: + ValueError: If the file is not valid YAML, or repeats a key. """ if not path.exists(): return {} - return yaml.safe_load(path.read_text()) or {} + try: + return yaml.load(path.read_text(), Loader=UniqueKeyLoader) or {} + except yaml.YAMLError as exc: + raise ValueError(f"{path} could not be read: {exc}") from exc def check_keys(mapping: dict | None, allowed: set, where: str) -> None: diff --git a/model_validation/src/validate_models.py b/model_validation/src/validate_models.py index ee372b5a..774bd079 100644 --- a/model_validation/src/validate_models.py +++ b/model_validation/src/validate_models.py @@ -39,7 +39,7 @@ from assets import Assets, check_synthesized_inputs from examples import test_local_example from spaces import test_space -from quota import ZeroGPUTracker, is_zerogpu +from quota import ZeroGPUTracker from results import FAIL, status_emoji, write_reports from utils import (get_token, check_token, scrub, load_config, check_config_keys, check_model_names, qualify, qualify_keys, Exclusions, @@ -282,7 +282,7 @@ def validate_spaces(opts: argparse.Namespace, config: dict, # (including retries, which reserve again) and the wall time of the # cases that made them. A queued or input-skipped case never ran info = r.hardware or "?" - if is_zerogpu(r.hardware): + if r.zerogpu: ran = [c for c in r.cases if c.gpu_calls] tracker.add(sum(c.gpu_calls for c in ran), sum(c.duration for c in ran)) @@ -304,16 +304,17 @@ def main() -> int: # Resolved so a case's `files:` paths stay relative to the config file # itself, whatever directory the run was launched from opts.config = opts.config.resolve() - config = load_config(opts.config) examples_tier = opts.local_examples is not None try: + config = load_config(opts.config) + # Checked before anything reads a setting, so a mistake is reported # once, names the key as it was written, and costs no model runs check_config_keys(config) check_model_names(config, opts.exclude, opts.spaces) - check_synthesized_inputs(config) + check_synthesized_inputs(config, opts.config.parent) # Only one tier runs, so a bare model name resolves against that tier owner = "examples" if examples_tier else opts.org @@ -332,7 +333,7 @@ def main() -> int: run_stamp = time.strftime("%Y-%m-%dT%H-%M-%S", time.localtime()) opts.output_dir = opts.output_dir / run_stamp assets = Assets(opts.output_dir / "assets", - config.get("synthesized_inputs")) + config.get("synthesized_inputs"), opts.config.parent) if examples_tier: token = "" diff --git a/model_validation/src/validators.py b/model_validation/src/validators.py index 1f146e92..d3d946af 100644 --- a/model_validation/src/validators.py +++ b/model_validation/src/validators.py @@ -31,6 +31,10 @@ the output at fault and say what was wrong with it. """ +import json +import math +from pathlib import Path + from audio import read_audio_props @@ -72,6 +76,245 @@ def register(fn): return register +def json_file(outputs, label): + """ + Read a model's JSON file output. + + Every failure here is reported as an AssertionError naming the output, + since a malformed result is the model's fault and has to read as one. A + missing label is the exception: the model no longer has the output this + validator was written for, which is a configuration mistake. + + Args: + outputs (dict): Output label -> value, as passed to a validator. + label (str): Label of the JSON file output to read. + + Returns: + result (dict): The decoded JSON object. + + Raises: + AssertionError: If the output is not a readable JSON object. + ValueError: If the model has no output with this label. + """ + + if label not in outputs: + raise ValueError(f"validator expects an output labeled '{label}', " + f"which this model does not have (available: " + f"{sorted(outputs)})") + + path = outputs[label] + assert isinstance(path, str), \ + f"output '{label}' is not a file path: {str(path)[:100]}" + + try: + with Path(path).open(encoding="utf-8") as stream: + result = json.load(stream) + except json.JSONDecodeError as exc: + raise AssertionError(f"output '{label}' is not valid JSON: {exc}") from exc + except OSError as exc: + raise AssertionError(f"output '{label}' could not be read: {exc}") from exc + + assert isinstance(result, dict), \ + f"output '{label}' must hold a JSON object, got {type(result).__name__}" + + return result + + +def entry(mapping, key, label): + """ + Read a required key out of a model's decoded result. + + A model that stops returning a field is reporting a different result than + the one it is validated against, so the absence is asserted rather than + left to raise a KeyError that names only the key. + + Args: + mapping (dict): The decoded object to read from. + key (str): The key the result must carry. + label (str): What the mapping describes, for the error message. + + Returns: + value: The value stored under the key. + + Raises: + AssertionError: If the mapping is not a dict or lacks the key. + """ + + assert isinstance(mapping, dict), \ + f"{label} must be a JSON object, got {type(mapping).__name__}" + assert key in mapping, \ + f"{label} is missing '{key}' (found: {sorted(mapping)})" + + return mapping[key] + + +def finite_number(value, label): + """ + Require a numeric prediction rather than null, a string, or NaN. + + Args: + value: The value a model reported. + label (str): What the value is, for the error message. + + Raises: + AssertionError: If the value is not a finite int or float. A bool is + rejected too, since `type` is compared rather than `isinstance`. + """ + + assert type(value) in (int, float) and math.isfinite(value), \ + f"'{label}' must be a finite number, got {value!r}" + + +@validator("merit_scores") +def merit_scores(outputs, controls, params): + """ + Check the three similarity scores in MERIT's result file. + + Each is a cosine similarity, so the range is the model's own contract + rather than a threshold on how similar the two inputs happen to be. + + Raises: + AssertionError: If a score is missing, is not a finite number, or + lies outside the [-1, 1] a cosine similarity can take. + ValueError: If the model has no "Similarity Results" output. + """ + + scores = entry(json_file(outputs, "Similarity Results"), "scores", + "MERIT's result") + + for name in ("melody", "rhythm", "timbre"): + value = entry(scores, name, "MERIT's scores") + finite_number(value, name) + # Floating point leaves an exact 1.0 marginally over, so the bound is + # loosened rather than rejecting a legitimately perfect match + assert -1.000001 <= value <= 1.000001, \ + f"MERIT {name} cosine similarity is outside [-1, 1]: {value}" + + +@validator("muq_ranking") +def muq_ranking(outputs, controls, params): + """ + Check candidate count, ranks, and descending similarity scores. + + Params: + count (int): How many candidate descriptions the case supplied, which + is how many ranked rows the model has to return. + + Raises: + AssertionError: If the ranking is the wrong length, is misnumbered, + carries an empty description, or is not sorted by similarity. + ValueError: If the model has no "Similarity Ranking" output, or the + case did not say how many candidates it supplied. + """ + + if "count" not in params: + raise ValueError("muq_ranking needs `count`, the number of candidate " + "descriptions the test case supplied") + + rows = entry(json_file(outputs, "Similarity Ranking"), "results", + "MuQ's result") + + assert isinstance(rows, list), \ + f"MuQ's ranking must be a list, got {type(rows).__name__}" + assert len(rows) == params["count"], \ + (f"MuQ returned {len(rows)} ranked candidate(s), expected " + f"{params['count']}") + + previous = math.inf + + for rank, row in enumerate(rows, start=1): + assert entry(row, "rank", f"MuQ candidate {rank}") == rank, \ + f"MuQ candidate {rank} is ranked {entry(row, 'rank', 'it')}" + + description = entry(row, "description", f"MuQ candidate {rank}") + assert isinstance(description, str) and description.strip(), \ + f"MuQ candidate {rank} has an empty description" + + score = entry(row, "similarity", f"MuQ candidate {rank}") + finite_number(score, "similarity") + assert score <= previous, \ + (f"MuQ candidate {rank} scores {score}, above the {previous} " + f"before it, so the ranking is not sorted by similarity") + previous = score + + +@validator("audio_decodes") +def audio_decodes(outputs, controls, params): + """ + Assert every audio output decodes and carries frames. + + The structural checks every case gets already require a file that exists + and is not empty, which a header-only WAV satisfies: inference ran, wrote + nothing, and reported success. Decoding is what catches that, and it is + worth a validator rather than an `expect` rule because the check applies + to whichever outputs are audio, so it reaches every model in a common + test case without naming a label. + + Raises: + AssertionError: If an audio output cannot be decoded or holds no + frames (read_audio_props asserts both). + ValidatorNotApplicable: If the model returns no audio. + """ + + audio_labels = [spec.get("label") for spec in controls.get("outputs", []) + if spec.get("type") == "audio_track"] + + checked = False + + for label in audio_labels: + value = outputs.get(label) + if isinstance(value, str): + read_audio_props(label, value) + checked = True + + if not checked: + raise ValidatorNotApplicable("no audio output to decode") + + +# The output types HARP draws labels over. A label output on its own has +# nowhere to appear. +FILE_OUTPUT_TYPES = {"audio_track", "midi_track", "generic_file"} + + +@validator("labels_reach_harp") +def labels_reach_harp(outputs, controls, params): + """ + Assert every JSON output is a label list HARP can show. + + HARP reads a JSON output as a pyharp LabelList and fails the whole request + on anything else, since it needs the `meta` key to tell the output apart, + and it draws labels only over an audio, MIDI or file output the model also + returns. A model returning a plain dictionary passes every other check here + and shows an error in HARP after every request, which is what six Spaces + once did. + + Raises: + AssertionError: If a JSON output is not a LabelList, or the model + returns labels with no output to draw them over. + ValidatorNotApplicable: If the model has no JSON output. + """ + + specs = controls.get("outputs", []) + json_labels = [spec.get("label") for spec in specs if spec.get("type") == "json"] + + if not json_labels: + raise ValidatorNotApplicable("no JSON output") + + assert any(spec.get("type") in FILE_OUTPUT_TYPES for spec in specs), \ + (f"labels {json_labels} have no audio, MIDI or file output to appear " + f"over, and HARP shows labels nowhere else") + + for label in json_labels: + value = outputs.get(label) + meta = value.get("meta") if isinstance(value, dict) else None + kind = meta.get("_type") if isinstance(meta, dict) else None + assert kind == "pyharp.LabelList", \ + (f"'{label}' is not a pyharp LabelList, which HARP rejects, failing the " + f"request: {json.dumps(value)[:200]}") + assert isinstance(value.get("labels"), list), \ + f"'{label}' is a LabelList without a list of labels" + + @validator("labels_within_audio") def labels_within_audio(outputs, controls, params): """ diff --git a/model_validation/test_data/example_511.remi b/model_validation/test_data/example_511.remi new file mode 100644 index 00000000..4c3cde9a --- /dev/null +++ b/model_validation/test_data/example_511.remi @@ -0,0 +1,4498 @@ +Bar_None +Beat_0 +Chord_N_N +Tempo_119 +Note_Pitch_67 +Note_Velocity_58 +Note_Duration_1920 +Note_Pitch_64 +Note_Velocity_58 +Note_Duration_1920 +Note_Pitch_62 +Note_Velocity_56 +Note_Duration_1920 +Note_Pitch_59 +Note_Velocity_54 +Note_Duration_1920 +Beat_12 +Tempo_140 +Bar_None +Beat_0 +Chord_D_m7 +Tempo_146 +Note_Pitch_81 +Note_Velocity_66 +Note_Duration_1920 +Note_Pitch_69 +Note_Velocity_60 +Note_Duration_1920 +Note_Pitch_65 +Note_Velocity_50 +Note_Duration_1920 +Note_Pitch_62 +Note_Velocity_56 +Note_Duration_1920 +Note_Pitch_60 +Note_Velocity_52 +Note_Duration_1920 +Beat_4 +Tempo_149 +Beat_8 +Tempo_143 +Beat_12 +Tempo_140 +Bar_None +Beat_0 +Chord_E_m7 +Tempo_149 +Note_Pitch_67 +Note_Velocity_58 +Note_Duration_1920 +Note_Pitch_64 +Note_Velocity_54 +Note_Duration_1920 +Note_Pitch_62 +Note_Velocity_58 +Note_Duration_1920 +Note_Pitch_59 +Note_Velocity_56 +Note_Duration_1920 +Beat_4 +Tempo_143 +Beat_12 +Tempo_140 +Bar_None +Beat_0 +Chord_D_m7 +Tempo_146 +Note_Pitch_81 +Note_Velocity_66 +Note_Duration_1920 +Note_Pitch_69 +Note_Velocity_62 +Note_Duration_1920 +Note_Pitch_65 +Note_Velocity_56 +Note_Duration_1920 +Note_Pitch_60 +Note_Velocity_52 +Note_Duration_1920 +Beat_4 +Tempo_143 +Beat_12 +Tempo_140 +Bar_None +Beat_0 +Chord_F_M7 +Tempo_146 +Note_Pitch_40 +Note_Velocity_48 +Note_Duration_600 +Beat_4 +Chord_E_m7 +Note_Pitch_67 +Note_Velocity_66 +Note_Duration_1560 +Note_Pitch_64 +Note_Velocity_64 +Note_Duration_1920 +Note_Pitch_62 +Note_Velocity_54 +Note_Duration_1920 +Note_Pitch_59 +Note_Velocity_60 +Note_Duration_1560 +Beat_8 +Tempo_143 +Note_Pitch_76 +Note_Velocity_64 +Note_Duration_480 +Note_Pitch_43 +Note_Velocity_44 +Note_Duration_120 +Beat_10 +Note_Pitch_40 +Note_Velocity_46 +Note_Duration_720 +Beat_12 +Note_Pitch_76 +Note_Velocity_56 +Note_Duration_480 +Bar_None +Beat_0 +Chord_F_M7 +Tempo_146 +Note_Pitch_76 +Note_Velocity_58 +Note_Duration_600 +Note_Pitch_41 +Note_Velocity_56 +Note_Duration_600 +Beat_4 +Note_Pitch_81 +Note_Velocity_64 +Note_Duration_1920 +Note_Pitch_72 +Note_Velocity_58 +Note_Duration_1080 +Note_Pitch_69 +Note_Velocity_56 +Note_Duration_1920 +Note_Pitch_60 +Note_Velocity_58 +Note_Duration_1080 +Note_Pitch_53 +Note_Velocity_50 +Note_Duration_1200 +Beat_8 +Tempo_143 +Note_Pitch_83 +Note_Velocity_54 +Note_Duration_1440 +Beat_12 +Tempo_137 +Note_Pitch_76 +Note_Velocity_66 +Note_Duration_1080 +Note_Pitch_41 +Note_Velocity_56 +Note_Duration_600 +Bar_None +Beat_0 +Tempo_149 +Note_Pitch_43 +Note_Velocity_54 +Note_Duration_480 +Beat_4 +Chord_G_M +Tempo_143 +Note_Pitch_67 +Note_Velocity_60 +Note_Duration_1320 +Note_Pitch_62 +Note_Velocity_62 +Note_Duration_1320 +Note_Pitch_59 +Note_Velocity_60 +Note_Duration_1200 +Note_Pitch_64 +Note_Velocity_52 +Note_Duration_960 +Beat_6 +Note_Pitch_74 +Note_Velocity_56 +Note_Duration_720 +Beat_8 +Chord_E_m7 +Tempo_146 +Note_Pitch_76 +Note_Velocity_60 +Note_Duration_240 +Beat_10 +Note_Pitch_76 +Note_Velocity_62 +Note_Duration_240 +Beat_12 +Tempo_143 +Note_Pitch_76 +Note_Velocity_56 +Note_Duration_240 +Note_Pitch_45 +Note_Velocity_48 +Note_Duration_360 +Note_Pitch_64 +Note_Velocity_48 +Note_Duration_360 +Beat_14 +Note_Pitch_76 +Note_Velocity_54 +Note_Duration_240 +Note_Pitch_47 +Note_Velocity_56 +Note_Duration_240 +Bar_None +Beat_0 +Chord_A_m +Note_Pitch_76 +Note_Velocity_64 +Note_Duration_600 +Note_Pitch_38 +Note_Velocity_48 +Note_Duration_600 +Beat_4 +Tempo_146 +Note_Pitch_81 +Note_Velocity_64 +Note_Duration_1560 +Note_Pitch_72 +Note_Velocity_60 +Note_Duration_1560 +Note_Pitch_69 +Note_Velocity_58 +Note_Duration_1920 +Note_Pitch_62 +Note_Velocity_50 +Note_Duration_1200 +Note_Pitch_60 +Note_Velocity_52 +Note_Duration_1440 +Beat_8 +Chord_D_m7 +Tempo_143 +Note_Pitch_83 +Note_Velocity_58 +Note_Duration_960 +Beat_10 +Note_Pitch_38 +Note_Velocity_46 +Note_Duration_600 +Beat_12 +Tempo_140 +Note_Pitch_76 +Note_Velocity_64 +Note_Duration_1080 +Beat_14 +Note_Pitch_41 +Note_Velocity_54 +Note_Duration_240 +Bar_None +Beat_0 +Chord_A_m +Tempo_146 +Note_Pitch_40 +Note_Velocity_52 +Note_Duration_480 +Beat_4 +Chord_G_M +Tempo_143 +Note_Pitch_72 +Note_Velocity_64 +Note_Duration_240 +Note_Pitch_64 +Note_Velocity_62 +Note_Duration_840 +Note_Pitch_67 +Note_Velocity_56 +Note_Duration_1200 +Note_Pitch_62 +Note_Velocity_60 +Note_Duration_1560 +Note_Pitch_59 +Note_Velocity_54 +Note_Duration_240 +Note_Pitch_55 +Note_Velocity_52 +Note_Duration_120 +Beat_6 +Note_Pitch_74 +Note_Velocity_64 +Note_Duration_600 +Beat_8 +Chord_C_M +Tempo_146 +Note_Pitch_76 +Note_Velocity_56 +Note_Duration_120 +Beat_9 +Note_Pitch_76 +Note_Velocity_60 +Note_Duration_120 +Beat_10 +Note_Pitch_72 +Note_Velocity_50 +Note_Duration_840 +Note_Pitch_40 +Note_Velocity_46 +Note_Duration_840 +Note_Pitch_76 +Note_Velocity_54 +Note_Duration_240 +Beat_12 +Note_Pitch_76 +Note_Velocity_56 +Note_Duration_240 +Beat_14 +Note_Pitch_76 +Note_Velocity_62 +Note_Duration_240 +Bar_None +Beat_0 +Chord_F_M7 +Tempo_143 +Note_Pitch_76 +Note_Velocity_60 +Note_Duration_240 +Note_Pitch_41 +Note_Velocity_56 +Note_Duration_480 +Beat_2 +Note_Pitch_76 +Note_Velocity_52 +Note_Duration_240 +Beat_4 +Tempo_146 +Note_Pitch_77 +Note_Velocity_62 +Note_Duration_0 +Note_Pitch_72 +Note_Velocity_62 +Note_Duration_1080 +Note_Pitch_69 +Note_Velocity_56 +Note_Duration_1080 +Note_Pitch_60 +Note_Velocity_60 +Note_Duration_1440 +Note_Pitch_53 +Note_Velocity_52 +Note_Duration_1080 +Beat_8 +Tempo_140 +Note_Pitch_83 +Note_Velocity_62 +Note_Duration_480 +Beat_12 +Note_Pitch_83 +Note_Velocity_54 +Note_Duration_480 +Note_Pitch_76 +Note_Velocity_56 +Note_Duration_1200 +Note_Pitch_41 +Note_Velocity_60 +Note_Duration_480 +Bar_None +Beat_0 +Chord_E_m7 +Tempo_149 +Note_Pitch_83 +Note_Velocity_54 +Note_Duration_1200 +Note_Pitch_43 +Note_Velocity_56 +Note_Duration_480 +Beat_4 +Tempo_143 +Note_Pitch_72 +Note_Velocity_60 +Note_Duration_120 +Note_Pitch_67 +Note_Velocity_50 +Note_Duration_960 +Note_Pitch_64 +Note_Velocity_48 +Note_Duration_1200 +Note_Pitch_59 +Note_Velocity_52 +Note_Duration_120 +Note_Pitch_52 +Note_Velocity_50 +Note_Duration_120 +Beat_5 +Note_Pitch_72 +Note_Velocity_66 +Note_Duration_360 +Beat_6 +Note_Pitch_74 +Note_Velocity_62 +Note_Duration_960 +Beat_8 +Chord_A_m7 +Tempo_146 +Note_Pitch_76 +Note_Velocity_60 +Note_Duration_240 +Note_Pitch_72 +Note_Velocity_54 +Note_Duration_720 +Beat_10 +Note_Pitch_79 +Note_Velocity_66 +Note_Duration_480 +Beat_12 +Tempo_143 +Note_Pitch_77 +Note_Velocity_66 +Note_Duration_480 +Note_Pitch_45 +Note_Velocity_62 +Note_Duration_240 +Beat_14 +Note_Pitch_76 +Note_Velocity_66 +Note_Duration_240 +Note_Pitch_47 +Note_Velocity_62 +Note_Duration_240 +Bar_None +Beat_0 +Chord_D_sus2 +Note_Pitch_76 +Note_Velocity_56 +Note_Duration_600 +Note_Pitch_38 +Note_Velocity_46 +Note_Duration_600 +Beat_2 +Note_Pitch_74 +Note_Velocity_66 +Note_Duration_360 +Beat_4 +Chord_A_m +Tempo_146 +Note_Pitch_72 +Note_Velocity_66 +Note_Duration_1200 +Note_Pitch_69 +Note_Velocity_62 +Note_Duration_1320 +Note_Pitch_60 +Note_Velocity_66 +Note_Duration_1440 +Beat_8 +Chord_D_m7 +Tempo_143 +Note_Pitch_83 +Note_Velocity_54 +Note_Duration_840 +Beat_10 +Note_Pitch_38 +Note_Velocity_58 +Note_Duration_600 +Beat_12 +Note_Pitch_76 +Note_Velocity_64 +Note_Duration_360 +Beat_14 +Note_Pitch_74 +Note_Velocity_66 +Note_Duration_480 +Note_Pitch_41 +Note_Velocity_58 +Note_Duration_480 +Bar_None +Beat_0 +Chord_G_7 +Tempo_146 +Note_Pitch_40 +Note_Velocity_46 +Note_Duration_120 +Beat_2 +Note_Pitch_74 +Note_Velocity_66 +Note_Duration_240 +Beat_4 +Tempo_143 +Note_Pitch_74 +Note_Velocity_64 +Note_Duration_840 +Note_Pitch_67 +Note_Velocity_60 +Note_Duration_1560 +Note_Pitch_62 +Note_Velocity_58 +Note_Duration_1440 +Note_Pitch_59 +Note_Velocity_60 +Note_Duration_840 +Beat_6 +Note_Pitch_76 +Note_Velocity_58 +Note_Duration_240 +Beat_8 +Chord_E_m7 +Note_Pitch_76 +Note_Velocity_54 +Note_Duration_480 +Beat_10 +Note_Pitch_40 +Note_Velocity_52 +Note_Duration_720 +Beat_11 +Note_Pitch_74 +Note_Velocity_54 +Note_Duration_600 +Beat_12 +Tempo_146 +Note_Pitch_72 +Note_Velocity_66 +Note_Duration_960 +Bar_None +Beat_0 +Chord_D_m7 +Tempo_143 +Note_Pitch_74 +Note_Velocity_66 +Note_Duration_240 +Note_Pitch_41 +Note_Velocity_50 +Note_Duration_480 +Beat_2 +Note_Pitch_74 +Note_Velocity_64 +Note_Duration_1080 +Beat_4 +Tempo_146 +Note_Pitch_72 +Note_Velocity_56 +Note_Duration_960 +Note_Pitch_69 +Note_Velocity_50 +Note_Duration_1080 +Note_Pitch_62 +Note_Velocity_52 +Note_Duration_1080 +Note_Pitch_60 +Note_Velocity_54 +Note_Duration_1440 +Note_Pitch_53 +Note_Velocity_46 +Note_Duration_1080 +Beat_6 +Note_Pitch_76 +Note_Velocity_64 +Note_Duration_240 +Beat_8 +Chord_F_M7 +Tempo_140 +Note_Pitch_76 +Note_Velocity_62 +Note_Duration_480 +Beat_11 +Note_Pitch_74 +Note_Velocity_60 +Note_Duration_120 +Beat_12 +Tempo_143 +Note_Pitch_72 +Note_Velocity_62 +Note_Duration_720 +Note_Pitch_41 +Note_Velocity_60 +Note_Duration_480 +Bar_None +Beat_0 +Chord_G_7 +Tempo_146 +Note_Pitch_74 +Note_Velocity_64 +Note_Duration_600 +Note_Pitch_43 +Note_Velocity_62 +Note_Duration_480 +Beat_4 +Note_Pitch_72 +Note_Velocity_60 +Note_Duration_960 +Note_Pitch_67 +Note_Velocity_56 +Note_Duration_1320 +Note_Pitch_62 +Note_Velocity_58 +Note_Duration_1680 +Note_Pitch_59 +Note_Velocity_62 +Note_Duration_960 +Beat_6 +Note_Pitch_76 +Note_Velocity_62 +Note_Duration_240 +Beat_8 +Chord_G_M +Tempo_143 +Note_Pitch_76 +Note_Velocity_62 +Note_Duration_600 +Beat_11 +Note_Pitch_74 +Note_Velocity_66 +Note_Duration_480 +Note_Pitch_43 +Note_Velocity_46 +Note_Duration_120 +Beat_12 +Note_Pitch_72 +Note_Velocity_62 +Note_Duration_960 +Note_Pitch_45 +Note_Velocity_62 +Note_Duration_240 +Beat_14 +Note_Pitch_47 +Note_Velocity_64 +Note_Duration_240 +Beat_15 +Note_Pitch_74 +Note_Velocity_58 +Note_Duration_120 +Bar_None +Beat_0 +Chord_D_m7 +Tempo_146 +Note_Pitch_74 +Note_Velocity_60 +Note_Duration_240 +Note_Pitch_38 +Note_Velocity_52 +Note_Duration_480 +Beat_2 +Note_Pitch_74 +Note_Velocity_64 +Note_Duration_360 +Beat_4 +Chord_F_M7 +Tempo_143 +Note_Pitch_72 +Note_Velocity_62 +Note_Duration_600 +Note_Pitch_69 +Note_Velocity_58 +Note_Duration_1560 +Note_Pitch_62 +Note_Velocity_56 +Note_Duration_1440 +Note_Pitch_60 +Note_Velocity_60 +Note_Duration_1440 +Note_Pitch_53 +Note_Velocity_52 +Note_Duration_120 +Note_Pitch_43 +Note_Velocity_54 +Note_Duration_0 +Beat_6 +Note_Pitch_76 +Note_Velocity_54 +Note_Duration_240 +Beat_8 +Chord_D_m7 +Note_Pitch_74 +Note_Velocity_64 +Note_Duration_480 +Beat_10 +Note_Pitch_38 +Note_Velocity_44 +Note_Duration_720 +Beat_11 +Note_Pitch_72 +Note_Velocity_62 +Note_Duration_1920 +Beat_12 +Tempo_146 +Note_Pitch_74 +Note_Velocity_54 +Note_Duration_600 +Beat_14 +Note_Pitch_41 +Note_Velocity_54 +Note_Duration_240 +Bar_None +Beat_0 +Chord_C_M +Tempo_143 +Note_Pitch_62 +Note_Velocity_60 +Note_Duration_1920 +Note_Pitch_55 +Note_Velocity_56 +Note_Duration_1920 +Note_Pitch_52 +Note_Velocity_60 +Note_Duration_1560 +Beat_6 +Note_Pitch_76 +Note_Velocity_62 +Note_Duration_240 +Beat_8 +Chord_C_M +Tempo_146 +Note_Pitch_76 +Note_Velocity_58 +Note_Duration_600 +Beat_11 +Note_Pitch_74 +Note_Velocity_56 +Note_Duration_720 +Note_Pitch_43 +Note_Velocity_40 +Note_Duration_720 +Beat_12 +Tempo_143 +Note_Pitch_72 +Note_Velocity_68 +Note_Duration_720 +Bar_None +Beat_0 +Note_Pitch_64 +Note_Velocity_64 +Note_Duration_1920 +Note_Pitch_60 +Note_Velocity_58 +Note_Duration_1920 +Note_Pitch_55 +Note_Velocity_64 +Note_Duration_1920 +Note_Pitch_45 +Note_Velocity_62 +Note_Duration_120 +Beat_4 +Tempo_146 +Note_Pitch_72 +Note_Velocity_54 +Note_Duration_960 +Beat_6 +Note_Pitch_76 +Note_Velocity_64 +Note_Duration_240 +Beat_8 +Chord_C_M7 +Tempo_143 +Note_Pitch_76 +Note_Velocity_60 +Note_Duration_600 +Beat_11 +Note_Pitch_74 +Note_Velocity_60 +Note_Duration_240 +Note_Pitch_43 +Note_Velocity_42 +Note_Duration_240 +Beat_12 +Note_Pitch_72 +Note_Velocity_64 +Note_Duration_360 +Beat_15 +Note_Pitch_72 +Note_Velocity_66 +Note_Duration_240 +Bar_None +Beat_0 +Chord_G_m7 +Tempo_146 +Note_Pitch_65 +Note_Velocity_56 +Note_Duration_1440 +Note_Pitch_62 +Note_Velocity_66 +Note_Duration_1560 +Note_Pitch_59 +Note_Velocity_54 +Note_Duration_120 +Note_Pitch_43 +Note_Velocity_58 +Note_Duration_120 +Note_Pitch_55 +Note_Velocity_54 +Note_Duration_1920 +Beat_4 +Chord_F_sus2 +Note_Pitch_72 +Note_Velocity_62 +Note_Duration_960 +Beat_6 +Note_Pitch_76 +Note_Velocity_66 +Note_Duration_240 +Beat_8 +Chord_C_M +Tempo_140 +Note_Pitch_76 +Note_Velocity_64 +Note_Duration_480 +Note_Pitch_67 +Note_Velocity_58 +Note_Duration_960 +Beat_11 +Note_Pitch_74 +Note_Velocity_62 +Note_Duration_240 +Beat_12 +Tempo_143 +Note_Pitch_72 +Note_Velocity_64 +Note_Duration_360 +Beat_15 +Note_Pitch_72 +Note_Velocity_52 +Note_Duration_240 +Bar_None +Beat_0 +Chord_D_m7 +Tempo_146 +Note_Pitch_62 +Note_Velocity_56 +Note_Duration_1920 +Note_Pitch_60 +Note_Velocity_60 +Note_Duration_1920 +Note_Pitch_57 +Note_Velocity_64 +Note_Duration_1920 +Note_Pitch_53 +Note_Velocity_56 +Note_Duration_1920 +Beat_2 +Note_Pitch_74 +Note_Velocity_60 +Note_Duration_720 +Beat_4 +Tempo_143 +Note_Pitch_72 +Note_Velocity_56 +Note_Duration_600 +Beat_6 +Note_Pitch_76 +Note_Velocity_62 +Note_Duration_240 +Beat_8 +Note_Pitch_74 +Note_Velocity_66 +Note_Duration_360 +Beat_10 +Note_Pitch_72 +Note_Velocity_56 +Note_Duration_240 +Beat_12 +Tempo_146 +Note_Pitch_74 +Note_Velocity_56 +Note_Duration_480 +Bar_None +Beat_0 +Tempo_143 +Note_Pitch_74 +Note_Velocity_66 +Note_Duration_600 +Note_Pitch_41 +Note_Velocity_46 +Note_Duration_600 +Beat_4 +Chord_G_7 +Tempo_146 +Note_Pitch_62 +Note_Velocity_64 +Note_Duration_1440 +Note_Pitch_59 +Note_Velocity_60 +Note_Duration_960 +Note_Pitch_55 +Note_Velocity_50 +Note_Duration_120 +Note_Pitch_52 +Note_Velocity_54 +Note_Duration_120 +Note_Pitch_67 +Note_Velocity_56 +Note_Duration_120 +Beat_5 +Note_Pitch_83 +Note_Velocity_62 +Note_Duration_1320 +Beat_8 +Chord_E_m7 +Note_Pitch_79 +Note_Velocity_60 +Note_Duration_960 +Beat_10 +Note_Pitch_28 +Note_Velocity_40 +Note_Duration_720 +Note_Pitch_40 +Note_Velocity_40 +Note_Duration_720 +Beat_12 +Tempo_143 +Note_Pitch_76 +Note_Velocity_66 +Note_Duration_1080 +Bar_None +Beat_0 +Chord_F_M7 +Note_Pitch_81 +Note_Velocity_64 +Note_Duration_480 +Note_Pitch_29 +Note_Velocity_52 +Note_Duration_480 +Beat_4 +Note_Pitch_81 +Note_Velocity_66 +Note_Duration_120 +Note_Pitch_60 +Note_Velocity_62 +Note_Duration_1080 +Note_Pitch_53 +Note_Velocity_58 +Note_Duration_1440 +Note_Pitch_69 +Note_Velocity_62 +Note_Duration_1440 +Beat_8 +Chord_F_M +Tempo_140 +Note_Pitch_84 +Note_Velocity_64 +Note_Duration_240 +Beat_10 +Note_Pitch_81 +Note_Velocity_58 +Note_Duration_240 +Note_Pitch_84 +Note_Velocity_58 +Note_Duration_240 +Beat_12 +Tempo_146 +Note_Pitch_84 +Note_Velocity_62 +Note_Duration_240 +Note_Pitch_81 +Note_Velocity_60 +Note_Duration_240 +Note_Pitch_29 +Note_Velocity_54 +Note_Duration_360 +Beat_14 +Note_Pitch_84 +Note_Velocity_62 +Note_Duration_240 +Note_Pitch_81 +Note_Velocity_58 +Note_Duration_240 +Bar_None +Beat_0 +Chord_E_m +Note_Pitch_83 +Note_Velocity_62 +Note_Duration_480 +Note_Pitch_43 +Note_Velocity_54 +Note_Duration_480 +Note_Pitch_31 +Note_Velocity_60 +Note_Duration_480 +Beat_4 +Tempo_143 +Note_Pitch_83 +Note_Velocity_64 +Note_Duration_480 +Note_Pitch_79 +Note_Velocity_64 +Note_Duration_480 +Note_Pitch_67 +Note_Velocity_62 +Note_Duration_600 +Note_Pitch_64 +Note_Velocity_56 +Note_Duration_600 +Beat_8 +Chord_E_m +Note_Pitch_83 +Note_Velocity_56 +Note_Duration_480 +Note_Pitch_79 +Note_Velocity_58 +Note_Duration_120 +Beat_9 +Note_Pitch_79 +Note_Velocity_64 +Note_Duration_600 +Beat_12 +Tempo_146 +Note_Pitch_76 +Note_Velocity_50 +Note_Duration_480 +Note_Pitch_33 +Note_Velocity_62 +Note_Duration_240 +Beat_14 +Note_Pitch_83 +Note_Velocity_58 +Note_Duration_240 +Note_Pitch_35 +Note_Velocity_60 +Note_Duration_240 +Note_Pitch_79 +Note_Velocity_54 +Note_Duration_240 +Bar_None +Beat_0 +Chord_F_M7 +Tempo_143 +Note_Pitch_81 +Note_Velocity_66 +Note_Duration_480 +Note_Pitch_72 +Note_Velocity_44 +Note_Duration_600 +Beat_4 +Tempo_146 +Note_Pitch_81 +Note_Velocity_58 +Note_Duration_480 +Note_Pitch_69 +Note_Velocity_52 +Note_Duration_720 +Note_Pitch_65 +Note_Velocity_54 +Note_Duration_120 +Note_Pitch_62 +Note_Velocity_52 +Note_Duration_840 +Note_Pitch_60 +Note_Velocity_60 +Note_Duration_840 +Note_Pitch_53 +Note_Velocity_50 +Note_Duration_120 +Beat_8 +Chord_F_M7 +Tempo_143 +Note_Pitch_84 +Note_Velocity_56 +Note_Duration_240 +Note_Pitch_81 +Note_Velocity_56 +Note_Duration_240 +Beat_10 +Note_Pitch_84 +Note_Velocity_62 +Note_Duration_240 +Note_Pitch_81 +Note_Velocity_58 +Note_Duration_120 +Note_Pitch_26 +Note_Velocity_48 +Note_Duration_120 +Beat_12 +Note_Pitch_84 +Note_Velocity_58 +Note_Duration_240 +Note_Pitch_81 +Note_Velocity_54 +Note_Duration_240 +Beat_14 +Note_Pitch_84 +Note_Velocity_60 +Note_Duration_240 +Note_Pitch_81 +Note_Velocity_56 +Note_Duration_240 +Note_Pitch_29 +Note_Velocity_44 +Note_Duration_240 +Bar_None +Beat_0 +Chord_E_m7 +Note_Pitch_84 +Note_Velocity_60 +Note_Duration_480 +Note_Pitch_83 +Note_Velocity_62 +Note_Duration_480 +Note_Pitch_28 +Note_Velocity_60 +Note_Duration_600 +Note_Pitch_40 +Note_Velocity_54 +Note_Duration_480 +Beat_4 +Tempo_146 +Note_Pitch_83 +Note_Velocity_62 +Note_Duration_480 +Note_Pitch_81 +Note_Velocity_58 +Note_Duration_120 +Note_Pitch_79 +Note_Velocity_60 +Note_Duration_480 +Note_Pitch_67 +Note_Velocity_56 +Note_Duration_600 +Note_Pitch_62 +Note_Velocity_50 +Note_Duration_840 +Note_Pitch_59 +Note_Velocity_52 +Note_Duration_1440 +Note_Pitch_55 +Note_Velocity_48 +Note_Duration_120 +Note_Pitch_52 +Note_Velocity_50 +Note_Duration_0 +Beat_5 +Note_Pitch_84 +Note_Velocity_58 +Note_Duration_0 +Beat_8 +Note_Pitch_83 +Note_Velocity_52 +Note_Duration_1080 +Note_Pitch_79 +Note_Velocity_64 +Note_Duration_1080 +Beat_10 +Note_Pitch_28 +Note_Velocity_44 +Note_Duration_720 +Note_Pitch_40 +Note_Velocity_44 +Note_Duration_720 +Beat_12 +Tempo_143 +Note_Pitch_76 +Note_Velocity_58 +Note_Duration_600 +Bar_None +Beat_0 +Chord_F_M7 +Note_Pitch_81 +Note_Velocity_60 +Note_Duration_480 +Note_Pitch_29 +Note_Velocity_44 +Note_Duration_120 +Beat_4 +Note_Pitch_81 +Note_Velocity_66 +Note_Duration_120 +Note_Pitch_69 +Note_Velocity_56 +Note_Duration_960 +Note_Pitch_62 +Note_Velocity_62 +Note_Duration_1080 +Note_Pitch_60 +Note_Velocity_62 +Note_Duration_1080 +Note_Pitch_57 +Note_Velocity_54 +Note_Duration_720 +Note_Pitch_53 +Note_Velocity_58 +Note_Duration_240 +Note_Pitch_65 +Note_Velocity_58 +Note_Duration_840 +Beat_8 +Chord_F_M +Tempo_146 +Note_Pitch_84 +Note_Velocity_60 +Note_Duration_480 +Note_Pitch_81 +Note_Velocity_58 +Note_Duration_480 +Beat_12 +Tempo_140 +Note_Pitch_84 +Note_Velocity_60 +Note_Duration_120 +Note_Pitch_81 +Note_Velocity_60 +Note_Duration_480 +Note_Pitch_41 +Note_Velocity_48 +Note_Duration_480 +Note_Pitch_29 +Note_Velocity_48 +Note_Duration_120 +Beat_13 +Note_Pitch_84 +Note_Velocity_60 +Note_Duration_840 +Bar_None +Beat_0 +Chord_G_7 +Tempo_146 +Note_Pitch_83 +Note_Velocity_58 +Note_Duration_480 +Note_Pitch_81 +Note_Velocity_56 +Note_Duration_480 +Note_Pitch_43 +Note_Velocity_52 +Note_Duration_600 +Note_Pitch_31 +Note_Velocity_56 +Note_Duration_480 +Beat_4 +Tempo_143 +Note_Pitch_84 +Note_Velocity_58 +Note_Duration_360 +Note_Pitch_83 +Note_Velocity_62 +Note_Duration_480 +Note_Pitch_81 +Note_Velocity_62 +Note_Duration_600 +Note_Pitch_64 +Note_Velocity_56 +Note_Duration_720 +Note_Pitch_62 +Note_Velocity_58 +Note_Duration_1560 +Note_Pitch_59 +Note_Velocity_52 +Note_Duration_240 +Beat_8 +Chord_G_M +Tempo_146 +Note_Pitch_83 +Note_Velocity_54 +Note_Duration_720 +Note_Pitch_79 +Note_Velocity_60 +Note_Duration_120 +Beat_9 +Note_Pitch_79 +Note_Velocity_64 +Note_Duration_960 +Beat_12 +Tempo_143 +Note_Pitch_76 +Note_Velocity_66 +Note_Duration_960 +Note_Pitch_33 +Note_Velocity_62 +Note_Duration_600 +Beat_14 +Note_Pitch_83 +Note_Velocity_58 +Note_Duration_720 +Note_Pitch_35 +Note_Velocity_54 +Note_Duration_240 +Bar_None +Beat_0 +Chord_A_m7 +Tempo_146 +Note_Pitch_81 +Note_Velocity_64 +Note_Duration_480 +Note_Pitch_72 +Note_Velocity_52 +Note_Duration_480 +Beat_4 +Chord_F_M +Tempo_143 +Note_Pitch_81 +Note_Velocity_70 +Note_Duration_480 +Note_Pitch_62 +Note_Velocity_60 +Note_Duration_1560 +Note_Pitch_60 +Note_Velocity_60 +Note_Duration_960 +Note_Pitch_53 +Note_Velocity_52 +Note_Duration_120 +Note_Pitch_72 +Note_Velocity_56 +Note_Duration_0 +Note_Pitch_69 +Note_Velocity_56 +Note_Duration_120 +Beat_6 +Note_Pitch_79 +Note_Velocity_68 +Note_Duration_480 +Beat_8 +Chord_C_sus2 +Tempo_146 +Note_Pitch_81 +Note_Velocity_64 +Note_Duration_240 +Beat_10 +Note_Pitch_81 +Note_Velocity_58 +Note_Duration_240 +Note_Pitch_79 +Note_Velocity_60 +Note_Duration_1080 +Note_Pitch_72 +Note_Velocity_54 +Note_Duration_720 +Note_Pitch_38 +Note_Velocity_44 +Note_Duration_720 +Note_Pitch_26 +Note_Velocity_48 +Note_Duration_0 +Beat_12 +Tempo_143 +Note_Pitch_81 +Note_Velocity_64 +Note_Duration_240 +Beat_14 +Note_Pitch_84 +Note_Velocity_60 +Note_Duration_240 +Note_Pitch_81 +Note_Velocity_58 +Note_Duration_240 +Note_Pitch_41 +Note_Velocity_46 +Note_Duration_240 +Note_Pitch_29 +Note_Velocity_44 +Note_Duration_120 +Bar_None +Beat_0 +Chord_E_m7 +Note_Pitch_86 +Note_Velocity_70 +Note_Duration_1320 +Note_Pitch_28 +Note_Velocity_58 +Note_Duration_360 +Note_Pitch_40 +Note_Velocity_50 +Note_Duration_360 +Beat_4 +Tempo_146 +Note_Pitch_64 +Note_Velocity_64 +Note_Duration_1920 +Note_Pitch_62 +Note_Velocity_54 +Note_Duration_1560 +Note_Pitch_59 +Note_Velocity_64 +Note_Duration_1440 +Note_Pitch_55 +Note_Velocity_56 +Note_Duration_120 +Beat_8 +Tempo_143 +Note_Pitch_76 +Note_Velocity_66 +Note_Duration_480 +Note_Pitch_43 +Note_Velocity_44 +Note_Duration_0 +Beat_10 +Note_Pitch_40 +Note_Velocity_44 +Note_Duration_720 +Beat_12 +Note_Pitch_76 +Note_Velocity_64 +Note_Duration_120 +Beat_13 +Note_Pitch_76 +Note_Velocity_58 +Note_Duration_360 +Bar_None +Beat_0 +Chord_F_M7 +Note_Pitch_76 +Note_Velocity_56 +Note_Duration_600 +Note_Pitch_29 +Note_Velocity_44 +Note_Duration_360 +Beat_4 +Tempo_146 +Note_Pitch_77 +Note_Velocity_62 +Note_Duration_960 +Note_Pitch_72 +Note_Velocity_64 +Note_Duration_1080 +Note_Pitch_65 +Note_Velocity_60 +Note_Duration_1440 +Note_Pitch_60 +Note_Velocity_64 +Note_Duration_1080 +Note_Pitch_69 +Note_Velocity_56 +Note_Duration_600 +Note_Pitch_62 +Note_Velocity_54 +Note_Duration_1080 +Note_Pitch_57 +Note_Velocity_54 +Note_Duration_960 +Note_Pitch_53 +Note_Velocity_54 +Note_Duration_0 +Beat_8 +Tempo_143 +Note_Pitch_84 +Note_Velocity_60 +Note_Duration_480 +Note_Pitch_83 +Note_Velocity_62 +Note_Duration_600 +Beat_12 +Tempo_140 +Note_Pitch_76 +Note_Velocity_66 +Note_Duration_480 +Note_Pitch_29 +Note_Velocity_58 +Note_Duration_480 +Bar_None +Beat_0 +Chord_E_m7 +Tempo_149 +Note_Pitch_83 +Note_Velocity_54 +Note_Duration_600 +Note_Pitch_76 +Note_Velocity_50 +Note_Duration_600 +Note_Pitch_43 +Note_Velocity_50 +Note_Duration_120 +Note_Pitch_31 +Note_Velocity_54 +Note_Duration_480 +Beat_4 +Tempo_143 +Note_Pitch_72 +Note_Velocity_66 +Note_Duration_240 +Note_Pitch_64 +Note_Velocity_46 +Note_Duration_1320 +Note_Pitch_62 +Note_Velocity_54 +Note_Duration_1320 +Note_Pitch_59 +Note_Velocity_50 +Note_Duration_120 +Note_Pitch_67 +Note_Velocity_48 +Note_Duration_840 +Beat_6 +Note_Pitch_74 +Note_Velocity_54 +Note_Duration_480 +Beat_8 +Chord_C_M +Note_Pitch_76 +Note_Velocity_58 +Note_Duration_240 +Note_Pitch_72 +Note_Velocity_50 +Note_Duration_240 +Beat_10 +Note_Pitch_76 +Note_Velocity_66 +Note_Duration_240 +Note_Pitch_72 +Note_Velocity_46 +Note_Duration_720 +Beat_12 +Chord_A_m +Note_Pitch_76 +Note_Velocity_58 +Note_Duration_240 +Note_Pitch_45 +Note_Velocity_48 +Note_Duration_240 +Note_Pitch_33 +Note_Velocity_50 +Note_Duration_240 +Beat_14 +Note_Pitch_76 +Note_Velocity_56 +Note_Duration_240 +Note_Pitch_35 +Note_Velocity_52 +Note_Duration_120 +Bar_None +Beat_0 +Chord_D_m7 +Tempo_146 +Note_Pitch_76 +Note_Velocity_68 +Note_Duration_600 +Note_Pitch_72 +Note_Velocity_60 +Note_Duration_480 +Note_Pitch_26 +Note_Velocity_50 +Note_Duration_480 +Beat_4 +Note_Pitch_72 +Note_Velocity_62 +Note_Duration_1920 +Note_Pitch_81 +Note_Velocity_62 +Note_Duration_1440 +Note_Pitch_77 +Note_Velocity_60 +Note_Duration_0 +Note_Pitch_69 +Note_Velocity_52 +Note_Duration_1800 +Note_Pitch_65 +Note_Velocity_58 +Note_Duration_1920 +Note_Pitch_62 +Note_Velocity_52 +Note_Duration_1800 +Note_Pitch_60 +Note_Velocity_52 +Note_Duration_1920 +Note_Pitch_53 +Note_Velocity_50 +Note_Duration_0 +Beat_8 +Tempo_143 +Note_Pitch_83 +Note_Velocity_62 +Note_Duration_960 +Beat_10 +Note_Pitch_38 +Note_Velocity_46 +Note_Duration_720 +Note_Pitch_26 +Note_Velocity_46 +Note_Duration_720 +Beat_12 +Tempo_140 +Note_Pitch_76 +Note_Velocity_50 +Note_Duration_1080 +Beat_14 +Note_Pitch_29 +Note_Velocity_52 +Note_Duration_240 +Bar_None +Beat_0 +Chord_F_M7 +Tempo_146 +Note_Pitch_83 +Note_Velocity_58 +Note_Duration_600 +Note_Pitch_74 +Note_Velocity_54 +Note_Duration_600 +Beat_4 +Chord_G_7 +Note_Pitch_72 +Note_Velocity_64 +Note_Duration_240 +Note_Pitch_67 +Note_Velocity_64 +Note_Duration_1920 +Note_Pitch_59 +Note_Velocity_62 +Note_Duration_840 +Note_Pitch_64 +Note_Velocity_62 +Note_Duration_1920 +Note_Pitch_62 +Note_Velocity_60 +Note_Duration_1560 +Beat_6 +Note_Pitch_74 +Note_Velocity_60 +Note_Duration_720 +Beat_8 +Chord_E_m7 +Tempo_143 +Note_Pitch_76 +Note_Velocity_66 +Note_Duration_480 +Beat_10 +Note_Pitch_28 +Note_Velocity_60 +Note_Duration_840 +Note_Pitch_40 +Note_Velocity_54 +Note_Duration_720 +Beat_12 +Note_Pitch_76 +Note_Velocity_66 +Note_Duration_240 +Beat_14 +Note_Pitch_76 +Note_Velocity_60 +Note_Duration_240 +Bar_None +Beat_0 +Tempo_146 +Note_Pitch_76 +Note_Velocity_56 +Note_Duration_600 +Note_Pitch_29 +Note_Velocity_48 +Note_Duration_120 +Beat_4 +Chord_F_M7 +Note_Pitch_60 +Note_Velocity_58 +Note_Duration_1200 +Note_Pitch_53 +Note_Velocity_52 +Note_Duration_120 +Note_Pitch_72 +Note_Velocity_58 +Note_Duration_120 +Note_Pitch_69 +Note_Velocity_60 +Note_Duration_1200 +Note_Pitch_62 +Note_Velocity_52 +Note_Duration_1080 +Beat_8 +Chord_D_m7 +Tempo_140 +Note_Pitch_83 +Note_Velocity_64 +Note_Duration_480 +Beat_12 +Chord_F_M7 +Tempo_143 +Note_Pitch_83 +Note_Velocity_54 +Note_Duration_120 +Note_Pitch_76 +Note_Velocity_66 +Note_Duration_1200 +Note_Pitch_29 +Note_Velocity_54 +Note_Duration_120 +Bar_None +Beat_0 +Chord_E_m7 +Tempo_146 +Note_Pitch_31 +Note_Velocity_62 +Note_Duration_600 +Beat_4 +Tempo_143 +Note_Pitch_72 +Note_Velocity_58 +Note_Duration_120 +Note_Pitch_67 +Note_Velocity_54 +Note_Duration_600 +Note_Pitch_62 +Note_Velocity_60 +Note_Duration_1200 +Note_Pitch_59 +Note_Velocity_62 +Note_Duration_480 +Beat_5 +Note_Pitch_72 +Note_Velocity_62 +Note_Duration_240 +Beat_6 +Note_Pitch_74 +Note_Velocity_60 +Note_Duration_240 +Beat_8 +Chord_G_7 +Note_Pitch_76 +Note_Velocity_66 +Note_Duration_240 +Beat_10 +Note_Pitch_79 +Note_Velocity_66 +Note_Duration_480 +Beat_12 +Note_Pitch_77 +Note_Velocity_58 +Note_Duration_240 +Note_Pitch_33 +Note_Velocity_62 +Note_Duration_240 +Beat_14 +Note_Pitch_76 +Note_Velocity_58 +Note_Duration_240 +Note_Pitch_35 +Note_Velocity_48 +Note_Duration_240 +Bar_None +Beat_0 +Chord_D_m7 +Tempo_146 +Note_Pitch_76 +Note_Velocity_64 +Note_Duration_600 +Note_Pitch_72 +Note_Velocity_50 +Note_Duration_480 +Note_Pitch_38 +Note_Velocity_42 +Note_Duration_600 +Note_Pitch_26 +Note_Velocity_44 +Note_Duration_480 +Beat_2 +Note_Pitch_74 +Note_Velocity_66 +Note_Duration_360 +Beat_4 +Note_Pitch_81 +Note_Velocity_64 +Note_Duration_1320 +Note_Pitch_72 +Note_Velocity_54 +Note_Duration_720 +Note_Pitch_69 +Note_Velocity_56 +Note_Duration_1440 +Note_Pitch_62 +Note_Velocity_48 +Note_Duration_1320 +Note_Pitch_60 +Note_Velocity_52 +Note_Duration_1320 +Note_Pitch_53 +Note_Velocity_46 +Note_Duration_120 +Beat_8 +Chord_F_M7 +Note_Pitch_83 +Note_Velocity_56 +Note_Duration_840 +Beat_10 +Note_Pitch_72 +Note_Velocity_50 +Note_Duration_600 +Beat_12 +Note_Pitch_76 +Note_Velocity_54 +Note_Duration_360 +Beat_14 +Note_Pitch_74 +Note_Velocity_62 +Note_Duration_480 +Note_Pitch_29 +Note_Velocity_52 +Note_Duration_120 +Bar_None +Beat_0 +Chord_D_m +Tempo_140 +Beat_2 +Note_Pitch_74 +Note_Velocity_58 +Note_Duration_240 +Note_Pitch_29 +Note_Velocity_44 +Note_Duration_240 +Beat_4 +Tempo_143 +Note_Pitch_74 +Note_Velocity_66 +Note_Duration_840 +Note_Pitch_29 +Note_Velocity_44 +Note_Duration_960 +Beat_6 +Note_Pitch_76 +Note_Velocity_66 +Note_Duration_240 +Beat_8 +Tempo_146 +Note_Pitch_76 +Note_Velocity_66 +Note_Duration_600 +Beat_11 +Note_Pitch_74 +Note_Velocity_62 +Note_Duration_240 +Beat_12 +Chord_C_+ +Tempo_143 +Note_Pitch_72 +Note_Velocity_60 +Note_Duration_1080 +Bar_None +Beat_0 +Chord_D_m7 +Note_Pitch_77 +Note_Velocity_68 +Note_Duration_120 +Note_Pitch_74 +Note_Velocity_58 +Note_Duration_120 +Note_Pitch_41 +Note_Velocity_50 +Note_Duration_120 +Note_Pitch_29 +Note_Velocity_50 +Note_Duration_240 +Beat_1 +Note_Pitch_77 +Note_Velocity_68 +Note_Duration_120 +Note_Pitch_74 +Note_Velocity_58 +Note_Duration_120 +Beat_2 +Note_Pitch_77 +Note_Velocity_66 +Note_Duration_240 +Note_Pitch_74 +Note_Velocity_64 +Note_Duration_240 +Beat_4 +Note_Pitch_81 +Note_Velocity_66 +Note_Duration_240 +Note_Pitch_77 +Note_Velocity_62 +Note_Duration_840 +Note_Pitch_74 +Note_Velocity_60 +Note_Duration_840 +Note_Pitch_69 +Note_Velocity_58 +Note_Duration_1080 +Note_Pitch_65 +Note_Velocity_56 +Note_Duration_1080 +Note_Pitch_62 +Note_Velocity_62 +Note_Duration_1080 +Note_Pitch_60 +Note_Velocity_54 +Note_Duration_960 +Beat_6 +Note_Pitch_79 +Note_Velocity_66 +Note_Duration_240 +Note_Pitch_76 +Note_Velocity_60 +Note_Duration_240 +Beat_8 +Chord_F_M7 +Note_Pitch_79 +Note_Velocity_58 +Note_Duration_600 +Note_Pitch_76 +Note_Velocity_60 +Note_Duration_480 +Note_Pitch_72 +Note_Velocity_48 +Note_Duration_240 +Beat_11 +Note_Pitch_77 +Note_Velocity_66 +Note_Duration_240 +Note_Pitch_74 +Note_Velocity_62 +Note_Duration_240 +Note_Pitch_29 +Note_Velocity_46 +Note_Duration_120 +Beat_12 +Tempo_146 +Note_Pitch_76 +Note_Velocity_64 +Note_Duration_600 +Note_Pitch_72 +Note_Velocity_56 +Note_Duration_600 +Note_Pitch_41 +Note_Velocity_52 +Note_Duration_120 +Bar_None +Beat_0 +Chord_G_M +Tempo_143 +Note_Pitch_79 +Note_Velocity_66 +Note_Duration_600 +Note_Pitch_74 +Note_Velocity_54 +Note_Duration_240 +Note_Pitch_43 +Note_Velocity_52 +Note_Duration_120 +Note_Pitch_31 +Note_Velocity_54 +Note_Duration_480 +Beat_2 +Note_Pitch_74 +Note_Velocity_66 +Note_Duration_360 +Beat_4 +Chord_C_M +Tempo_146 +Note_Pitch_76 +Note_Velocity_62 +Note_Duration_240 +Note_Pitch_72 +Note_Velocity_66 +Note_Duration_600 +Note_Pitch_64 +Note_Velocity_60 +Note_Duration_720 +Note_Pitch_62 +Note_Velocity_62 +Note_Duration_960 +Beat_6 +Note_Pitch_81 +Note_Velocity_68 +Note_Duration_360 +Note_Pitch_76 +Note_Velocity_66 +Note_Duration_240 +Beat_8 +Chord_C_M +Tempo_140 +Note_Pitch_79 +Note_Velocity_70 +Note_Duration_600 +Note_Pitch_76 +Note_Velocity_62 +Note_Duration_360 +Beat_11 +Note_Pitch_77 +Note_Velocity_68 +Note_Duration_240 +Note_Pitch_74 +Note_Velocity_56 +Note_Duration_240 +Note_Pitch_43 +Note_Velocity_52 +Note_Duration_120 +Note_Pitch_31 +Note_Velocity_58 +Note_Duration_0 +Beat_12 +Chord_F_M +Note_Pitch_72 +Note_Velocity_60 +Note_Duration_480 +Note_Pitch_33 +Note_Velocity_66 +Note_Duration_480 +Beat_14 +Note_Pitch_77 +Note_Velocity_56 +Note_Duration_240 +Note_Pitch_35 +Note_Velocity_58 +Note_Duration_240 +Beat_15 +Note_Pitch_74 +Note_Velocity_62 +Note_Duration_720 +Bar_None +Beat_0 +Chord_F_M7 +Tempo_149 +Note_Pitch_72 +Note_Velocity_56 +Note_Duration_480 +Beat_4 +Tempo_146 +Note_Pitch_72 +Note_Velocity_62 +Note_Duration_840 +Note_Pitch_69 +Note_Velocity_56 +Note_Duration_1800 +Note_Pitch_62 +Note_Velocity_54 +Note_Duration_1920 +Note_Pitch_60 +Note_Velocity_62 +Note_Duration_1680 +Note_Pitch_53 +Note_Velocity_52 +Note_Duration_120 +Note_Pitch_65 +Note_Velocity_56 +Note_Duration_600 +Beat_6 +Note_Pitch_81 +Note_Velocity_66 +Note_Duration_1680 +Note_Pitch_76 +Note_Velocity_60 +Note_Duration_480 +Beat_8 +Chord_D_m7 +Note_Pitch_79 +Note_Velocity_64 +Note_Duration_480 +Beat_10 +Note_Pitch_38 +Note_Velocity_40 +Note_Duration_600 +Note_Pitch_26 +Note_Velocity_42 +Note_Duration_120 +Beat_11 +Note_Pitch_76 +Note_Velocity_66 +Note_Duration_1080 +Note_Pitch_72 +Note_Velocity_58 +Note_Duration_360 +Beat_12 +Chord_D_m7 +Tempo_140 +Note_Pitch_79 +Note_Velocity_66 +Note_Duration_1080 +Note_Pitch_74 +Note_Velocity_54 +Note_Duration_960 +Beat_14 +Note_Pitch_72 +Note_Velocity_50 +Note_Duration_240 +Note_Pitch_29 +Note_Velocity_46 +Note_Duration_120 +Bar_None +Beat_0 +Chord_A_m7 +Tempo_146 +Note_Pitch_72 +Note_Velocity_46 +Note_Duration_480 +Beat_4 +Chord_E_m7 +Tempo_143 +Note_Pitch_67 +Note_Velocity_62 +Note_Duration_1080 +Note_Pitch_64 +Note_Velocity_62 +Note_Duration_1920 +Note_Pitch_62 +Note_Velocity_62 +Note_Duration_1920 +Note_Pitch_59 +Note_Velocity_56 +Note_Duration_480 +Beat_6 +Note_Pitch_81 +Note_Velocity_64 +Note_Duration_240 +Note_Pitch_76 +Note_Velocity_56 +Note_Duration_240 +Beat_8 +Chord_C_M +Tempo_146 +Note_Pitch_81 +Note_Velocity_62 +Note_Duration_480 +Note_Pitch_79 +Note_Velocity_62 +Note_Duration_360 +Note_Pitch_74 +Note_Velocity_62 +Note_Duration_960 +Beat_10 +Note_Pitch_40 +Note_Velocity_44 +Note_Duration_720 +Note_Pitch_28 +Note_Velocity_48 +Note_Duration_720 +Beat_11 +Note_Pitch_79 +Note_Velocity_68 +Note_Duration_720 +Note_Pitch_72 +Note_Velocity_52 +Note_Duration_720 +Beat_12 +Tempo_143 +Note_Pitch_76 +Note_Velocity_62 +Note_Duration_480 +Note_Pitch_69 +Note_Velocity_58 +Note_Duration_480 +Note_Pitch_81 +Note_Velocity_64 +Note_Duration_840 +Bar_None +Beat_0 +Chord_D_m7 +Note_Pitch_74 +Note_Velocity_66 +Note_Duration_600 +Note_Pitch_29 +Note_Velocity_50 +Note_Duration_120 +Beat_4 +Tempo_146 +Note_Pitch_72 +Note_Velocity_66 +Note_Duration_960 +Note_Pitch_69 +Note_Velocity_64 +Note_Duration_1920 +Note_Pitch_65 +Note_Velocity_56 +Note_Duration_1560 +Note_Pitch_62 +Note_Velocity_56 +Note_Duration_1920 +Note_Pitch_60 +Note_Velocity_60 +Note_Duration_1440 +Note_Pitch_53 +Note_Velocity_54 +Note_Duration_120 +Beat_6 +Note_Pitch_76 +Note_Velocity_52 +Note_Duration_240 +Beat_8 +Chord_F_M7 +Tempo_143 +Note_Pitch_76 +Note_Velocity_66 +Note_Duration_600 +Beat_11 +Note_Pitch_74 +Note_Velocity_58 +Note_Duration_240 +Beat_12 +Note_Pitch_72 +Note_Velocity_68 +Note_Duration_360 +Beat_15 +Note_Pitch_72 +Note_Velocity_60 +Note_Duration_240 +Bar_None +Beat_0 +Chord_G_sus2 +Note_Pitch_74 +Note_Velocity_62 +Note_Duration_240 +Note_Pitch_31 +Note_Velocity_58 +Note_Duration_600 +Beat_2 +Note_Pitch_74 +Note_Velocity_60 +Note_Duration_240 +Beat_4 +Chord_E_m7 +Tempo_146 +Note_Pitch_72 +Note_Velocity_66 +Note_Duration_480 +Note_Pitch_67 +Note_Velocity_62 +Note_Duration_1080 +Note_Pitch_64 +Note_Velocity_60 +Note_Duration_1080 +Note_Pitch_59 +Note_Velocity_62 +Note_Duration_840 +Note_Pitch_62 +Note_Velocity_58 +Note_Duration_960 +Beat_6 +Note_Pitch_76 +Note_Velocity_56 +Note_Duration_240 +Beat_8 +Chord_A_m7 +Tempo_143 +Note_Pitch_76 +Note_Velocity_64 +Note_Duration_600 +Note_Pitch_72 +Note_Velocity_46 +Note_Duration_480 +Beat_11 +Note_Pitch_74 +Note_Velocity_54 +Note_Duration_240 +Beat_12 +Note_Pitch_72 +Note_Velocity_66 +Note_Duration_360 +Note_Pitch_33 +Note_Velocity_62 +Note_Duration_480 +Beat_14 +Note_Pitch_35 +Note_Velocity_60 +Note_Duration_240 +Beat_15 +Note_Pitch_72 +Note_Velocity_66 +Note_Duration_360 +Bar_None +Beat_0 +Chord_D_m7 +Tempo_146 +Note_Pitch_74 +Note_Velocity_68 +Note_Duration_240 +Beat_2 +Note_Pitch_74 +Note_Velocity_66 +Note_Duration_360 +Note_Pitch_72 +Note_Velocity_56 +Note_Duration_240 +Beat_4 +Tempo_143 +Note_Pitch_69 +Note_Velocity_60 +Note_Duration_720 +Note_Pitch_65 +Note_Velocity_62 +Note_Duration_840 +Note_Pitch_62 +Note_Velocity_60 +Note_Duration_1560 +Note_Pitch_72 +Note_Velocity_58 +Note_Duration_120 +Note_Pitch_60 +Note_Velocity_56 +Note_Duration_720 +Note_Pitch_53 +Note_Velocity_56 +Note_Duration_0 +Beat_6 +Note_Pitch_76 +Note_Velocity_60 +Note_Duration_600 +Beat_8 +Chord_D_m7 +Note_Pitch_74 +Note_Velocity_62 +Note_Duration_480 +Beat_10 +Note_Pitch_72 +Note_Velocity_68 +Note_Duration_480 +Beat_12 +Tempo_146 +Note_Pitch_74 +Note_Velocity_56 +Note_Duration_480 +Beat_14 +Note_Pitch_84 +Note_Velocity_64 +Note_Duration_240 +Note_Pitch_72 +Note_Velocity_50 +Note_Duration_360 +Note_Pitch_29 +Note_Velocity_52 +Note_Duration_240 +Note_Pitch_81 +Note_Velocity_60 +Note_Duration_240 +Bar_None +Beat_0 +Chord_E_m7 +Tempo_143 +Note_Pitch_83 +Note_Velocity_66 +Note_Duration_600 +Note_Pitch_74 +Note_Velocity_62 +Note_Duration_600 +Note_Pitch_28 +Note_Velocity_54 +Note_Duration_360 +Beat_4 +Note_Pitch_64 +Note_Velocity_62 +Note_Duration_1440 +Note_Pitch_62 +Note_Velocity_64 +Note_Duration_1560 +Note_Pitch_59 +Note_Velocity_62 +Note_Duration_1440 +Note_Pitch_81 +Note_Velocity_64 +Note_Duration_120 +Note_Pitch_55 +Note_Velocity_60 +Note_Duration_1440 +Note_Pitch_43 +Note_Velocity_62 +Note_Duration_0 +Beat_5 +Note_Pitch_79 +Note_Velocity_66 +Note_Duration_360 +Beat_8 +Tempo_146 +Note_Pitch_83 +Note_Velocity_60 +Note_Duration_1080 +Note_Pitch_79 +Note_Velocity_62 +Note_Duration_120 +Note_Pitch_74 +Note_Velocity_66 +Note_Duration_1080 +Note_Pitch_43 +Note_Velocity_46 +Note_Duration_0 +Beat_9 +Note_Pitch_79 +Note_Velocity_70 +Note_Duration_360 +Beat_10 +Note_Pitch_40 +Note_Velocity_54 +Note_Duration_840 +Note_Pitch_28 +Note_Velocity_52 +Note_Duration_720 +Beat_12 +Note_Pitch_76 +Note_Velocity_66 +Note_Duration_600 +Note_Pitch_72 +Note_Velocity_68 +Note_Duration_1080 +Note_Pitch_79 +Note_Velocity_64 +Note_Duration_600 +Bar_None +Beat_0 +Chord_F_M7 +Tempo_140 +Note_Pitch_81 +Note_Velocity_70 +Note_Duration_480 +Note_Pitch_29 +Note_Velocity_54 +Note_Duration_240 +Note_Pitch_69 +Note_Velocity_60 +Note_Duration_480 +Note_Pitch_41 +Note_Velocity_48 +Note_Duration_120 +Beat_4 +Tempo_146 +Note_Pitch_81 +Note_Velocity_62 +Note_Duration_480 +Note_Pitch_60 +Note_Velocity_56 +Note_Duration_1080 +Note_Pitch_77 +Note_Velocity_60 +Note_Duration_960 +Note_Pitch_65 +Note_Velocity_56 +Note_Duration_0 +Note_Pitch_62 +Note_Velocity_52 +Note_Duration_960 +Note_Pitch_53 +Note_Velocity_50 +Note_Duration_120 +Beat_8 +Chord_F_M +Tempo_140 +Note_Pitch_84 +Note_Velocity_62 +Note_Duration_240 +Note_Pitch_81 +Note_Velocity_58 +Note_Duration_240 +Beat_10 +Note_Pitch_84 +Note_Velocity_60 +Note_Duration_240 +Note_Pitch_81 +Note_Velocity_58 +Note_Duration_240 +Beat_12 +Tempo_143 +Note_Pitch_84 +Note_Velocity_60 +Note_Duration_240 +Note_Pitch_81 +Note_Velocity_60 +Note_Duration_240 +Note_Pitch_41 +Note_Velocity_58 +Note_Duration_480 +Note_Pitch_29 +Note_Velocity_52 +Note_Duration_480 +Beat_14 +Note_Pitch_84 +Note_Velocity_62 +Note_Duration_240 +Note_Pitch_81 +Note_Velocity_58 +Note_Duration_240 +Bar_None +Beat_0 +Chord_C_M7 +Tempo_149 +Note_Pitch_84 +Note_Velocity_58 +Note_Duration_1080 +Note_Pitch_83 +Note_Velocity_60 +Note_Duration_600 +Note_Pitch_43 +Note_Velocity_56 +Note_Duration_480 +Note_Pitch_31 +Note_Velocity_58 +Note_Duration_480 +Beat_4 +Chord_G_M +Tempo_143 +Note_Pitch_81 +Note_Velocity_62 +Note_Duration_720 +Note_Pitch_62 +Note_Velocity_62 +Note_Duration_960 +Note_Pitch_59 +Note_Velocity_60 +Note_Duration_840 +Note_Pitch_55 +Note_Velocity_50 +Note_Duration_720 +Beat_5 +Note_Pitch_83 +Note_Velocity_64 +Note_Duration_240 +Beat_7 +Note_Pitch_83 +Note_Velocity_58 +Note_Duration_720 +Beat_8 +Chord_G_M +Tempo_146 +Note_Pitch_79 +Note_Velocity_68 +Note_Duration_120 +Note_Pitch_43 +Note_Velocity_46 +Note_Duration_480 +Beat_9 +Note_Pitch_79 +Note_Velocity_64 +Note_Duration_360 +Beat_12 +Tempo_143 +Note_Pitch_79 +Note_Velocity_64 +Note_Duration_480 +Note_Pitch_76 +Note_Velocity_56 +Note_Duration_600 +Note_Pitch_67 +Note_Velocity_64 +Note_Duration_480 +Note_Pitch_45 +Note_Velocity_56 +Note_Duration_240 +Note_Pitch_33 +Note_Velocity_56 +Note_Duration_240 +Beat_14 +Note_Pitch_47 +Note_Velocity_64 +Note_Duration_240 +Note_Pitch_35 +Note_Velocity_62 +Note_Duration_240 +Bar_None +Beat_0 +Chord_D_7 +Note_Pitch_81 +Note_Velocity_70 +Note_Duration_480 +Note_Pitch_69 +Note_Velocity_64 +Note_Duration_480 +Note_Pitch_38 +Note_Velocity_54 +Note_Duration_720 +Note_Pitch_26 +Note_Velocity_48 +Note_Duration_120 +Beat_4 +Chord_A_o +Tempo_146 +Note_Pitch_81 +Note_Velocity_66 +Note_Duration_480 +Note_Pitch_69 +Note_Velocity_62 +Note_Duration_720 +Note_Pitch_60 +Note_Velocity_62 +Note_Duration_840 +Note_Pitch_43 +Note_Velocity_54 +Note_Duration_0 +Beat_8 +Chord_F_M +Tempo_143 +Note_Pitch_84 +Note_Velocity_56 +Note_Duration_240 +Note_Pitch_81 +Note_Velocity_56 +Note_Duration_240 +Beat_10 +Note_Pitch_84 +Note_Velocity_60 +Note_Duration_240 +Note_Pitch_72 +Note_Velocity_54 +Note_Duration_600 +Note_Pitch_26 +Note_Velocity_50 +Note_Duration_120 +Note_Pitch_81 +Note_Velocity_58 +Note_Duration_0 +Beat_12 +Tempo_146 +Note_Pitch_84 +Note_Velocity_56 +Note_Duration_240 +Note_Pitch_81 +Note_Velocity_56 +Note_Duration_240 +Beat_14 +Note_Pitch_84 +Note_Velocity_60 +Note_Duration_360 +Note_Pitch_81 +Note_Velocity_58 +Note_Duration_240 +Note_Pitch_41 +Note_Velocity_46 +Note_Duration_360 +Note_Pitch_48 +Note_Velocity_46 +Note_Duration_240 +Bar_None +Beat_0 +Chord_E_m7 +Tempo_140 +Note_Pitch_83 +Note_Velocity_62 +Note_Duration_1920 +Note_Pitch_40 +Note_Velocity_54 +Note_Duration_480 +Note_Pitch_28 +Note_Velocity_52 +Note_Duration_120 +Beat_4 +Tempo_146 +Note_Pitch_81 +Note_Velocity_64 +Note_Duration_240 +Note_Pitch_79 +Note_Velocity_62 +Note_Duration_120 +Note_Pitch_62 +Note_Velocity_64 +Note_Duration_1560 +Note_Pitch_59 +Note_Velocity_58 +Note_Duration_840 +Note_Pitch_55 +Note_Velocity_60 +Note_Duration_1560 +Beat_5 +Note_Pitch_79 +Note_Velocity_68 +Note_Duration_360 +Beat_8 +Tempo_143 +Note_Pitch_79 +Note_Velocity_70 +Note_Duration_480 +Note_Pitch_74 +Note_Velocity_66 +Note_Duration_1080 +Note_Pitch_43 +Note_Velocity_54 +Note_Duration_120 +Beat_10 +Note_Pitch_40 +Note_Velocity_54 +Note_Duration_720 +Note_Pitch_28 +Note_Velocity_50 +Note_Duration_120 +Beat_12 +Tempo_146 +Note_Pitch_79 +Note_Velocity_64 +Note_Duration_720 +Note_Pitch_76 +Note_Velocity_66 +Note_Duration_600 +Note_Pitch_72 +Note_Velocity_68 +Note_Duration_480 +Bar_None +Beat_0 +Chord_F_M7 +Tempo_140 +Note_Pitch_81 +Note_Velocity_68 +Note_Duration_480 +Note_Pitch_69 +Note_Velocity_60 +Note_Duration_480 +Note_Pitch_41 +Note_Velocity_46 +Note_Duration_480 +Note_Pitch_29 +Note_Velocity_50 +Note_Duration_120 +Note_Pitch_72 +Note_Velocity_44 +Note_Duration_480 +Beat_4 +Tempo_146 +Note_Pitch_81 +Note_Velocity_66 +Note_Duration_480 +Note_Pitch_69 +Note_Velocity_58 +Note_Duration_1080 +Note_Pitch_60 +Note_Velocity_54 +Note_Duration_1080 +Note_Pitch_53 +Note_Velocity_52 +Note_Duration_120 +Note_Pitch_43 +Note_Velocity_56 +Note_Duration_120 +Beat_8 +Chord_F_M +Tempo_143 +Note_Pitch_84 +Note_Velocity_60 +Note_Duration_480 +Note_Pitch_81 +Note_Velocity_56 +Note_Duration_480 +Beat_12 +Note_Pitch_84 +Note_Velocity_62 +Note_Duration_120 +Note_Pitch_81 +Note_Velocity_58 +Note_Duration_600 +Note_Pitch_41 +Note_Velocity_52 +Note_Duration_600 +Note_Pitch_29 +Note_Velocity_50 +Note_Duration_120 +Beat_13 +Note_Pitch_84 +Note_Velocity_58 +Note_Duration_360 +Bar_None +Beat_0 +Chord_G_7 +Tempo_146 +Note_Pitch_83 +Note_Velocity_60 +Note_Duration_480 +Note_Pitch_43 +Note_Velocity_52 +Note_Duration_480 +Note_Pitch_31 +Note_Velocity_54 +Note_Duration_120 +Beat_4 +Note_Pitch_83 +Note_Velocity_64 +Note_Duration_240 +Note_Pitch_67 +Note_Velocity_64 +Note_Duration_840 +Note_Pitch_64 +Note_Velocity_62 +Note_Duration_1440 +Note_Pitch_62 +Note_Velocity_58 +Note_Duration_1560 +Note_Pitch_59 +Note_Velocity_64 +Note_Duration_960 +Note_Pitch_81 +Note_Velocity_62 +Note_Duration_600 +Beat_6 +Note_Pitch_83 +Note_Velocity_64 +Note_Duration_720 +Beat_8 +Chord_E_m7 +Tempo_143 +Note_Pitch_79 +Note_Velocity_68 +Note_Duration_480 +Beat_12 +Note_Pitch_79 +Note_Velocity_54 +Note_Duration_1080 +Note_Pitch_76 +Note_Velocity_54 +Note_Duration_1080 +Note_Pitch_45 +Note_Velocity_54 +Note_Duration_480 +Note_Pitch_33 +Note_Velocity_54 +Note_Duration_360 +Note_Pitch_83 +Note_Velocity_52 +Note_Duration_240 +Beat_14 +Note_Pitch_83 +Note_Velocity_60 +Note_Duration_720 +Note_Pitch_47 +Note_Velocity_58 +Note_Duration_240 +Note_Pitch_35 +Note_Velocity_60 +Note_Duration_240 +Bar_None +Beat_0 +Chord_G_7 +Tempo_146 +Note_Pitch_81 +Note_Velocity_64 +Note_Duration_480 +Note_Pitch_38 +Note_Velocity_52 +Note_Duration_480 +Note_Pitch_72 +Note_Velocity_48 +Note_Duration_0 +Beat_4 +Chord_F_M7 +Note_Pitch_81 +Note_Velocity_70 +Note_Duration_480 +Note_Pitch_62 +Note_Velocity_60 +Note_Duration_840 +Note_Pitch_60 +Note_Velocity_52 +Note_Duration_720 +Note_Pitch_53 +Note_Velocity_52 +Note_Duration_240 +Note_Pitch_69 +Note_Velocity_54 +Note_Duration_720 +Note_Pitch_57 +Note_Velocity_48 +Note_Duration_120 +Beat_6 +Note_Pitch_79 +Note_Velocity_64 +Note_Duration_600 +Note_Pitch_72 +Note_Velocity_54 +Note_Duration_480 +Beat_8 +Chord_A_m7 +Tempo_140 +Note_Pitch_81 +Note_Velocity_66 +Note_Duration_480 +Note_Pitch_76 +Note_Velocity_48 +Note_Duration_240 +Beat_10 +Note_Pitch_72 +Note_Velocity_66 +Note_Duration_480 +Note_Pitch_38 +Note_Velocity_52 +Note_Duration_480 +Note_Pitch_26 +Note_Velocity_56 +Note_Duration_0 +Beat_12 +Chord_D_m7 +Tempo_143 +Note_Pitch_81 +Note_Velocity_68 +Note_Duration_240 +Note_Pitch_69 +Note_Velocity_62 +Note_Duration_240 +Beat_14 +Note_Pitch_84 +Note_Velocity_68 +Note_Duration_240 +Note_Pitch_72 +Note_Velocity_64 +Note_Duration_240 +Note_Pitch_41 +Note_Velocity_62 +Note_Duration_240 +Bar_None +Beat_0 +Chord_D_m7 +Note_Pitch_86 +Note_Velocity_68 +Note_Duration_600 +Note_Pitch_81 +Note_Velocity_62 +Note_Duration_600 +Note_Pitch_74 +Note_Velocity_66 +Note_Duration_600 +Note_Pitch_40 +Note_Velocity_58 +Note_Duration_600 +Beat_3 +Note_Pitch_69 +Note_Velocity_50 +Note_Duration_120 +Beat_4 +Chord_G_M +Tempo_149 +Note_Pitch_64 +Note_Velocity_64 +Note_Duration_1560 +Note_Pitch_62 +Note_Velocity_58 +Note_Duration_1560 +Note_Pitch_59 +Note_Velocity_56 +Note_Duration_960 +Note_Pitch_67 +Note_Velocity_54 +Note_Duration_720 +Note_Pitch_55 +Note_Velocity_50 +Note_Duration_120 +Beat_8 +Chord_E_m7 +Tempo_140 +Note_Pitch_76 +Note_Velocity_66 +Note_Duration_480 +Beat_10 +Note_Pitch_40 +Note_Velocity_60 +Note_Duration_720 +Note_Pitch_28 +Note_Velocity_64 +Note_Duration_720 +Note_Pitch_74 +Note_Velocity_58 +Note_Duration_720 +Beat_12 +Tempo_146 +Note_Pitch_76 +Note_Velocity_62 +Note_Duration_120 +Beat_13 +Note_Pitch_76 +Note_Velocity_60 +Note_Duration_360 +Bar_None +Beat_0 +Chord_D_m7 +Tempo_143 +Note_Pitch_76 +Note_Velocity_64 +Note_Duration_960 +Note_Pitch_74 +Note_Velocity_64 +Note_Duration_480 +Note_Pitch_69 +Note_Velocity_58 +Note_Duration_480 +Note_Pitch_41 +Note_Velocity_56 +Note_Duration_600 +Note_Pitch_29 +Note_Velocity_60 +Note_Duration_240 +Note_Pitch_81 +Note_Velocity_66 +Note_Duration_480 +Beat_4 +Tempo_146 +Note_Pitch_74 +Note_Velocity_58 +Note_Duration_1080 +Note_Pitch_72 +Note_Velocity_56 +Note_Duration_600 +Note_Pitch_69 +Note_Velocity_54 +Note_Duration_1080 +Note_Pitch_62 +Note_Velocity_54 +Note_Duration_1080 +Note_Pitch_60 +Note_Velocity_56 +Note_Duration_1080 +Note_Pitch_57 +Note_Velocity_52 +Note_Duration_1080 +Note_Pitch_53 +Note_Velocity_52 +Note_Duration_120 +Beat_8 +Chord_D_m7 +Tempo_143 +Note_Pitch_83 +Note_Velocity_62 +Note_Duration_480 +Note_Pitch_79 +Note_Velocity_58 +Note_Duration_600 +Beat_12 +Chord_C_M7 +Tempo_140 +Note_Pitch_76 +Note_Velocity_64 +Note_Duration_1080 +Note_Pitch_72 +Note_Velocity_68 +Note_Duration_960 +Note_Pitch_67 +Note_Velocity_66 +Note_Duration_960 +Note_Pitch_55 +Note_Velocity_54 +Note_Duration_0 +Note_Pitch_48 +Note_Velocity_60 +Note_Duration_480 +Bar_None +Beat_0 +Chord_C_M +Tempo_146 +Note_Pitch_43 +Note_Velocity_60 +Note_Duration_480 +Note_Pitch_31 +Note_Velocity_60 +Note_Duration_480 +Beat_4 +Tempo_143 +Note_Pitch_72 +Note_Velocity_64 +Note_Duration_360 +Note_Pitch_67 +Note_Velocity_52 +Note_Duration_840 +Note_Pitch_64 +Note_Velocity_54 +Note_Duration_720 +Note_Pitch_62 +Note_Velocity_54 +Note_Duration_1320 +Beat_6 +Note_Pitch_74 +Note_Velocity_66 +Note_Duration_480 +Beat_8 +Chord_C_M +Tempo_146 +Note_Pitch_76 +Note_Velocity_56 +Note_Duration_240 +Beat_10 +Note_Pitch_76 +Note_Velocity_54 +Note_Duration_240 +Note_Pitch_72 +Note_Velocity_48 +Note_Duration_600 +Beat_12 +Chord_A_m +Tempo_143 +Note_Pitch_33 +Note_Velocity_62 +Note_Duration_240 +Note_Pitch_76 +Note_Velocity_56 +Note_Duration_240 +Note_Pitch_45 +Note_Velocity_58 +Note_Duration_240 +Beat_14 +Note_Pitch_76 +Note_Velocity_66 +Note_Duration_480 +Note_Pitch_47 +Note_Velocity_52 +Note_Duration_240 +Note_Pitch_35 +Note_Velocity_54 +Note_Duration_240 +Bar_None +Beat_0 +Chord_D_m7 +Tempo_146 +Note_Pitch_74 +Note_Velocity_62 +Note_Duration_600 +Note_Pitch_69 +Note_Velocity_56 +Note_Duration_480 +Note_Pitch_72 +Note_Velocity_56 +Note_Duration_480 +Note_Pitch_38 +Note_Velocity_58 +Note_Duration_480 +Note_Pitch_26 +Note_Velocity_56 +Note_Duration_0 +Beat_2 +Note_Pitch_76 +Note_Velocity_52 +Note_Duration_240 +Beat_4 +Chord_F_M7 +Tempo_143 +Note_Pitch_72 +Note_Velocity_60 +Note_Duration_720 +Note_Pitch_69 +Note_Velocity_56 +Note_Duration_720 +Note_Pitch_65 +Note_Velocity_62 +Note_Duration_1440 +Note_Pitch_60 +Note_Velocity_64 +Note_Duration_1200 +Note_Pitch_43 +Note_Velocity_56 +Note_Duration_0 +Beat_8 +Chord_B_o +Tempo_146 +Note_Pitch_86 +Note_Velocity_64 +Note_Duration_960 +Note_Pitch_83 +Note_Velocity_60 +Note_Duration_480 +Beat_10 +Note_Pitch_72 +Note_Velocity_56 +Note_Duration_600 +Note_Pitch_38 +Note_Velocity_54 +Note_Duration_720 +Note_Pitch_26 +Note_Velocity_54 +Note_Duration_120 +Beat_12 +Tempo_143 +Note_Pitch_83 +Note_Velocity_62 +Note_Duration_240 +Note_Pitch_76 +Note_Velocity_60 +Note_Duration_600 +Beat_14 +Note_Pitch_83 +Note_Velocity_58 +Note_Duration_240 +Note_Pitch_41 +Note_Velocity_56 +Note_Duration_240 +Note_Pitch_29 +Note_Velocity_56 +Note_Duration_240 +Bar_None +Beat_0 +Chord_E_m7 +Note_Pitch_83 +Note_Velocity_58 +Note_Duration_840 +Note_Pitch_74 +Note_Velocity_56 +Note_Duration_600 +Note_Pitch_28 +Note_Velocity_56 +Note_Duration_360 +Beat_4 +Tempo_146 +Note_Pitch_72 +Note_Velocity_60 +Note_Duration_840 +Note_Pitch_67 +Note_Velocity_64 +Note_Duration_1440 +Note_Pitch_62 +Note_Velocity_58 +Note_Duration_840 +Note_Pitch_59 +Note_Velocity_60 +Note_Duration_840 +Beat_6 +Note_Pitch_74 +Note_Velocity_58 +Note_Duration_840 +Beat_8 +Chord_C_M7 +Tempo_143 +Note_Pitch_76 +Note_Velocity_64 +Note_Duration_480 +Beat_10 +Note_Pitch_40 +Note_Velocity_54 +Note_Duration_720 +Note_Pitch_28 +Note_Velocity_60 +Note_Duration_720 +Beat_12 +Note_Pitch_76 +Note_Velocity_54 +Note_Duration_240 +Beat_14 +Note_Pitch_76 +Note_Velocity_52 +Note_Duration_240 +Bar_None +Beat_0 +Chord_F_M7 +Note_Pitch_41 +Note_Velocity_54 +Note_Duration_600 +Note_Pitch_81 +Note_Velocity_68 +Note_Duration_1200 +Note_Pitch_79 +Note_Velocity_64 +Note_Duration_960 +Note_Pitch_76 +Note_Velocity_60 +Note_Duration_1440 +Note_Pitch_69 +Note_Velocity_62 +Note_Duration_1440 +Note_Pitch_29 +Note_Velocity_58 +Note_Duration_120 +Beat_4 +Tempo_146 +Note_Pitch_57 +Note_Velocity_56 +Note_Duration_1080 +Note_Pitch_62 +Note_Velocity_52 +Note_Duration_1080 +Note_Pitch_60 +Note_Velocity_54 +Note_Duration_960 +Note_Pitch_53 +Note_Velocity_48 +Note_Duration_0 +Beat_8 +Chord_D_m7 +Tempo_143 +Note_Pitch_43 +Note_Velocity_52 +Note_Duration_120 +Note_Pitch_86 +Note_Velocity_66 +Note_Duration_480 +Note_Pitch_83 +Note_Velocity_56 +Note_Duration_480 +Note_Pitch_79 +Note_Velocity_64 +Note_Duration_1560 +Note_Pitch_41 +Note_Velocity_52 +Note_Duration_360 +Beat_12 +Chord_E_m7 +Tempo_140 +Note_Pitch_83 +Note_Velocity_62 +Note_Duration_240 +Note_Pitch_76 +Note_Velocity_64 +Note_Duration_480 +Note_Pitch_72 +Note_Velocity_54 +Note_Duration_480 +Note_Pitch_29 +Note_Velocity_54 +Note_Duration_120 +Beat_14 +Note_Pitch_83 +Note_Velocity_64 +Note_Duration_840 +Note_Pitch_71 +Note_Velocity_64 +Note_Duration_240 +Note_Pitch_43 +Note_Velocity_50 +Note_Duration_120 +Bar_None +Beat_0 +Chord_E_m7 +Tempo_149 +Note_Pitch_76 +Note_Velocity_54 +Note_Duration_600 +Note_Pitch_43 +Note_Velocity_54 +Note_Duration_600 +Note_Pitch_31 +Note_Velocity_56 +Note_Duration_600 +Note_Pitch_71 +Note_Velocity_50 +Note_Duration_600 +Beat_4 +Tempo_143 +Note_Pitch_72 +Note_Velocity_58 +Note_Duration_120 +Note_Pitch_67 +Note_Velocity_54 +Note_Duration_120 +Note_Pitch_64 +Note_Velocity_60 +Note_Duration_720 +Note_Pitch_62 +Note_Velocity_60 +Note_Duration_960 +Note_Pitch_59 +Note_Velocity_56 +Note_Duration_240 +Beat_5 +Note_Pitch_72 +Note_Velocity_66 +Note_Duration_360 +Beat_6 +Note_Pitch_74 +Note_Velocity_56 +Note_Duration_240 +Beat_8 +Chord_A_m7 +Note_Pitch_76 +Note_Velocity_62 +Note_Duration_240 +Note_Pitch_72 +Note_Velocity_50 +Note_Duration_720 +Note_Pitch_43 +Note_Velocity_42 +Note_Duration_120 +Beat_10 +Note_Pitch_79 +Note_Velocity_70 +Note_Duration_480 +Beat_12 +Tempo_146 +Note_Pitch_77 +Note_Velocity_60 +Note_Duration_240 +Note_Pitch_33 +Note_Velocity_60 +Note_Duration_240 +Beat_14 +Note_Pitch_76 +Note_Velocity_68 +Note_Duration_720 +Note_Pitch_35 +Note_Velocity_56 +Note_Duration_240 +Bar_None +Beat_0 +Chord_C_M7 +Tempo_143 +Note_Pitch_81 +Note_Velocity_62 +Note_Duration_240 +Note_Pitch_72 +Note_Velocity_48 +Note_Duration_480 +Note_Pitch_38 +Note_Velocity_46 +Note_Duration_600 +Beat_2 +Note_Pitch_81 +Note_Velocity_62 +Note_Duration_480 +Note_Pitch_79 +Note_Velocity_68 +Note_Duration_360 +Note_Pitch_74 +Note_Velocity_64 +Note_Duration_360 +Note_Pitch_43 +Note_Velocity_52 +Note_Duration_240 +Beat_4 +Chord_D_m7 +Note_Pitch_77 +Note_Velocity_64 +Note_Duration_480 +Note_Pitch_72 +Note_Velocity_62 +Note_Duration_240 +Note_Pitch_60 +Note_Velocity_64 +Note_Duration_240 +Note_Pitch_43 +Note_Velocity_56 +Note_Duration_120 +Beat_6 +Note_Pitch_76 +Note_Velocity_62 +Note_Duration_360 +Beat_8 +Chord_G_7 +Tempo_146 +Note_Pitch_83 +Note_Velocity_64 +Note_Duration_960 +Note_Pitch_74 +Note_Velocity_66 +Note_Duration_480 +Note_Pitch_43 +Note_Velocity_50 +Note_Duration_120 +Note_Pitch_79 +Note_Velocity_58 +Note_Duration_480 +Beat_10 +Note_Pitch_38 +Note_Velocity_54 +Note_Duration_720 +Beat_12 +Tempo_143 +Note_Pitch_79 +Note_Velocity_66 +Note_Duration_1920 +Note_Pitch_76 +Note_Velocity_54 +Note_Duration_480 +Note_Pitch_74 +Note_Velocity_62 +Note_Duration_1920 +Beat_14 +Note_Pitch_48 +Note_Velocity_46 +Note_Duration_240 +Note_Pitch_41 +Note_Velocity_44 +Note_Duration_240 +Bar_None +Beat_0 +Chord_E_m7 +Note_Pitch_83 +Note_Velocity_58 +Note_Duration_1920 +Note_Pitch_40 +Note_Velocity_50 +Note_Duration_1200 +Beat_4 +Tempo_146 +Beat_10 +Note_Pitch_40 +Note_Velocity_42 +Note_Duration_840 +Beat_12 +Tempo_143 +Note_Pitch_79 +Note_Velocity_66 +Note_Duration_1920 +Bar_None +Beat_0 +Chord_F_M7 +Note_Pitch_81 +Note_Velocity_64 +Note_Duration_1440 +Note_Pitch_41 +Note_Velocity_50 +Note_Duration_1440 +Beat_4 +Tempo_146 +Beat_8 +Tempo_143 +Beat_12 +Note_Pitch_81 +Note_Velocity_68 +Note_Duration_600 +Note_Pitch_76 +Note_Velocity_62 +Note_Duration_600 +Note_Pitch_41 +Note_Velocity_48 +Note_Duration_600 +Beat_14 +Note_Pitch_72 +Note_Velocity_60 +Note_Duration_360 +Note_Pitch_84 +Note_Velocity_64 +Note_Duration_240 +Bar_None +Beat_0 +Chord_G_7 +Note_Pitch_83 +Note_Velocity_64 +Note_Duration_1200 +Note_Pitch_74 +Note_Velocity_64 +Note_Duration_1320 +Note_Pitch_43 +Note_Velocity_54 +Note_Duration_960 +Beat_4 +Tempo_146 +Beat_8 +Chord_E_m7 +Tempo_143 +Note_Pitch_76 +Note_Velocity_62 +Note_Duration_240 +Note_Pitch_43 +Note_Velocity_42 +Note_Duration_600 +Beat_10 +Note_Pitch_79 +Note_Velocity_68 +Note_Duration_480 +Beat_12 +Tempo_140 +Note_Pitch_77 +Note_Velocity_58 +Note_Duration_240 +Note_Pitch_45 +Note_Velocity_54 +Note_Duration_240 +Beat_14 +Note_Pitch_76 +Note_Velocity_66 +Note_Duration_240 +Note_Pitch_47 +Note_Velocity_54 +Note_Duration_240 +Bar_None +Beat_0 +Chord_C_M +Tempo_146 +Note_Pitch_76 +Note_Velocity_54 +Note_Duration_600 +Note_Pitch_38 +Note_Velocity_54 +Note_Duration_1200 +Note_Pitch_79 +Note_Velocity_58 +Note_Duration_960 +Note_Pitch_72 +Note_Velocity_52 +Note_Duration_480 +Beat_2 +Note_Pitch_74 +Note_Velocity_60 +Note_Duration_360 +Note_Pitch_43 +Note_Velocity_42 +Note_Duration_240 +Beat_4 +Note_Pitch_72 +Note_Velocity_68 +Note_Duration_720 +Beat_7 +Note_Pitch_81 +Note_Velocity_58 +Note_Duration_600 +Beat_8 +Chord_D_m7 +Tempo_143 +Note_Pitch_83 +Note_Velocity_56 +Note_Duration_480 +Beat_10 +Note_Pitch_72 +Note_Velocity_50 +Note_Duration_240 +Note_Pitch_38 +Note_Velocity_48 +Note_Duration_720 +Beat_12 +Note_Pitch_76 +Note_Velocity_54 +Note_Duration_480 +Beat_14 +Note_Pitch_41 +Note_Velocity_56 +Note_Duration_240 +Bar_None +Beat_0 +Chord_E_sus4 +Tempo_146 +Note_Pitch_83 +Note_Velocity_58 +Note_Duration_1920 +Note_Pitch_81 +Note_Velocity_58 +Note_Duration_1440 +Note_Pitch_40 +Note_Velocity_54 +Note_Duration_1200 +Beat_4 +Tempo_149 +Beat_8 +Chord_E_m7 +Tempo_140 +Beat_10 +Note_Pitch_40 +Note_Velocity_56 +Note_Duration_840 +Beat_12 +Tempo_143 +Note_Pitch_79 +Note_Velocity_60 +Note_Duration_600 +Note_Pitch_76 +Note_Velocity_64 +Note_Duration_600 +Bar_None +Beat_0 +Chord_D_m +Note_Pitch_81 +Note_Velocity_68 +Note_Duration_1440 +Note_Pitch_74 +Note_Velocity_60 +Note_Duration_480 +Note_Pitch_41 +Note_Velocity_52 +Note_Duration_1440 +Beat_1 +Note_Pitch_79 +Note_Velocity_62 +Note_Duration_360 +Beat_3 +Note_Pitch_76 +Note_Velocity_62 +Note_Duration_1680 +Beat_4 +Chord_E_o +Note_Pitch_67 +Note_Velocity_48 +Note_Duration_1440 +Note_Pitch_79 +Note_Velocity_56 +Note_Duration_1200 +Beat_8 +Tempo_149 +Beat_12 +Chord_C_M7 +Tempo_143 +Note_Pitch_81 +Note_Velocity_66 +Note_Duration_1920 +Note_Pitch_41 +Note_Velocity_44 +Note_Duration_600 +Beat_14 +Note_Pitch_84 +Note_Velocity_58 +Note_Duration_720 +Bar_None +Beat_0 +Chord_F_M7 +Note_Pitch_83 +Note_Velocity_60 +Note_Duration_1320 +Note_Pitch_43 +Note_Velocity_52 +Note_Duration_960 +Beat_4 +Chord_G_+ +Tempo_146 +Beat_6 +Note_Pitch_72 +Note_Velocity_54 +Note_Duration_960 +Beat_8 +Chord_C_M7 +Tempo_143 +Note_Pitch_76 +Note_Velocity_56 +Note_Duration_480 +Note_Pitch_43 +Note_Velocity_40 +Note_Duration_600 +Beat_10 +Note_Pitch_79 +Note_Velocity_68 +Note_Duration_480 +Beat_12 +Note_Pitch_77 +Note_Velocity_64 +Note_Duration_360 +Note_Pitch_45 +Note_Velocity_54 +Note_Duration_240 +Beat_14 +Note_Pitch_76 +Note_Velocity_68 +Note_Duration_240 +Note_Pitch_47 +Note_Velocity_54 +Note_Duration_240 +Bar_None +Beat_0 +Chord_D_m7 +Note_Pitch_81 +Note_Velocity_68 +Note_Duration_1080 +Note_Pitch_76 +Note_Velocity_56 +Note_Duration_360 +Note_Pitch_38 +Note_Velocity_42 +Note_Duration_1920 +Beat_2 +Note_Pitch_74 +Note_Velocity_62 +Note_Duration_1320 +Beat_6 +Note_Pitch_60 +Note_Velocity_56 +Note_Duration_240 +Beat_8 +Note_Pitch_64 +Note_Velocity_68 +Note_Duration_480 +Note_Pitch_69 +Note_Velocity_66 +Note_Duration_960 +Beat_10 +Note_Pitch_60 +Note_Velocity_64 +Note_Duration_360 +Beat_12 +Tempo_146 +Note_Pitch_64 +Note_Velocity_52 +Note_Duration_480 +Beat_14 +Note_Pitch_60 +Note_Velocity_60 +Note_Duration_360 +Note_Pitch_41 +Note_Velocity_56 +Note_Duration_360 +Bar_None +Beat_0 +Chord_A_m +Note_Pitch_64 +Note_Velocity_66 +Note_Duration_480 +Note_Pitch_40 +Note_Velocity_50 +Note_Duration_720 +Beat_2 +Note_Pitch_60 +Note_Velocity_50 +Note_Duration_240 +Beat_4 +Tempo_143 +Note_Pitch_64 +Note_Velocity_70 +Note_Duration_480 +Beat_6 +Note_Pitch_60 +Note_Velocity_52 +Note_Duration_360 +Beat_8 +Chord_C_M +Note_Pitch_64 +Note_Velocity_70 +Note_Duration_480 +Beat_10 +Note_Pitch_60 +Note_Velocity_52 +Note_Duration_240 +Note_Pitch_40 +Note_Velocity_46 +Note_Duration_720 +Beat_12 +Note_Pitch_67 +Note_Velocity_64 +Note_Duration_600 +Note_Pitch_64 +Note_Velocity_58 +Note_Duration_480 +Beat_14 +Note_Pitch_60 +Note_Velocity_52 +Note_Duration_240 +Bar_None +Beat_0 +Chord_F_M7 +Tempo_146 +Note_Pitch_64 +Note_Velocity_66 +Note_Duration_1560 +Note_Pitch_69 +Note_Velocity_60 +Note_Duration_1440 +Note_Pitch_41 +Note_Velocity_50 +Note_Duration_1440 +Beat_2 +Note_Pitch_60 +Note_Velocity_54 +Note_Duration_480 +Beat_4 +Tempo_143 +Beat_6 +Note_Pitch_60 +Note_Velocity_62 +Note_Duration_360 +Beat_8 +Note_Pitch_62 +Note_Velocity_62 +Note_Duration_360 +Beat_10 +Note_Pitch_60 +Note_Velocity_64 +Note_Duration_240 +Beat_12 +Note_Pitch_69 +Note_Velocity_52 +Note_Duration_480 +Note_Pitch_62 +Note_Velocity_60 +Note_Duration_360 +Note_Pitch_41 +Note_Velocity_54 +Note_Duration_480 +Beat_14 +Note_Pitch_72 +Note_Velocity_66 +Note_Duration_360 +Note_Pitch_60 +Note_Velocity_70 +Note_Duration_240 +Bar_None +Beat_0 +Chord_C_M7 +Tempo_146 +Note_Pitch_71 +Note_Velocity_60 +Note_Duration_120 +Note_Pitch_62 +Note_Velocity_58 +Note_Duration_600 +Note_Pitch_43 +Note_Velocity_58 +Note_Duration_480 +Beat_2 +Note_Pitch_60 +Note_Velocity_54 +Note_Duration_480 +Beat_4 +Note_Pitch_64 +Note_Velocity_66 +Note_Duration_960 +Note_Pitch_43 +Note_Velocity_48 +Note_Duration_240 +Beat_6 +Note_Pitch_60 +Note_Velocity_54 +Note_Duration_240 +Note_Pitch_43 +Note_Velocity_46 +Note_Duration_600 +Beat_8 +Tempo_143 +Note_Pitch_62 +Note_Velocity_52 +Note_Duration_240 +Beat_10 +Note_Pitch_60 +Note_Velocity_66 +Note_Duration_240 +Beat_12 +Note_Pitch_67 +Note_Velocity_52 +Note_Duration_1080 +Note_Pitch_62 +Note_Velocity_52 +Note_Duration_240 +Note_Pitch_45 +Note_Velocity_52 +Note_Duration_240 +Beat_14 +Note_Pitch_60 +Note_Velocity_58 +Note_Duration_240 +Note_Pitch_47 +Note_Velocity_60 +Note_Duration_240 +Bar_None +Beat_0 +Chord_D_m7 +Note_Pitch_69 +Note_Velocity_66 +Note_Duration_960 +Note_Pitch_62 +Note_Velocity_58 +Note_Duration_960 +Note_Pitch_38 +Note_Velocity_50 +Note_Duration_1200 +Beat_2 +Note_Pitch_57 +Note_Velocity_62 +Note_Duration_1680 +Beat_4 +Tempo_146 +Beat_8 +Tempo_143 +Note_Pitch_69 +Note_Velocity_64 +Note_Duration_1920 +Note_Pitch_62 +Note_Velocity_54 +Note_Duration_720 +Beat_10 +Note_Pitch_60 +Note_Velocity_68 +Note_Duration_240 +Note_Pitch_38 +Note_Velocity_52 +Note_Duration_480 +Beat_14 +Note_Pitch_60 +Note_Velocity_58 +Note_Duration_360 +Note_Pitch_41 +Note_Velocity_52 +Note_Duration_240 +Bar_None +Beat_0 +Chord_A_m +Tempo_146 +Note_Pitch_64 +Note_Velocity_58 +Note_Duration_960 +Note_Pitch_52 +Note_Velocity_54 +Note_Duration_120 +Note_Pitch_40 +Note_Velocity_52 +Note_Duration_1200 +Beat_2 +Note_Pitch_60 +Note_Velocity_50 +Note_Duration_240 +Beat_4 +Tempo_143 +Note_Pitch_60 +Note_Velocity_58 +Note_Duration_240 +Beat_6 +Note_Pitch_60 +Note_Velocity_66 +Note_Duration_360 +Beat_8 +Chord_A_m7 +Note_Pitch_62 +Note_Velocity_66 +Note_Duration_480 +Beat_10 +Note_Pitch_60 +Note_Velocity_68 +Note_Duration_360 +Note_Pitch_40 +Note_Velocity_48 +Note_Duration_840 +Beat_12 +Chord_C_M +Tempo_146 +Note_Pitch_67 +Note_Velocity_66 +Note_Duration_600 +Note_Pitch_62 +Note_Velocity_64 +Note_Duration_480 +Beat_14 +Note_Pitch_60 +Note_Velocity_50 +Note_Duration_240 +Bar_None +Beat_0 +Chord_D_m7 +Tempo_143 +Note_Pitch_69 +Note_Velocity_64 +Note_Duration_1080 +Note_Pitch_62 +Note_Velocity_64 +Note_Duration_1200 +Note_Pitch_41 +Note_Velocity_56 +Note_Duration_1080 +Beat_2 +Note_Pitch_57 +Note_Velocity_64 +Note_Duration_1440 +Beat_8 +Chord_F_M7 +Tempo_146 +Note_Pitch_64 +Note_Velocity_70 +Note_Duration_720 +Beat_10 +Note_Pitch_60 +Note_Velocity_56 +Note_Duration_240 +Beat_12 +Tempo_140 +Note_Pitch_69 +Note_Velocity_62 +Note_Duration_480 +Note_Pitch_62 +Note_Velocity_64 +Note_Duration_720 +Note_Pitch_41 +Note_Velocity_60 +Note_Duration_480 +Beat_14 +Note_Pitch_72 +Note_Velocity_66 +Note_Duration_360 +Note_Pitch_60 +Note_Velocity_68 +Note_Duration_240 +Bar_None +Beat_0 +Chord_A_m7 +Tempo_146 +Note_Pitch_71 +Note_Velocity_56 +Note_Duration_120 +Note_Pitch_43 +Note_Velocity_60 +Note_Duration_480 +Note_Pitch_69 +Note_Velocity_50 +Note_Duration_1080 +Beat_2 +Note_Pitch_60 +Note_Velocity_50 +Note_Duration_480 +Beat_4 +Note_Pitch_64 +Note_Velocity_66 +Note_Duration_960 +Note_Pitch_43 +Note_Velocity_48 +Note_Duration_240 +Beat_6 +Note_Pitch_60 +Note_Velocity_50 +Note_Duration_240 +Note_Pitch_43 +Note_Velocity_44 +Note_Duration_240 +Beat_8 +Chord_C_M7 +Tempo_143 +Note_Pitch_62 +Note_Velocity_58 +Note_Duration_480 +Note_Pitch_43 +Note_Velocity_42 +Note_Duration_600 +Beat_10 +Note_Pitch_60 +Note_Velocity_66 +Note_Duration_240 +Beat_12 +Note_Pitch_67 +Note_Velocity_64 +Note_Duration_480 +Note_Pitch_62 +Note_Velocity_60 +Note_Duration_360 +Note_Pitch_45 +Note_Velocity_58 +Note_Duration_360 +Beat_14 +Note_Pitch_60 +Note_Velocity_66 +Note_Duration_240 +Note_Pitch_47 +Note_Velocity_60 +Note_Duration_240 +Bar_None +Beat_0 +Chord_D_m7 +Note_Pitch_69 +Note_Velocity_62 +Note_Duration_720 +Note_Pitch_62 +Note_Velocity_54 +Note_Duration_840 +Note_Pitch_38 +Note_Velocity_50 +Note_Duration_720 +Beat_2 +Note_Pitch_57 +Note_Velocity_46 +Note_Duration_480 +Beat_4 +Tempo_146 +Note_Pitch_72 +Note_Velocity_60 +Note_Duration_240 +Note_Pitch_67 +Note_Velocity_50 +Note_Duration_240 +Beat_6 +Note_Pitch_72 +Note_Velocity_62 +Note_Duration_480 +Note_Pitch_67 +Note_Velocity_54 +Note_Duration_240 +Beat_8 +Chord_C_sus2 +Note_Pitch_74 +Note_Velocity_56 +Note_Duration_360 +Note_Pitch_69 +Note_Velocity_52 +Note_Duration_360 +Beat_10 +Note_Pitch_72 +Note_Velocity_56 +Note_Duration_720 +Note_Pitch_67 +Note_Velocity_60 +Note_Duration_720 +Note_Pitch_38 +Note_Velocity_52 +Note_Duration_240 +Beat_12 +Chord_F_sus2 +Tempo_143 +Beat_14 +Note_Pitch_41 +Note_Velocity_56 +Note_Duration_600 +Bar_None +Beat_0 +Chord_F_M7 +Tempo_146 +Note_Pitch_69 +Note_Velocity_64 +Note_Duration_1680 +Note_Pitch_65 +Note_Velocity_62 +Note_Duration_360 +Beat_2 +Note_Pitch_67 +Note_Velocity_56 +Note_Duration_720 +Note_Pitch_64 +Note_Velocity_68 +Note_Duration_1320 +Beat_4 +Chord_A_m7 +Beat_8 +Tempo_140 +Beat_10 +Note_Pitch_40 +Note_Velocity_48 +Note_Duration_840 +Beat_12 +Chord_C_M +Tempo_143 +Note_Pitch_67 +Note_Velocity_54 +Note_Duration_600 +Note_Pitch_60 +Note_Velocity_64 +Note_Duration_360 +Beat_14 +Note_Pitch_55 +Note_Velocity_62 +Note_Duration_1200 +Bar_None +Beat_0 +Chord_F_M7 +Note_Pitch_69 +Note_Velocity_56 +Note_Duration_480 +Note_Pitch_60 +Note_Velocity_68 +Note_Duration_480 +Note_Pitch_41 +Note_Velocity_52 +Note_Duration_1080 +Beat_4 +Tempo_146 +Note_Pitch_69 +Note_Velocity_60 +Note_Duration_600 +Note_Pitch_60 +Note_Velocity_60 +Note_Duration_480 +Beat_8 +Chord_C_M +Tempo_140 +Note_Pitch_67 +Note_Velocity_68 +Note_Duration_720 +Note_Pitch_60 +Note_Velocity_66 +Note_Duration_480 +Beat_12 +Chord_F_sus2 +Tempo_146 +Note_Pitch_41 +Note_Velocity_58 +Note_Duration_840 +Note_Pitch_60 +Note_Velocity_56 +Note_Duration_240 +Beat_13 +Note_Pitch_62 +Note_Velocity_64 +Note_Duration_240 +Beat_15 +Note_Pitch_64 +Note_Velocity_56 +Note_Duration_600 +Bar_None +Beat_0 +Chord_E_m7 +Tempo_143 +Note_Pitch_71 +Note_Velocity_60 +Note_Duration_840 +Note_Pitch_43 +Note_Velocity_64 +Note_Duration_720 +Beat_2 +Note_Pitch_62 +Note_Velocity_64 +Note_Duration_600 +Beat_4 +Chord_G_M +Note_Pitch_60 +Note_Velocity_58 +Note_Duration_360 +Beat_6 +Note_Pitch_55 +Note_Velocity_64 +Note_Duration_840 +Note_Pitch_43 +Note_Velocity_56 +Note_Duration_720 +Beat_8 +Chord_C_M7 +Tempo_146 +Note_Pitch_60 +Note_Velocity_58 +Note_Duration_960 +Beat_12 +Tempo_143 +Note_Pitch_71 +Note_Velocity_66 +Note_Duration_1080 +Note_Pitch_45 +Note_Velocity_58 +Note_Duration_240 +Beat_14 +Note_Pitch_47 +Note_Velocity_58 +Note_Duration_240 +Bar_None +Beat_0 +Chord_B_m7 +Tempo_146 +Note_Pitch_69 +Note_Velocity_60 +Note_Duration_480 +Note_Pitch_60 +Note_Velocity_60 +Note_Duration_600 +Note_Pitch_38 +Note_Velocity_56 +Note_Duration_1080 +Beat_4 +Chord_D_sus4 +Tempo_143 +Note_Pitch_69 +Note_Velocity_62 +Note_Duration_480 +Note_Pitch_43 +Note_Velocity_48 +Note_Duration_480 +Beat_8 +Chord_G_M +Note_Pitch_69 +Note_Velocity_64 +Note_Duration_480 +Note_Pitch_62 +Note_Velocity_56 +Note_Duration_1080 +Note_Pitch_43 +Note_Velocity_54 +Note_Duration_360 +Note_Pitch_67 +Note_Velocity_54 +Note_Duration_960 +Beat_10 +Note_Pitch_60 +Note_Velocity_64 +Note_Duration_240 +Note_Pitch_38 +Note_Velocity_54 +Note_Duration_720 +Beat_12 +Chord_D_m7 +Tempo_146 +Note_Pitch_69 +Note_Velocity_52 +Note_Duration_1920 +Note_Pitch_60 +Note_Velocity_48 +Note_Duration_600 +Beat_14 +Note_Pitch_41 +Note_Velocity_56 +Note_Duration_360 +Bar_None +Beat_0 +Chord_A_m7 +Tempo_143 +Note_Pitch_59 +Note_Velocity_58 +Note_Duration_240 +Note_Pitch_40 +Note_Velocity_52 +Note_Duration_1200 +Beat_2 +Note_Pitch_57 +Note_Velocity_54 +Note_Duration_480 +Beat_4 +Note_Pitch_60 +Note_Velocity_66 +Note_Duration_360 +Beat_6 +Note_Pitch_55 +Note_Velocity_50 +Note_Duration_840 +Beat_8 +Chord_C_M +Note_Pitch_60 +Note_Velocity_48 +Note_Duration_480 +Beat_10 +Note_Pitch_40 +Note_Velocity_54 +Note_Duration_840 +Beat_12 +Tempo_146 +Note_Pitch_67 +Note_Velocity_56 +Note_Duration_600 +Note_Pitch_60 +Note_Velocity_60 +Note_Duration_240 +Beat_13 +Note_Pitch_55 +Note_Velocity_58 +Note_Duration_840 +Bar_None +Beat_0 +Chord_F_M7 +Tempo_143 +Note_Pitch_41 +Note_Velocity_50 +Note_Duration_480 +Note_Pitch_69 +Note_Velocity_60 +Note_Duration_480 +Note_Pitch_60 +Note_Velocity_54 +Note_Duration_600 +Beat_4 +Tempo_146 +Note_Pitch_69 +Note_Velocity_56 +Note_Duration_600 +Note_Pitch_67 +Note_Velocity_58 +Note_Duration_480 +Note_Pitch_55 +Note_Velocity_64 +Note_Duration_960 +Note_Pitch_41 +Note_Velocity_58 +Note_Duration_960 +Beat_8 +Chord_F_M +Tempo_143 +Note_Pitch_67 +Note_Velocity_58 +Note_Duration_600 +Note_Pitch_60 +Note_Velocity_62 +Note_Duration_480 +Beat_12 +Note_Pitch_41 +Note_Velocity_46 +Note_Duration_600 +Note_Pitch_69 +Note_Velocity_62 +Note_Duration_1080 +Note_Pitch_57 +Note_Velocity_52 +Note_Duration_600 +Beat_14 +Note_Pitch_72 +Note_Velocity_68 +Note_Duration_840 +Note_Pitch_55 +Note_Velocity_64 +Note_Duration_1200 +Note_Pitch_67 +Note_Velocity_58 +Note_Duration_240 +Bar_None +Beat_0 +Chord_C_M +Tempo_146 +Note_Pitch_43 +Note_Velocity_60 +Note_Duration_1440 +Beat_4 +Tempo_143 +Note_Pitch_60 +Note_Velocity_60 +Note_Duration_960 +Beat_8 +Chord_C_M7 +Tempo_146 +Note_Pitch_67 +Note_Velocity_58 +Note_Duration_480 +Note_Pitch_55 +Note_Velocity_64 +Note_Duration_600 +Beat_12 +Tempo_140 +Note_Pitch_67 +Note_Velocity_66 +Note_Duration_960 +Note_Pitch_60 +Note_Velocity_62 +Note_Duration_240 +Note_Pitch_45 +Note_Velocity_60 +Note_Duration_240 +Beat_14 +Note_Pitch_55 +Note_Velocity_64 +Note_Duration_240 +Note_Pitch_47 +Note_Velocity_64 +Note_Duration_240 +Bar_None +Beat_0 +Chord_D_sus4 +Tempo_146 +Note_Pitch_69 +Note_Velocity_62 +Note_Duration_600 +Note_Pitch_57 +Note_Velocity_58 +Note_Duration_600 +Note_Pitch_38 +Note_Velocity_56 +Note_Duration_1080 +Beat_4 +Tempo_143 +Note_Pitch_67 +Note_Velocity_56 +Note_Duration_720 +Note_Pitch_55 +Note_Velocity_62 +Note_Duration_1920 +Beat_8 +Tempo_146 +Note_Pitch_69 +Note_Velocity_64 +Note_Duration_1080 +Beat_10 +Note_Pitch_38 +Note_Velocity_48 +Note_Duration_360 +Beat_11 +Note_Pitch_60 +Note_Velocity_68 +Note_Duration_240 +Beat_12 +Chord_D_m7 +Tempo_143 +Note_Pitch_62 +Note_Velocity_68 +Note_Duration_1920 +Beat_14 +Note_Pitch_41 +Note_Velocity_60 +Note_Duration_1920 +Note_Pitch_60 +Note_Velocity_54 +Note_Duration_1920 +Bar_None +EOS_None diff --git a/model_validation/test_data/prompt_1.mid b/model_validation/test_data/prompt_1.mid new file mode 100644 index 00000000..f597225c Binary files /dev/null and b/model_validation/test_data/prompt_1.mid differ diff --git a/model_validation/test_data/soulx_en_prompt.mp3 b/model_validation/test_data/soulx_en_prompt.mp3 new file mode 100644 index 00000000..644a4ddb Binary files /dev/null and b/model_validation/test_data/soulx_en_prompt.mp3 differ diff --git a/model_validation/test_data/soulx_en_target.mp3 b/model_validation/test_data/soulx_en_target.mp3 new file mode 100644 index 00000000..b6eb34fd Binary files /dev/null and b/model_validation/test_data/soulx_en_target.mp3 differ diff --git a/model_validation/test_data/test_band_16s.mid b/model_validation/test_data/test_band_16s.mid new file mode 100644 index 00000000..76ea1f92 Binary files /dev/null and b/model_validation/test_data/test_band_16s.mid differ diff --git a/model_validation/test_data/test_band_speech_16s.flac b/model_validation/test_data/test_band_speech_16s.flac new file mode 100644 index 00000000..71f26914 Binary files /dev/null and b/model_validation/test_data/test_band_speech_16s.flac differ diff --git a/model_validation/test_data/test_drums_16s.flac b/model_validation/test_data/test_drums_16s.flac new file mode 100644 index 00000000..7ebbad9e Binary files /dev/null and b/model_validation/test_data/test_drums_16s.flac differ diff --git a/model_validation/test_data/test_melody_16s.mid b/model_validation/test_data/test_melody_16s.mid new file mode 100644 index 00000000..f98c7ad0 Binary files /dev/null and b/model_validation/test_data/test_melody_16s.mid differ diff --git a/model_validation/test_data/test_piano_20s.wav b/model_validation/test_data/test_piano_20s.wav new file mode 100644 index 00000000..c875cdd3 Binary files /dev/null and b/model_validation/test_data/test_piano_20s.wav differ diff --git a/model_validation/test_data/test_score.musicxml b/model_validation/test_data/test_score.musicxml new file mode 100644 index 00000000..efa823ca --- /dev/null +++ b/model_validation/test_data/test_score.musicxml @@ -0,0 +1,40 @@ + + + + + + + Piano + + Acoustic Grand Piano + + + 1 + 1 + + + + + + + 1 + 0 + + G2 + + C41quarter + D41quarter + E41quarter + F41quarter + + + G41quarter + A41quarter + B41quarter + C51quarter + + + diff --git a/model_validation/test_data/test_speech_10s.flac b/model_validation/test_data/test_speech_10s.flac new file mode 100644 index 00000000..e3b64ff8 Binary files /dev/null and b/model_validation/test_data/test_speech_10s.flac differ diff --git a/model_validation/test_data/test_voice_16s.wav b/model_validation/test_data/test_voice_16s.wav new file mode 100644 index 00000000..ce64d44b Binary files /dev/null and b/model_validation/test_data/test_voice_16s.wav differ diff --git a/model_validation/tests/test_assets.py b/model_validation/tests/test_assets.py new file mode 100644 index 00000000..2fbac549 --- /dev/null +++ b/model_validation/tests/test_assets.py @@ -0,0 +1,106 @@ +""" +Checks for the inputs built from a `source` recording or MIDI file. + +These run locally with no network and no Hugging Face token: + + python -m unittest discover -s model_validation/tests -v +""" + +from pathlib import Path +import sys +import tempfile +import unittest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +import numpy +import soundfile +from assets import Assets, check_synthesized_inputs + + +class SourceTest(unittest.TestCase): + """A scratch directory holding the sources and the built inputs.""" + + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.dir = Path(self.temp.name) + self.assets = Assets(self.dir / "assets", base_dir=self.dir) + + # One second of stereo at 22.05 kHz, the channels told apart by level + left = numpy.full(22050, 0.25, dtype=numpy.float32) + soundfile.write(str(self.dir / "stereo.wav"), + numpy.stack([left, 2 * left], axis=1), 22050) + + def build(self, **props): + """Build an audio input from the stereo source with these properties.""" + path = self.assets.audio({"audio": {"source": "stereo.wav", **props}}) + return soundfile.read(str(path), always_2d=True) + + def test_a_short_source_is_repeated_to_the_duration(self): + data, rate = self.build(duration=2.5, sample_rate=22050, num_channels=2) + self.assertEqual(rate, 22050) + self.assertEqual(len(data), int(2.5 * 22050)) + + def test_a_long_source_is_cut_to_the_duration(self): + data, _ = self.build(duration=0.5, sample_rate=22050, num_channels=2) + self.assertEqual(len(data), int(0.5 * 22050)) + + def test_the_source_is_resampled(self): + data, rate = self.build(duration=1.0, sample_rate=44100, num_channels=2) + self.assertEqual(rate, 44100) + self.assertEqual(len(data), 44100) + + def test_stereo_keeps_its_channels(self): + data, _ = self.build(duration=1.0, sample_rate=22050, num_channels=2) + self.assertAlmostEqual(float(data[100, 0]), 0.25, places=3) + self.assertAlmostEqual(float(data[100, 1]), 0.5, places=3) + + def test_mono_averages_the_channels(self): + data, _ = self.build(duration=1.0, sample_rate=22050, num_channels=1) + self.assertEqual(data.shape[1], 1) + self.assertAlmostEqual(float(data[100, 0]), 0.375, places=3) + + def test_a_midi_source_is_passed_as_it_is(self): + midi = self.dir / "song.mid" + midi.write_bytes(b"MThd\x00\x00\x00\x06\x00\x00\x00\x01\x01\xe0") + path = self.assets.midi({"midi": {"source": "song.mid", "num_notes": 9}}) + self.assertEqual(path.read_bytes(), midi.read_bytes()) + + +class SourceConfigTest(unittest.TestCase): + """A `source` is checked when the configuration is, before any model runs.""" + + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.dir = Path(self.temp.name) + + def check(self, kind, source): + check_synthesized_inputs({"synthesized_inputs": {kind: {"source": source}}}, + self.dir) + + def test_a_missing_source_is_rejected(self): + with self.assertRaises(ValueError) as caught: + self.check("audio", "absent.wav") + self.assertIn("absent.wav", str(caught.exception)) + + def test_a_source_that_is_not_audio_is_rejected(self): + (self.dir / "notes.txt").write_text("not audio") + with self.assertRaises(ValueError): + self.check("audio", "notes.txt") + + def test_a_midi_source_needs_a_midi_extension(self): + (self.dir / "song.wav").write_bytes(b"RIFF") + with self.assertRaises(ValueError): + self.check("midi", "song.wav") + + def test_the_repository_sources_are_accepted(self): + test_data = Path(__file__).resolve().parents[1] + check_synthesized_inputs( + {"synthesized_inputs": {"audio": {"source": "test_data/test_piano_20s.wav"}}}, + test_data) + + +if __name__ == "__main__": + unittest.main() diff --git a/model_validation/tests/test_model_outputs.py b/model_validation/tests/test_model_outputs.py new file mode 100644 index 00000000..cb395f80 --- /dev/null +++ b/model_validation/tests/test_model_outputs.py @@ -0,0 +1,409 @@ +""" +Regression checks for the custom output validators and the `expect` rules +that need more than a property comparison. + +These run locally with no network and no Hugging Face token, so a change to +a validator can be checked without spending a model run on it: + + python -m unittest discover -s model_validation/tests -v +""" + +import json +from pathlib import Path +import sys +import tempfile +import unittest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +import soundfile +from expectations import check_expectations, resolve_expect_targets +from assets import check_synthesized_inputs +from harness import merge_common_cases +from utils import check_config_keys, load_config +from validators import VALIDATORS, ValidatorNotApplicable + + +class ValidatorTest(unittest.TestCase): + """Shared scratch file for the validators that read a result file.""" + + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.path = Path(self.temp.name) / "result.json" + + def check_file(self, name, label, data, params=None): + """Run a validator over a result file holding `data`.""" + self.path.write_text(json.dumps(data), encoding="utf-8") + VALIDATORS[name]({label: str(self.path)}, {}, params or {}) + + +class MeritScoresTest(ValidatorTest): + LABEL = "Similarity Results" + + def scores(self, **overrides): + values = {"melody": 1.0, "rhythm": 0.9, "timbre": -0.2} + values.update(overrides) + return {"scores": values} + + def test_accepts_three_similarities(self): + self.check_file("merit_scores", self.LABEL, self.scores()) + + def test_rejects_missing_score(self): + # A model that stops reporting a score has to name the one it dropped + with self.assertRaises(AssertionError) as caught: + self.check_file("merit_scores", self.LABEL, {"scores": {}}) + self.assertIn("melody", str(caught.exception)) + + def test_rejects_missing_scores_block(self): + with self.assertRaises(AssertionError) as caught: + self.check_file("merit_scores", self.LABEL, {"other": 1}) + self.assertIn("scores", str(caught.exception)) + + def test_rejects_values_that_are_not_similarities(self): + for value in (None, True, "0.9", float("nan"), 2.0, -2.0): + with self.subTest(value=value), self.assertRaises(AssertionError): + self.check_file("merit_scores", self.LABEL, + self.scores(melody=value)) + + +class MuqRankingTest(ValidatorTest): + LABEL = "Similarity Ranking" + + def rows(self): + return [{"rank": 1, "description": "piano", "similarity": 0.8}, + {"rank": 2, "description": "drums", "similarity": 0.2}] + + def test_accepts_a_sorted_ranking(self): + self.check_file("muq_ranking", self.LABEL, + {"results": self.rows()}, {"count": 2}) + + def test_rejects_the_wrong_number_of_candidates(self): + with self.assertRaises(AssertionError) as caught: + self.check_file("muq_ranking", self.LABEL, + {"results": self.rows()}, {"count": 3}) + self.assertIn("expected 3", str(caught.exception)) + + def test_rejects_an_unsorted_ranking(self): + rows = self.rows() + rows[1]["similarity"] = 0.9 + with self.assertRaises(AssertionError) as caught: + self.check_file("muq_ranking", self.LABEL, + {"results": rows}, {"count": 2}) + self.assertIn("sorted", str(caught.exception)) + + def test_rejects_a_misnumbered_rank(self): + rows = self.rows() + rows[1]["rank"] = 5 + with self.assertRaises(AssertionError): + self.check_file("muq_ranking", self.LABEL, + {"results": rows}, {"count": 2}) + + def test_rejects_an_empty_description(self): + rows = self.rows() + rows[0]["description"] = " " + with self.assertRaises(AssertionError): + self.check_file("muq_ranking", self.LABEL, + {"results": rows}, {"count": 2}) + + def test_requires_the_count_parameter(self): + # Without it the validator would assert nothing about the length + with self.assertRaises(ValueError): + self.check_file("muq_ranking", self.LABEL, + {"results": self.rows()}) + + +class ResultFileTest(ValidatorTest): + """Failures in reading the file at all, which must read as model faults.""" + + CASES = (("merit_scores", "Similarity Results"), + ("muq_ranking", "Similarity Ranking")) + + def test_malformed_json_fails_the_model(self): + self.path.write_text("not json" * 30, encoding="utf-8") + for name, label in self.CASES: + with self.subTest(name=name), self.assertRaises(AssertionError) as caught: + VALIDATORS[name]({label: str(self.path)}, {}, {"count": 1}) + self.assertIn("not valid JSON", str(caught.exception)) + + def test_a_json_array_is_not_a_result(self): + for name, label in self.CASES: + with self.subTest(name=name), self.assertRaises(AssertionError): + self.check_file(name, label, [1, 2, 3], {"count": 1}) + + def test_a_missing_output_is_a_configuration_error(self): + # The validator names an output the model no longer has, which is the + # config's mistake rather than a fault in the model + for name, _ in self.CASES: + with self.subTest(name=name), self.assertRaises(ValueError): + VALIDATORS[name]({"Some Other Output": "x"}, {}, {"count": 1}) + + +class AudioDecodesTest(unittest.TestCase): + """The common case that catches an audio output holding no samples.""" + + LABEL = "Output Audio" + CONTROLS = {"outputs": [{"label": LABEL, "type": "audio_track"}]} + + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.path = str(Path(self.temp.name) / "out.wav") + + def write(self, frames): + soundfile.write(self.path, [0.25] * frames, 44100, subtype="PCM_16") + + def test_accepts_audio_with_frames(self): + self.write(1024) + VALIDATORS["audio_decodes"]({self.LABEL: self.path}, self.CONTROLS, {}) + + def test_rejects_a_header_with_no_samples(self): + # The structural checks pass this: the file exists and is not empty + self.write(0) + self.assertGreater(Path(self.path).stat().st_size, 0) + with self.assertRaises(AssertionError) as caught: + VALIDATORS["audio_decodes"]({self.LABEL: self.path}, self.CONTROLS, {}) + self.assertIn("no audio frames", str(caught.exception)) + + def test_rejects_a_file_that_is_not_audio(self): + Path(self.path).write_text("this is not a wav", encoding="utf-8") + with self.assertRaises(AssertionError): + VALIDATORS["audio_decodes"]({self.LABEL: self.path}, self.CONTROLS, {}) + + def test_skips_a_model_with_no_audio_output(self): + controls = {"outputs": [{"label": "Notes", "type": "midi_track"}]} + with self.assertRaises(ValidatorNotApplicable): + VALIDATORS["audio_decodes"]({"Notes": "x.mid"}, controls, {}) + + +class TextExpectationsTest(unittest.TestCase): + """The `expect` rules for a text output, which arrives as a .txt file.""" + + LABEL = "Transcription" + + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.path = Path(self.temp.name) / "out.txt" + + def check(self, text, rules): + self.path.write_text(text, encoding="utf-8") + check_expectations(self.LABEL, "generic_file", str(self.path), rules) + + def test_min_length_accepts_text(self): + self.check("C:maj G:maj", {"min_length": 1}) + + def test_min_length_rejects_blank_text(self): + # A file that exists and is non-empty can still hold only whitespace + for text in ("", " ", "\n"): + with self.subTest(text=text), self.assertRaises(AssertionError): + self.check(text, {"min_length": 1}) + + def test_excludes_rejects_a_reported_failure(self): + with self.assertRaises(AssertionError) as caught: + self.check("Error: Could not extract notes", {"excludes": "Error:"}) + self.assertIn("Error:", str(caught.exception)) + + def test_excludes_takes_a_list_and_ignores_case(self): + with self.assertRaises(AssertionError): + self.check("no predictions generated.", + {"excludes": ["Error:", "No predictions generated."]}) + self.check("C:maj G:maj", {"excludes": ["Error:"]}) + + def test_a_missing_file_is_reported(self): + with self.assertRaises(AssertionError): + check_expectations(self.LABEL, "generic_file", + str(self.path) + ".absent", {"min_length": 1}) + + def test_bytes_that_are_not_text_are_reported(self): + self.path.write_bytes(b"\xff\xfe\x00binary") + with self.assertRaises(AssertionError) as caught: + check_expectations(self.LABEL, "generic_file", str(self.path), + {"min_length": 1}) + self.assertIn("could not be read as text", str(caught.exception)) + + +class ConfigFileTest(unittest.TestCase): + """The shipped config.yml has to stay loadable and internally consistent.""" + + PATH = Path(__file__).resolve().parents[1] / "config.yml" + + def test_config_loads(self): + self.assertIn("overrides", load_config(self.PATH)) + + def test_config_settings_are_valid(self): + # The checks a run makes before validating anything, so that a + # misspelled setting or a source file left out of test_data/ fails here + # rather than costing a run + config = load_config(self.PATH) + check_config_keys(config) + check_synthesized_inputs(config, self.PATH.parent) + + def test_a_model_written_twice_is_rejected(self): + # yaml keeps the last of several entries sharing a key, so a model + # written twice would otherwise run without the first entry's + # settings. This is what two branches configuring one model produce + # on merge, and it is invisible in the loaded configuration + with tempfile.TemporaryDirectory() as temp: + path = Path(temp) / "config.yml" + path.write_text("overrides:\n" + " teamup-tech/a:\n process_timeout: 10\n" + " teamup-tech/a:\n process_timeout: 20\n", + encoding="utf-8") + with self.assertRaises(ValueError) as caught: + load_config(path) + self.assertIn("duplicate key 'teamup-tech/a'", str(caught.exception)) + + def test_every_validator_referenced_exists(self): + config = load_config(self.PATH) + cases = list(config.get("common_test_cases") or []) + for entry in (config.get("overrides") or {}).values(): + cases += list((entry or {}).get("test_cases") or []) + for case in cases: + for name in (case.get("validators") or {}): + with self.subTest(case=case.get("name"), validator=name): + self.assertIn(name, VALIDATORS) + + def test_models_are_in_order(self): + # One model's settings are found by name, which only works while the + # entries stay sorted + names = list(load_config(self.PATH).get("overrides") or {}) + self.assertEqual(names, sorted(names)) + + def test_case_names_are_unique_per_model(self): + # The report tells a model's cases apart by name alone, so two cases + # sharing one could not be told apart + for model, entry in (load_config(self.PATH).get("overrides") or {}).items(): + names = [case.get("name") for case in (entry or {}).get("test_cases") or []] + with self.subTest(model=model): + self.assertEqual(len(names), len(set(names))) + + +class MergeCommonCasesTest(unittest.TestCase): + """How the common cases are folded into a model's own.""" + + def test_checks_are_merged_rather_than_run(self): + # A common case without inputs costs a /process call of its own for + # nothing, so its rules ride along with the model's own case + cases = merge_common_cases([{"name": "decodes", "validators": {"audio_decodes": {}}}], + [{"name": "default"}]) + self.assertEqual([case["name"] for case in cases], ["default"]) + self.assertIn("audio_decodes", cases[0]["validators"]) + + def test_rules_on_one_output_accumulate(self): + # Two common cases targeting the same output both keep their rules + cases = merge_common_cases([{"name": "a", "expect": {"*": {"min_bytes": 100}}}, + {"name": "b", "expect": {"*": {"min_rms_db": -60}}}], + [{"name": "default"}]) + self.assertEqual(cases[0]["expect"]["*"], {"min_bytes": 100, "min_rms_db": -60}) + + def test_model_rules_win_on_a_conflict(self): + cases = merge_common_cases([{"name": "a", "expect": {"*": {"min_bytes": 100}}}], + [{"name": "default", "expect": {"*": {"min_bytes": 5}}}]) + self.assertEqual(cases[0]["expect"]["*"]["min_bytes"], 5) + + def test_scenarios_run_separately_with_the_model_inputs(self): + # A common case that sends inputs is its own scenario, and inherits the + # files the model needs but does not set itself + cases = merge_common_cases([{"name": "stereo", + "synthesized_inputs": {"audio": {"num_channels": 2}}}], + [{"name": "default", "files": {"In": "a.wav"}}]) + self.assertEqual([case["name"] for case in cases], ["common:stereo", "default"]) + self.assertEqual(cases[0]["files"], {"In": "a.wav"}) + + +if __name__ == "__main__": + unittest.main() + + +class ReportTest(unittest.TestCase): + """What the human-readable report shows for a failure.""" + + def test_a_failure_carries_the_end_of_its_log(self): + from results import ModelResult, render_markdown + result = ModelResult(target="teamup-tech/a", error="AppError: boom", + log_tail=["Traceback (most recent call last):", + "ValueError: the checkpoint is corrupt"]) + report = render_markdown([result], {"passed": 0, "validated": 1, "skipped": 0, + "timestamp": "now", "command": ""}) + self.assertIn("ValueError: the checkpoint is corrupt", report) + + +class LabelsReachHarpTest(unittest.TestCase): + """HARP reads a JSON output as a LabelList and draws it over a file output.""" + + CONTROLS = {"outputs": [{"type": "audio_track", "label": "Audio"}, + {"type": "json", "label": "Labels"}]} + + def check(self, value, controls=None): + VALIDATORS["labels_reach_harp"]({"Labels": value}, controls or self.CONTROLS, {}) + + def test_accepts_a_label_list(self): + self.check({"meta": {"_type": "pyharp.LabelList"}, "labels": []}) + + def test_rejects_a_plain_dictionary(self): + # What six Spaces returned, which HARP fails the request on + with self.assertRaises(AssertionError) as caught: + self.check({"chords": [{"chord": "C"}]}) + self.assertIn("not a pyharp LabelList", str(caught.exception)) + + def test_rejects_labels_with_nothing_to_appear_over(self): + with self.assertRaises(AssertionError): + self.check({"meta": {"_type": "pyharp.LabelList"}, "labels": []}, + {"outputs": [{"type": "json", "label": "Labels"}]}) + + def test_skips_a_model_without_a_json_output(self): + with self.assertRaises(ValidatorNotApplicable): + VALIDATORS["labels_reach_harp"]({}, {"outputs": [{"type": "audio_track"}]}, {}) + + +class RmsBoundsTest(unittest.TestCase): + """The level rules on either side of an audio output.""" + + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.path = str(Path(self.temp.name) / "quiet.wav") + # A sine at amplitude 0.01, about -43 dBFS RMS + import numpy + t = numpy.arange(44100) / 44100 + soundfile.write(self.path, 0.01 * numpy.sin(2 * numpy.pi * 440 * t), 44100) + + def test_max_rms_db_accepts_a_quiet_output(self): + check_expectations("Stem", "audio_track", self.path, {"max_rms_db": -40}) + + def test_max_rms_db_rejects_a_louder_output(self): + with self.assertRaises(AssertionError) as caught: + check_expectations("Stem", "audio_track", self.path, {"max_rms_db": -50}) + self.assertIn("more signal than expected", str(caught.exception)) + + +class ExpectTargetsTest(unittest.TestCase): + """How rules under "*" and under an output's label combine.""" + + OUT_TYPES = {"Vocals": "audio_track", "Guitar": "audio_track", "Score": "midi_track"} + + def resolve(self, expect): + return dict(resolve_expect_targets(expect, self.OUT_TYPES)) + + def test_a_named_rule_replaces_the_same_rule_under_the_wildcard(self): + # A separator's stem for a source the input lacks lifts the common + # floor on that stem alone, and the other stems keep it + targets = self.resolve({"*": {"min_rms_db": -80}, + "Guitar": {"min_rms_db": -float("inf"), "max_rms_db": -60}}) + self.assertEqual(targets["Guitar"], {"min_rms_db": -float("inf"), "max_rms_db": -60}) + self.assertEqual(targets["Vocals"], {"min_rms_db": -80}) + + def test_the_named_rule_wins_whichever_key_comes_first(self): + targets = self.resolve({"Guitar": {"min_rms_db": -90}, "*": {"min_rms_db": -80}}) + self.assertEqual(targets["Guitar"], {"min_rms_db": -90}) + + def test_other_wildcard_rules_still_reach_a_named_output(self): + targets = self.resolve({"*": {"min_rms_db": -80, "min_duration": 7.8}, + "Guitar": {"min_rms_db": -90}}) + self.assertEqual(targets["Guitar"], {"min_rms_db": -90, "min_duration": 7.8}) + + def test_a_wildcard_rule_reaches_only_the_types_it_covers(self): + targets = self.resolve({"*": {"min_rms_db": -80, "min_notes": 1}}) + self.assertEqual(targets["Score"], {"min_notes": 1}) + self.assertEqual(targets["Vocals"], {"min_rms_db": -80}) diff --git a/model_validation/tests/test_spaces.py b/model_validation/tests/test_spaces.py new file mode 100644 index 00000000..3d39ebba --- /dev/null +++ b/model_validation/tests/test_spaces.py @@ -0,0 +1,46 @@ +""" +Checks for following a space's runtime stage before it is validated. + +These run locally with no network and no Hugging Face token: + + python -m unittest discover -s model_validation/tests -v +""" + +from pathlib import Path +import sys +import time +from types import SimpleNamespace +import unittest +from unittest import mock + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +import spaces + + +class WaitForRuntimeTest(unittest.TestCase): + """Which stages a run waits out, and which it reports as they are.""" + + def follow(self, *stages): + """The stage a wait settles on, given the stages the space reports in turn.""" + reported = iter(stages) + api = SimpleNamespace(get_space_runtime=lambda _: SimpleNamespace(stage=next(reported))) + with mock.patch.object(spaces, "RUNTIME_POLL_INTERVAL", 0): + return spaces.wait_for_runtime(api, "teamup-tech/a", time.time() + 5).stage + + def test_a_new_revision_starting_is_waited_out(self): + # The old revision serves meanwhile, and stopping there failed the + # space as "not running" the moment a run found it updating + self.assertEqual(self.follow("RUNNING_APP_STARTING", "RUNNING"), "RUNNING") + + def test_a_build_is_waited_out(self): + self.assertEqual(self.follow("BUILDING", "APP_STARTING", "RUNNING"), "RUNNING") + + def test_a_sleeping_space_is_reported_at_once(self): + # A sleeping space only starts once something asks it to, so waiting + # on it would spend the whole connect budget + self.assertEqual(self.follow("SLEEPING", "RUNNING"), "SLEEPING") + + +if __name__ == "__main__": + unittest.main() diff --git a/model_validation/tests/test_zerogpu_detection.py b/model_validation/tests/test_zerogpu_detection.py new file mode 100644 index 00000000..84e8c68f --- /dev/null +++ b/model_validation/tests/test_zerogpu_detection.py @@ -0,0 +1,28 @@ +"""Tests for deciding whether calling a space spends ZeroGPU allowance.""" + +import sys +import unittest +from pathlib import Path + + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from quota import spends_zerogpu + + +class SpendsZeroGPUTests(unittest.TestCase): + def test_zerogpu_hardware_spends_allowance(self): + self.assertTrue(spends_zerogpu("zero-a10g", [])) + + def test_cpu_space_does_not(self): + self.assertFalse(spends_zerogpu("cpu-basic", ["category:effects"])) + + def test_frontend_declaring_a_zerogpu_backend_spends_allowance(self): + self.assertTrue(spends_zerogpu("cpu-basic", ["category:synthesis", "zerogpu"])) + + def test_unknown_hardware_and_tags_do_not(self): + self.assertFalse(spends_zerogpu("", None)) + + +if __name__ == "__main__": + unittest.main() diff --git a/packaging/dmg.json b/packaging/dmg.json deleted file mode 100644 index b7fc0d4e..00000000 --- a/packaging/dmg.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "title": "HARP", - "window": { - "position": { - "x": 100, - "y": 100 - }, - "size": { - "width": 730, - "height": 520 - } - }, - "format": "UDZO", - "contents": [ - { - "x": 250, - "y": 405, - "type": "file", - "path": "dmg/HARP.app" - }, - { - "x": 480, - "y": 405, - "type": "link", - "path": "/Applications" - } - ] -} \ No newline at end of file diff --git a/packaging/dmg_settings.py b/packaging/dmg_settings.py new file mode 100644 index 00000000..470575c1 --- /dev/null +++ b/packaging/dmg_settings.py @@ -0,0 +1,17 @@ +# Layout of the HARP installer window, for dmgbuild (https://dmgbuild.readthedocs.io). +# The app path is passed in by package.sh with -D app=. + +import os.path + +app = defines["app"] + +format = "UDZO" +files = [app] +symlinks = {"Applications": "/Applications"} + +window_rect = ((100, 100), (730, 520)) +icon_size = 80 +icon_locations = { + os.path.basename(app): (250, 405), + "Applications": (480, 405), +} diff --git a/packaging/package.sh b/packaging/package.sh old mode 100644 new mode 100755 index f22fd50b..8f3246b8 --- a/packaging/package.sh +++ b/packaging/package.sh @@ -1,65 +1,45 @@ #!/bin/bash -# Usage: -# Set environment variables or pass arguments -# ./package/package.sh - -# Function to get the value from either the environment variable or the command-line argument -set -e -trap 'echo "Error on line $LINENO: command failed with status $?" >&2' ERR - -DEV_ID_APPLICATION=$1 -ARTIFACTS_PATH=$2 -PROJECT_NAME=$3 -PRODUCT_NAME=$4 -NOTARIZATION_USERNAME=$5 -NOTARIZATION_PASSWORD=$6 -TEAM_ID=$7 - -# check if the version file exists -if [ ! -f "VERSION" ]; then - echo "Error: VERSION not found. are you in the root directory of the project?" >&2 +# Packages HARP.app into a DMG, then code signs and notarizes it if credentials are provided. +# +# Usage: packaging/package.sh +# +# Optional environment variables (see docs/SIGNING.md): +# MACOS_SIGNING_IDENTITY Keychain identity used for signing, e.g. "Developer ID Application: Name (TEAMID)". +# If unset, the DMG is left unsigned. +# APPLE_ID Apple Account used for notarization. If unset, notarization is skipped. +# APPLE_APP_PASSWORD App-specific password for APPLE_ID. +# +# Requires dmgbuild (pip install dmgbuild). + +set -euo pipefail + +if [ $# -ne 2 ]; then + echo "Usage: $0 " >&2 exit 1 fi -VERSION=$(cat ./VERSION) - -# Check if the necessary variables are set -for var in DEV_ID_APPLICATION ARTIFACTS_PATH PROJECT_NAME PRODUCT_NAME NOTARIZATION_USERNAME NOTARIZATION_PASSWORD TEAM_ID; do - if [ -z "${!var}" ]; then - echo "Error: $var is not set" >&2 - exit 1 - fi -done - -# Install appdmg -npm install -g appdmg +APP=$1 +DMG=$2 -# Create necessary directories -rm -rf packaging/dmg -rm packaging/${PRODUCT_NAME}.dmg || true -mkdir -p packaging/dmg - -# Create directories for the dmg symlinks -cp -r "${ARTIFACTS_PATH}/${PROJECT_NAME}.app" packaging/dmg - -# Make sign.sh executable and run it -# chmod +x ./py/client/sign.sh -# ./py/client/sign.sh "$DEV_ID_APPLICATION" "packaging/dmg/${PROJECT_NAME}.app/Contents/Resources/gradiojuce_client/" - -# Sign the application -# codesign -s "$DEV_ID_APPLICATION" --timestamp -i com.HARP.HARP --options runtime --force "packaging/dmg/${PROJECT_NAME}.app/Contents/Resources/gradiojuce_client/gradiojuce_client" -codesign -s "$DEV_ID_APPLICATION" --timestamp -i com.HARP.HARP --options=runtime --force "packaging/dmg/${PROJECT_NAME}.app/Contents/MacOS/HARP" -codesign -s "$DEV_ID_APPLICATION" --timestamp -i com.HARP.HARP --options=runtime --force "packaging/dmg/${PROJECT_NAME}.app" +if [ -n "${MACOS_SIGNING_IDENTITY:-}" ]; then + # The hardened runtime and a secure timestamp are required for notarization + codesign --force --timestamp --options runtime --sign "$MACOS_SIGNING_IDENTITY" "$APP" +fi -# Create the .dmg -cd packaging && appdmg dmg.json "${PRODUCT_NAME}.dmg" +mkdir -p "$(dirname "$DMG")" +dmgbuild -s "$(dirname "$0")/dmg_settings.py" -D app="$APP" HARP "$DMG" -# Sign the .dmg -codesign -s "$DEV_ID_APPLICATION" --deep --timestamp -i com.HARP.HARP --force "${PRODUCT_NAME}.dmg" +if [ -n "${MACOS_SIGNING_IDENTITY:-}" ]; then + codesign --force --timestamp --sign "$MACOS_SIGNING_IDENTITY" "$DMG" -# Notarize the .dmg -xcrun notarytool submit "${PRODUCT_NAME}.dmg" --apple-id "$NOTARIZATION_USERNAME" --password "$NOTARIZATION_PASSWORD" --team-id "$TEAM_ID" --wait -xcrun stapler staple "${PRODUCT_NAME}.dmg" + if [ -n "${APPLE_ID:-}" ]; then + # Submit under the team that signed the app + TEAM_ID=$(codesign -dv "$APP" 2>&1 | sed -n 's/^TeamIdentifier=//p') -# mv "${PRODUCT_NAME}.dmg" "${PRODUCT_NAME}.dmg" \ No newline at end of file + # Notarizing the DMG covers the app inside it, and stapling lets Gatekeeper verify it offline + xcrun notarytool submit "$DMG" --apple-id "$APPLE_ID" --password "$APPLE_APP_PASSWORD" \ + --team-id "$TEAM_ID" --wait + xcrun stapler staple "$DMG" + fi +fi diff --git a/pyharp b/pyharp index be3e7540..edb30a64 160000 --- a/pyharp +++ b/pyharp @@ -1 +1 @@ -Subproject commit be3e7540442e3c85fc1c5d2daf4675f8ffa88916 +Subproject commit edb30a64320380f917a383497873e8baa4c5b9aa diff --git a/src/Application.cpp b/src/Application.cpp index 058e4fbc..8462d0b0 100644 --- a/src/Application.cpp +++ b/src/Application.cpp @@ -2,21 +2,15 @@ enum CommandIDs { - // invalid = 0x0000, - // File open = 0x0001, - save = 0x0002, saveAs = 0x0003, settings = 0x0004, - // Edit - undo = 0x1000, - redo = 0x1001, - // View viewStatusArea = 0x2000, viewMediaClipboard = 0x2001, + viewPreviewPane = 0x2002, // Help about = 0x3000, @@ -28,7 +22,6 @@ StringArray MainComponent::getMenuBarNames() StringArray menuBarNames; menuBarNames.add("File"); - //menuBarNames.add("Edit"); menuBarNames.add("View"); menuBarNames.add("Help"); @@ -55,22 +48,17 @@ PopupMenu MainComponent::getMenuForIndex([[maybe_unused]] int menuIndex, const S if (menuName == "File") { menu.addCommandItem(&commandManager, CommandIDs::open); - //menu.addCommandItem(&commandManager, CommandIDs::save); menu.addCommandItem(&commandManager, CommandIDs::saveAs); menu.addSeparator(); menu.addCommandItem(&commandManager, CommandIDs::settings); } - else if (menuName == "Edit") - { - //menu.addCommandItem(&commandManager, CommandIDs::undo); - //menu.addCommandItem(&commandManager, CommandIDs::redo); - } else if (menuName == "View") { menu.addCommandItem(&commandManager, CommandIDs::viewStatusArea); menu.addCommandItem(&commandManager, CommandIDs::viewMediaClipboard); + menu.addCommandItem(&commandManager, CommandIDs::viewPreviewPane); } else if (menuName == "Help") { @@ -125,17 +113,13 @@ void MainComponent::deinitializeMenuBar() */ void MainComponent::getAllCommands(Array& commands) { - const CommandID fileIDs[] = { - CommandIDs::open, CommandIDs::save, CommandIDs::saveAs, CommandIDs::settings - }; + const CommandID fileIDs[] = { CommandIDs::open, CommandIDs::saveAs, CommandIDs::settings }; commands.addArray(fileIDs, numElementsInArray(fileIDs)); - const CommandID editIDs[] = { CommandIDs::undo, CommandIDs::redo }; - - commands.addArray(editIDs, numElementsInArray(editIDs)); - - const CommandID viewIDs[] = { CommandIDs::viewStatusArea, CommandIDs::viewMediaClipboard }; + const CommandID viewIDs[] = { CommandIDs::viewStatusArea, + CommandIDs::viewMediaClipboard, + CommandIDs::viewPreviewPane }; commands.addArray(viewIDs, numElementsInArray(viewIDs)); @@ -160,15 +144,8 @@ void MainComponent::getCommandInfo(CommandID commandID, ApplicationCommandInfo& break; - case CommandIDs::save: - result.setInfo("Save", "Saves currently selected media clipboard track", "File", 0); - result.addDefaultKeypress('s', ModifierKeys::commandModifier); - - break; - case CommandIDs::saveAs: result.setInfo("Save As", "Saves currently selected track with a new name", "File", 0); - // TODO = is logical (|) correct here? result.addDefaultKeypress('s', ModifierKeys::shiftModifier | ModifierKeys::commandModifier); @@ -179,21 +156,6 @@ void MainComponent::getCommandInfo(CommandID commandID, ApplicationCommandInfo& break; - /* --Edit-- */ - case CommandIDs::undo: - result.setInfo("Undo", "Reverses most recent action", "Edit", 0); - result.addDefaultKeypress('z', ModifierKeys::commandModifier); - - break; - - case CommandIDs::redo: - result.setInfo("Redo", "Repeats next future action", "Edit", 0); - // TODO = is logical (|) correct here? - result.addDefaultKeypress('z', - ModifierKeys::shiftModifier | ModifierKeys::commandModifier); - - break; - /* --View-- */ case CommandIDs::viewStatusArea: result.setInfo("Status Area", "Toggles display of status area", "View", 0); @@ -207,6 +169,14 @@ void MainComponent::getCommandInfo(CommandID commandID, ApplicationCommandInfo& break; + case CommandIDs::viewPreviewPane: + result.setInfo("Preview Pane", "Toggles display of preview pane", "View", 0); + result.setTicked(mediaClipboardWidget.isPreviewPaneVisible()); + // The pane is part of the media clipboard, so has nowhere to show without it + result.setActive(showMediaClipboard); + + break; + /* --Help-- */ case CommandIDs::about: result.setInfo( @@ -229,13 +199,12 @@ bool MainComponent::perform(const InvocationInfo& info) /* --File-- */ case CommandIDs::open: DBG_AND_LOG("MainComponent::perform: \"open\" command invoked."); - mediaClipboardWidget.addFileCallback(); - break; + // The file is added to the media clipboard, which has to be showing to see it + if (! showMediaClipboard) + viewMediaClipboardCallback(); - case CommandIDs::save: - DBG_AND_LOG("MainComponent::perform: \"save\" command invoked."); - // TODO - saveCallback(); + mediaClipboardWidget.addFileCallback(); break; @@ -251,19 +220,6 @@ bool MainComponent::perform(const InvocationInfo& info) break; - /* --Edit-- */ - case CommandIDs::undo: - DBG_AND_LOG("MainComponent::perform: \"undo\" command invoked."); - // TODO - undoCallback(); - - break; - - case CommandIDs::redo: - DBG_AND_LOG("MainComponent::perform: \"redo\" command invoked."); - // TODO - redoCallback(); - - break; - /* --View-- */ case CommandIDs::viewMediaClipboard: DBG_AND_LOG("MainComponent::perform: \"viewMediaClipboard\" command invoked."); @@ -277,6 +233,12 @@ bool MainComponent::perform(const InvocationInfo& info) break; + case CommandIDs::viewPreviewPane: + DBG_AND_LOG("MainComponent::perform: \"viewPreviewPane\" command invoked."); + viewPreviewPaneCallback(); + + break; + /* --Help-- */ case CommandIDs::about: DBG_AND_LOG("MainComponent::perform: \"about\" command invoked."); @@ -286,7 +248,7 @@ bool MainComponent::perform(const InvocationInfo& info) case CommandIDs::tutorial: DBG_AND_LOG("MainComponent::perform: \"tutorial\" command invoked."); - openWelcomeWindow(); + openTutorial(); break; diff --git a/src/HomeTab.h b/src/HomeTab.h new file mode 100644 index 00000000..0a7dadcf --- /dev/null +++ b/src/HomeTab.h @@ -0,0 +1,850 @@ +/** + * @file HomeTab.h + * @brief Home tab for browsing the model catalog and opening models in new tabs. + * @author cwitkowitz, 2cylu2, JEYuhas, VedMistry42 + */ + +#pragma once + +#include +#include +#include + +#include + +#include "gui/HoverHandler.h" +#include "gui/RoundIconButton.h" + +#include "utils/Interface.h" +#include "utils/Messages.h" +#include "utils/ModelCatalog.h" +#include "utils/ModelTags.h" + +#include "widgets/ModelStyle.h" + +#include "windows/tutorial/TutorialTargets.h" + +#include "windows/CustomPathWindow.h" + +using namespace juce; + +/** + * Clickable card summarizing one catalog entry. + */ +class ModelCard : public Button +{ +public: + ModelCard(const CatalogEntry& catalogEntry, std::function onOpen) + : Button(catalogEntry.name), + entry(catalogEntry), + badges(ModelStyle::getBadges(catalogEntry)), + tagLabels(catalogEntry.tags.getDisplayLabels()), + searchableText(catalogEntry.getSearchableText()), + onOpenRequested(std::move(onOpen)) + { + setMouseCursor(MouseCursor::PointingHandCursor); + + // Only a custom path can be removed, since every other entry comes from the catalog + if (entry.isCustom) + { + removeButton = + std::make_unique("Click to remove " + entry.path + " from the list."); + removeButton->onClick = [this] + { + // Deferred, since removing the path deletes this card along with its button + MessageManager::callAsync( + [safeThis = SafePointer(this)] + { + if (safeThis == nullptr || ! safeThis->onRemoveRequested) + return; + + // Copied, since the card they belong to is deleted during the call + const auto removeRequested = safeThis->onRemoveRequested; + const CatalogEntry entryToRemove = safeThis->entry; + + removeRequested(entryToRemove); + }); + }; + addAndMakeVisible(*removeButton); + } + } + + const CatalogEntry& getEntry() const { return entry; } + + bool matchesSearch(const String& lowercaseQuery) const + { + return lowercaseQuery.isEmpty() || searchableText.contains(lowercaseQuery); + } + + // Called when the remove button of a custom path is clicked + std::function onRemoveRequested; + + // Opens the model on a left click, so that a right click does nothing unexpected + using Button::clicked; + void clicked(const ModifierKeys& modifiers) override + { + if (! modifiers.isPopupMenu() && onOpenRequested) + onOpenRequested(entry); + } + + void resized() override + { + if (removeButton != nullptr) + removeButton->setBounds(getNameRow().removeFromRight(removeButtonSize)); + } + + // Everything the card has no room for is shown in the instructions box while hovering + void mouseEnter(const MouseEvent& e) override + { + Button::mouseEnter(e); + + StringArray lines; + + if (const String description = entry.description.trim(); description.isNotEmpty()) + lines.add(description); + + if (const String tags = ModelStyle::describeTags(entry.tags); tags.isNotEmpty()) + lines.add(tags); + + lines.add("Click to open " + entry.path + " in a new tab."); + + instructionsMessage->setMessage(lines.joinIntoString("\n")); + } + + void mouseExit(const MouseEvent& e) override + { + Button::mouseExit(e); + instructionsMessage->clearMessage(); + } + + void paintButton(Graphics& g, bool isHighlighted, bool isDown) override + { + using namespace ModelStyle; + + drawCard(g, getLocalBounds().toFloat().reduced(1.0f), isHighlighted, isDown); + + auto area = getContentArea(); + + /* Name, with badges on the right, left of the remove button if there is one */ + + auto nameRow = area.removeFromTop(nameRowHeight); + + if (removeButton != nullptr) + nameRow.removeFromRight(removeButtonSize + chipGap); + + const int badgesLeft = drawBadges(g, badges, nameRow); + + g.setColour(Colours::white); + g.setFont(font(15.0f, true)); + g.drawText( + entry.name, nameRow.withRight(badgesLeft - chipGap), Justification::centredLeft, true); + + /* Description, cut short (all of it is shown while hovering) */ + + area.removeFromTop(2); + + const auto descriptionArea = area.removeFromTop(30); + const Font descriptionFont = font(13.0f); + + if (descriptionArea.getWidth() != descriptionWidth) + { + descriptionLines = getTruncatedLines(entry.description.isNotEmpty() + ? entry.description + : String("No description provided."), + descriptionFont, + descriptionArea.getWidth(), + descriptionMaxLines); + descriptionWidth = descriptionArea.getWidth(); + } + + g.setColour(Colours::whitesmoke.withAlpha(0.85f)); + g.setFont(descriptionFont); + drawTruncatedLines(g, descriptionLines, descriptionArea, descriptionMaxLines); + + /* Tags, as many as fit (all of them are described while hovering) */ + + area.removeFromTop(4); + drawTagRow(g, tagLabels, area.removeFromTop(chipHeight)); + + /* Path */ + + area.removeFromTop(4); + + g.setColour(Colours::grey); + g.setFont(font(11.0f)); + g.drawText(entry.path, area.removeFromTop(13), Justification::centredLeft, true); + } + + static constexpr int preferredHeight = 110; + static constexpr int minimumWidth = 300; + +private: + static constexpr int nameRowHeight = 20; + static constexpr int removeButtonSize = 18; + static constexpr int descriptionMaxLines = 2; + + Rectangle getContentArea() const { return getLocalBounds().reduced(12, 9); } + Rectangle getNameRow() const { return getContentArea().removeFromTop(nameRowHeight); } + + const CatalogEntry entry; + const std::vector badges; + const StringArray tagLabels; + const String searchableText; + + // The description as last broken into lines, and the width it was broken at + StringArray descriptionLines; + int descriptionWidth = -1; + + std::function onOpenRequested; + + // Shown only on a custom path + std::unique_ptr removeButton; + + SharedResourcePointer instructionsMessage; +}; + +/** + * The catalog as a responsive grid of cards, in one section per category. + */ +class ModelGrid : public Component +{ +public: + // Stands for models that declare no category + static inline const String otherSectionId { "other" }; + + std::function onOpenRequested; + std::function onRemoveRequested; + + void setEntries(const std::vector& entries) + { + sections.clear(); + removeAllChildren(); + + auto addSection = [&](const String& id, const String& title) + { + auto section = std::make_unique
(); + section->id = id; + section->title = title; + + for (const auto& entry : entries) + { + const bool belongs = id == otherSectionId ? ! entry.tags.isCategorized() + : entry.tags.isInCategory(id); + + if (! belongs) + continue; + + auto* card = section->cards.add(new ModelCard(entry, onOpenRequested)); + card->onRemoveRequested = onRemoveRequested; + addChildComponent(card); + } + + // A model that belongs to several categories is listed in each of them + sections.push_back(std::move(section)); + }; + + for (const auto& category : Taxonomy::getCategories()) + addSection(category.id, category.displayName); + + addSection(otherSectionId, "Other"); + } + + // Number of models in a section, or across all sections for an empty id + int countEntries(const String& sectionId) const + { + StringArray paths; + + for (const auto& section : sections) + { + if (sectionId.isNotEmpty() && section->id != sectionId) + continue; + + for (auto* card : section->cards) + paths.addIfNotAlreadyThere(card->getEntry().path); + } + + return paths.size(); + } + + /** + * Shows the cards in a section (or all sections, for an empty id) that match a + * search, leaving out metered models if asked (see CatalogEntry::isMetered), and + * returns how many distinct models are shown. The grid has to be laid out again + * afterwards. + */ + int applyFilter(const String& sectionId, const String& searchText, bool hideMetered) + { + const String query = searchText.trim().toLowerCase(); + + StringArray shownPaths; + + for (auto& section : sections) + { + const bool sectionShown = sectionId.isEmpty() || section->id == sectionId; + + for (auto* card : section->cards) + { + const bool shown = sectionShown && card->matchesSearch(query) + && ! (hideMetered && card->getEntry().isMetered()); + card->setVisible(shown); + + if (shown) + shownPaths.addIfNotAlreadyThere(card->getEntry().path); + } + } + + return shownPaths.size(); + } + + int getHeightForWidth(int width) const { return layOut(width, false); } + + void resized() override { layOut(getWidth(), true); } + + void paint(Graphics& g) override + { + for (const auto& section : sections) + { + if (! section->headerBounds.isEmpty()) + ModelStyle::drawSectionHeader(g, section->title, section->headerBounds); + } + } + +private: + struct Section + { + String id; + String title; + OwnedArray cards; + Rectangle headerBounds; + }; + + // Lays the visible cards out in as many columns as fit, and returns the height used + int layOut(int width, bool applyBounds) const + { + const int columns = jmax(1, (width + gap) / (ModelCard::minimumWidth + gap)); + const int cardWidth = jmax(0, (width - gap * (columns - 1)) / columns); + + int y = 0; + + for (const auto& section : sections) + { + Array visibleCards; + + for (auto* card : section->cards) + { + if (card->isVisible()) + visibleCards.add(card); + } + + if (visibleCards.isEmpty()) + { + if (applyBounds) + section->headerBounds = {}; + + continue; + } + + if (applyBounds) + section->headerBounds = { 0, y, width, ModelStyle::sectionHeaderHeight }; + + y += ModelStyle::sectionHeaderHeight; + + for (int i = 0; i < visibleCards.size(); ++i) + { + const int column = i % columns; + + if (column == 0 && i > 0) + y += ModelCard::preferredHeight + gap; + + if (applyBounds) + visibleCards[i]->setBounds( + column * (cardWidth + gap), y, cardWidth, ModelCard::preferredHeight); + } + + y += ModelCard::preferredHeight + sectionGap; + } + + return y; + } + + static constexpr int gap = 8; + static constexpr int sectionGap = 10; + + std::vector> sections; +}; + +/** + * Chips for choosing which section of the catalog to show, wrapped onto as many rows as + * the width requires. + */ +class CategoryFilterBar : public Component +{ +public: + std::function onSelectionChanged; + + CategoryFilterBar() + { + addChip({}, "All"); + + for (const auto& category : Taxonomy::getCategories()) + addChip(category.id, category.displayName); + + addChip(ModelGrid::otherSectionId, "Other"); + + chips.getFirst()->setToggleState(true, dontSendNotification); + } + + // Id of the selected section, or empty for all of them + String getSelectedId() const + { + for (auto* chip : chips) + { + if (chip->getToggleState()) + return chip->id; + } + + return {}; + } + + void setCounts(const ModelGrid& grid) + { + for (auto* chip : chips) + chip->count = grid.countEntries(chip->id); + + resized(); + repaint(); + } + + int getHeightForWidth(int width) const + { + FlexBox box = createLayout(); + box.performLayout(Rectangle(0, 0, width, 1000)); + + float bottom = 0.0f; + + for (const auto& item : box.items) + bottom = jmax(bottom, item.currentBounds.getBottom() + item.margin.bottom); + + return roundToInt(bottom); + } + + void resized() override { createLayout().performLayout(getLocalBounds()); } + +private: + struct Chip : public Button + { + Chip(const String& chipId, const String& chipName) : Button(chipName), id(chipId) + { + setClickingTogglesState(true); + setRadioGroupId(1); + } + + String getText() const { return getName() + " " + String(count); } + + int getPreferredWidth() const + { + return ModelStyle::getTextWidth(chipFont, getText()) + 2 * horizontalPadding; + } + + void paintButton(Graphics& g, bool isHighlighted, bool isDown) override + { + const auto bounds = getLocalBounds().toFloat().reduced(1.0f); + const bool selected = getToggleState(); + + g.setColour(selected ? ModelStyle::accentDark + : Colour(isHighlighted || isDown ? 0xff263a3d : 0xff1e1e24)); + g.fillRoundedRectangle(bounds, 5.0f); + + g.setColour(selected ? ModelStyle::accent : Colours::white.withAlpha(0.1f)); + g.drawRoundedRectangle(bounds, 5.0f, 1.0f); + + g.setColour(selected || isHighlighted ? Colours::white : Colours::lightgrey); + g.setFont(chipFont); + g.drawText(getText(), getLocalBounds(), Justification::centred, false); + } + + void mouseEnter(const MouseEvent& e) override + { + Button::mouseEnter(e); + + if (id.isEmpty()) + instructionsMessage->setMessage("Click to show every model."); + else if (id == ModelGrid::otherSectionId) + instructionsMessage->setMessage( + "Click to show the models that declare no category."); + else + instructionsMessage->setMessage("Click to show only the " + getName() + " models."); + } + + void mouseExit(const MouseEvent& e) override + { + Button::mouseExit(e); + instructionsMessage->clearMessage(); + } + + const String id; + int count = 0; + + const Font chipFont = ModelStyle::font(12.0f, true); + static constexpr int horizontalPadding = 10; + + SharedResourcePointer instructionsMessage; + }; + + void addChip(const String& id, const String& name) + { + auto* chip = chips.add(new Chip(id, name)); + chip->onClick = [this] + { + if (onSelectionChanged) + onSelectionChanged(); + }; + addAndMakeVisible(chip); + } + + FlexBox createLayout() const + { + FlexBox box; + box.flexWrap = FlexBox::Wrap::wrap; + box.alignContent = FlexBox::AlignContent::flexStart; + + for (auto* chip : chips) + { + box.items.add(FlexItem(*chip) + .withWidth((float) chip->getPreferredWidth()) + .withHeight((float) chipHeight) + .withMargin(FlexItem::Margin(0, 6, 6, 0))); + } + + return box; + } + + static constexpr int chipHeight = 26; + + OwnedArray chips; +}; + +/** + * A small note on the state of the catalog (e.g., "Offline" or "2 hidden"), shown only when + * there is something to note, and explained in the instructions box while hovering over it. + */ +class CatalogStatusIndicator : public Component +{ +public: + void setStatus(const String& newText, Colour newColour, const String& newDetails) + { + text = newText; + colour = newColour; + details = newDetails; + + setVisible(text.isNotEmpty()); + repaint(); + } + + int getPreferredWidth() const + { + return text.isEmpty() ? 0 : ModelStyle::getTextWidth(textFont, text) + 16; + } + + void paint(Graphics& g) override + { + const auto bounds = getLocalBounds().toFloat().reduced(0.5f, 3.0f); + + g.setColour(colour.withAlpha(0.18f)); + g.fillRoundedRectangle(bounds, bounds.getHeight() / 2.0f); + + g.setColour(colour); + g.setFont(textFont); + g.drawText(text, getLocalBounds(), Justification::centred, false); + } + + void mouseEnter(const MouseEvent&) override { instructionsMessage->setMessage(details); } + + void mouseExit(const MouseEvent&) override { instructionsMessage->clearMessage(); } + +private: + String text; + Colour colour; + String details; + + const Font textFont = ModelStyle::font(12.0f, true); + + SharedResourcePointer instructionsMessage; +}; + +class HomeTab : public Component, private ChangeListener +{ +public: + // Settings key under which the choice to hide metered models is saved + static constexpr const char* hideMeteredKey = "models.hideMetered"; + + HomeTab() + { + titleLabel.setText("Models", dontSendNotification); + titleLabel.setFont(ModelStyle::font(20.0f, true)); + titleLabel.setBorderSize({ 0, 0, 0, 0 }); + addAndMakeVisible(titleLabel); + + addChildComponent(statusIndicator); + + addInstructions(refreshButton, + "Click to fetch the latest list of models from Hugging Face."); + refreshButton.onClick = [this] { catalog->refresh(); }; + addAndMakeVisible(refreshButton); + + addInstructions(customPathButton, + "Click to open a model that is not listed, by its Hugging Face Space, " + "Gradio URL, or local address."); + customPathButton.onClick = [this] + { + CustomPathComponent::launch(this, + [safeThis = SafePointer(this)](String path) + { + if (safeThis != nullptr) + safeThis->requestOpen(path, {}); + }); + }; + addAndMakeVisible(customPathButton); + + searchEditor.setTextToShowWhenEmpty("Search by name, task, tag, or path...", Colours::grey); + searchEditor.setMultiLine(false); + searchEditor.setReturnKeyStartsNewLine(false); + searchEditor.onTextChange = [this] { applyFilter(); }; + addInstructions(searchEditor, + "Type to show only the models whose name, description, tags, or path " + "contain the text."); + addAndMakeVisible(searchEditor); + + meteredToggle.setToggleState(Settings::getBoolValue(hideMeteredKey), + dontSendNotification); + meteredToggle.onClick = [this] + { + Settings::setValue(hideMeteredKey, meteredToggle.getToggleState()); + applyFilter(); + }; + addInstructions(meteredToggle, + "Click to hide the models that need an API key. Stability AI models need " + "a Stability AI key and spend its credits. ZeroGPU models also run " + "without a key, but only within a small daily allowance that a Hugging " + "Face key raises."); + addAndMakeVisible(meteredToggle); + + categoryFilterBar.onSelectionChanged = [this] { applyFilter(); }; + addAndMakeVisible(categoryFilterBar); + + modelGrid.onOpenRequested = [this](const CatalogEntry& entry) + { requestOpen(entry.path, entry.name); }; + modelGrid.onRemoveRequested = [this](const CatalogEntry& entry) + { catalog->removeCustomPath(entry.path); }; + + viewport.setViewedComponent(&modelGrid, false); + viewport.setScrollBarsShown(true, false); + + searchEditor.setComponentID(TutorialTargets::modelSearch); + viewport.setComponentID(TutorialTargets::modelList); + addAndMakeVisible(viewport); + + noResultsLabel.setJustificationType(Justification::centredTop); + noResultsLabel.setColour(Label::textColourId, Colours::grey); + addChildComponent(noResultsLabel); + + catalog->addChangeListener(this); + updateFromCatalog(); + } + + ~HomeTab() override + { + catalog->removeChangeListener(this); + + for (auto& handler : hoverHandlers) + handler->detach(); + } + + // Called with the path and display name of the model to open + std::function onModelOpenRequested; + + void resized() override + { + auto area = getLocalBounds().reduced(14, 12); + + FlexBox header; + header.alignItems = FlexBox::AlignItems::center; + header.items.add(FlexItem(titleLabel).withFlex(1).withHeight(28)); + header.items.add(FlexItem(statusIndicator) + .withWidth((float) statusIndicator.getPreferredWidth()) + .withHeight(26) + .withMargin({ 0, 8, 0, 0 })); + header.items.add( + FlexItem(refreshButton).withWidth(80).withHeight(26).withMargin({ 0, 6, 0, 0 })); + header.items.add(FlexItem(customPathButton).withWidth(110).withHeight(26)); + header.performLayout(area.removeFromTop(28)); + + area.removeFromTop(8); + + FlexBox searchRow; + searchRow.alignItems = FlexBox::AlignItems::center; + searchRow.items.add(FlexItem(searchEditor).withFlex(1).withHeight(28)); + searchRow.items.add(FlexItem(meteredToggle) + .withWidth((float) getMeteredToggleWidth()) + .withHeight(28) + .withMargin({ 0, 0, 0, 10 })); + searchRow.performLayout(area.removeFromTop(28)); + + area.removeFromTop(8); + categoryFilterBar.setBounds( + area.removeFromTop(categoryFilterBar.getHeightForWidth(area.getWidth()))); + + viewport.setBounds(area); + noResultsLabel.setBounds(area.withTrimmedTop(24)); + + layOutGrid(); + } + +private: + void changeListenerCallback(ChangeBroadcaster*) override { updateFromCatalog(); } + + void updateFromCatalog() + { + modelGrid.setEntries(catalog->getEntries()); + categoryFilterBar.setCounts(modelGrid); + + updateStatusIndicator(); + refreshButton.setEnabled(catalog->getFetchState() != ModelCatalog::FetchState::Fetching); + + // The chip counts, and so the rows they wrap onto, may have changed as well + updateVisibleModels(); + resized(); + } + + void updateStatusIndicator() + { + const String source = "huggingface.co/" + String(ModelCatalog::hubOrganization); + const auto& hidden = catalog->getHiddenEntries(); + + StringArray notes; + StringArray details; + Colour colour = ModelStyle::secondaryText; + + switch (catalog->getFetchState()) + { + case ModelCatalog::FetchState::Fetching: + notes.add("Updating..."); + details.add("Fetching the latest list of models from " + source + "."); + colour = ModelStyle::information; + break; + + case ModelCatalog::FetchState::Failed: + { + const Time listingTime = catalog->getListingTime(); + + notes.add("Offline"); + details.add("Could not reach " + source + " (" + catalog->getFetchError() + "). " + + (listingTime != Time() + ? "Showing the list from " + + listingTime.toString(true, true, false) + "." + : String("Only built-in and custom models are shown.")) + + " Click Refresh to try again."); + colour = ModelStyle::problem; + break; + } + + case ModelCatalog::FetchState::Succeeded: + break; + } + + if (! hidden.empty()) + { + notes.add(String((int) hidden.size()) + " hidden"); + + StringArray hiddenLines { "Hidden, since they cannot currently be loaded:" }; + + for (const auto& entry : hidden) + hiddenLines.add(entry.name + " (" + entry.reason + ")"); + + details.add(hiddenLines.joinIntoString("\n")); + } + + statusIndicator.setStatus(notes.joinIntoString(String::fromUTF8(" \xc2\xb7 ")), + colour, + details.joinIntoString("\n")); + } + + void applyFilter() + { + updateVisibleModels(); + layOutGrid(); + } + + void updateVisibleModels() + { + const bool hideMetered = meteredToggle.getToggleState(); + const int numShown = modelGrid.applyFilter( + categoryFilterBar.getSelectedId(), searchEditor.getText(), hideMetered); + + String noResults = "No models in this category yet."; + + if (! searchEditor.isEmpty()) + noResults = "No models match \"" + searchEditor.getText().trim() + "\"."; + else if (hideMetered) + noResults = "Every model in this category needs an API key."; + + noResultsLabel.setText(noResults, dontSendNotification); + noResultsLabel.setVisible(numShown == 0); + } + + // Room for the toggle's label, which JUCE draws at no more than 15 points, and its tick box + int getMeteredToggleWidth() const + { + return ModelStyle::getTextWidth(ModelStyle::font(15.0f), meteredToggle.getButtonText()) + + 34; + } + + void layOutGrid() + { + const int width = jmax(0, viewport.getWidth() - viewport.getScrollBarThickness()); + const int height = jmax(viewport.getHeight(), modelGrid.getHeightForWidth(width)); + + // Which cards are shown can change without the size changing + if (modelGrid.getWidth() == width && modelGrid.getHeight() == height) + modelGrid.resized(); + else + modelGrid.setSize(width, height); + + modelGrid.repaint(); + } + + void requestOpen(const String& path, const String& name) + { + if (onModelOpenRequested) + onModelOpenRequested(path, name); + } + + // Shows instructions for a component in the instructions box while hovering over it + void addInstructions(Component& component, const String& instructions) + { + auto handler = std::make_unique(component); + + handler->onMouseEnter = [this, instructions] + { instructionsMessage->setMessage(instructions); }; + handler->onMouseExit = [this] { instructionsMessage->clearMessage(); }; + handler->attach(); + + hoverHandlers.push_back(std::move(handler)); + } + + Label titleLabel; + CatalogStatusIndicator statusIndicator; + TextButton refreshButton { "Refresh" }; + TextButton customPathButton { "Custom Path..." }; + TextEditor searchEditor; + ToggleButton meteredToggle { "Hide models that need an API key" }; + CategoryFilterBar categoryFilterBar; + Viewport viewport; + ModelGrid modelGrid; + Label noResultsLabel; + + std::vector> hoverHandlers; + + SharedResourcePointer catalog; + SharedResourcePointer instructionsMessage; +}; diff --git a/src/Main.cpp b/src/Main.cpp index 961955dc..0670b746 100644 --- a/src/Main.cpp +++ b/src/Main.cpp @@ -1,598 +1,719 @@ -/** - * @file Main.cpp - * @brief Initial JUCE launch code and window management. - * @author hugofloresgarcia, xribene, cwitkowitz - */ - -#include "MainComponent.h" - -#include "windows/WelcomeWindow.h" - -#include "utils/Settings.h" - -using namespace juce; - -class GuiAppApplication : public JUCEApplication, public FocusChangeListener -{ -public: - GuiAppApplication() - { - PropertiesFile::Options options; - - options.applicationName = getApplicationName(); - options.osxLibrarySubFolder = "Application Support/"; - options.folderName = getApplicationName(); - options.filenameSuffix = ".settings"; - /* - Settings should be located at: - - macOS: ~/Library/Application Support/HARP/HARP.settings - - Windows: %APPDATA%\HARP\HARP.settings (no need to manually set) - - Linux: ~/.config/HARP/HARP.settings (no need to manually set) - */ - options.commonToAllUsers = false; - applicationProperties.setStorageParameters(options); - - // Initialize Settings singleton with our application properties - Settings::initialize(&applicationProperties); - } - - /* - Multiple invocations are automatically handled on macOS / Windows. - */ - bool moreThanOneInstanceAllowed() override { return false; } - - /* - We inject the following as compile definitions from CMakeLists.txt. If you've - enabled the juce header with `juce_generate_juce_header()` you - could `#include ` and use `ProjectInfo::projectName`, etc., instead. - */ - const String getApplicationName() override { return JUCE_APPLICATION_NAME_STRING; } - const String getApplicationVersion() override { return JUCE_APPLICATION_VERSION_STRING; } - - /** - * Called when the app is invoked. - */ - void initialise(const String& commandLine) override - { - debugAndLog("GuiAppApplication::initialise: Invoked with command line \"" + commandLine - + "\"."); - debugAndLog("GuiAppApplication::getCommandLineParameters(): \"" + getCommandLineParameters() - + "\"."); - - appJustLaunched = true; - originalCommandLine = commandLine; - - clearLastFocusedWindow(); - Desktop::getInstance().addFocusChangeListener(this); - - String windowTitle = getApplicationName(); - - if (windowCounter > 1) - { - windowTitle += " (" + String(windowCounter) + ")"; - } - - windowCounter++; - - mainWindow.reset(new HARPWindow(windowTitle)); - - bool showWelcome = Settings::getBoolValue("view.showWelcomePopup", true); - - if (showWelcome) - { - if (auto* mainComp = - dynamic_cast(getMainWindowPtr()->getContentComponent())) - { - mainComp->openWelcomeWindow(); - } - } - - StringArray args; - - // Split command line arguments at spaces - args.addTokens(commandLine.trim(), " ", "\""); - - importInitialFiles(args); - - // Assumes app can't be re-invoked manually within 500ms of initial launch - Timer::callAfterDelay(500, [this]() { appJustLaunched = false; }); - } - - void importInitialFiles(StringArray files) - { - for (auto f : files) - { - File inputMediaFile(f.unquoted()); - - // Check if argument is valid file - if (inputMediaFile.existsAsFile()) - { - if (auto* mainComp = - dynamic_cast(mainWindow->getContentComponent())) - { - // Import file into initialized window - mainComp->importNewFile(inputMediaFile, true); - } - } - } - } - - /** - * Called when the app is invoked and other instances are running. - */ - void anotherInstanceStarted(const String& commandLine) override - { - StringArray args; - - // Split command line arguments at spaces - args.addTokens(commandLine.trim(), " ", "\""); - - /* - With moreThanOneInstanceAllowed() returning false, HARP is actually launched more - than once depending on the number of arguments given and the source of the launch. - - For example, when REAPER launches HARP with file path argument(s), initialise() is - called once with no arguments and anotherInstanceStarted() is subsequently called - with the arguments. However, when HARP is launched from the command line with - arguments, initialise() is called with the argument(s), and anotherInstanceStarted() - is subsequently called with the same argument(s). - - The following conditional is an ugly solution to an ugly problem... - - See also https://github.com/juce-framework/JUCE/issues/607 - */ - if (appJustLaunched) - { - if (! commandLine.isEmpty() && originalCommandLine.isEmpty()) - { - debugAndLog( - "GuiAppApplication::anotherInstanceStarted: Handling subsequent invocation with command line \"" - + commandLine + "\"."); - - importInitialFiles(args); - - return; - } - - debugAndLog( - "GuiAppApplication::anotherInstanceStarted: Ignoring spurious invocation with command line \"" - + commandLine + "\"."); - - return; - } - - debugAndLog( - "GuiAppApplication::anotherInstanceStarted: Another instance started with command line \"" - + commandLine + "\"."); - - for (auto arg : args) - { - File inputMediaFile(arg.unquoted()); - - // Check if argument is valid file - if (inputMediaFile.existsAsFile()) - { - // Choose/handle protocol for additional instances - tryOpenChoiceWindow(inputMediaFile); - } - } - } - - void shutdown() override - { - Desktop::getInstance().removeFocusChangeListener(this); - - // Delete our window - mainWindow = nullptr; - } - - /** - * Called when the app is being asked to quit. This request can be ignored and - * the app will continue to run, or quit() can be called to close the app. - */ - void systemRequestedQuit() override { quit(); } - - /** - * Implements desktop window that contains instance of MainComponent. - */ - class HARPWindow : public DocumentWindow - { - public: - explicit HARPWindow(String name) - : DocumentWindow(name, - Desktop::getInstance().getDefaultLookAndFeel().findColour( - ResizableWindow::backgroundColourId), - DocumentWindow::allButtons), - windowIdentifier(name.replaceCharacters(" :", "__")) - { - setUsingNativeTitleBar(true); - MainComponent* mainComp = new MainComponent(); - setContentOwned(mainComp, true); - setResizable(true, true); - - setConstrainer(&constrainer); - mainComp->updateWindowConstraints(); - - // Try to restore saved position and size - restoreWindowPosition(); - - setVisible(true); - } - - /** - * Defines the behavior when the user closes the window. Although multiple - * windows are supported, they will all belong to the same application instance - * and closing the main window will lead to all other windows closing. - * - * If the main window is closed, we promote the first additional window - * (if any exist) to become the new main window. - */ - void closeButtonPressed() override - { - // Save window position and size before closing - saveWindowPosition(); - - auto* app = dynamic_cast(JUCEApplication::getInstance()); - - if (this == app->getLastFocusedWindowPtr()) - { - // Clear last focused window pointer - app->clearLastFocusedWindow(); - } - - if (this == app->getMainWindowPtr()) - { - // Check if other windows open - if (app->hasAdditionalWindows()) - { - app->promoteAdditionalWindowToMain(); - - // Main window will be deleted automatically when it goes out of scope - } - else - { - // No other windows - terminate application - JUCEApplication::getInstance()->systemRequestedQuit(); - } - } - else - { - app->removeAdditionalWindow(this); - } - } - - /*void activeWindowStatusChanged() override - { - if (isActiveWindow()) - { - if (auto* mainComp = dynamic_cast(getContentComponent())) - { - // Check if loaded file(s) are still up to date when window refocused - mainComp->focusCallback(); - } - } - - DocumentWindow::activeWindowStatusChanged(); - }*/ - - private: - String getPrefix() { return "window." + windowIdentifier + "."; } - - void saveWindowPosition() - { - // Don't save minimized position - if (! isMinimised()) - { - auto bounds = getBounds(); - - String prefix = getPrefix(); - - // Create property names using window identifier to avoid conflicts - Settings::setValue(prefix + "x", bounds.getX()); - Settings::setValue(prefix + "y", bounds.getY()); - Settings::setValue(prefix + "width", bounds.getWidth()); - Settings::setValue(prefix + "height", bounds.getHeight()); - - Settings::saveIfNeeded(); - } - } - - void restoreWindowPosition() - { - String prefix = getPrefix(); - - // Check if we have saved position data - if (Settings::containsKey(prefix + "x") && Settings::containsKey(prefix + "y") - && Settings::containsKey(prefix + "width") - && Settings::containsKey(prefix + "height")) - { - // Get stored position and size - int x = Settings::getIntValue(prefix + "x"); - int y = Settings::getIntValue(prefix + "y"); - int width = Settings::getIntValue(prefix + "width"); - int height = Settings::getIntValue(prefix + "height"); - - // Validate size - width = jmax(100, width); - height = jmax(100, height); - - // Create rectangle with the stored bounds - Rectangle bounds(x, y, width, height); - - auto displays = Desktop::getInstance().getDisplays(); - - // Check if position exists on an available displays - if (displays.getDisplayForRect(bounds) != nullptr) - { - // Position is valid - setBounds(bounds); - } - else - { - // No display found for saved position - center on main display - centreWithSize(width, height); - } - } - else - { - // No saved position - center on screen - centreWithSize(getWidth(), getHeight()); - } - } - - String windowIdentifier; - - ComponentBoundsConstrainer constrainer; - - JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(HARPWindow) - }; - - HARPWindow* getMainWindowPtr() const { return mainWindow.get(); } - HARPWindow* getLastFocusedWindowPtr() const { return lastFocusedWindow; } - - bool hasAdditionalWindows() const { return ! additionalWindows.isEmpty(); } - - void promoteAdditionalWindowToMain() - { - if (! additionalWindows.isEmpty()) - { - // Promote first additional window in line as new main - mainWindow = std::move(additionalWindows.getReference(0)); - // Remove from additional windows - additionalWindows.remove(0); - - //windowCounter--; // TODO - leads to duplicate window numbers - } - } - - void removeAdditionalWindow(HARPWindow* windowToRemove) - { - for (int i = 0; i < additionalWindows.size(); ++i) - { - if (additionalWindows.getReference(i).get() == windowToRemove) - { - // Remove referenced window - additionalWindows.remove(i); - - //windowCounter--; // TODO - leads to duplicate window numbers - - break; - } - } - } - -private: - /* - There's no way to hit breakpoints in Main.cpp when invoking from the DAW, - so we use print statements and write to a dedicated log file located at: - - macOS: ~/Library/Logs/HARP/launch.log - - Windows: C:\Users\\AppData\Roaming\HARP\launch.log - - Linux: ~/.config/HARP/launch.log - */ - void debugAndLog(const String& message) - { - // Write message to standard error stream - DBG(message); - - // Get application platform-dependent logs directory - File logsDir = FileLogger::getSystemLogFileFolder().getChildFile(getApplicationName()); - // Ensure log directory exists - logsDir.createDirectory(); - // Create log file if it doesn't exist - File debugFile = logsDir.getChildFile("launch.log"); - // Write message to file - debugFile.appendText(message + "\n", true, true); - } - - void globalFocusChanged(Component* focusedComponent) override - { - if (focusedComponent == nullptr) - { - // Handle case where pointer was cleared prior to callback - return; - } - - // Obtain reference to top-level component containing focused child - Component* topFocusedComponent = focusedComponent->getTopLevelComponent(); - - if (HARPWindow* focusedWindow = dynamic_cast(topFocusedComponent)) - { - if (lastFocusedWindow != focusedWindow) - { - // Update pointer to most recently focused HARP window - lastFocusedWindow = focusedWindow; - - debugAndLog( - "GuiAppApplication::globalFocusChanged: Last focused window updated to \"" - + focusedWindow->getName() + "\"."); - } - } - } - - void clearLastFocusedWindow() { lastFocusedWindow = nullptr; } - - void tryOpenChoiceWindow(File inputMediaFile) - { - if (blockNewModalWindows) - { - // Wait until current modal window is closed to continue - Timer::callAfterDelay( - 100, [this, inputMediaFile]() { tryOpenChoiceWindow(inputMediaFile); }); - return; - } - - openChoiceWindow(inputMediaFile); - } - - void openChoiceWindow(File inputMediaFile) - { - // We deal with UI stuff so it's safer to work on the message thread - MessageManager::callAsync( - [this, inputMediaFile]() - { - // Check if existing user choice in settings - bool hasPreference = Settings::containsKey("newInstancePreference"); - int preferenceValue = Settings::getIntValue("newInstancePreference", -1); - - if (hasPreference && preferenceValue >= 0 && preferenceValue <= 1) - { - handleFileOpenChoice(preferenceValue, inputMediaFile); - } - else - { - blockNewModalWindows = true; - - // Show dialog with "Remember my choice" option - auto options = MessageBoxOptions() - .withTitle("Open File") - .withMessage("How would you like to open \"" - + inputMediaFile.getFileName() + "\"?") - .withIconType(MessageBoxIconType::QuestionIcon) - .withButton("New Window") - .withButton("Current Window") - .withButton("Cancel"); - - /* - Create custom AlertWindow to add checkbox. AlertWindow::showAsync() - is avoided because it's unclear how to add a checkbox to it and - MessageBoxOptions doesn't have withCustomComponent() method. - */ - std::unique_ptr alertWindow = std::make_unique( - options.getTitle(), options.getMessage(), options.getIconType()); - - alertWindow->addButton(options.getButtonText(0), 1); // New window - alertWindow->addButton(options.getButtonText(1), 2); // Current window - alertWindow->addButton(options.getButtonText(2), 0); // Cancel - - // Checkbox is a raw pointer because it couldn't be passed to lambda as a unique_ptr - auto rememberCheckbox = std::make_unique("Remember my choice"); - rememberCheckbox->setSize(200, 24); - rememberCheckbox->setName(""); - - // AlertWindow doesn't take ownership of custom components so checkbox needs to be deleted manually - alertWindow->addCustomComponent(rememberCheckbox.get()); - - // Open window asynchronously - alertWindow->enterModalState( - true, - ModalCallbackFunction::create( - [this, - alertWindow = alertWindow.release(), - inputMediaFile, - rememberCheckboxPtr = std::move(rememberCheckbox)](int result) - { - blockNewModalWindows = false; - - if (result != 0) // Cancel - { - int choice = result - 1; - - bool rememberChoice = rememberCheckboxPtr->getToggleState(); - - // Save preference if requested but d on't save Cancel choice - if (rememberChoice && choice <= 1) - { - Settings::setValue("newInstancePreference", choice, true); - } - - handleFileOpenChoice(choice, inputMediaFile); - } - }), - true); - } - }); - } - - void handleFileOpenChoice(int choice, const File& inputMediaFile) - { - switch (choice) - { - case 0: // New Window - { - String windowTitle = getApplicationName(); - - windowCounter++; - - if (windowCounter > 1) - { - windowTitle += " (" + String(windowCounter - 1) + ")"; - } - - std::unique_ptr newWindow = std::make_unique(windowTitle); - - if (auto* mainComp = dynamic_cast(newWindow->getContentComponent())) - { - // Import file into new window - mainComp->importNewFile(inputMediaFile, true); - } - - // Store window and make it visible - additionalWindows.add(std::move(newWindow)); - - break; - } - case 1: // Current window - { - HARPWindow* windowForFileImport; - - if (lastFocusedWindow != nullptr) - { - windowForFileImport = lastFocusedWindow; - } - else - { - debugAndLog( - "GuiAppApplication::handleFileOpenChoice: No window currently focused. Importing file to main window."); - - windowForFileImport = getMainWindowPtr(); - } - - if (auto* mainComp = - dynamic_cast(windowForFileImport->getContentComponent())) - { - // Import file into most recently focused window - mainComp->importNewFile(inputMediaFile, true); - } - - break; - } - } - } - - std::unique_ptr mainWindow; - Array> additionalWindows; - - HARPWindow* lastFocusedWindow; - - ApplicationProperties applicationProperties; - - int windowCounter = 0; - - String originalCommandLine; - - std::atomic appJustLaunched { false }; - std::atomic blockNewModalWindows { false }; -}; - -// Generates main() routine that launches app -START_JUCE_APPLICATION(GuiAppApplication) +/** + * @file Main.cpp + * @brief Initial JUCE launch code and window management. + * @author cwitkowitz, xribene, hugofloresgarcia + */ + +#include "MainComponent.h" + +#include "utils/Settings.h" + +#if JUCE_LINUX +#include +#endif + +using namespace juce; + +class GuiAppApplication : public JUCEApplication, public FocusChangeListener +{ +public: + GuiAppApplication() + { + PropertiesFile::Options options; + + options.applicationName = getApplicationName(); + options.osxLibrarySubFolder = "Application Support/"; + options.folderName = getApplicationName(); + options.filenameSuffix = ".settings"; + /* + Settings should be located at: + - macOS: ~/Library/Application Support/HARP/HARP.settings + - Windows: %APPDATA%\HARP\HARP.settings (no need to manually set) + - Linux: ~/.config/HARP/HARP.settings (no need to manually set) + */ + options.commonToAllUsers = false; + applicationProperties.setStorageParameters(options); + + // Initialize Settings singleton with our application properties + Settings::initialize(&applicationProperties); + } + + /* + Multiple invocations are automatically handled on macOS / Windows. + */ + bool moreThanOneInstanceAllowed() override { return false; } + + /* + We inject the following as compile definitions from CMakeLists.txt. If you've + enabled the juce header with `juce_generate_juce_header()` you + could `#include ` and use `ProjectInfo::projectName`, etc., instead. + */ + const String getApplicationName() override { return JUCE_APPLICATION_NAME_STRING; } + const String getApplicationVersion() override { return JUCE_APPLICATION_VERSION_STRING; } + + /** + * Called when the app is invoked. + */ + void initialise(const String& commandLine) override + { + debugAndLog("GuiAppApplication::initialise: Invoked with command line \"" + commandLine + + "\"."); + debugAndLog("GuiAppApplication::getCommandLineParameters(): \"" + getCommandLineParameters() + + "\"."); + + appJustLaunched = true; + originalCommandLine = commandLine; + + clearLastFocusedWindow(); + Desktop::getInstance().addFocusChangeListener(this); + + windowCounter++; + + mainWindow = std::make_unique(getApplicationName()); + + bool showWelcome = Settings::getBoolValue("view.showWelcomePopup", true); + + if (showWelcome) + { + if (auto* mainComp = + dynamic_cast(getMainWindowPtr()->getContentComponent())) + { + mainComp->openTutorial(); + } + } + + StringArray args; + + // Split command line arguments at spaces + args.addTokens(commandLine.trim(), " ", "\""); + + importInitialFiles(args); + + // Assumes app can't be re-invoked manually within 500ms of initial launch + Timer::callAfterDelay(500, [this]() { appJustLaunched = false; }); + } + + void importInitialFiles(StringArray files) + { + for (auto f : files) + { + File inputMediaFile(f.unquoted()); + + // Check if argument is valid file + if (inputMediaFile.existsAsFile()) + { + if (auto* mainComp = + dynamic_cast(mainWindow->getContentComponent())) + { + // Import file into initialized window + mainComp->importNewFile(inputMediaFile, true); + } + } + } + } + + /** + * Called when the app is invoked and other instances are running. + */ + void anotherInstanceStarted(const String& commandLine) override + { + StringArray args; + + // Split command line arguments at spaces + args.addTokens(commandLine.trim(), " ", "\""); + + /* + With moreThanOneInstanceAllowed() returning false, HARP is actually launched more + than once depending on the number of arguments given and the source of the launch. + + For example, when REAPER launches HARP with file path argument(s), initialise() is + called once with no arguments and anotherInstanceStarted() is subsequently called + with the arguments. However, when HARP is launched from the command line with + arguments, initialise() is called with the argument(s), and anotherInstanceStarted() + is subsequently called with the same argument(s). + + The following conditional is an ugly solution to an ugly problem... + + See also https://github.com/juce-framework/JUCE/issues/607 + */ + if (appJustLaunched) + { + if (! commandLine.isEmpty() && originalCommandLine.isEmpty()) + { + debugAndLog( + "GuiAppApplication::anotherInstanceStarted: Handling subsequent invocation with command line \"" + + commandLine + "\"."); + + importInitialFiles(args); + + return; + } + + debugAndLog( + "GuiAppApplication::anotherInstanceStarted: Ignoring spurious invocation with command line \"" + + commandLine + "\"."); + + return; + } + + debugAndLog( + "GuiAppApplication::anotherInstanceStarted: Another instance started with command line \"" + + commandLine + "\"."); + + for (auto arg : args) + { + File inputMediaFile(arg.unquoted()); + + // Check if argument is valid file + if (inputMediaFile.existsAsFile()) + { + // Choose/handle protocol for additional instances + tryOpenChoiceWindow(inputMediaFile); + } + } + } + + void shutdown() override + { + Desktop::getInstance().removeFocusChangeListener(this); + + // Delete our window + mainWindow = nullptr; + } + + /** + * Called when the app is being asked to quit. This request can be ignored and + * the app will continue to run, or quit() can be called to close the app. + */ + void systemRequestedQuit() override { quit(); } + + /** + * Implements desktop window that contains instance of MainComponent. + */ + class HARPWindow : public DocumentWindow + { + public: + explicit HARPWindow(String name) + : DocumentWindow(name, + Desktop::getInstance().getDefaultLookAndFeel().findColour( + ResizableWindow::backgroundColourId), + DocumentWindow::allButtons), + windowIdentifier(name.replaceCharacters(" :", "__")) + { + setUsingNativeTitleBar(true); + MainComponent* mainComp = new MainComponent(); + setContentOwned(mainComp, true); + setResizable(true, true); + + setConstrainer(&constrainer); + mainComp->updateWindowConstraints(); + + // Try to restore saved position and size + restoreWindowPosition(); + + setVisible(true); + } + + /** + * Hands the constrainer's minimum size to the window manager. + * + * JUCE publishes size limits from the constrainer in + * XWindowSystem::updateConstraints, but only reaches the branch that reads it + * when the peer carries ComponentPeer::windowIsResizable. ResizableWindow only + * sets that flag where Desktop::supportsBorderlessNonClientResize() is true, + * which on Linux it never is, so the other branch pins the limits to the + * current size instead. Either way XWindowSystem::setBounds then assigns + * WM_NORMAL_HINTS outright as USSize | USPosition, dropping whatever was just + * published, so the window advertises no limits at all and the window manager + * allows any size. Re-applying the minimum here, after each resize, is what + * the window manager actually ends up seeing. + * + * Snapping back afterwards is not a workable substitute: the window manager + * may refuse, leaving the window and its contents at different sizes. + * + * Xlib is reached through dlopen rather than its headers, whose Window, Time + * and KeyPress macros collide with JUCE's types. Does nothing on platforms + * where JUCE already applies the limits itself. + */ + void publishMinimumSizeToWindowManager() + { +#if JUCE_LINUX + auto* peer = getPeer(); + + if (peer == nullptr) + { + return; + } + + const auto windowHandle = (unsigned long) (pointer_sized_int) peer->getNativeHandle(); + + if (windowHandle == 0) + { + return; + } + + /* Mirrors Xlib's XSizeHints, whose layout is fixed by the X11 ABI */ + struct SizeHints + { + long flags; + int x, y, width, height; + int minWidth, minHeight; + int maxWidth, maxHeight; + int widthInc, heightInc; + struct + { + int x, y; + } minAspect, maxAspect; + int baseWidth, baseHeight; + int winGravity; + }; + + static constexpr long minSizeFlag = 1L << 4; // PMinSize + + /* Opened once and held for the lifetime of the process. This runs on + every resize, and a connection set up and torn down per call would + mean a full X handshake many times a second while a resize is being + dragged. The connection is deliberately separate from the one JUCE + holds: Xlib's locking is per-display, so nothing here can race with + JUCE's own X calls and no ScopedXLock equivalent is needed. */ + struct XLib + { + void* display = nullptr; + SizeHints* (*allocSizeHints)() = nullptr; + int (*getWMNormalHints)(void*, unsigned long, SizeHints*, long*) = nullptr; + void (*setWMNormalHints)(void*, unsigned long, SizeHints*) = nullptr; + int (*freeMemory)(void*) = nullptr; + int (*flush)(void*) = nullptr; + + XLib() + { + void* handle = dlopen("libX11.so.6", RTLD_LAZY); + + if (handle == nullptr) + { + return; + } + + auto* openDisplay = (void* (*) (const char*) ) dlsym(handle, "XOpenDisplay"); + + allocSizeHints = (decltype(allocSizeHints)) dlsym(handle, "XAllocSizeHints"); + getWMNormalHints = + (decltype(getWMNormalHints)) dlsym(handle, "XGetWMNormalHints"); + setWMNormalHints = + (decltype(setWMNormalHints)) dlsym(handle, "XSetWMNormalHints"); + freeMemory = (decltype(freeMemory)) dlsym(handle, "XFree"); + flush = (decltype(flush)) dlsym(handle, "XFlush"); + + if (openDisplay != nullptr && allocSizeHints != nullptr + && getWMNormalHints != nullptr && setWMNormalHints != nullptr + && freeMemory != nullptr && flush != nullptr) + { + display = openDisplay(nullptr); + } + } + + bool isUsable() const { return display != nullptr; } + }; + + static const XLib xlib; + + if (! xlib.isUsable()) + { + return; + } + + if (SizeHints* hints = xlib.allocSizeHints()) + { + long supplied = 0; + + // Preserve whatever the window already advertises + xlib.getWMNormalHints(xlib.display, windowHandle, hints, &supplied); + + hints->flags |= minSizeFlag; + hints->minWidth = constrainer.getMinimumWidth(); + hints->minHeight = constrainer.getMinimumHeight(); + + xlib.setWMNormalHints(xlib.display, windowHandle, hints); + xlib.freeMemory(hints); + } + + xlib.flush(xlib.display); +#endif + } + + void resized() override + { + DocumentWindow::resized(); + + /* JUCE rewrites WM_NORMAL_HINTS while setting the window bounds, and + replaces the structure rather than merging into it, so the minimum + has to be applied once that has happened. */ + Component::SafePointer safeThis(this); + + MessageManager::callAsync( + [safeThis] + { + if (safeThis != nullptr) + { + safeThis->publishMinimumSizeToWindowManager(); + } + }); + } + + /** + * Defines the behavior when the user closes the window. Although multiple + * windows are supported, they will all belong to the same application instance + * and closing the main window will lead to all other windows closing. + * + * If the main window is closed, we promote the first additional window + * (if any exist) to become the new main window. + */ + void closeButtonPressed() override + { + // Save window position and size before closing + saveWindowPosition(); + + auto* app = dynamic_cast(JUCEApplication::getInstance()); + + if (this == app->getLastFocusedWindowPtr()) + { + // Clear last focused window pointer + app->clearLastFocusedWindow(); + } + + if (this == app->getMainWindowPtr()) + { + // Check if other windows open + if (app->hasAdditionalWindows()) + { + app->promoteAdditionalWindowToMain(); + + // Main window will be deleted automatically when it goes out of scope + } + else + { + // No other windows, so terminate the application + JUCEApplication::getInstance()->systemRequestedQuit(); + } + } + else + { + app->removeAdditionalWindow(this); + } + } + + private: + String getPrefix() { return "window." + windowIdentifier + "."; } + + void saveWindowPosition() + { + // Don't save minimized position + if (! isMinimised()) + { + auto bounds = getBounds(); + + String prefix = getPrefix(); + + // Create property names using window identifier to avoid conflicts + Settings::setValue(prefix + "x", bounds.getX()); + Settings::setValue(prefix + "y", bounds.getY()); + Settings::setValue(prefix + "width", bounds.getWidth()); + Settings::setValue(prefix + "height", bounds.getHeight()); + + Settings::saveIfNeeded(); + } + } + + void restoreWindowPosition() + { + String prefix = getPrefix(); + + // Check if we have saved position data + if (Settings::containsKey(prefix + "x") && Settings::containsKey(prefix + "y") + && Settings::containsKey(prefix + "width") + && Settings::containsKey(prefix + "height")) + { + // Get stored position and size + int x = Settings::getIntValue(prefix + "x"); + int y = Settings::getIntValue(prefix + "y"); + int width = Settings::getIntValue(prefix + "width"); + int height = Settings::getIntValue(prefix + "height"); + + // Validate size + width = jmax(100, width); + height = jmax(100, height); + + // Create rectangle with the stored bounds + Rectangle bounds(x, y, width, height); + + auto displays = Desktop::getInstance().getDisplays(); + + // Check if position exists on an available displays + if (displays.getDisplayForRect(bounds) != nullptr) + { + // Position is valid + setBounds(bounds); + } + else + { + // No display found for saved position, so center on main display + centreWithSize(width, height); + } + } + else + { + // No saved position, so center on screen + centreWithSize(getWidth(), getHeight()); + } + } + + String windowIdentifier; + + ComponentBoundsConstrainer constrainer; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(HARPWindow) + }; + + HARPWindow* getMainWindowPtr() const { return mainWindow.get(); } + HARPWindow* getLastFocusedWindowPtr() const { return lastFocusedWindow; } + + bool hasAdditionalWindows() const { return ! additionalWindows.isEmpty(); } + + void promoteAdditionalWindowToMain() + { + if (! additionalWindows.isEmpty()) + { + // Promote first additional window in line as new main + mainWindow = std::move(additionalWindows.getReference(0)); + // Remove from additional windows + additionalWindows.remove(0); + } + } + + void removeAdditionalWindow(HARPWindow* windowToRemove) + { + for (int i = 0; i < additionalWindows.size(); ++i) + { + if (additionalWindows.getReference(i).get() == windowToRemove) + { + // Remove referenced window + additionalWindows.remove(i); + + break; + } + } + } + +private: + /* + There's no way to hit breakpoints in Main.cpp when invoking from the DAW, + so we use print statements and write to a dedicated log file located at: + - macOS: ~/Library/Logs/HARP/launch.log + - Windows: C:\Users\\AppData\Roaming\HARP\launch.log + - Linux: ~/.config/HARP/launch.log + */ + void debugAndLog(const String& message) + { + // Write message to standard error stream + DBG(message); + + // Get application platform-dependent logs directory + File logsDir = FileLogger::getSystemLogFileFolder().getChildFile(getApplicationName()); + // Ensure log directory exists + logsDir.createDirectory(); + // Create log file if it doesn't exist + File debugFile = logsDir.getChildFile("launch.log"); + // Write message to file + debugFile.appendText(message + "\n", true, true); + } + + void globalFocusChanged(Component* focusedComponent) override + { + if (focusedComponent == nullptr) + { + // Handle case where pointer was cleared prior to callback + return; + } + + // Obtain reference to top-level component containing focused child + Component* topFocusedComponent = focusedComponent->getTopLevelComponent(); + + if (HARPWindow* focusedWindow = dynamic_cast(topFocusedComponent)) + { + if (lastFocusedWindow != focusedWindow) + { + // Update pointer to most recently focused HARP window + lastFocusedWindow = focusedWindow; + + debugAndLog( + "GuiAppApplication::globalFocusChanged: Last focused window updated to \"" + + focusedWindow->getName() + "\"."); + } + } + } + + void clearLastFocusedWindow() { lastFocusedWindow = nullptr; } + + void tryOpenChoiceWindow(File inputMediaFile) + { + if (blockNewModalWindows) + { + // Wait until current modal window is closed to continue + Timer::callAfterDelay( + 100, [this, inputMediaFile]() { tryOpenChoiceWindow(inputMediaFile); }); + return; + } + + openChoiceWindow(inputMediaFile); + } + + void openChoiceWindow(File inputMediaFile) + { + // We deal with UI stuff so it's safer to work on the message thread + MessageManager::callAsync( + [this, inputMediaFile]() + { + // Check if existing user choice in settings + bool hasPreference = Settings::containsKey("newInstancePreference"); + int preferenceValue = Settings::getIntValue("newInstancePreference", -1); + + if (hasPreference && preferenceValue >= 0 && preferenceValue <= 1) + { + handleFileOpenChoice(preferenceValue, inputMediaFile); + } + else + { + blockNewModalWindows = true; + + // Show dialog with "Remember my choice" option + auto options = MessageBoxOptions() + .withTitle("Open File") + .withMessage("How would you like to open \"" + + inputMediaFile.getFileName() + "\"?") + .withIconType(MessageBoxIconType::QuestionIcon) + .withButton("New Window") + .withButton("Current Window") + .withButton("Cancel"); + + /* + Create custom AlertWindow to add checkbox. AlertWindow::showAsync() + is avoided because it's unclear how to add a checkbox to it and + MessageBoxOptions doesn't have withCustomComponent() method. + */ + auto alertWindow = std::make_unique( + options.getTitle(), options.getMessage(), options.getIconType()); + + alertWindow->addButton(options.getButtonText(0), 1); // New window + alertWindow->addButton(options.getButtonText(1), 2); // Current window + alertWindow->addButton(options.getButtonText(2), 0); // Cancel + + // AlertWindow does not own its custom components, so the callback does + auto rememberCheckbox = std::make_unique("Remember my choice"); + rememberCheckbox->setSize(200, 24); + rememberCheckbox->setName(""); + + alertWindow->addCustomComponent(rememberCheckbox.get()); + + // Deleted by the modal manager once it is dismissed + alertWindow.release()->enterModalState( + true, + ModalCallbackFunction::create( + [this, + inputMediaFile, + rememberCheckboxPtr = std::move(rememberCheckbox)](int result) + { + blockNewModalWindows = false; + + if (result != 0) // Cancel + { + int choice = result - 1; + + bool rememberChoice = rememberCheckboxPtr->getToggleState(); + + // Save the preference if requested + if (rememberChoice) + { + Settings::setValue("newInstancePreference", choice, true); + } + + handleFileOpenChoice(choice, inputMediaFile); + } + }), + true); + } + }); + } + + void handleFileOpenChoice(int choice, const File& inputMediaFile) + { + switch (choice) + { + case 0: // New Window + { + String windowTitle = getApplicationName(); + + windowCounter++; + + if (windowCounter > 1) + { + windowTitle += " (" + String(windowCounter - 1) + ")"; + } + + std::unique_ptr newWindow = std::make_unique(windowTitle); + + if (auto* mainComp = dynamic_cast(newWindow->getContentComponent())) + { + // Import file into new window + mainComp->importNewFile(inputMediaFile, true); + } + + // Store window and make it visible + additionalWindows.add(std::move(newWindow)); + + break; + } + case 1: // Current window + { + HARPWindow* windowForFileImport; + + if (lastFocusedWindow != nullptr) + { + windowForFileImport = lastFocusedWindow; + } + else + { + debugAndLog( + "GuiAppApplication::handleFileOpenChoice: No window currently focused. Importing file to main window."); + + windowForFileImport = getMainWindowPtr(); + } + + if (auto* mainComp = + dynamic_cast(windowForFileImport->getContentComponent())) + { + // Import file into most recently focused window + mainComp->importNewFile(inputMediaFile, true); + } + + break; + } + } + } + + std::unique_ptr mainWindow; + Array> additionalWindows; + + HARPWindow* lastFocusedWindow = nullptr; + + ApplicationProperties applicationProperties; + + int windowCounter = 0; + + String originalCommandLine; + + std::atomic appJustLaunched { false }; + std::atomic blockNewModalWindows { false }; +}; + +// Generates main() routine that launches app +START_JUCE_APPLICATION(GuiAppApplication) diff --git a/src/MainComponent.cpp b/src/MainComponent.cpp index a31a32df..2e06903d 100644 --- a/src/MainComponent.cpp +++ b/src/MainComponent.cpp @@ -1,7 +1,5 @@ #include "MainComponent.h" -#include "windows/WelcomeWindow.h" - JUCE_IMPLEMENT_SINGLETON(HARPLogger) MainComponent::MainComponent() @@ -10,28 +8,66 @@ MainComponent::MainComponent() initializeMenuBar(); - mainModelTab.addChangeListener(this); + modelTabs.addChangeListener(this); + + /* Every model tab needs this, and tabs are opened as models are, so the container + carries it and hands it to each tab it creates. */ + modelTabs.onOpenAPIKeySettings = [this](Provider provider) { openSettingsWindow(provider); }; - addAndMakeVisible(mainModelTab); + addAndMakeVisible(modelTabs); addAndMakeVisible(statusAreaWidget); addAndMakeVisible(mediaClipboardWidget); + statusAreaWidget.setComponentID(TutorialTargets::statusArea); + mediaClipboardWidget.setComponentID(TutorialTargets::clipboard); + + mediaClipboardWidget.onResize = [this](int newWidth) + { + // Leave the main panel at least the width its controls need + const int maximumClipboardWidth = getWidth() - getRequiredMainPanelWidth(); + + clipboardWidth = + jmax(mediaClipboardWidget.getMinimumWidth(), jmin(newWidth, maximumClipboardWidth)); + resized(); + }; + addAndMakeVisible(dragOverlay); + showStatusArea = Settings::getBoolValue("view.showStatusArea", true); showMediaClipboard = Settings::getBoolValue("view.showMediaClipboard", false); + mediaClipboardWidget.setPreviewPaneVisible( + Settings::getBoolValue("view.showPreviewPane", true)); + mediaClipboardWidget.onPreviewPaneVisibilityChanged = [this] + { + Settings::setValue( + "view.showPreviewPane", mediaClipboardWidget.isPreviewPaneVisible() ? "1" : "0", true); + commandManager.commandStatusChanged(); + }; + + // Sizes the clipboard and its preview pane were last given + clipboardWidth = jmax( + mediaClipboardWidget.getMinimumWidth(), + Settings::getIntValue("view.mediaClipboardWidth", mediaClipboardWidget.getDefaultWidth())); + mediaClipboardWidget.setPreviewPaneHeight( + Settings::getIntValue("view.previewPaneHeight", PreviewPaneWidget::defaultHeight)); + mediaClipboardWidget.setPreviewPaneMinimized( + Settings::getBoolValue("view.previewPaneMinimized", false)); + // Attached after the sizes are restored, which would otherwise save them straight back + mediaClipboardWidget.onLayoutChanged = [this] { saveClipboardLayout(); }; + + dragOverlay.setVisible(false); + dragOverlay.toFront(false); requiredWindowWidth = minimumWindowWidth; requiredWindowHeight = minimumWindowHeight; setSize(requiredWindowWidth, requiredWindowHeight); - sharedTokens->initializeAPIKeys(); - statusMessage->setMessage("Welcome to HARP!"); } MainComponent::~MainComponent() { deinitializeMenuBar(); - mainModelTab.removeChangeListener(this); + modelTabs.removeChangeListener(this); } void MainComponent::paint(Graphics& g) @@ -39,55 +75,11 @@ void MainComponent::paint(Graphics& g) g.fillAll(getUIColourIfAvailable(LookAndFeel_V4::ColourScheme::UIColour::windowBackground)); } -void MainComponent::paintOverChildren(Graphics& g) -{ - if (isTutorialActive) - { - auto area = getLocalBounds(); - g.setColour(Colours::black.withAlpha(0.6f)); - - if (tutorialHighlightRect.isEmpty() && tutorialExtraHighlights.empty()) - { - // Full dim if no highlight - g.fillAll(); - } - else - { - // Dim with cutout - Path backgroundPath; - backgroundPath.addRectangle(area.toFloat()); - - Path highlightPath; - if (! tutorialHighlightRect.isEmpty()) - highlightPath.addRoundedRectangle(tutorialHighlightRect.toFloat(), 5.0f); - - // Add extra highlights to the cutout path - for (auto& rect : tutorialExtraHighlights) - { - highlightPath.addRoundedRectangle(rect.toFloat(), 5.0f); - } - - backgroundPath.setUsingNonZeroWinding(false); - backgroundPath.addPath(highlightPath); - - g.fillPath(backgroundPath); - - g.setColour(Colours::white); - g.drawRoundedRectangle(tutorialHighlightRect.toFloat(), 5.0f, 2.0f); - - for (auto& rect : tutorialExtraHighlights) - { - g.drawRoundedRectangle(rect.toFloat(), 5.0f, 2.0f); - } - } - } -} - void MainComponent::resized() { Rectangle fullArea = getLocalBounds(); -#if not JUCE_MAC +#if ! JUCE_MAC menuBar->setBounds( fullArea.removeFromTop(LookAndFeel::getDefaultLookAndFeel().getDefaultMenuBarHeight())); #endif @@ -98,7 +90,7 @@ void MainComponent::resized() FlexBox mainPanel; mainPanel.flexDirection = FlexBox::Direction::column; - mainPanel.items.add(FlexItem(mainModelTab).withFlex(1.0)); + mainPanel.items.add(FlexItem(modelTabs).withFlex(1.0)); if (showStatusArea) { @@ -113,7 +105,7 @@ void MainComponent::resized() if (showMediaClipboard) { - fullWindow.items.add(FlexItem(mediaClipboardWidget).withFlex(mediaClipboardFlex)); + fullWindow.items.add(FlexItem(mediaClipboardWidget).withWidth(getVisibleClipboardWidth())); } else { @@ -122,33 +114,50 @@ void MainComponent::resized() fullWindow.performLayout(fullArea); - if (welcomeWindow != nullptr) - { - welcomeWindow->refreshHighlightForCurrentStep(); - } + dragOverlay.setBounds(getLocalBounds()); +} + +int MainComponent::measureRequiredMainPanelWidth() +{ + // The Home tab has no controls, so only the general minimums apply while it is showing + auto* tab = modelTabs.getCurrentModelTab(); + const int requiredControlWidth = tab != nullptr ? tab->getMinimumRequiredControlWidth() : 0; + + // Determine minimum width needed to display controls plus padding + return jmax(minimumMainPanelWidth, requiredControlWidth + minimumMainPanelHorPadding); +} + +int MainComponent::getVisibleClipboardWidth() +{ + /* clipboardWidth is the width the user chose. In a narrow window the clipboard + gives up space, down to its own minimum, so that the main panel keeps the width + it needs. The clipboard gets that space back when the window widens again. */ + const int availableWidth = getWidth() - getRequiredMainPanelWidth(); + + return jmax(mediaClipboardWidget.getMinimumWidth(), jmin(clipboardWidth, availableWidth)); } void MainComponent::updateWindowConstraints() { + /* Measuring the control labels is slow, so it happens here, whenever the controls + can have changed, rather than on every resize of the window or the clipboard. */ + requiredMainPanelWidth = measureRequiredMainPanelWidth(); + if (auto* window = findParentComponentOfClass()) { - // Compute percentage of total window width given to main panel - const float mainPanelRatio = showMediaClipboard ? (1.0f / mediaClipboardScale) : 1.0f; - - // Determine minimum width needed to display controls plus padding - const int requiredMainPanelWidth = - jmax(minimumMainPanelWidth, - mainModelTab.getMinimumRequiredControlWidth() + minimumMainPanelHorPadding); - // Determine current width of main panel - const int mainPanelWidth = jmax(requiredMainPanelWidth, mainModelTab.getWidth()); - // Determine minimum height needed to display all model contents plus status widget - const int requiredMainPanelHeight = - mainModelTab.getMinimumRequiredHeightForWidth(mainPanelWidth) - + (showStatusArea ? statusAreaHeight : 0); + // The window can shrink until both panels are down to their minimum widths + const int requiredClipboardWidth = + showMediaClipboard ? mediaClipboardWidget.getMinimumWidth() : 0; + + /* Each tab scrolls vertically, so the window does not have to be tall enough + for every control. It only has to stay usably large. Width is still + content-driven, since there is no horizontal scrolling. */ + const int requiredMainPanelHeight = minimumWindowHeight - minimumMainPanelVertPadding + + (showStatusArea ? statusAreaHeight : 0); // Determine effective minimum width of entire window - const int newRequiredWindowWidth = jmax( - minimumWindowWidth, (int) std::ceil((float) requiredMainPanelWidth / mainPanelRatio)); + const int newRequiredWindowWidth = + jmax(minimumWindowWidth, requiredMainPanelWidth + requiredClipboardWidth); // Determine effective minimum height of entire window const int newRequiredWindowHeight = jmax(minimumWindowHeight, requiredMainPanelHeight + minimumMainPanelVertPadding); @@ -185,6 +194,13 @@ void MainComponent::updateWindowConstraints() window->setBounds(bounds); } } + + /* Whatever prompted this (e.g., a model loading or a panel being toggled) changed + how much there is to show, and so how much the current tab has to scroll. The + window itself may not have changed size, in which case nothing else would + recompute the scrollable area and the scrollbar would not appear until the + next resize. */ + modelTabs.layOutCurrentPage(); } /* --File-- */ @@ -202,12 +218,29 @@ void MainComponent::importNewFile(File mediaFile, bool fromDAW) } } -void MainComponent::openSettingsWindow() +void MainComponent::openSettingsWindow(std::optional focusProvider) { DialogWindow::LaunchOptions options; options.dialogTitle = "Settings"; options.dialogBackgroundColour = Colours::darkgrey; - options.content.setOwned(new SettingsWindow()); + // The settings dialog is a free-standing desktop window that can outlive this + // component, so guard the callback with a SafePointer + Component::SafePointer safeThis(this); + + auto* settings = new SettingsWindow( + [safeThis] + { + if (safeThis != nullptr) + safeThis->restoreViewDefaults(); + }); + + // Selected before the window is shown, so it opens already on the right tab + if (focusProvider.has_value()) + { + settings->showAPIKeysFor(*focusProvider); + } + + options.content.setOwned(settings); options.useNativeTitleBar = true; options.resizable = true; @@ -216,8 +249,53 @@ void MainComponent::openSettingsWindow() options.launchAsync(); } +void MainComponent::restoreViewDefaults() +{ + // Defaults must match the fallbacks used when reading the settings + // in the constructor: status area shown, media clipboard hidden, preview pane shown, + // and the clipboard and preview pane at their default sizes + if (! showStatusArea) + viewStatusAreaCallback(); + + if (showMediaClipboard) + viewMediaClipboardCallback(); + + if (! mediaClipboardWidget.isPreviewPaneVisible()) + viewPreviewPaneCallback(); + + // Sizes as well, after hiding the clipboard, which shrinks the window by its current width + clipboardWidth = mediaClipboardWidget.getDefaultWidth(); + mediaClipboardWidget.setPreviewPaneHeight(PreviewPaneWidget::defaultHeight); + mediaClipboardWidget.setPreviewPaneMinimized(false); + + /* The window takes the geometry of a first launch, which HARPWindow gives it when no + position is saved: as small as the default view allows, and centred. Its position + is saved again when it closes, so leaving it where it was would undo the reset. */ + if (auto* window = findParentComponentOfClass()) + { + updateWindowConstraints(); + + if (window->isFullScreen()) + window->setFullScreen(false); + + window->centreWithSize(requiredWindowWidth, requiredWindowHeight); + } + + // showWelcomePopup default (true) is already restored by clearing settings; + // it will show on the next launch automatically. +} + /* --View-- */ +void MainComponent::saveClipboardLayout() +{ + Settings::setValue("view.mediaClipboardWidth", clipboardWidth); + Settings::setValue("view.previewPaneHeight", mediaClipboardWidget.getPreviewPaneHeight()); + Settings::setValue("view.previewPaneMinimized", + mediaClipboardWidget.isPreviewPaneMinimized() ? "1" : "0", + true); +} + void MainComponent::viewStatusAreaCallback() { // Toggle status Area visibility state @@ -276,11 +354,24 @@ void MainComponent::viewStatusAreaCallback() updateWindowConstraints(); } +void MainComponent::viewPreviewPaneCallback() +{ + // Saving the preference and updating the menu both happen in + // onPreviewPaneVisibilityChanged, which also covers the pane's own close button + mediaClipboardWidget.setPreviewPaneVisible(! mediaClipboardWidget.isPreviewPaneVisible()); +} + void MainComponent::viewMediaClipboardCallback() { // Toggle media clipboard visibility state showMediaClipboard = ! showMediaClipboard; + // A hidden preview pane has no controls left to stop it with + if (! showMediaClipboard) + { + mediaClipboardWidget.stopPlayback(); + } + // Find top-level window for resizing if (auto* window = findParentComponentOfClass()) { @@ -308,18 +399,16 @@ void MainComponent::viewMediaClipboardCallback() if (showMediaClipboard) { - // Scale bounds to extend window by 40% of main width + // Opened at the width it was last given windowBounds.setWidth( - jmin(currentDisplayWidth, - static_cast(mediaClipboardScale * windowBounds.getWidth()))); + jmin(currentDisplayWidth, windowBounds.getWidth() + clipboardWidth)); } else { if (! window->isFullScreen()) { - // Scale bounds to reduce window to main width windowBounds.setWidth( - static_cast(windowBounds.getWidth() / mediaClipboardScale)); + jmax(minimumWindowWidth, windowBounds.getWidth() - getVisibleClipboardWidth())); } } @@ -354,14 +443,11 @@ void MainComponent::openAboutWindow() options.launchAsync(); } -void MainComponent::openWelcomeWindow(bool ensureDefaultModelLoaded) +void MainComponent::openTutorial() { - if (ensureDefaultModelLoaded) - ensureTutorialModelLoaded(); - - if (welcomeWindow != nullptr) + if (tutorialWindow != nullptr) { - welcomeWindow->toFront(true); + tutorialWindow->toFront(true); return; } @@ -372,242 +458,43 @@ void MainComponent::openWelcomeWindow(bool ensureDefaultModelLoaded) if (safeThis == nullptr) return; - safeThis->welcomeWindow.reset(new WelcomeWindow(safeThis.getComponent())); - safeThis->welcomeWindow->onClose = [safeThis]() + TutorialHost& host = *safeThis.getComponent(); + safeThis->tutorialWindow = std::make_unique(host); + safeThis->tutorialWindow->onClose = [safeThis]() { if (safeThis != nullptr) - safeThis->welcomeWindow.reset(); + safeThis->tutorialWindow.reset(); }; - safeThis->welcomeWindow->setVisible(true); - safeThis->welcomeWindow->positionOnMainComponentDisplay(); - safeThis->welcomeWindow->toFront(true); + safeThis->tutorialWindow->setVisible(true); + safeThis->tutorialWindow->positionOnHostDisplay(); + safeThis->tutorialWindow->toFront(true); }); } -/* --Tutorial-- */ - -void MainComponent::setTutorialActive(bool active) -{ - isTutorialActive = active; - repaint(); -} - -void MainComponent::setTutorialHighlight(Rectangle bounds) -{ - tutorialHighlightRect = bounds; - repaint(); -} - -void MainComponent::setTutorialExtraHighlights(std::vector> bounds) -{ - tutorialExtraHighlights = bounds; - repaint(); -} - -void MainComponent::ensureTutorialModelLoaded() -{ - if (! mainModelTab.isModelLoaded()) - mainModelTab.loadDefaultModel(); -} - -void MainComponent::resetTutorialAutoLoadedModel() -{ - if (! mainModelTab.isModelLoaded()) - return; - - if (mainModelTab.getLoadedPath() == TutorialConstants::fallbackModelPath) - { - mainModelTab.resetState(); - } -} - -Rectangle MainComponent::getModelSelectBounds() -{ - auto bounds = mainModelTab.getModelSelectBounds(); - return getLocalArea(&mainModelTab, bounds); -} - -Rectangle MainComponent::getControlsBounds() -{ - auto bounds = mainModelTab.getControlsBounds(); - return getLocalArea(&mainModelTab, bounds); -} - -Rectangle MainComponent::getInputTrackBounds() -{ - auto bounds = mainModelTab.getInputTrackBounds(); - return getLocalArea(&mainModelTab, bounds); -} - -Rectangle MainComponent::getInputFolderBounds() -{ - auto bounds = mainModelTab.getInputFolderBounds(); - return getLocalArea(&mainModelTab, bounds); -} - -Rectangle MainComponent::getInputPlayBounds() -{ - auto bounds = mainModelTab.getInputPlayBounds(); - return getLocalArea(&mainModelTab, bounds); -} - -Rectangle MainComponent::getProcessButtonBounds() -{ - auto bounds = mainModelTab.getProcessButtonBounds(); - return getLocalArea(&mainModelTab, bounds); -} - -Rectangle MainComponent::getTracksBounds() -{ - auto bounds = mainModelTab.getTracksBounds(); - return getLocalArea(&mainModelTab, bounds); -} - -Rectangle MainComponent::getClipboardBounds() -{ - if (showMediaClipboard && mediaClipboardWidget.isVisible()) - return mediaClipboardWidget.getBounds(); - return {}; -} - -Rectangle MainComponent::getClipboardTrackAreaBounds() -{ - if (showMediaClipboard && mediaClipboardWidget.isVisible()) - { - auto bounds = mediaClipboardWidget.getClipboardTrackAreaBounds(); - return getLocalArea(&mediaClipboardWidget, bounds); - } - return {}; -} - -Rectangle MainComponent::getClipboardControlsBounds() +void MainComponent::showPanel(Panel panel) { - if (showMediaClipboard && mediaClipboardWidget.isVisible()) + switch (panel) { - auto bounds = mediaClipboardWidget.getClipboardControlsBounds(); - return getLocalArea(&mediaClipboardWidget, bounds); - } - return {}; -} + case Panel::statusArea: + if (! showStatusArea) + viewStatusAreaCallback(); -Rectangle MainComponent::getClipboardNameBoxBounds() -{ - if (showMediaClipboard && mediaClipboardWidget.isVisible()) - { - auto bounds = mediaClipboardWidget.getClipboardNameBoxBounds(); - return getLocalArea(&mediaClipboardWidget, bounds); - } - return {}; -} + break; -Rectangle MainComponent::getClipboardButtonsBounds() -{ - if (showMediaClipboard && mediaClipboardWidget.isVisible()) - { - auto bounds = mediaClipboardWidget.getClipboardButtonsBounds(); - return getLocalArea(&mediaClipboardWidget, bounds); - } - return {}; -} - -Rectangle MainComponent::getClipboardAddButtonBounds() -{ - if (showMediaClipboard && mediaClipboardWidget.isVisible()) - { - auto bounds = mediaClipboardWidget.getAddFileButtonBounds(); - return getLocalArea(&mediaClipboardWidget, bounds); - } - return {}; -} + case Panel::mediaClipboard: + if (! showMediaClipboard) + viewMediaClipboardCallback(); -Rectangle MainComponent::getClipboardRemoveButtonBounds() -{ - if (showMediaClipboard && mediaClipboardWidget.isVisible()) - { - auto bounds = mediaClipboardWidget.getRemoveButtonBounds(); - return getLocalArea(&mediaClipboardWidget, bounds); + break; } - return {}; -} - -Rectangle MainComponent::getClipboardPlayButtonBounds() -{ - if (showMediaClipboard && mediaClipboardWidget.isVisible()) - { - auto bounds = mediaClipboardWidget.getPlayButtonBounds(); - return getLocalArea(&mediaClipboardWidget, bounds); - } - return {}; -} - -Rectangle MainComponent::getClipboardSendToDAWButtonBounds() -{ - if (showMediaClipboard && mediaClipboardWidget.isVisible()) - { - auto bounds = mediaClipboardWidget.getSendToDAWButtonBounds(); - return getLocalArea(&mediaClipboardWidget, bounds); - } - return {}; -} - -Rectangle MainComponent::getInfoBarBounds() -{ - if (showStatusArea && statusAreaWidget.isVisible()) - return statusAreaWidget.getBounds(); - return {}; } /* --Miscellaneous-- */ -// TODO - The following is an old callback from V2. It may be helpful in the future. - -/* -void MainComponent::focusCallback() -{ - if (mediaDisplay->isFileLoaded()) - { - Time lastModTime = - mediaDisplay->getTargetFilePath().getLocalFile().getLastModificationTime(); - if (lastModTime > lastLoadTime) - { - // Create an AlertWindow - auto* reloadCheckWindow = new AlertWindow( - "File has been modified", - "The loaded file has been modified in a different editor! Would you like HARP to load the new version of the file?\nWARNING: This will clear the undo log and cause all unsaved edits to be lost!", - AlertWindow::QuestionIcon); - - reloadCheckWindow->addButton("Yes", 1, KeyPress(KeyPress::returnKey)); - reloadCheckWindow->addButton("No", 0, KeyPress(KeyPress::escapeKey)); - - // Show the window and handle the result asynchronously - reloadCheckWindow->enterModalState( - true, - new CustomPathAlertCallback( - [this, reloadCheckWindow](int result) - { - if (result == 1) - { // Yes was clicked - DBG_AND_LOG("Reloading file"); - loadMediaDisplay(mediaDisplay->getTargetFilePath().getLocalFile()); - } - else - { // No was clicked or the window was closed - DBG_AND_LOG("Not reloading file"); - lastLoadTime = - Time::getCurrentTime(); //Reset time so we stop asking - } - delete reloadCheckWindow; - }), - true); - } - } -} -*/ - void MainComponent::changeListenerCallback(ChangeBroadcaster* source) { - if (source == &mainModelTab) + if (source == &modelTabs) { updateWindowConstraints(); } diff --git a/src/MainComponent.h b/src/MainComponent.h index b229d7a4..03bad623 100644 --- a/src/MainComponent.h +++ b/src/MainComponent.h @@ -1,166 +1,150 @@ -/** - * @file MainComponent.h - * @brief Top-level component containing all HARP GUI elements and state. - * @author hugofloresgarcia, xribene, cwitkowitz - */ - -#pragma once - -#include - -#include "ModelTab.h" - -#include "clients/Client.h" - -#include "widgets/MediaClipboardWidget.h" -#include "widgets/StatusAreaWidget.h" - -#include "windows/AboutWindow.h" -#include "windows/settings/SettingsWindow.h" - -#include "utils/Interface.h" -#include "utils/Logging.h" -#include "utils/Settings.h" -#include "utils/Tutorial.h" - -using namespace juce; - -// Forward declaration (include in .cpp) -class WelcomeWindow; - -class MainComponent : public Component, - public MenuBarModel, - public ApplicationCommandTarget, - private ChangeListener - -{ -public: - MainComponent(); - ~MainComponent() override; - - /* File Menu */ - - StringArray getMenuBarNames() override; - - std::unique_ptr getMacExtraMenu(); - - PopupMenu getMenuForIndex([[maybe_unused]] int menuIndex, const String& menuName) override; - - void menuItemSelected(int menuItemID, int topLevelMenuIndex) override; - - /* Application */ - - ApplicationCommandTarget* getNextCommandTarget() override { return nullptr; } - - void getAllCommands(Array& commands) override; - - void getCommandInfo(CommandID commandID, ApplicationCommandInfo& result) override; - - bool perform(const InvocationInfo& info) override; - - /* Callbacks */ - - // File - void importNewFile(File mediaFile, bool fromDAW = false); - void openSettingsWindow(); - - // View - void viewStatusAreaCallback(); - void viewMediaClipboardCallback(); - - // Help - void openAboutWindow(); - void openWelcomeWindow(bool ensureDefaultModelLoaded = false); - - /* Tutorial */ - - ModelTab* getModelTab() { return &mainModelTab; } - - void setTutorialActive(bool active); - void setTutorialHighlight(Rectangle bounds); - void setTutorialExtraHighlights(std::vector> bounds); - void ensureTutorialModelLoaded(); - void resetTutorialAutoLoadedModel(); - - // Bounds accessors for tutorial steps (public for WelcomeWindow) - Rectangle getModelSelectBounds(); - Rectangle getControlsBounds(); - Rectangle getInputTrackBounds(); - Rectangle getInputFolderBounds(); - Rectangle getInputPlayBounds(); - Rectangle getProcessButtonBounds(); - Rectangle getTracksBounds(); - Rectangle getClipboardBounds(); - Rectangle getClipboardTrackAreaBounds(); - Rectangle getClipboardControlsBounds(); - Rectangle getClipboardNameBoxBounds(); - Rectangle getClipboardButtonsBounds(); - Rectangle getClipboardAddButtonBounds(); - Rectangle getClipboardRemoveButtonBounds(); - Rectangle getClipboardPlayButtonBounds(); - Rectangle getClipboardSendToDAWButtonBounds(); - Rectangle getInfoBarBounds(); - - /* Component */ - - void paint(Graphics& g) override; - void paintOverChildren(Graphics& g) override; - void resized() override; - - void updateWindowConstraints(); - -private: - /* File Menu */ - - void initializeMenuBar(); - void deinitializeMenuBar(); - - std::unique_ptr menuBar; - std::unique_ptr macExtraMenu; - - /* Application */ - - ApplicationCommandManager commandManager; - - /* Callbacks */ - - // Miscellaneous - //void focusCallback(); - void changeListenerCallback(ChangeBroadcaster* source); - - /* Interface */ - - const int statusAreaHeight = 100; - const float mediaClipboardFlex = 0.4f; - const float mediaClipboardScale = 1.4f; - - // Minimum size to ensure all controls remain visible and functional: - // - WelcomeWindow popup is 480x500, needs padding - // - Dropdown labels need adequate width - // - Control Area needs space for sliders/toggles/textboxes - const int minimumWindowWidth = 700; - const int minimumWindowHeight = 500; - const int minimumMainPanelWidth = 320; - const int minimumMainPanelHorPadding = 32; - const int minimumMainPanelVertPadding = 32; - - int requiredWindowWidth; - int requiredWindowHeight; - - bool showStatusArea; - bool showMediaClipboard; - - ModelTab mainModelTab; - StatusAreaWidget statusAreaWidget; - MediaClipboardWidget mediaClipboardWidget; - - bool isTutorialActive = false; - Rectangle tutorialHighlightRect; - std::vector> tutorialExtraHighlights; - std::unique_ptr welcomeWindow; - - SharedResourcePointer sharedTokens; - SharedResourcePointer statusMessage; - - JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(MainComponent) -}; +/** + * @file MainComponent.h + * @brief Top-level component containing all HARP GUI elements and state. + * @author cwitkowitz, saumya-pailwan, xribene, hugofloresgarcia + */ + +#pragma once + +#include + +#include "ModelTab.h" +#include "ModelTabContainer.h" + +#include "clients/Client.h" + +#include "widgets/MediaClipboardWidget.h" +#include "widgets/StatusAreaWidget.h" + +#include "windows/tutorial/TutorialWindow.h" + +#include "windows/AboutWindow.h" +#include "windows/settings/SettingsWindow.h" + +#include "utils/Interface.h" +#include "utils/Logging.h" +#include "utils/Settings.h" + +using namespace juce; + +class MainComponent : public Component, + public MenuBarModel, + public ApplicationCommandTarget, + public DragAndDropContainer, + private ChangeListener, + private TutorialHost +{ +public: + MainComponent(); + ~MainComponent() override; + + /* File Menu */ + + StringArray getMenuBarNames() override; + + std::unique_ptr getMacExtraMenu(); + + PopupMenu getMenuForIndex([[maybe_unused]] int menuIndex, const String& menuName) override; + + void menuItemSelected(int menuItemID, int topLevelMenuIndex) override; + + /* Application */ + + ApplicationCommandTarget* getNextCommandTarget() override { return nullptr; } + + void getAllCommands(Array& commands) override; + + void getCommandInfo(CommandID commandID, ApplicationCommandInfo& result) override; + + bool perform(const InvocationInfo& info) override; + + /* Callbacks */ + + // File + void importNewFile(File mediaFile, bool fromDAW = false); + // Opens on a provider's API key when a failure points at one + void openSettingsWindow(std::optional focusProvider = {}); + + // View + void viewStatusAreaCallback(); + void viewMediaClipboardCallback(); + void saveClipboardLayout(); + void viewPreviewPaneCallback(); + void restoreViewDefaults(); + + // Help + void openAboutWindow(); + void openTutorial(); + + /* Component */ + + void paint(Graphics& g) override; + void resized() override; + + void updateWindowConstraints(); + int getRequiredMainPanelWidth() const { return requiredMainPanelWidth; } + int getVisibleClipboardWidth(); + +private: + /* File Menu */ + + void initializeMenuBar(); + void deinitializeMenuBar(); + + std::unique_ptr menuBar; + std::unique_ptr macExtraMenu; + + /* Application */ + + ApplicationCommandManager commandManager; + + /* Callbacks */ + + // Miscellaneous + void changeListenerCallback(ChangeBroadcaster* source) override; + + /* Tutorial */ + + Component& getTutorialArea() override { return *this; } + ModelTabContainer& getModelTabs() override { return modelTabs; } + void showPanel(Panel panel) override; + + /* Interface */ + + const int statusAreaHeight = 100; + // Width chosen for the media clipboard by dragging its edge, which the settings keep + int clipboardWidth = 0; + + // Minimum size to ensure all controls remain visible and functional: + // - Tutorial window is 500x420, needs padding + // - Dropdown labels need adequate width + // - Control Area needs space for sliders/toggles/textboxes + const int minimumWindowWidth = 700; + const int minimumWindowHeight = 500; + const int minimumMainPanelWidth = 320; + const int minimumMainPanelHorPadding = 32; + const int minimumMainPanelVertPadding = 32; + + int requiredWindowWidth; + int requiredWindowHeight; + + // Width the main panel needs for the current tab's controls, plus padding + int measureRequiredMainPanelWidth(); + int requiredMainPanelWidth = minimumMainPanelWidth; + + bool showStatusArea; + bool showMediaClipboard; + + // Home tab plus one tab per opened model, each of which scrolls on its own + ModelTabContainer modelTabs; + + StatusAreaWidget statusAreaWidget; + DragOverlayComponent dragOverlay; + MediaClipboardWidget mediaClipboardWidget { &dragOverlay }; + + std::unique_ptr tutorialWindow; + + SharedResourcePointer statusMessage; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(MainComponent) +}; diff --git a/src/Model.h b/src/Model.h index eb205086..d5a09c50 100644 --- a/src/Model.h +++ b/src/Model.h @@ -1,11 +1,12 @@ /** * @file Model.h * @brief Model state and interface for loading and processing. - * @author hugofloresgarcia, aldo-aguilar, xribene, cwitkowitz + * @author cwitkowitz, saumya-pailwan, xribene, hugofloresgarcia, aldo-aguilar */ #pragma once +#include #include #include @@ -24,16 +25,16 @@ using namespace juce; +// Shown in the status history as the model moves through loading and processing enum class ModelStatus { EMPTY, // No model has been successfully added since initialization QUERYING_CONTROLS, // Controls information for a model is being requested - PREPARING_REQUEST, // Files and metadata is being prepared for processing + PREPARING_REQUEST, // Files and metadata are being prepared for processing PROCESSING, // Awaiting result of processing request CANCELING, // Awaiting result of cancellation request - FAILURE, // Synonymous with EMPTY or READY but indicates error occurred READY // Model is loaded and ready for processing }; @@ -49,56 +50,65 @@ struct ModelMetadata ModelMetadata(DynamicObject* card) { - // TODO - check that the following properties are of the correct type + readString(card, "name", name); + readString(card, "author", author); + readString(card, "description", description); + readStringList(card, "tags", tags); + } - if (card->hasProperty("name")) - { - name = card->getProperty("name").toString().toStdString(); - } - if (card->hasProperty("author")) - { - author = card->getProperty("author").toString().toStdString(); - } - if (card->hasProperty("description")) - { - description = card->getProperty("description").toString().toStdString(); - } - if (card->hasProperty("tags")) - { - Array* newTags = card->getProperty("tags").getArray(); + // The card as JSON, in the form the constructor reads + var toVar() const + { + Array tagList; - if (newTags == nullptr) - { - DBG_AND_LOG("ModelMetadata::ModelMetadata: Failed to parse tags."); - } - else - { - for (int i = 0; i < newTags->size(); i++) - { - tags.push_back(newTags->getReference(i).toString().toStdString()); - } - } - } + for (const auto& tag : tags) + tagList.add(String(tag)); + + DynamicObject::Ptr card = new DynamicObject(); + card->setProperty("name", String(name)); + card->setProperty("author", String(author)); + card->setProperty("description", String(description)); + card->setProperty("tags", tagList); + + return var(card.get()); } }; class Model { public: - Model() { resetState(); } - ~Model() { resetState(); } + Model() { resetState(/*suppressStatus=*/true); } + ~Model() { resetState(/*suppressStatus=*/true); } bool isEmpty() { return loadedPath.isEmpty() || client == nullptr; } bool isLoaded() { return ! isEmpty(); } - void setStatus(ModelStatus newStatus) + /* A model whose requests were aborted is being discarded, so nothing it reports + reaches the status area afterwards (see abortActiveRequests) */ + void setStatus(ModelStatus newStatus, const String& pathContext = "") { - status = newStatus; + if (requestRegistry.hasBeenAborted()) + return; - if (statusMessage != nullptr) - { - statusMessage->setMessage("Model Status: " + enumToString(status)); - } + statusMessage->setMessage(enumToString(newStatus) + describePath(pathContext)); + } + + void setFailure(const OpResult& result, const String& pathContext = "") + { + /* Record the classification alongside the raw traffic already logged by the + client, so that a log attached to a report shows which branch was taken */ + DBG_AND_LOG("Model::setFailure: " + toLogString(result.getError()) + + (pathContext.isNotEmpty() ? " for \"" + pathContext + "\"" : String())); + + if (requestRegistry.hasBeenAborted()) + return; + + const String errorMessage = toUserMessage(result.getError()).trim(); + + // Only the first line, so that the status history stays readable + statusMessage->setMessage("[error] " + + errorMessage.upToFirstOccurrenceOf("\n", false, false).trim() + + describePath(pathContext)); } String getLoadedPath() { return loadedPath; } @@ -109,7 +119,7 @@ class Model ModelComponentInfoList getInputTracks() { return inputTrackComponents; } ModelComponentInfoList getOutputTracks() { return outputTrackComponents; } - void resetState() + void resetState(bool suppressStatus = false) { metadata = ModelMetadata {}; @@ -124,139 +134,156 @@ class Model loadedPath.clear(); openablePath.clear(); - setStatus(ModelStatus::EMPTY); + if (! suppressStatus) + setStatus(ModelStatus::EMPTY); } OpResult load(String pathToLoad) { - // Create new client for querying - std::unique_ptr tempClient; - // Initialize appropriate client for selected path - OpResult result = multiplexClients(pathToLoad, tempClient); + std::unique_ptr tempClient = createClientForPath(pathToLoad); - if (result.failed()) + if (tempClient == nullptr) { - setStatus(ModelStatus::FAILURE); + const OpResult result = + OpResult::fail(ClientError { ClientError::Type::UnknownClient, pathToLoad }); + + setFailure(result, pathToLoad); return result; } - setStatus(ModelStatus::QUERYING_CONTROLS); + // Requests made while loading belong to this model as well (so that they can be + // aborted), even though the client only replaces the current one once loading + // has succeeded + tempClient->setRequestRegistry(&requestRegistry); - // Initialize empty dictionary to hold query response - DynamicObject::Ptr controls; + /* Settle on the address the provider itself uses before anything is queried + or recorded, so that the same model entered several ways is loaded, listed, + and linked to as one. */ + String canonicalPath; - // Obtain JSON string corresponding to controls + track layout - result = tempClient->queryControls(pathToLoad, controls); + OpResult result = tempClient->resolveCanonicalPath(pathToLoad, canonicalPath); if (result.failed()) { - setStatus(ModelStatus::FAILURE); + setFailure(result, pathToLoad); return result; } - ModelMetadata newMetadata; + pathToLoad = canonicalPath; - // Attempt to extract metadata from JSON - result = extractMetadata(controls, newMetadata); + setStatus(ModelStatus::QUERYING_CONTROLS, pathToLoad); - if (result.failed()) - { - setStatus(ModelStatus::FAILURE); + // Initialize empty dictionary to hold query response + DynamicObject::Ptr controls; - return result; - } + // Obtain JSON string corresponding to controls + track layout + result = tempClient->queryControls(pathToLoad, controls); + ModelMetadata newMetadata; ModelComponentInfoList newInputs; ModelComponentInfoList newControls; + ModelComponentInfoList newOutputs; + std::vector newInputOrder; - // Attempt to extract inputs and controls from JSON - result = extractInputs(controls, newInputs, newControls); + if (result.wasOk()) + result = extractMetadata(controls, newMetadata); - if (result.failed()) - { - setStatus(ModelStatus::FAILURE); + if (result.wasOk()) + result = extractInputs(controls, newInputs, newControls, newInputOrder); - return result; - } - - ModelComponentInfoList newOutputs; - - // Attempt to extract outputs from JSON - result = extractOutputs(controls, newOutputs); + if (result.wasOk()) + result = extractOutputs(controls, newOutputs); if (result.failed()) { - setStatus(ModelStatus::FAILURE); + setFailure(result, pathToLoad); return result; } - resetState(); + resetState(/*suppressStatus=*/true); // Update model information if all loading operations are successful metadata = newMetadata; controlComponents = newControls; inputTrackComponents = newInputs; outputTrackComponents = newOutputs; + orderedInputComponentIDs = std::move(newInputOrder); - // Register extracted component IDs - orderedInputComponentIDs = std::move(tempComponentIDs); // Replace existing client client = std::move(tempClient); // Keep track of successfully loaded path loadedPath = pathToLoad; openablePath = client->inferDocumentationPath(loadedPath); - setStatus(ModelStatus::READY); + setStatus(ModelStatus::READY, loadedPath); return OpResult::ok(); } - OpResult - process(std::map inputFiles, std::vector& outputFiles, LabelList& labels) + OpResult process(const std::map& inputFiles, + std::vector& outputFiles, + LabelList& labels) { - /* Upload files to server if necessary and obtain remote paths */ + /* A canceled process runs on until the server stops it, which it reports as a + failure. The cancel has already been reported by then, so whatever this process + reports afterwards is left out of the status area and only logged. */ + const uint64_t cancelsAtStart = numCancels.load(); + + auto reportStatus = [&](ModelStatus newStatus) + { + if (numCancels.load() == cancelsAtStart) + setStatus(newStatus, loadedPath); + }; + + auto reportFailure = [&](const OpResult& result) + { + if (numCancels.load() == cancelsAtStart) + setFailure(result, loadedPath); + else + DBG_AND_LOG("Model::process: Canceled process ended with " + + toLogString(result.getError()) + "."); + }; - OpResult result = OpResult::ok(); + reportStatus(ModelStatus::PREPARING_REQUEST); - setStatus(ModelStatus::PREPARING_REQUEST); + /* Upload files to the server if necessary and obtain their remote paths. These are + kept here rather than in the components, which a request still running after a + cancel shares with the next one. */ + std::map remotePaths; - for (auto& fileEntry : inputFiles) + auto upload = [&](const Uuid& componentID, const File& file) { - String remoteFilePath; + String remotePath; - Uuid trackID = fileEntry.first; - File fileToUpload = fileEntry.second; + OpResult result = client->uploadFile(loadedPath, file, remotePath); - result = client->uploadFile(loadedPath, fileToUpload, remoteFilePath); + if (result.wasOk()) + remotePaths[componentID] = remotePath; + else + reportFailure(result); - if (result.failed()) - { - setStatus(ModelStatus::FAILURE); + return result; + }; + for (const auto& [trackID, file] : inputFiles) + { + if (OpResult result = upload(trackID, file); result.failed()) return result; - } + } - auto componentInfo = getComponentInfo(trackID); + for (const auto& controlComponent : controlComponents) + { + auto* fileInfo = dynamic_cast(controlComponent.get()); - if (componentInfo == nullptr) - { - // No components match track ID - jassertfalse; - } - else if (auto trackComponentInfo = - dynamic_cast(componentInfo.get())) - { - trackComponentInfo->path = remoteFilePath.toStdString(); - } - else - { - // Component is not a valid input track - jassertfalse; - } + if (fileInfo == nullptr || fileInfo->path.empty()) + continue; + + if (OpResult result = upload(fileInfo->id, File(fileInfo->path)); result.failed()) + return result; } /* Extract control values for JSON payload in order */ @@ -267,27 +294,29 @@ class Model { auto componentInfo = getComponentInfo(componentID); - var controlValue; - bool wasFile = false; + // The order is recorded from the same response as the components + jassert(componentInfo != nullptr); + + if (componentInfo == nullptr) + continue; - String label = componentInfo->label; + var controlValue; + bool isFile = false; - if (auto trackComponentInfo = dynamic_cast(componentInfo.get())) + if (dynamic_cast(componentInfo.get()) != nullptr + || dynamic_cast(componentInfo.get()) != nullptr) { - if (trackComponentInfo->path.empty()) - { - controlValue = var(); // Empty - } - else + isFile = true; + + // An optional input left empty is sent as nothing at all + if (auto it = remotePaths.find(componentID); it != remotePaths.end()) { DynamicObject::Ptr fileObj = new DynamicObject(); - fileObj->setProperty("path", var(trackComponentInfo->path)); + fileObj->setProperty("path", it->second); - controlValue = var(fileObj); + controlValue = var(fileObj.get()); } - - wasFile = true; } else if (auto textBoxComponentInfo = dynamic_cast(componentInfo.get())) @@ -314,58 +343,85 @@ class Model { controlValue = var(comboBoxComponentInfo->value); } - else + else if (auto multiSelectComponentInfo = + dynamic_cast(componentInfo.get())) { - // Unsupported control was added - jassertfalse; + // Gradio expects the selection of a multiselect dropdown as a list + Array selectedValues; + + for (const auto& selectedValue : multiSelectComponentInfo->values) + { + selectedValues.add(var(String(selectedValue))); + } + + controlValue = var(selectedValues); } - controlValue = client->wrapPayloadElement(controlValue, wasFile, label); - controlValues.add(controlValue); + controlValues.add( + client->wrapPayloadElement(controlValue, isFile, String(componentInfo->label))); } DynamicObject::Ptr dataObject = new DynamicObject(); dataObject->setProperty("data", controlValues); - String payloadJSON = JSON::toString(var(dataObject), true); + String payloadJSON = JSON::toString(var(dataObject.get()), true); DBG_AND_LOG("Model::process: Payload \"" + payloadJSON + "\" prepared for processing."); - setStatus(ModelStatus::PROCESSING); + reportStatus(ModelStatus::PROCESSING); - result = client->process(loadedPath, payloadJSON, outputFiles, labels); + OpResult result = client->process(loadedPath, payloadJSON, outputFiles, labels); if (result.failed()) { - setStatus(ModelStatus::FAILURE); + reportFailure(result); return result; } - setStatus(ModelStatus::READY); + reportStatus(ModelStatus::READY); return result; } + // Aborts any request this model currently has in flight, locally. Unlike + // cancel(), this needs neither the server nor a reachable network, and it + // unblocks the worker thread that is waiting on the connection. Once aborted + // the model will not open any further connections or report to the status + // area, so this is only for models that are being discarded (see + // ModelTab::abandon). + void abortActiveRequests() { requestRegistry.abortActiveRequests(); } + OpResult cancel() { - setStatus(ModelStatus::CANCELING); + // Counted first, so that the process being canceled reports nothing further + ++numCancels; + + setStatus(ModelStatus::CANCELING, loadedPath); OpResult result = client->cancel(loadedPath); if (result.failed()) { - setStatus(ModelStatus::FAILURE); + setFailure(result, loadedPath); return result; } - setStatus(ModelStatus::READY); + setStatus(ModelStatus::READY, loadedPath); return OpResult::ok(); } private: + // The last part of a path, appended to a status message + static String describePath(const String& path) + { + const String shortPath = path.fromLastOccurrenceOf("/", false, false).trim(); + + return shortPath.isNotEmpty() ? " | " + shortPath : String(); + } + OpResult extractMetadata(DynamicObject::Ptr& controls, ModelMetadata& newMetadata) { static const Identifier metadataKey { "card" }; @@ -379,243 +435,189 @@ class Model return result; } - newMetadata = ModelMetadata(metadataDict); + newMetadata = ModelMetadata(metadataDict.get()); return OpResult::ok(); } - OpResult extractInputs(DynamicObject::Ptr& controls, - ModelComponentInfoList& newInputs, - ModelComponentInfoList& newControls) + /* The component a "type" in a /controls response declares, or nullptr for a type + HARP does not support */ + static std::shared_ptr createComponentInfo(const String& type, + DynamicObject* spec) { - static const Identifier inputsKey { "inputs" }; + if (type == "audio_track") + return std::make_shared(spec); - Array* inputComponents; + if (type == "midi_track") + return std::make_shared(spec); - OpResult result = getRequiredArrayProperty(controls, inputsKey, inputComponents); + if (type == "generic_file") + return std::make_shared(spec); - if (result.failed()) - { - return result; - } + if (type == "text_box") + return std::make_shared(spec); - for (int i = 0; i < inputComponents->size(); i++) - { - var controlsVar = inputComponents->getReference(i); + if (type == "number_box") + return std::make_shared(spec); - if (! controlsVar.isObject()) - { - return OpResult::fail( - JsonError { JsonError::Type::NotADictionary, controlsVar.toString() }); - } + if (type == "toggle") + return std::make_shared(spec); - DynamicObject* controlsDict = controlsVar.getDynamicObject(); - - static const Identifier typeKey { "type" }; - - // TODO - could abstract some of the following for extractInputs & extractOutputs - if (controlsDict->hasProperty(typeKey)) - { - String type = controlsDict->getProperty("type").toString().toStdString(); - - if (type == "audio_track") - { - std::shared_ptr audioTrack = - std::make_shared(controlsDict); - - newInputs.push_back(audioTrack); - tempComponentIDs.push_back(audioTrack->id); - - DBG_AND_LOG("Model::extractInputs: Audio track input \"" + audioTrack->label - + "\" extracted."); - } - else if (type == "midi_track") - { - std::shared_ptr midiTrack = - std::make_shared(controlsDict); - - newInputs.push_back(midiTrack); - tempComponentIDs.push_back(midiTrack->id); - - DBG_AND_LOG("Model::extractInputs: MIDI track input \"" + midiTrack->label - + "\" extracted."); - } - else if (type == "text_box") - { - std::shared_ptr textControl = - std::make_shared(controlsDict); - - newControls.push_back(textControl); - tempComponentIDs.push_back(textControl->id); - - DBG_AND_LOG("Model::extractInputs: Text control \"" + textControl->label - + "\" extracted."); - } - else if (type == "number_box") - { - std::shared_ptr numberControl = - std::make_shared(controlsDict); - - newControls.push_back(numberControl); - tempComponentIDs.push_back(numberControl->id); - - DBG_AND_LOG("Model::extractInputs: Number box control \"" + numberControl->label - + "\" extracted."); - } - else if (type == "toggle") - { - std::shared_ptr toggleControl = - std::make_shared(controlsDict); + if (type == "slider") + return std::make_shared(spec); - newControls.push_back(toggleControl); - tempComponentIDs.push_back(toggleControl->id); - - DBG_AND_LOG("Model::extractInputs: Toggle control \"" + toggleControl->label - + "\" extracted."); - } - else if (type == "slider") - { - std::shared_ptr sliderControl = - std::make_shared(controlsDict); - - newControls.push_back(sliderControl); - tempComponentIDs.push_back(sliderControl->id); + if (type == "dropdown") + { + /* A dropdown declared with multiselect=True carries a list of values rather + than a single one, and needs its own control */ + bool isMultiSelect = false; - DBG_AND_LOG("Model::extractInputs: Slider control \"" + sliderControl->label - + "\" extracted."); - } - else if (type == "dropdown") - { - std::shared_ptr dropdownControl = - std::make_shared(controlsDict); + readBool(spec, "multiselect", isMultiSelect); - newControls.push_back(dropdownControl); - tempComponentIDs.push_back(dropdownControl->id); + if (isMultiSelect) + return std::make_shared(spec); - DBG_AND_LOG("Model::extractInputs: Dropdown control \"" + dropdownControl->label - + "\" extracted."); - } - else - { - return OpResult::fail( - ControlError { ControlError::Type::UnsupportedControl, type }); - } - } - else - { - return OpResult::fail(JsonError { JsonError::Type::MissingKey, - JSON::toString(var(controlsDict), true), - typeKey.toString() }); - } + return std::make_shared(spec); } - return OpResult::ok(); + return nullptr; } - OpResult extractOutputs(DynamicObject::Ptr& controls, ModelComponentInfoList& newOutputs) + /* Calls handleComponent with the type and specification of each component listed + under a key of a /controls response, stopping at the first failure */ + static OpResult readComponents( + DynamicObject::Ptr& controls, + const Identifier& key, + const std::function& handleComponent) { - static const Identifier outputsKey { "outputs" }; + static const Identifier typeKey { "type" }; - Array* outputComponents; + Array* components; - OpResult result = getRequiredArrayProperty(controls, outputsKey, outputComponents); + OpResult result = getRequiredArrayProperty(controls, key, components); if (result.failed()) { return result; } - for (int i = 0; i < outputComponents->size(); i++) + for (const auto& componentVar : *components) { - var controlsVar = outputComponents->getReference(i); + auto* spec = componentVar.getDynamicObject(); - if (! controlsVar.isObject()) + if (spec == nullptr) { return OpResult::fail( - JsonError { JsonError::Type::NotADictionary, controlsVar.toString() }); + JsonError { JsonError::Type::NotADictionary, componentVar.toString() }); + } + + if (! spec->hasProperty(typeKey)) + { + return OpResult::fail(JsonError { JsonError::Type::MissingKey, + JSON::toString(componentVar, true), + typeKey.toString() }); } - DynamicObject* controlsDict = controlsVar.getDynamicObject(); + result = handleComponent(spec->getProperty(typeKey).toString(), spec); - static const Identifier typeKey { "type" }; + if (result.failed()) + { + return result; + } + } - if (controlsDict->hasProperty("type")) + return OpResult::ok(); + } + + // Tracks are inputs, and anything else is a control, which are all sent in this order + static OpResult extractInputs(DynamicObject::Ptr& controls, + ModelComponentInfoList& newInputs, + ModelComponentInfoList& newControls, + std::vector& newInputOrder) + { + return readComponents( + controls, + "inputs", + [&](const String& type, DynamicObject* spec) { - String type = controlsDict->getProperty("type").toString().toStdString(); + auto info = createComponentInfo(type, spec); - if (type == "audio_track") + if (info == nullptr) { - std::shared_ptr audioTrack = - std::make_shared(controlsDict); + return OpResult::fail( + ControlError { ControlError::Type::UnsupportedControl, type }); + } - newOutputs.push_back(audioTrack); + const bool isTrack = dynamic_cast(info.get()) != nullptr; - DBG_AND_LOG("Model::extractOutputs: Audio track output \"" + audioTrack->label - + "\" extracted."); - } - else if (type == "midi_track") - { - std::shared_ptr midiTrack = - std::make_shared(controlsDict); + (isTrack ? newInputs : newControls).push_back(info); + newInputOrder.push_back(info->id); - newOutputs.push_back(midiTrack); + DBG_AND_LOG("Model::extractInputs: " << (isTrack ? "Track" : "Control") << " \"" + << String(info->label) << "\" of type \"" + << type << "\" extracted."); - DBG_AND_LOG("Model::extractOutputs: MIDI track output \"" + midiTrack->label - + "\" extracted."); - } - else if (type == "json") - { - // Labels are handled separately + return OpResult::ok(); + }); + } + static OpResult extractOutputs(DynamicObject::Ptr& controls, ModelComponentInfoList& newOutputs) + { + return readComponents( + controls, + "outputs", + [&](const String& type, DynamicObject* spec) + { + // Labels arrive with the other outputs and are drawn on their tracks + if (type == "json") + { DBG_AND_LOG("Model::extractOutputs: JSON (labels) output detected."); + + return OpResult::ok(); } - else + + const bool isOutputType = + type == "audio_track" || type == "midi_track" || type == "generic_file"; + + auto info = isOutputType ? createComponentInfo(type, spec) : nullptr; + + if (info == nullptr) { return OpResult::fail( ControlError { ControlError::Type::UnsupportedControl, type }); } - } - else - { - return OpResult::fail(JsonError { JsonError::Type::MissingKey, - JSON::toString(var(controlsDict), true), - typeKey.toString() }); - } - } - return OpResult::ok(); + newOutputs.push_back(info); + + DBG_AND_LOG("Model::extractOutputs: Output \"" + << String(info->label) << "\" of type \"" << type << "\" extracted."); + + return OpResult::ok(); + }); } std::shared_ptr getComponentInfo(const Uuid& id) const { - for (const auto& controlComponent : controlComponents) - { - if (id == controlComponent->id) - { - return controlComponent; - } - } - - for (const auto& inputTrackComponent : inputTrackComponents) - { - if (id == inputTrackComponent->id) - { - return inputTrackComponent; - } - } - - for (const auto& outputTrackComponent : outputTrackComponents) + for (const auto* list : + { &controlComponents, &inputTrackComponents, &outputTrackComponents }) { - if (id == outputTrackComponent->id) + for (const auto& component : *list) { - return outputTrackComponent; + if (component->id == id) + { + return component; + } } } return nullptr; } - ModelStatus status; + // Declared before the client so that it is destroyed after it + RequestRegistry requestRegistry; + + // Advanced by every cancel, so that a process can tell it has been canceled + std::atomic numCancels { 0 }; std::unique_ptr client; @@ -624,7 +626,6 @@ class Model ModelMetadata metadata; - std::vector tempComponentIDs; std::vector orderedInputComponentIDs; ModelComponentInfoList controlComponents; diff --git a/src/ModelTab.h b/src/ModelTab.h index 3c172ede..26781bf5 100644 --- a/src/ModelTab.h +++ b/src/ModelTab.h @@ -1,126 +1,125 @@ /** * @file ModelTab.h * @brief Reusable component containing HARP GUI elements and state for a single model. - * @author hugofloresgarcia, xribene, cwitkowitz + * @author cwitkowitz, saumya-pailwan, VedMistry42, xribene, hugofloresgarcia */ #pragma once +#include + #include #include "Model.h" #include "widgets/ControlAreaWidget.h" #include "widgets/ModelInfoWidget.h" -#include "widgets/ModelSelectionWidget.h" #include "widgets/TrackAreaWidget.h" #include "utils/Errors.h" #include "utils/Logging.h" -#include "utils/Tutorial.h" +#include "utils/ModelCatalog.h" +#include "utils/Settings.h" + +#include "windows/tutorial/TutorialTargets.h" using namespace juce; -class ModelTab : public Component, private ChangeListener, public ChangeBroadcaster +/** + * Sends a change message once a model has loaded, and once the error from a failed load + * has been dismissed. A tab whose first load failed is then left without a model. + */ +class ModelTab : public Component, public ChangeBroadcaster { public: ModelTab() { - modelSelectionWidget.addChangeListener(this); - - addAndMakeVisible(modelSelectionWidget); addAndMakeVisible(modelInfoWidget); addAndMakeVisible(controlAreaWidget); - inputTracksLabel.setJustificationType(Justification::centred); - inputTracksLabel.setFont(Font(20.0f, Font::bold)); - addAndMakeVisible(inputTracksLabel); addAndMakeVisible(inputTrackAreaWidget); initializeProcessCancelButton(); - outputTracksLabel.setJustificationType(Justification::centred); - outputTracksLabel.setFont(Font(20.0f, Font::bold)); - addAndMakeVisible(outputTracksLabel); addAndMakeVisible(outputTrackAreaWidget); - } - ~ModelTab() { modelSelectionWidget.removeChangeListener(this); } + controlAreaWidget.setComponentID(TutorialTargets::controls); + inputTrackAreaWidget.setComponentID(TutorialTargets::inputTracks); + outputTrackAreaWidget.setComponentID(TutorialTargets::outputTracks); + processCancelButton.setComponentID(TutorialTargets::processButton); + } - // Accessor methods for WelcomeWindow tutorial std::shared_ptr getModel() const { return model; } - String getLoadedPath() const { return model->getLoadedPath(); } - void loadDefaultModel() + // Loads a model in the background, reporting the outcome with a change message + void loadModel(const String& modelPath) { - modelSelectionWidget.loadModelBypass(TutorialConstants::fallbackModelPath); - } + loading = true; - // Bounds accessors for tutorial steps - Rectangle getModelSelectBounds() const - { - return modelSelectionWidget.getBounds().expanded(2, 2); - } + // Disable processing until model is loaded + processCancelButton.setEnabled(false); - Rectangle getControlsBounds() const - { - auto bounds = controlAreaWidget.getBounds(); + const String pathToLoad = canonicalizeModelPath(modelPath); - if (bounds.getWidth() > 0 && bounds.getHeight() > 0) - return bounds.expanded(2, 2); + DBG_AND_LOG("ModelTab::loadModel: Attempting to load path \"" << pathToLoad << "\"."); - return {}; - } + SafePointer safeThis(this); - Rectangle getInputFolderBounds() - { - auto bounds = inputTrackAreaWidget.getFirstTrackFolderButtonBounds(); - return getLocalArea(&inputTrackAreaWidget, bounds); - } + loadingThreadPool.addJob( + [this, safeThis, pathToLoad] + { + OpResult result = model->load(pathToLoad); - Rectangle getInputPlayBounds() - { - auto bounds = inputTrackAreaWidget.getFirstTrackPlayButtonBounds(); - return getLocalArea(&inputTrackAreaWidget, bounds); + // Perform updates on message (GUI) thread + MessageManager::callAsync( + [safeThis, result, pathToLoad] + { + if (safeThis != nullptr) + safeThis->finishLoading(result, pathToLoad); + }); + }); } - Rectangle getInputTrackBounds() const { return inputTrackAreaWidget.getBounds(); } + // True from the moment a load is requested until its outcome has been reported + bool isLoading() const { return loading; } - Rectangle getProcessButtonBounds() const { return processCancelButton.getBounds(); } + bool isModelLoaded() { return model->isLoaded(); } - Rectangle getTracksBounds() const + // True while a load, process, or cancel request is still queued or running on + // one of this tab's thread pools. Used to defer destruction of the tab: if + // its ThreadPool is destroyed while a worker is blocked in a network call, + // JUCE force-kills the worker thread, which can corrupt the shared + // networking subsystem and hang all future requests. + bool hasPendingRequests() const { - auto bounds = inputTrackAreaWidget.getBounds(); - if (outputTrackAreaWidget.isVisible()) - bounds = bounds.getUnion(outputTrackAreaWidget.getBounds()); + return loadingThreadPool.getNumJobs() > 0 || processingThreadPool.getNumJobs() > 0; + } - if (inputTracksLabel.isVisible()) - bounds = bounds.getUnion(inputTracksLabel.getBounds()); - if (outputTracksLabel.isVisible()) - bounds = bounds.getUnion(outputTracksLabel.getBounds()); + // Called once this tab has left the UI but cannot be destroyed yet because a + // request is still in flight. Aborts the connection locally so that the + // request settles within moments, rather than once the server answers or the + // request times out. That may be long after the app has started shutting down, + // when delivering the result crashes. Any result that does still arrive is + // dropped, since the user closed this tab. + void abandon() + { + abandoned = true; - return bounds.expanded(2, 2); - } + // Invalidate any in-flight jobs + ++currentProcessID; - bool isModelLoaded() { return model->isLoaded(); } + model->abortActiveRequests(); + } void resized() override { FlexBox tabArea; tabArea.flexDirection = FlexBox::Direction::column; - const int width = getWidth(); - - /* Model Selection */ - - tabArea.items.add(FlexItem(modelSelectionWidget) - .withHeight(modelSelectionRowHeight) - .withMinHeight(modelSelectionRowHeight) - .withMaxHeight(modelSelectionRowHeight) - .withFlex(0) - .withMargin(marginSize)); + const auto contentArea = getLocalBounds().reduced(pagePadding); + const int width = contentArea.getWidth(); /* Model Info */ @@ -147,8 +146,9 @@ class ModelTab : public Component, private ChangeListener, public ChangeBroadcas controlAreaWidget.setBounds(0, 0, 0, 0); } - const float totalTracks = - inputTrackAreaWidget.getNumTracks() + outputTrackAreaWidget.getNumTracks(); + // Weighting is by flexible tracks, so the total has to be of those too + const float totalTracks = (float) (inputTrackAreaWidget.getNumFlexibleTracks() + + outputTrackAreaWidget.getNumFlexibleTracks()); /* Input Tracks Area Widget */ @@ -189,16 +189,22 @@ class ModelTab : public Component, private ChangeListener, public ChangeBroadcas outputTrackAreaWidget.getNumTracks(), totalTracks); - tabArea.performLayout(getLocalBounds()); + tabArea.performLayout(contentArea); + + positionErrorPopup(); } int getMinimumRequiredControlWidth() { return controlAreaWidget.getMinimumRequiredWidth(); } + // Set by the owner, which is the layer that knows how to open a window + std::function onOpenAPIKeySettings; + int getMinimumRequiredHeightForWidth(int width) { - int height = 0; + width -= 2 * pagePadding; + + int height = 2 * pagePadding; - height += modelSelectionRowHeight + 2 * marginSize; height += modelInfoWidget.getPreferredHeightForWidth(width) + 2 * marginSize; if (controlAreaWidget.getNumControls() > 0) @@ -208,8 +214,8 @@ class ModelTab : public Component, private ChangeListener, public ChangeBroadcas if (inputTrackAreaWidget.getNumTracks() > 0) { - height += trackSectionLabelHeight + 4 * marginSize - + getTrackAreaMinimumHeight(inputTrackAreaWidget.getNumTracks()); + height += + trackSectionLabelHeight + 4 * marginSize + inputTrackAreaWidget.getMinimumHeight(); } if (model->isLoaded()) @@ -219,31 +225,13 @@ class ModelTab : public Component, private ChangeListener, public ChangeBroadcas if (outputTrackAreaWidget.getNumTracks() > 0) { - height += trackSectionLabelHeight + 4 * marginSize - + getTrackAreaMinimumHeight(outputTrackAreaWidget.getNumTracks()); + height += + trackSectionLabelHeight + 4 * marginSize + outputTrackAreaWidget.getMinimumHeight(); } return height; } - void resetState() - { - model = std::make_shared(); - - modelSelectionWidget.resetState(); - modelInfoWidget.resetState(); - controlAreaWidget.resetState(); - inputTrackAreaWidget.resetState(); - outputTrackAreaWidget.resetState(); - - processCancelButton.setMode(processButtonInfo.displayLabel); - processCancelButton.setEnabled(false); - - currentProcessID = 0; - - resized(); - } - private: void initializeProcessCancelButton() { @@ -266,33 +254,13 @@ class ModelTab : public Component, private ChangeListener, public ChangeBroadcas addAndMakeVisible(processCancelButton); } - void changeListenerCallback(ChangeBroadcaster* source) - { - if (source == &modelSelectionWidget) - { - loadModelCallback(); - } - } - int getControlAreaRequiredHeightForTabWidth(int tabWidth) const { return jmax(minControlAreaHeight, controlAreaWidget.getRequiredHeightForWidth(tabWidth)); } - int getTrackAreaMinimumHeight(int numTracks) const - { - if (numTracks <= 0) - { - return 0; - } - - const int perTrackWithMargin = minVisibleTrackHeight + 2 * marginSize; - - return numTracks * perTrackWithMargin; - } - void addTrackSection(FlexBox& box, - Label& label, + Component& label, Component& trackArea, int numTracks, float totalTracks) const @@ -306,9 +274,18 @@ class ModelTab : public Component, private ChangeListener, public ChangeBroadcas .withFlex(0) .withMargin(marginSize)); - float flex = 4.0f * (numTracks / totalTracks); + /* Weight by the tracks that actually stretch. Counting a fixed-height + track (a generic file picker) as a full share of flexible space gives + its section too much, so the flexible tracks beside it end up taller + than those in the other section. */ + auto* area = dynamic_cast(&trackArea); + + const int flexibleTracks = area != nullptr ? area->getNumFlexibleTracks() : numTracks; - int minHeight = getTrackAreaMinimumHeight(numTracks); + float flex = totalTracks > 0.0f ? 4.0f * ((float) flexibleTracks / totalTracks) : 0.0f; + + // The track area applies its own margins, so it owns this calculation + const int minHeight = area != nullptr ? area->getMinimumHeight() : 0; box.items.add(FlexItem(trackArea) .withFlex(flex) @@ -324,138 +301,248 @@ class ModelTab : public Component, private ChangeListener, public ChangeBroadcas void openErrorPopup(const Error error, std::function onExit = {}) { - MessageBoxOptions errorPopup = - MessageBoxOptions() - .withIconType(AlertWindow::WarningIcon) - .withTitle("Error") // TODO - Name of error family would be nice here - // error ? toUserMessage(*error) : "An unknown error occurred." - .withMessage(toUserMessage(error)); - std::optional openablePath = getOpenablePath(error); + String errorMessage = toUserMessage(error); + + DBG_AND_LOG("ModelTab::openErrorPopup: " + toLogString(error)); + + // Determine whether this error warrants a GitHub bug report. + // Quota errors, invalid paths, and expected HTTP failures are user-actionable + // and do not need a report. Only unexpected runtime and parse errors do. + bool isReportableError = false; + if (const auto* gradioErr = std::get_if(&error)) + isReportableError = (gradioErr->type == GradioError::Type::RuntimeError); + else if (std::get_if(&error) || std::get_if(&error)) + isReportableError = true; + + // If a popup is being replaced, its pending cleanup must still run so the + // widgets it was responsible for re-enabling do not stay disabled + dismissErrorPopup(); + + errorPopupWindow = std::make_unique( + "Error", errorMessage, AlertWindow::WarningIcon); + centredAlertLF.messageText = errorMessage; + errorPopupWindow->setLookAndFeel(¢redAlertLF); + errorPopupOnExit = std::move(onExit); + + auto addPopupButton = [this](const String& buttonText, std::function callback) + { + errorPopupWindow->addButton(buttonText, 0); + + if (auto* button = errorPopupWindow->getButton(buttonText)) + button->onClick = std::move(callback); + }; if (openablePath.has_value()) { - errorPopup = errorPopup.withButton("Open URL"); + addPopupButton("Open URL", + [openablePath] { URL(*openablePath).launchInDefaultBrowser(); }); } - errorPopup = errorPopup.withButton("Open Logs").withButton("Ok"); + addPopupButton("Open Logs", [] { HARPLogger::getInstance()->getLogFile().revealToUser(); }); - auto alertCallback = [this, error, openablePath, onExit, errorPopup](int choice) + /* Left open rather than dismissed, since the key is entered elsewhere and the + message is worth keeping in view while doing so. */ + if (auto keyProvider = getAPIKeyProvider(error)) { - DBG_AND_LOG("ModelTab::loadModelCallback::alertCallback: Chose button index: " << choice - << "."); + addPopupButton("Settings", + [this, provider = *keyProvider] + { + if (onOpenAPIKeySettings) + { + onOpenAPIKeySettings(provider); + } + }); + } - enum Choice - { - OpenURL, - OpenLogs, - OK - }; + if (isReportableError) + { + addPopupButton("Report", + [this, error, errorMessage] + { + // Open GitHub issue but keep the popup open + openGitHubIssue(error, errorMessage); + }); + } - /* - TODO - The button indices assigned by MessageBoxOptions do not follow the order in which - they were added. This should be fixed in JUCE v8. The following is a temporary workaround. + addPopupButton("Ok", [this] { dismissErrorPopup(); }); - See https://forum.juce.com/t/wrong-callback-value-for-alertwindow-showokcancelbox/55671/2 + addAndMakeVisible(*errorPopupWindow); + errorPopupWindow->setAlwaysOnTop(true); - When this is fixed, errorPopup can be removed from the argument list. - */ - { - std::map observedButtonIndicesMap = {}; + positionErrorPopup(); + errorPopupWindow->toFront(true); + } - if (errorPopup.getNumButtons() == 3) - { - observedButtonIndicesMap.insert({ 1, Choice::OpenURL }); - } + void dismissErrorPopup() + { + if (errorPopupOnExit) + { + // Clear before invoking in case the callback opens another popup + auto pendingOnExit = std::move(errorPopupOnExit); + errorPopupOnExit = {}; + pendingOnExit(); + } - observedButtonIndicesMap.insert( - { errorPopup.getNumButtons() - 1, Choice::OpenLogs }); + if (errorPopupWindow != nullptr) + { + removeChildComponent(errorPopupWindow.get()); + errorPopupWindow->setVisible(false); + errorPopupWindow->setLookAndFeel(nullptr); + + /* Defer destruction: this can be reached from one of the popup's own + button callbacks, and a Button must not be destroyed from inside + its own onClick. The lambda holds the last reference and releases + it on the message thread. */ + std::shared_ptr oldPopup = std::move(errorPopupWindow); + MessageManager::callAsync([oldPopup] {}); + } + } - observedButtonIndicesMap.insert({ 0, Choice::OK }); + void positionErrorPopup() + { + if (errorPopupWindow == nullptr) + { + return; + } - choice = observedButtonIndicesMap[choice]; - } + Component* topLevel = getTopLevelComponent(); + + // Size based on full window width so the popup is never squashed when + // the media clipboard panel is open and ModelTab is narrow. + int windowWidth = (topLevel != nullptr) ? topLevel->getWidth() : getWidth(); + int windowHeight = (topLevel != nullptr) ? topLevel->getHeight() : getHeight(); + int popupWidth = jmin(520, windowWidth - 24); + + /* Measure the wrapped message so the popup is tall enough to show all + of it, using the same font and insets as CentredAlertLookAndFeel */ + AttributedString attrStr; + attrStr.append(centredAlertLF.messageText, Font(FontOptions(popupMessageFontHeight))); + + TextLayout layout; + layout.createLayout(attrStr, (float) (popupWidth - 2 * (popupEdgeGap + popupIconWidth))); + + const int buttonH = centredAlertLF.getAlertWindowButtonHeight(); + int popupHeight = popupEdgeGap + popupTitleHeight + (int) std::ceil(layout.getHeight()) + + popupEdgeGap + buttonH + popupButtonBottomPadding; + popupHeight = jlimit(180, jmax(180, windowHeight - 24), popupHeight); + + // Find the window's center in screen space, then convert to ModelTab's + // local coordinate space so the popup is centered in the full window + // regardless of where ModelTab sits within it. + Point windowCentreScreen = + (topLevel != nullptr) + ? topLevel->localPointToGlobal(topLevel->getLocalBounds().getCentre()) + : localPointToGlobal(getLocalBounds().getCentre()); + Point centreInLocal = getLocalPoint(nullptr, windowCentreScreen); + + errorPopupWindow->setBounds( + Rectangle(popupWidth, popupHeight).withCentre(centreInLocal)); + } - if (choice == Choice::OpenURL) - { - URL(*openablePath).launchInDefaultBrowser(); - } - else if (choice == Choice::OpenLogs) + void openGitHubIssue(const Error& error, const String& errorMessage) + { + static const String issueBaseUrl = "https://github.com/TEAMuP-dev/HARP/issues/new"; + static const String issueTemplate = "runtime_error_report.yml"; + + String issueTitle = "HARP runtime error report"; + String endpointPath; + + if (const auto* gradioError = std::get_if(&error)) + { + if (gradioError->reason.isNotEmpty()) { - HARPLogger::getInstance()->getLogFile().revealToUser(); + issueTitle = "HARP: " + gradioError->reason; } - else + else if (gradioError->type == GradioError::Type::QuotaExceeded) { - // Nothing to do + issueTitle = "HARP: Hugging Face quota exceeded"; } - if (onExit) - { - // Perform optional state cleanup - onExit(); - } - }; + endpointPath = gradioError->endpointPath; + } - AlertWindow::showAsync(errorPopup, alertCallback); - } + /* The log file's location is left out, since its path names the user's account and + the issue is public */ + String environment; + environment << "- HARP version: " << JUCE_APPLICATION_VERSION_STRING << "\n"; + environment << "- Operating system: " << SystemStats::getOperatingSystemName() << "\n"; + environment << "- Time (local): " << Time::getCurrentTime().toString(true, true); + + /* Only values are supplied here. The report's structure lives solely in + the issue form, whose field ids these query parameters correspond to. + See .github/ISSUE_TEMPLATE/runtime_error_report.yml */ + StringPairArray fields; + fields.set("title", issueTitle); + fields.set("summary", errorMessage); + fields.set("environment", environment); + + if (endpointPath.isNotEmpty()) + { + fields.set("endpoint", endpointPath); + } - void loadModelCallback() - { - modelSelectionWidget.setDisabled(); + String query = "?template=" + URL::addEscapeChars(issueTemplate, true); - // Disable processing until model is loaded - processCancelButton.setEnabled(false); + for (const auto& key : fields.getAllKeys()) + { + query += "&" + key + "=" + URL::addEscapeChars(fields[key], true); + } - // Obtain currently selected path - String selectedPath = modelSelectionWidget.getCurrentlySelectedPath(); + URL(issueBaseUrl + query).launchInDefaultBrowser(); + } - DBG_AND_LOG("ModelTab::loadModelCallback: Attempting to load path \"" << selectedPath - << "\"."); + void finishLoading(const OpResult& result, const String& requestedPath) + { + if (abandoned) + { + // Tab was closed while this load was in flight + return; + } - loadingThreadPool.addJob( - [this, selectedPath] - { - OpResult result = model->load(selectedPath); + if (result.wasOk()) + { + loading = false; - // Perform updates on message (GUI) thread - MessageManager::callAsync( - [this, result] - { - if (result.wasOk()) - { - modelSelectionWidget.setSuccessfulState(); + catalog->recordLoadSuccess(model->getLoadedPath(), model->getMetadata()); - modelInfoWidget.updateLabels(model->getMetadata()); - modelInfoWidget.addOpenablePath(model->getOpenablePath()); + const auto* entry = catalog->findEntry(model->getLoadedPath()); - controlAreaWidget.updateControls(model->getControls()); + // A Space's license is known to the catalog rather than to the card + modelInfoWidget.updateLabels(model->getMetadata(), + entry != nullptr ? entry->tags.license : String()); + modelInfoWidget.addOpenablePath(model->getOpenablePath()); - inputTrackAreaWidget.updateTracks(model->getInputTracks()); - outputTrackAreaWidget.updateTracks(model->getOutputTracks()); + // Once loaded, only how the model is deployed is worth noting + if (entry != nullptr) + modelInfoWidget.setBadges(ModelStyle::getBadges(*entry, false)); - resized(); + controlAreaWidget.updateControls(model->getControls()); - sendSynchronousChangeMessage(); + inputTrackAreaWidget.updateTracks(model->getInputTracks()); + outputTrackAreaWidget.updateTracks(model->getOutputTracks()); - // Re-enable processing immediately - processCancelButton.setEnabled(true); - } - else - { - const Error error = result.getError(); + resized(); - std::function onExit = [this, error] - { - modelSelectionWidget.setUnsuccessfulState(error); + // Enable processing now that a model is loaded + processCancelButton.setEnabled(true); - // Re-enable processing after closing error window - processCancelButton.setEnabled(true); - }; + sendSynchronousChangeMessage(); + } + else + { + const Error error = result.getError(); - openErrorPopup(error, onExit); - } - }); - }); + catalog->recordLoadFailure(requestedPath, error); + + // The outcome is reported once the error has been seen + openErrorPopup(error, + [this] + { + loading = false; + sendChangeMessage(); + }); + } } void processCallback() @@ -481,13 +568,25 @@ class ModelTab : public Component, private ChangeListener, public ChangeBroadcas loadedInputFiles[inputTrack->getTrackID()] = inputTrack->getOriginalFilePath().getLocalFile(); } - else + } + + for (const auto& controlInfo : model->getControls()) + { + if (auto* fileInfo = dynamic_cast(controlInfo.get())) { - // Optional track skipped + if (fileInfo->required && fileInfo->path.empty()) + { + AlertWindow::showMessageBoxAsync( + AlertWindow::WarningIcon, + "Error", + "Required file input \"" + String(fileInfo->label) + + "\" is empty. Please select a file before processing."); + + return; + } } } - modelSelectionWidget.setDisabled(); processCancelButton.setMode(cancelButtonInfo.displayLabel); // Switch choose-file button to inactive mode on all tracks during processing @@ -495,8 +594,10 @@ class ModelTab : public Component, private ChangeListener, public ChangeBroadcas uint64_t processID = currentProcessID; + SafePointer safeThis(this); + processingThreadPool.addJob( - [this, loadedInputFiles, processID] + [this, safeThis, loadedInputFiles, processID] { std::vector outputFiles; LabelList labels; @@ -519,40 +620,51 @@ class ModelTab : public Component, private ChangeListener, public ChangeBroadcas // Perform updates on message (GUI) thread MessageManager::callAsync( - [this, result, outputFilesPtr, labelsPtr] + [safeThis, result, outputFilesPtr, labelsPtr] { - std::function onExit = [this] - { - // Re-enable processing immediately - modelSelectionWidget - .setFinishedState(); // TODO - should this be last selected? - processCancelButton.setMode(processButtonInfo.displayLabel); - - // Switch choose-file button back to active on all tracks - inputTrackAreaWidget.setLoadTrackEnabled(true); - }; - - if (result.wasOk()) - { - auto& outputMediaDisplays = outputTrackAreaWidget.getMediaDisplays(); - - for (size_t i = 0; i < outputMediaDisplays.size(); ++i) - { - outputMediaDisplays[i]->initializeDisplay( - URL((*outputFilesPtr)[i])); - outputMediaDisplays[i]->addLabels(*labelsPtr); - } - - onExit(); - } - else - { - openErrorPopup(result.getError(), onExit); - } + if (safeThis != nullptr) + safeThis->finishProcessing(result, *outputFilesPtr, *labelsPtr); }); }); } + void finishProcessing(const OpResult& result, + const std::vector& outputFiles, + const LabelList& labels) + { + if (abandoned) + { + // Tab was closed while this process was in flight + return; + } + + std::function onExit = [this] + { + // Re-enable processing immediately + processCancelButton.setMode(processButtonInfo.displayLabel); + + // Switch choose-file button back to active on all tracks + inputTrackAreaWidget.setLoadTrackEnabled(true); + }; + + if (result.wasOk()) + { + auto& outputMediaDisplays = outputTrackAreaWidget.getMediaDisplays(); + + for (size_t i = 0; i < outputMediaDisplays.size() && i < outputFiles.size(); ++i) + { + outputMediaDisplays[i]->initializeDisplay(URL(outputFiles[i])); + outputMediaDisplays[i]->addLabels(labels); + } + + onExit(); + } + else + { + openErrorPopup(result.getError(), onExit); + } + } + void cancelCallback() { processCancelButton.setEnabled(false); @@ -563,16 +675,36 @@ class ModelTab : public Component, private ChangeListener, public ChangeBroadcas // Invalidate any in-flight jobs ++currentProcessID; - OpResult result = model->cancel(); + SafePointer safeThis(this); + + // Asking the server to stop is a request of its own, which must not block the interface + processingThreadPool.addJob( + [this, safeThis] + { + OpResult result = model->cancel(); + + MessageManager::callAsync( + [safeThis, result] + { + if (safeThis != nullptr) + safeThis->finishCanceling(result); + }); + }); + } + + void finishCanceling(const OpResult& result) + { + if (abandoned) + { + // Tab was closed while the cancellation was in flight + return; + } if (result.failed()) { openErrorPopup(result.getError()); } - // Re-enable processing immediately - modelSelectionWidget.setFinishedState(); // TODO - should this be last selected? - processCancelButton.setMode(processButtonInfo.displayLabel); processCancelButton.setEnabled(true); @@ -580,33 +712,186 @@ class ModelTab : public Component, private ChangeListener, public ChangeBroadcas inputTrackAreaWidget.setLoadTrackEnabled(true); } + // Shared popup layout metrics, used by CentredAlertLookAndFeel when drawing + // and by positionErrorPopup when measuring the required popup height + static constexpr int popupButtonBottomPadding = 24; + static constexpr int popupEdgeGap = 10; + static constexpr int popupIconWidth = 80; + static constexpr int popupTitleHeight = 24; + static constexpr float popupMessageFontHeight = 16.0f; + + /* LookAndFeel override that lays the AlertWindow message out over the whole + window rather than JUCE's default text area. The text area is re-derived from + the actual window height, reserving the strip at the bottom that + BottomButtonAlertWindow moves the buttons into. */ + struct CentredAlertLookAndFeel : public LookAndFeel_V4 + { + String messageText; // set before showing the popup + + void drawAlertBox(Graphics& g, + AlertWindow& alert, + const Rectangle& /*textArea*/, + TextLayout& /*unused*/) override + { + // Background + auto cornerSize = 4.0f; + g.setColour(alert.findColour(AlertWindow::outlineColourId)); + g.drawRoundedRectangle(alert.getLocalBounds().toFloat(), cornerSize, 2.0f); + + auto bounds = alert.getLocalBounds().reduced(1); + g.reduceClipRegion(bounds); + + g.setColour(alert.findColour(AlertWindow::backgroundColourId)); + g.fillRoundedRectangle(bounds.toFloat(), cornerSize); + + // Icon + auto iconSpaceUsed = 0; + const auto iconWidth = popupIconWidth; + auto iconSize = jmin(iconWidth + 50, bounds.getHeight() + 20); + + if (alert.containsAnyExtraComponents() || alert.getNumButtons() > 2) + iconSize = jmin(iconSize, 200); + + Rectangle iconRect(iconSize / -10, iconSize / -10, iconSize, iconSize); + + if (alert.getAlertType() != MessageBoxIconType::NoIcon) + { + Path icon; + char character; + uint32 color; + + if (alert.getAlertType() == MessageBoxIconType::WarningIcon) + { + character = '!'; + icon.addTriangle((float) iconRect.getX() + (float) iconRect.getWidth() * 0.5f, + (float) iconRect.getY(), + (float) iconRect.getRight(), + (float) iconRect.getBottom(), + (float) iconRect.getX(), + (float) iconRect.getBottom()); + icon = icon.createPathWithRoundedCorners(5.0f); + color = 0x66ff2a00; + } + else + { + color = Colour(0xff00b0b9).withAlpha(0.4f).getARGB(); + character = alert.getAlertType() == MessageBoxIconType::InfoIcon ? 'i' : '?'; + icon.addEllipse(iconRect.toFloat()); + } + + GlyphArrangement ga; + ga.addFittedText(Font(FontOptions((float) iconRect.getHeight() * 0.9f, Font::bold)), + String::charToString((juce_wchar) (uint8) character), + (float) iconRect.getX(), + (float) iconRect.getY(), + (float) iconRect.getWidth(), + (float) iconRect.getHeight(), + Justification::centred, + false); + ga.createPath(icon); + icon.setUsingNonZeroWinding(false); + g.setColour(Colour(color)); + g.fillPath(icon); + + iconSpaceUsed = iconWidth; + } + + if (messageText.isNotEmpty()) + { + const int buttonH = getAlertWindowButtonHeight(); + const int titleH = popupTitleHeight; + const int edgeGap = popupEdgeGap; + const int bottomOfText = + alert.getHeight() - buttonH - popupButtonBottomPadding - edgeGap; + + const int rightPadding = edgeGap + iconSpaceUsed; + Rectangle fullTextArea( + (float) (edgeGap + iconSpaceUsed), + (float) (edgeGap + titleH), + (float) (alert.getWidth() - edgeGap - iconSpaceUsed - rightPadding), + (float) (bottomOfText - (edgeGap + titleH))); + + const Font msgFont(FontOptions { popupMessageFontHeight }); + + AttributedString attrStr; + attrStr.setJustification(Justification::topLeft); + attrStr.append(messageText, msgFont, alert.findColour(AlertWindow::textColourId)); + + TextLayout layout; + layout.createLayout(attrStr, fullTextArea.getWidth()); + layout.draw(g, fullTextArea); + } + } + }; + + class BottomButtonAlertWindow : public AlertWindow + { + public: + BottomButtonAlertWindow(const String& title, + const String& message, + MessageBoxIconType iconType) + : AlertWindow(title, message, iconType) + { + } + + void resized() override + { + const int buttonH = getLookAndFeel().getAlertWindowButtonHeight(); + const int targetY = getHeight() - popupButtonBottomPadding - buttonH; + const int spacer = 16; + + Array btns; + for (int i = 0; i < getNumChildComponents(); ++i) + if (auto* btn = dynamic_cast(getChildComponent(i))) + btns.add(btn); + + int totalWidth = -spacer; + for (auto* btn : btns) + totalWidth += btn->getWidth() + spacer; + + int x = (getWidth() - totalWidth) / 2; + for (auto* btn : btns) + { + btn->setTopLeftPosition(x, targetY); + x += btn->getWidth() + spacer; + } + } + }; + static constexpr float marginSize = 2; + // Space around the whole tab, matching the Home tab's + static constexpr int pagePadding = 8; - static constexpr int modelSelectionRowHeight = 30; static constexpr int minControlAreaHeight = 96; static constexpr int processButtonWidth = 150; static constexpr int processButtonRowHeight = 30; - static constexpr int trackSectionLabelHeight = 20; - static constexpr int minVisibleTrackHeight = 50; + static constexpr int trackSectionLabelHeight = ModelStyle::sectionHeaderHeight - 2; std::shared_ptr model { new Model() }; - ModelSelectionWidget modelSelectionWidget; ModelInfoWidget modelInfoWidget; ControlAreaWidget controlAreaWidget; - Label inputTracksLabel { "Input Tracks", "Input Tracks" }; + ModelStyle::SectionHeader inputTracksLabel { "Input Tracks" }; TrackAreaWidget inputTrackAreaWidget { DisplayMode::Input }; MultiButton processCancelButton; MultiButton::Mode processButtonInfo; MultiButton::Mode cancelButtonInfo; - Label outputTracksLabel { "Output Tracks", "Output Tracks" }; + ModelStyle::SectionHeader outputTracksLabel { "Output Tracks" }; TrackAreaWidget outputTrackAreaWidget { DisplayMode::Output }; ThreadPool loadingThreadPool { 1 }; ThreadPool processingThreadPool { 10 }; std::atomic currentProcessID { 0 }; -}; \ No newline at end of file + std::atomic abandoned { false }; + bool loading = false; + + SharedResourcePointer catalog; + + CentredAlertLookAndFeel centredAlertLF; + std::unique_ptr errorPopupWindow; + std::function errorPopupOnExit; +}; diff --git a/src/ModelTabContainer.h b/src/ModelTabContainer.h new file mode 100644 index 00000000..eef42ed9 --- /dev/null +++ b/src/ModelTabContainer.h @@ -0,0 +1,588 @@ +/** + * @file ModelTabContainer.h + * @brief Tab bar holding the Home tab and one tab per opened model. + * @author cwitkowitz, VedMistry42, 2cylu2, JEYuhas + */ + +#pragma once + +#include + +#include + +#include "HomeTab.h" +#include "ModelTab.h" + +#include "clients/Client.h" + +#include "gui/RoundIconButton.h" + +#include "media/MediaDisplayComponent.h" + +#include "utils/Enums.h" +#include "utils/Interface.h" +#include "utils/ModelCatalog.h" +#include "utils/Providers.h" +#include "utils/Settings.h" + +#include "windows/tutorial/TutorialTargets.h" + +using namespace juce; + +class ModelTabsLookAndFeel : public LookAndFeel_V4 +{ +public: + void drawTabbedButtonBarBackground(TabbedButtonBar& bar, Graphics& g) override + { + g.fillAll(tabBarColour); + g.setColour(separatorColour); + g.fillRect(0, bar.getHeight() - 1, bar.getWidth(), 1); + } + + void drawTabAreaBehindFrontButton(TabbedButtonBar&, Graphics& g, int w, int h) override + { + g.setColour(separatorColour); + g.fillRect(0, h - 1, w, 1); + } + + void drawTabButton(TabBarButton& button, + Graphics& g, + bool isMouseOver, + bool isMouseDown) override + { + const auto isActive = button.isFrontTab(); + auto area = button.getActiveArea(); + + const auto fill = + isActive ? activeTabColour + : inactiveTabColour.brighter(isMouseOver || isMouseDown ? 0.08f : 0.0f); + + g.setColour(fill); + g.fillRect(area); + + if (button.getIndex() > 0) + { + g.setColour(separatorColour); + g.fillRect(area.getX(), area.getY() + 2, 1, area.getHeight() - 4); + } + + auto textArea = button.getTextArea().reduced(tabTextInset, 0); + + g.setColour(isActive ? activeTextColour : inactiveTextColour); + + g.drawText(button.getButtonText(), textArea, Justification::centred, true); + } + + int getTabButtonBestWidth(TabBarButton& button, int /*tabDepth*/) override + { + // The Home tab is always first + return button.getIndex() == 0 ? homeTabWidth : fixedTabWidth; + } + + /* The default gives a tab's close button the full height of the tab, which stretches it, + so it is kept square, centred, and clear of the tab's edge instead */ + Rectangle getTabButtonExtraComponentBounds(const TabBarButton& button, + Rectangle& textArea, + Component& extraComponent) override + { + textArea.removeFromRight(extraComponentMargin); + + const int side = extraComponent.getWidth(); + + return LookAndFeel_V4::getTabButtonExtraComponentBounds(button, textArea, extraComponent) + .withSizeKeepingCentre(side, side); + } + + void drawTabButtonText(TabBarButton&, + Graphics&, + bool /*isMouseOver*/, + bool /*isMouseDown*/) override + { + } + +private: + const Colour tabBarColour { Colour(0xff1f1f1f) }; + const Colour inactiveTabColour { Colour(0xff242424) }; + const Colour activeTabColour { Colour(0xff343434) }; + const Colour separatorColour { Colour(0xff4a4a4a) }; + const Colour activeTextColour { Colours::white }; + const Colour inactiveTextColour { Colour(0xffaeb0b4) }; + static constexpr int fixedTabWidth = 140; + static constexpr int homeTabWidth = 64; + static constexpr int tabTextInset = 10; + static constexpr int extraComponentMargin = 6; +}; + +/** + * Scrolling page that holds one model tab. + * + * The tab is given the width the page can show and whatever height it needs, so that a + * window too small for the content scrolls instead of clipping it. The tab itself is + * owned by ModelTabContainer, since its lifetime is tied to the requests it has in flight. + */ +class ModelTabPage : public Viewport +{ +public: + explicit ModelTabPage(ModelTab& tabToShow) : modelTab(tabToShow) + { + setViewedComponent(&modelTab, false); + setScrollBarsShown(true, false); + setScrollOnDragMode(Viewport::ScrollOnDragMode::never); + } + + ModelTab& getModelTab() const { return modelTab; } + + void resized() override + { + Viewport::resized(); + + layOutTab(); + } + + void layOutTab() + { + /* Laying out once can make the scrollbar appear, which narrows the visible + area and would leave it overlapping the content. Lay out again whenever the + available width changed as a result. */ + const int firstWidth = layOutTabForVisibleWidth(); + + if (firstWidth > 0 && getMaximumVisibleWidth() != firstWidth) + { + layOutTabForVisibleWidth(); + } + } + + /* Tracks use the wheel to zoom their contents, so the page must not treat a wheel + event over one as a request to scroll. Viewport::useMouseWheelMoveIfNeeded is not + virtual, so the decision is made here instead. */ + void mouseWheelMove(const MouseEvent& e, const MouseWheelDetails& wheel) override + { + /* Test originalComponent, not eventComponent: a wheel event that goes unhandled + is passed up the hierarchy with getEventRelativeTo, which rewrites + eventComponent to each parent in turn, so by the time it arrives here + eventComponent is this viewport. originalComponent still names the component + the wheel was actually over. */ + if (isWithinTrack(e.originalComponent)) + { + return; + } + + Viewport::mouseWheelMove(e, wheel); + } + +private: + int layOutTabForVisibleWidth() + { + const int visibleWidth = getMaximumVisibleWidth(); + + if (visibleWidth <= 0) + { + return 0; + } + + const int requiredHeight = modelTab.getMinimumRequiredHeightForWidth(visibleWidth); + + modelTab.setSize(visibleWidth, jmax(requiredHeight, getMaximumVisibleHeight())); + + return visibleWidth; + } + + /* True when the wheel landed on a track that will act on it. A track with no media + loaded does not, so the page should still scroll over it. */ + static bool isWithinTrack(Component* c) + { + for (auto* candidate = c; candidate != nullptr; candidate = candidate->getParentComponent()) + { + if (auto* track = dynamic_cast(candidate)) + { + return track->usesMouseWheel(); + } + } + + return false; + } + + ModelTab& modelTab; +}; + +class ModelTabContainer : public TabbedComponent, private ChangeListener, public ChangeBroadcaster +{ +public: + ModelTabContainer() : TabbedComponent(TabbedButtonBar::TabsAtTop) + { + getTabbedButtonBar().setLookAndFeel(&tabsLookAndFeel); + + setColour(TabbedComponent::backgroundColourId, tabBackgroundColour); + getTabbedButtonBar().setColour(TabbedButtonBar::tabTextColourId, Colours::white); + getTabbedButtonBar().setColour(TabbedButtonBar::frontTextColourId, Colours::white); + getTabbedButtonBar().setColour(TabbedButtonBar::tabOutlineColourId, + tabBackgroundColour.darker(0.35f)); + getTabbedButtonBar().setColour(TabbedButtonBar::frontOutlineColourId, + tabBackgroundColour.darker(0.35f)); + + homeTab.onModelOpenRequested = [this](const String& modelPath, const String& modelName) + { openFromHome(modelPath, modelName); }; + + addTab("Home", tabBackgroundColour, &homeTab, false); + setCurrentTabIndex(0); + + setComponentID(TutorialTargets::modelTabs); + getTabbedButtonBar().setComponentID(TutorialTargets::tabBar); + } + + ~ModelTabContainer() override + { + getTabbedButtonBar().setLookAndFeel(nullptr); + + /* App shutdown: a tab that is still open (never closed) may have an in-flight + request. Letting modelTabs auto-destruct would delete such a tab and destroy its + ThreadPool while a worker is blocked in a network call, force-killing it (see + ModelTab::hasPendingRequests). Waiting for the request instead would stall the + quit for as long as its timeout. So the tab is abandoned, which aborts the + connection while enough of the app remains to deliver the result safely, and + released without being deleted, leaving the threads and memory for the exiting + process to reclaim. Idle tabs are left in the array and destroyed normally. */ + for (int i = modelTabs.size(); --i >= 0;) + { + if (ModelTab* tab = modelTabs.getUnchecked(i); tab->hasPendingRequests()) + { + tab->abandon(); + modelTabs.remove(i, false); + } + } + } + + /* Set by MainComponent and handed to every tab this container opens, so an error + popup about a missing API key can open the settings that hold it. */ + std::function onOpenAPIKeySettings; + + /** + * Opens a model in a new tab and shows it. The tab fills in once the model has loaded, + * and closes by itself if loading fails, once the error has been dismissed. + */ + ModelTab* openModelTab(const String& modelPath, String tabName = {}) + { + if (tabName.isEmpty()) + { + const auto* entry = catalog->findEntry(modelPath); + tabName = + entry != nullptr ? entry->name : modelPath.fromLastOccurrenceOf("/", false, false); + } + + auto* tab = new ModelTab(); + + // Forwarded rather than copied, so a callback set after this tab opened + // still reaches it + tab->onOpenAPIKeySettings = [this](Provider provider) + { + if (onOpenAPIKeySettings) + onOpenAPIKeySettings(provider); + }; + + // Owned from creation, so that a tab is never left unowned while a + // request is in flight (see closeTab and the destructor) + modelTabs.add(tab); + tab->addChangeListener(this); + + auto* page = pages.add(new ModelTabPage(*tab)); + + // The page is owned by pages and the tab by modelTabs, so it is added with + // deleteComponentWhenNotNeeded = false: closing it must not force JUCE + // to destroy the tab synchronously in removeTab(); see closeTab() for + // why destruction may need to be deferred. + addTab(tabName, tabBackgroundColour, page, false); + addCloseButton(*tab, getNumTabs() - 1); + + setCurrentTabIndex(getNumTabs() - 1); + + tab->loadModel(modelPath); + + return tab; + } + + // Closes a model tab as if its close button had been clicked. Does nothing + // if the tab is no longer in the tab bar. + void closeTab(ModelTab* tabToClose) + { + const int index = findTabIndex(tabToClose); + + if (index < 0) + return; + + const auto currentIndex = getCurrentTabIndex(); + const auto targetIndex = currentIndex == index + ? jmax(0, index - 1) + : (currentIndex > index ? currentIndex - 1 : currentIndex); + + // Remove the tab from the UI immediately so it looks closed to the user. + // Because the page was added with deleteComponentWhenNotNeeded = false, + // removeTab() does not destroy it. It is owned via pages. + removeTab(index); + + // Deleting the page only detaches the tab from it + pages.removeObject(findPage(tabToClose)); + + setCurrentTabIndex(jlimit(0, getNumTabs() - 1, targetIndex)); + + tabToClose->removeChangeListener(this); + + if (tabToClose->hasPendingRequests()) + { + // A network request is still in flight. Destroying the tab + // (and its ThreadPool) now would force-kill a worker thread + // blocked in a network call. Abandoning it aborts the connection + // so that the worker returns within moments, and the reaper + // deletes the tab once it has. + tabToClose->abandon(); + + modelTabs.removeObject(tabToClose, false); + tabReaper.add(tabToClose); + } + else + { + modelTabs.removeObject(tabToClose, true); + } + + sendChangeMessage(); + } + + ModelTabPage* getCurrentModelTabPage() const + { + return dynamic_cast(getCurrentContentComponent()); + } + + ModelTab* getCurrentModelTab() const + { + auto* page = getCurrentModelTabPage(); + return page != nullptr ? &page->getModelTab() : nullptr; + } + + void layOutCurrentPage() + { + if (auto* page = getCurrentModelTabPage()) + page->layOutTab(); + } + + void currentTabChanged(int newCurrentTabIndex, const String& newCurrentTabName) override + { + TabbedComponent::currentTabChanged(newCurrentTabIndex, newCurrentTabName); + + // Window constraints and the tutorial both follow whichever tab is showing + sendChangeMessage(); + } + +private: + /* Opens a model chosen on the Home tab. One that needs an API key HARP does not have + is opened only after a notice saying what it needs, shown the first time a model + from that provider is chosen. Stability AI models cannot run without a key, and + ZeroGPU models run without one on a small daily allowance, so neither is hidden + from the catalog, which would leave half of it out of view. */ + void openFromHome(const String& modelPath, const String& modelName) + { + const auto* entry = catalog->findEntry(modelPath); + + if (entry == nullptr || ! entry->isMetered()) + { + openModelTab(modelPath, modelName); + return; + } + + const Provider provider = entry->isZeroGPU() ? Provider::HuggingFace : Provider::Stability; + const String noticeKey = String(keyNoticeKeyPrefix) + enumToString(provider); + + if (apiKeys->getKey(provider).isNotEmpty() || Settings::getBoolValue(noticeKey)) + { + openModelTab(modelPath, modelName); + return; + } + + const String name = entry->name; + auto options = MessageBoxOptions() + .withButton("Add API Key") + .withButton("Open") + .withButton("Cancel") + .withAssociatedComponent(this); + + if (provider == Provider::Stability) + { + options = options.withIconType(MessageBoxIconType::WarningIcon) + .withTitle("Stability AI API Key Needed") + .withMessage(name + + " runs on Stability AI's servers, which need a Stability " + "AI API key and spend that account's credits on every " + "request.\n\nThe model can be opened without a key, but " + "processing fails until one is added in Settings."); + } + else + { + options = options.withIconType(MessageBoxIconType::InfoIcon) + .withTitle("ZeroGPU Model") + .withMessage(name + + " runs on Hugging Face ZeroGPU. Without an API key, its " + "requests draw on a small anonymous daily GPU allowance, " + "which the longest ones may not fit at all.\n\nA free " + "Hugging Face API key raises the allowance, and can be " + "added in Settings at any time."); + } + + // Button results are 1, 2 and 0 in the order the buttons were added + auto onChoice = [safeThis = SafePointer(this), + modelPath, + modelName, + noticeKey, + provider](int result) + { + if (safeThis == nullptr || result == 0) + return; + + // Dismissing the notice is not acknowledging it, so only these record it + Settings::setValue(noticeKey, true, true); + safeThis->openModelTab(modelPath, modelName); + + if (result == 1 && safeThis->onOpenAPIKeySettings) + safeThis->onOpenAPIKeySettings(provider); + }; + + AlertWindow::showAsync(options, std::move(onChoice)); + } + + void addCloseButton(ModelTab& tab, int tabIndex) + { + auto* closeButton = new CloseButton("Click to close this model tab."); + closeButton->setSize(18, 18); + closeButton->onClick = [this, safeTab = SafePointer(&tab)] + { + // Deferred, since closing deletes the tab button that owns this one + MessageManager::callAsync( + [safeThis = SafePointer(this), safeTab] + { + if (safeThis != nullptr && safeTab != nullptr) + safeThis->closeTab(safeTab.getComponent()); + }); + }; + + // The tab button takes ownership of the close button + if (auto* tabButton = getTabbedButtonBar().getTabButton(tabIndex)) + tabButton->setExtraComponent(closeButton, TabBarButton::afterText); + else + delete closeButton; + } + + ModelTabPage* findPage(const ModelTab* tab) const + { + for (auto* page : pages) + { + if (&page->getModelTab() == tab) + return page; + } + + return nullptr; + } + + int findTabIndex(const ModelTab* tab) const + { + for (int i = 1; i < getNumTabs(); ++i) + { + auto* page = dynamic_cast(getTabContentComponent(i)); + + if (page != nullptr && &page->getModelTab() == tab) + return i; + } + + return -1; + } + + void changeListenerCallback(ChangeBroadcaster* source) override + { + auto* tab = dynamic_cast(source); + + if (tab == nullptr) + return; + + // A tab left without a model once its load finished failed to load one, and + // has nothing to show now that its error has been dismissed + if (! tab->isModelLoaded() && ! tab->isLoading()) + { + closeTab(tab); + return; + } + + // What the tab has to show changed, and so how far its page has to scroll + if (auto* page = findPage(tab)) + page->layOutTab(); + + sendChangeMessage(); // bubble up to MainComponent + } + + // Owns model tabs whose UI has been closed while a request was still in + // flight. Polls each tab until its thread pools are idle, then deletes it on + // the message thread so that no worker thread is force-killed mid-request. + class DeferredTabReaper : private Timer + { + public: + ~DeferredTabReaper() override + { + stopTimer(); + + // The reaper is only destroyed when the container is, i.e. at app + // shutdown. Any tab still pending here has an in-flight request; + // deleting it would destroy a ThreadPool whose worker is blocked in + // a network syscall, force-killing it. Their connections were + // already aborted when they were added, so release ownership + // without deleting and let the exiting process reclaim the threads + // and memory. + pendingTabs.clear(false); + } + + void add(ModelTab* tab) + { + pendingTabs.add(tab); + + if (! isTimerRunning()) + startTimer(pollIntervalMs); + } + + private: + void timerCallback() override + { + for (int i = pendingTabs.size(); --i >= 0;) + { + ModelTab* tab = pendingTabs.getUnchecked(i); + + if (! tab->hasPendingRequests()) + { + // Release ownership and delete via callAsync so that any + // completion callbacks the finished job already queued run + // before the tab is destroyed. + pendingTabs.removeObject(tab, false); + MessageManager::callAsync([tab] { delete tab; }); + } + } + + if (pendingTabs.isEmpty()) + stopTimer(); + } + + static constexpr int pollIntervalMs = 250; + + OwnedArray pendingTabs; + }; + + const Colour tabBackgroundColour { getUIColourIfAvailable( + LookAndFeel_V4::ColourScheme::UIColour::windowBackground) }; + + ModelTabsLookAndFeel tabsLookAndFeel; + + HomeTab homeTab; + + OwnedArray modelTabs; + // Declared after modelTabs so that each page is destroyed before the tab it shows + OwnedArray pages; + DeferredTabReaper tabReaper; + + SharedResourcePointer catalog; + SharedResourcePointer apiKeys; + + // Prefix of the settings keys recording, per provider, that its notice was acknowledged + static constexpr const char* keyNoticeKeyPrefix = "models.keyNoticeSeen."; +}; diff --git a/src/clients/Client.h b/src/clients/Client.h index 5596eb01..beb9e98a 100644 --- a/src/clients/Client.h +++ b/src/clients/Client.h @@ -1,7 +1,7 @@ /** * @file Client.h * @brief Helper functions, shared functionality, and parent class for interacting with APIs. - * @author xribene, huiranyu, cwitkowitz + * @author cwitkowitz, VedMistry42, huiranyu, xribene */ #pragma once @@ -14,52 +14,72 @@ #include "../utils/Errors.h" #include "../utils/Labels.h" #include "../utils/Logging.h" +#include "../utils/Messages.h" +#include "../utils/Providers.h" #include "../utils/Settings.h" using namespace juce; -// TODO - hard-coded client strings for error-reporting -// can be deterministic based off of these enums -enum class Provider +/* The API key saved for each provider. Read by worker threads making requests while the + settings tab may be changing it, so every access is locked. */ +class SharedAPIKeys { - HuggingFace, - Stability -}; +public: + // Prefix of the settings keys under which the API keys are saved + static constexpr const char* settingsPrefix = "apikeys"; -struct SharedAPIKeys -{ - void initializeAPIKeys() + SharedAPIKeys() { for (auto provider : { Provider::HuggingFace, Provider::Stability }) { - String savedToken = Settings::getString(providerToSettingsKey(provider)); + const String savedKey = Settings::getString(getSettingsKey(provider)); - if (savedToken.isNotEmpty()) + if (savedKey.isNotEmpty()) { - savedTokens[provider] = savedToken; + keys[provider] = savedKey; } } } - String providerToSettingsKey(Provider p) { return settingsPrefix + "." + enumToString(p); } + // The saved key, or an empty string if there is none + String getKey(Provider provider) const + { + const ScopedLock lock(keysLock); + + auto it = keys.find(provider); - void updateKey(Provider provider, String newAPIKey) + return it != keys.end() ? it->second : String(); + } + + void updateKey(Provider provider, const String& newKey) { - savedTokens[provider] = newAPIKey; + { + const ScopedLock lock(keysLock); + keys[provider] = newKey; + } - Settings::setValue(providerToSettingsKey(provider), newAPIKey, true); + Settings::setValue(getSettingsKey(provider), newKey, true); } void removeKey(Provider provider) { - savedTokens.erase(provider); + { + const ScopedLock lock(keysLock); + keys.erase(provider); + } - Settings::removeValue(providerToSettingsKey(provider), true); + Settings::removeValue(getSettingsKey(provider), true); } - String settingsPrefix = "apikeys"; +private: + static String getSettingsKey(Provider provider) + { + return String(settingsPrefix) + "." + enumToString(provider); + } - std::unordered_map savedTokens = {}; + CriticalSection keysLock; + + std::unordered_map keys; }; inline OpResult parseJSONString(const String& stringJSON, var& outData) @@ -176,26 +196,214 @@ inline OpResult getRequiredArrayProperty(DynamicObject::Ptr& parentDict, return OpResult::ok(); } +/* + Keeps track of the network streams that are currently in flight for a single + model, so that they can be aborted locally. + + This is not the same thing as Client::cancel(), which asks the server to stop + a job by sending it a brand new request: that leaves the original connection + open and needs the network to be reachable. Aborting here closes the + connection on this side, so a worker thread blocked on it returns promptly and + the underlying OS networking task cannot deliver its completion long after the + objects that started the request are gone. +*/ +class RequestRegistry +{ +public: + void abortActiveRequests() + { + const ScopedLock lock(streamsLock); + + aborted = true; + + for (WebInputStream* stream : streams) + { + stream->cancel(); + } + } + + bool hasBeenAborted() const + { + const ScopedLock lock(streamsLock); + + return aborted; + } + + void addStream(WebInputStream* stream) + { + const ScopedLock lock(streamsLock); + + streams.add(stream); + } + + void removeStream(WebInputStream* stream) + { + const ScopedLock lock(streamsLock); + + streams.removeFirstMatchingValue(stream); + } + +private: + CriticalSection streamsLock; + + Array streams; + + bool aborted = false; +}; + +/* + A WebInputStream that stays registered with a RequestRegistry for as long as + it exists, so the registry can abort it while a worker thread is blocked + connecting to it or reading from it. Unregistering in the destructor (under + the registry lock) guarantees the registry never holds a dangling stream. +*/ +class RegisteredWebInputStream : public WebInputStream +{ +public: + RegisteredWebInputStream(RequestRegistry& registryToUse, + const URL& url, + bool addParametersToRequestBody) + : WebInputStream(url, addParametersToRequestBody), registry(registryToUse) + { + registry.addStream(this); + } + + ~RegisteredWebInputStream() override { registry.removeStream(this); } + +private: + RequestRegistry& registry; +}; + +/* + Equivalent to URL::createInputStream(), except that the stream is + registered with requestRegistry for its whole lifetime so that it can be + aborted locally (see RequestRegistry). Registering before connecting is + what makes the connect phase abortable too, since that phase blocks for up + to the connection timeout. + + Returns nullptr if the connection could not be established, including + when the request was aborted. Without a registry this is the same as + URL::createInputStream(). +*/ +inline std::unique_ptr createRegisteredStream(RequestRegistry* requestRegistry, + const URL& endpoint, + const URL::InputStreamOptions& options) +{ + if (requestRegistry == nullptr || endpoint.isLocalFile()) + { + return endpoint.createInputStream(options); + } + + auto stream = std::make_unique( + *requestRegistry, + endpoint, + options.getParameterHandling() == URL::ParameterHandling::inPostData); + + // Checked once registered, so that an abort cannot slip in between the check and the + // registration and leave this connection running + if (requestRegistry->hasBeenAborted()) + { + return nullptr; + } + + const String extraHeaders = options.getExtraHeaders(); + + if (extraHeaders.isNotEmpty()) + { + stream->withExtraHeaders(extraHeaders); + } + + const int connectionTimeoutMs = options.getConnectionTimeoutMs(); + + if (connectionTimeoutMs != 0) + { + stream->withConnectionTimeout(connectionTimeoutMs); + } + + const String requestCmd = options.getHttpRequestCmd(); + + if (requestCmd.isNotEmpty()) + { + stream->withCustomRequestCommand(requestCmd); + } + + stream->withNumRedirectsToFollow(options.getNumRedirectsToFollow()); + + const bool connected = stream->connect(nullptr); + + if (int* statusCode = options.getStatusCode()) + { + *statusCode = stream->getStatusCode(); + } + + if (StringPairArray* responseHeaders = options.getResponseHeaders()) + { + *responseHeaders = stream->getResponseHeaders(); + } + + if (! connected || stream->isError()) + { + return nullptr; + } + + return stream; +} + class Client { public: Client() = default; - virtual ~Client() {}; + virtual ~Client() = default; + + void setRequestRegistry(RequestRegistry* registryToUse) { requestRegistry = registryToUse; } + + /** + * Reduces the many ways of writing one model's address to a single form. + * + * A model can be entered abbreviated, as a full page URL, or as the API + * subdomain, and all three should be recognized as the same model rather than + * accumulating as separate entries. Returns the path unchanged when there is + * no canonical form to reduce it to. + */ + virtual String canonicalizePath(String modelPath) { return modelPath; } + + /** + * Confirms a model's address with the provider and returns its exact spelling. + * + * Some of the addresses a model can be entered as cannot be reduced without + * asking the provider, so this may make a network request and must not be + * called from the message thread. Providers that need no such lookup keep the + * default, which reduces the path offline and succeeds. + */ + virtual OpResult resolveCanonicalPath(const String& modelPath, String& canonicalPath) + { + canonicalPath = canonicalizePath(modelPath); + + return OpResult::ok(); + } virtual String inferHostSlashModel(String modelPath) = 0; virtual String inferEndpointPath(String modelPath) = 0; virtual String inferDocumentationPath(String modelPath) = 0; + // See createRegisteredStream + std::unique_ptr createRequestStream(const URL& endpoint, + const URL::InputStreamOptions& options) const + { + return createRegisteredStream(requestRegistry, endpoint, options); + } + OpResult queryToken(const String& tokenToQuery, String& response, const int timeoutMs = 10000) { String tokenValidationPath = tokenValidationURL.toString(true); - DBG_AND_LOG("Client::queryToken: Attempting to query client at \"" - << tokenValidationPath << "\" with token \"" << tokenToQuery << "\"."); + // Neither the key nor the response (which describes the account) belongs in the log + DBG_AND_LOG("Client::queryToken: Validating an API key at \"" << tokenValidationPath + << "\"."); if (! tokenValidationURL.isWellFormed()) { - return OpResult::fail(HttpError { + return fail(HttpError { HttpError::Type::InvalidURL, HttpError::Request::GET, tokenValidationPath }); } @@ -206,25 +414,24 @@ class Client .withConnectionTimeoutMs(timeoutMs) .withStatusCode(&statusCode); - std::unique_ptr stream(tokenValidationURL.createInputStream(options)); + std::unique_ptr stream(createRequestStream(tokenValidationURL, options)); if (stream == nullptr) { - return OpResult::fail(HttpError { + return fail(HttpError { HttpError::Type::ConnectionFailed, HttpError::Request::GET, tokenValidationPath }); } response = stream->readEntireStreamAsString(); - DBG_AND_LOG("Client::queryToken: Received status code \"" - << String(statusCode) << "\" with response \"" << response << "\"."); + DBG_AND_LOG("Client::queryToken: Received status code \"" << String(statusCode) << "\"."); if (statusCode != 200) { - return OpResult::fail(HttpError { HttpError::Type::BadStatusCode, - HttpError::Request::GET, - tokenValidationPath, - statusCode }); + return fail(HttpError { HttpError::Type::BadStatusCode, + HttpError::Request::GET, + tokenValidationPath, + statusCode }); } return OpResult::ok(); @@ -271,16 +478,64 @@ class Client String& payloadJSON, std::vector& outputFiles, LabelList& labels) = 0; - virtual OpResult cancel(String modelPath) { return OpResult::ok(); } + virtual OpResult cancel(String modelPath) + { + ignoreUnused(modelPath); + return OpResult::ok(); + } const String emptyJSONBody = R"({"data": []})"; String acceptHeader; String contentTypeJSONHeader; + /* Escapes line breaks for single-line logging, and redacts any credentials. + + API keys must never reach the log file: HARP asks users to open their logs + and to attach them to bug reports, so anything written here should be + assumed to end up in a public issue. */ String toPrintableHeaders(String headers) { - return headers.replace("\r", "\\r").replace("\n", "\\n"); + String printableHeaders = headers.replace("\r", "\\r").replace("\n", "\\n"); + + static const StringArray sensitivePrefixes { "Authorization:", "Cookie:", "Set-Cookie:" }; + + for (const auto& prefix : sensitivePrefixes) + { + int searchFrom = 0; + + for (;;) + { + int prefixStart = printableHeaders.indexOfIgnoreCase(searchFrom, prefix); + + if (prefixStart < 0) + { + break; + } + + int valueStart = prefixStart + prefix.length(); + + // Header values are separated by the escaped line breaks above + int valueEnd = printableHeaders.indexOf(valueStart, "\\r"); + + if (valueEnd < 0) + { + valueEnd = printableHeaders.indexOf(valueStart, "\\n"); + } + + if (valueEnd < 0) + { + valueEnd = printableHeaders.length(); + } + + printableHeaders = printableHeaders.replaceSection( + valueStart, valueEnd - valueStart, " "); + + searchFrom = valueStart; + } + } + + return printableHeaders; } Provider provider; @@ -289,22 +544,74 @@ class Client URL tokenRegistrationURL; protected: - String getCommonHeaders() const { return getAuthorizationHeader() + acceptHeader; } - String getJSONHeaders() const { return getCommonHeaders() + contentTypeJSONHeader; } + /* The headers of a request. A client whose endpoints are not all run by its provider + leaves out the API key for those, which would otherwise be handed to whoever runs + them. */ + String getCommonHeaders(bool includeAPIKey = true) const + { + return (includeAPIKey ? getAuthorizationHeader() : String()) + acceptHeader + + getClientHeader(); + } -private: - String getAuthorizationHeader() const + String getJSONHeaders(bool includeAPIKey = true) const { - String accessToken; + return getCommonHeaders(includeAPIKey) + contentTypeJSONHeader; + } - if (sharedTokens->savedTokens.contains(provider)) - { - accessToken = sharedTokens->savedTokens[provider]; - } + SharedResourcePointer statusMessage; + + /* A new file in the temporary directory for a result, named after it and stamped with + the time it arrived, e.g. "open-unmix_vocals_10-20-2026_14-35-12.wav". HARP shows a + result under its file name, so a time keeps the results of successive runs apart in a + form a user can read. Results named alike in the same second are numbered. */ + static File createResultFile(const String& baseName, const String& extension) + { + const String stamped = + baseName + "_" + Time::getCurrentTime().formatted("%m-%d-%Y_%H-%M-%S"); + const File directory = File::getSpecialLocation(File::tempDirectory); + + // Numbered " (2)", " (3)", and so on + File file = directory.getChildFile(stamped + extension); + + for (int number = 2; file.exists(); ++number) + file = directory.getChildFile(stamped + " (" + String(number) + ")" + extension); - return getAuthorizationHeader(accessToken); + // Claimed at once, so that a concurrent download cannot be given the same name + file.create(); + + return file; + } + + /* Records which provider an error came from on its way out, the client being the + only layer that knows. Overloaded rather than templated so that error types + carrying no provider cannot be passed here by mistake. */ + OpResult fail(ClientError error) const + { + error.provider = provider; + + return OpResult::fail(error); + } + + OpResult fail(HttpError error) const + { + error.provider = provider; + + return OpResult::fail(error); } +private: + String getAuthorizationHeader() const + { + return getAuthorizationHeader(sharedTokens->getKey(provider)); + } + + /* Identifies this client to a PyHARP endpoint, so that a cancel it sends stops the + job it started rather than whatever happens to be running. Gradio scopes its own + cancellation to a browser session, which an endpoint reached over the API does not + have, so the endpoint matches on this instead. One client is created per model + load, which is the granularity to which a cancel belongs. */ + String getClientHeader() const { return "X-HARP-Client: " + clientId + "\r\n"; } + String getAuthorizationHeader(String accessToken) const { String authorizationHeader; @@ -317,5 +624,11 @@ class Client return authorizationHeader; } + const String clientId { Uuid().toDashedString() }; + SharedResourcePointer sharedTokens; + + /* Owned by whatever the requests belong to, such as a Model. Without one, requests + cannot be aborted. */ + RequestRegistry* requestRegistry = nullptr; }; diff --git a/src/clients/GradioClient.h b/src/clients/GradioClient.h index 9b77390f..cee88e8b 100644 --- a/src/clients/GradioClient.h +++ b/src/clients/GradioClient.h @@ -1,11 +1,13 @@ /** * @file GradioClient.h * @brief Client specifics for Gradio and Hugging Face (simple JSON requests). - * @author xribene, huiranyu, cwitkowitz + * @author cwitkowitz, saumya-pailwan, xribene, huiranyu */ #pragma once +#include + #include "Client.h" using namespace juce; @@ -13,13 +15,10 @@ using namespace juce; class GradioClient : public Client { public: - enum GradioEvents - { - Complete, - Heartbeat, - Error - //Generating - }; + // Events of the simple API that the response handling depends on + static constexpr const char* completeEvent = "complete"; + static constexpr const char* heartbeatEvent = "heartbeat"; + static constexpr const char* errorEvent = "error"; GradioClient() { @@ -38,6 +37,141 @@ class GradioClient : public Client || isValidHuggingFacePath(modelPath); } + String canonicalizePath(String modelPath) override + { + /* "user/name" is the form Hugging Face itself uses, and the only one both + the page URL and the API subdomain can be derived from. A page URL carries + that form verbatim, so it can be reduced here without asking anyone. + + The API subdomain cannot: it lowercases the name and replaces both the + separating "/" and every "_" with "-", so neither the boundary nor the + original spelling survives. Reducing it offline would only guess, which is + what resolveCanonicalPath below asks the Hub to avoid. Local and Gradio + addresses have no canonical form at all and are left alone. */ + if (isValidLongHuggingFacePath(modelPath)) + { + const String hostSlashModel = inferHostSlashModel(modelPath); + + if (hostSlashModel.isNotEmpty()) + { + return hostSlashModel; + } + } + + return modelPath; + } + + /** + * Asks the provider for a model's exact address. + * + * The Hub answers for the two written forms and the running app answers for the + * API subdomain, so an address that only appears to work (e.g., a "-" typed where the + * name has a "_" or the wrong capitalization) is corrected here rather than + * silently loading a different model or producing a broken documentation link. + */ + OpResult resolveCanonicalPath(const String& modelPath, String& canonicalPath) override + { + canonicalPath = canonicalizePath(modelPath); + + // Only Hugging Face publishes a canonical spelling to resolve against + if (! isValidHuggingFacePath(canonicalPath)) + { + return OpResult::ok(); + } + + /* A subdomain has to be asked for its own identity, since the Hub cannot be + queried for it without already knowing the answer. It advertises that + identity in a "canonical" link header, which it sends whether it is awake + or asleep, so no request to the app itself is needed. */ + if (isValidShortHuggingFacePath(canonicalPath)) + { + const String advertisedPath = queryCanonicalSpace(inferEndpointPath(canonicalPath)); + + if (advertisedPath.isNotEmpty()) + { + if (advertisedPath != canonicalPath) + { + DBG_AND_LOG("GradioClient::resolveCanonicalPath: Path \"" + << modelPath << "\" resolved to \"" << advertisedPath << "\"."); + } + + canonicalPath = advertisedPath; + } + else + { + /* Without an answer only the ambiguous reading of the subdomain is + left, which is no basis for rejecting the path. */ + DBG_AND_LOG("GradioClient::resolveCanonicalPath: Path \"" + << modelPath << "\" advertises no identity. Continuing with \"" + << canonicalPath << "\"."); + } + + return OpResult::ok(); + } + + static const Identifier identityKey { "id" }; + + std::unique_ptr stream; + + OpResult result = makeGETRequestStream( + URL("https://huggingface.co/api/spaces/" + canonicalPath), stream, modelPath, 10000); + + if (result.failed()) + { + const auto* e = std::get_if(&result.getError()); + + /* 401 and 404 are the Hub's answers for "no such Space" and for "private, + and you are not signed in", which it does not distinguish between. Both + mean this address cannot be loaded, so say so now with a message about + the address rather than letting the request fail later as a transport + error. */ + if (e != nullptr && e->type == HttpError::Type::BadStatusCode + && (e->statusCode == 401 || e->statusCode == 404)) + { + DBG_AND_LOG("GradioClient::resolveCanonicalPath: Path \"" + << modelPath << "\" was not recognized by the provider (status code \"" + << String(e->statusCode) << "\")."); + + return fail(ClientError { ClientError::Type::ModelNotFound, modelPath }); + } + + /* Anything else is a problem reaching the provider rather than a verdict + on the path, and must not stop a load that would otherwise succeed. */ + DBG_AND_LOG("GradioClient::resolveCanonicalPath: Could not confirm path \"" + << modelPath << "\" (" << toLogString(result.getError()) + << "). Continuing with \"" << canonicalPath << "\"."); + + return OpResult::ok(); + } + + DynamicObject::Ptr responseDict; + + if (stream == nullptr + || stringJSONToDict(stream->readEntireStreamAsString(), responseDict).failed() + || ! responseDict->hasProperty(identityKey)) + { + DBG_AND_LOG("GradioClient::resolveCanonicalPath: Provider gave no identity for path \"" + << modelPath << "\". Continuing with \"" << canonicalPath << "\"."); + + return OpResult::ok(); + } + + const String resolvedPath = responseDict->getProperty(identityKey).toString().trim(); + + if (resolvedPath.isNotEmpty() && isValidAbbrevHuggingFacePath(resolvedPath)) + { + if (resolvedPath != canonicalPath) + { + DBG_AND_LOG("GradioClient::resolveCanonicalPath: Path \"" + << modelPath << "\" resolved to \"" << resolvedPath << "\"."); + } + + canonicalPath = resolvedPath; + } + + return OpResult::ok(); + } + String inferHostSlashModel(String modelPath) override { String hostSlashModel; @@ -50,13 +184,15 @@ class GradioClient : public Client { if (isValidShortHuggingFacePath(modelPath)) { - StringArray array = StringArray::fromTokens( - modelPath.fromFirstOccurrenceOf("https://", false, false) - .upToFirstOccurrenceOf(".hf.space", false, false), - "-", - ""); - - hostSlashModel = array[0] + "/" + array[1]; + /* Only the first "-" is known to be the owner boundary. The rest + belong to the name, whose original spelling the subdomain does not + preserve. This is a best guess for display and for addressing the + Hub, which resolveCanonicalPath replaces with the exact spelling. */ + const String subdomain = modelPath.fromFirstOccurrenceOf("https://", false, false) + .upToFirstOccurrenceOf(".hf.space", false, false); + + hostSlashModel = subdomain.upToFirstOccurrenceOf("-", false, false) + "/" + + subdomain.fromFirstOccurrenceOf("-", false, false); } else if (isValidLongHuggingFacePath(modelPath)) { @@ -114,13 +250,12 @@ class GradioClient : public Client String host = array[0]; String model = array[1]; - // TODO - this can load paths that were incorrectly added with "-" instead of "_" resulting in a broken documentation link endpointPath = "https://" + host + "-" + model.replace("_", "-") + ".hf.space/"; } } else { - DBG_AND_LOG("GradioClient::inferEndpointURL: Path \"" + DBG_AND_LOG("GradioClient::inferEndpointPath: Path \"" << modelPath << "\" does not match valid specification for Gradio."); } @@ -133,7 +268,7 @@ class GradioClient : public Client if (isValidLocalPath(modelPath) || isValidGradioPath(modelPath)) { - documentationPath = inferEndpointPath(documentationPath); + documentationPath = inferEndpointPath(modelPath); } else if (isValidHuggingFacePath(modelPath)) { @@ -156,7 +291,7 @@ class GradioClient : public Client return documentationPath; } - virtual OpResult validateToken(const String& tokenToValidate) override + OpResult validateToken(const String& tokenToValidate) override { String responseJSON; @@ -176,58 +311,43 @@ class GradioClient : public Client return result; } - auto* tokenJSON = responseDict->getProperty("auth") - .getDynamicObject() - ->getProperty("accessToken") - .getDynamicObject(); - - String role = tokenJSON->getProperty("role").toString(); + const var tokenInfo = responseDict->getProperty("auth")["accessToken"]; - if (! (role == "write" || role == "read")) + if (! tokenInfo.isObject()) { - bool hasAllPermissions = false; - - auto* scopedArray = tokenJSON->getProperty("fineGrained") - .getDynamicObject() - ->getProperty("scoped") - .getArray(); + return OpResult::fail(JsonError { JsonError::Type::MissingKey, {}, "accessToken" }); + } - for (const auto& scopeEntry : *scopedArray) - { - if (! scopeEntry.isObject()) - continue; + const String role = tokenInfo["role"].toString(); - var permissionsVar = scopeEntry.getDynamicObject()->getProperty("permissions"); + // A classic read or write key grants everything HARP needs + if (role == "write" || role == "read") + { + return OpResult::ok(); + } - if (! permissionsVar.isArray()) - continue; + // A fine-grained key has to grant it within one of its scopes - auto* permissionsArray = permissionsVar.getArray(); - bool hasAll = permissionsArray->contains("repo.content.read") - && permissionsArray->contains("repo.write") - && permissionsArray->contains("inference.serverless.write") - && permissionsArray->contains("inference.endpoints.infer.write"); + if (const auto* scopes = tokenInfo["fineGrained"]["scoped"].getArray()) + { + for (const auto& scope : *scopes) + { + const auto* permissions = scope["permissions"].getArray(); - if (hasAll) + if (permissions != nullptr && permissions->contains("repo.content.read") + && permissions->contains("repo.write") + && permissions->contains("inference.serverless.write") + && permissions->contains("inference.endpoints.infer.write")) { - hasAllPermissions = true; - break; + return OpResult::ok(); } } - - if (! hasAllPermissions) - { - return OpResult::fail(ClientError { ClientError::Type::InsufficientPermissions, - "", - "Hugging Face", - tokenToValidate }); - } } - return OpResult::ok(); + return fail(ClientError { ClientError::Type::InsufficientPermissions }); } - OpResult queryControls(String modelPath, DynamicObject::Ptr& controls) + OpResult queryControls(String modelPath, DynamicObject::Ptr& controls) override { String responseJSON; @@ -279,11 +399,13 @@ class GradioClient : public Client String responseJSON; OpResult result = makePOSTRequest(endpoint, - getCommonHeaders(), + getCommonHeaders(acceptsAPIKey(endpoint)), requestBody, responseJSON, inferDocumentationPath(modelPath), - fileToUpload); + fileToUpload, + 10000, + modelPath); if (result.failed()) { @@ -314,7 +436,7 @@ class GradioClient : public Client { ignoreUnused(label); - if (isFile and ! payloadElement.isVoid()) + if (isFile && ! payloadElement.isVoid()) { DynamicObject::Ptr wrappedPayloadElement = payloadElement.getDynamicObject(); @@ -334,11 +456,12 @@ class GradioClient : public Client OpResult process(String modelPath, String& payloadJSON, std::vector& outputFiles, - LabelList& labels) + LabelList& labels) override { String responseJSON; - OpResult result = makeRequest(modelPath, "process", payloadJSON, responseJSON); + OpResult result = + makeRequest(modelPath, "process", payloadJSON, responseJSON, processTimeoutMs); if (result.failed()) { @@ -413,8 +536,9 @@ class GradioClient : public Client } else { - // Unknown output type - jassertfalse; + // Possibly from a newer pyharp, so it is left out rather than failing + DBG_AND_LOG("GradioClient::process: Ignoring output of unknown type \"" + << outputType << "\"."); } } else @@ -428,18 +552,11 @@ class GradioClient : public Client return OpResult::ok(); } - OpResult cancel(String modelPath) + OpResult cancel(String modelPath) override { String response; - OpResult result = makeRequest(modelPath, "cancel", emptyJSONBody, response); - - if (result.failed()) - { - return result; - } - - return OpResult::ok(); + return makeRequest(modelPath, "cancel", emptyJSONBody, response); } private: @@ -461,6 +578,17 @@ class GradioClient : public Client return modelPath.startsWith("https://") && modelPath.endsWith(".gradio.live"); } + /* Whether an endpoint may be sent the Hugging Face API key. Hugging Face and this + machine are trusted with it, and anything else (e.g., a gradio.live link or another + computer on the network) is run by someone who has no use for it. */ + static bool acceptsAPIKey(const URL& endpoint) + { + const String host = endpoint.getDomain().toLowerCase(); + + return host == "huggingface.co" || host.endsWith(".hf.space") || host == "localhost" + || host == "127.0.0.1"; + } + static bool isValidHuggingFacePath(String modelPath) { return isValidShortHuggingFacePath(modelPath) || isValidLongHuggingFacePath(modelPath) @@ -473,34 +601,23 @@ class GradioClient : public Client e.g., "https://xribene-midi-pitch-shifter.hf.space/" */ - StringArray array = - StringArray::fromTokens(modelPath.fromFirstOccurrenceOf("https://", false, false) - .upToFirstOccurrenceOf(".hf.space", false, false), - "-", - ""); - - if (array.size() == 2) + /* Without this the check below is meaningless for any other address: + upToFirstOccurrenceOf returns the whole string when the needle is + absent, so a long-form path that happens to contain a single hyphen + splits into two tokens and is mistaken for a subdomain. */ + if (! modelPath.contains(".hf.space")) { - return true; + return false; } - else if (array.size() != 0) - { - /* - This is ambiguous. There's no way to tell where the delimeter - belongs and which hypens were converted from underscores. - */ - DBG_AND_LOG( - "GradioClient::isValidShortHuggingFacePath: Path \"" - << modelPath - << "\" is ambiguous. Please use the abbreviated or long-form path instead."); + const String subdomain = modelPath.fromFirstOccurrenceOf("https://", false, false) + .upToFirstOccurrenceOf(".hf.space", false, false); - return false; - } - else - { - return false; - } + /* The owner and the name are joined by a "-", so at least one is needed for + this to name a Space at all. Where that "-" falls, and which of the others + were underscores, cannot be told from the address. resolveCanonicalPath + asks the running app which Space it is rather than guessing. */ + return subdomain.isNotEmpty() && subdomain.containsChar('-'); } static bool isValidLongHuggingFacePath(String modelPath) @@ -509,6 +626,11 @@ class GradioClient : public Client e.g., "https://huggingface.co/spaces/xribene/midi_pitch_shifter" */ + if (! modelPath.startsWith("https://huggingface.co/spaces/")) + { + return false; + } + return isValidAbbrevHuggingFacePath( modelPath.fromFirstOccurrenceOf("https://huggingface.co/spaces/", false, false)); } @@ -524,13 +646,161 @@ class GradioClient : public Client return array.size() == 2; } + /* The Hub's report on how a Space is currently deployed, covering both its + lifecycle stage and its hardware. Returns false when it cannot be obtained, + which the callers treat as "unknown" rather than as a verdict. */ + bool querySpaceRuntime(const String& modelPath, DynamicObject::Ptr& runtime) + { + if (! isValidHuggingFacePath(modelPath)) + return false; + + URL runtimeEndpoint = + URL("https://huggingface.co/api/spaces/" + inferHostSlashModel(modelPath) + "/runtime"); + + std::unique_ptr stream; + + if (makeGETRequestStream(runtimeEndpoint, stream, "", 5000).failed() || stream == nullptr) + return false; + + return stringJSONToDict(stream->readEntireStreamAsString(), runtime).wasOk(); + } + + /* The lifecycle stage reported by the Hub, e.g. RUNNING, SLEEPING, APP_STARTING, + BUILDING, RUNTIME_ERROR, PAUSED. Returns an empty string when it cannot be + determined. Unlike the hardware type this changes constantly, so it is never + cached. */ + String querySpaceStage(const String& modelPath) + { + DynamicObject::Ptr runtime; + + if (! querySpaceRuntime(modelPath, runtime)) + return {}; + + static const Identifier stageKey { "stage" }; + + return runtime->hasProperty(stageKey) + ? runtime->getProperty(stageKey).toString().toUpperCase() + : String(); + } + + /* Which of the two verdicts a stage amounts to. Asking the Hub for the stage is + what separates them, since a Space that is merely waking up answers a request + exactly like one that is genuinely broken. A stage matching neither is left + unclassified so that the original failure is reported as it stands. */ + static bool isStartingStage(const String& stage) + { + /* RUNNING belongs here because the Hub marks a Space running as soon as its + container is up, a moment before the app behind it accepts requests. */ + return stage == "SLEEPING" || stage == "APP_STARTING" || stage == "BUILDING" + || stage == "RUNNING"; + } + + static bool isUnavailableStage(const String& stage) + { + return stage == "RUNTIME_ERROR" || stage == "BUILD_ERROR" || stage == "CONFIG_ERROR" + || stage == "NO_APP_FILE" || stage == "PAUSED" || stage == "STOPPED" + || stage == "DELETING"; + } + + /* The exact "user/Name" a Space host answers for, taken from the "canonical" + link header it sends alongside its page. The address used to reach a Space + lowercases its name and replaces every "_" with "-", so this header is the + only way to recover the original spelling. Returns an empty string when the + host does not answer or does not advertise one. */ + String queryCanonicalSpace(const String& hostURL) + { + const URL host { hostURL }; + + if (! host.isWellFormed()) + return {}; + + StringPairArray responseHeaders; + + auto options = URL::InputStreamOptions(URL::ParameterHandling::inAddress) + .withExtraHeaders(getCommonHeaders(acceptsAPIKey(host))) + .withConnectionTimeoutMs(10000) + .withResponseHeaders(&responseHeaders) + .withNumRedirectsToFollow(5); + + /* A sleeping Space answers with an error status but still sends the header, + so the status code is deliberately not consulted here. */ + if (createRequestStream(host, options) == nullptr) + return {}; + + static const String spacesPrefix { "https://huggingface.co/spaces/" }; + + /* Several link headers can be sent at once and are joined into one value, so + the canonical entry is picked out rather than the header taken whole. */ + for (const String& entry : StringArray::fromTokens(responseHeaders["link"], ",", "\"'")) + { + if (! entry.contains("canonical") || ! entry.contains(spacesPrefix)) + continue; + + const String advertised = entry.fromFirstOccurrenceOf(spacesPrefix, false, false) + .upToFirstOccurrenceOf(">", false, false) + .trim(); + + if (isValidAbbrevHuggingFacePath(advertised)) + return advertised; + } + + return {}; + } + + bool isZeroGPUSpace(const String& modelPath) + { + if (! isValidHuggingFacePath(modelPath)) + return false; + + // The hardware type is a static property of the Space, so it is cached to + // avoid repeated blocking lookups from error-handling paths + { + const ScopedLock lock(zeroGPUCacheLock); + + auto cached = zeroGPUCache.find(modelPath); + + if (cached != zeroGPUCache.end()) + return cached->second; + } + + DynamicObject::Ptr runtime; + + // Failed lookups are not cached, so that a transient network error + // does not permanently misclassify the Space + if (! querySpaceRuntime(modelPath, runtime)) + return false; + + // Navigate: hardware -> current + static const Identifier hardwareKey { "hardware" }; + static const Identifier currentKey { "current" }; + + String hardwareCurrent; + + if (runtime->hasProperty(hardwareKey)) + { + if (auto* hardwareDict = runtime->getProperty(hardwareKey).getDynamicObject()) + hardwareCurrent = hardwareDict->getProperty(currentKey).toString(); + } + + // ZeroGPU hardware names start with "zero-" (e.g. "zero-a10g", "zero-a100") + bool isZeroGPU = hardwareCurrent.startsWithIgnoreCase("zero-"); + + { + const ScopedLock lock(zeroGPUCacheLock); + zeroGPUCache[modelPath] = isZeroGPU; + } + + return isZeroGPU; + } + OpResult makePOSTRequest(URL endpoint, const String headers, const String body, String& response, const String errorPath = "", const File& fileToUpload = File(), - const int timeoutMs = 10000) + const int timeoutMs = 10000, + const String modelPath = "") { String debugMessage = "GradioClient::makePOSTRequest: Attempting to make POST request for endpoint \"" @@ -556,7 +826,7 @@ class GradioClient : public Client if (! endpoint.isWellFormed()) { - return OpResult::fail( + return fail( HttpError { HttpError::Type::InvalidURL, HttpError::Request::POST, errorPath }); } @@ -571,11 +841,11 @@ class GradioClient : public Client .withNumRedirectsToFollow(5) .withHttpRequestCmd("POST"); - std::unique_ptr stream(endpoint.createInputStream(options)); + std::unique_ptr stream(createRequestStream(endpoint, options)); if (stream == nullptr) { - return OpResult::fail(HttpError { + return fail(HttpError { HttpError::Type::ConnectionFailed, HttpError::Request::POST, errorPath }); } @@ -587,8 +857,51 @@ class GradioClient : public Client if (statusCode != 200) { - return OpResult::fail(HttpError { - HttpError::Type::BadStatusCode, HttpError::Request::POST, errorPath, statusCode }); + String reason; + + // HTML bodies (proxy/login/error pages) carry no useful diagnostics + if (! isHTMLResponse(response)) + { + reason = extractShortReason(extractErrorInfoFromPayload(response).combined()); + } + + /* A 503 from a Space is ambiguous on its own: the Hub answers the same way + whether the Space is waking up or genuinely broken. Ask the Hub which it + is rather than reporting it as down. */ + if (statusCode == 503 && modelPath.isNotEmpty()) + { + String stage = querySpaceStage(modelPath); + + DBG_AND_LOG("GradioClient::makePOSTRequest: Space stage \"" << stage << "\"."); + + if (isStartingStage(stage)) + { + return OpResult::fail( + GradioError { GradioError::Type::SpaceStarting, errorPath, stage, true }); + } + + if (isUnavailableStage(stage)) + { + return OpResult::fail(GradioError { + GradioError::Type::SpaceUnavailable, errorPath, stage, true }); + } + } + + // Keep the real status code so callers can react to specific + // failures (e.g. 404 for an invalid path, 402 for quota limits) + // and attach any diagnostic text from the response body + return fail(HttpError { HttpError::Type::BadStatusCode, + HttpError::Request::POST, + errorPath, + statusCode, + reason }); + } + + if (isHTMLResponse(response)) + { + // A 200 with an HTML body is not a Gradio API response at all + return fail(HttpError { + HttpError::Type::UnexpectedResponse, HttpError::Request::POST, errorPath }); } return OpResult::ok(); @@ -599,7 +912,7 @@ class GradioClient : public Client const String errorPath = "", const int timeoutMs = -1) { - String requestHeaders = getCommonHeaders(); + String requestHeaders = getCommonHeaders(acceptsAPIKey(endpoint)); DBG_AND_LOG( "GradioClient::makeGETRequestStream: Attempting to make GET request for endpoint \"" @@ -608,7 +921,7 @@ class GradioClient : public Client if (! endpoint.isWellFormed()) { - return OpResult::fail( + return fail( HttpError { HttpError::Type::InvalidURL, HttpError::Request::GET, errorPath }); } @@ -622,11 +935,11 @@ class GradioClient : public Client .withStatusCode(&statusCode) .withNumRedirectsToFollow(5); - stream = endpoint.createInputStream(options); + stream = createRequestStream(endpoint, options); if (stream == nullptr) { - return OpResult::fail(HttpError { + return fail(HttpError { HttpError::Type::ConnectionFailed, HttpError::Request::GET, errorPath }); } @@ -636,14 +949,14 @@ class GradioClient : public Client if (statusCode != 200) { - return OpResult::fail(HttpError { + return fail(HttpError { HttpError::Type::BadStatusCode, HttpError::Request::GET, errorPath, statusCode }); } return OpResult::ok(); } - String extractPayLoad(String response) + static String extractPayload(const String& response) { String payload = response.trim(); @@ -655,11 +968,164 @@ class GradioClient : public Client return payload; } + // Compares an event type (the text after "event:") to a known event, ignoring case + static bool isEventType(const String& eventType, const char* event) + { + return eventType.equalsIgnoreCase(event); + } + + /* What a Gradio error payload carries. + + Gradio >= 6.13 forwards the failing event's error payload on this endpoint: + {"error": "", "title": "", "duration": .., "visible": ..} + The message is populated whenever the server raised a gr.Error (which is how + ZeroGPU reports quota exhaustion, with title "ZeroGPU quota exceeded"), and + for any other exception only when the app was launched with show_error=True. + When it was not, the payload is {"error": null}, which is a real runtime + error whose details the server deliberately withheld. + + Gradio <= 6.12 discards the payload entirely and sends "data: null", so + nothing at all can be told apart from the response. */ + struct GradioErrorInfo + { + String title; + String message; + + // True when the server sent no payload at all, as opposed to a payload + // that was present but carried no message + bool payloadAbsent = false; + + String combined() const + { + if (title.isNotEmpty() && message.isNotEmpty()) + return title + ": " + message; + + return title.isNotEmpty() ? title : message; + } + }; + + /* Whether a data line carries nothing at all. Gradio writes an absent payload as + a bare "null", so that has to be told apart from text worth parsing. */ + static bool isPayloadAbsent(const String& payload) + { + String normalizedPayload = payload.trim(); + + return normalizedPayload.isEmpty() || normalizedPayload.equalsIgnoreCase("null") + || normalizedPayload.equalsIgnoreCase("none"); + } + + static GradioErrorInfo extractErrorInfoFromPayload(const String& payload) + { + GradioErrorInfo info; + + if (isPayloadAbsent(payload)) + { + info.payloadAbsent = true; + + return info; + } + + String normalizedPayload = payload.trim(); + + var parsedPayload = JSON::parse(normalizedPayload); + + if (auto* payloadDict = parsedPayload.getDynamicObject()) + { + info.title = extractFirstNonEmptyField(payloadDict, { "title" }); + info.message = + extractFirstNonEmptyField(payloadDict, { "error", "message", "detail", "reason" }); + + return info; + } + + // A non-dictionary payload (e.g. a bare error string) + info.message = normalizedPayload; + + return info; + } + + static bool isHTMLResponse(const String& text) + { + String trimmed = text.trimStart(); + return trimmed.startsWithIgnoreCase("<!DOCTYPE") || trimmed.startsWithIgnoreCase("<html"); + } + + // Returns true when the Hugging Face proxy reports that the Space itself is broken + static bool isSpaceStatusError(const String& text) + { + String lower = text.toLowerCase(); + + return lower.contains("space is in error") || lower.contains("check its status on hf"); + } + + static String extractShortReason(const String& text) + { + String firstLine = text.upToFirstOccurrenceOf("\n", false, false).trim(); + + if (firstLine.length() > 200) + { + firstLine = firstLine.substring(0, 200).trimEnd() + "..."; + } + + return firstLine; + } + + static String extractFirstNonEmptyField(const DynamicObject* object, + std::initializer_list<const char*> keys) + { + if (object == nullptr) + { + return ""; + } + + for (auto key : keys) + { + Identifier id(key); + + if (! object->hasProperty(id)) + { + continue; + } + + String value = object->getProperty(id).toString().trim(); + + if (value.isNotEmpty() && ! value.equalsIgnoreCase("null") + && ! value.equalsIgnoreCase("undefined")) + { + return value; + } + } + + return ""; + } + + /* Reports an event that is still in progress in the status area. + + The simple API (/gradio_api/call) delivers only "complete", "generating", + "heartbeat", and "error" events, and drops the messages gr.Info and gr.Warning + send (verified against Gradio 5.28 through 6.24). Those are delivered only on the + queue API (/gradio_api/queue/join and /gradio_api/queue/data). */ + void reportEvent(const String& eventType) + { + if (eventType.isEmpty() || isEventType(eventType, heartbeatEvent) + || isEventType(eventType, completeEvent) || isEventType(eventType, errorEvent)) + { + return; + } + + const String statusText = "[status] " + eventType.replace("_", " "); + + DBG_AND_LOG("GradioClient::reportEvent: \"" << statusText << "\"."); + + statusMessage->setMessage(statusText); + } + // String response version OpResult makeGETRequest(const URL endpoint, String& response, const String errorPath = "", - const int timeoutMs = -1) + const int timeoutMs = -1, + const String modelPath = "") { std::unique_ptr<InputStream> stream; @@ -670,49 +1136,153 @@ class GradioClient : public Client return result; } + // Tracks whether any result-bearing data: lines have arrived. Heartbeats are + // excluded, since they say nothing about whether the job has produced output. + bool seenResultData = false; + bool checkedFirstLine = false; + + // Set when a "complete" event delivers the response the caller is waiting for + bool receivedCompleteEvent = false; + + // The type from the most recent "event:" line, which labels the "data:" lines after it + String currentSseEventType; + while (! stream->isExhausted()) { response = stream->readNextLine(); DBG_AND_LOG("GradioClient::makeGETRequest: Streamed response \"" << response << "\"."); - if (response.containsIgnoreCase(enumToString(GradioEvents::Complete))) + String eventLine = response.trim(); + + if (eventLine.isEmpty()) { - response = extractPayLoad(stream->readNextLine()); + // Blank line signals the end of one SSE message block + currentSseEventType = ""; + continue; + } - DBG_AND_LOG("GradioClient::makeGETRequest: Final response \"" << response << "\"."); + if (! checkedFirstLine) + { + checkedFirstLine = true; - break; + if (isHTMLResponse(eventLine)) + { + return fail(HttpError { + HttpError::Type::UnexpectedResponse, HttpError::Request::GET, errorPath }); + } } - else if (response.containsIgnoreCase(enumToString(GradioEvents::Error))) + + // Capture the SSE event type from "event: <type>" lines. + if (eventLine.startsWithIgnoreCase("event:")) { - response = stream->readNextLine(); + currentSseEventType = eventLine.fromFirstOccurrenceOf(":", false, false).trim(); + + if (isEventType(currentSseEventType, completeEvent)) + { + response = extractPayload(stream->readNextLine()); + + DBG_AND_LOG("GradioClient::makeGETRequest: Final response \"" << response + << "\"."); + + receivedCompleteEvent = true; + + break; + } + else if (isEventType(currentSseEventType, errorEvent)) + { + String errorDataLine = stream->readNextLine(); - DBG_AND_LOG("GradioClient::makeGETRequest: Error response \"" << response << "\"."); + DBG_AND_LOG("GradioClient::makeGETRequest: Error response \"" << errorDataLine + << "\"."); - // TODO - could potentially identify other errors (e.g., too many requests) + String payload = extractPayload(errorDataLine); + GradioErrorInfo errorInfo = extractErrorInfoFromPayload(payload); + + String diagnosticText = errorInfo.combined(); + String reason = extractShortReason(diagnosticText); + + /* The proxy has stated outright that the Space is down, so unlike + the ambiguous 503 handled in makePOSTRequest there is nothing to + decide. The Hub is still asked for the stage, purely so that the + message can name it, and only a stage that agrees is reported. */ + if (isSpaceStatusError(diagnosticText)) + { + const String stage = querySpaceStage(modelPath); + + DBG_AND_LOG("GradioClient::makeGETRequest: Space stage \"" << stage + << "\"."); + + return OpResult::fail( + GradioError { GradioError::Type::SpaceUnavailable, + errorPath, + isUnavailableStage(stage) ? stage : String(), + true }); + } + + GradioError::Type errorType = GradioError::Type::RuntimeError; + + if (diagnosticText.containsIgnoreCase("quota")) + { + /* ZeroGPU raises quota exhaustion as a gr.Error titled + "ZeroGPU quota exceeded", which Gradio >= 6.13 forwards + verbatim on this endpoint */ + errorType = GradioError::Type::QuotaExceeded; + } + else if (errorInfo.payloadAbsent && ! seenResultData + && isZeroGPUSpace(modelPath)) + { + /* Gradio <= 6.12 drops the error payload entirely, so a quota + rejection really is indistinguishable from any other early + failure. Report that ambiguity rather than guessing: the space + runs on ZeroGPU, so quota is one possible cause, but nothing + here establishes it. + + Note this is only reached when NO payload arrived. A payload + that is present but carries no message (Gradio >= 6.13 with + show_error=False) is a genuine runtime error whose details the + server withheld, never a quota rejection, since gr.Error + messages are forwarded regardless of show_error. */ + errorType = GradioError::Type::Indeterminate; + } - return OpResult::fail(GradioError { GradioError::Type::RuntimeError, errorPath }); + return OpResult::fail(GradioError { + errorType, errorPath, reason, isValidHuggingFacePath(modelPath) }); + } } - else + else if (eventLine.startsWithIgnoreCase("data:")) { - // TODO - what other information is available? - // Informational or progress events - // Examples: - // - event: heartbeat - // - event: log - // - event: progress + if (! isEventType(currentSseEventType, heartbeatEvent)) + { + seenResultData = true; + } + + reportEvent(currentSseEventType); } } + if (! receivedCompleteEvent) + { + /* The stream ended without ever delivering a "complete" or "error" event, + so there is no response to parse. Reporting success here would leave the + caller to fail on an empty string, which surfaces as a confusing JSON + error rather than the connection problem it is. + + The most common cause is the transfer being aborted while the model was + still working: JUCE's curl backend turns the timeout into a low-speed + abort (CURLOPT_LOW_SPEED_LIMIT/TIME), and a stream carrying nothing but + Gradio's periodic heartbeats sits far below that threshold. */ + return OpResult::fail(GradioError { GradioError::Type::IncompleteResponse, + errorPath, + "", + isValidHuggingFacePath(modelPath) }); + } + return OpResult::ok(); } - OpResult downloadFile(String downloadPath, File& fileToDownload) //override + OpResult downloadFile(String downloadPath, File& fileToDownload) { - // Obtain local temporary directory for downloaded file - File tempDir = File::getSpecialLocation(File::tempDirectory); - URL endpoint = URL(downloadPath); String fileName = endpoint.getFileName(); String baseName = @@ -720,17 +1290,9 @@ class GradioClient : public Client String extension = File::createFileWithoutCheckingPath(fileName).getFileExtension(); - // Create file at a unique path - fileToDownload = tempDir.getChildFile(baseName + "_" + Uuid().toString() + extension); - - OpResult result = makeGETRequest(endpoint, fileToDownload, downloadPath); - - if (result.failed()) - { - return result; - } + fileToDownload = createResultFile(baseName, extension); - return OpResult::ok(); + return makeGETRequest(endpoint, fileToDownload, downloadPath); } // File download version @@ -743,6 +1305,13 @@ class GradioClient : public Client OpResult result = makeGETRequestStream(endpoint, stream, errorPath, timeoutMs); + // Nothing was opened to copy from (e.g., the request failed or was aborted), so + // report why rather than reading a null stream + if (result.failed()) + { + return result; + } + // Remove file at target path if one already exists fileToDownload.deleteFile(); @@ -764,15 +1333,22 @@ class GradioClient : public Client OpResult makeRequest(const String modelPath, const String requestType, const String body, - String& response) + String& response, + const int timeoutMs = controlsTimeoutMs) { - URL endpoint = URL(inferEndpointPath(modelPath)) - .getChildURL("gradio_api") - .getChildURL("call") - .getChildURL(requestType); - - OpResult result = makePOSTRequest( - endpoint, getJSONHeaders(), body, response, inferDocumentationPath(modelPath)); + const URL callEndpoint = URL(inferEndpointPath(modelPath)) + .getChildURL("gradio_api") + .getChildURL("call") + .getChildURL(requestType); + + OpResult result = makePOSTRequest(callEndpoint, + getJSONHeaders(acceptsAPIKey(callEndpoint)), + body, + response, + inferDocumentationPath(modelPath), + File(), + 10000, + modelPath); if (result.failed()) { @@ -797,28 +1373,20 @@ class GradioClient : public Client eventKey.toString() }); } - String eventID = responseDict->getProperty(eventKey); + const String eventID = responseDict->getProperty(eventKey); - DBG_AND_LOG("GradioClient::queryControls: Process created with ID \"" << eventID << "\"."); - - endpoint = URL(inferEndpointPath(modelPath)) - .getChildURL("gradio_api") - .getChildURL("call") - .getChildURL(requestType) - .getChildURL(eventID); + DBG_AND_LOG("GradioClient::makeRequest: Created " << requestType << " event \"" << eventID + << "\"."); response.clear(); - /* WARNING: it's very important to give Gradio enough time to yield a response - (10 seconds was too little for ZeroGPU spaces and led to stream == nullptr) */ - result = makeGETRequest(endpoint, response, inferDocumentationPath(modelPath), 120000); - - if (result.failed()) - { - return result; - } - - return OpResult::ok(); + /* Gradio needs enough time to yield a response (10 seconds was too little for + ZeroGPU Spaces and left the stream empty) */ + return makeGETRequest(callEndpoint.getChildURL(eventID), + response, + inferDocumentationPath(modelPath), + timeoutMs, + modelPath); } OpResult extractLabels(DynamicObject::Ptr& output, LabelList& labels) @@ -838,6 +1406,14 @@ class GradioClient : public Client { DynamicObject* labelObject = labelObjects->getReference(i).getDynamicObject(); + if (labelObject == nullptr) + { + DBG_AND_LOG( + "GradioClient::extractLabels: Ignoring label that is not a dictionary."); + + continue; + } + String labelType = labelObject->getProperty("label_type").toString(); std::unique_ptr<OutputLabel> label; @@ -909,8 +1485,11 @@ class GradioClient : public Client } else { - // Unsupported label type received - jassertfalse; + // Possibly from a newer pyharp, so it is left out rather than failing + DBG_AND_LOG("GradioClient::extractLabels: Ignoring label of unknown type \"" + << labelType << "\"."); + + continue; } labelLogMessage += "\" with time \""; @@ -972,7 +1551,7 @@ class GradioClient : public Client if (labelObject->hasProperty("color")) { - // Make sure color was provided as a int + // Make sure color was provided as an int if ((labelObject->getProperty("color").isInt64() || labelObject->getProperty("color").isInt())) { @@ -999,4 +1578,14 @@ class GradioClient : public Client return OpResult::ok(); } + + /* How long a request may go without meaningful traffic before the transfer is + abandoned. Gradio only emits a heartbeat every 15 seconds while a model runs, + so on the curl backend this acts as a ceiling on total processing time. */ + static constexpr int controlsTimeoutMs = 120000; // 2 minutes + static constexpr int processTimeoutMs = 1800000; // 30 minutes + + // Cached results of isZeroGPUSpace, keyed by model path + CriticalSection zeroGPUCacheLock; + std::map<String, bool> zeroGPUCache; }; diff --git a/src/clients/providers/stability/StabilityClient.h b/src/clients/providers/stability/StabilityClient.h index 15f66c39..958e4f22 100644 --- a/src/clients/providers/stability/StabilityClient.h +++ b/src/clients/providers/stability/StabilityClient.h @@ -1,7 +1,7 @@ /** * @file StabilityClient.h * @brief Client specifics for Stability AI (multipart requests). - * @author xribene, huiranyu, lindseydeng, cwitkowitz + * @author cwitkowitz, xribene, huiranyu, lindseydeng */ #pragma once @@ -78,6 +78,12 @@ class StabilityClient : public Client return isValidTextToAudioPath(modelPath) || isValidAudioToAudioPath(modelPath); } + // Every model this provider offers, each with built-in controls + static StringArray getModelPaths() + { + return { "stability/text-to-audio", "stability/audio-to-audio" }; + } + String inferHostSlashModel(String modelPath) override { String hostSlashModel; @@ -113,7 +119,7 @@ class StabilityClient : public Client } else { - DBG_AND_LOG("StabilityClient::inferEndpointURL: Path \"" + DBG_AND_LOG("StabilityClient::inferEndpointPath: Path \"" << modelPath << "\" does not match valid specification for Stability AI."); } @@ -138,14 +144,14 @@ class StabilityClient : public Client } else { - DBG_AND_LOG("StabilityClient::inferDocumentationURL: Path \"" + DBG_AND_LOG("StabilityClient::inferDocumentationPath: Path \"" << modelPath << "\" does not match valid specification for Stability AI."); } return documentationPath; } - OpResult queryControls(String modelPath, DynamicObject::Ptr& controls) + OpResult queryControls(String modelPath, DynamicObject::Ptr& controls) override { const char* jsonData; int jsonDataSize = 0; @@ -168,11 +174,11 @@ class StabilityClient : public Client } else { - ClientError error { ClientError::Type::InvalidModelPath, modelPath, "Stability AI" }; + OpResult result = fail(ClientError { ClientError::Type::InvalidModelPath, modelPath }); - DBG_AND_LOG("StabilityClient::queryControls: " << toUserMessage(error)); + DBG_AND_LOG("StabilityClient::queryControls: " << toLogString(result.getError())); - return OpResult::fail(error); + return result; } String queryResponse = String::fromUTF8(jsonData, jsonDataSize); @@ -183,14 +189,7 @@ class StabilityClient : public Client } // Extract model metadata, inputs, controls, and outputs - OpResult result = stringJSONToDict(queryResponse, controls); - - if (result.failed()) - { - return result; - } - - return OpResult::ok(); + return stringJSONToDict(queryResponse, controls); } var wrapPayloadElement(var payloadElement, bool isFile = false, String label = "") override @@ -199,7 +198,7 @@ class StabilityClient : public Client String controlLabel = label.isNotEmpty() ? label : "control"; - // Use "input" label for audio file — required for Stability AI + // Stability AI requires the "input" label for the audio file const String payloadLabel = isFile ? "input" : controlLabel; wrappedPayloadElement->setProperty("label", payloadLabel); @@ -211,7 +210,7 @@ class StabilityClient : public Client OpResult process(String modelPath, String& payloadJSON, std::vector<File>& outputFiles, - LabelList& labels) + LabelList& labels) override { DynamicObject::Ptr dataDict; @@ -279,11 +278,7 @@ class StabilityClient : public Client const String extension = getValue(offsetIdx + 3).isNotEmpty() ? "." + getValue(offsetIdx + 3) : ".wav"; - // Obtain local temporary directory for downloaded file - File tempDir = File::getSpecialLocation(File::tempDirectory); - - // Create file at a unique path - File outputFile = tempDir.getChildFile("stable-audio_" + Uuid().toString() + extension); + File outputFile = createResultFile("stable-audio-2", extension); result = makePOSTRequest(URL(inferEndpointPath(modelPath)), request, @@ -372,16 +367,13 @@ class StabilityClient : public Client DBG_AND_LOG( "StabilityClient::makePOSTRequest: Attempting to make POST request for endpoint \"" - + endpoint.toString(true) + "\" with headers \"" - + toPrintableHeaders(headers) - //+ "\" and body \"" + body.toString() - + "\"."); + + endpoint.toString(true) + "\" with headers \"" + toPrintableHeaders(headers) + "\"."); endpoint = endpoint.withPOSTData(body); if (! endpoint.isWellFormed()) { - return OpResult::fail( + return fail( HttpError { HttpError::Type::InvalidURL, HttpError::Request::POST, errorPath }); } @@ -396,11 +388,11 @@ class StabilityClient : public Client .withNumRedirectsToFollow(5) .withHttpRequestCmd("POST"); - std::unique_ptr<InputStream> stream(endpoint.createInputStream(options)); + std::unique_ptr<InputStream> stream(createRequestStream(endpoint, options)); if (stream == nullptr) { - return OpResult::fail(HttpError { + return fail(HttpError { HttpError::Type::ConnectionFailed, HttpError::Request::POST, errorPath }); } @@ -414,17 +406,16 @@ class StabilityClient : public Client if (response.contains("authorization")) { - return OpResult::fail( - ClientError { ClientError::Type::InsufficientPermissions, "", "Stability AI" }); - } - // TODO - could potentially identify other errors (e.g., copyrighted material) - else - { - return OpResult::fail(HttpError { HttpError::Type::BadStatusCode, - HttpError::Request::POST, - errorPath, - statusCode }); + return fail(ClientError { ClientError::Type::InsufficientPermissions }); } + + return fail(HttpError { HttpError::Type::BadStatusCode, + HttpError::Request::POST, + errorPath, + statusCode, + statusCode == 402 + ? "Stability AI reported insufficient usage credits." + : String() }); } // Remove file at target path if one already exists @@ -445,7 +436,7 @@ class StabilityClient : public Client return OpResult::ok(); } - String getMimeForAudioFile(const File& f) + static String getMimeForAudioFile(const File& f) { static const std::unordered_map<String, String> mimeLookup { { ".wav", "audio/wav" }, { ".wave", "audio/wav" }, diff --git a/src/clients/providers/stability/models/audio-to-audio.json b/src/clients/providers/stability/models/audio-to-audio.json index d46a92d0..897ad26e 100644 --- a/src/clients/providers/stability/models/audio-to-audio.json +++ b/src/clients/providers/stability/models/audio-to-audio.json @@ -1,69 +1,82 @@ { - "card": { - "name": "Stable Audio 2.0 (Audio to Audio)", - "description": "Enter text prompt to modify an audio file.", - "author": "Stability AI", - "tags": [ - "audio-to-audio", - "stability" + "card": { + "name": "Stable Audio 2.0 (Audio to Audio)", + "description": "Transforms an audio file to follow a text prompt with Stability AI's Stable Audio 2.0. Requires a Stability AI API key, added under Settings > API Keys, and each request spends credits from that account.", + "author": "Stability AI", + "tags": [ + "category:generation", + "subcategory:editing", + "input:audio", + "input:text", + "output:audio/wav|mp3", + "sample-rate:44100", + "channels:stereo", + "license:other", + "stable-audio", + "stability" + ] + }, + "inputs": [ + { + "label": "Input Audio", + "required": true, + "type": "audio_track" + }, + { + "label": "Duration (s)", + "info": "Length of the generated audio, in seconds.", + "minimum": 1, + "maximum": 190, + "step": 1, + "value": 10, + "type": "slider" + }, + { + "label": "Steps", + "info": "Number of diffusion steps. Higher values improve quality but take longer to process.", + "minimum": 30, + "maximum": 100, + "step": 1, + "value": 50, + "type": "slider" + }, + { + "label": "Guidance Scale", + "info": "Classifier-free guidance (cfg_scale). Higher values make the output follow the prompt more strictly.", + "minimum": 1, + "maximum": 25, + "step": 1, + "value": 7, + "type": "slider" + }, + { + "label": "Output Format", + "info": "File format of the generated audio.", + "choices": [ + [ + "wav", + "wav" + ], + [ + "mp3", + "mp3" ] + ], + "value": "wav", + "type": "dropdown" }, - "inputs": [ - { - "label": "Input Audio", - "required": true, - "type": "audio_track" - }, - { - "label": "Duration (s)", - "minimum": 1, - "maximum": 190, - "step": 1, - "value": 10, - "type": "slider" - }, - { - "label": "steps", - "minimum": 30, - "maximum": 100, - "step": 1, - "value": 50, - "type": "slider" - }, - { - "label": "cfg", - "minimum": 1, - "maximum": 25, - "step": 1, - "value": 7, - "type": "slider" - }, - { - "label": "Output Format", - "choices": [ - [ - "wav", - "wav" - ], - [ - "mp3", - "mp3" - ] - ], - "value": "wav", - "type": "dropdown" - }, - { - "label": "Text Prompt", - "value": "A song in the 3/4 time signature that features cheerful acoustic guitar, live recorded drums, and rhythmic claps, The mood is happy and up-lifting.", - "type": "text_box" - } - ], - "outputs": [ - { - "label": "Output Audio", - "required": true, - "type": "audio_track" - } - ] -} \ No newline at end of file + { + "label": "Text Prompt", + "info": "Description of how to transform the input audio.", + "value": "A song in the 3/4 time signature that features cheerful acoustic guitar, live recorded drums, and rhythmic claps, The mood is happy and up-lifting.", + "type": "text_box" + } + ], + "outputs": [ + { + "label": "Transformed Audio", + "required": true, + "type": "audio_track" + } + ] +} diff --git a/src/clients/providers/stability/models/text-to-audio.json b/src/clients/providers/stability/models/text-to-audio.json index 49f44d01..3f99d834 100644 --- a/src/clients/providers/stability/models/text-to-audio.json +++ b/src/clients/providers/stability/models/text-to-audio.json @@ -1,64 +1,76 @@ { - "card": { - "name": "Stable Audio 2.0 (Text to Audio)", - "description": "Enter text prompts to generate 44.1khz audio.", - "author": "Stability AI", - "tags": [ - "text-to-audio", - "stability" + "card": { + "name": "Stable Audio 2.0 (Text to Audio)", + "description": "Generates 44.1 kHz stereo audio from a text prompt with Stability AI's Stable Audio 2.0. Requires a Stability AI API key, added under Settings > API Keys, and each request spends credits from that account.", + "author": "Stability AI", + "tags": [ + "category:generation", + "subcategory:holistic", + "input:text", + "output:audio/wav|mp3", + "sample-rate:44100", + "channels:stereo", + "license:other", + "stable-audio", + "stability" + ] + }, + "inputs": [ + { + "label": "Duration (s)", + "info": "Length of the generated audio, in seconds.", + "minimum": 1, + "maximum": 190, + "step": 1, + "value": 10, + "type": "slider" + }, + { + "label": "Steps", + "info": "Number of diffusion steps. Higher values improve quality but take longer to process.", + "minimum": 30, + "maximum": 100, + "step": 1, + "value": 50, + "type": "slider" + }, + { + "label": "Guidance Scale", + "info": "Classifier-free guidance (cfg_scale). Higher values make the output follow the prompt more strictly.", + "minimum": 1, + "maximum": 25, + "step": 1, + "value": 7, + "type": "slider" + }, + { + "label": "Output Format", + "info": "File format of the generated audio.", + "choices": [ + [ + "wav", + "wav" + ], + [ + "mp3", + "mp3" ] + ], + "value": "wav", + "type": "dropdown" }, - "inputs": [ - { - "label": "Duration (s)", - "minimum": 1, - "maximum": 190, - "step": 1, - "value": 10, - "type": "slider" - }, - { - "label": "steps", - "minimum": 30, - "maximum": 100, - "step": 1, - "value": 50, - "type": "slider" - }, - { - "label": "cfg", - "minimum": 1, - "maximum": 25, - "step": 1, - "value": 7, - "type": "slider" - }, - { - "label": "Output Format", - "choices": [ - [ - "wav", - "wav" - ], - [ - "mp3", - "mp3" - ] - ], - "value": "wav", - "type": "dropdown" - }, - { - "label": "Text Prompt", - "value": "A song in the 3/4 time signature that features cheerful acoustic guitar, live recorded drums, and rhythmic claps, The mood is happy and up-lifting.", - "type": "text_box" - } - ], - "outputs": [ - { - "label": "Output Audio", - "required": true, - "type": "audio_track" - } - ] -} \ No newline at end of file + { + "label": "Text Prompt", + "info": "Description of the audio to generate.", + "value": "A song in the 3/4 time signature that features cheerful acoustic guitar, live recorded drums, and rhythmic claps, The mood is happy and up-lifting.", + "type": "text_box" + } + ], + "outputs": [ + { + "label": "Generated Audio", + "required": true, + "type": "audio_track" + } + ] +} diff --git a/src/gui/ComboBoxWithLabel.h b/src/gui/ComboBoxWithLabel.h index 257a60aa..8eed71e2 100644 --- a/src/gui/ComboBoxWithLabel.h +++ b/src/gui/ComboBoxWithLabel.h @@ -1,7 +1,7 @@ /** * @file ComboBoxWithLabel.h * @brief Custom dropdown component with label. - * @author xribene + * @author cwitkowitz, saumya-pailwan, xribene */ #pragma once @@ -33,6 +33,10 @@ class ComboBoxWithLabel : public ControlComponent void setMinimumContentWidth(int width) { minimumContentWidth = jmax(0, width); } + int getPreferredWidth() const override { return preferredComboWidth; } + + int getPreferredHeight() const override { return preferredComboHeight; } + int getMinimumRequiredWidth() const override { const int labelWidth = getLabelWidth(label); @@ -43,6 +47,9 @@ class ComboBoxWithLabel : public ControlComponent ComboBox& getComboBox() { return comboBox; } private: + static constexpr int preferredComboWidth = 140; + static constexpr int preferredComboHeight = 44; + static constexpr int minComboWidth = 80; static constexpr int comboChromeWidth = 54; diff --git a/src/gui/ControlComponent.h b/src/gui/ControlComponent.h index 32619aa6..c68117c6 100644 --- a/src/gui/ControlComponent.h +++ b/src/gui/ControlComponent.h @@ -1,25 +1,40 @@ /** * @file ControlComponent.h * @brief Base class for control components with label and minimum size support. - * @author gemini + * @author saumya-pailwan, cwitkowitz */ #pragma once #include <juce_gui_basics/juce_gui_basics.h> +#include "Palette.h" + using namespace juce; /** * Abstract base class for control components that display a label. - * Provides common functionality for calculating minimum required width - * based on label text. + * + * Each control owns its own sizing: the area laying them out asks for these + * metrics rather than keeping a table of its own, so a control's appearance is + * described in exactly one place. */ class ControlComponent : public Component { public: virtual ~ControlComponent() = default; + /** + * Returns the width this control should be given when there is room for it. + * The layout falls back to getMinimumRequiredWidth() when space is tight. + */ + virtual int getPreferredWidth() const = 0; + + /** + * Returns the height this control needs in order to lay itself out. + */ + virtual int getPreferredHeight() const = 0; + /** * Returns the minimum width required to display this control properly. * This includes the label width plus any necessary padding. @@ -35,9 +50,8 @@ class ControlComponent : public Component * @param label The label to measure * @return The width in pixels required to display the label text */ - int getLabelWidth(const Label& label) const + static int getLabelWidth(const Label& label) { - Font font = label.getFont(); - return font.getStringWidth(label.getText()); + return ModelStyle::getTextWidth(label.getFont(), label.getText()); } }; diff --git a/src/gui/FileChooserWithLabel.h b/src/gui/FileChooserWithLabel.h new file mode 100644 index 00000000..ddbec07a --- /dev/null +++ b/src/gui/FileChooserWithLabel.h @@ -0,0 +1,229 @@ +/** + * @file FileChooserWithLabel.h + * @brief Custom file chooser component with label. + * @author cwitkowitz, derekllanes + */ + +#pragma once + +#include "ControlComponent.h" +#include "MultiButton.h" + +using namespace juce; + +class FileChooserWithLabel : public ControlComponent, public FileDragAndDropTarget +{ +public: + explicit FileChooserWithLabel(const String& labelText = {}) + { + label.setText(labelText, dontSendNotification); + label.setJustificationType(Justification::centred); + addAndMakeVisible(label); + + initializeButton(); + addAndMakeVisible(actionButton); + } + + ~FileChooserWithLabel() override { actionButton.setLookAndFeel(nullptr); } + + void resized() override + { + auto area = getLocalBounds(); + + label.setBounds(area.removeFromTop(jmin(labelHeight, area.getHeight()))); + + if (! required) + area.removeFromTop(jmin(bannerHeight, area.getHeight())); + + if (area.isEmpty()) + { + actionButton.setBounds({}); + return; + } + + int buttonSize = jmin(area.getHeight(), area.getWidth()); + actionButton.setBounds(area.removeFromRight(buttonSize)); + } + + void paint(Graphics& g) override + { + auto area = getLocalBounds(); + + // Drawing into a negative-sized rectangle trips a JUCE assertion + if (area.getHeight() <= labelHeight || area.getWidth() <= 0) + { + return; + } + + area.removeFromTop(labelHeight); + + if (! required) + { + auto bannerRect = area.removeFromTop(bannerHeight).toFloat(); + g.setColour(Colour::fromRGB(90, 105, 105)); + g.fillRect(bannerRect); + g.setColour(Colours::white); + g.setFont(ModelStyle::font(12.0f)); + g.drawText("OPTIONAL", bannerRect, Justification::centred, false); + } + + auto body = area.toFloat(); + auto bodyInt = area; + float r = 3.0f; + + auto bg = findColour(ComboBox::backgroundColourId); + auto outline = findColour(ComboBox::outlineColourId); + auto textCol = findColour(ComboBox::textColourId); + + g.setColour(bg); + g.fillRoundedRectangle(body.reduced(0.5f), r); + g.setColour(outline); + g.drawRoundedRectangle(body.reduced(0.5f), r, 1.0f); + + auto textArea = bodyInt.withTrimmedRight(jmin(bodyInt.getHeight(), bodyInt.getWidth())) + .withTrimmedLeft(jmin(4, bodyInt.getWidth())); + + g.setFont(ModelStyle::font(13.0f)); + g.setColour(currentPath.isEmpty() ? textCol.withAlpha(0.4f) : textCol.withAlpha(0.85f)); + g.drawText(currentPath.isEmpty() ? getPlaceholderText() : File(currentPath).getFileName(), + textArea, + Justification::centredLeft, + true); + } + + bool isInterestedInFileDrag(const StringArray&) override { return currentPath.isEmpty(); } + + void filesDropped(const StringArray& files, int, int) override + { + if (! files.isEmpty()) + { + File f(files[0]); + + if (f.existsAsFile()) + setAndNotify(f.getFullPathName()); + } + } + + void setPath(const String& path) + { + currentPath = path; + actionButton.setMode(currentPath.isEmpty() ? chooseFileModeInfo.displayLabel + : removeFileModeInfo.displayLabel); + repaint(); + } + + // Extensions with a leading dot (e.g., ".json"), where none means any file + void setFileTypes(const StringArray& extensions) { fileTypes = extensions; } + + void setRequired(bool req) + { + required = req; + repaint(); + } + + int getPreferredWidth() const override { return minFilePickerWidth; } + + int getPreferredHeight() const override + { + return minFilePickerHeight + (required ? 0 : bannerHeight); + } + + int getMinimumRequiredWidth() const override + { + const int labelWidth = getLabelWidth(label); + return jmax(minFilePickerWidth, labelWidth + defaultPadding); + } + + /** Receives notification when the chosen file is changed by the user. */ + struct Listener + { + virtual ~Listener() = default; + + virtual void fileChooserChanged(FileChooserWithLabel* fileChooser) = 0; + }; + + void addListener(Listener* listener) { listeners.add(listener); } + + void removeListener(Listener* listener) { listeners.remove(listener); } + + const String& getPath() const { return currentPath; } + +private: + void initializeButton() + { + chooseFileModeInfo = MultiButton::Mode { "ChooseFile", + "Click to choose a file.", + [this] { launchFileChooser(); }, + MultiButton::DrawingMode::IconOnly, + Colours::lightblue, + fontawesome::Folder }; + + removeFileModeInfo = MultiButton::Mode { "RemoveFile", + "Click to remove the selected file.", + [this] { clearFile(); }, + MultiButton::DrawingMode::IconOnly, + Colours::orangered, + fontawesome::Remove }; + + actionButton.addMode(chooseFileModeInfo); + actionButton.addMode(removeFileModeInfo); + actionButton.setMode(chooseFileModeInfo.displayLabel); + + actionButton.setLookAndFeel(&noBorderLAF); + } + + String getPlaceholderText() const + { + if (fileTypes.isEmpty()) + return "No file selected"; + + return "No file selected (" + fileTypes.joinIntoString(", ") + ")"; + } + + void clearFile() { setAndNotify({}); } + + void setAndNotify(const String& path) + { + setPath(path); + + listeners.call([this](Listener& l) { l.fileChooserChanged(this); }); + } + + void launchFileChooser() + { + fileChooser = std::make_unique<FileChooser>("Select file", File(), buildWildcardPattern()); + + fileChooser->launchAsync(FileBrowserComponent::openMode + | FileBrowserComponent::canSelectFiles, + [this](const FileChooser& chooser) + { + const File f = chooser.getResult(); + + if (f.existsAsFile()) + setAndNotify(f.getFullPathName()); + }); + } + + String buildWildcardPattern() const + { + return fileTypes.isEmpty() ? "*" : "*" + fileTypes.joinIntoString(";*"); + } + + static constexpr int minFilePickerWidth = 260; + static constexpr int minFilePickerHeight = 50; + static constexpr int labelHeight = 20; + static constexpr int bannerHeight = 14; + + BorderlessButtonLookAndFeel noBorderLAF; + Label label; + MultiButton actionButton; + MultiButton::Mode chooseFileModeInfo; + MultiButton::Mode removeFileModeInfo; + + ListenerList<Listener> listeners; + + String currentPath; + bool required = true; + StringArray fileTypes; + std::unique_ptr<FileChooser> fileChooser; +}; diff --git a/src/gui/HoverHandler.h b/src/gui/HoverHandler.h index 0624bce6..347ffa6a 100644 --- a/src/gui/HoverHandler.h +++ b/src/gui/HoverHandler.h @@ -1,7 +1,7 @@ /** * @file HoverHandler.h * @brief Attach custom event handling functionality to an arbitrary component. - * @author xribene + * @author cwitkowitz, xribene */ #pragma once @@ -10,6 +10,8 @@ #include <juce_gui_basics/juce_gui_basics.h> +using namespace juce; + class HoverHandler : public MouseListener { public: diff --git a/src/gui/HoverableLabel.h b/src/gui/HoverableLabel.h index 78007e0b..8c48948d 100644 --- a/src/gui/HoverableLabel.h +++ b/src/gui/HoverableLabel.h @@ -1,7 +1,7 @@ /** * @file HoverableLabel.h * @brief Custom clickable label that changes color when hovering over the text. - * @author xribene + * @author cwitkowitz, xribene */ #pragma once @@ -10,6 +10,8 @@ #include <juce_gui_basics/juce_gui_basics.h> +using namespace juce; + class HoverableLabel : public Label { public: @@ -21,8 +23,6 @@ class HoverableLabel : public Label setHoverable(true); } - bool isHoverable() { return hoverable; } - void setHoverable(bool h) { hoverable = h; @@ -38,7 +38,7 @@ class HoverableLabel : public Label } /** - * Overriden component function to test if mouse is over text. + * Overridden component function to test if mouse is over text. */ bool hitTest(int x, int y) override { @@ -91,7 +91,7 @@ class HoverableLabel : public Label Label::mouseDown(event); } - void setHoverColour(Colour color) { hoverColor = color; } + void setHoverColor(Colour color) { hoverColor = color; } // Callbacks for mouse events std::function<void()> onHover; @@ -99,23 +99,19 @@ class HoverableLabel : public Label std::function<void()> onClick; private: + /* Where the text is drawn, in this label's own coordinates (which are what hitTest is + given), allowing for its border and justification */ Rectangle<int> getTextBounds() const { - Font f = getFont(); - - int textWidth = f.getStringWidth(getText()); - int textHeight = f.getHeight(); - - float x_offset = (getBounds().getWidth() - textWidth) / 2; - float y_offset = (getBounds().getHeight() - textHeight) / 2; + const Font f = getFont(); + const Rectangle<int> textSize(GlyphArrangement::getStringWidthInt(f, getText()), + roundToInt(f.getHeight())); - return Rectangle<int>(getX() + static_cast<int>(x_offset), - getY() + static_cast<int>(y_offset), - static_cast<int>(textWidth), - static_cast<int>(textHeight)); + return getJustificationType().appliedToRectangle( + textSize, getBorderSize().subtractedFrom(getLocalBounds())); } - bool hoverable; + bool hoverable = true; Colour originalTextColor; Colour hoverColor = Colours::blue; diff --git a/src/gui/MultiButton.h b/src/gui/MultiButton.h index 9db0705b..022c5592 100644 --- a/src/gui/MultiButton.h +++ b/src/gui/MultiButton.h @@ -1,10 +1,10 @@ /** * @file MultiButton.h * @brief Flexible button component that supports multiple modes, hover instructions, and icons. - * @author xribene - * - * The MultiButton class extends the JUCE TextButton to provide a button that can switch between - * multiple modes. Each mode can have its own label, callback function, color, icon, and hover + * @author cwitkowitz, xribene + * + * The MultiButton class extends the JUCE TextButton to provide a button that can switch between + * multiple modes. Each mode can have its own label, callback function, color, icon, and hover * message. The button can display text or an icon, depending on the specified drawing mode. * * ### Example: Creating a Play/Stop Button @@ -30,7 +30,7 @@ * }; * playStopButton.addMode(playButtonInfo); * playStopButton.addMode(stopButtonInfo); - * playStopButton.setMode(playButtonInfo.label); + * playStopButton.setMode(playButtonInfo.displayLabel); * addAndMakeVisible(playStopButton); * } * ``` @@ -151,3 +151,9 @@ class MultiButton : public TextButton SharedResourcePointer<InstructionsMessage> instructionsMessage; }; + +// Draws a button without its background, for an icon that stands on its own +struct BorderlessButtonLookAndFeel : public LookAndFeel_V4 +{ + void drawButtonBackground(Graphics&, Button&, const Colour&, bool, bool) override {} +}; diff --git a/src/gui/MultiSelectWithLabel.h b/src/gui/MultiSelectWithLabel.h new file mode 100644 index 00000000..da01d8e2 --- /dev/null +++ b/src/gui/MultiSelectWithLabel.h @@ -0,0 +1,198 @@ +/** + * @file MultiSelectWithLabel.h + * @brief Custom multiple-selection dropdown component with label. + * @author cwitkowitz + */ + +#pragma once + +#include <algorithm> + +#include "ControlComponent.h" + +using namespace juce; + +/** + * JUCE has no multiple-selection combo box, so this presents the options as a + * popup menu of tickable items and summarizes the selection on the button. + */ +class MultiSelectWithLabel : public ControlComponent +{ +public: + MultiSelectWithLabel(const String& labelText = {}) + { + label.setText(labelText, dontSendNotification); + label.setJustificationType(Justification::centred); + + selectionButton.onClick = [this] { showSelectionMenu(); }; + + addAndMakeVisible(label); + addAndMakeVisible(selectionButton); + } + + void resized() override + { + auto area = getLocalBounds(); + + if (area.isEmpty()) + { + return; + } + + label.setBounds(area.removeFromTop(jmin(labelHeight, area.getHeight()))); + selectionButton.setBounds(area); + + // What fits in the button depends on its width, so recompute the summary + updateSelectionText(); + } + + int getPreferredWidth() const override { return preferredSelectionWidth; } + + int getPreferredHeight() const override { return preferredSelectionHeight; } + + int getMinimumRequiredWidth() const override + { + const int labelWidth = getLabelWidth(label); + return jmax(minSelectionWidth, labelWidth + defaultPadding); + } + + /** Receives notification when the selection is changed by the user. */ + struct Listener + { + virtual ~Listener() = default; + + virtual void multiSelectChanged(MultiSelectWithLabel* multiSelect) = 0; + }; + + void addListener(Listener* listener) { listeners.add(listener); } + + void removeListener(Listener* listener) { listeners.remove(listener); } + + void setOptions(const std::vector<String>& newOptions) + { + options = newOptions; + + updateSelectionText(); + } + + /** Sets the selection without notifying listeners, as JUCE controls do. */ + void setSelection(const std::vector<String>& newSelection) + { + selected = newSelection; + + updateSelectionText(); + } + + const std::vector<String>& getSelection() const { return selected; } + + bool isSelected(const String& option) const + { + return std::find(selected.begin(), selected.end(), option) != selected.end(); + } + + TextButton& getSelectionButton() { return selectionButton; } + +private: + void updateSelectionText() + { + StringArray chosen; + + for (const auto& option : options) + { + if (isSelected(option)) + { + chosen.add(option); + } + } + + const String allSelected = chosen.joinIntoString(", "); + + if (chosen.isEmpty()) + { + selectionButton.setButtonText("None selected"); + } + else + { + /* Name the choices rather than counting them, falling back to a count + only when the list cannot fit in the button */ + const Font font = selectionButton.getLookAndFeel().getTextButtonFont( + selectionButton, selectionButton.getHeight()); + + const int available = selectionButton.getWidth() - textPadding; + + if (available <= 0 || ModelStyle::getTextWidth(font, allSelected) <= available) + { + selectionButton.setButtonText(allSelected); + } + else + { + selectionButton.setButtonText(String(chosen.size()) + " selected"); + } + } + + selectionButton.setTooltip(allSelected); + } + + void showSelectionMenu() + { + PopupMenu menu; + + for (int i = 0; i < (int) options.size(); ++i) + { + const auto& option = options[(size_t) i]; + const bool ticked = isSelected(option); + + // Item IDs are 1-based, so that 0 can signal a dismissed menu + menu.addItem(i + 1, option, true, ticked); + } + + // The menu outlives this component if the tab is torn down while it is open + Component::SafePointer<MultiSelectWithLabel> safeThis(this); + + menu.showMenuAsync(PopupMenu::Options() + .withTargetComponent(&selectionButton) + .withMinimumWidth(selectionButton.getWidth()), + [safeThis](int result) + { + if (safeThis == nullptr || result == 0) + { + return; + } + + safeThis->toggle(safeThis->options[(size_t) (result - 1)]); + }); + } + + void toggle(const String& option) + { + auto existing = std::find(selected.begin(), selected.end(), option); + + if (existing == selected.end()) + { + selected.push_back(option); + } + else + { + selected.erase(existing); + } + + updateSelectionText(); + + listeners.call([this](Listener& l) { l.multiSelectChanged(this); }); + } + + static constexpr int preferredSelectionWidth = 140; + static constexpr int preferredSelectionHeight = 44; + + static constexpr int minSelectionWidth = 120; + static constexpr int labelHeight = 20; + static constexpr int textPadding = 16; + + std::vector<String> options; + std::vector<String> selected; + + ListenerList<Listener> listeners; + + Label label; + TextButton selectionButton; +}; diff --git a/src/gui/NumberBoxWithLabel.h b/src/gui/NumberBoxWithLabel.h new file mode 100644 index 00000000..ba0ec7b0 --- /dev/null +++ b/src/gui/NumberBoxWithLabel.h @@ -0,0 +1,73 @@ +/** + * @file NumberBoxWithLabel.h + * @brief Custom number box component with label. + * @author cwitkowitz, saumya-pailwan + */ + +#pragma once + +#include "ControlComponent.h" + +using namespace juce; + +class NumberBoxWithLabel : public ControlComponent +{ +public: + NumberBoxWithLabel(const String& labelText) + : numberBox(Slider::IncDecButtons, Slider::TextBoxLeft) + { + label.setText(labelText, dontSendNotification); + label.setJustificationType(Justification::centred); + + numberBox.setIncDecButtonsMode(Slider::incDecButtonsDraggable_Vertical); + + // Fixing the text box size keeps the buttons from growing taller than it + numberBox.setTextBoxStyle(Slider::TextBoxLeft, false, textBoxWidth, rowHeight); + + addAndMakeVisible(label); + addAndMakeVisible(numberBox); + } + + void resized() override + { + auto area = getLocalBounds(); + + if (area.isEmpty()) + { + return; + } + + label.setBounds(area.removeFromTop(jmin(labelHeight, area.getHeight()))); + + /* JUCE sizes the increment / decrement buttons to fill whatever is left of + the slider, so the slider is limited to one row and centered instead of + being given the whole remaining area. */ + int boxHeight = jmin(rowHeight, area.getHeight()); + + numberBox.setBounds(area.withSizeKeepingCentre(area.getWidth(), boxHeight)); + } + + int getPreferredWidth() const override { return preferredNumberBoxWidth; } + + int getPreferredHeight() const override { return preferredNumberBoxHeight; } + + int getMinimumRequiredWidth() const override + { + const int labelWidth = getLabelWidth(label); + return jmax(minNumberBoxBody, labelWidth + defaultPadding); + } + + Slider& getNumberBox() { return numberBox; } + +private: + static constexpr int preferredNumberBoxWidth = 140; + static constexpr int preferredNumberBoxHeight = 56; + + static constexpr int minNumberBoxBody = 100; + static constexpr int labelHeight = 20; + static constexpr int rowHeight = 24; + static constexpr int textBoxWidth = 64; + + Label label; + Slider numberBox; +}; diff --git a/src/gui/Palette.h b/src/gui/Palette.h new file mode 100644 index 00000000..409cbfd7 --- /dev/null +++ b/src/gui/Palette.h @@ -0,0 +1,59 @@ +/** + * @file Palette.h + * @brief Colours and fonts of HARP's shared look, apart from the parts that present models. + * @author cwitkowitz + */ + +#pragma once + +#include <map> +#include <utility> + +#include <juce_gui_basics/juce_gui_basics.h> + +using namespace juce; + +/* This opens the ModelStyle namespace, which widgets/ModelStyle.h continues with cards, + chips, and section headers. The two are kept apart so that components below the + widgets, such as tracks, can share the colours without depending on the catalog. */ +namespace ModelStyle +{ +inline const Colour accent { 0xff2dd4bf }; +inline const Colour accentDark { 0xff0f766e }; +inline const Colour chipBackground { 0xff183238 }; +inline const Colour chipText { 0xff9eeadf }; +inline const Colour problem { 0xfff87171 }; +inline const Colour information { 0xff7dd3fc }; +inline const Colour zeroGPU { 0xffa78bfa }; +/* A license sits with the badges rather than the tags because it answers the + same kind of question they do: not what the model is, but what using it + entails. Amber reads as something to note without reading as an error. */ +inline const Colour license { 0xffe9c894 }; +inline const Colour secondaryText { 0xffa3a3a3 }; +// Outline of a card, or of any panel edge, when it is not highlighted +inline const Colour outline = Colours::white.withAlpha(0.12f); + +inline Font font(float height, bool bold = false) +{ + return Font(FontOptions(height, bold ? Font::bold : Font::plain)); +} + +/* Measures text once for each font and string. JUCE shapes the text on every measurement, + which is slow enough to show in paint() and resized(), where most of these are taken. + Only the message thread measures text, so the cache needs no lock. */ +inline int getTextWidth(const Font& f, const String& text) +{ + static std::map<std::pair<String, String>, int> widths; + + const auto key = std::make_pair(f.toString(), text); + + auto it = widths.find(key); + + if (it == widths.end()) + { + it = widths.emplace(key, GlyphArrangement::getStringWidthInt(f, text)).first; + } + + return it->second; +} +} // namespace ModelStyle diff --git a/src/gui/ResizeEdge.h b/src/gui/ResizeEdge.h new file mode 100644 index 00000000..0ceddc0b --- /dev/null +++ b/src/gui/ResizeEdge.h @@ -0,0 +1,92 @@ +/** + * @file ResizeEdge.h + * @brief Strip along the edge of a panel that is dragged to resize it. + * @author cwitkowitz, NatalieElizabeth + */ + +#pragma once + +#include <functional> + +#include <juce_gui_basics/juce_gui_basics.h> + +#include "Palette.h" + +using namespace juce; + +/** + * Strip along the left or top edge of a panel that is dragged to resize it. It lights up in + * the accent colour while hovered, as a card's outline does. + */ +class ResizeEdge : public Component +{ +public: + enum class Side + { + Left, + Top + }; + + explicit ResizeEdge(Side edgeSide) : side(edgeSide) + { + setMouseCursor(side == Side::Left ? MouseCursor::LeftRightResizeCursor + : MouseCursor::UpDownResizeCursor); + } + + // Called once when a drag begins + std::function<void()> onDragStart; + // Called throughout a drag with how far right (or down) the mouse is from where it began + std::function<void(int)> onDrag; + // Called once when a drag ends, which is when a size worth keeping is known + std::function<void()> onDragEnd; + + void mouseDown(const MouseEvent&) override + { + if (onDragStart) + { + onDragStart(); + } + } + + void mouseDrag(const MouseEvent& e) override + { + if (onDrag) + { + onDrag(side == Side::Left ? e.getScreenX() - e.getMouseDownScreenX() + : e.getScreenY() - e.getMouseDownScreenY()); + } + } + + void mouseUp(const MouseEvent& e) override + { + repaint(); + + if (e.mouseWasDraggedSinceMouseDown() && onDragEnd) + { + onDragEnd(); + } + } + + void mouseEnter(const MouseEvent&) override { repaint(); } + void mouseExit(const MouseEvent&) override { repaint(); } + + void paint(Graphics& g) override + { + const bool isActive = isMouseOverOrDragging(); + + // A left edge also divides its panel from the one beside it, so it shows while idle + if (side == Side::Left) + { + g.setColour(isActive ? ModelStyle::accent.withAlpha(0.6f) : ModelStyle::outline); + g.fillRect(getLocalBounds().removeFromLeft(isActive ? 2 : 1)); + } + else if (isActive) + { + g.setColour(ModelStyle::accent.withAlpha(0.6f)); + g.fillRect(getLocalBounds().removeFromTop(2)); + } + } + +private: + const Side side; +}; diff --git a/src/gui/RoundIconButton.h b/src/gui/RoundIconButton.h new file mode 100644 index 00000000..503b4bcc --- /dev/null +++ b/src/gui/RoundIconButton.h @@ -0,0 +1,119 @@ +/** + * @file RoundIconButton.h + * @brief Small round buttons drawn from strokes, for closing, removing, or minimizing + * whatever they sit on. + * @author cwitkowitz, 2cylu2 + */ + +#pragma once + +#include <juce_gui_basics/juce_gui_basics.h> + +#include "../utils/Messages.h" + +using namespace juce; + +/** + * Highlights a circle behind its icon while hovered, and explains itself in the + * instructions box, since an icon alone does not say what it closes or removes. + */ +class RoundIconButton : public Button +{ +public: + explicit RoundIconButton(const String& instructionsText) + : Button("RoundIcon"), instructions(instructionsText) + { + } + + void setInstructions(const String& newInstructions) + { + instructions = newInstructions; + + if (isMouseOver()) + instructionsMessage->setMessage(instructions); + } + + void paintButton(Graphics& g, bool isHighlighted, bool isDown) override + { + // Square whatever this is given, so that the circle and icon are never stretched + const float side = (float) jmin(getWidth(), getHeight()); + const auto bounds = + getLocalBounds().toFloat().withSizeKeepingCentre(side, side).reduced(1.0f); + + if (isHighlighted || isDown) + { + g.setColour(Colours::white.withAlpha(isDown ? 0.2f : 0.12f)); + g.fillEllipse(bounds); + } + + g.setColour(isHighlighted ? Colours::white : Colours::lightgrey); + drawIcon(g, bounds.reduced(bounds.getWidth() * 0.32f)); + } + + void mouseEnter(const MouseEvent& e) override + { + Button::mouseEnter(e); + instructionsMessage->setMessage(instructions); + } + + void mouseExit(const MouseEvent& e) override + { + Button::mouseExit(e); + instructionsMessage->clearMessage(); + } + +protected: + // Strokes, which sit centred where a glyph might not, in the current colour + virtual void drawIcon(Graphics& g, Rectangle<float> area) = 0; + + static constexpr float strokeWidth = 1.5f; + +private: + String instructions; + + SharedResourcePointer<InstructionsMessage> instructionsMessage; +}; + +class CloseButton : public RoundIconButton +{ +public: + using RoundIconButton::RoundIconButton; + +private: + void drawIcon(Graphics& g, Rectangle<float> area) override + { + g.drawLine({ area.getTopLeft(), area.getBottomRight() }, strokeWidth); + g.drawLine({ area.getBottomLeft(), area.getTopRight() }, strokeWidth); + } +}; + +class ChevronButton : public RoundIconButton +{ +public: + using RoundIconButton::RoundIconButton; + + void setPointingUp(bool shouldPointUp) + { + pointingUp = shouldPointUp; + repaint(); + } + +private: + void drawIcon(Graphics& g, Rectangle<float> area) override + { + // Half as tall as it is wide, centred in the area + const float halfHeight = area.getWidth() / 4.0f; + const float tipY = area.getCentreY() + (pointingUp ? -halfHeight : halfHeight); + const float endY = area.getCentreY() + (pointingUp ? halfHeight : -halfHeight); + + Path chevron; + chevron.startNewSubPath(area.getX(), endY); + chevron.lineTo(area.getCentreX(), tipY); + chevron.lineTo(area.getRight(), endY); + + g.strokePath(chevron, + PathStrokeType(strokeWidth, PathStrokeType::curved, PathStrokeType::rounded)); + } + + bool pointingUp = false; +}; diff --git a/src/gui/SliderWithLabel.h b/src/gui/SliderWithLabel.h index 6c913eed..3d2664d8 100644 --- a/src/gui/SliderWithLabel.h +++ b/src/gui/SliderWithLabel.h @@ -1,7 +1,7 @@ /** * @file SliderWithLabel.h * @brief Custom slider component with label. - * @author xribene + * @author cwitkowitz, xribene, saumya-pailwan */ #pragma once @@ -34,6 +34,10 @@ class SliderWithLabel : public ControlComponent slider.setBounds(sliderArea); } + int getPreferredWidth() const override { return preferredSliderWidth; } + + int getPreferredHeight() const override { return preferredSliderHeight; } + int getMinimumRequiredWidth() const override { const int labelWidth = getLabelWidth(label); @@ -43,6 +47,9 @@ class SliderWithLabel : public ControlComponent Slider& getSlider() { return slider; } private: + static constexpr int preferredSliderWidth = 108; + static constexpr int preferredSliderHeight = 108; + static constexpr int minSliderBody = 60; Label label; diff --git a/src/gui/TextBoxWithLabel.h b/src/gui/TextBoxWithLabel.h index 507781a1..495bbabd 100644 --- a/src/gui/TextBoxWithLabel.h +++ b/src/gui/TextBoxWithLabel.h @@ -1,7 +1,7 @@ /** * @file TextBoxWithLabel.h * @brief Custom text box component with label. - * @author xribene + * @author cwitkowitz, saumya-pailwan, xribene */ #pragma once @@ -16,6 +16,7 @@ class TextBoxWithLabel : public ControlComponent TextBoxWithLabel(const String& labelText) { label.setText(labelText, dontSendNotification); + label.setJustificationType(Justification::centred); textBox.setMultiLine(true, true); textBox.setReadOnly(false); @@ -45,6 +46,10 @@ class TextBoxWithLabel : public ControlComponent textBox.setBounds(textBoxArea); } + int getPreferredWidth() const override { return preferredTextBoxWidth; } + + int getPreferredHeight() const override { return preferredTextBoxHeight; } + int getMinimumRequiredWidth() const override { const int labelWidth = getLabelWidth(label); @@ -56,6 +61,9 @@ class TextBoxWithLabel : public ControlComponent void setText(const String& text) { textBox.setText(text, dontSendNotification); } private: + static constexpr int preferredTextBoxWidth = 200; + static constexpr int preferredTextBoxHeight = 84; + static constexpr int minTextBoxWidth = 120; Label label; diff --git a/src/gui/ToggleWithLabel.h b/src/gui/ToggleWithLabel.h index 79176434..29283528 100644 --- a/src/gui/ToggleWithLabel.h +++ b/src/gui/ToggleWithLabel.h @@ -1,7 +1,7 @@ /** * @file ToggleWithLabel.h * @brief Custom toggle button component with label. - * @author gemini + * @author saumya-pailwan */ #pragma once @@ -21,9 +21,14 @@ class ToggleWithLabel : public ControlComponent void resized() override { toggleButton.setBounds(getLocalBounds()); } + int getPreferredWidth() const override { return preferredToggleWidth; } + + int getPreferredHeight() const override { return preferredToggleHeight; } + int getMinimumRequiredWidth() const override { - const int textWidth = Font().getStringWidth(toggleButton.getButtonText()); + const int textWidth = + ModelStyle::getTextWidth(Font(FontOptions()), toggleButton.getButtonText()); return jmax(minToggleWidth, textWidth + defaultPadding + checkboxWidthPadding); } @@ -37,6 +42,9 @@ class ToggleWithLabel : public ControlComponent bool getToggleState() const { return toggleButton.getToggleState(); } private: + static constexpr int preferredToggleWidth = 112; + static constexpr int preferredToggleHeight = 34; + static constexpr int minToggleWidth = 60; static constexpr int checkboxWidthPadding = 30; diff --git a/src/media/AudioDisplayComponent.cpp b/src/media/AudioDisplayComponent.cpp index c81f3992..6ef42422 100644 --- a/src/media/AudioDisplayComponent.cpp +++ b/src/media/AudioDisplayComponent.cpp @@ -13,8 +13,8 @@ AudioDisplayComponent::AudioDisplayComponent(String name, bool req, bool fromDAW thumbnailComponent.addMouseListener(this, true); contentComponent.addAndMakeVisible(thumbnailComponent); - mediaInstructions = - "Audio waveform.\nClick and drag to start playback from any point in the waveform\nVertical scroll to zoom in/out.\nHorizontal scroll to move the waveform."; + mediaInstructions = "Audio waveform.\nClick or drag to move the playback position.\n" + "Scroll to zoom in time, or hold Shift while scrolling to pan."; } AudioDisplayComponent::~AudioDisplayComponent() @@ -50,27 +50,15 @@ void AudioDisplayComponent::resized() void AudioDisplayComponent::loadMediaFile(const URL& filePath) { - const auto source = std::make_unique<URLInputSource>(filePath); - File audioFile = filePath.getLocalFile(); - if (source == nullptr) - { - DBG_AND_LOG("AudioDisplayComponent::loadMediaFile: File " << audioFile.getFullPathName() - << " does not exist."); - // TODO - better error handing - jassertfalse; - return; - } - - auto stream = rawToUniquePtr(source->createInputStream()); + auto stream = rawToUniquePtr(URLInputSource(filePath).createInputStream()); + // Left empty if the file cannot be read, as a file of silence would be if (stream == nullptr) { DBG_AND_LOG("AudioDisplayComponent::loadMediaFile: Failed to load file " << audioFile.getFullPathName() << "."); - // TODO - better error handing - jassertfalse; return; } @@ -80,8 +68,6 @@ void AudioDisplayComponent::loadMediaFile(const URL& filePath) { DBG_AND_LOG("AudioDisplayComponent::loadMediaFile: Failed to read file " << audioFile.getFullPathName() << "."); - // TODO - better error handing - jassertfalse; return; } @@ -104,9 +90,6 @@ void AudioDisplayComponent::resetMedia() void AudioDisplayComponent::postLoadActions(const URL& filePath) { - if (auto inputSource = std::make_unique<URLInputSource>(filePath)) - { - thumbnailCache.clear(); - thumbnail.setSource(inputSource.release()); - } + thumbnailCache.clear(); + thumbnail.setSource(new URLInputSource(filePath)); } diff --git a/src/media/FileDisplayComponent.cpp b/src/media/FileDisplayComponent.cpp new file mode 100644 index 00000000..40e6561e --- /dev/null +++ b/src/media/FileDisplayComponent.cpp @@ -0,0 +1,117 @@ +#include "FileDisplayComponent.h" + +FileDisplayComponent::FileDisplayComponent() : FileDisplayComponent("File Track") {} + +FileDisplayComponent::FileDisplayComponent(String name, bool req, bool fromDAW, DisplayMode mode) + : MediaDisplayComponent(name, req, fromDAW, mode) +{ + downloadActiveMode = MultiButton::Mode { "Download", + "Click to save the output file.", + [this] { saveFileCallback(); }, + MultiButton::DrawingMode::IconOnly, + Colours::lightblue, + fontawesome::Save }; + downloadInactiveMode = MultiButton::Mode { "Download-Inactive", + "No output file available.", + [] {}, + MultiButton::DrawingMode::IconOnly, + Colours::lightgrey, + fontawesome::Save }; + downloadButton.addMode(downloadActiveMode); + downloadButton.addMode(downloadInactiveMode); + downloadButton.setMode(downloadInactiveMode.displayLabel); + downloadButton.setLookAndFeel(&noBorderLAF); + + addAndMakeVisible(downloadButton); +} + +FileDisplayComponent::~FileDisplayComponent() { downloadButton.setLookAndFeel(nullptr); } + +StringArray FileDisplayComponent::getSupportedExtensions() +{ + return {}; // Empty = accept all extensions +} + +StringArray FileDisplayComponent::getInstanceExtensions() +{ + // Empty to accept any file, unless the model declares the types it returns + return instanceFileTypes; +} + +double FileDisplayComponent::getTotalLengthInSecs() { return 0.0; } + +void FileDisplayComponent::paint(Graphics& g) +{ + auto area = getLocalBounds(); + + // Trimming past the available size yields a negative extent + if (area.getHeight() <= labelHeight || area.getWidth() <= 0) + { + return; + } + + auto body = area.withTrimmedTop(labelHeight).toFloat(); + auto bodyInt = body.toNearestInt(); + float r = 3.0f; + + auto bg = findColour(ComboBox::backgroundColourId); + auto outline = findColour(ComboBox::outlineColourId); + auto textCol = findColour(ComboBox::textColourId); + + // Draw label area + g.setFont(ModelStyle::font(14.0f)); + g.setColour(textCol); + g.drawText( + getTrackName(), getLocalBounds().removeFromTop(labelHeight), Justification::centred, true); + + // Draw body background + g.setColour(bg); + g.fillRoundedRectangle(body.reduced(0.5f), r); + g.setColour(outline); + g.drawRoundedRectangle(body.reduced(0.5f), r, 1.0f); + + // Draw filename text (leaves space for the square button on the right) + // The square button occupies the right of the body, but only if it fits + auto textArea = bodyInt.withTrimmedRight(jmin(bodyInt.getHeight(), bodyInt.getWidth())) + .withTrimmedLeft(jmin(4, bodyInt.getWidth())); + + g.setFont(ModelStyle::font(13.0f)); + g.setColour(isFileLoaded() ? textCol.withAlpha(0.85f) : textCol.withAlpha(0.4f)); + g.drawText(isFileLoaded() ? getOriginalFilePath().getFileName() : "No output file", + textArea, + Justification::centredLeft, + true); +} + +void FileDisplayComponent::resized() +{ + auto area = getLocalBounds(); + + if (area.getHeight() <= labelHeight || area.getWidth() <= 0) + { + downloadButton.setBounds({}); + return; + } + + area = area.withTrimmedTop(labelHeight); + + int buttonSize = jmin(area.getHeight(), area.getWidth()); + downloadButton.setBounds(area.removeFromRight(buttonSize)); +} + +void FileDisplayComponent::loadMediaFile(const URL& /*filePath*/) +{ + downloadButton.setMode(downloadActiveMode.displayLabel); + repaint(); +} + +void FileDisplayComponent::resetMedia() +{ + downloadButton.setMode(downloadInactiveMode.displayLabel); + repaint(); +} + +void FileDisplayComponent::postLoadActions(const URL& /*filePath*/) +{ + // No extra action needed for generic files. +} diff --git a/src/media/FileDisplayComponent.h b/src/media/FileDisplayComponent.h new file mode 100644 index 00000000..2a460e3a --- /dev/null +++ b/src/media/FileDisplayComponent.h @@ -0,0 +1,54 @@ +/** + * @file FileDisplayComponent.h + * @brief Media display for a generic file track (neither audio nor MIDI). + * @author cwitkowitz, derekllanes + */ + +#pragma once + +#include "MediaDisplayComponent.h" + +class FileDisplayComponent : public MediaDisplayComponent +{ +public: + FileDisplayComponent(); + FileDisplayComponent(String name, + bool req = true, + bool fromDAW = false, + DisplayMode mode = DisplayMode::Input); + ~FileDisplayComponent() override; + + static StringArray getSupportedExtensions(); + + StringArray getInstanceExtensions() override; + + int getFixedHeight() const override { return fixedHeight; } + + double getTotalLengthInSecs() override; + + bool hasPlaybackCursor() const override { return false; } + bool usesMouseWheel() const override { return false; } + + void paint(Graphics& g) override; + void resized() override; + + void loadMediaFile(const URL& filePath) override; + void resetMedia() override; + + void postLoadActions(const URL& filePath) override; + + // Extensions with a leading dot (e.g., ".json"), where none means any file + void setInstanceFileTypes(const StringArray& extensions) { instanceFileTypes = extensions; } + +private: + BorderlessButtonLookAndFeel noBorderLAF; + + MultiButton downloadButton; + MultiButton::Mode downloadActiveMode; + MultiButton::Mode downloadInactiveMode; + + StringArray instanceFileTypes; + + static constexpr int fixedHeight = 50; + static constexpr int labelHeight = 20; +}; diff --git a/src/media/MediaDisplayComponent.cpp b/src/media/MediaDisplayComponent.cpp index 91178c12..3b3e33ab 100644 --- a/src/media/MediaDisplayComponent.cpp +++ b/src/media/MediaDisplayComponent.cpp @@ -2,6 +2,225 @@ #include "AudioDisplayComponent.h" #include "MidiDisplayComponent.h" +#include "../utils/Interface.h" + +#include "../windows/tutorial/TutorialTargets.h" + +#include <cmath> +#include <map> + +void OptionalBannerComponent::paint(Graphics& g) +{ + const float cx = static_cast<float>(getWidth()) / 2.0f; + const float cy = static_cast<float>(getHeight()) / 2.0f; + + g.setColour(Colour::fromRGB(90, 105, 105)); + g.fillAll(); + + g.setColour(Colours::white); + g.setFont(12.0f); + + Graphics::ScopedSaveState state(g); + g.addTransform(AffineTransform::rotation(-MathConstants<float>::halfPi, cx, cy)); + + Rectangle<float> textBounds( + 0, 0, static_cast<float>(getHeight()), static_cast<float>(getWidth())); + textBounds.setCentre(cx, cy); + + g.drawText("OPTIONAL", textBounds, Justification::centred, false); +} + +namespace +{ +struct TickScheme +{ + double majorStep; + double minorStep; + int minorCount; +}; + +String formatTime(double t, double step); + +// Gap between a major tick and its label, and between the label and the next tick +constexpr int labelOffset = 3; +constexpr int labelGap = 5; + +/* Shaping text is slow, especially in a Debug build, and every axis repaints on each + scroll and zoom. A label holds only digits and a few symbols, so its width is taken + as the sum of per-character widths, which are measured once for each font height. */ +int getLabelWidth(const Font& font, const String& label) +{ + static float measuredFontHeight = 0.0f; + static std::map<juce_wchar, float> characterWidths; + + if (! approximatelyEqual(font.getHeight(), measuredFontHeight)) + { + characterWidths.clear(); + measuredFontHeight = font.getHeight(); + } + + float width = 0.0f; + + for (const auto c : label) + { + auto it = characterWidths.find(c); + + if (it == characterWidths.end()) + { + const float w = GlyphArrangement::getStringWidth(font, String::charToString(c)); + it = characterWidths.emplace(c, w).first; + } + + width += it->second; + } + + return static_cast<int>(std::ceil(width)); +} + +/** + * Picks the finest tick spacing whose labels still fit between the major ticks, so + * that a narrow or zoomed-out axis gets fewer ticks instead of overlapping ones. + */ +TickScheme chooseTickScheme(const Range<double>& visibleRange, + float pixelsPerSecond, + const Font& labelFont) +{ + static const TickScheme schemes[] = { + { 0.01, 0.002, 5 }, { 0.02, 0.005, 4 }, { 0.05, 0.01, 5 }, { 0.1, 0.02, 5 }, + { 0.5, 0.1, 5 }, { 1.0, 0.25, 4 }, { 2.0, 0.5, 4 }, { 5.0, 1.0, 5 }, + { 15.0, 5.0, 3 }, { 30.0, 10.0, 3 }, { 60.0, 15.0, 4 }, { 120.0, 30.0, 4 }, + { 300.0, 60.0, 5 }, { 600.0, 120.0, 5 }, { 1800.0, 600.0, 3 }, { 3600.0, 900.0, 4 }, + }; + + for (const auto& s : schemes) + { + const double numMajor = visibleRange.getLength() / s.majorStep; + const double majorSpacing = s.majorStep * static_cast<double>(pixelsPerSecond); + + const double lastMajor = std::floor(visibleRange.getEnd() / s.majorStep) * s.majorStep; + const int labelWidth = getLabelWidth(labelFont, formatTime(lastMajor, s.majorStep)); + + if (numMajor <= 15.0 && majorSpacing >= labelOffset + labelWidth + labelGap) + return s; + } + + return schemes[numElementsInArray(schemes) - 1]; +} + +String formatTime(double t, double step) +{ + // Only as many decimal places as it takes to tell neighbouring ticks apart + const int decimals = step >= 1.0 ? 0 : (step >= 0.1 ? 1 : 2); + const int unitsPerSecond = decimals == 0 ? 1 : (decimals == 1 ? 10 : 100); + + // Rounded to a whole number of the smallest unit shown before being split up + const int64 totalUnits = std::llround(jmax(0.0, t) * unitsPerSecond); + const int64 totalSeconds = totalUnits / unitsPerSecond; + + const int hours = static_cast<int>(totalSeconds / 3600); + const int minutes = static_cast<int>((totalSeconds % 3600) / 60); + const int seconds = static_cast<int>(totalSeconds % 60); + + String fraction; + + if (decimals > 0) + { + fraction = "." + String(totalUnits % unitsPerSecond).paddedLeft('0', decimals); + } + + // Under a minute, uses "s" for seconds + if (totalSeconds < 60) + { + return String(seconds) + fraction + "s"; + } + + const String paddedSeconds = String(seconds).paddedLeft('0', 2) + fraction; + + // Under an hour, minutes and seconds (e.g. "1:05" or "1:05.5") + if (hours == 0) + { + return String(minutes) + ":" + paddedSeconds; + } + + // Hours, minutes, and seconds (e.g. "1:02:05") + return String(hours) + ":" + String(minutes).paddedLeft('0', 2) + ":" + paddedSeconds; +} +} // namespace + +void TimeAxisStrip::paint(Graphics& g) +{ + if (owner == nullptr || ! owner->isFileLoaded()) + return; + + const auto& visibleRange = owner->getVisibleRange(); + const float pps = owner->getPixelsPerSecond(); + const double totalLength = owner->getTotalLengthInSecs(); + + if (pps <= 0.0f || visibleRange.getLength() <= 0.0) + return; + + const double visibleStart = visibleRange.getStart(); + const double visibleEnd = visibleRange.getEnd(); + const int w = getWidth(); + const int h = getHeight(); + + g.setColour(Colours::darkgrey); + g.fillRect(getLocalBounds()); + + const int labelH = jmin(13, h - 2); + const Font labelFont(FontOptions(static_cast<float>(labelH))); + const auto scheme = chooseTickScheme(visibleRange, pps, labelFont); + const double majorStep = scheme.majorStep; + const double minorStep = scheme.minorStep; + + const float majorTickTop = 0.0f; + const float majorTickBot = static_cast<float>(h); + const float minorTickTop = static_cast<float>(h) * 0.55f; + const float minorTickBot = static_cast<float>(h); + + // Minor ticks + g.setColour(Colours::grey.withAlpha(0.5f)); + + const double firstMinor = std::ceil(visibleStart / minorStep) * minorStep; + for (double t = firstMinor; t <= visibleEnd && t <= totalLength; t += minorStep) + { + double remainder = std::fmod(t, majorStep); + if (remainder < minorStep * 0.1 || (majorStep - remainder) < minorStep * 0.1) + continue; + + const float x = static_cast<float>((t - visibleStart) * pps); + if (x < 0.0f || x > static_cast<float>(w)) + continue; + + g.drawVerticalLine(static_cast<int>(x), minorTickTop, minorTickBot); + } + + // Major ticks and labels + g.setColour(Colours::lightgrey.withAlpha(0.9f)); + + g.setFont(labelFont); + + const double firstMajor = std::ceil(visibleStart / majorStep) * majorStep; + for (double t = firstMajor; t <= visibleEnd && t <= totalLength; t += majorStep) + { + const float x = static_cast<float>((t - visibleStart) * pps); + if (x < -60.0f || x > static_cast<float>(w) + 60.0f) + continue; + + g.drawVerticalLine(static_cast<int>(x), majorTickTop, majorTickBot); + + const String label = formatTime(t, majorStep); + const int labelX = static_cast<int>(x) + labelOffset; + const int labelWidth = getLabelWidth(labelFont, label); + + // Labels that would run off the edge of the axis are left out + if (labelX + labelWidth <= w) + { + g.drawText(label, labelX, 0, labelWidth + 1, h, Justification::centredLeft, false); + } + } +} + MediaDisplayComponent::MediaDisplayComponent() : MediaDisplayComponent("Media Track") {} MediaDisplayComponent::MediaDisplayComponent(String name, bool req, bool fromDAW, DisplayMode mode) @@ -9,8 +228,7 @@ MediaDisplayComponent::MediaDisplayComponent(String name, bool req, bool fromDAW { formatManager.registerBasicFormats(); - deviceManager.initialise(0, 2, nullptr, true, {}, nullptr); - deviceManager.addAudioCallback(&sourcePlayer); + audioDevice->manager.addAudioCallback(&sourcePlayer); sourcePlayer.setSource(&transportSource); @@ -21,6 +239,26 @@ MediaDisplayComponent::MediaDisplayComponent(String name, bool req, bool fromDAW trackNameLabel.setText(trackName, dontSendNotification); trackNameLabel.setJustificationType(Justification::centred); + + if (isThumbnailTrack()) + { + trackNameLabel.setEditable(false, true, false); + trackNameLabel.onTextChange = [this] + { + const String newName = trackNameLabel.getText().trim(); + + if (newName.isEmpty()) + { + trackNameLabel.setText(trackName, dontSendNotification); + } + else + { + setTrackName(newName); + sendChangeMessage(); + } + }; + } + headerComponent.addAndMakeVisible(trackNameLabel); headerComponent.addMouseListener(this, true); initializeButtons(); @@ -36,8 +274,13 @@ MediaDisplayComponent::MediaDisplayComponent(String name, bool req, bool fromDAW horizontalScrollBar.setAutoHide(false); horizontalScrollBar.addListener(this); + addAndMakeVisible(optionalBanner); + + timeAxisStrip = std::make_unique<TimeAxisStrip>(this); + mediaAreaContainer.addAndMakeVisible(overheadPanel); mediaAreaContainer.addAndMakeVisible(contentComponent); + mediaAreaContainer.addAndMakeVisible(*timeAxisStrip); mediaAreaContainer.addAndMakeVisible(horizontalScrollBar); addAndMakeVisible(mediaAreaContainer); @@ -52,27 +295,44 @@ MediaDisplayComponent::MediaDisplayComponent(String name, bool req, bool fromDAW void MediaDisplayComponent::initializeButtons() { + playPauseButton.setComponentID(TutorialTargets::trackPlayButton); + chooseFileButton.setComponentID(TutorialTargets::trackFolderButton); + // Mode when a playable file is loaded playButtonActiveInfo = MultiButton::Mode { "Play-Active", "Click to start playback.", [this] { start(); }, MultiButton::DrawingMode::IconOnly, Colours::limegreen, fontaudio::Play }; // Mode when there is nothing to play - playButtonInactiveInfo = - MultiButton::Mode { "Play-Inactive", "Nothing to play.", - [this] {}, MultiButton::DrawingMode::IconOnly, - Colours::lightgrey, fontaudio::Play }; - // Mode during playback - stopButtonInfo = MultiButton::Mode { "Stop", - "Click to stop playback.", - [this] { stop(); }, - MultiButton::DrawingMode::IconOnly, - Colours::orangered, - fontaudio::Stop }; - playStopButton.addMode(playButtonActiveInfo); - playStopButton.addMode(playButtonInactiveInfo); - playStopButton.addMode(stopButtonInfo); - headerComponent.addAndMakeVisible(playStopButton); + playButtonInactiveInfo = MultiButton::Mode { + "Play-Inactive", "Nothing to play.", [] {}, MultiButton::DrawingMode::IconOnly, + Colours::lightgrey, fontaudio::Play + }; + // Mode during playback, which keeps the playback position for resuming + pauseButtonInfo = MultiButton::Mode { "Pause", + "Click to pause playback.", + [this] { pause(); }, + MultiButton::DrawingMode::IconOnly, + Colours::yellow, + fontaudio::Pause }; + playPauseButton.addMode(playButtonActiveInfo); + playPauseButton.addMode(playButtonInactiveInfo); + playPauseButton.addMode(pauseButtonInfo); + headerComponent.addAndMakeVisible(playPauseButton); + + // Mode while playing or paused + stopButtonActiveInfo = + MultiButton::Mode { "Stop-Active", "Click to stop playback and return to the start.", + [this] { stop(); }, MultiButton::DrawingMode::IconOnly, + Colours::orangered, fontaudio::Stop }; + // Mode when there is nothing to stop + stopButtonInactiveInfo = MultiButton::Mode { + "Stop-Inactive", "Nothing to stop.", [] {}, MultiButton::DrawingMode::IconOnly, + Colours::lightgrey, fontaudio::Stop + }; + stopButton.addMode(stopButtonActiveInfo); + stopButton.addMode(stopButtonInactiveInfo); + headerComponent.addAndMakeVisible(stopButton); chooseFileButtonActiveInfo = MultiButton::Mode { "ChooseFile", "Click to choose a media file.", @@ -82,7 +342,7 @@ void MediaDisplayComponent::initializeButtons() fontawesome::Folder }; chooseFileButtonInactiveInfo = MultiButton::Mode { "ChooseFile-Inactive", "Cannot choose file while processing.", - [this] {}, + [] {}, MultiButton::DrawingMode::IconOnly, Colours::lightgrey, fontawesome::Folder }; @@ -98,27 +358,41 @@ void MediaDisplayComponent::initializeButtons() Colours::lightblue, fontawesome::Save }; // Mode when there is nothing to save - saveFileButtonInactiveInfo = - MultiButton::Mode { "Save-Inactive", "Nothing to save.", - [this] {}, MultiButton::DrawingMode::IconOnly, - Colours::lightgrey, fontawesome::Save }; + saveFileButtonInactiveInfo = MultiButton::Mode { + "Save-Inactive", "Nothing to save.", [] {}, MultiButton::DrawingMode::IconOnly, + Colours::lightgrey, fontawesome::Save + }; saveFileButton.addMode(saveFileButtonActiveInfo); saveFileButton.addMode(saveFileButtonInactiveInfo); headerComponent.addAndMakeVisible(saveFileButton); + // Mode when an copyable file is loaded + copyFileButtonActiveInfo = MultiButton::Mode { "Copy-Active", + "Click to copy the media file.", + [this] { copyFileCallback(); }, + MultiButton::DrawingMode::IconOnly, + Colours::lightblue, + fontawesome::Copy }; + // Mode when there is nothing to copy + copyFileButtonInactiveInfo = MultiButton::Mode { + "Copy-Inactive", "Nothing to copy.", [] {}, MultiButton::DrawingMode::IconOnly, + Colours::lightgrey, fontawesome::Copy + }; + copyFileButton.addMode(copyFileButtonActiveInfo); + copyFileButton.addMode(copyFileButtonInactiveInfo); + headerComponent.addAndMakeVisible(copyFileButton); + resetButtonState(); } MediaDisplayComponent::~MediaDisplayComponent() { - deviceManager.removeAudioCallback(&sourcePlayer); + audioDevice->manager.removeAudioCallback(&sourcePlayer); sourcePlayer.setSource(nullptr); headerComponent.removeMouseListener(this); horizontalScrollBar.removeListener(this); - - //clearLabels(); // Seems to cause problems when re-loading model } StringArray MediaDisplayComponent::getSupportedExtensions() @@ -134,14 +408,7 @@ StringArray MediaDisplayComponent::getSupportedExtensions() void MediaDisplayComponent::paint(Graphics& g) { - if (isThumbnailTrack() && isCurrentlySelected()) - { - g.fillAll(selectionColor); - } - else - { - g.fillAll(defaultColor); - } + g.fillAll(defaultColor); g.setColour(graphicsColor); @@ -168,6 +435,16 @@ void MediaDisplayComponent::paint(Graphics& g) } } +void MediaDisplayComponent::paintOverChildren(Graphics& g) +{ + // Outlined over its media, as a hovered model card is, so the selection reads at a glance + if (isThumbnailTrack() && isCurrentlySelected()) + { + g.setColour(ModelStyle::accent.withAlpha(0.8f)); + g.drawRect(getLocalBounds(), 2); + } +} + void MediaDisplayComponent::resized() { Rectangle<int> totalBounds = getLocalBounds(); @@ -184,12 +461,29 @@ void MediaDisplayComponent::resized() .withHeight(trackNameLabel.getFont().getHeight()) .withMargin(1)); } + else if (isPreviewTrack()) + { + // No header, since the preview pane has its own title bar and controls + mainFlexBox.flexDirection = FlexBox::Direction::row; + headerComponent.setBounds(0, 0, 0, 0); + } else { // Place header beside media mainFlexBox.flexDirection = FlexBox::Direction::row; + + if (! isRequired() && isInputTrack() && ! isThumbnailTrack()) + { + mainFlexBox.items.add(FlexItem(optionalBanner).withWidth(24)); + } + else + { + optionalBanner.setBounds(0, 0, 0, 0); + } + // Fixed area for track label and buttons - mainFlexBox.items.add(FlexItem(headerComponent).withFlex(1).withMaxWidth(40).withMargin(4)); + mainFlexBox.items.add( + FlexItem(headerComponent).withFlex(1).withMaxWidth(40).withMargin(headerMargin)); } // Media area takes remaining space @@ -240,18 +534,31 @@ void MediaDisplayComponent::resized() // Remove existing items in button flex buttonsFlexBox.items.clear(); - // Add buttons to flex with equal height - buttonsFlexBox.items.add( - FlexItem(playStopButton).withHeight(22).withWidth(22).withMargin({ 2, 0, 2, 0 })); - if (isInputTrack()) - { - buttonsFlexBox.items.add( - FlexItem(chooseFileButton).withHeight(22).withWidth(22).withMargin({ 2, 0, 2, 0 })); - } - if (isOutputTrack()) + // Only a header beside the media has buttons, which getNumHeaderButtons() has to match + if (hasHeaderButtons()) { - buttonsFlexBox.items.add( - FlexItem(saveFileButton).withHeight(22).withWidth(22).withMargin({ 2, 0, 2, 0 })); + auto addButton = [this](MultiButton& button) + { + buttonsFlexBox.items.add( + FlexItem(button) + .withHeight(headerButtonSize) + .withWidth(headerButtonSize) + .withMargin({ headerButtonMargin, 0, headerButtonMargin, 0 })); + }; + + addButton(playPauseButton); + addButton(stopButton); + + if (isInputTrack()) + { + addButton(chooseFileButton); + } + + if (isOutputTrack()) + { + addButton(saveFileButton); + addButton(copyFileButton); + } } buttonsFlexBox.performLayout(buttonsComponent.getBounds()); @@ -274,8 +581,24 @@ void MediaDisplayComponent::resized() overheadPanel.setBounds(0, 0, 0, 0); } - // Media component takes remaining space - mediaAreaFlexBox.items.add(FlexItem(contentComponent).withFlex(1)); + /* Media component takes the remaining space. FlexBox hands it whatever is left + after the fixed strips above and below, which can be nothing at all when the + track is very short, so a floor of zero is enforced. */ + mediaAreaFlexBox.items.add(FlexItem(contentComponent).withFlex(1).withMinHeight(0.0f)); + + if (timeAxisStrip != nullptr) + { + timeAxisStrip->setVisible(horizontalScrollBar.isVisible()); + if (timeAxisStrip->isVisible()) + { + mediaAreaFlexBox.items.add(FlexItem(*timeAxisStrip) + .withHeight(timeAxisHeight) + .withMargin({ 0, + getVerticalControlsWidth(), + static_cast<float>(controlSpacing), + getMediaXPos() })); + } + } if (horizontalScrollBar.isVisible()) { @@ -291,17 +614,29 @@ void MediaDisplayComponent::resized() // Perform layout in media area mediaAreaFlexBox.performLayout(mediaAreaContainer.getLocalBounds()); + // Protect against a negative height when the fixed strips exceed the container + for (auto* child : mediaAreaContainer.getChildren()) + { + if (child->getWidth() < 0 || child->getHeight() < 0) + { + child->setBounds(child->getBounds().withSize(jmax(0, child->getWidth()), + jmax(0, child->getHeight()))); + } + } + if (! isLabelRepositioningScheduled) { isLabelRepositioningScheduled = true; // Defer label repositioning until all layout passes are complete MessageManager::callAsync( - [this]() + [safeThis = SafePointer<MediaDisplayComponent>(this)] { - isLabelRepositioningScheduled = false; + if (safeThis == nullptr) + return; - repositionLabels(); + safeThis->isLabelRepositioningScheduled = false; + safeThis->repositionLabels(); }); } } @@ -347,7 +682,6 @@ void MediaDisplayComponent::repositionLabels() Rectangle<float> labelBounds(xPos, yPos, labelWidth, static_cast<float>(labelHeight)); l->setBounds(labelBounds.toNearestInt()); - //l->toFront(true); float cursorRadius = cursorWidth / 2.0f; @@ -365,15 +699,6 @@ void MediaDisplayComponent::repositionLabels() l->setDurationFillBounds( Rectangle<float>(leftLabelMarkerPos + cursorRadius, 0, durationWidth, mediaHeight) .toNearestInt()); - - if (l->getIndex() == currentTempFileIdx) - { - l->setVisible(true); - } - else - { - l->setVisible(false); - } } }; @@ -408,6 +733,7 @@ void MediaDisplayComponent::setChooseFileButtonEnabled(bool enabled) void MediaDisplayComponent::resetDisplay() { + paused = false; clearLabels(); resetMedia(); resetPaths(); @@ -415,13 +741,7 @@ void MediaDisplayComponent::resetDisplay() resetButtonState(); } -void MediaDisplayComponent::resetPaths() -{ - originalFilePath = URL(); - - tempFilePaths.clear(); - currentTempFileIdx = -1; -} +void MediaDisplayComponent::resetPaths() { originalFilePath = URL(); } void MediaDisplayComponent::resetTransport() { @@ -438,9 +758,10 @@ void MediaDisplayComponent::resetScrollBar() void MediaDisplayComponent::resetButtonState() { - playStopButton.setMode(playButtonInactiveInfo.displayLabel); + updatePlaybackButtons(); chooseFileButton.setMode(chooseFileButtonActiveInfo.displayLabel); saveFileButton.setMode(saveFileButtonInactiveInfo.displayLabel); + copyFileButton.setMode(copyFileButtonInactiveInfo.displayLabel); } void MediaDisplayComponent::initializeDisplay(const URL& filePath) @@ -471,148 +792,52 @@ void MediaDisplayComponent::updateDisplay(const URL& filePath) horizontalScrollBar.setRangeLimits(range); - playStopButton.setMode(playButtonActiveInfo.displayLabel); + updatePlaybackButtons(); saveFileButton.setMode(saveFileButtonActiveInfo.displayLabel); + copyFileButton.setMode(copyFileButtonActiveInfo.displayLabel); } -void MediaDisplayComponent::setOriginalFilePath(URL filePath) -{ - originalFilePath = filePath; - - //addNewTempFile(); -} - -/*void MediaDisplayComponent::addNewTempFile() +void MediaDisplayComponent::updatePlaybackButtons() { - // Prune any future files in chain before adding new temp file - clearFutureTempFiles(); - - int numTempFiles = tempFilePaths.size(); - - // Obtain original file used to initialize display - File originalFile = originalFilePath.getLocalFile(); - - File targetFile; // File to copy to new temp file - - if (! numTempFiles) - { - // Copy original file if no temp files - targetFile = originalFile; - } - else + if (! isFileLoaded()) { - // Otherwise copy most recent temp file - targetFile = getTempFilePath().getLocalFile(); - } - - String tempDirectory = - File::getSpecialLocation(File::SpecialLocationType::tempDirectory).getFullPathName(); - - String targetFileName = originalFile.getFileNameWithoutExtension(); - String targetFileExtension = originalFile.getFileExtension(); - - URL tempFilePath = URL(File(tempDirectory + "/HARP/" + targetFileName + "_" - + String(numTempFiles) + targetFileExtension)); + playPauseButton.setMode(playButtonInactiveInfo.displayLabel); + stopButton.setMode(stopButtonInactiveInfo.displayLabel); - File tempFile = tempFilePath.getLocalFile(); - - tempFile.getParentDirectory().createDirectory(); - - if (! targetFile.copyFileTo(tempFile)) - { - DBG_AND_LOG("MediaDisplayComponent::addNewTempFile: Failed to copy file " - << targetFile.getFullPathName() << " to " << tempFile.getFullPathName() << "."); - } - else - { - DBG_AND_LOG("MediaDisplayComponent::addNewTempFile: Copied file " - << targetFile.getFullPathName() << " to " << tempFile.getFullPathName() << "."); + return; } - tempFilePaths.add(tempFilePath); - currentTempFileIdx++; + playPauseButton.setMode(isPlaying() ? pauseButtonInfo.displayLabel + : playButtonActiveInfo.displayLabel); + stopButton.setMode(isPlaying() || isPaused() ? stopButtonActiveInfo.displayLabel + : stopButtonInactiveInfo.displayLabel); } -bool MediaDisplayComponent::iteratePreviousTempFile() +int MediaDisplayComponent::getNumHeaderButtons() const { - if (currentTempFileIdx > 0) + if (! hasHeaderButtons()) { - currentTempFileIdx--; - - updateDisplay(getTempFilePath()); - - return true; + return 0; } - else - { - return false; - } -} - -bool MediaDisplayComponent::iterateNextTempFile() -{ - if (currentTempFileIdx + 1 < tempFilePaths.size()) - { - currentTempFileIdx++; - - updateDisplay(getTempFilePath()); - return true; - } - else - { - return false; - } + // Play/pause and stop, then choosing a file on an input track, or saving and copying an output + return 2 + (isInputTrack() ? 1 : 0) + (isOutputTrack() ? 2 : 0); } -void MediaDisplayComponent::clearFutureTempFiles() +int MediaDisplayComponent::getMinimumHeight() const { - int n = tempFilePaths.size() - (currentTempFileIdx + 1); - - tempFilePaths.removeLast(n); + // The header buttons are stacked in a column as tall as the track + const int headerHeight = + getNumHeaderButtons() * (headerButtonSize + 2 * headerButtonMargin) + 2 * headerMargin; - clearLabels(currentTempFileIdx + 1); + return jmax(minimumUsefulHeight, headerHeight); } -void MediaDisplayComponent::overwriteOriginalFile() -{ - File targetFile = originalFilePath.getLocalFile(); - File tempFile = getTempFilePath().getLocalFile(); - - String parentDirectory = targetFile.getParentDirectory().getFullPathName(); - String targetFileName = targetFile.getFileNameWithoutExtension(); - String targetFileExtension = targetFile.getFileExtension(); - - File backupFile = - File(parentDirectory + "/" + targetFileName + "_BACKUP" + targetFileExtension); - - if (targetFile.copyFileTo(backupFile)) - { - DBG_AND_LOG("MediaDisplayComponent::overwriteOriginalFile: Created backup of file " - << targetFile.getFullPathName() << " at " << backupFile.getFullPathName() << "."); - } - else - { - DBG_AND_LOG("MediaDisplayComponent::overwriteOriginalFile: Failed to create backup of file " - << targetFile.getFullPathName() << " at " << backupFile.getFullPathName() << "."); - } - - if (tempFile.copyFileTo(targetFile)) - { - DBG_AND_LOG("MediaDisplayComponent::overwriteOriginalFile: Overwriting file " - << targetFile.getFullPathName() << " with " << tempFile.getFullPathName() << "."); - } - else - { - DBG_AND_LOG("MediaDisplayComponent::overwriteOriginalFile: Failed to overwrite file " - << targetFile.getFullPathName() << " with " << tempFile.getFullPathName() << "."); - } -}*/ +void MediaDisplayComponent::setOriginalFilePath(URL filePath) { originalFilePath = filePath; } bool MediaDisplayComponent::isDuplicateFile(const URL& filePath) { - return getOriginalFilePath() - == filePath; //|| (isFileLoaded() && getTempFilePath() == filePath); + return getOriginalFilePath() == filePath; } void MediaDisplayComponent::filesDropped(const StringArray& files, int /*x*/, int /*y*/) @@ -631,7 +856,7 @@ void MediaDisplayComponent::filesDropped(const StringArray& files, int /*x*/, in return; } - if (! getInstanceExtensions().contains(mediaFile.getFileExtension())) + if (! getInstanceExtensions().contains(mediaFile.getFileExtension(), true)) { AlertWindow::showMessageBoxAsync(AlertWindow::WarningIcon, "Invalid File", @@ -669,14 +894,6 @@ void MediaDisplayComponent::saveFileCallback() { if (saveFileButton.getModeName() == saveFileButtonActiveInfo.displayLabel) { - //overwriteOriginalFile(); - //saveFileButton.setMode(saveButtonInactiveInfo.displayLabel); - - /*if (statusBox != nullptr) - { - statusBox->setStatusMessage("File saved successfully"); - }*/ - if (isFileLoaded()) { StringArray validExtensions = StringArray(getInstanceExtensions()); @@ -692,16 +909,15 @@ void MediaDisplayComponent::saveFileCallback() File chosenFile = fc.getResult(); if (chosenFile != File {}) { - if (chosenFile.getFileExtension().compare("") == 0) + if (chosenFile.getFileExtension().isEmpty() && ! validExtensions.isEmpty()) { - // Add default extension in none provided + // Add default extension if none provided and a specific list exists chosenFile = chosenFile.withFileExtension(validExtensions[0]); } - if (validExtensions.contains(chosenFile.getFileExtension())) + if (validExtensions.isEmpty() + || validExtensions.contains(chosenFile.getFileExtension(), true)) { - //URL tempFilePath = mediaDisplay->getTempFilePath(); - if (URL(chosenFile) != getOriginalFilePath()) { // Remove DAW linking @@ -709,16 +925,11 @@ void MediaDisplayComponent::saveFileCallback() } // Attempt to save file contained within media display to chosen location - //bool saveSuccessful = tempFilePath.getLocalFile().copyFileTo(newFile); if (getOriginalFilePath().getLocalFile().copyFileTo(chosenFile)) { - //loadMediaDisplay(newFile); - // Update path associated with media display setOriginalFilePath(URL(chosenFile)); - //saveFileButton.setMode(saveButtonInactiveInfo.label); - if (statusMessage != nullptr) { statusMessage->setMessage("File successfully saved to " @@ -744,18 +955,39 @@ void MediaDisplayComponent::saveFileCallback() "OK"); } } - else - { - //DBG_AND_LOG("MediaDisplayComponent::saveFileCallback: Save operation canceled."); - } }); } } } +void MediaDisplayComponent::copyFileCallback() +{ + if (isFileLoaded()) + { + File file = getOriginalFilePath().getLocalFile(); + + if (file.exists()) + { + copyFileToClipboard(file); + + if (statusMessage != nullptr) + { + statusMessage->setMessage("File copied to clipboard."); + } + } + else + { + if (statusMessage != nullptr) + { + statusMessage->setMessage("Failed to copy file to clipboard."); + } + } + } +} + float MediaDisplayComponent::getPixelsPerSecond() { - if (visibleRange.getLength()) + if (visibleRange.getLength() > 0.0) { return getMediaWidth() / static_cast<float>(visibleRange.getLength()); } @@ -767,7 +999,7 @@ float MediaDisplayComponent::getPixelsPerSecond() double MediaDisplayComponent::mediaXToTime(const float mX) { - if (visibleRange.getLength()) + if (visibleRange.getLength() > 0.0) { return static_cast<double>(mX / getPixelsPerSecond()) + getTimeAtOrigin(); } @@ -781,7 +1013,7 @@ float MediaDisplayComponent::timeToMediaX(const double t) { double t_ = jmin(getTotalLengthInSecs(), jmax(0.0, t)); - if (visibleRange.getLength()) + if (visibleRange.getLength() > 0.0) { return static_cast<float>(t_ - getTimeAtOrigin()) * getPixelsPerSecond(); } @@ -796,7 +1028,7 @@ float MediaDisplayComponent::mediaXToDisplayX(const float mX) float offsetX = 0; float visibleStartX = 0; - if (visibleRange.getLength()) + if (visibleRange.getLength() > 0.0) { offsetX = static_cast<float>(getTimeAtOrigin()) * getPixelsPerSecond(); visibleStartX = static_cast<float>(visibleRange.getStart() * getPixelsPerSecond()); @@ -823,6 +1055,9 @@ void MediaDisplayComponent::updateVisibleRange(Range<double> r) updateCursorPosition(); repositionLabels(); + if (timeAxisStrip != nullptr) + timeAxisStrip->repaint(); + visibleRangeCallback(); } @@ -831,24 +1066,56 @@ void MediaDisplayComponent::horizontalMove(double deltaT) double visibleStart = visibleRange.getStart(); double visibleLength = visibleRange.getLength(); + const double totalLength = getTotalLengthInSecs(); + const double maxStart = jmax(0.0, totalLength - visibleLength); double newStart = visibleStart - deltaT * visibleLength / 10.0; - newStart = jlimit(0.0, jmax(0.0, getTotalLengthInSecs() - visibleLength), newStart); + newStart = jlimit(0.0, maxStart, newStart); updateVisibleRange({ newStart, newStart + visibleLength }); } void MediaDisplayComponent::horizontalZoom(double deltaZoom, double scrollPosT) { - horizontalZoomFactor = jlimit(1.0, 2.0, horizontalZoomFactor + deltaZoom); + const float mediaWidth = getMediaWidth(); + const double totalLength = getTotalLengthInSecs(); + + if (mediaWidth <= 0.0f || totalLength <= 0.0) + return; + + const float pps = getPixelsPerSecond(); + if (pps <= 0.0f) + return; + + const double minVisibleSeconds = 0.1; + + const float minPps = static_cast<float>(mediaWidth / totalLength); + float maxPps = static_cast<float>(mediaWidth / minVisibleSeconds); + + // Media shorter than the floor can still only be shown in full + maxPps = jmax(maxPps, minPps); + + float newPps = pps * (1.0f + 0.5f * static_cast<float>(deltaZoom)); + newPps = jlimit(minPps, maxPps, newPps); - double newScale = jmax(0.05, getTotalLengthInSecs() * (2.0 - horizontalZoomFactor)); + if (std::abs(newPps - pps) < 0.01f) + return; + + double newVisibleLength = static_cast<double>(mediaWidth) / static_cast<double>(newPps); + newVisibleLength = jmin(newVisibleLength, totalLength); + double anchorRatio = 0.5; double visibleStart = visibleRange.getStart(); - double visibleEnd = visibleRange.getEnd(); double visibleLength = visibleRange.getLength(); - double newStart = scrollPosT - newScale * (scrollPosT - visibleStart) / visibleLength; - double newEnd = scrollPosT + newScale * (visibleEnd - scrollPosT) / visibleLength; + if (visibleLength > 0.0) + anchorRatio = (scrollPosT - visibleStart) / visibleLength; + + anchorRatio = jlimit(0.0, 1.0, anchorRatio); + + const double maxStart = jmax(0.0, totalLength - newVisibleLength); + double newStart = scrollPosT - anchorRatio * newVisibleLength; + newStart = jlimit(0.0, maxStart, newStart); + double newEnd = newStart + newVisibleLength; updateVisibleRange({ newStart, newEnd }); } @@ -864,11 +1131,15 @@ void MediaDisplayComponent::scrollBarMoved(ScrollBar* scrollBarThatHasMoved, void MediaDisplayComponent::mouseWheelMove(const MouseEvent& evt, const MouseWheelDetails& wheel) { - if (isThumbnailTrack()) + /* Whatever this track does not act on has to be handed upwards explicitly: + Component::mouseWheelMove is what walks the event up to the panel viewport, + so returning without calling it swallows the scroll. The same predicate the + viewport consults decides that, so the two cannot disagree. */ + if (! usesMouseWheel()) { Component::mouseWheelMove(evt, wheel); } - else if (isFileLoaded()) + else { #if (JUCE_MAC) bool commandMod = evt.mods.isCommandDown() || evt.mods.isCtrlDown(); @@ -879,7 +1150,7 @@ void MediaDisplayComponent::mouseWheelMove(const MouseEvent& evt, const MouseWhe double scrollTime = mediaXToTime(evt.position.getX()); - if (! commandMod) + if (! commandMod && evt.eventComponent == getMediaComponent()) { if (std::abs(wheel.deltaX) > 2 * std::abs(wheel.deltaY)) { @@ -903,10 +1174,6 @@ void MediaDisplayComponent::mouseWheelMove(const MouseEvent& evt, const MouseWhe } } } - else - { - // Ignore mouse wheel events - } } void MediaDisplayComponent::unlinkFromDAW() @@ -964,15 +1231,24 @@ void MediaDisplayComponent::deselectTrack() void MediaDisplayComponent::start() { + paused = false; + startPlaying(); startTimerHz(40); - playStopButton.setMode(stopButtonInfo.displayLabel); + updatePlaybackButtons(); + + if (onPlaybackStateChanged) + { + onPlaybackStateChanged(); + } } void MediaDisplayComponent::stop() { + paused = false; + stopPlaying(); stopTimer(); @@ -980,9 +1256,33 @@ void MediaDisplayComponent::stop() currentPositionCursor.setVisible(false); setPlaybackPosition(0.0); - playStopButton.setMode(playButtonActiveInfo.displayLabel); + updatePlaybackButtons(); sendChangeMessage(); + + if (onPlaybackStateChanged) + { + onPlaybackStateChanged(); + } +} + +void MediaDisplayComponent::pause() +{ + stopPlaying(); + + stopTimer(); + + paused = true; + + updatePlaybackButtons(); + + // Preserves resume position + updateCursorPosition(); + + if (onPlaybackStateChanged) + { + onPlaybackStateChanged(); + } } void MediaDisplayComponent::updateCursorPosition() @@ -995,15 +1295,16 @@ void MediaDisplayComponent::updateCursorPosition() float cursorPositionX = mediaXToDisplayX(timeToMediaX(getPlaybackPosition())); float cursorPositionY = 0; - if (isPlaying() && cursorPositionX >= minCursorXPos && cursorPositionX <= maxCursorXPos) + if ((isPlaying() || paused) && cursorPositionX >= minCursorXPos + && cursorPositionX <= maxCursorXPos) { - currentPositionCursor.setVisible(! isThumbnailTrack()); + currentPositionCursor.setVisible(hasPlaybackCursor()); } else if (isFileLoaded() && ! isPlaying() && (getMediaComponent()->isMouseButtonDown(false) && getLocalBounds().contains(getMouseXYRelative()))) { - currentPositionCursor.setVisible(! isThumbnailTrack()); + currentPositionCursor.setVisible(hasPlaybackCursor()); } else { @@ -1027,30 +1328,22 @@ void MediaDisplayComponent::updateCursorPosition() Rectangle<float>(cursorPositionX, cursorPositionY, cursorWidth, mediaBounds.getHeight())); } -Rectangle<int> MediaDisplayComponent::getChooseFileButtonBounds() +void MediaDisplayComponent::mouseEnter(const MouseEvent& e) { - if (auto* p = chooseFileButton.getParentComponent()) + if (instructionsMessage == nullptr) { - return getLocalArea(p, chooseFileButton.getBounds()); + return; } - return chooseFileButton.getBounds(); -} - -Rectangle<int> MediaDisplayComponent::getPlayButtonBounds() -{ - if (auto* p = playStopButton.getParentComponent()) + if (isThumbnailTrack()) { - return getLocalArea(p, playStopButton.getBounds()); + // A thumbnail has no buttons, so everything it does is explained from any part of it + instructionsMessage->setMessage( + "Click to select this file and play it in the preview pane.\n" + "Double-click its name to rename it, or its media to restore a hidden preview pane.\n" + "Drag it to reorder the list, onto a track, or out of HARP."); } - - return playStopButton.getBounds(); -} - -void MediaDisplayComponent::mouseEnter(const MouseEvent& e) -{ - if (! isThumbnailTrack() && e.eventComponent == getMediaComponent() - && instructionsMessage != nullptr) + else if (e.eventComponent == getMediaComponent()) { instructionsMessage->setMessage(mediaInstructions); } @@ -1058,8 +1351,8 @@ void MediaDisplayComponent::mouseEnter(const MouseEvent& e) void MediaDisplayComponent::mouseExit(const MouseEvent& e) { - if (! isThumbnailTrack() && e.eventComponent == getMediaComponent() - && instructionsMessage != nullptr) + if (instructionsMessage != nullptr + && (isThumbnailTrack() || e.eventComponent == getMediaComponent())) { instructionsMessage->clearMessage(); } @@ -1067,7 +1360,7 @@ void MediaDisplayComponent::mouseExit(const MouseEvent& e) void MediaDisplayComponent::mouseDown(const MouseEvent& e) { - mouseDrag(e); // Make sure playback position has been updated + seekToMouse(e); // Make sure playback position has been updated if (isThumbnailTrack() && isFileLoaded()) { @@ -1075,49 +1368,57 @@ void MediaDisplayComponent::mouseDown(const MouseEvent& e) } } -void MediaDisplayComponent::mouseDrag(const MouseEvent& e) +namespace { - if (isFileLoaded()) +/* True if an event comes from a control inside a track, such as the MIDI vertical + zoom, so that dragging the control does not also drag the track's file. */ +bool isFromControl(const MouseEvent& e, const Component* track) +{ + for (auto* c = e.eventComponent; c != nullptr && c != track; c = c->getParentComponent()) { - if (! isThumbnailTrack() && e.eventComponent == getMediaComponent() && ! isPlaying() - && getLocalBounds().contains(getMouseXYRelative())) + if (dynamic_cast<Slider*>(c) != nullptr || dynamic_cast<ScrollBar*>(c) != nullptr + || dynamic_cast<Button*>(c) != nullptr || dynamic_cast<TextEditor*>(c) != nullptr) { - float x_ = static_cast<float>(e.x); - - double visibleStart = visibleRange.getStart(); - double visibleStop = visibleStart + visibleRange.getLength(); - - x_ = jmax(timeToMediaX(visibleStart), x_); - x_ = jmin(timeToMediaX(visibleStop), x_); - - setPlaybackPosition(mediaXToTime(x_)); + return true; } + } - if (! getLocalBounds().contains(getMouseXYRelative())) - { - //performExternalDragDropOfFiles( - // StringArray(getTempFilePath().getLocalFile().getFullPathName()), true, this); - performExternalDragDropOfFiles( - StringArray(getOriginalFilePath().getLocalFile().getFullPathName()), true, this); - } + return false; +} +} // namespace - updateCursorPosition(); +void MediaDisplayComponent::mouseDrag(const MouseEvent& e) +{ + seekToMouse(e); + + // Only a drag starts a file drag, never the click that ends one + if (isFileLoaded() && ! isFromControl(e, this) + && ! getLocalBounds().contains(getMouseXYRelative())) + { + startFileDrag(e); } } void MediaDisplayComponent::mouseUp(const MouseEvent& e) { - mouseDrag(e); // Make sure playback position has been updated + seekToMouse(e); // Make sure playback position has been updated if (! isThumbnailTrack()) { if (e.eventComponent == getMediaComponent() && isFileLoaded() && isMouseOver(true)) { - start(); // Only start playback if still within media area + /* A click or drag only moves the cursor, as in most DAWs. A playing track carries + on from there, and any other is left paused there, which keeps the cursor in + view and is where the play button resumes from. */ + if (! isPlaying()) + { + pause(); + } } else { - if (! isPlaying()) + // A paused track keeps its position for resuming + if (! isPlaying() && ! isPaused()) { setPlaybackPosition(0.0); } @@ -1127,33 +1428,81 @@ void MediaDisplayComponent::mouseUp(const MouseEvent& e) void MediaDisplayComponent::mouseDoubleClick(const MouseEvent& e) { - // TODO - mouseUp/Down (selectTrack()) is still called before this + if (! isThumbnailTrack() || ! isFileLoaded()) + { + return; + } - if (isThumbnailTrack() && isFileLoaded() && isMouseOver(true)) + // A double-click on the header renames the track instead + const bool isOnHeader = + e.eventComponent == &headerComponent || headerComponent.isParentOf(e.eventComponent); + + if (! isOnHeader && onMediaDoubleClick) { - deselectTrack(); + onMediaDoubleClick(); } } -int MediaDisplayComponent::getNumOverheadLabels() +void MediaDisplayComponent::seekToMouse(const MouseEvent& e) +{ + if (! isFileLoaded()) + { + return; + } + + // Playback continues from the new position if the track is playing + if (! isThumbnailTrack() && e.eventComponent == getMediaComponent() + && getLocalBounds().contains(getMouseXYRelative())) + { + float x_ = static_cast<float>(e.x); + + double visibleStart = visibleRange.getStart(); + double visibleStop = visibleStart + visibleRange.getLength(); + + x_ = jmax(timeToMediaX(visibleStart), x_); + x_ = jmin(timeToMediaX(visibleStop), x_); + + setPlaybackPosition(mediaXToTime(x_)); + } + + updateCursorPosition(); +} + +void MediaDisplayComponent::startFileDrag(const MouseEvent& e) { - int nOverheadLabels = 0; + auto* container = DragAndDropContainer::findParentDragContainerFor(this); - for (auto l : overheadLabels) + if (container == nullptr) { - if (l->getIndex() == currentTempFileIdx) + return; + } + + const String filePath = getOriginalFilePath().getLocalFile().getFullPathName(); + + const Component* window = getTopLevelComponent(); + const bool isInsideWindow = + window != nullptr && window->getScreenBounds().contains(e.getScreenPosition()); + + if (isInsideWindow) + { + // A drag within the window stays inside JUCE, where tracks and the clipboard take it + if (! container->isDragAndDropActive()) { - nOverheadLabels++; + container->startDragging(filePath, this, ScaledImage {}, false); } } - - return nOverheadLabels; + else + { + // Past the window edge it becomes an operating system drag, which copies the file + container->performExternalDragDropOfFiles(StringArray(filePath), false, this); + } } +int MediaDisplayComponent::getNumOverheadLabels() { return overheadLabels.size(); } + void MediaDisplayComponent::addOverheadLabel(OverheadLabelComponent* l) { - l->setFont(Font(jmax(minFontSize, labelHeight - 2 * textSpacing))); - l->setIndex(currentTempFileIdx); + l->setFont(Font(FontOptions((float) jmax(minFontSize, labelHeight - 2 * textSpacing)))); overheadLabels.add(l); Component* mediaComponentPtr = getMediaComponent(); @@ -1175,8 +1524,7 @@ void MediaDisplayComponent::removeOverheadLabel(OverheadLabelComponent* l) void MediaDisplayComponent::addLabelOverlay(LabelOverlayComponent* l) { - l->setFont(Font(jmax(minFontSize, labelHeight - 2 * textSpacing))); - l->setIndex(currentTempFileIdx); + l->setFont(Font(FontOptions((float) jmax(minFontSize, labelHeight - 2 * textSpacing)))); labelOverlays.add(l); Component* mediaComponentPtr = getMediaComponent(); @@ -1205,103 +1553,50 @@ void MediaDisplayComponent::addLabels(const LabelList& labels) continue; } - std::unique_ptr<OutputLabelComponent> lc = - std::make_unique<OutputLabelComponent>((double) l->t, l->label); - - if ((l->description).has_value()) - { - lc->setDescription((l->description).value()); - } + const double duration = l->duration.has_value() ? (double) *l->duration : 0.0; + const String description = l->description.value_or(String()); + const Colour colour = l->color.has_value() ? Colour(static_cast<uint32>(*l->color)) + : Colours::purple.withAlpha(0.8f); + const String link = l->link.value_or(String()); - if ((l->duration).has_value()) - { - lc->setDuration(static_cast<double>((l->duration).value())); - } + // A label that has a height in the media is drawn over it, and any other above it + std::optional<float> relativeY; - if ((l->color).has_value()) + if (auto* audioLabel = dynamic_cast<AudioLabel*>(l.get()); + audioLabel != nullptr && audioLabel->amplitude.has_value()) { - lc->setColor(Colour(static_cast<uint32_t>((l->color).value()))); + relativeY = LabelOverlayComponent::amplitudeToRelativeY(*audioLabel->amplitude); } - - if ((l->link).has_value()) + else if (auto* midiLabel = dynamic_cast<MidiLabel*>(l.get()); + midiLabel != nullptr && midiLabel->pitch.has_value()) { - lc->setLink((l->link).value()); + relativeY = LabelOverlayComponent::pitchToRelativeY(*midiLabel->pitch); } - float y = 0.0f; - - bool isOverlay = false; - - if (auto audioLabel = dynamic_cast<AudioLabel*>(l.get())) + if (relativeY.has_value()) { - if ((audioLabel->amplitude).has_value()) - { - isOverlay = true; - - float amp = (audioLabel->amplitude).value(); - - y = LabelOverlayComponent::amplitudeToRelativeY(amp); - } - } - - if (auto midiLabel = dynamic_cast<MidiLabel*>(l.get())) - { - if ((midiLabel->pitch).has_value()) - { - isOverlay = true; - - float p = (midiLabel->pitch).value(); - - y = LabelOverlayComponent::pitchToRelativeY(p); - } - } - - if (isOverlay) - { - auto* lo = new LabelOverlayComponent(*static_cast<LabelOverlayComponent*>(lc.get())); - lo->setRelativeY(y); - - addLabelOverlay(lo); + addLabelOverlay(new LabelOverlayComponent( + (double) l->t, l->label, *relativeY, duration, description, colour, link)); } else { - auto* ol = new OverheadLabelComponent(*static_cast<OverheadLabelComponent*>(lc.get())); - addOverheadLabel(ol); + addOverheadLabel(new OverheadLabelComponent( + (double) l->t, l->label, duration, description, colour, link)); } } } -void MediaDisplayComponent::clearLabels(int processingIdxCutoff) +void MediaDisplayComponent::clearLabels() { - for (int i = labelOverlays.size() - 1; i >= 0; --i) + while (! labelOverlays.isEmpty()) { - LabelOverlayComponent* l = labelOverlays[i]; - - if (l->getIndex() >= processingIdxCutoff) - { - removeLabelOverlay(l); - } + removeLabelOverlay(labelOverlays.getLast()); } - if (! processingIdxCutoff) + while (! overheadLabels.isEmpty()) { - labelOverlays.clear(); - } - - for (int i = overheadLabels.size() - 1; i >= 0; --i) - { - OverheadLabelComponent* l = overheadLabels[i]; - - if (l->getIndex() >= processingIdxCutoff) - { - removeOverheadLabel(l); - } - } - - if (! processingIdxCutoff) - { - overheadLabels.clear(); + removeOverheadLabel(overheadLabels.getLast()); } resized(); // Remove overhead label panel -} \ No newline at end of file +} diff --git a/src/media/MediaDisplayComponent.h b/src/media/MediaDisplayComponent.h index a4020658..c71dc6a5 100644 --- a/src/media/MediaDisplayComponent.h +++ b/src/media/MediaDisplayComponent.h @@ -1,7 +1,7 @@ /** * @file MediaDisplayComponent.h * @brief Parent class for shared functionality of media displays. - * @author cwitkowitz, xribene, nathanpruyne + * @author cwitkowitz, 2cylu2, xribene, nathanpruyne, JEYuhas */ #pragma once @@ -10,6 +10,7 @@ #include <juce_core/juce_core.h> #include "../gui/MultiButton.h" +#include "../gui/Palette.h" #include "OutputLabelComponent.h" #include "../utils/Labels.h" @@ -17,19 +18,50 @@ using namespace juce; +class MediaDisplayComponent; + enum class DisplayMode { Input, Output, Hybrid, // All functionality - Thumbnail // Reduced functionality + Thumbnail, // Reduced functionality + Preview // Media area only (for preview pane) +}; + +/* One audio output shared by every track. A device opened for each track would leave a device + and an audio thread running for every track of every tab and of the media clipboard, and + some systems allow only one client per device. The manager mixes all of its callbacks. */ +struct SharedAudioDevice +{ + SharedAudioDevice() { manager.initialise(0, 2, nullptr, true, {}, nullptr); } + + AudioDeviceManager manager; +}; + +class OptionalBannerComponent : public Component +{ +public: + void paint(Graphics& g) override; +}; + +class TimeAxisStrip : public Component +{ +public: + explicit TimeAxisStrip(MediaDisplayComponent* ownerIn) : owner(ownerIn) {} + + void paint(Graphics& g) override; + +private: + MediaDisplayComponent* owner = nullptr; }; class ColorablePanel : public Component { public: - ColorablePanel(Colour color = Colours::darkgrey) - : defaultColor(color), backgroundColor(color) {}; + ColorablePanel(Colour color = Colours::darkgrey) : defaultColor(color), backgroundColor(color) + { + } void paint(Graphics& g) override { g.fillAll(backgroundColor); } @@ -49,8 +81,8 @@ class ColorablePanel : public Component class MediaDisplayComponent : public Component, public ChangeListener, public ChangeBroadcaster, + public DragAndDropTarget, public FileDragAndDropTarget, - public DragAndDropContainer, private Timer, private ScrollBar::Listener { @@ -66,9 +98,13 @@ class MediaDisplayComponent : public Component, virtual StringArray getInstanceExtensions() = 0; void paint(Graphics& g) override; + void paintOverChildren(Graphics& g) override; virtual void resized() override; void repositionLabels(); + // Returns a fixed height in pixels this display should occupy, or 0 to use flex sizing. + virtual int getFixedHeight() const { return 0; } + void setTrackID(Uuid id) { trackID = id; } Uuid getTrackID() { return trackID; } @@ -78,10 +114,11 @@ class MediaDisplayComponent : public Component, bool isRequired() const { return required; } bool isLinkedToDAW() const { return linkedToDAW; } - bool isInputTrack() { return (displayMode == DisplayMode::Input) || isHybridTrack(); } - bool isOutputTrack() { return (displayMode == DisplayMode::Output) || isHybridTrack(); } - bool isHybridTrack() { return displayMode == DisplayMode::Hybrid; } - bool isThumbnailTrack() { return displayMode == DisplayMode::Thumbnail; } + bool isInputTrack() const { return (displayMode == DisplayMode::Input) || isHybridTrack(); } + bool isOutputTrack() const { return (displayMode == DisplayMode::Output) || isHybridTrack(); } + bool isHybridTrack() const { return displayMode == DisplayMode::Hybrid; } + bool isThumbnailTrack() const { return displayMode == DisplayMode::Thumbnail; } + bool isPreviewTrack() const { return displayMode == DisplayMode::Preview; } void setMediaInstructions(String instructions) { mediaInstructions = instructions; } @@ -95,26 +132,36 @@ class MediaDisplayComponent : public Component, URL getOriginalFilePath() { return originalFilePath; } - //void addNewTempFile(); // Create working temp file to modify instead of current or original - //bool iteratePreviousTempFile(); - //bool iterateNextTempFile(); - - //bool isFileLoaded() { return ! tempFilePaths.isEmpty(); } - bool isFileLoaded() { return ! originalFilePath.isEmpty(); } - //URL getTempFilePath() { return tempFilePaths.getReference(currentTempFileIdx); } + bool isFileLoaded() const { return ! originalFilePath.isEmpty(); } - //void clearFutureTempFiles(); // Prune temp files after currently selected index - //void overwriteOriginalFile(); // Necessary for seamless sample editing integration + /** Whether a wheel event over this track is used to zoom or scrub its media. */ + virtual bool usesMouseWheel() const { return isFileLoaded() && ! isThumbnailTrack(); } bool isInterestedInFileDrag(const StringArray& /*files*/) override { return isInputTrack(); } - bool isDuplicateFile(const URL& fileParth); + bool isInterestedInDragSource(const DragAndDropTarget::SourceDetails& details) override + { + return isInputTrack() && details.description.isString(); + } + + void itemDropped(const DragAndDropTarget::SourceDetails& details) override + { + String filePath = details.description.toString(); + if (filePath.isNotEmpty()) + { + filesDropped(StringArray(filePath), 0, 0); + } + } + + bool isDuplicateFile(const URL& filePath); void saveFileCallback(); + void copyFileCallback(); virtual double getTotalLengthInSecs() = 0; virtual double getTimeAtOrigin() { return visibleRange.getStart(); } virtual float getPixelsPerSecond(); + const Range<double>& getVisibleRange() const { return visibleRange; } virtual void setPlaybackPosition(double t) { transportSource.setPosition(t); } virtual double getPlaybackPosition() { return transportSource.getCurrentPosition(); } @@ -125,11 +172,15 @@ class MediaDisplayComponent : public Component, void start(); void stop(); + void pause(); virtual bool isPlaying() { return transportSource.isPlaying(); } + virtual bool isPaused() const { return paused; } - Rectangle<int> getChooseFileButtonBounds(); - Rectangle<int> getPlayButtonBounds(); + // Called whenever playback starts, pauses, or stops + std::function<void()> onPlaybackStateChanged; + // Called when the media of a thumbnail is double-clicked + std::function<void()> onMediaDoubleClick; int getNumOverheadLabels(); @@ -140,7 +191,7 @@ class MediaDisplayComponent : public Component, void removeLabelOverlay(LabelOverlayComponent* l); void addLabels(const LabelList& labels); - void clearLabels(int processingIdxCutoff = 0); + void clearLabels(); protected: void resetTransport(); @@ -149,9 +200,31 @@ class MediaDisplayComponent : public Component, virtual void mouseWheelMove(const MouseEvent&, const MouseWheelDetails& wheel) override; - const int controlSpacing = 1; - const int scrollBarSize = 8; + static constexpr int controlSpacing = 1; + static constexpr int scrollBarSize = 8; + static constexpr int timeAxisHeight = 20; + static constexpr int labelHeight = 20; + + /* A track stacks fixed chrome above and below its media: a strip for overhead + labels, the time axis, and the horizontal scrollbar. A track shorter than + these plus a usable content area cannot show the media at all, and leaves + the content area with a negative height to paint into. */ + static constexpr int overheadPanelHeight = labelHeight + 2 * controlSpacing; + static constexpr int horizontalScrollBarHeight = scrollBarSize + 2 * controlSpacing; + static constexpr int minimumContentHeight = 40; + +public: + /** Height consumed by the strips a track always stacks around its media. */ + static constexpr int fixedChromeHeight = + overheadPanelHeight + timeAxisHeight + horizontalScrollBarHeight; + + /** Shortest a track can be and still show anything of its media. */ + static constexpr int minimumUsefulHeight = fixedChromeHeight + minimumContentHeight; + + /** Shortest this track can be and still show its media and every button in its header. */ + int getMinimumHeight() const; +protected: // Media (audio or MIDI) content area Component contentComponent; @@ -159,8 +232,10 @@ class MediaDisplayComponent : public Component, Range<double> visibleRange; + std::unique_ptr<TimeAxisStrip> timeAxisStrip; + AudioFormatManager formatManager; - AudioDeviceManager deviceManager; + SharedResourcePointer<SharedAudioDevice> audioDevice; AudioSourcePlayer sourcePlayer; AudioTransportSource transportSource; @@ -176,6 +251,16 @@ class MediaDisplayComponent : public Component, void resetPaths(); void resetScrollBar(); void resetButtonState(); + void updatePlaybackButtons(); + + // Only a header beside the media, as on input and output tracks, has buttons + bool hasHeaderButtons() const { return ! isThumbnailTrack() && ! isPreviewTrack(); } + int getNumHeaderButtons() const; + + // Header buttons are stacked in a column, each with a margin above and below + static constexpr int headerButtonSize = 25; + static constexpr int headerButtonMargin = 2; + static constexpr int headerMargin = 4; void setOriginalFilePath(URL filePath); @@ -187,6 +272,9 @@ class MediaDisplayComponent : public Component, virtual Component* getMediaComponent() { return this; } + /** Whether this track has a timeline for a playback cursor to move along. */ + virtual bool hasPlaybackCursor() const { return ! isThumbnailTrack(); } + virtual float getMediaHeight() { return static_cast<float>(getMediaComponent()->getHeight()); } virtual float getMediaWidth() { return static_cast<float>(getMediaComponent()->getWidth()); } virtual float getVerticalControlsWidth() { return 0.0f; } @@ -218,16 +306,21 @@ class MediaDisplayComponent : public Component, void mouseUp(const MouseEvent& e) override; void mouseDoubleClick(const MouseEvent& e) override; + // Moves the playback position to the mouse, where the track allows it + void seekToMouse(const MouseEvent& e); + // Drags the track's file to another track, the media clipboard, or another application + void startFileDrag(const MouseEvent& e); + virtual bool shouldRenderLabel(const std::unique_ptr<OutputLabel>& /*l*/) const { return true; } const int textSpacing = 2; const int minFontSize = 10; - const int labelHeight = 20; Colour defaultColor = Colours::darkgrey; Colour graphicsColor = Colours::lightblue; Colour cursorColor = Colours::white.withAlpha(0.85f); - Colour selectionColor = Colours::darkblue.brighter(); + // The accent used for whatever is selected or highlighted on the Home tab and model tabs + Colour selectionColor = ModelStyle::accentDark; Colour linkedToDAWColor = Colours::purple.withAlpha(0.5f); Colour overheadPanelColor = Colours::darkgrey.darker(); @@ -240,16 +333,25 @@ class MediaDisplayComponent : public Component, // Header sub-components Label trackNameLabel; - MultiButton playStopButton; + MultiButton playPauseButton; MultiButton::Mode playButtonActiveInfo; MultiButton::Mode playButtonInactiveInfo; - MultiButton::Mode stopButtonInfo; + MultiButton::Mode pauseButtonInfo; + MultiButton stopButton; + MultiButton::Mode stopButtonActiveInfo; + MultiButton::Mode stopButtonInactiveInfo; MultiButton chooseFileButton; MultiButton::Mode chooseFileButtonActiveInfo; MultiButton::Mode chooseFileButtonInactiveInfo; MultiButton saveFileButton; MultiButton::Mode saveFileButtonActiveInfo; MultiButton::Mode saveFileButtonInactiveInfo; + MultiButton copyFileButton; + MultiButton::Mode copyFileButtonActiveInfo; + MultiButton::Mode copyFileButtonInactiveInfo; + + // Banner shown on left edge of optional input tracks + OptionalBannerComponent optionalBanner; // Panel displaying overhead labels ColorablePanel overheadPanel { overheadPanelColor }; @@ -270,10 +372,9 @@ class MediaDisplayComponent : public Component, const DisplayMode displayMode; bool isSelected = false; + bool paused = false; URL originalFilePath; - int currentTempFileIdx; - Array<URL> tempFilePaths; std::unique_ptr<FileChooser> chooseFileBrowser; std::unique_ptr<FileChooser> saveFileBrowser; diff --git a/src/media/MidiDisplayComponent.cpp b/src/media/MidiDisplayComponent.cpp index 2c13d614..59a097ca 100644 --- a/src/media/MidiDisplayComponent.cpp +++ b/src/media/MidiDisplayComponent.cpp @@ -11,8 +11,16 @@ MidiDisplayComponent::MidiDisplayComponent(String name, bool req, bool fromDAW, pianoRoll.addMouseListener(this, true); contentComponent.addAndMakeVisible(pianoRoll); - mediaInstructions = - "MIDI pianoroll.\nClick and drag to start playback from any point in the pianoroll\nVertical or Horizontal scroll to move.\nCmd+scroll to zoom in both axis."; +#if JUCE_MAC + const String pitchModifier = "Cmd"; +#else + const String pitchModifier = "Ctrl"; +#endif + + mediaInstructions = "MIDI piano roll.\nClick or drag to move the playback position.\n" + "Scroll to zoom in time, or hold Shift while scrolling to pan.\nHold " + + pitchModifier + " while scrolling to zoom in pitch, or " + pitchModifier + + "+Shift to move through pitch."; } MidiDisplayComponent::~MidiDisplayComponent() @@ -48,36 +56,34 @@ void MidiDisplayComponent::loadMediaFile(const URL& filePath) MidiFile midiFile; - if (! midiFile.readFrom(*fileStream)) + if (fileStream == nullptr || ! midiFile.readFrom(*fileStream)) { + // Left empty, as a file without notes is DBG_AND_LOG("MidiDisplayComponent::loadMediaFile: Error reading MIDI from file " << file.getFullPathName() << "."); - // TODO - better error handing - //jassertfalse; - //return; + + return; } midiFile.convertTimestampTicksToSeconds(); totalLengthInSecs = midiFile.getLastTimestamp(); - //DBG_AND_LOG("MidiDisplayComponent::loadMediaFile: Total duration of MIDI file " << totalLengthInSecs << " seconds."); - pianoRoll.resizeNoteGrid(totalLengthInSecs); MidiMessageSequence allTracks; for (int trackIdx = 0; trackIdx < midiFile.getNumTracks(); ++trackIdx) { - const MidiMessageSequence* constTrack = midiFile.getTrack(trackIdx); - - if (constTrack != nullptr) + if (const MidiMessageSequence* track = midiFile.getTrack(trackIdx)) { - allTracks.addSequence(*constTrack, 0.0); - allTracks.updateMatchedPairs(); + allTracks.addSequence(*track, 0.0); } } + // Pairs each note-on with the note-off that ends it + allTracks.updateMatchedPairs(); + // Keep track of note MIDI numbers std::vector<int> midiNumbers; @@ -90,77 +96,56 @@ void MidiDisplayComponent::loadMediaFile(const URL& filePath) const auto midiEvent = allTracks.getEventPointer(eventIdx); const auto& midiMessage = midiEvent->message; - double startTime = midiEvent->message.getTimeStamp(); - - //DBG_AND_LOG("MidiDisplayComponent::loadMediaFile: Event " << eventIdx << " at " << startTime << ": " << midiMessage.getDescription()); - - int channel = midiMessage.getChannel(); - - if (channel == 10) - { - // TODO - handle drums channel - } + // Only channel messages are used, which number their channels from 1 to 16 + const auto channelIdx = (size_t) (midiMessage.getChannel() - 1); if (midiMessage.isProgramChange()) { // Update instrument number for current channel - instrumentNumbers[channel - 1] = midiMessage.getProgramChangeNumber(); + instrumentNumbers[channelIdx] = midiMessage.getProgramChangeNumber(); } if (midiMessage.isNoteOn()) { - int noteChannel = midiMessage.getChannel(); - int noteNumber = midiMessage.getNoteNumber(); - int velocity = midiMessage.getVelocity(); - int instrument = instrumentNumbers[channel - 1]; + const int noteNumber = midiMessage.getNoteNumber(); + const double startTime = midiMessage.getTimeStamp(); midiNumbers.push_back(noteNumber); - double duration = 0; - - for (int offIdx = eventIdx + 1; offIdx < allTracks.getNumEvents(); ++offIdx) - { - const auto offEvent = allTracks.getEventPointer(offIdx); - - // Find matching note offset event - if (offEvent->message.isNoteOff() && offEvent->message.getNoteNumber() == noteNumber - && offEvent->message.getChannel() == noteChannel) - { - duration = - (offEvent->message.getTimeStamp() - midiEvent->message.getTimeStamp()); - break; - } - } - - // Create component for each for each note and add to pianoroll - MidiNote n = MidiNote(static_cast<unsigned char>(noteNumber), - startTime, - duration, - static_cast<unsigned char>(velocity), - static_cast<unsigned char>(instrument)); - pianoRoll.insertNote(n); + // A note never released lasts no time at all + const double duration = + midiEvent->noteOffObject != nullptr + ? midiEvent->noteOffObject->message.getTimeStamp() - startTime + : 0.0; + + // Create component for each note and add to pianoroll + pianoRoll.insertNote(MidiNote(static_cast<unsigned char>(noteNumber), + startTime, + duration, + static_cast<unsigned char>(midiMessage.getVelocity()), + static_cast<unsigned char>(instrumentNumbers[channelIdx]), + midiMessage.getChannel() == 10)); // Drums } } - // Compute median and standard deviation of MIDI numbers - std::sort(midiNumbers.begin(), midiNumbers.end()); - - int numNotes = midiNumbers.size(); + if (! midiNumbers.empty()) + { + // Compute median and standard deviation of MIDI numbers + std::sort(midiNumbers.begin(), midiNumbers.end()); - medianMidi = midiNumbers[numNotes / 2]; + const size_t numNotes = midiNumbers.size(); - if (! numNotes % 2) - { - medianMidi += midiNumbers[numNotes / 2 - 1]; - medianMidi /= 2; - } + medianMidi = numNotes % 2 == 0 + ? (midiNumbers[numNotes / 2 - 1] + midiNumbers[numNotes / 2]) / 2 + : midiNumbers[numNotes / 2]; - float sum = std::accumulate(midiNumbers.begin(), midiNumbers.end(), 0.0f); - float mean = sum / static_cast<float>(numNotes); - float sq_sum = - std::inner_product(midiNumbers.begin(), midiNumbers.end(), midiNumbers.begin(), 0.0f); + const float mean = std::accumulate(midiNumbers.begin(), midiNumbers.end(), 0.0f) + / static_cast<float>(numNotes); + const float squareSum = + std::inner_product(midiNumbers.begin(), midiNumbers.end(), midiNumbers.begin(), 0.0f); - stdDevMidi = std::sqrt(sq_sum / static_cast<float>(numNotes) - mean * mean); + stdDevMidi = std::sqrt(jmax(0.0f, squareSum / static_cast<float>(numNotes) - mean * mean)); + } synthAudioSource.useSequence(allTracks); transportSource.setSource(&synthAudioSource); @@ -174,6 +159,10 @@ void MidiDisplayComponent::resetMedia() pianoRoll.resizeNoteGrid(0.0); totalLengthInSecs = 0.0; + + // Centered on middle C until notes say otherwise + medianMidi = defaultMedianMidi; + stdDevMidi = defaultStdDevMidi; } void MidiDisplayComponent::postLoadActions(const URL& /*filePath*/) @@ -246,15 +235,25 @@ void MidiDisplayComponent::mouseWheelMove(const MouseEvent& evt, const MouseWhee // Do nothing } } + else if (getTotalLengthInSecs() > 0.0) + { + MediaDisplayComponent::mouseWheelMove(evt, wheel); + } else { - if (getTotalLengthInSecs() > 0.0) - { - MediaDisplayComponent::mouseWheelMove(evt, wheel); - } + // No timeline to move along, so let the panel scroll instead + Component::mouseWheelMove(evt, wheel); } } +void MidiDisplayComponent::setPlaybackPosition(double t) +{ + MediaDisplayComponent::setPlaybackPosition(t); + + // A seek during playback would otherwise hold the notes sounding at the old position + synthAudioSource.resetNotes(); +} + void MidiDisplayComponent::startPlaying() { synthAudioSource.resetNotes(); diff --git a/src/media/MidiDisplayComponent.h b/src/media/MidiDisplayComponent.h index 1c42c680..273ff70c 100644 --- a/src/media/MidiDisplayComponent.h +++ b/src/media/MidiDisplayComponent.h @@ -1,7 +1,7 @@ /** * @file MidiDisplayComponent.h * @brief Display specifics for MIDI (pianoroll). - * @author xribene, cwitkowitz + * @author cwitkowitz, xribene */ #pragma once @@ -35,6 +35,8 @@ class MidiDisplayComponent : public MediaDisplayComponent double getTotalLengthInSecs() override { return totalLengthInSecs; } float getPixelsPerSecond() override { return static_cast<float>(pianoRoll.getResolution()); } + void setPlaybackPosition(double t) override; + private: void visibleRangeCallback() override {} void changeListenerCallback(ChangeBroadcaster*) override { repositionLabels(); } @@ -65,8 +67,12 @@ class MidiDisplayComponent : public MediaDisplayComponent return dynamic_cast<MidiLabel*>(l.get()) != nullptr; } - int medianMidi; - float stdDevMidi; + static constexpr int defaultMedianMidi = 60; + static constexpr float defaultStdDevMidi = 12.0f; + + // Where the notes lie, which the piano roll is centered on + int medianMidi = defaultMedianMidi; + float stdDevMidi = defaultStdDevMidi; double totalLengthInSecs = 0.0; diff --git a/src/media/OutputLabelComponent.cpp b/src/media/OutputLabelComponent.cpp index 0ecb510f..4089b24d 100644 --- a/src/media/OutputLabelComponent.cpp +++ b/src/media/OutputLabelComponent.cpp @@ -23,20 +23,6 @@ OutputLabelComponent::OutputLabelComponent(double t, setDefaultAttributes(); } -OutputLabelComponent::OutputLabelComponent(const OutputLabelComponent& other) -{ - setTime(other.getTime()); - setLabel(other.getLabel()); - setDuration(other.getDuration()); - setDescription(other.getDescription()); - setColor(other.getColor()); - setLink(other.getLink()); - - setDefaultAttributes(); -} - -OutputLabelComponent::~OutputLabelComponent() {} - void OutputLabelComponent::setDefaultAttributes() { setText(getLabel(), dontSendNotification); @@ -56,7 +42,10 @@ void OutputLabelComponent::setColor(Colour clr) durationFill.setColor(clr.withAlpha(0.5f)); } -float OutputLabelComponent::getTextWidth() { return getFont().getStringWidthFloat(getText()); } +float OutputLabelComponent::getTextWidth() +{ + return (float) ModelStyle::getTextWidth(getFont(), getText()); +} void OutputLabelComponent::addMarkersTo(Component* c) { @@ -145,11 +134,6 @@ OverheadLabelComponent::OverheadLabelComponent(double t, { } -OverheadLabelComponent::OverheadLabelComponent(const OverheadLabelComponent& other) - : OutputLabelComponent(other) -{ -} - LabelOverlayComponent::LabelOverlayComponent(double t, String lbl, float y, @@ -162,12 +146,6 @@ LabelOverlayComponent::LabelOverlayComponent(double t, setRelativeY(y); } -LabelOverlayComponent::LabelOverlayComponent(const LabelOverlayComponent& other) - : OutputLabelComponent(other) -{ - setRelativeY(other.getRelativeY()); -} - float LabelOverlayComponent::amplitudeToRelativeY(float amplitude) { return jmin(1.0f, jmax(0.0f, 1 - (amplitude + 1) / 2)); diff --git a/src/media/OutputLabelComponent.h b/src/media/OutputLabelComponent.h index f283fe09..21e9e0d1 100644 --- a/src/media/OutputLabelComponent.h +++ b/src/media/OutputLabelComponent.h @@ -8,6 +8,7 @@ #include <juce_gui_basics/juce_gui_basics.h> +#include "../gui/Palette.h" #include "../widgets/StatusAreaWidget.h" #include "../utils/Logging.h" @@ -40,8 +41,7 @@ class OutputLabelComponent : public Label String dsc = "", Colour clr = Colours::purple.withAlpha(0.8f), String lnk = ""); - OutputLabelComponent(const OutputLabelComponent& other); - ~OutputLabelComponent() override; + ~OutputLabelComponent() override = default; void setDefaultAttributes(); @@ -51,7 +51,6 @@ class OutputLabelComponent : public Label void setDescription(String d) { description = d; } void setColor(Colour clr); void setLink(String lnk) { link = lnk; } - void setIndex(int i) { processingIndex = i; } double getTime() const { return time; } String getLabel() const { return label; } @@ -59,7 +58,6 @@ class OutputLabelComponent : public Label String getDescription() const { return description; } Colour getColor() const { return color; } String getLink() const { return link; } - int getIndex() const { return processingIndex; } float getTextWidth(); @@ -88,8 +86,6 @@ class OutputLabelComponent : public Label void mouseEnter(const MouseEvent& e) override; void mouseExit(const MouseEvent& e) override; - int processingIndex = 0; - TimeMarkerComponent leftMarker; TimeMarkerComponent rightMarker; TimeMarkerComponent durationFill; @@ -101,7 +97,6 @@ class OverheadLabelComponent : public OutputLabelComponent { public: OverheadLabelComponent(double t, String lbl, double dur, String dsc, Colour clr, String lnk); - OverheadLabelComponent(const OverheadLabelComponent& other); }; class LabelOverlayComponent : public OutputLabelComponent @@ -114,10 +109,8 @@ class LabelOverlayComponent : public OutputLabelComponent String dsc, Colour clr, String lnk); - LabelOverlayComponent(const LabelOverlayComponent& other); static float amplitudeToRelativeY(float amplitude); - //static float frequencyToRelativeY(float frequency); static float pitchToRelativeY(float pitch); void setRelativeY(float y) { relativeY = y; } @@ -125,5 +118,5 @@ class LabelOverlayComponent : public OutputLabelComponent float getRelativeY() const { return relativeY; } private: - float relativeY; + float relativeY = 0.0f; }; diff --git a/src/media/pianoroll/KeyboardComponent.cpp b/src/media/pianoroll/KeyboardComponent.cpp index c674f0b2..42873473 100644 --- a/src/media/pianoroll/KeyboardComponent.cpp +++ b/src/media/pianoroll/KeyboardComponent.cpp @@ -10,6 +10,10 @@ void KeyboardComponent::paint(Graphics& g) { const float keyHeight = getKeyHeight(); + /* Shrinks the text on short keys. Whole-pixel heights keep the rendered glyphs + cached, where a height that follows every resize would re-render them each time. */ + const float labelFontHeight = std::floor(jmin(14.0f, keyHeight - 1.0f)); + float cumHeight = 0; // Cumulative height for (int i = 127; i >= 0; i--) @@ -39,6 +43,7 @@ void KeyboardComponent::paint(Graphics& g) String noteName = String(i) + " (" + pitchNames[pitch] + String(octave) + ")"; g.setColour(Colours::white); + g.setFont(labelFontHeight); g.drawText(noteName, 5, static_cast<int>(cumHeight), diff --git a/src/media/pianoroll/KeyboardComponent.hpp b/src/media/pianoroll/KeyboardComponent.hpp index 105ebea0..a28ffb30 100644 --- a/src/media/pianoroll/KeyboardComponent.hpp +++ b/src/media/pianoroll/KeyboardComponent.hpp @@ -1,7 +1,7 @@ /** * @file KeyboardComponent.hpp * @brief Reusable component with rows of piano keys. - * @author xribene, cwitkowitz + * @author cwitkowitz, xribene */ #pragma once @@ -13,14 +13,14 @@ using namespace juce; class KeyboardComponent : public Component { public: - KeyboardComponent() {}; + KeyboardComponent() {} - ~KeyboardComponent() {}; + ~KeyboardComponent() override {} static const char* pitchNames[]; static const Array<int> blackPitches; - void paint(Graphics& g); + void paint(Graphics& g) override; virtual bool isKeyboardComponent() { return true; } diff --git a/src/media/pianoroll/NoteGridComponent.cpp b/src/media/pianoroll/NoteGridComponent.cpp index 3fc574c7..ab371bf7 100644 --- a/src/media/pianoroll/NoteGridComponent.cpp +++ b/src/media/pianoroll/NoteGridComponent.cpp @@ -6,47 +6,64 @@ NoteGridComponent::NoteGridComponent() lengthInSeconds = 0.0; } -NoteGridComponent::~NoteGridComponent() -{ - //resetNotes(); -} +NoteGridComponent::~NoteGridComponent() = default; void NoteGridComponent::paint(Graphics& g) { // Paint key background KeyboardComponent::paint(g); + const float noteHeight = getKeyHeight(); + + // A drum hit has no duration to speak of, so it is drawn as a cross of a fixed width + const float drumWidth = 0.01f * static_cast<float>(getParentWidth()); + // Paint all notes - for (auto n : midiNotes) + for (const auto& n : midiNotes) { - const float noteWidth = static_cast<float>(n->duration * pixelsPerSecond); - const float noteHeight = getKeyHeight(); + const float noteWidth = static_cast<float>(n.duration * pixelsPerSecond); - const float noteXPos = static_cast<float>(n->startTime * pixelsPerSecond); - const float noteYPos = static_cast<float>(getHeight()) - ((n->noteNumber) * noteHeight); + const float noteXPos = static_cast<float>(n.startTime * pixelsPerSecond); + const float noteYPos = static_cast<float>(getHeight()) - ((n.noteNumber) * noteHeight); Rectangle<float> bounds(noteXPos, noteYPos, jmax(3.0f, noteWidth), noteHeight - 1.0f); - float hue = static_cast<float>(n->instrument) / 127.0f; + float hue = static_cast<float>(n.instrument) / 127.0f; Colour color = Colour::fromHSV(hue, 1.0f, 1.0f, 1.0f).brighter(); - // Note fill - g.setColour(color.withAlpha(0.75f)); - g.fillRect(bounds.toNearestInt()); + if (n.isDrum) + { + g.setColour(color); + + const float thickness = jmax(2.0f, noteHeight * 0.18f); + const float pad = jmax(1.0f, thickness * 0.5f); - if ((noteWidth >= 5) & (noteHeight >= 8)) + const float x1 = noteXPos - drumWidth / 2.0f; + const float y1 = bounds.getY() + pad; + const float x2 = noteXPos + drumWidth / 2.0f; + const float y2 = bounds.getBottom() - pad; + + g.drawLine(x1, y1, x2, y2, thickness); + g.drawLine(x2, y1, x1, y2, thickness); + } + else { - const float maxVelocityWidth = static_cast<float>(noteWidth - 4); - const float verticalOffset = static_cast<float>(noteHeight) * 0.5f - 2.0f; - const float velocityHeight = 4.0f; - - // Velocity fill - g.setColour(color.brighter()); - g.fillRect(bounds.translated(2, verticalOffset) - .withWidth(maxVelocityWidth * n->velocity / 127.0f) - .withHeight(velocityHeight) - .toNearestInt()); + g.setColour(color.withAlpha(0.75f)); + g.fillRect(bounds.toNearestInt()); + + if ((noteWidth >= 5) && (noteHeight >= 8)) + { + const float maxVelocityWidth = static_cast<float>(noteWidth - 4); + const float verticalOffset = static_cast<float>(noteHeight) * 0.5f - 2.0f; + const float velocityHeight = 4.0f; + + g.setColour(color.brighter()); + g.fillRect(bounds.translated(2, verticalOffset) + .withWidth(maxVelocityWidth * n.velocity / 127.0f) + .withHeight(velocityHeight) + .toNearestInt()); + } } } } @@ -72,20 +89,13 @@ void NoteGridComponent::updateSize() void NoteGridComponent::insertNote(MidiNote n) { - MidiNote* note = new MidiNote(n); - midiNotes.add(note); + midiNotes.push_back(n); repaint(); } void NoteGridComponent::resetNotes() { - for (int i = 0; i < midiNotes.size(); i++) - { - MidiNote* note = midiNotes.getReference(i); - delete note; - } - midiNotes.clear(); repaint(); diff --git a/src/media/pianoroll/NoteGridComponent.hpp b/src/media/pianoroll/NoteGridComponent.hpp index 7a3eb19a..e4b4205b 100644 --- a/src/media/pianoroll/NoteGridComponent.hpp +++ b/src/media/pianoroll/NoteGridComponent.hpp @@ -1,11 +1,13 @@ /** * @file NoteGridComponent.hpp * @brief Component with rows of piano keys and MIDI notes. - * @author xribene, cwitkowitz + * @author cwitkowitz, derekllanes, xribene */ #pragma once +#include <vector> + #include <juce_gui_basics/juce_gui_basics.h> #include "KeyboardComponent.hpp" @@ -15,18 +17,14 @@ using namespace juce; struct MidiNote { public: - MidiNote(unsigned char n, double s, double d, unsigned char v, unsigned char i = 0) - : noteNumber(n), startTime(s), duration(d), velocity(v), instrument(i) - { - } - - MidiNote(const MidiNote& other) + MidiNote(unsigned char n, + double s, + double d, + unsigned char v, + unsigned char i = 0, + bool drum = false) + : noteNumber(n), startTime(s), duration(d), velocity(v), instrument(i), isDrum(drum) { - noteNumber = other.noteNumber; - startTime = other.startTime; - duration = other.duration; - velocity = other.velocity; - instrument = other.instrument; } unsigned char noteNumber; @@ -34,6 +32,7 @@ struct MidiNote double duration; unsigned char velocity; unsigned char instrument; + bool isDrum; }; class NoteGridComponent : public KeyboardComponent @@ -49,7 +48,6 @@ class NoteGridComponent : public KeyboardComponent double getPixelsPerSecond() { return pixelsPerSecond; } void setLength(double len); - double getLengthInSeconds() { return lengthInSeconds; } bool isKeyboardComponent() override { return false; } @@ -59,7 +57,7 @@ class NoteGridComponent : public KeyboardComponent void resetNotes(); private: - Array<MidiNote*> midiNotes; + std::vector<MidiNote> midiNotes; double pixelsPerSecond; double lengthInSeconds; diff --git a/src/media/pianoroll/PianoRollComponent.cpp b/src/media/pianoroll/PianoRollComponent.cpp index 9f23eee4..96f22cf5 100644 --- a/src/media/pianoroll/PianoRollComponent.cpp +++ b/src/media/pianoroll/PianoRollComponent.cpp @@ -34,10 +34,7 @@ PianoRollComponent::PianoRollComponent(int kbw, int prs, int sbsz, int sbsp, boo resizeNoteGrid(0.0); } -PianoRollComponent::~PianoRollComponent() -{ - //resetNotes(); -} +PianoRollComponent::~PianoRollComponent() = default; void PianoRollComponent::resized() { @@ -181,8 +178,11 @@ void PianoRollComponent::scrollBarMoved(ScrollBar* scrollBarThatHasMoved, void PianoRollComponent::autoCenterViewBox(int medianMidi, float stdDevMidi) { - // Make sure vertical range is less than maximum keys visible - float halfRange = jmin(2.0f * stdDevMidi, static_cast<float>(maxKeysVisible / 2.0f)); + // Keep the vertical range within the number of keys the roll can show, which notes that + // all share one pitch would otherwise shrink to nothing + const float halfRange = jlimit(static_cast<float>(minKeysVisible) / 2.0f, + static_cast<float>(maxKeysVisible) / 2.0f, + 2.0f * stdDevMidi); double zoomAmount = keysVisibleToZoom(2.0f * halfRange); diff --git a/src/media/pianoroll/PianoRollComponent.hpp b/src/media/pianoroll/PianoRollComponent.hpp index 3de5a7f3..b9cd021d 100644 --- a/src/media/pianoroll/PianoRollComponent.hpp +++ b/src/media/pianoroll/PianoRollComponent.hpp @@ -1,7 +1,7 @@ /** * @file PianoRollComponent.hpp * @brief Top-level container for pianoroll components. - * @author xribene, cwitkowitz + * @author cwitkowitz, xribene * * Note that this pianoroll implementation was adapted from: * https://github.com/Sjhunt93/Piano-Roll-Editor @@ -51,7 +51,6 @@ class PianoRollComponent : public Component, public ChangeBroadcaster, private S int getControlsWidth(); float getKeyHeight(); - //Range<double> getVisibleMediaRange() { return visibleMediaRange; } Range<double> getVisibleKeyRange() { return visibleKeyRange; } void resizeNoteGrid(double lengthInSecs); diff --git a/src/media/pianoroll/SynthAudioSource.h b/src/media/pianoroll/SynthAudioSource.h index 8266aa9a..9c3dd61e 100644 --- a/src/media/pianoroll/SynthAudioSource.h +++ b/src/media/pianoroll/SynthAudioSource.h @@ -1,7 +1,7 @@ /** * @file SynthAudioSource.h * @brief Handles MIDI playback for pianoroll. - * @author nathanpruyne + * @author nathanpruyne, cwitkowitz * * Note that this file was adapted from: * https://juce.com/tutorials/tutorial_synth_using_midi_input/ @@ -23,6 +23,9 @@ struct SineWaveVoice : public SynthesiserVoice { SineWaveVoice() {} + // Only the float overload is rendered, and the double one stays as JUCE defines it + using SynthesiserVoice::renderNextBlock; + bool canPlaySound(SynthesiserSound* sound) override { return dynamic_cast<SineWaveSound*>(sound) != nullptr; @@ -48,7 +51,7 @@ struct SineWaveVoice : public SynthesiserVoice { if (allowTailOff) { - if (tailOff == 0.0) + if (exactlyEqual(tailOff, 0.0)) { tailOff = 1.0; } @@ -66,7 +69,7 @@ struct SineWaveVoice : public SynthesiserVoice void renderNextBlock(AudioSampleBuffer& outputBuffer, int startSample, int numSamples) override { - if (angleDelta != 0.0) + if (! exactlyEqual(angleDelta, 0.0)) { if (tailOff > 0.0) { @@ -127,14 +130,8 @@ class SynthAudioSource : public PositionableAudioSource public: SynthAudioSource() { synth.addSound(new SineWaveSound()); } - void setUsingSineWaveSound() { synth.clearSounds(); } - void prepareToPlay(int samplesPerBlockExpected, double sr) override { - //DBG_AND_LOG("SynthAudioSource::prepareToPlay: Sample rate being set to " << sr << "."); - //DBG_AND_LOG("SynthAudioSource::prepareToPlay: Samples per block being set to " - // << samplesPerBlockExpected << "."); - synth.setCurrentPlaybackSampleRate(sr); sampleRate = sr; samplesPerBlock = samplesPerBlockExpected; @@ -174,8 +171,6 @@ class SynthAudioSource : public PositionableAudioSource } } - //DBG_AND_LOG("SynthAudioSource::useSequence Max voices set to " << maxVoices << "."); - synth.clearVoices(); for (auto i = 0; i < maxVoices; ++i) diff --git a/src/utils/Clients.h b/src/utils/Clients.h index 4d60ffbd..10e9c3cf 100644 --- a/src/utils/Clients.h +++ b/src/utils/Clients.h @@ -17,46 +17,80 @@ using namespace juce; -inline std::unique_ptr<Client> multiplexClients(Provider provider) +inline std::unique_ptr<Client> createClient(Provider provider) { if (provider == Provider::Stability) { - DBG_AND_LOG("utils::multiplexClients: Initializing Stability client."); - return std::make_unique<StabilityClient>(); } - else - { - DBG_AND_LOG("utils::multiplexClients: Initializing Gradio client."); - return std::make_unique<GradioClient>(); - } + return std::make_unique<GradioClient>(); } -inline OpResult multiplexClients(String modelPath, std::unique_ptr<Client>& client) +/** + * The client of the provider that recognizes a model path, or nullptr if none does. + * + * Gradio is checked last, as the catch-all for paths that match no other provider. + */ +inline std::unique_ptr<Client> createClientForPath(const String& modelPath) { if (StabilityClient::matchesPathSpec(modelPath)) { - DBG_AND_LOG( - "utils::multiplexClients: Stability AI path detected. Initializing Stability client."); - - client = std::make_unique<StabilityClient>(); + return createClient(Provider::Stability); } - // Check Gradio last to serve as a catch-all if no third-party provider path specifications match - else if (GradioClient::matchesPathSpec(modelPath)) - { - DBG_AND_LOG("utils::multiplexClients: Gradio path detected. Initializing Gradio client."); - client = std::make_unique<GradioClient>(); - } - else + if (GradioClient::matchesPathSpec(modelPath)) { - ClientError error { ClientError::Type::UnknownClient, modelPath, {} }; + return createClient(Provider::HuggingFace); + } + + return nullptr; +} - DBG_AND_LOG("utils::multiplexClients: " << toUserMessage(error)); +/** + * Whether any provider recognizes a model path, so that one no client could load can be + * rejected before anything is attempted with it. + */ +inline bool isSupportedModelPath(const String& modelPath) +{ + return createClientForPath(modelPath) != nullptr; +} + +/** + * Reduces a model path to the single form its provider recognizes it by. + * + * Returns the path unchanged when no provider claims it, so that an unrecognized path + * still fails to load with a message about the path itself. + */ +inline String canonicalizeModelPath(const String& modelPath) +{ + auto client = createClientForPath(modelPath); + + return client != nullptr ? client->canonicalizePath(modelPath) : modelPath; +} + +/** + * The model cards of models whose controls ship with HARP, keyed by model path, so that + * they can be listed without querying anything. + */ +inline std::vector<std::pair<String, DynamicObject::Ptr>> getBuiltInModelCards() +{ + static const Identifier cardKey { "card" }; + + std::vector<std::pair<String, DynamicObject::Ptr>> cards; + + StabilityClient stabilityClient; + + for (const auto& modelPath : StabilityClient::getModelPaths()) + { + DynamicObject::Ptr controls; - return OpResult::fail(error); + if (stabilityClient.queryControls(modelPath, controls).wasOk() && controls != nullptr) + { + if (auto* card = controls->getProperty(cardKey).getDynamicObject()) + cards.emplace_back(modelPath, card); + } } - return OpResult::ok(); + return cards; } diff --git a/src/utils/Controls.h b/src/utils/Controls.h index 051cb9c0..cd9cb41c 100644 --- a/src/utils/Controls.h +++ b/src/utils/Controls.h @@ -1,13 +1,20 @@ /** * @file Controls.h * @brief Defines data structures representing various types of controls, inputs, and outputs. - * @author xribene + * @author cwitkowitz, xribene */ #pragma once +#include <algorithm> + #include <juce_core/juce_core.h> +#include "../gui/FileChooserWithLabel.h" +#include "../gui/MultiSelectWithLabel.h" + +#include "Logging.h" + using namespace juce; /** @@ -26,6 +33,174 @@ inline bool stringToBool(const String& str) return false; } +/* + Readers for the fields of a control specification. + + Each returns true when the field was present, held the expected type, and was + applied. A field that is missing, explicitly null, or of the wrong type leaves + its destination untouched, so a specification HARP does not understand falls + back to the control's own defaults rather than to whatever the member happened + to hold. Returning whether a field was applied also lets a caller tell an + omitted field from one that was set to the default value. + + A field of the wrong type is logged, since that points at a mismatch between + HARP and the model rather than at anything the user can correct. +*/ + +inline void logUnexpectedType(const Identifier& key, const String& expected, const var& value) +{ + DBG_AND_LOG("Controls: Field \"" << key.toString() << "\" was expected to hold a " << expected + << " but holds \"" << value.toString() + << "\". Falling back to the default."); +} + +// Retrieves a field that is both present and not null, which is how an unset field arrives +inline bool findField(DynamicObject* input, const Identifier& key, var& property) +{ + if (input == nullptr || ! input->hasProperty(key)) + { + return false; + } + + property = input->getProperty(key); + + return ! property.isVoid(); +} + +inline bool readNumber(DynamicObject* input, const Identifier& key, double& destination) +{ + var property; + + if (! findField(input, key, property)) + { + return false; + } + + if (property.isDouble() || property.isInt() || property.isInt64()) + { + destination = (double) property; + + return true; + } + + // A provider may quote its numbers, so a string is accepted when it reads as one + const String text = property.toString().trim(); + + if (text.isNotEmpty() && text.containsOnly("0123456789+-.eE")) + { + destination = text.getDoubleValue(); + + return true; + } + + logUnexpectedType(key, "number", property); + + return false; +} + +inline bool readBool(DynamicObject* input, const Identifier& key, bool& destination) +{ + var property; + + if (! findField(input, key, property)) + { + return false; + } + + if (property.isBool() || property.isInt() || property.isInt64() || property.isDouble()) + { + destination = (bool) property; + + return true; + } + + if (property.isString()) + { + destination = stringToBool(property.toString()); + + return true; + } + + logUnexpectedType(key, "boolean", property); + + return false; +} + +inline bool readString(DynamicObject* input, const Identifier& key, std::string& destination) +{ + var property; + + if (! findField(input, key, property)) + { + return false; + } + + // Anything with structure would stringify into something meaningless to show + if (property.isArray() || property.isObject() || property.isMethod()) + { + logUnexpectedType(key, "string", property); + + return false; + } + + destination = property.toString().toStdString(); + + return true; +} + +/* + Appends the entries of a list field. + + Gradio normalizes dropdown choices to (label, value) pairs, so an entry that is + itself a list contributes only its label, which is both what is displayed and + what is sent back. A lone value counts as a list of one, which is how a + multiselect dropdown given a single default arrives. +*/ +inline bool readStringList(DynamicObject* input, + const Identifier& key, + std::vector<std::string>& destination) +{ + var property; + + if (! findField(input, key, property)) + { + return false; + } + + const Array<var>* entries = property.getArray(); + + if (entries == nullptr) + { + if (property.isObject() || property.isMethod()) + { + logUnexpectedType(key, "list", property); + + return false; + } + + destination.push_back(property.toString().toStdString()); + + return true; + } + + for (const var& entry : *entries) + { + if (const Array<var>* pair = entry.getArray()) + { + if (! pair->isEmpty()) + { + destination.push_back(pair->getFirst().toString().toStdString()); + } + } + else + { + destination.push_back(entry.toString().toStdString()); + } + } + + return true; +} + struct ModelComponentInfo { Uuid id { "" }; @@ -40,16 +215,8 @@ struct ModelComponentInfo { id = Uuid(); - // TODO - check that the following properties are of the correct type - - if (input->hasProperty("label")) - { - label = input->getProperty("label").toString().toStdString(); - } - if (input->hasProperty("info")) - { - info = input->getProperty("info").toString().toStdString(); - } + readString(input, "label", label); + readString(input, "info", info); } }; @@ -57,19 +224,12 @@ struct TrackComponentInfo : public ModelComponentInfo { bool required = true; - std::string path { "" }; // Used when uploading files - TrackComponentInfo() = default; virtual ~TrackComponentInfo() = default; // Make child-tree polymorphic TrackComponentInfo(DynamicObject* input) : ModelComponentInfo(input) { - // TODO - check that the following properties are of the correct type - - if (input->hasProperty("required")) - { - required = stringToBool(input->getProperty("required").toString()); - } + readBool(input, "required", required); } }; @@ -83,50 +243,90 @@ struct MidiTrackComponentInfo : public TrackComponentInfo using TrackComponentInfo::TrackComponentInfo; }; -struct TextBoxComponentInfo : public ModelComponentInfo, public TextEditor::Listener +struct FileComponentInfo : public ModelComponentInfo, public FileChooserWithLabel::Listener { - std::string value { "" }; + bool required = true; - TextBoxComponentInfo(DynamicObject* input) : ModelComponentInfo(input) + std::string path { "" }; + + // Extensions with a leading dot (e.g., ".json"), where none means any file + StringArray fileTypes; + + FileComponentInfo(DynamicObject* input) : ModelComponentInfo(input) { - // TODO - check that the following properties are of the correct type + readBool(input, "required", required); + readString(input, "path", path); - if (input->hasProperty("value")) + /* An empty list is not an error here. A file chooser with no declared types + accepts any file, which is what an unrestricted gr.File means. */ + std::vector<std::string> declaredTypes; + + readStringList(input, "file_types", declaredTypes); + + for (const auto& type : declaredTypes) { - value = input->getProperty("value").toString().toStdString(); + const String extension = String(type).trim(); + + if (extension.isNotEmpty()) + fileTypes.add(extension.startsWithChar('.') ? extension : "." + extension); } } + void fileChooserChanged(FileChooserWithLabel* fileChooser) override + { + path = fileChooser->getPath().toStdString(); + } +}; + +struct TextBoxComponentInfo : public ModelComponentInfo, public TextEditor::Listener +{ + std::string value { "" }; + + TextBoxComponentInfo(DynamicObject* input) : ModelComponentInfo(input) + { + readString(input, "value", value); + } + void textEditorTextChanged(TextEditor& textEditor) override { value = textEditor.getText().toStdString(); } }; -struct NumberBoxComponentInfo : public ModelComponentInfo // TODO - Listener +struct NumberBoxComponentInfo : public ModelComponentInfo, public Slider::Listener { - // TODO - consistency with SliderComponent - double min; - double max; - double value; + /* A number box carries no bounds unless the model sets them, and an unset one + arrives as null. The box still has to be given a concrete range, so an unset + bound becomes a wide one. Leaving it at zero would make the range empty and + pin the box to a single value it could never be edited away from. */ + static constexpr double unboundedLimit = 1.0e9; + + double minimum = -unboundedLimit; + double maximum = unboundedLimit; + + /* How much the increment / decrement buttons move the value. A zero step + would make them do nothing, so fall back to whole numbers. */ + double step = 1.0; + + double value = 0.0; NumberBoxComponentInfo(DynamicObject* input) : ModelComponentInfo(input) { - // TODO - check that the following properties are of the correct type + readNumber(input, "minimum", minimum); + readNumber(input, "maximum", maximum); + readNumber(input, "step", step); - if (input->hasProperty("min")) - { - min = input->getProperty("min").toString().getFloatValue(); - } - if (input->hasProperty("max")) - { - max = input->getProperty("max").toString().getFloatValue(); - } - if (input->hasProperty("value")) + if (step <= 0.0) { - value = input->getProperty("value").toString().getFloatValue(); + step = 1.0; } + + readNumber(input, "value", value); } + + /* Unlike a slider, a number box is edited by typing or by clicking the + increment buttons, so the value is committed on every change */ + void sliderValueChanged(Slider* slider) override { value = slider->getValue(); } }; struct ToggleComponentInfo : public ModelComponentInfo, public Button::Listener @@ -135,12 +335,7 @@ struct ToggleComponentInfo : public ModelComponentInfo, public Button::Listener ToggleComponentInfo(DynamicObject* input) : ModelComponentInfo(input) { - // TODO - check that the following properties are of the correct type - - if (input->hasProperty("value")) - { - value = stringToBool(input->getProperty("value").toString()); - } + readBool(input, "value", value); } void buttonClicked(Button* button) override { value = button->getToggleState(); } @@ -148,30 +343,26 @@ struct ToggleComponentInfo : public ModelComponentInfo, public Button::Listener struct SliderComponentInfo : public ModelComponentInfo, public Slider::Listener { - double minimum; - double maximum; - double step; - double value; + /* The range a gr.Slider falls back on when it declares none of its own */ + double minimum = 0.0; + double maximum = 100.0; + + /* Zero is the slider's own notion of a continuous range, so unlike the number + box above it needs no substitute when the model leaves the step unset. */ + double step = 0.0; + + double value = 0.0; SliderComponentInfo(DynamicObject* input) : ModelComponentInfo(input) { - // TODO - check that the following properties are of the correct type + readNumber(input, "minimum", minimum); + readNumber(input, "maximum", maximum); + readNumber(input, "step", step); - if (input->hasProperty("minimum")) - { - minimum = input->getProperty("minimum").toString().getFloatValue(); - } - if (input->hasProperty("maximum")) - { - maximum = input->getProperty("maximum").toString().getFloatValue(); - } - if (input->hasProperty("step")) + if (! readNumber(input, "value", value)) { - step = input->getProperty("step").toString().getFloatValue(); - } - if (input->hasProperty("value")) - { - value = input->getProperty("value").toString().getFloatValue(); + // With no starting value given, the low end of the range is the safe choice + value = minimum; } } @@ -187,50 +378,63 @@ struct ComboBoxComponentInfo : public ModelComponentInfo, public ComboBox::Liste ComboBoxComponentInfo(DynamicObject* input) : ModelComponentInfo(input) { - // TODO - check that the following properties are of the correct type + readStringList(input, "choices", options); - if (input->hasProperty("choices")) + if (options.empty()) { - Array<var>* choices = input->getProperty("choices").getArray(); - - if (choices == nullptr) - { - // TODO - handle error case: couldn't load choices - } - - int numChoices = choices->size(); + /* A dropdown with nothing to choose from cannot be operated. It is left + empty rather than refused, since the rest of the controls are still + usable and the model may not depend on this one. */ + DBG_AND_LOG("ComboBoxComponentInfo: Dropdown \"" << String(label) + << "\" offers no choices."); - if (numChoices > 0) - { - for (int j = 0; j < numChoices; j++) - { - options.push_back( - choices->getReference(j).getArray()->getFirst().toString().toStdString()); - } - - if (! input->hasProperty("value")) - { - // Set selection to first option - value = options[0]; - } - else - { - // Set selection to chosen value - value = input->getProperty("value").toString().toStdString(); - } - } - else - { - // TODO - handle error case: no choices - } + return; } - else + + // Falling back to the first option keeps the box and this value in agreement + if (! readString(input, "value", value)) { - // TODO - handle error case: no choises provided + value = options.front(); } } void comboBoxChanged(ComboBox* comboBox) override { value = comboBox->getText().toStdString(); } }; +/** + * A dropdown allowing any number of its options to be selected at once, + * corresponding to a gr.Dropdown declared with multiselect=True. + */ +struct MultiSelectComponentInfo : public ModelComponentInfo, public MultiSelectWithLabel::Listener +{ + std::vector<std::string> options; + + std::vector<std::string> values; + + MultiSelectComponentInfo(DynamicObject* input) : ModelComponentInfo(input) + { + readStringList(input, "choices", options); + + if (options.empty()) + { + DBG_AND_LOG("MultiSelectComponentInfo: Dropdown \"" << String(label) + << "\" offers no choices."); + } + + /* Nothing selected is a valid starting state for a multiselect dropdown, so + unlike the single-selection box above there is no fallback to apply. */ + readStringList(input, "value", values); + } + + void multiSelectChanged(MultiSelectWithLabel* multiSelect) override + { + values.clear(); + + for (const String& selected : multiSelect->getSelection()) + { + values.push_back(selected.toStdString()); + } + } +}; + using ModelComponentInfoList = std::vector<std::shared_ptr<ModelComponentInfo>>; diff --git a/src/utils/Enums.h b/src/utils/Enums.h index 0d450c19..fc6e358d 100644 --- a/src/utils/Enums.h +++ b/src/utils/Enums.h @@ -1,7 +1,7 @@ /** * @file Enums.h * @brief Simple helper functions for enums. - * @author huiranyu + * @author cwitkowitz, huiranyu */ #pragma once diff --git a/src/utils/Errors.h b/src/utils/Errors.h index fd9c8318..86cd6fa7 100644 --- a/src/utils/Errors.h +++ b/src/utils/Errors.h @@ -1,13 +1,18 @@ /** * @file Errors.h * @brief Classes and helper functions for error handling. - * @author xribene, cwitkowitz + * @author cwitkowitz, xribene */ #pragma once +#include <optional> + #include <juce_core/juce_core.h> +#include "Enums.h" +#include "Providers.h" + using namespace juce; struct ClientError @@ -16,85 +21,54 @@ struct ClientError { UnknownClient, InvalidModelPath, + ModelNotFound, InsufficientPermissions }; Type type; - String path; - String client; - String token; + String path {}; + + // Absent when no client had been chosen yet, and so no key applies + std::optional<Provider> provider {}; }; inline String toUserMessage(const ClientError& e) { - String userMessage = "A client error occurred."; + const String quotedPath = e.path.isNotEmpty() ? " \"" + e.path + "\"" : String(); + const String providerName = e.provider.has_value() ? getProviderName(*e.provider) : String(); switch (e.type) { case ClientError::Type::UnknownClient: - userMessage = "Path "; - - if (e.path.isNotEmpty()) - { - userMessage += "\"" + e.path + "\" "; - } - - userMessage += "does not match valid specification for any supported clients."; - - return userMessage; + return "No supported provider recognizes the path" + quotedPath + "."; case ClientError::Type::InvalidModelPath: - userMessage = "Path "; - - if (e.path.isNotEmpty()) - { - userMessage += "\"" + e.path + "\" "; - } - - userMessage += "does not match valid specification for client"; - - if (e.client.isNotEmpty()) - { - userMessage += " \"" + e.client + "\""; - } + return "The path" + quotedPath + " does not name a model offered by " + + (providerName.isNotEmpty() ? providerName : String("this provider")) + "."; - userMessage += "."; + case ClientError::Type::ModelNotFound: - return userMessage; + /* Underscores and hyphens are interchangeable in the address used to + reach a model but not in its name, so a misspelling of one for the + other is the likeliest reason to land here. */ + return "No model was found at" + + (quotedPath.isNotEmpty() ? quotedPath : String(" the given path")) + + ". Please check the spelling, including any underscores and" + " capitalization. If the model is private, make sure you have added a" + " valid API key in settings."; case ClientError::Type::InsufficientPermissions: - if (e.token.isEmpty()) - { - userMessage = "Authorization for client"; - - if (e.client.isNotEmpty()) - { - userMessage += " \"" + e.client + "\""; - } - - userMessage += - " failed. Please make sure you have added a valid API key in settings."; - } - else - { - userMessage = "Token \"" + e.token + "\" for client"; - - if (e.client.isNotEmpty()) - { - userMessage += " \"" + e.client + "\""; - } - - userMessage += " does not have sufficient permissions."; - } - - return userMessage; + return "Authorization" + + (providerName.isNotEmpty() ? " with " + providerName : String()) + + " failed. Please make sure you have added a valid API key with sufficient" + " permissions in settings."; } - return userMessage; + return "Something went wrong while setting up the model."; } struct HttpError @@ -104,7 +78,7 @@ struct HttpError InvalidURL, ConnectionFailed, BadStatusCode, - //InvalidResponse + UnexpectedResponse }; Type type; @@ -117,100 +91,131 @@ struct HttpError Request request; - String endpointPath; + String endpointPath {}; int statusCode = 0; + + // Optional diagnostic text extracted from the response body + String detail {}; + + std::optional<Provider> provider {}; }; inline String toUserMessage(const HttpError& e) { - String userMessage = "An HTTP error occurred."; + String userMessage = "Something went wrong while contacting the model."; switch (e.type) { case HttpError::Type::InvalidURL: - userMessage = "Endpoint URL "; - if (e.endpointPath.isNotEmpty()) { - userMessage += "\"" + e.endpointPath + "\" "; + userMessage = "\"" + e.endpointPath + "\" is not a valid address."; + } + else + { + userMessage = "The model's address is not valid."; } - - userMessage += "is malformed."; return userMessage; case HttpError::Type::ConnectionFailed: - userMessage = "Unable to make "; + userMessage = "Could not reach the model."; - if (e.request == HttpError::Request::POST) - { - userMessage += "POST"; - } - else if (e.request == HttpError::Request::GET) - { - userMessage += "GET"; - } - else - { - } + userMessage += "\n\nIt may be asleep, starting up, or temporarily unavailable." + "\n\nTry again in a few seconds, or click 'Open URL' to view the " + "model's page."; - userMessage += " request to endpoint"; + return userMessage; - if (e.endpointPath.isNotEmpty()) - { - userMessage += " \"" + e.endpointPath + "\""; - } + case HttpError::Type::UnexpectedResponse: - userMessage += "."; + userMessage = "This address did not respond like a model endpoint."; - if (e.request == HttpError::Request::POST) - { - userMessage += " If this is a valid Hugging Face space, this " - "could indicate the space is sleeping or restarting. " - "Please try again in a few minutes."; - } + userMessage += "\n\nA web page was returned instead of model data. Check that the " + "address points at a running Gradio app, and that it does not require " + "signing in through a browser."; return userMessage; case HttpError::Type::BadStatusCode: - userMessage.clear(); - - if (e.request == HttpError::Request::POST) + if (e.statusCode == 401 || e.statusCode == 403) { - userMessage += "POST"; + userMessage = "This model requires authentication."; + + if (e.detail.isNotEmpty()) + { + userMessage += "\n\nDetails: " + e.detail; + } + + userMessage += "\n\nIt may be private or gated. Add an API key for this " + "provider under Settings, and make sure the account it belongs " + "to has been granted access."; + + return userMessage; } - else if (e.request == HttpError::Request::GET) + + if (e.statusCode == 404) { - userMessage += "GET"; + userMessage = "No model was found at this address."; + + userMessage += "\n\nCheck the path for typos. If the model is hosted on " + "Hugging Face, confirm the Space still exists and is public."; + + return userMessage; } - else + + if (e.statusCode == 429) { - } + userMessage = "The service is rate-limiting requests from this machine."; - userMessage += " request to endpoint "; + if (e.detail.isNotEmpty()) + { + userMessage += "\n\nDetails: " + e.detail; + } - if (e.endpointPath.isNotEmpty()) + userMessage += "\n\nThis happens when requests are made in quick succession, " + "such as starting and canceling repeatedly. Wait a few moments " + "before trying again."; + + return userMessage; + } + + if (e.statusCode == 402) { - userMessage += "\"" + e.endpointPath + "\" "; + userMessage = "The service declined the request because the account has reached " + "its usage or billing limit. Please check the account, then try " + "again."; + + if (e.detail.isNotEmpty()) + { + userMessage += "\n\nDetails: " + e.detail; + } + + return userMessage; } - userMessage += "failed"; + userMessage = "The server could not complete the request"; if (e.statusCode != 0) { - userMessage += " with status code " + String(e.statusCode); + userMessage += " (error " + String(e.statusCode) + ")"; } userMessage += "."; + if (e.detail.isNotEmpty()) + { + userMessage += "\n\nDetails: " + e.detail; + } + if (e.statusCode == 503) { - userMessage += " If this is a valid Hugging Face space, this could indicate " - "the space is paused or down due to a build or runtime error."; + userMessage += "\n\nThe model may be paused, or down because of a build " + "or runtime error."; } } @@ -221,31 +226,136 @@ struct GradioError { enum class Type { - RuntimeError + RuntimeError, + QuotaExceeded, + Indeterminate, + IncompleteResponse, + SpaceStarting, + SpaceUnavailable }; Type type; - String endpointPath; + String endpointPath {}; + String reason {}; + + /* Whether the endpoint is a Hugging Face Space, so that messages can name it + accurately instead of guessing. Set by the client, which is the only layer + that knows how to classify a model path. */ + bool isSpace = false; + + /** "Space" or "model", whichever this endpoint actually is. */ + String describeTarget() const { return isSpace ? "Space" : "model"; } }; inline String toUserMessage(const GradioError& e) { - String userMessage = "A Gradio error occurred."; + String userMessage = "The model reported an error."; switch (e.type) { case GradioError::Type::RuntimeError: - userMessage = "A runtime error occurred at endpoint"; + userMessage = "The " + e.describeTarget() + " reported an error while processing."; - if (e.endpointPath.isNotEmpty()) + if (e.reason.isNotEmpty()) + { + userMessage += "\n\nDetails: " + e.reason; + } + else + { + userMessage += "\n\nNo details were reported. A Gradio app only forwards the " + "text of an error when it is launched with \"show_error=True\" " + "or raises a \"gr.Error\". Otherwise the cause appears only in " + "the server's own logs, which are visible only to whoever runs it."; + } + + userMessage += "\n\nClick 'Open URL' to open the " + e.describeTarget() + "'s page."; + + return userMessage; + + case GradioError::Type::QuotaExceeded: + + userMessage = "GPU quota has been exceeded for this " + e.describeTarget() + "."; + + if (e.reason.isNotEmpty()) + { + userMessage += "\n\nDetails: " + e.reason; + } + + userMessage += "\n\nQuota is counted against whoever is signed in, and without an " + "API key that is an anonymous session with only a few minutes of it. " + "Click 'Settings' to add a key, or to switch to an account that has " + "quota left, then try again." + "\n\nClick 'Open URL' to open the " + + e.describeTarget() + "'s page."; + + return userMessage; + + case GradioError::Type::SpaceStarting: + + userMessage = "The Space is still starting up."; + + if (e.reason.isNotEmpty()) + { + userMessage += "\n\nIts current state is \"" + e.reason + "\"."; + } + + userMessage += "\n\nSpaces are suspended when idle and take a short while to wake. " + "Try loading it again in a minute."; + + return userMessage; + + case GradioError::Type::SpaceUnavailable: + + userMessage = "The Space is not currently available."; + + if (e.reason.isNotEmpty()) { - userMessage += " \"" + e.endpointPath + "\""; + userMessage += "\n\nIts current state is \"" + e.reason + "\"."; } - userMessage += ". If this is a Hugging Face space running on ZeroGPU, this " - "can also indicate a user has exceeded their daily ZeroGPU quota."; + userMessage += "\n\nThis usually means it has been paused by its owner, or has " + "stopped because of a build or runtime error. Click 'Open URL' to " + "check its status on Hugging Face."; + + return userMessage; + + case GradioError::Type::IncompleteResponse: + + userMessage = + "Lost contact with the " + e.describeTarget() + " before it returned a result."; + + userMessage += + "\n\nThe connection is dropped when nothing arrives for a prolonged period, " + "which most often means the model was still working. The job may well have " + "finished on the server afterwards, but HARP was no longer listening for it."; + + userMessage += + "\n\nTry again with a shorter input, or on faster hardware. A model that " + "routinely takes this long is better suited to a dedicated GPU than to a " + "free CPU instance."; + + return userMessage; + + case GradioError::Type::Indeterminate: + + userMessage = "The Space reported an error, but gave no indication of its cause."; + + userMessage += + "\n\nThis Space runs on ZeroGPU, so the GPU quota may have been exhausted, " + "but it could equally be a runtime error in the model. Spaces using a version " + "of Gradio before 6.13 do not report enough for HARP to tell the two apart."; + + userMessage += + "\n\nIf it is quota, an API key is what raises it: quota is counted against " + "whoever is signed in, and without a key that is an anonymous session with " + "only a few minutes of it. Click 'Settings' to add one."; + + userMessage += + "\n\nRunning the model from the Space's own page will report a quota message " + "if that is the cause, and nothing further if it is not. Click 'Open URL' to " + "open it on Hugging Face."; return userMessage; } @@ -266,8 +376,8 @@ struct JsonError Type type; - String stringJSON; - String key; + String stringJSON {}; + String key {}; }; inline String toUserMessage(const JsonError& e) @@ -354,7 +464,7 @@ struct ControlError Type type; - String controlType; + String controlType {}; }; inline String toUserMessage(const ControlError& e) @@ -387,12 +497,13 @@ struct FileError DoesNotExist, UploadFailed, DownloadFailed, + WriteFailed, UnsupportedFormat }; Type type; - String path; + String path {}; }; inline String toUserMessage(const FileError& e) @@ -410,7 +521,7 @@ inline String toUserMessage(const FileError& e) userMessage += " at path \"" + e.path + "\""; } - userMessage += "does not exist."; + userMessage += " does not exist."; return userMessage; @@ -440,6 +551,19 @@ inline String toUserMessage(const FileError& e) return userMessage; + case FileError::Type::WriteFailed: + + userMessage = "Failed to write file"; + + if (e.path.isNotEmpty()) + { + userMessage += " at path \"" + e.path + "\""; + } + + userMessage += "."; + + return userMessage; + case FileError::Type::UnsupportedFormat: userMessage = "File format"; @@ -466,15 +590,108 @@ inline String toUserMessage(const Error& error) return std::visit([](const auto& e) { return toUserMessage(e); }, error); } -inline std::optional<String> getOpenablePath(const Error& error) +/** + * A compact technical description intended for the log file. + * + * This is deliberately separate from toUserMessage: the popup explains what to do + * about a failure, whereas this records which branch produced it, so that a report + * accompanied by a log can be traced back to a specific decision. + */ +inline String toLogString(const Error& error) +{ + if (const auto* e = std::get_if<ClientError>(&error)) + { + return "ClientError(" + enumToString(e->type) + ", path=\"" + e->path + "\", provider=\"" + + (e->provider.has_value() ? enumToString(*e->provider) : String()) + "\")"; + } + + if (const auto* e = std::get_if<HttpError>(&error)) + { + return "HttpError(" + enumToString(e->type) + ", " + enumToString(e->request) + + ", status=" + String(e->statusCode) + ", endpoint=\"" + e->endpointPath + + "\", detail=\"" + e->detail + "\")"; + } + + if (const auto* e = std::get_if<GradioError>(&error)) + { + return "GradioError(" + enumToString(e->type) + ", isSpace=" + (e->isSpace ? "1" : "0") + + ", endpoint=\"" + e->endpointPath + "\", reason=\"" + e->reason + "\")"; + } + + if (const auto* e = std::get_if<JsonError>(&error)) + { + return "JsonError(" + enumToString(e->type) + ", key=\"" + e->key + "\", json=\"" + + e->stringJSON.substring(0, 400) + "\")"; + } + + if (const auto* e = std::get_if<FileError>(&error)) + { + return "FileError(" + enumToString(e->type) + ", path=\"" + e->path + "\")"; + } + + if (const auto* e = std::get_if<ControlError>(&error)) + { + return "ControlError(" + enumToString(e->type) + ")"; + } + + return "UnknownError"; +} + +/** + * The provider whose API key an error points at, when adding or changing one could + * plausibly resolve it, and nothing when no key applies. + * + * This is what puts the settings button on the popup, so it covers exactly those + * messages that tell the user to add a key. + */ +inline std::optional<Provider> getAPIKeyProvider(const Error& error) { + if (const auto* e = std::get_if<ClientError>(&error)) + { + if (e->type == ClientError::Type::InsufficientPermissions + || e->type == ClientError::Type::ModelNotFound) + { + return e->provider; + } + } + if (const auto* e = std::get_if<HttpError>(&error)) { - if (e->type == HttpError::Type::ConnectionFailed && e->endpointPath.isNotEmpty()) + // The message for these asks the user to add a key for this provider + if (e->type == HttpError::Type::BadStatusCode + && (e->statusCode == 401 || e->statusCode == 403)) { - return e->endpointPath; + return e->provider; } - else if (e->type == HttpError::Type::BadStatusCode && e->endpointPath.isNotEmpty()) + } + + if (const auto* e = std::get_if<GradioError>(&error)) + { + /* Quota is charged against whoever is signed in, and an anonymous user is + allowed only a couple of minutes before it runs out, so adding a key is + the usual remedy rather than waiting. Indeterminate is included because + quota is one of the two causes it cannot tell apart. + + Only a Space draws on that allowance. A Gradio app served from localhost + has no key to add, which is what isSpace distinguishes. */ + if (e->isSpace + && (e->type == GradioError::Type::QuotaExceeded + || e->type == GradioError::Type::Indeterminate)) + { + return Provider::HuggingFace; + } + } + + return std::nullopt; +} + +inline std::optional<String> getOpenablePath(const Error& error) +{ + if (const auto* e = std::get_if<HttpError>(&error)) + { + /* A malformed address is the one case with nothing to open, since it is the + address itself that is at fault. */ + if (e->type != HttpError::Type::InvalidURL && e->endpointPath.isNotEmpty()) { return e->endpointPath; } @@ -482,7 +699,9 @@ inline std::optional<String> getOpenablePath(const Error& error) if (const auto* e = std::get_if<GradioError>(&error)) { - if (e->type == GradioError::Type::RuntimeError && e->endpointPath.isNotEmpty()) + /* Every one of these messages sends the user to the model's own page, since + that is where the underlying error is actually reported. */ + if (e->endpointPath.isNotEmpty()) { return e->endpointPath; } @@ -497,25 +716,22 @@ class OpResult /** * Create a result indicating success. */ - static OpResult ok() noexcept { return OpResult(Result::ok(), std::nullopt); } + static OpResult ok() noexcept { return OpResult(std::nullopt); } /** * Create a result indicating failure with a specific error. */ - static OpResult fail(Error error) - { - return OpResult(Result::fail(toUserMessage(error)), std::move(error)); - } + static OpResult fail(Error error) { return OpResult(std::move(error)); } /** * Check if this result indicates success. */ - bool wasOk() const noexcept { return result.wasOk(); } + bool wasOk() const noexcept { return ! error.has_value(); } /** * Check if this result indicates failure. */ - bool failed() const noexcept { return result.failed(); } + bool failed() const noexcept { return error.has_value(); } /** * Obtain the error associated with this result if one exists. @@ -529,9 +745,7 @@ class OpResult explicit operator bool() const noexcept { return wasOk(); } private: - OpResult(Result r, std::optional<Error> e) : result(std::move(r)), error(std::move(e)) {} - - Result result; + explicit OpResult(std::optional<Error> e) : error(std::move(e)) {} std::optional<Error> error; }; diff --git a/src/utils/Interface.h b/src/utils/Interface.h index a44effa1..c1ceb147 100644 --- a/src/utils/Interface.h +++ b/src/utils/Interface.h @@ -1,7 +1,7 @@ /** * @file Interface.h * @brief Simple helper functions for interface. - * @author hugofloresgarcia + * @author cwitkowitz, hugofloresgarcia, JEYuhas */ #pragma once @@ -20,3 +20,5 @@ inline Colour getUIColourIfAvailable(LookAndFeel_V4::ColourScheme::UIColour uiCo return fallback; } + +void copyFileToClipboard(const File& file); diff --git a/src/utils/Labels.h b/src/utils/Labels.h index f6780daf..c1e88313 100644 --- a/src/utils/Labels.h +++ b/src/utils/Labels.h @@ -1,7 +1,7 @@ /** * @file Labels.h * @brief Defines data structures for various types of labels. - * @author xribene + * @author cwitkowitz, xribene */ #pragma once @@ -13,7 +13,7 @@ using namespace juce; struct OutputLabel { // Required fields - float t; + float t = 0.0f; String label; // Optional fields diff --git a/src/utils/Logging.h b/src/utils/Logging.h index e603efb4..2177aad7 100644 --- a/src/utils/Logging.h +++ b/src/utils/Logging.h @@ -1,7 +1,7 @@ /** * @file Logging.h * @brief Handles logging to terminal and file. - * @author xribene + * @author cwitkowitz, saumya-pailwan, xribene */ #pragma once @@ -9,6 +9,8 @@ #include <juce_core/juce_core.h> #include <juce_events/juce_events.h> +#include "Errors.h" + using namespace juce; class HARPLogger : private DeletedAtShutdown @@ -28,29 +30,73 @@ class HARPLogger : private DeletedAtShutdown // Disable assignment operator HARPLogger& operator=(const HARPLogger&) = delete; + /* Opens the log, unless it is already open. Every window asks for it, and worker + threads may be writing to it at the time, so it is replaced only under the lock. + + macOS: ~/Library/Logs/HARP/main.log + Windows: C:\Users\<username>\AppData\Roaming\HARP\main.log + Linux: ~/.config/HARP/main.log */ void initializeLogger() { - // MacOS: ~/Library/Logs/HARP/main.log - // Windows: C:\Users\<username>\AppData\Roaming\HARP\main.log - // Linux: ~/.config/HARP/main.log - logger.reset(FileLogger::createDefaultAppLogger("HARP", "main.log", "")); + const ScopedLock lock(loggerLock); + + if (logger == nullptr) + { + logger.reset(FileLogger::createDefaultAppLogger("HARP", "main.log", "")); + } } void debugAndLog(const String& message) const { DBG(message); // Write to console + const ScopedLock lock(loggerLock); + if (logger) { logger->logMessage(message); // Write to file } } - File getLogFile() const { return logger->getLogFile(); } + File getLogFile() const + { + const ScopedLock lock(loggerLock); + return logger != nullptr ? logger->getLogFile() : File(); + } + + OpResult clearLog() + { + const ScopedLock lock(loggerLock); + + if (logger == nullptr) + { + return OpResult::fail(FileError { FileError::Type::WriteFailed, "" }); + } + + File logFile = logger->getLogFile(); + logger.reset(); // release file handle before truncating + bool truncated = logFile.replaceWithText(""); + initializeLogger(); // reopen + + File launchLog = + FileLogger::getSystemLogFileFolder().getChildFile("HARP").getChildFile("launch.log"); + if (launchLog.existsAsFile()) + truncated = launchLog.replaceWithText("") && truncated; + + if (! truncated) + { + return OpResult::fail( + FileError { FileError::Type::WriteFailed, logFile.getFullPathName() }); + } + + return OpResult::ok(); + } private: HARPLogger() = default; // Prevents instantiation from outside + mutable CriticalSection loggerLock; + std::unique_ptr<FileLogger> logger { nullptr }; }; diff --git a/src/utils/Messages.h b/src/utils/Messages.h new file mode 100644 index 00000000..822e04b7 --- /dev/null +++ b/src/utils/Messages.h @@ -0,0 +1,96 @@ +/** + * @file Messages.h + * @brief Shared message resources used across UI and clients. + * @author saumya-pailwan, cwitkowitz + */ + +#pragma once + +#include <juce_core/juce_core.h> + +using namespace juce; + +struct SharedMessage : public ChangeBroadcaster +{ + virtual ~SharedMessage() = default; + + virtual void setMessage(const String& m) + { + { + const ScopedLock lock(messageLock); + message = m; + } + + sendChangeMessage(); + } + + virtual void clearMessage() + { + { + const ScopedLock lock(messageLock); + message.clear(); + } + + sendChangeMessage(); + } + + String getMessage() const + { + const ScopedLock lock(messageLock); + return message; + } + +protected: + mutable CriticalSection messageLock; + String message; +}; + +struct StatusMessage : SharedMessage +{ + static constexpr int maxHistoryEntries = 250; + + void setMessage(const String& m) override + { + { + const ScopedLock lock(messageLock); + message = m; + + if (m.isNotEmpty()) + { + String timestamp = Time::getCurrentTime().formatted("%H:%M:%S"); + history.add("[" + timestamp + "] " + m); + + while (history.size() > maxHistoryEntries) + { + history.remove(0); + } + } + } + + sendChangeMessage(); + } + + void clearMessage() override + { + { + const ScopedLock lock(messageLock); + message.clear(); + history.clear(); + } + + sendChangeMessage(); + } + + String getHistoryText() const + { + const ScopedLock lock(messageLock); + return history.joinIntoString("\n"); + } + +private: + StringArray history; +}; + +struct InstructionsMessage : SharedMessage +{ +}; diff --git a/src/utils/ModelCatalog.h b/src/utils/ModelCatalog.h new file mode 100644 index 00000000..afa034ec --- /dev/null +++ b/src/utils/ModelCatalog.h @@ -0,0 +1,729 @@ +/** + * @file ModelCatalog.h + * @brief The models HARP offers for browsing on the Home tab. + * @author cwitkowitz + * + * The catalog combines the models whose controls ship with HARP (Stability AI), every + * Space of the HARP organization on Hugging Face, and any other paths the user has loaded + * successfully. The organization's Spaces are listed through the Hub API in a single + * request, which describes each Space (including its tags, see ModelTags.h) without waking + * any of them. The last listing is cached on disk, so the Home tab is populated at once on + * startup and still works offline. + * + * Once a model loads, its own card (description and tags) is shown in place of what the + * listing says about it. Cards are saved too, and a Space's card is shown again after a + * restart for as long as the Space runs the same revision it did when the card was saved. + */ + +#pragma once + +#include <algorithm> +#include <map> +#include <memory> +#include <vector> + +#include <juce_events/juce_events.h> + +#include "../Model.h" + +#include "Clients.h" +#include "Errors.h" +#include "Logging.h" +#include "ModelTags.h" +#include "Settings.h" + +using namespace juce; + +struct CatalogEntry +{ + // Outcome of the last attempt to load a model in this session + enum class LoadStatus + { + None, // Not attempted, or loaded successfully + Failed, // Failed for a reason specific to the model + Unavailable, // The Space is not running (e.g., it crashed or was paused) + TryAgain // Failed for a reason that is likely temporary + }; + + // A short note on whether the model can be expected to load + struct AvailabilityNote + { + String text; // Empty if there is nothing to note + bool isProblem = false; // As opposed to merely informative, e.g. "Sleeping" + }; + + String path; + String name; + String description; + String provider; + ModelTags tags; + + // Deployment details reported by the Hub, empty when unknown + String stage; // e.g. "RUNNING", "SLEEPING", "RUNTIME_ERROR" + String hardware; // e.g. "cpu-basic", "zero-a10g" + String revision; // The commit the Space runs, which its card comes from + + bool isCustom = false; // Added by the user rather than found + LoadStatus loadStatus = LoadStatus::None; + + /* Either the Space runs on ZeroGPU, or it says its work does: a frontend that + forwards to the authors' own Space is hosted on CPU, so only the declaration + tells a user their request needs GPU allowance and queues for it. */ + bool isZeroGPU() const { return hardware.startsWithIgnoreCase("zero-") || tags.zeroGPU; } + + /* Whether using the model is limited by an account: a ZeroGPU model spends the caller's + daily GPU allowance, which a Hugging Face token raises, and a Stability AI model needs + a Stability AI key and spends that account's credits. */ + bool isMetered() const { return isZeroGPU() || provider == "Stability AI"; } + + /* The Hub's report on a Space, before anything is asked of it, is only an expectation. + Once a load has been attempted, its outcome is what counts. */ + AvailabilityNote getAvailabilityNote() const + { + switch (loadStatus) + { + case LoadStatus::Failed: // Only custom paths are still listed after this + return { "Failed to load", true }; + case LoadStatus::Unavailable: + return { "Unavailable", true }; + case LoadStatus::TryAgain: + return { "Try again", true }; + case LoadStatus::None: + break; + } + + if (stage == "SLEEPING") + return { "Sleeping" }; // Loads, after a wait while the Space starts + if (stage.contains("BUILDING") || stage.contains("STARTING")) + return { "Starting" }; + + return {}; + } + + // Whether the Hub reports the Space as unable to serve at all, e.g. crashed or paused + bool isReportedBroken() const + { + return stage.contains("ERROR") || stage == "PAUSED" || stage == "STOPPED" + || stage == "NO_APP_FILE" || stage == "DELETING"; + } + + String getSearchableText() const + { + return (name + " " + description + " " + path + " " + provider + " " + + tags.getSearchableText()) + .toLowerCase(); + } +}; + +class ModelCatalog : public ChangeBroadcaster +{ +public: + // Hugging Face organization whose Spaces are listed + static constexpr const char* hubOrganization = "teamup-tech"; + + // Settings key under which the custom paths are saved + static constexpr const char* customPathsKey = "models.customPaths"; + + enum class FetchState + { + Fetching, + Succeeded, + Failed + }; + + ModelCatalog() + { + for (const auto& [path, card] : getBuiltInModelCards()) + builtInEntries.push_back(makeEntry(path, ModelMetadata(card.get()), "Stability AI")); + + customPaths = StringArray::fromLines(Settings::getString(customPathsKey)); + customPaths.removeEmptyStrings(); + + loadCachedListing(); + loadSavedCards(); + rebuildEntries(); + + refresh(); + } + + ~ModelCatalog() override + { + /* A listing in flight would leave its worker blocked in a network call, and + destroying the pool under it would force-kill the thread. Abort the request so + the worker returns at once, then wait for it. */ + requestRegistry.abortActiveRequests(); + fetchPool.removeAllJobs(true, 5000); + } + + // All entries, in alphabetical order whatever their provider + const std::vector<CatalogEntry>& getEntries() const { return entries; } + + const CatalogEntry* findEntry(const String& path) const + { + for (const auto& entry : entries) + { + if (entry.path.equalsIgnoreCase(path)) + return &entry; + } + + return nullptr; + } + + // Lists the organization's Spaces again, in the background + void refresh() + { + if (fetchState == FetchState::Fetching && fetchPool.getNumJobs() > 0) + return; + + fetchState = FetchState::Fetching; + sendChangeMessage(); + + WeakReference<ModelCatalog> weakThis(this); + + fetchPool.addJob( + [this, weakThis] + { + auto listing = std::make_shared<var>(); + String error; + + const bool succeeded = fetchHubListing(*listing, error); + + MessageManager::callAsync( + [weakThis, succeeded, listing, error] + { + if (auto* catalog = weakThis.get()) + catalog->finishRefresh(succeeded, *listing, error); + }); + }); + } + + FetchState getFetchState() const { return fetchState; } + String getFetchError() const { return fetchError; } + // When the listing being shown was obtained, or a null time if there is none + Time getListingTime() const { return listingTime; } + + // Spaces left out because they cannot currently be loaded, with the reason for each + struct HiddenEntry + { + String name; + String reason; + }; + + const std::vector<HiddenEntry>& getHiddenEntries() const { return hiddenEntries; } + + /* A loaded model's own card is more authoritative than what its listing says about it, + so it is shown in the listing's place, now and after a restart (see rebuildEntries). */ + void recordLoadSuccess(const String& path, const ModelMetadata& card) + { + const auto* entry = findEntry(path); + + loadStatuses.erase(path.toLowerCase()); + loadedCards[path.toLowerCase()] = { card, entry != nullptr ? entry->revision : String() }; + saveLoadedCards(); + + if (entry == nullptr) + { + customPaths.add(path); + saveCustomPaths(); + } + + rebuildEntries(); + sendChangeMessage(); + } + + void recordLoadFailure(const String& path, const Error& error) + { + // Paths that never loaded are not listed, so a typo does not become an entry + if (findEntry(path) == nullptr) + return; + + loadStatuses[path.toLowerCase()] = classifyLoadFailure(error); + + rebuildEntries(); + sendChangeMessage(); + } + + void removeCustomPath(const String& path) + { + customPaths.removeString(path, true); + saveCustomPaths(); + + loadedCards.erase(path.toLowerCase()); + saveLoadedCards(); + + rebuildEntries(); + sendChangeMessage(); + } + + /* What a failed load says about the model: + - TryAgain: nothing about the model itself, e.g. the connection dropped, the request + timed out or was rate limited, the GPU quota ran out, or the Space was still + waking up. The same load is likely to work later. + - Unavailable: the Space is not running, e.g. it crashed or was paused. + - Failed: the Space answered, but not as a HARP model, e.g. it does not exist, is + private, or its interface could not be read. Retrying will not help. */ + static CatalogEntry::LoadStatus classifyLoadFailure(const Error& error) + { + using LoadStatus = CatalogEntry::LoadStatus; + + if (const auto* httpError = std::get_if<HttpError>(&error)) + { + if (httpError->type == HttpError::Type::ConnectionFailed) + return LoadStatus::TryAgain; + + if (httpError->type == HttpError::Type::BadStatusCode && httpError->statusCode == 429) + return LoadStatus::TryAgain; + + // Only reaches here when the Hub could not say why (see GradioClient) + if (httpError->type == HttpError::Type::BadStatusCode && httpError->statusCode == 503) + return LoadStatus::Unavailable; + } + else if (const auto* gradioError = std::get_if<GradioError>(&error)) + { + switch (gradioError->type) + { + case GradioError::Type::SpaceStarting: + case GradioError::Type::IncompleteResponse: + case GradioError::Type::QuotaExceeded: + return LoadStatus::TryAgain; + + case GradioError::Type::SpaceUnavailable: + return LoadStatus::Unavailable; + + case GradioError::Type::RuntimeError: + case GradioError::Type::Indeterminate: + break; + } + } + + return LoadStatus::Failed; + } + +private: + static CatalogEntry + makeEntry(const String& path, const ModelMetadata& metadata, const String& provider) + { + CatalogEntry entry; + entry.path = path; + entry.name = metadata.name.empty() ? getNameFromPath(path) : String(metadata.name); + entry.description = metadata.description; + entry.provider = provider; + + StringArray tags; + + for (const auto& tag : metadata.tags) + tags.add(tag); + + entry.tags = ModelTags::parse(tags); + + return entry; + } + + static String getNameFromPath(const String& path) + { + return path.fromLastOccurrenceOf("/", false, false).replaceCharacters("-_", " ").trim(); + } + + /* The Hub pages its results, so the request is repeated for as long as the response + links to a next page. Runs on the fetch thread. */ + bool fetchHubListing(var& listing, String& error) + { + static constexpr int timeoutMs = 15000; + static constexpr int maxPages = 20; + + URL pageURL = URL("https://huggingface.co/api/spaces") + .withParameter("author", hubOrganization) + .withParameter("limit", "1000") + .withParameter("expand[]", "cardData") + .withParameter("expand[]", "runtime") + .withParameter("expand[]", "private") + .withParameter("expand[]", "disabled"); + + Array<var> spaces; + + for (int page = 0; page < maxPages && pageURL.isWellFormed(); ++page) + { + DBG_AND_LOG("ModelCatalog::fetchHubListing: Requesting \"" << pageURL.toString(true) + << "\"."); + + int statusCode = 0; + StringPairArray responseHeaders; + + auto options = URL::InputStreamOptions(URL::ParameterHandling::inAddress) + .withConnectionTimeoutMs(timeoutMs) + .withStatusCode(&statusCode) + .withResponseHeaders(&responseHeaders) + .withNumRedirectsToFollow(5); + + auto stream = createRegisteredStream(&requestRegistry, pageURL, options); + + if (stream == nullptr) + { + error = requestRegistry.hasBeenAborted() ? "the request was aborted" + : "could not connect"; + return false; + } + + if (statusCode != 200) + { + error = "status code " + String(statusCode); + return false; + } + + const var response = JSON::parse(stream->readEntireStreamAsString()); + + if (! response.isArray()) + { + error = "unexpected response"; + return false; + } + + spaces.addArray(*response.getArray()); + + pageURL = URL(getNextPageLink(responseHeaders)); + } + + listing = spaces; + + DBG_AND_LOG("ModelCatalog::fetchHubListing: Found " << spaces.size() << " Spaces."); + + return true; + } + + // The target of a 'Link: <...>; rel="next"' header, or empty if there is none + static String getNextPageLink(const StringPairArray& headers) + { + for (const auto& link : StringArray::fromTokens(headers["link"], ",", "<>")) + { + if (link.contains("rel=\"next\"")) + return link.fromFirstOccurrenceOf("<", false, false) + .upToFirstOccurrenceOf(">", false, false) + .trim(); + } + + return {}; + } + + void finishRefresh(bool succeeded, const var& listing, const String& error) + { + if (succeeded) + { + hubEntries = parseHubListing(listing, true); + listingTime = Time::getCurrentTime(); + fetchState = FetchState::Succeeded; + fetchError.clear(); + + saveCachedListing(listing); + + // A card saved for an earlier revision of a Space will not be shown again + const auto sizeBefore = loadedCards.size(); + + for (const auto& entry : hubEntries) + { + auto saved = loadedCards.find(entry.path.toLowerCase()); + + if (saved != loadedCards.end() && saved->second.revision != entry.revision) + loadedCards.erase(saved); + } + + if (loadedCards.size() != sizeBefore) + saveLoadedCards(); + } + else + { + DBG_AND_LOG("ModelCatalog::finishRefresh: Could not list models (" << error << ")."); + + fetchState = FetchState::Failed; + fetchError = error; + } + + rebuildEntries(); + sendChangeMessage(); + } + + std::vector<CatalogEntry> parseHubListing(const var& listing, bool isCurrent) const + { + std::vector<CatalogEntry> parsed; + + if (auto* spaces = listing.getArray()) + { + for (const auto& space : *spaces) + { + const String path = space["id"].toString(); + + // Hidden from anyone without access, or switched off by the Hub + if (path.isEmpty() || (bool) space["private"] || (bool) space["disabled"]) + continue; + + const var& cardData = space["cardData"]; + + StringArray tags; + + if (auto* tagList = cardData["tags"].getArray()) + { + for (const auto& tag : *tagList) + tags.add(tag.toString()); + } + + /* A Space declares its license in its own frontmatter field rather than as a + tag, so it is read from there and tagged, which is how the license reaches + the Home tab. A model card listing "license:<id>" among its tags is read + the same way, wherever it is hosted (see ModelTags.h). */ + if (const String license = cardData["license"].toString().trim(); + license.isNotEmpty()) + { + tags.add("license:" + license); + } + + CatalogEntry entry; + entry.path = path; + entry.name = cardData["title"].toString().trim(); + // A summary of the model card's description, which the Space's README is + // recommended to carry (see "Listing on the Home Tab" in the pyharp README). + // The card itself replaces it once the model is loaded. + entry.description = cardData["short_description"].toString().trim(); + entry.provider = "Hugging Face"; + entry.tags = ModelTags::parse(tags); + /* A sleeping Space runs on no hardware at the moment, so what it asks for is + what tells whether it will run on ZeroGPU once woken */ + const auto& hardware = space["runtime"]["hardware"]; + entry.hardware = hardware["current"].isString() ? hardware["current"].toString() + : hardware["requested"].toString(); + entry.revision = space["runtime"]["sha"].toString(); + + // A cached stage is stale, and would misreport whether the Space is awake + if (isCurrent) + entry.stage = space["runtime"]["stage"].toString().toUpperCase(); + + if (entry.name.isEmpty()) + entry.name = getNameFromPath(path); + + parsed.push_back(std::move(entry)); + } + } + + std::sort(parsed.begin(), + parsed.end(), + [](const CatalogEntry& a, const CatalogEntry& b) + { return a.name.compareNatural(b.name) < 0; }); + + return parsed; + } + + void rebuildEntries() + { + entries = builtInEntries; + hiddenEntries.clear(); + + /* A Space that cannot load is not offered, whether the Hub reports it as broken or + it answered a load this session in a way no retry will change (e.g., it is not a + HARP app, or needs controls this version does not support). Custom paths are the + user's own, so they stay listed with a note instead. */ + for (const auto& entry : hubEntries) + { + auto status = loadStatuses.find(entry.path.toLowerCase()); + + const bool failedToLoad = + status != loadStatuses.end() && status->second == CatalogEntry::LoadStatus::Failed; + + if (failedToLoad) + hiddenEntries.push_back({ entry.name, "failed to load" }); + else if (entry.isReportedBroken()) + hiddenEntries.push_back( + { entry.name, + "the Space reports " + entry.stage.replace("_", " ").toLowerCase() }); + else + entries.push_back(entry); + } + + for (const auto& path : customPaths) + { + if (findEntry(path) != nullptr) + continue; + + CatalogEntry entry; + entry.path = path; + entry.name = getNameFromPath(path); + entry.provider = "Custom"; + entry.isCustom = true; + + entries.push_back(std::move(entry)); + } + + for (auto& entry : entries) + { + const String key = entry.path.toLowerCase(); + + auto status = loadStatuses.find(key); + + entry.loadStatus = + status != loadStatuses.end() ? status->second : CatalogEntry::LoadStatus::None; + + auto loaded = loadedCards.find(key); + + /* A Space's card is only as current as the revision it came from. Custom paths + have no revision to check, so theirs is shown until they load again. A built-in + model's card ships with HARP, so a saved one could only be older. */ + if (loaded != loadedCards.end() && ! isBuiltIn(entry.path) + && (entry.revision.isEmpty() || loaded->second.revision == entry.revision)) + { + const ModelMetadata& card = loaded->second.card; + + if (! card.description.empty()) + entry.description = card.description; + + if (entry.isCustom && ! card.name.empty()) + entry.name = card.name; + + if (! card.tags.empty()) + { + StringArray tags; + + for (const auto& tag : card.tags) + tags.add(tag); + + /* A Space declares its license in its own metadata rather than in + its card, so replacing the tags outright would drop the license + the moment the model loaded. It is carried over unless the card + names one itself. */ + const String license = entry.tags.license; + + entry.tags = ModelTags::parse(tags); + + if (entry.tags.license.isEmpty()) + entry.tags.license = license; + } + } + } + + // Sorted once names are final, since a loaded card can rename a custom path + std::stable_sort(entries.begin(), + entries.end(), + [](const CatalogEntry& a, const CatalogEntry& b) + { return a.name.compareNatural(b.name) < 0; }); + } + + // A file beside the settings file + static File getCacheFile(const String& name) + { + if (auto* settings = Settings::getUserSettings()) + return settings->getFile().getSiblingFile(name); + + return {}; + } + + static File getListingFile() { return getCacheFile("model_catalog.json"); } + static File getCardsFile() { return getCacheFile("model_cards.json"); } + + void loadCachedListing() + { + const File cacheFile = getListingFile(); + + if (! cacheFile.existsAsFile()) + return; + + hubEntries = parseHubListing(JSON::parse(cacheFile), false); + listingTime = cacheFile.getLastModificationTime(); + } + + static void saveCachedListing(const var& listing) + { + const File cacheFile = getListingFile(); + + if (cacheFile != File() && ! cacheFile.replaceWithText(JSON::toString(listing, true))) + { + DBG_AND_LOG("ModelCatalog::saveCachedListing: Could not write \"" + << cacheFile.getFullPathName() << "\"."); + } + } + + // Saved as {"<path>": {"card": {...}, "revision": "<sha>"}, ...} + void loadSavedCards() + { + const File cardsFile = getCardsFile(); + + if (! cardsFile.existsAsFile()) + return; + + // Held for as long as the object within it is read + const var saved = JSON::parse(cardsFile); + + if (auto* cards = saved.getDynamicObject()) + { + for (const auto& [path, value] : cards->getProperties()) + { + if (auto* card = value["card"].getDynamicObject()) + loadedCards[path.toString()] = { ModelMetadata(card), + value["revision"].toString() }; + } + } + } + + void saveLoadedCards() const + { + DynamicObject::Ptr saved = new DynamicObject(); + + for (const auto& [path, loaded] : loadedCards) + { + // Built-in cards ship with HARP, which is where an update to one comes from + if (isBuiltIn(path)) + continue; + + DynamicObject::Ptr value = new DynamicObject(); + value->setProperty("card", loaded.card.toVar()); + value->setProperty("revision", loaded.revision); + + saved->setProperty(path, var(value.get())); + } + + const File cardsFile = getCardsFile(); + + if (cardsFile != File() + && ! cardsFile.replaceWithText(JSON::toString(var(saved.get()), true))) + { + DBG_AND_LOG("ModelCatalog::saveLoadedCards: Could not write \"" + << cardsFile.getFullPathName() << "\"."); + } + } + + bool isBuiltIn(const String& path) const + { + return std::any_of(builtInEntries.begin(), + builtInEntries.end(), + [&](const CatalogEntry& entry) + { return entry.path.equalsIgnoreCase(path); }); + } + + void saveCustomPaths() + { + Settings::setValue(customPathsKey, customPaths.joinIntoString("\n"), true); + } + + std::vector<CatalogEntry> builtInEntries; + std::vector<CatalogEntry> hubEntries; + StringArray customPaths; + // Keyed by lowercase path, since the Hub does not distinguish case + std::map<String, CatalogEntry::LoadStatus> loadStatuses; + + // The card of a model that has loaded, and the revision of the Space it came from + struct LoadedCard + { + ModelMetadata card; + String revision; + }; + + std::map<String, LoadedCard> loadedCards; + std::vector<HiddenEntry> hiddenEntries; + + std::vector<CatalogEntry> entries; + + FetchState fetchState = FetchState::Failed; + String fetchError; + Time listingTime; + + RequestRegistry requestRegistry; + // Declared last so that it is destroyed first, while what its job uses still exists + ThreadPool fetchPool { 1 }; + + JUCE_DECLARE_WEAK_REFERENCEABLE(ModelCatalog) +}; diff --git a/src/utils/ModelTags.h b/src/utils/ModelTags.h new file mode 100644 index 00000000..a071acc2 --- /dev/null +++ b/src/utils/ModelTags.h @@ -0,0 +1,373 @@ +/** + * @file ModelTags.h + * @brief Model taxonomy and parsing of the tags models declare about themselves. + * @author cwitkowitz + * + * Tags are plain strings, read from a Space's README metadata when browsing models and + * from the model card once a model is loaded. Structured tags take the form + * "<key>:<value>" (e.g., "category:separation"), and anything else is a custom tag. + * + * The taxonomy is pyharp's (pyharp/pyharp/taxonomy.json), and is where models declare + * their tags, embedded at build time. A tag it does not recognize is kept as a custom tag, + * so a model tagged against a newer taxonomy is still shown, just not categorized by it. + */ + +#pragma once + +#include <algorithm> +#include <iterator> +#include <vector> + +#include <TaxonomyData.h> + +#include <juce_core/juce_core.h> + +using namespace juce; + +namespace Taxonomy +{ +struct Subcategory +{ + String id; + String displayName; +}; + +struct Category +{ + String id; + String displayName; + std::vector<Subcategory> subcategories; +}; + +// In display order, as defined by pyharp's taxonomy.json (embedded when HARP is built) +inline const std::vector<Category>& getCategories() +{ + static const std::vector<Category> categories = [] + { + std::vector<Category> parsed; + + const var taxonomy = JSON::parse( + String::fromUTF8(TaxonomyData::taxonomy_json, TaxonomyData::taxonomy_jsonSize)); + + if (auto* categoryList = taxonomy["categories"].getArray()) + { + for (const auto& categoryData : *categoryList) + { + Category category { categoryData["id"].toString(), + categoryData["name"].toString(), + {} }; + + if (auto* subcategoryList = categoryData["subcategories"].getArray()) + { + for (const auto& subcategoryData : *subcategoryList) + { + category.subcategories.push_back({ subcategoryData["id"].toString(), + subcategoryData["name"].toString() }); + } + } + + parsed.push_back(std::move(category)); + } + } + + // Malformed taxonomy data is a build error, which cannot be recovered at run time + jassert(! parsed.empty()); + + return parsed; + }(); + + return categories; +} + +inline const Category* findCategory(const String& id) +{ + for (const auto& category : getCategories()) + { + if (category.id == id) + return &category; + } + + return nullptr; +} + +// Subcategory ids are unique across the taxonomy, so each one names its category too +inline const Category* findCategoryOfSubcategory(const String& id, + const Subcategory** subcategory = nullptr) +{ + for (const auto& category : getCategories()) + { + for (const auto& candidate : category.subcategories) + { + if (candidate.id == id) + { + if (subcategory != nullptr) + *subcategory = &candidate; + + return &category; + } + } + } + + return nullptr; +} +} // namespace Taxonomy + +/** + * What a model's tags say about it. + */ +struct ModelTags +{ + // One kind of data a model takes in or gives back, from tags like "output:file/json" + struct DataType + { + String modality; // e.g. "audio", "midi", "text", "file", "labels" + StringArray formats; // File extensions it is restricted to, if any, e.g. "json" + + // e.g. "File (JSON, TXT)" + String describe() const + { + String name = modality == "midi" + ? String("MIDI") + : modality.substring(0, 1).toUpperCase() + modality.substring(1); + + if (! formats.isEmpty()) + name << " (" << formats.joinIntoString(", ").toUpperCase() << ")"; + + return name; + } + }; + + StringArray categories; // Category ids, in taxonomy order + StringArray subcategories; // Subcategory ids, in taxonomy order + std::vector<DataType> inputs; + std::vector<DataType> outputs; + int sampleRate = 0; // Hz, or 0 if not declared + String channels; // e.g. "mono", "stereo", "6" + String license; // SPDX id as declared, e.g. "cc-by-nc-4.0", or empty + /* Declared by a Space whose work runs on ZeroGPU even though the Space itself does + not: a thin frontend that forwards to the authors' own Space is hosted on CPU, so + its hardware says nothing about the allowance a request actually draws on. Shown + as the ZeroGPU badge rather than as a tag of its own. */ + bool zeroGPU = false; + StringArray custom; // Everything else, as given + + static ModelTags parse(const StringArray& tags) + { + ModelTags parsed; + + StringArray categoriesFound; + StringArray subcategoriesFound; + + for (auto tag : tags) + { + tag = tag.trim(); + + if (tag.isEmpty()) + continue; + + const String key = tag.upToFirstOccurrenceOf(":", false, false).toLowerCase(); + const String value = tag.fromFirstOccurrenceOf(":", false, false).trim(); + const bool hasValue = tag.containsChar(':') && value.isNotEmpty(); + + if (hasValue && key == "category" && Taxonomy::findCategory(value) != nullptr) + { + categoriesFound.addIfNotAlreadyThere(value); + } + else if (hasValue && key == "subcategory" + && Taxonomy::findCategoryOfSubcategory(value) != nullptr) + { + subcategoriesFound.addIfNotAlreadyThere(value); + // A subcategory implies its category + categoriesFound.addIfNotAlreadyThere( + Taxonomy::findCategoryOfSubcategory(value)->id); + } + else if (hasValue && (key == "input" || key == "output")) + { + addDataType(key == "input" ? parsed.inputs : parsed.outputs, value); + } + else if (hasValue && key == "sample-rate" && value.containsOnly("0123456789")) + { + parsed.sampleRate = value.getIntValue(); + } + else if (hasValue && key == "channels") + { + parsed.channels = value.toLowerCase(); + } + else if (hasValue && key == "license") + { + parsed.license = value.toLowerCase(); + } + else if (tag.equalsIgnoreCase("zerogpu")) + { + parsed.zeroGPU = true; + } + else + { + parsed.custom.addIfNotAlreadyThere(tag); + } + } + + // Keep taxonomy order, regardless of the order in which the tags were declared + for (const auto& category : Taxonomy::getCategories()) + { + if (categoriesFound.contains(category.id)) + parsed.categories.add(category.id); + + for (const auto& subcategory : category.subcategories) + { + if (subcategoriesFound.contains(subcategory.id)) + parsed.subcategories.add(subcategory.id); + } + } + + return parsed; + } + + bool isCategorized() const { return ! categories.isEmpty(); } + + bool isInCategory(const String& categoryId) const { return categories.contains(categoryId); } + + /* The license as a user reads it, e.g. "CC-BY-NC-4.0". SPDX ids are conventionally + written in upper case, apart from "other", which names terms only the model's + description can explain. */ + String describeLicense() const + { + if (license.isEmpty()) + return {}; + + return license == "other" ? "Custom license" : license.toUpperCase(); + } + + // e.g. "Audio, File (JSON, TXT)", or empty when none are declared + static String describe(const std::vector<DataType>& dataTypes) + { + StringArray descriptions; + + for (const auto& dataType : dataTypes) + descriptions.add(dataType.describe()); + + return descriptions.joinIntoString(", "); + } + + /* What the model takes in and gives back, as one label, e.g. "Audio \xe2\x86\x92 MIDI" + or "MIDI \xe2\x86\x92 File (TXT)". The formats come along, since what kind of file a + model returns is as much a part of the answer as that it returns one. */ + String describeFlow() const + { + const auto modalities = [](const std::vector<DataType>& dataTypes) + { + StringArray names; + + for (const auto& dataType : dataTypes) + names.addIfNotAlreadyThere(dataType.describe()); + + return names.joinIntoString(" + "); + }; + + const String takes = modalities(inputs); + const String gives = modalities(outputs); + + if (takes.isEmpty() && gives.isEmpty()) + return {}; + + return takes + String::fromUTF8(" \xe2\x86\x92 ") + gives; + } + + // Short labels for the most distinguishing tags, most important first + StringArray getDisplayLabels() const + { + StringArray labels; + + // The most specific place in the taxonomy: each subcategory, or the category itself + // where none of its subcategories is given (e.g., "Utility", which has none) + for (const auto& category : Taxonomy::getCategories()) + { + if (! categories.contains(category.id)) + continue; + + bool hasSubcategory = false; + + for (const auto& subcategory : category.subcategories) + { + if (! subcategories.contains(subcategory.id)) + continue; + + hasSubcategory = true; + + // "Music" alone is ambiguous, so the analysis subcategories carry their + // category + labels.add(category.id == "analysis" + ? subcategory.displayName + " " + category.displayName + : subcategory.displayName); + } + + if (! hasSubcategory) + labels.add(category.displayName); + } + + /* The license is deliberately absent here. It is not a description of the + model but a condition on using its output, so it gets a badge of its own + (see ModelStyle::getBadges) rather than sitting among the tags, where it + reads as one more property and is lost in a crowded row. */ + + if (const String flow = describeFlow(); flow.isNotEmpty()) + labels.add(flow); + + if (sampleRate > 0) + labels.add(sampleRate % 1000 == 0 ? String(sampleRate / 1000) + " kHz" + : String(sampleRate / 1000.0, 1) + " kHz"); + + if (channels.isNotEmpty()) + labels.add(channels.containsOnly("0123456789") + ? channels + " channels" + : channels.substring(0, 1).toUpperCase() + channels.substring(1)); + + labels.addArray(custom); + + return labels; + } + + // Everything a search should match against, lowercased + String getSearchableText() const + { + StringArray words; + + for (const auto& id : categories) + words.add(Taxonomy::findCategory(id)->displayName); + + words.addArray(getDisplayLabels()); + words.add(describe(inputs)); + words.add(describe(outputs)); + + return words.joinIntoString(" ").toLowerCase(); + } + +private: + /* A value is a modality, optionally followed by the formats it is restricted to, e.g. + "file/json|txt". Tags for the same kind of data (e.g., two file outputs) are merged. */ + static void addDataType(std::vector<DataType>& dataTypes, const String& value) + { + const String modality = value.upToFirstOccurrenceOf("/", false, false).trim().toLowerCase(); + const StringArray formats = StringArray::fromTokens( + value.fromFirstOccurrenceOf("/", false, false).toLowerCase(), "|", ""); + + if (modality.isEmpty()) + return; + + auto existing = std::find_if(dataTypes.begin(), + dataTypes.end(), + [&](const DataType& d) { return d.modality == modality; }); + + if (existing == dataTypes.end()) + { + dataTypes.push_back({ modality, {} }); + existing = std::prev(dataTypes.end()); + } + + for (const auto& format : formats) + { + if (format.trim().isNotEmpty()) + existing->formats.addIfNotAlreadyThere(format.trim()); + } + } +}; diff --git a/src/utils/Providers.h b/src/utils/Providers.h new file mode 100644 index 00000000..c8d104df --- /dev/null +++ b/src/utils/Providers.h @@ -0,0 +1,32 @@ +/** + * @file Providers.h + * @brief Identifies the services HARP can reach models through. + * @author cwitkowitz + */ + +#pragma once + +#include <juce_core/juce_core.h> + +/* Kept out of Client.h so that an error can name a provider too. Client.h already + includes Errors.h, so the enum cannot live there without the two including each + other. */ +enum class Provider +{ + HuggingFace, + Stability +}; + +// The name of a provider as the user knows it +inline juce::String getProviderName(Provider provider) +{ + switch (provider) + { + case Provider::HuggingFace: + return "Hugging Face"; + case Provider::Stability: + return "Stability AI"; + } + + return {}; +} diff --git a/src/utils/Settings.h b/src/utils/Settings.h index ea6069fb..26fbbe04 100644 --- a/src/utils/Settings.h +++ b/src/utils/Settings.h @@ -1,7 +1,7 @@ /** * @file Settings.h * @brief Handles storage and reading of persistent settings. - * @author xribene, lindseydeng, cwitkowitz + * @author cwitkowitz, xribene, lindseydeng */ #pragma once diff --git a/src/utils/Tutorial.h b/src/utils/Tutorial.h deleted file mode 100644 index 835a0593..00000000 --- a/src/utils/Tutorial.h +++ /dev/null @@ -1,12 +0,0 @@ -/** - * @file Tutorial.h - * @brief Constants needed for tutorial steps. - * @author saumya-pailwan - */ - -#pragma once - -namespace TutorialConstants -{ -inline constexpr const char* fallbackModelPath = "teamup-tech/demucs-source-separation"; -} diff --git a/src/utils/copy/CopyLinux.cpp b/src/utils/copy/CopyLinux.cpp new file mode 100644 index 00000000..0b8a0dbc --- /dev/null +++ b/src/utils/copy/CopyLinux.cpp @@ -0,0 +1,229 @@ +/** + * @file CopyLinux.cpp + * @brief Copy file path to clipboard on Linux. + * @author cwitkowitz, JEYuhas + */ + +extern "C" +{ +#include <X11/Xatom.h> +#include <X11/Xlib.h> +} + +#include <atomic> +#include <chrono> +#include <memory> +#include <thread> + +#include "../Interface.h" + +namespace +{ +/* How long the worker waits between checks for a paste request. It also bounds + how long replacing the clipboard blocks the caller, since that joins. */ +constexpr auto pollInterval = std::chrono::milliseconds(10); + +/* + An owner of the X11 CLIPBOARD selection, and the thread that services it. + + X11 keeps no copy of what was copied: the owner has to stay alive and answer a + SelectionRequest every time something asks to paste. That is what the thread is + for, and why the state outlives the call that created it. + + All of the Xlib setup happens on the calling thread before the worker starts, + and the worker is joined before anything is torn down, so only one thread ever + touches the connection and XInitThreads is not needed. +*/ +struct Clipboard +{ + Display* display = nullptr; + Window window = 0; + + Atom clipboard; + Atom targets; + Atom uriList; + Atom textPlain; + Atom utf8; + Atom gnome; + + std::string dataUri; + std::string dataPlain; + std::string dataGnome; + + std::atomic<bool> running { true }; + + std::thread worker; + + ~Clipboard() + { + running = false; + + /* Joining rather than detaching is what makes the teardown below safe. The + worker polls, so this waits up to one poll interval. */ + if (worker.joinable()) + { + worker.join(); + } + + if (display != nullptr) + { + if (window != 0) + { + XDestroyWindow(display, window); + } + + XCloseDisplay(display); + } + } +}; + +/* Ownership is released only when a later copy replaces it, or at shutdown. The + selection is given up either way, since X ties it to the connection. */ +std::unique_ptr<Clipboard> clipboardState; + +void handleRequest(Clipboard& state, XEvent& e) +{ + auto* req = &e.xselectionrequest; + + XEvent res {}; + res.xselection.type = SelectionNotify; + res.xselection.display = req->display; + res.xselection.requestor = req->requestor; + res.xselection.selection = req->selection; + res.xselection.target = req->target; + res.xselection.time = req->time; + res.xselection.property = None; + + if (req->target == state.targets) + { + Atom list[] = { state.targets, state.uriList, state.textPlain, state.utf8, state.gnome }; + + XChangeProperty(req->display, + req->requestor, + req->property, + XA_ATOM, + 32, + PropModeReplace, + (unsigned char*) list, + (int) (sizeof(list) / sizeof(list[0]))); + + res.xselection.property = req->property; + } + else if (req->target == state.uriList) + { + XChangeProperty(req->display, + req->requestor, + req->property, + state.uriList, + 8, + PropModeReplace, + (unsigned char*) state.dataUri.c_str(), + (int) state.dataUri.size()); + + res.xselection.property = req->property; + } + else if (req->target == state.textPlain || req->target == state.utf8) + { + XChangeProperty(req->display, + req->requestor, + req->property, + req->target, + 8, + PropModeReplace, + (unsigned char*) state.dataPlain.c_str(), + (int) state.dataPlain.size()); + + res.xselection.property = req->property; + } + else if (req->target == state.gnome) + { + XChangeProperty(req->display, + req->requestor, + req->property, + state.gnome, + 8, + PropModeReplace, + (unsigned char*) state.dataGnome.c_str(), + (int) state.dataGnome.size()); + + res.xselection.property = req->property; + } + + // A property of None refuses a target that is not on offer, as ICCCM requires + XSendEvent(req->display, req->requestor, False, 0, &res); + XFlush(req->display); +} + +/* Takes the state it services as an argument rather than reading the global, so + that replacing the global cannot pull the state out from under a running loop. */ +void runLoop(Clipboard* state) +{ + while (state->running) + { + while (XPending(state->display)) + { + XEvent e; + XNextEvent(state->display, &e); + + if (e.type == SelectionRequest) + handleRequest(*state, e); + } + + std::this_thread::sleep_for(pollInterval); + } +} +} // namespace + +void copyFileToClipboard(const File& file) +{ + if (! file.existsAsFile()) + return; + + /* Give up the previous ownership first. The destructor joins its worker, so the + state it is still reading cannot be freed underneath it. */ + clipboardState.reset(); + + auto state = std::make_unique<Clipboard>(); + + state->display = XOpenDisplay(nullptr); + + if (state->display == nullptr) + return; + + state->window = + XCreateSimpleWindow(state->display, DefaultRootWindow(state->display), 0, 0, 1, 1, 0, 0, 0); + + state->clipboard = XInternAtom(state->display, "CLIPBOARD", False); + state->targets = XInternAtom(state->display, "TARGETS", False); + state->uriList = XInternAtom(state->display, "text/uri-list", False); + state->textPlain = XInternAtom(state->display, "text/plain", False); + state->utf8 = XInternAtom(state->display, "UTF8_STRING", False); + state->gnome = XInternAtom(state->display, "x-special/gnome-copied-files", False); + + /* Percent-encodes the path, which a URI built by concatenation does not. A name + containing a space or a "#" would otherwise produce a URI the file manager + either truncates or rejects. */ + std::string uri = URL(file).toString(false).toStdString(); + + std::string path = file.getFullPathName().toStdString(); + + // RFC 2483 terminates each entry of a uri-list, so the trailing break belongs here + state->dataUri = uri + "\r\n"; + + state->dataPlain = path; + + /* The GNOME format is an operation followed by one URI per line, where the line + break separates entries rather than terminating them. A trailing break leaves + an empty final entry, which the file manager takes for a second file, fails to + find, and reports through a dialog offering to skip it. The real file is still + pasted, which is why the warning looks spurious. */ + state->dataGnome = "copy\n" + uri; + + XSetSelectionOwner(state->display, state->clipboard, state->window, CurrentTime); + + /* Started last, so that every Xlib call above has already run on this thread and + the worker is the only one using the connection from here on. */ + state->worker = std::thread(runLoop, state.get()); + + clipboardState = std::move(state); +} diff --git a/src/utils/copy/CopyMacOS.mm b/src/utils/copy/CopyMacOS.mm new file mode 100644 index 00000000..d501d6c6 --- /dev/null +++ b/src/utils/copy/CopyMacOS.mm @@ -0,0 +1,26 @@ +/** + * @file CopyMacOS.mm + * @brief Copy file path to clipboard on MacOS. + * @author JEYuhas + */ + +#import <Cocoa/Cocoa.h> + +#include "../Interface.h" + +void copyFileToClipboard (const juce::File& file) +{ + if (! file.existsAsFile()) + return; + + NSPasteboard* pb = [NSPasteboard generalPasteboard]; + + [pb declareTypes: [NSArray arrayWithObject: NSPasteboardTypeFileURL] + owner: nil]; + + NSString* path = [NSString stringWithUTF8String: file.getFullPathName().toUTF8()]; + NSURL* fileURL = [NSURL fileURLWithPath: path]; + + [pb setString: [fileURL absoluteString] + forType: NSPasteboardTypeFileURL]; +} diff --git a/src/utils/copy/CopyWindows.cpp b/src/utils/copy/CopyWindows.cpp new file mode 100644 index 00000000..973def2b --- /dev/null +++ b/src/utils/copy/CopyWindows.cpp @@ -0,0 +1,39 @@ +/** + * @file CopyWindows.cpp + * @brief Copy file path to clipboard on Windows. + * @author JEYuhas + */ + +#include <Shlobj.h> +#include <windows.h> + +#include "../Interface.h" + +void copyFileToClipboard(const juce::File& file) +{ + if (! file.existsAsFile()) + return; + + if (! OpenClipboard(nullptr)) + return; + + EmptyClipboard(); + + std::wstring path = file.getFullPathName().toWideCharPointer(); + + size_t size = sizeof(DROPFILES) + (path.size() + 2) * sizeof(wchar_t); + + HGLOBAL hMem = GlobalAlloc(GHND, size); + DROPFILES* df = (DROPFILES*) GlobalLock(hMem); + + df->pFiles = sizeof(DROPFILES); + df->fWide = TRUE; + + wchar_t* data = (wchar_t*) ((BYTE*) df + sizeof(DROPFILES)); + wcscpy(data, path.c_str()); + + GlobalUnlock(hMem); + + SetClipboardData(CF_HDROP, hMem); + CloseClipboard(); +} diff --git a/src/widgets/ControlAreaWidget.h b/src/widgets/ControlAreaWidget.h index 58c8ec98..3325c5d4 100644 --- a/src/widgets/ControlAreaWidget.h +++ b/src/widgets/ControlAreaWidget.h @@ -1,18 +1,22 @@ /** * @file ControlAreaWidget.h * @brief Component comprising all model controls. - * @author hugofloresgarcia, xribene, cwitkowitz + * @author cwitkowitz, saumya-pailwan, hugofloresgarcia, xribene */ #pragma once +#include <array> + #include <juce_gui_basics/juce_gui_basics.h> -#include "../gui/HoverableLabel.h" #include "../widgets/StatusAreaWidget.h" #include "../gui/ComboBoxWithLabel.h" +#include "../gui/FileChooserWithLabel.h" #include "../gui/HoverHandler.h" +#include "../gui/MultiSelectWithLabel.h" +#include "../gui/NumberBoxWithLabel.h" #include "../gui/SliderWithLabel.h" #include "../gui/TextBoxWithLabel.h" #include "../gui/ToggleWithLabel.h" @@ -25,8 +29,7 @@ using namespace juce; class ControlAreaWidget : public Component { public: - ControlAreaWidget() { resetState(); } - ~ControlAreaWidget() { resetState(); } + ~ControlAreaWidget() override { resetState(); } void resized() override { @@ -43,11 +46,6 @@ class ControlAreaWidget : public Component for (const auto& row : rows) { - if (row.empty()) - { - continue; - } - int totalComponentWidth = 0; for (const auto& entry : row) @@ -78,28 +76,16 @@ class ControlAreaWidget : public Component } } - int getNumControls() const - { - return sliderComponents.size() + toggleComponents.size() + dropdownComponents.size() - + textComponents.size(); - } + int getNumControls() const { return (int) controls.size(); } int getMinimumRequiredWidth() const { int requiredWidth = 0; - auto checkGroup = [&](const auto& group) + for (const auto& c : controls) { - for (const auto& c : group) - { - requiredWidth = jmax(requiredWidth, c->getMinimumRequiredWidth()); - } - }; - - checkGroup(sliderComponents); - checkGroup(toggleComponents); - checkGroup(dropdownComponents); - checkGroup(textComponents); + requiredWidth = jmax(requiredWidth, c->getMinimumRequiredWidth()); + } return requiredWidth + 2 * (marginSize + minEdgeGap); } @@ -113,16 +99,11 @@ class ControlAreaWidget : public Component return 0; } - int totalHeight = 2 * marginSize; + int totalHeight = 2 * marginSize + minRowGap * (int) (rows.size() - 1); - for (size_t i = 0; i < rows.size(); ++i) + for (const auto& row : rows) { - totalHeight += getRowHeight(rows[i]); - - if (i + 1 < rows.size()) - { - totalHeight += minRowGap; - } + totalHeight += getRowHeight(row); } return totalHeight; @@ -130,32 +111,12 @@ class ControlAreaWidget : public Component void resetState() { - for (auto& c : textComponents) - { - removeChildComponent(c.get()); - } - textComponents.clear(); - - // TODO - numberComponents - - for (auto& c : toggleComponents) - { - removeChildComponent(c.get()); - } - toggleComponents.clear(); - - for (auto& c : sliderComponents) - { - removeChildComponent(c.get()); - } - sliderComponents.clear(); - - for (auto& c : dropdownComponents) + for (auto& c : controls) { removeChildComponent(c.get()); } - dropdownComponents.clear(); + controls.clear(); handlers.clear(); } @@ -163,40 +124,63 @@ class ControlAreaWidget : public Component { resetState(); + /* Controls are laid out grouped by kind, in this order, rather than in the order + the model declares them */ + std::array<std::vector<std::unique_ptr<ControlComponent>>, 7> groups; + for (const auto& info : controlsInfo) { - if (auto* textInfo = dynamic_cast<TextBoxComponentInfo*>(info.get())) + if (auto* sliderInfo = dynamic_cast<SliderComponentInfo*>(info.get())) { - addTextBox(textInfo); + groups[0].push_back(createSlider(sliderInfo)); } - //else if (const auto* numberInfo = dynamic_cast<NumberBoxComponentInfo*>(info.get())) { addNumberBox(numberInfo); } else if (auto* toggleInfo = dynamic_cast<ToggleComponentInfo*>(info.get())) { - addToggle(toggleInfo); + groups[1].push_back(createToggle(toggleInfo)); } - else if (auto* sliderInfo = dynamic_cast<SliderComponentInfo*>(info.get())) + else if (auto* dropdownInfo = dynamic_cast<ComboBoxComponentInfo*>(info.get())) { - addSlider(sliderInfo); + groups[2].push_back(createDropdown(dropdownInfo)); } - else if (auto* dropdownInfo = dynamic_cast<ComboBoxComponentInfo*>(info.get())) + else if (auto* textInfo = dynamic_cast<TextBoxComponentInfo*>(info.get())) + { + groups[3].push_back(createTextBox(textInfo)); + } + else if (auto* numberInfo = dynamic_cast<NumberBoxComponentInfo*>(info.get())) { - addDropdown(dropdownInfo); + groups[4].push_back(createNumberBox(numberInfo)); + } + else if (auto* multiSelectInfo = dynamic_cast<MultiSelectComponentInfo*>(info.get())) + { + groups[5].push_back(createMultiSelect(multiSelectInfo)); + } + else if (auto* fileChooserInfo = dynamic_cast<FileComponentInfo*>(info.get())) + { + groups[6].push_back(createFileChooser(fileChooserInfo)); } else { - // Unsupported control detected + // Model::extractInputs creates no other kind of control jassertfalse; } } + for (auto& group : groups) + { + for (auto& control : group) + { + addAndMakeVisible(*control); + controls.push_back(std::move(control)); + } + } + resized(); } private: - void addTextBox(TextBoxComponentInfo* info) + std::unique_ptr<ControlComponent> createTextBox(TextBoxComponentInfo* info) { - std::unique_ptr<TextBoxWithLabel> textComponent = - std::make_unique<TextBoxWithLabel>(info->label); + auto textComponent = std::make_unique<TextBoxWithLabel>(info->label); auto& textBox = textComponent->getTextBox(); @@ -205,17 +189,27 @@ class ControlAreaWidget : public Component addHandler(&textBox, info); textBox.addListener(info); - addAndMakeVisible(*textComponent); - - textComponents.push_back(std::move(textComponent)); + return textComponent; } - // TODO - void addNumberBox() {} + std::unique_ptr<ControlComponent> createNumberBox(NumberBoxComponentInfo* info) + { + auto numberComponent = std::make_unique<NumberBoxWithLabel>(info->label); + + auto& numberBox = numberComponent->getNumberBox(); + + numberBox.setRange(info->minimum, info->maximum, info->step); + numberBox.setValue(info->value, dontSendNotification); - void addToggle(ToggleComponentInfo* info) + addHandler(&numberBox, info); + numberBox.addListener(info); + + return numberComponent; + } + + std::unique_ptr<ControlComponent> createToggle(ToggleComponentInfo* info) { - std::unique_ptr<ToggleWithLabel> toggleComponent = - std::make_unique<ToggleWithLabel>(info->label); + auto toggleComponent = std::make_unique<ToggleWithLabel>(info->label); auto& toggle = toggleComponent->getToggleButton(); @@ -224,14 +218,12 @@ class ControlAreaWidget : public Component addHandler(&toggle, info); toggle.addListener(info); - addAndMakeVisible(*toggleComponent); - - toggleComponents.push_back(std::move(toggleComponent)); + return toggleComponent; } - void addSlider(SliderComponentInfo* info) + std::unique_ptr<ControlComponent> createSlider(SliderComponentInfo* info) { - std::unique_ptr<SliderWithLabel> sliderComponent = + auto sliderComponent = std::make_unique<SliderWithLabel>(info->label, Slider::RotaryHorizontalVerticalDrag); auto& slider = sliderComponent->getSlider(); @@ -243,15 +235,12 @@ class ControlAreaWidget : public Component addHandler(&slider, info); slider.addListener(info); - addAndMakeVisible(*sliderComponent); - - sliderComponents.push_back(std::move(sliderComponent)); + return sliderComponent; } - void addDropdown(ComboBoxComponentInfo* info) + std::unique_ptr<ControlComponent> createDropdown(ComboBoxComponentInfo* info) { - std::unique_ptr<ComboBoxWithLabel> dropdownComponent = - std::make_unique<ComboBoxWithLabel>(info->label); + auto dropdownComponent = std::make_unique<ComboBoxWithLabel>(info->label); auto& dropdown = dropdownComponent->getComboBox(); auto font = dropdown.getLookAndFeel().getComboBoxFont(dropdown); @@ -260,65 +249,79 @@ class ControlAreaWidget : public Component for (const auto& option : info->options) { dropdown.addItem(option, dropdown.getNumItems() + 1); - widestOptionText = jmax(widestOptionText, font.getStringWidth(option)); + widestOptionText = jmax(widestOptionText, ModelStyle::getTextWidth(font, option)); } - if (! info->value.empty()) + const auto selected = std::find(info->options.begin(), info->options.end(), info->value); + + if (selected != info->options.end()) { - // Set initial selection if "value" was provided - dropdown.setSelectedItemIndex( - std::distance(info->options.begin(), - std::find(info->options.begin(), info->options.end(), info->value)), - dontSendNotification); + dropdown.setSelectedItemIndex((int) (selected - info->options.begin()), + dontSendNotification); } else { - // Fallback to first item - dropdown.setSelectedItemIndex(0, dontSendNotification); + // A value outside the choices (e.g., a custom one) is shown as it will be sent + dropdown.setText(info->value, dontSendNotification); } + dropdown.setTextWhenNoChoicesAvailable("Empty"); dropdownComponent->setMinimumContentWidth(widestOptionText); addHandler(&dropdown, info); dropdown.addListener(info); - addAndMakeVisible(*dropdownComponent); - - dropdownComponents.push_back(std::move(dropdownComponent)); + return dropdownComponent; } - void addHandler(Component* comp, ModelComponentInfo* info) + std::unique_ptr<ControlComponent> createMultiSelect(MultiSelectComponentInfo* info) { - std::unique_ptr<HoverHandler> handler = std::make_unique<HoverHandler>(*comp); + auto multiSelectComponent = std::make_unique<MultiSelectWithLabel>(info->label); - handler->onMouseEnter = [this, info]() { setInstructions(info->info); }; - handler->onMouseExit = [this]() { clearInstructions(); }; - handler->attach(); + std::vector<String> options(info->options.begin(), info->options.end()); + std::vector<String> selected(info->values.begin(), info->values.end()); - handlers.push_back(std::move(handler)); + multiSelectComponent->setOptions(options); + multiSelectComponent->setSelection(selected); + multiSelectComponent->addListener(info); + + addHandler(&multiSelectComponent->getSelectionButton(), info); + + return multiSelectComponent; } - void setInstructions(const String& text) + std::unique_ptr<ControlComponent> createFileChooser(FileComponentInfo* info) { - if (text.isNotEmpty() && instructionsMessage != nullptr) - { - instructionsMessage->setMessage(text); - } + auto fileChooserComponent = std::make_unique<FileChooserWithLabel>(info->label); + + if (! info->path.empty()) + fileChooserComponent->setPath(info->path); + + fileChooserComponent->setRequired(info->required); + fileChooserComponent->setFileTypes(info->fileTypes); + + fileChooserComponent->addListener(info); + + addHandler(fileChooserComponent.get(), info); + + return fileChooserComponent; } - void clearInstructions() + // Shows a control's description in the instructions box while hovering over it + void addHandler(Component* comp, ModelComponentInfo* info) { - if (instructionsMessage != nullptr) + auto handler = std::make_unique<HoverHandler>(*comp); + + handler->onMouseEnter = [this, info] { - instructionsMessage->clearMessage(); - } - } + if (! info->info.empty()) + instructionsMessage->setMessage(info->info); + }; + handler->onMouseExit = [this] { instructionsMessage->clearMessage(); }; + handler->attach(); - struct LayoutSpec - { - int preferredWidth; - int minHeight; - }; + handlers.push_back(std::move(handler)); + } struct RowEntry { @@ -328,84 +331,39 @@ class ControlAreaWidget : public Component int height = 0; }; - std::vector<std::vector<RowEntry>> buildRowsForWidth(int width) const + /* Fills each row with as many controls as fit, in order, along with the gaps that + resized() leaves between them and at either end */ + std::vector<std::vector<RowEntry>> buildRowsForWidth(int availableWidth) const { std::vector<std::vector<RowEntry>> rows; - if (width <= 0) + if (availableWidth <= 0) { return rows; } - addGroupToRows(rows, sliderComponents, 0, width); - addGroupToRows(rows, toggleComponents, 1, width); - addGroupToRows(rows, dropdownComponents, 2, width); - addGroupToRows(rows, textComponents, 3, width); - - return rows; - } - - void addGroupToRows(std::vector<std::vector<RowEntry>>& rows, - const auto& components, - int type, - int availableWidth) const - { - auto spec = getLayoutSpec(type); + int rowWidth = 0; - for (const auto& c : components) + for (const auto& c : controls) { - int minWidth = c->getMinimumRequiredWidth(); - int itemWidth = jmax(minWidth, spec.preferredWidth); - - itemWidth = jmin(itemWidth, availableWidth); + const int itemWidth = + jmin(jmax(c->getMinimumRequiredWidth(), c->getPreferredWidth()), availableWidth); - if (rows.empty()) + if (rows.empty() + || 2 * minEdgeGap + rowWidth + minInterItemGap + itemWidth > availableWidth) { rows.emplace_back(); + rowWidth = itemWidth; } - - auto& row = rows.back(); - - int currentWidth = 0; - - for (const auto& entry : row) - { - currentWidth += entry.width; - } - - if (! row.empty()) - { - currentWidth += minInterItemGap * (int) row.size(); - } - - int candidateWidth = currentWidth + (row.empty() ? 0 : minInterItemGap) + itemWidth; - - if (! row.empty() && candidateWidth > availableWidth) + else { - rows.emplace_back(); + rowWidth += minInterItemGap + itemWidth; } - auto& activeRow = rows.back(); - - activeRow.push_back({ c.get(), itemWidth, spec.minHeight }); + rows.back().push_back({ c.get(), itemWidth, c->getPreferredHeight() }); } - } - LayoutSpec getLayoutSpec(int type) const - { - switch (type) - { - case 0: - return { preferredSliderWidth, minSliderHeight }; - case 1: - return { preferredToggleWidth, minToggleHeight }; - case 2: - return { preferredDropdownWidth, minDropdownHeight }; - case 3: - return { preferredTextBoxWidth, minTextBoxHeight }; - } - - return { preferredDropdownWidth, minDropdownHeight }; + return rows; } static int getRowHeight(const std::vector<RowEntry>& row) @@ -420,27 +378,14 @@ class ControlAreaWidget : public Component return height; } - static constexpr float marginSize = 4; - - static constexpr int minSliderHeight = 108; - static constexpr int minToggleHeight = 34; - static constexpr int minDropdownHeight = 44; - static constexpr int minTextBoxHeight = 84; - - static constexpr int preferredSliderWidth = 108; - static constexpr int preferredToggleWidth = 112; - static constexpr int preferredDropdownWidth = 140; - static constexpr int preferredTextBoxWidth = 200; + static constexpr int marginSize = 4; static constexpr int minInterItemGap = 6; static constexpr int minEdgeGap = 4; static constexpr int minRowGap = 6; - std::vector<std::unique_ptr<TextBoxWithLabel>> textComponents; - // TODO - numberComponents - std::vector<std::unique_ptr<ToggleWithLabel>> toggleComponents; - std::vector<std::unique_ptr<SliderWithLabel>> sliderComponents; - std::vector<std::unique_ptr<ComboBoxWithLabel>> dropdownComponents; + // In the order they are laid out (see updateControls) + std::vector<std::unique_ptr<ControlComponent>> controls; std::vector<std::unique_ptr<HoverHandler>> handlers; diff --git a/src/widgets/MediaClipboardWidget.h b/src/widgets/MediaClipboardWidget.h index 3753cbfd..858c5eec 100644 --- a/src/widgets/MediaClipboardWidget.h +++ b/src/widgets/MediaClipboardWidget.h @@ -1,7 +1,7 @@ /** * @file MediaClipboardWidget.h * @brief Component that manages cached (non-model-specific) media files HARP. - * @author cwitkowitz + * @author cwitkowitz, rzhu15 */ #pragma once @@ -10,97 +10,133 @@ #include "TrackAreaWidget.h" +#include "ModelStyle.h" +#include "PreviewPaneWidget.h" + #include "../gui/MultiButton.h" +#include "../gui/ResizeEdge.h" +#include "../utils/Interface.h" #include "../utils/Logging.h" +#include "../windows/tutorial/TutorialTargets.h" + using namespace juce; class MediaClipboardWidget : public Component, public ChangeListener { public: - MediaClipboardWidget() + explicit MediaClipboardWidget(DragOverlayComponent* overlay) + : trackAreaWidget(DisplayMode::Thumbnail, thumbnailHeight, overlay) { - selectionTextBox.onReturnKey = [this] { renameSelectionCallback(); }; - controlsComponent.addAndMakeVisible(selectionTextBox); initializeButtons(); - controlsComponent.addAndMakeVisible(buttonsComponent); addAndMakeVisible(controlsComponent); + controlsComponent.setComponentID(TutorialTargets::clipboardControls); resetState(); trackAreaWidget.addChangeListener(this); + trackAreaWidget.onTrackMediaDoubleClicked = [this] + { + setPreviewPaneVisible(true); + previewPaneWidget.setMinimized(false); + }; trackArea.setViewedComponent(&trackAreaWidget, false); addAndMakeVisible(trackArea); + previewPaneWidget.onResize = [this](int newHeight) + { + previewPaneHeight = + jlimit(PreviewPaneWidget::minimumHeight, getMaximumPreviewPaneHeight(), newHeight); + resized(); + }; + previewPaneWidget.onResizeFinished = [this] { notifyLayoutChanged(); }; + previewPaneWidget.onMinimizeChanged = [this] + { + resized(); + notifyLayoutChanged(); + }; + previewPaneWidget.onClose = [this] { setPreviewPaneVisible(false); }; + addAndMakeVisible(previewPaneWidget); + + resizeEdge.onDragStart = [this] { widthAtDragStart = getWidth(); }; + resizeEdge.onDrag = [this](int distanceRight) + { + // The clipboard sits at the right of the window, so dragging left widens it + if (onResize) + { + onResize(jmax(minimumWidth, widthAtDragStart - distanceRight)); + } + }; + resizeEdge.onDragEnd = [this] { notifyLayoutChanged(); }; + addAndMakeVisible(resizeEdge); } - ~MediaClipboardWidget() { trackAreaWidget.removeChangeListener(this); } + ~MediaClipboardWidget() override { trackAreaWidget.removeChangeListener(this); } - void paint(Graphics& g) { g.fillAll(Colours::lightgrey.darker().withAlpha(0.5f)); } + void paint(Graphics& g) override + { + // The background of the model tab beside it + g.fillAll(getUIColourIfAvailable(LookAndFeel_V4::ColourScheme::UIColour::windowBackground)); + + // Headed like a section of a model tab, with the buttons at the end of its rule + ModelStyle::drawSectionHeader( + g, + "Media Clipboard", + controlsComponent.getBounds().withTrimmedRight( + numButtons * static_cast<int>(buttonWidth + marginSize) + headerGap)); + } void resized() override { - Rectangle<int> totalBounds = getLocalBounds(); + resizeEdge.setBounds(0, 0, resizeEdgeWidth, getHeight()); + + // Space around the clipboard, as around a model tab + Rectangle<int> totalBounds = getLocalBounds().reduced(pagePadding); // Flex for whole media clipboard FlexBox mainFlexBox; mainFlexBox.flexDirection = FlexBox::Direction::column; - // Add control bar and track area to flex - mainFlexBox.items.add( - FlexItem(controlsComponent) - .withHeight(30) - .withMargin(marginSize)); //jmax(30, trackNameLabel.getFont().getHeight())) + // Add header and track area to flex mainFlexBox.items.add( - FlexItem(trackArea).withFlex(10).withMargin({ 0, marginSize, marginSize, marginSize })); + FlexItem(controlsComponent).withHeight((float) ModelStyle::sectionHeaderHeight)); + mainFlexBox.items.add(FlexItem(trackArea).withFlex(10)); - mainFlexBox.performLayout(totalBounds); - - // Flex for controls area (text box and buttons) - FlexBox controlsFlexBox; - controlsFlexBox.flexDirection = FlexBox::Direction::row; - controlsFlexBox.alignItems = FlexBox::AlignItems::stretch; + // Add preview pane to flex + if (showPreviewPane) + { + const int paneHeight = previewPaneWidget.isMinimized() + ? PreviewPaneWidget::titleBarHeight + : jmin(previewPaneHeight, getMaximumPreviewPaneHeight()); - // Add control elements to control flex - controlsFlexBox.items.add(FlexItem(selectionTextBox).withFlex(1)); - controlsFlexBox.items.add( - FlexItem(buttonsComponent).withWidth(5 * (buttonWidth + marginSize))); + mainFlexBox.items.add(FlexItem(previewPaneWidget) + .withHeight(static_cast<float>(paneHeight)) + .withMargin({ (float) sectionGap, 0, 0, 0 })); + } + else + { + previewPaneWidget.setBounds(0, 0, 0, 0); + } - controlsFlexBox.performLayout(controlsComponent.getLocalBounds()); + mainFlexBox.performLayout(totalBounds); // Flex for buttons FlexBox buttonsFlexBox; buttonsFlexBox.flexDirection = FlexBox::Direction::row; buttonsFlexBox.alignItems = FlexBox::AlignItems::center; - buttonsFlexBox.justifyContent = FlexBox::JustifyContent::center; + buttonsFlexBox.justifyContent = FlexBox::JustifyContent::flexEnd; // Add buttons to flex with equal width - /*buttonsFlexBox.items.add(FlexItem(renameSelectionButton) - .withWidth(buttonWidth) - .withHeight(buttonWidth) - .withMargin({ 0, 0, 0, marginSize }));*/ - buttonsFlexBox.items.add(FlexItem(addFileButton) - .withWidth(buttonWidth) - .withHeight(buttonWidth) - .withMargin({ 0, 0, 0, marginSize })); - buttonsFlexBox.items.add(FlexItem(removeSelectionButton) - .withWidth(buttonWidth) - .withHeight(buttonWidth) - .withMargin({ 0, 0, 0, marginSize })); - buttonsFlexBox.items.add(FlexItem(playStopButton) - .withWidth(buttonWidth) - .withHeight(buttonWidth) - .withMargin({ 0, 0, 0, marginSize })); - buttonsFlexBox.items.add(FlexItem(saveFileButton) - .withWidth(buttonWidth) - .withHeight(buttonWidth) - .withMargin({ 0, 0, 0, marginSize })); - buttonsFlexBox.items.add(FlexItem(sendToDAWButton) - .withWidth(buttonWidth) - .withHeight(buttonWidth) - .withMargin({ 0, 0, 0, marginSize })); - - buttonsFlexBox.performLayout(buttonsComponent.getLocalBounds()); + for (auto* button : + { &addFileButton, &removeSelectionButton, &saveFileButton, &sendToDAWButton }) + { + buttonsFlexBox.items.add(FlexItem(*button) + .withWidth(buttonWidth) + .withHeight(buttonWidth) + .withMargin({ 0, marginSize, 0, 0 })); + } + + buttonsFlexBox.performLayout(controlsComponent.getLocalBounds()); int visibleWidthBeforeResize = trackArea.getMaximumVisibleWidth(); int visibleHeightBeforeResize = trackArea.getMaximumVisibleHeight(); @@ -127,44 +163,73 @@ class MediaClipboardWidget : public Component, public ChangeListener void addTrackFromFilePath(URL filePath, bool fromDAW = false) { - // TODO - is there an explicit way to check how HARP was invoked? trackAreaWidget.addTrackFromFilePath(filePath, fromDAW); } - Rectangle<int> getClipboardTrackAreaBounds() const { return trackArea.getBounds().expanded(2); } + std::function<void(int)> onResize; - Rectangle<int> getClipboardControlsBounds() const { return controlsComponent.getBounds().expanded(2); } + int getDefaultWidth() const { return defaultWidth; } + int getMinimumWidth() const { return minimumWidth; } - Rectangle<int> getClipboardNameBoxBounds() const - { - return getLocalArea(&controlsComponent, selectionTextBox.getBounds()).expanded(2); - } + /* Called once a drag of the clipboard's edge or the preview pane's edge ends, or the pane + is minimized or restored, which is when the owner keeps the sizes in the settings. The + width belongs to the owner, which lays the clipboard out beside the main panel. */ + std::function<void()> onLayoutChanged; - Rectangle<int> getClipboardButtonsBounds() const - { - return getLocalArea(&controlsComponent, buttonsComponent.getBounds()).expanded(2); - } + // The height the preview pane takes when shown in full, wherever the clipboard is tall enough + int getPreviewPaneHeight() const { return previewPaneHeight; } - Rectangle<int> getAddFileButtonBounds() const + void setPreviewPaneHeight(int newHeight) { - return getLocalArea(&buttonsComponent, addFileButton.getBounds()).expanded(2); + previewPaneHeight = jmax(PreviewPaneWidget::minimumHeight, newHeight); + resized(); } - Rectangle<int> getRemoveButtonBounds() const - { - return getLocalArea(&buttonsComponent, removeSelectionButton.getBounds()).expanded(2); - } + bool isPreviewPaneMinimized() const { return previewPaneWidget.isMinimized(); } - Rectangle<int> getPlayButtonBounds() const + void setPreviewPaneMinimized(bool shouldBeMinimized) { - return getLocalArea(&buttonsComponent, playStopButton.getBounds()).expanded(2); + previewPaneWidget.setMinimized(shouldBeMinimized); } - Rectangle<int> getSendToDAWButtonBounds() const + // Called whenever preview pane is shown or hidden + std::function<void()> onPreviewPaneVisibilityChanged; + + bool isPreviewPaneVisible() const { return showPreviewPane; } + + void setPreviewPaneVisible(bool shouldBeVisible) { - return getLocalArea(&buttonsComponent, sendToDAWButton.getBounds()).expanded(2); + if (showPreviewPane == shouldBeVisible) + { + return; + } + + showPreviewPane = shouldBeVisible; + + if (showPreviewPane) + { + previewPaneWidget.setMinimized(false); + + if (currentlySelectedDisplay != nullptr) + { + previewPaneWidget.showTrack(currentlySelectedDisplay->getOriginalFilePath()); + } + } + else + { + previewPaneWidget.clearTrack(); + } + + resized(); + + if (onPreviewPaneVisibilityChanged) + { + onPreviewPaneVisibilityChanged(); + } } + void stopPlayback() { previewPaneWidget.stopPlayback(); } + void addFileCallback() { StringArray validExtensions = MediaDisplayComponent::getSupportedExtensions(); @@ -221,11 +286,13 @@ class MediaClipboardWidget : public Component, public ChangeListener linkedTracksDropdown->setSize(275, 24); linkedTracksDropdown->setName(""); - for (unsigned int i = 0; i < linkedDisplays.size(); i++) + // Item IDs are 1-based indices into linkedDisplays, since 0 means no selection + for (int i = 0; i < (int) linkedDisplays.size(); i++) { - if (linkedDisplays[i] != selectedTrack) + if (linkedDisplays[(size_t) i] != selectedTrack) { - linkedTracksDropdown->addItem(linkedDisplays[i]->getTrackName(), i + 1); + linkedTracksDropdown->addItem(linkedDisplays[(size_t) i]->getTrackName(), + i + 1); } } @@ -236,85 +303,120 @@ class MediaClipboardWidget : public Component, public ChangeListener alertWindow->addCustomComponent(linkedTracksDropdown.get()); - // Open window asynchronously - alertWindow->enterModalState( + // Deleted by the modal manager once it is dismissed + alertWindow.release()->enterModalState( true, ModalCallbackFunction::create( [this, - alertWindow = alertWindow.release(), selectedTrack, linkedDisplays, linkedTracksDropdown = std::move(linkedTracksDropdown)](int result) { if (result == 0) // Overwrite { - unsigned int selectedIndex = - linkedTracksDropdown->getSelectedId() - 1; + const int selectedIndex = linkedTracksDropdown->getSelectedId() - 1; - if (selectedIndex >= 0 && selectedIndex < linkedDisplays.size()) + if (isPositiveAndBelow(selectedIndex, (int) linkedDisplays.size())) { MediaDisplayComponent* originalTrack = - linkedDisplays[selectedIndex]; + linkedDisplays[(size_t) selectedIndex]; File originalFile = originalTrack->getOriginalFilePath().getLocalFile(); File selectedFile = selectedTrack->getOriginalFilePath().getLocalFile(); - /*StringArray validExtensions = + StringArray validExtensions = originalTrack->getInstanceExtensions(); - if (! validExtensions.contains(selectedFile.getFileExtension())) + if (! validExtensions.contains(selectedFile.getFileExtension(), + true)) { AlertWindow::showMessageBoxAsync( AlertWindow::WarningIcon, "Invalid File", - "This track can only be overwritten with data of the following file types: " - + validExtensions.joinIntoString(", ") + ".", + "This track cannot be overwritten with the selected file.", "OK"); - }*/ - if (originalFile.getFileExtension() - != selectedFile.getFileExtension()) + return; + } + + bool successfulOverwrite = false; + + String ext = originalFile.getFileExtension().toLowerCase(); + + if (AudioDisplayComponent::getSupportedExtensions().contains( + ext, true) + && ext != selectedFile.getFileExtension().toLowerCase()) { - AlertWindow::showMessageBoxAsync( - AlertWindow::WarningIcon, - "File Type Mismatch", - "Cannot overwrite file of type \"" - + originalFile.getFileExtension() - + "\" with file of type \"" - + selectedFile.getFileExtension() + "\".", - "OK"); + File tempFile = selectedFile.getSiblingFile( + selectedFile.getFileNameWithoutExtension() + + "_converted" + originalFile.getFileExtension()); - // TODO - perform conversion if possible + if (convertAudioFile(selectedFile, tempFile)) + { + if (tempFile.copyFileTo(originalFile)) + { + DBG_AND_LOG( + "MediaClipboardWidget::sendToDAWCallback: Converted and overwrote \"" + << originalFile.getFullPathName() + << "\" with \"" + << selectedFile.getFullPathName() << "\"."); + + successfulOverwrite = true; + } + else + { + DBG_AND_LOG( + "MediaClipboardWidget::sendToDAWCallback: Conversion succeeded " + "but failed to copy to \"" + << originalFile.getFullPathName() << "\"."); + } + + tempFile.deleteFile(); + } + else + { + AlertWindow::showMessageBoxAsync( + AlertWindow::WarningIcon, + "File Type Mismatch", + "Cannot convert file of type \"" + + selectedFile.getFileExtension() + "\" to \"" + + originalFile.getFileExtension() + "\".", + "OK"); + } } else { if (selectedFile.copyFileTo(originalFile)) { DBG_AND_LOG( - "MediaClipboardWidget::sendToDAWCallback: Overwriting file " - << originalFile.getFullPathName() << " with " - << selectedFile.getFullPathName() << "."); - - // Update display with overwritten media - linkedDisplays[selectedIndex]->initializeDisplay( - URL(originalFile)); + "MediaClipboardWidget::sendToDAWCallback: Overwriting file \"" + << originalFile.getFullPathName() << "\" with \"" + << selectedFile.getFullPathName() << "\"."); - // Remove selected track - removeSelectionCallback(); - - // Select overwritten track - linkedDisplays[selectedIndex]->selectTrack(); + successfulOverwrite = true; } else { DBG_AND_LOG( - "MediaClipboardWidget::sendToDAWCallback: Failed to overwrite file " - << originalFile.getFullPathName() << " with " - << selectedFile.getFullPathName() << "."); + "MediaClipboardWidget::sendToDAWCallback: Failed to overwrite file \"" + << originalFile.getFullPathName() << "\" with \"" + << selectedFile.getFullPathName() << "\"."); } } + + if (successfulOverwrite) + { + // Update display with overwritten media + originalTrack->initializeDisplay(URL(originalFile)); + + // Remove selected track + removeSelectionCallback(); + + // Select overwritten track + originalTrack->selectTrack(); + } } } }), @@ -325,30 +427,6 @@ class MediaClipboardWidget : public Component, public ChangeListener private: void initializeButtons() { - /* - // Mode when a track is selected (rename enabled) - renameSelectionButtonActiveInfo = MultiButton::Mode { - "RenameSelection-Active", - "Rename the currently selected track.", - [this] {}, // TODO - MultiButton::DrawingMode::IconOnly, - Colours::darkblue, - fontawesome::FileText - }; - // Mode when there is no track selected (rename disabled) - renameSelectionButtonInactiveInfo = MultiButton::Mode { - "RenameSelection-Inactive", - "No track selected.", - [this] {}, - MultiButton::DrawingMode::IconOnly, - Colours::lightgrey, - fontawesome::FileText - }; - renameSelectionButton.addMode(renameSelectionButtonActiveInfo); - renameSelectionButton.addMode(renameSelectionButtonInactiveInfo); - buttonsComponent.addAndMakeVisible(renameSelectionButton); - */ - addFileButtonInfo = MultiButton::Mode { "AddFile", "Click to add a file to the media clipboard.", [this] { addFileCallback(); }, @@ -356,7 +434,7 @@ class MediaClipboardWidget : public Component, public ChangeListener Colours::lightblue, fontawesome::Folder }; addFileButton.addMode(addFileButtonInfo); - buttonsComponent.addAndMakeVisible(addFileButton); + controlsComponent.addAndMakeVisible(addFileButton); // Mode when a track is selected (remove enabled) removeSelectionButtonActiveInfo = MultiButton::Mode { @@ -367,38 +445,14 @@ class MediaClipboardWidget : public Component, public ChangeListener Colours::orangered, fontawesome::Remove }; - // Mode when there is no track selected (rename disabled) + // Mode when there is no track selected (remove disabled) removeSelectionButtonInactiveInfo = MultiButton::Mode { - "RemoveSelection-Inactive", "No track selected.", [this] {}, + "RemoveSelection-Inactive", "No track selected.", [] {}, MultiButton::DrawingMode::IconOnly, Colours::lightgrey, fontawesome::Remove }; removeSelectionButton.addMode(removeSelectionButtonActiveInfo); removeSelectionButton.addMode(removeSelectionButtonInactiveInfo); - buttonsComponent.addAndMakeVisible(removeSelectionButton); - - // Mode when a playable track is selected (play enabled) - playButtonActiveInfo = MultiButton::Mode { "Play-Active", - "Click to start playback.", - [this] { playCallback(); }, - MultiButton::DrawingMode::IconOnly, - Colours::limegreen, - fontaudio::Play }; - // Mode when there is no track selected (play disabled) - playButtonInactiveInfo = - MultiButton::Mode { "Play-Inactive", "Nothing to play.", - [this] {}, MultiButton::DrawingMode::IconOnly, - Colours::lightgrey, fontaudio::Play }; - // Mode during playback (stop enabled) - stopButtonInfo = MultiButton::Mode { "Stop", - "Click to stop playback.", - [this] { stopCallback(); }, - MultiButton::DrawingMode::IconOnly, - Colours::orangered, - fontaudio::Stop }; - playStopButton.addMode(playButtonActiveInfo); - playStopButton.addMode(playButtonInactiveInfo); - playStopButton.addMode(stopButtonInfo); - buttonsComponent.addAndMakeVisible(playStopButton); + controlsComponent.addAndMakeVisible(removeSelectionButton); // Mode when a track is selected (save file enabled) saveFileButtonActiveInfo = @@ -409,13 +463,13 @@ class MediaClipboardWidget : public Component, public ChangeListener Colours::lightblue, fontawesome::Save }; // Mode when there is no track selected (save file disabled) - saveFileButtonInactiveInfo = - MultiButton::Mode { "Save-Inactive", "Nothing to save.", - [this] {}, MultiButton::DrawingMode::IconOnly, - Colours::lightgrey, fontawesome::Save }; + saveFileButtonInactiveInfo = MultiButton::Mode { + "Save-Inactive", "Nothing to save.", [] {}, MultiButton::DrawingMode::IconOnly, + Colours::lightgrey, fontawesome::Save + }; saveFileButton.addMode(saveFileButtonActiveInfo); saveFileButton.addMode(saveFileButtonInactiveInfo); - buttonsComponent.addAndMakeVisible(saveFileButton); + controlsComponent.addAndMakeVisible(saveFileButton); // Mode when DAW-linked tracks are available sendToDAWButtonActiveInfo = MultiButton::Mode { @@ -430,14 +484,14 @@ class MediaClipboardWidget : public Component, public ChangeListener sendToDAWButtonSelectedInfo = MultiButton::Mode { "SendToDAW-Selected", "This track is already DAW-linked. Select another one.", - [this] {}, + [] {}, MultiButton::DrawingMode::IconOnly, Colours::lightgrey, fontawesome::ArrowCircleORight }; - // Mode no track is selected + // Mode when no track is selected sendToDAWButtonInactiveInfo1 = MultiButton::Mode { "SendToDAW-Inactive1", "No track selected.", - [this] {}, + [] {}, MultiButton::DrawingMode::IconOnly, Colours::lightgrey, fontawesome::ArrowCircleORight }; @@ -445,7 +499,7 @@ class MediaClipboardWidget : public Component, public ChangeListener sendToDAWButtonInactiveInfo2 = MultiButton::Mode { "SendToDAW-Inactive2", "No DAW-linked files in media clipboard.", - [this] {}, + [] {}, MultiButton::DrawingMode::IconOnly, Colours::lightgrey, fontawesome::ArrowCircleORight }; @@ -453,35 +507,21 @@ class MediaClipboardWidget : public Component, public ChangeListener sendToDAWButton.addMode(sendToDAWButtonSelectedInfo); sendToDAWButton.addMode(sendToDAWButtonInactiveInfo1); sendToDAWButton.addMode(sendToDAWButtonInactiveInfo2); - buttonsComponent.addAndMakeVisible(sendToDAWButton); + controlsComponent.addAndMakeVisible(sendToDAWButton); } void changeListenerCallback(ChangeBroadcaster* /*source*/) override { MediaDisplayComponent* mediaDisplay = trackAreaWidget.getCurrentlySelectedDisplay(); - if (currentlySelectedDisplay) - { - if (currentlySelectedDisplay->isPlaying()) - { - // Cancel playback and reset play/stop button state for select and stop events - stopCallback(currentlySelectedDisplay); - } - else - { - // Reset play/stop button state for select and stop events (avoid infinite messages) - playStopButton.setMode(playButtonActiveInfo.displayLabel); - } - } - if (mediaDisplay) { if (mediaDisplay != currentlySelectedDisplay) { // Handle select events if selected display changes selectTrack(mediaDisplay); - // Handle track area resizing after adding a track - resized(); // TODO - decouple from track selection? + // A new track is selected once added, so the track area is laid out again here + resized(); } } else @@ -491,18 +531,6 @@ class MediaClipboardWidget : public Component, public ChangeListener } } - void renameSelectionCallback() - { - MediaDisplayComponent* mediaDisplay = trackAreaWidget.getCurrentlySelectedDisplay(); - - String currentText = selectionTextBox.getText(); - - if (mediaDisplay && ! currentText.isEmpty()) - { - mediaDisplay->setTrackName(currentText); - } - } - void removeSelectionCallback() { MediaDisplayComponent* mediaDisplay = trackAreaWidget.getCurrentlySelectedDisplay(); @@ -517,56 +545,100 @@ class MediaClipboardWidget : public Component, public ChangeListener } } - void playCallback() + bool convertAudioFile(const File& source, const File& target) { - MediaDisplayComponent* mediaDisplay = trackAreaWidget.getCurrentlySelectedDisplay(); + AudioFormatManager formatManager; + formatManager.registerBasicFormats(); - if (mediaDisplay) + std::unique_ptr<AudioFormatReader> reader(formatManager.createReaderFor(source)); + + if (! reader) { - mediaDisplay->start(); + DBG_AND_LOG("MediaClipboardWidget::convertAudioFile: Could not read source file \"" + << source.getFullPathName() << "\"."); - playStopButton.setMode(stopButtonInfo.displayLabel); + return false; } - } - void stopCallback(MediaDisplayComponent* mediaDisplay = nullptr) - { - if (! mediaDisplay) + String ext = target.getFileExtension().toLowerCase(); + AudioFormat* format = nullptr; + + if (ext == ".wav") + format = formatManager.findFormatForFileExtension("wav"); + else if (ext == ".aiff" || ext == ".aif") + format = formatManager.findFormatForFileExtension("aiff"); + else if (ext == ".flac") + format = formatManager.findFormatForFileExtension("flac"); + else { - mediaDisplay = trackAreaWidget.getCurrentlySelectedDisplay(); + DBG_AND_LOG("MediaClipboardWidget::convertAudioFile: Unsupported target format \"" + << ext << "\"."); + + return false; } - if (mediaDisplay) + target.deleteFile(); + + std::unique_ptr<OutputStream> outputStream = target.createOutputStream(); + + if (! outputStream) + { + DBG_AND_LOG("MediaClipboardWidget::convertAudioFile: Could not create output file \"" + << target.getFullPathName() << "\"."); + + return false; + } + + // Takes ownership of the stream only if it succeeds + std::unique_ptr<AudioFormatWriter> writer = + format->createWriterFor(outputStream, + AudioFormatWriterOptions {} + .withSampleRate(reader->sampleRate) + .withNumChannels((int) reader->numChannels) + .withBitsPerSample(16)); + + if (! writer) { - mediaDisplay->stop(); + DBG_AND_LOG("MediaClipboardWidget::convertAudioFile: Could not create writer for \"" + << target.getFullPathName() << "\"."); - playStopButton.setMode(playButtonActiveInfo.displayLabel); + return false; } + + const int blockSize = 4096; + AudioBuffer<float> buffer((int) reader->numChannels, blockSize); + int64 totalFrames = (int64) reader->lengthInSamples; + int64 position = 0; + + while (position < totalFrames) + { + int64 framesToRead = jmin((int64) blockSize, totalFrames - position); + reader->read(&buffer, 0, (int) framesToRead, position, true, true); + writer->writeFromAudioSampleBuffer(buffer, 0, (int) framesToRead); + position += framesToRead; + } + + DBG_AND_LOG("MediaClipboardWidget::convertAudioFile: Converted and saved \"" + << source.getFullPathName() << "\" to \"" << target.getFullPathName() << "\"."); + + return true; } void resetState() { - selectionTextBox.clear(); - selectionTextBox.setEnabled(false); - - //renameSelectionButton.setMode(renameSelectionButtonInactiveInfo.label); addFileButton.setMode(addFileButtonInfo.displayLabel); removeSelectionButton.setMode(removeSelectionButtonInactiveInfo.displayLabel); - playStopButton.setMode(playButtonInactiveInfo.displayLabel); saveFileButton.setMode(saveFileButtonInactiveInfo.displayLabel); sendToDAWButton.setMode(sendToDAWButtonInactiveInfo1.displayLabel); + previewPaneWidget.clearTrack(); + currentlySelectedDisplay = nullptr; } void selectTrack(MediaDisplayComponent* mediaDisplay) { - selectionTextBox.setText(mediaDisplay->getTrackName()); - selectionTextBox.setEnabled(true); - - //renameSelectionButton.setMode(renameSelectionButtonActiveInfo.label); removeSelectionButton.setMode(removeSelectionButtonActiveInfo.displayLabel); - playStopButton.setMode(playButtonActiveInfo.displayLabel); saveFileButton.setMode(saveFileButtonActiveInfo.displayLabel); int nOtherDAWLinkedTracks = @@ -585,24 +657,54 @@ class MediaClipboardWidget : public Component, public ChangeListener sendToDAWButton.setMode(sendToDAWButtonInactiveInfo2.displayLabel); } + if (showPreviewPane) + { + previewPaneWidget.showTrack(mediaDisplay->getOriginalFilePath()); + } + currentlySelectedDisplay = mediaDisplay; } - const float marginSize = 2; - const float buttonWidth = 26; + void notifyLayoutChanged() + { + if (onLayoutChanged) + { + onLayoutChanged(); + } + } - // Main controls component + // The tallest the preview pane can be while leaving room for the header and a track + int getMaximumPreviewPaneHeight() const + { + const int otherSpace = + 2 * pagePadding + ModelStyle::sectionHeaderHeight + sectionGap + minimumTrackListHeight; + + return jmax(PreviewPaneWidget::minimumHeight, getHeight() - otherSpace); + } + + const float marginSize = 2; + const float buttonWidth = 24; + // Buttons at the end of the header, where the title and rule fill the space left of them + static constexpr int numButtons = 4; + static constexpr int headerGap = 8; + // Space around the clipboard, and between its sections, as in a model tab + static constexpr int pagePadding = 8; + static constexpr int sectionGap = 8; + static constexpr int thumbnailHeight = 75; + // Room for one track in the list, with the margins the track area gives it + static constexpr int minimumTrackListHeight = thumbnailHeight + 8; + const int resizeEdgeWidth = 6; + // Wide enough for the header to show its rule beside the title, as a model tab's sections do + const int defaultWidth = 300; + const int minimumWidth = 150; + + // Bar of buttons above the track list Component controlsComponent; - // Text editor subcomponent for track label of current selection - TextEditor selectionTextBox; - // Buttons area subcomponent - Component buttonsComponent; + // Dragged to change the width of the clipboard + ResizeEdge resizeEdge { ResizeEdge::Side::Left }; + int widthAtDragStart = defaultWidth; // Button components - /*MultiButton renameSelectionButton; - MultiButton::Mode renameSelectionButtonActiveInfo; - MultiButton::Mode renameSelectionButtonInactiveInfo;*/ - MultiButton addFileButton; MultiButton::Mode addFileButtonInfo; @@ -610,11 +712,6 @@ class MediaClipboardWidget : public Component, public ChangeListener MultiButton::Mode removeSelectionButtonActiveInfo; MultiButton::Mode removeSelectionButtonInactiveInfo; - MultiButton playStopButton; - MultiButton::Mode playButtonActiveInfo; - MultiButton::Mode playButtonInactiveInfo; - MultiButton::Mode stopButtonInfo; - MultiButton saveFileButton; MultiButton::Mode saveFileButtonActiveInfo; MultiButton::Mode saveFileButtonInactiveInfo; @@ -626,9 +723,13 @@ class MediaClipboardWidget : public Component, public ChangeListener MultiButton::Mode sendToDAWButtonInactiveInfo2; Viewport trackArea; - TrackAreaWidget trackAreaWidget { DisplayMode::Thumbnail, 75 }; + TrackAreaWidget trackAreaWidget; + + PreviewPaneWidget previewPaneWidget; + int previewPaneHeight = PreviewPaneWidget::defaultHeight; + bool showPreviewPane = true; std::unique_ptr<FileChooser> chooseFileBrowser; - MediaDisplayComponent* currentlySelectedDisplay; + MediaDisplayComponent* currentlySelectedDisplay = nullptr; }; diff --git a/src/widgets/ModelInfoWidget.h b/src/widgets/ModelInfoWidget.h index 3f076e78..97da1b91 100644 --- a/src/widgets/ModelInfoWidget.h +++ b/src/widgets/ModelInfoWidget.h @@ -1,14 +1,16 @@ /** * @file ModelInfoWidget.h * @brief Component presenting model metadata and information. - * @author hugofloresgarcia, xribene, cwitkowitz + * @author cwitkowitz, xribene, hugofloresgarcia */ #pragma once -#include <juce_gui_basics/juce_gui_basics.h> #include <cmath> +#include <juce_gui_basics/juce_gui_basics.h> + +#include "../widgets/ModelStyle.h" #include "../widgets/StatusAreaWidget.h" #include "../gui/HoverHandler.h" @@ -21,29 +23,21 @@ using namespace juce; class ModelAuthorLabel : public Component { public: - ModelAuthorLabel(const String& modelName = "", - const String& author = "", - const URL& newURL = URL()) + ModelAuthorLabel() { - if (modelName.isNotEmpty()) - { - setModelName(modelName); - } - - if (author.isNotEmpty()) - { - setAuthor(author); - } - - setURL(newURL); - - modelLabel.setFont(Font(22.0f, Font::bold)); + modelLabel.setFont(ModelStyle::font(18.0f, true)); + modelLabel.setBorderSize({ 0, 0, 0, 0 }); modelLabel.onHover = [this] { instructionsMessage->setMessage("Click to view the model's webpage or documentation."); }; modelLabel.onExit = [this] { instructionsMessage->clearMessage(); }; modelLabel.onClick = [this] { url.launchInDefaultBrowser(); }; + authorLabel.setFont(ModelStyle::font(13.0f)); + authorLabel.setColour(Label::textColourId, ModelStyle::secondaryText); + + setURL(URL()); + addAndMakeVisible(modelLabel); addAndMakeVisible(authorLabel); } @@ -52,10 +46,11 @@ class ModelAuthorLabel : public Component { Rectangle<int> totalArea = getLocalBounds(); - float modelNameWidth = modelLabel.getFont().getStringWidthFloat(modelLabel.getText()) + 10; + const int modelNameWidth = + ModelStyle::getTextWidth(modelLabel.getFont(), modelLabel.getText()) + 2; - modelLabel.setBounds(totalArea.removeFromLeft(static_cast<int>(modelNameWidth))); - authorLabel.setBounds(totalArea.translated(0, 3)); + modelLabel.setBounds(totalArea.removeFromLeft(jmin(modelNameWidth, totalArea.getWidth()))); + authorLabel.setBounds(totalArea.withTrimmedTop(2)); } void setModelName(const String& modelName) @@ -64,28 +59,22 @@ class ModelAuthorLabel : public Component resized(); } + void setAuthor(const String& author) { authorLabel.setText(author, dontSendNotification); resized(); } + void setURL(const URL& newURL) { - if (newURL.isWellFormed()) - { - url = newURL; + const bool isLinked = newURL.isWellFormed() && newURL.toString(false).isNotEmpty(); - modelLabel.setHoverColour(Colours::coral); - modelLabel.setHoverable(true); + url = newURL; - resized(); - } - else - { - modelLabel.setHoverColour(Colours::white); - modelLabel.setHoverable(false); - } + modelLabel.setHoverColor(isLinked ? ModelStyle::accent : Colours::white); + modelLabel.setHoverable(isLinked); } private: @@ -97,6 +86,10 @@ class ModelAuthorLabel : public Component SharedResourcePointer<InstructionsMessage> instructionsMessage; }; +/** + * The loaded model's card, presented like the Home tab's: its name (linking to its page) + * and author, notes on its deployment, its description, and its tags. + */ class ModelInfoWidget : public Component { public: @@ -104,146 +97,148 @@ class ModelInfoWidget : public Component { addAndMakeVisible(modelAuthorLabel); - // Configure description as scrollable read-only text + // Scrollable read-only text, for descriptions longer than the widget shows description.setMultiLine(true); description.setReadOnly(true); description.setScrollbarsShown(true); - description.setCaretVisible(false); // no blinking cursor - description.setPopupMenuEnabled(false); // disable right-click menu - description.setFont(Font(15.0f)); - - // Make it visually match the old TextLabel appearance + description.setCaretVisible(false); + description.setPopupMenuEnabled(false); + description.setFont(descriptionFont); + description.setIndents(0, 0); + description.setColour(TextEditor::textColourId, Colours::whitesmoke.withAlpha(0.85f)); description.setColour(TextEditor::backgroundColourId, Colours::transparentBlack); description.setColour(TextEditor::outlineColourId, Colours::transparentBlack); + description.setColour(TextEditor::focusedOutlineColourId, Colours::transparentBlack); description.setColour(TextEditor::shadowColourId, Colours::transparentBlack); - addAndMakeVisible(description); - } - ~ModelInfoWidget() {} + addChildComponent(tagRow); + } - //void paint(Graphics& g) {} + void paint(Graphics& g) override + { + ModelStyle::drawCard(g, getLocalBounds().toFloat().reduced(1.0f)); + ModelStyle::drawBadges(g, badges, getContentArea().removeFromTop(headerHeight)); + } void resized() override { - Rectangle<int> bounds = getLocalBounds().reduced(marginSize); + auto area = getContentArea(); + + auto headerRow = area.removeFromTop(headerHeight); + headerRow.setRight(ModelStyle::getBadgesLeft(badges, headerRow) - ModelStyle::chipGap); + modelAuthorLabel.setBounds(headerRow); + + area.removeFromTop(rowGap); + description.setBounds(area.removeFromTop(getDescriptionHeightForWidth(area.getWidth()))); - // Set fixed size for model and author labels - modelAuthorLabel.setBounds(bounds.removeFromTop(headerHeight)); - // Grant remaining space to description - description.setBounds(bounds); + if (! tagRow.isEmpty()) + { + area.removeFromTop(rowGap + 2); + tagRow.setBounds(area.removeFromTop(ModelStyle::chipHeight)); + } } int getPreferredHeightForWidth(int width) const { - const int contentWidth = jmax(120, width - 2 * (int) marginSize); - const int lineCount = estimateWrappedLineCount(description.getText(), contentWidth); - const int visibleLines = jlimit(1, 4, lineCount); - const int lineHeight = (int) std::ceil(description.getFont().getHeight() + 2.0f); - const int descriptionHeight = visibleLines * lineHeight + 4; + const int contentWidth = width - 2 * horizontalPadding; - return (int) (2 * marginSize + headerHeight + descriptionHeight); - } + int height = 2 * verticalPadding + headerHeight + rowGap + + getDescriptionHeightForWidth(contentWidth); - void resetState() - { - ModelMetadata emptyMetadata; + if (! tagRow.isEmpty()) + height += rowGap + 2 + ModelStyle::chipHeight; - updateLabels(emptyMetadata); - modelAuthorLabel.setURL(URL("")); + return height; } - void updateLabels(const ModelMetadata& metadata) + /* licenseFallback is the license the catalog knows, which a Space declares in its own + metadata rather than in its card, so it reaches the panel only by being passed. */ + void updateLabels(const ModelMetadata& metadata, const String& licenseFallback = {}) { - if (metadata.name.empty()) - { - modelAuthorLabel.setModelName(""); - } - else - { - modelAuthorLabel.setModelName(String(metadata.name)); - } + modelAuthorLabel.setModelName(String(metadata.name)); + modelAuthorLabel.setAuthor(metadata.author.empty() ? String() + : "by " + String(metadata.author)); - if (metadata.author.empty()) - { - modelAuthorLabel.setAuthor(""); - } - else - { - modelAuthorLabel.setAuthor("by " + String(metadata.author)); - } + description.setText(String(metadata.description)); + measuredDescriptionWidth = -1; - if (metadata.description.empty()) - { - description.setText(""); - } - else - { - description.setText(String(metadata.description)); - } + StringArray tags; + + for (const auto& tag : metadata.tags) + tags.add(tag); + + ModelTags parsed = ModelTags::parse(tags); + + // The card's own license wins, where it declares one + if (parsed.license.isEmpty()) + parsed.license = licenseFallback; + + tagRow.setTags(parsed); + tagRow.setVisible(! tagRow.isEmpty()); resized(); + repaint(); + } + + void setBadges(std::vector<ModelStyle::Badge> newBadges) + { + badges = std::move(newBadges); + + resized(); + repaint(); } void addOpenablePath(const String& openablePath) { modelAuthorLabel.setURL(URL(openablePath)); } private: - int estimateWrappedLineCount(const String& text, int availableWidth) const + Rectangle<int> getContentArea() const { - if (availableWidth <= 0) - return 1; - - auto font = description.getFont(); - const float spaceWidth = jmax(1.0f, font.getStringWidthFloat(" ")); - int lines = 0; + return getLocalBounds().reduced(horizontalPadding, verticalPadding); + } - StringArray paragraphs; - paragraphs.addLines(text.isEmpty() ? String(" ") : text); + /* Tall enough for the description, up to a limit beyond which it scrolls. Laying the + text out is slow, and every resize asks for this, so the last result is kept. */ + int getDescriptionHeightForWidth(int width) const + { + if (description.isEmpty() || width <= 0) + return 0; - for (const auto& paragraph : paragraphs) + if (width != measuredDescriptionWidth) { - StringArray words; - words.addTokens(paragraph, " ", ""); - words.removeEmptyStrings(); - - if (words.isEmpty()) - { - ++lines; - continue; - } - - float currentLineWidth = 0.0f; - for (const auto& word : words) - { - const float wordWidth = font.getStringWidthFloat(word); - - if (currentLineWidth <= 0.0f) - { - currentLineWidth = wordWidth; - continue; - } - - if (currentLineWidth + spaceWidth + wordWidth <= (float) availableWidth) - { - currentLineWidth += spaceWidth + wordWidth; - } - else - { - ++lines; - currentLineWidth = wordWidth; - } - } - - ++lines; + AttributedString text; + text.append(description.getText(), descriptionFont); + + TextLayout layout; + // Leave room for the scrollbar, which appears once the text is too long + layout.createLayout(text, + (float) (width - getLookAndFeel().getDefaultScrollbarWidth())); + + const int lines = jlimit(1, maxDescriptionLines, layout.getNumLines()); + + measuredDescriptionHeight = + (int) std::ceil((float) lines * descriptionFont.getHeight()) + 2; + measuredDescriptionWidth = width; } - return jmax(1, lines); + return measuredDescriptionHeight; } - const float headerHeight = 30; - const float marginSize = 2; + static constexpr int horizontalPadding = 12; + static constexpr int verticalPadding = 9; + static constexpr int headerHeight = 24; + static constexpr int rowGap = 2; + static constexpr int maxDescriptionLines = 4; - ModelAuthorLabel modelAuthorLabel; + const Font descriptionFont = ModelStyle::font(13.0f); + // The width the description height was last measured for, and that height + mutable int measuredDescriptionWidth = -1; + mutable int measuredDescriptionHeight = 0; + + ModelAuthorLabel modelAuthorLabel; TextEditor description; + ModelStyle::TagRow tagRow; + + std::vector<ModelStyle::Badge> badges; }; diff --git a/src/widgets/ModelSelectionWidget.h b/src/widgets/ModelSelectionWidget.h deleted file mode 100644 index bba39217..00000000 --- a/src/widgets/ModelSelectionWidget.h +++ /dev/null @@ -1,553 +0,0 @@ -/** - * @file ModelSelectionWidget.h - * @brief Component allowing for selection and loading of model. - * @author hugofloresgarcia, rc2000123, xribene, lindseydeng, cwitkowitz - */ - -#pragma once - -#include <any> -#include <functional> - -#include <juce_gui_basics/juce_gui_basics.h> - -#include "../widgets/StatusAreaWidget.h" - -#include "../gui/HoverHandler.h" -#include "../gui/MultiButton.h" - -#include "../utils/Errors.h" -#include "../utils/Interface.h" -#include "../utils/Logging.h" - -using namespace juce; - -struct SharedChoices : public ChangeBroadcaster -{ - int getIndexForPath(const std::string& p) - { - int idx = -1; - - for (unsigned int i = 0; i < savedModelPaths.size(); ++i) - { - if (savedModelPaths[i] == p) - { - idx = (int) i; - - break; - } - } - - return idx; - } - - // TODO - should check endpoint path - otherwise could be duplicates - bool containsPath(const std::string& p) { return getIndexForPath(p) != -1; } - - void addNewPath(const std::string& p) - { - savedModelPaths.push_back(p); - sendSynchronousChangeMessage(); - } - - void updatePath(unsigned int idx, const std::string& p) - { - savedModelPaths[idx] = p; - sendSynchronousChangeMessage(); - } - - std::vector<std::string> savedModelPaths = { - "click here to enter a custom path...", - "stability/text-to-audio", - "stability/audio-to-audio", - "teamup-tech/text2midi-symbolic-music-generation", - "teamup-tech/demucs-source-separation", - "teamup-tech/solo-piano-audio-to-midi-transcription", - "teamup-tech/transkun", // TODO - more intuitive name - "teamup-tech/TRIA", // TODO - more intuitive name: (The Rhythm In Anything) conditional drum generation - "teamup-tech/anticipatory-music-transformer", - "teamup-tech/vampnet-conditional-music-generation", - "teamup-tech/harmonic-percussive-separation", - "teamup-tech/Kokoro-TTS", - "teamup-tech/MegaTTS3-Voice-Cloning", - "teamup-tech/midi-synthesizer", - "teamup-tech/audioseal", // TODO - more intuitive name - // "xribene/HARP-UI-TEST-v3" - }; -}; - -class CustomPathComponent : public Component -{ -public: - CustomPathComponent(std::function<void(String)> onLoad, std::function<void()> onCancel) - : onLoadCallback(std::move(onLoad)), onCancelCallback(std::move(onCancel)) - { - pathEditor.setMultiLine(false); - pathEditor.setReturnKeyStartsNewLine(false); - pathEditor.onTextChange = [this] { loadButton.setEnabled(! pathEditor.isEmpty()); }; - pathEditor.onReturnKey = [this] - { - if (loadButton.isEnabled()) - { - loadButton.triggerClick(); - } - }; - addAndMakeVisible(pathEditor); - - loadButton.setEnabled(false); - loadButton.onClick = [this] - { - wasLoadPressed = true; - - if (onLoadCallback) - { - onLoadCallback(pathEditor.getText()); - } - - closePopup(); - }; - addAndMakeVisible(loadButton); - - cancelButton.onClick = [this] { closePopup(); }; - addAndMakeVisible(cancelButton); - - setSize(400, 80); - } - - ~CustomPathComponent() override - { - if (! wasLoadPressed && onCancelCallback) - { - // Treat as cancel if closed without load - onCancelCallback(); - } - } - - void visibilityChanged() override - { - if (isVisible()) - { - MessageManager::callAsync([this] { pathEditor.grabKeyboardFocus(); }); - } - } - - void resized() override - { - Rectangle<int> fullArea = getLocalBounds(); - - FlexBox fullPopup; - fullPopup.flexDirection = FlexBox::Direction::column; - - fullPopup.items.add(FlexItem(pathEditor).withHeight(30).withMargin(2)); - - FlexBox buttonsArea; - buttonsArea.flexDirection = FlexBox::Direction::row; - - buttonsArea.items.add(FlexItem(loadButton).withFlex(1).withMargin(10)); - buttonsArea.items.add(FlexItem().withFlex(0.25)); - buttonsArea.items.add(FlexItem(cancelButton).withFlex(1).withMargin(10)); - - fullPopup.items.add(FlexItem(buttonsArea).withFlex(1)); - - fullPopup.performLayout(fullArea); - } - - void paint(Graphics& g) override - { - g.fillAll(getUIColourIfAvailable(LookAndFeel_V4::ColourScheme::UIColour::windowBackground)); - } - - void setTextFieldValue(const String& text) - { - pathEditor.setText(text, dontSendNotification); - pathEditor.selectAll(); - } - -private: - void closePopup() - { - if (auto* popup = findParentComponentOfClass<DialogWindow>()) - { - popup->exitModalState(0); - } - } - - TextEditor pathEditor; - TextButton loadButton { "Load" }; - TextButton cancelButton { "Cancel" }; - - bool wasLoadPressed = false; - - std::function<void(String)> onLoadCallback; - std::function<void()> onCancelCallback; -}; - -class ModelSelectionWidget : public Component, public ChangeBroadcaster, public ChangeListener -{ -public: - ModelSelectionWidget() - { - initializeLoadModelButton(); - initializeModelPathComboBox(); - - resetState(); - - sharedChoices->addChangeListener(this); - } - - ~ModelSelectionWidget() { sharedChoices->removeChangeListener(this); } - - void resized() override - { - FlexBox selectionArea; - selectionArea.flexDirection = FlexBox::Direction::row; - - selectionArea.items.add(FlexItem(modelPathComboBox).withFlex(1).withMargin(marginSize)); - selectionArea.items.add(FlexItem(loadModelButton).withWidth(100).withMargin(marginSize)); - - selectionArea.performLayout(getLocalBounds()); - } - - String getCurrentlySelectedPath() { return selectedPath; } - - void loadModelBypass(const String& modelPath) - { - selectedPath = modelPath; - sendChangeMessage(); - } - - void resetState() - { - lastLoadedPathIndex = -1; - lastSelectedPathIndex = -1; - modelPathComboBox.setSelectedId(lastSelectedPathIndex); - modelPathComboBox.setEnabled(true); - - loadModelButton.setEnabled(false); - } - - void setDisabled() - { - modelPathComboBox.setEnabled(false); - loadModelButton.setEnabled(false); - } - - void setEnabled() - { - modelPathComboBox.setEnabled(true); - loadModelButton.setEnabled(true); - } - - void setFinishedState() - { - setEnabled(); - - modelPathComboBox.setSelectedId(lastLoadedPathIndex + 1); - } - - void setSuccessfulState() - { - std::string loadedPath = selectedPath.toStdString(); - - if (! sharedChoices->containsPath(loadedPath)) - { - if (sharedChoices->containsPath(loadedPath + validPathBrokenTag)) - { - unsigned int currentIdx = - (unsigned int) sharedChoices->getIndexForPath(loadedPath + validPathBrokenTag); - - // Remove broken tag from existing entry for path - sharedChoices->updatePath(currentIdx, loadedPath); - } - else if (sharedChoices->containsPath(loadedPath + validPathTryAgainTag)) - { - unsigned int currentIdx = (unsigned int) sharedChoices->getIndexForPath( - loadedPath + validPathTryAgainTag); - - // Remove try again tag from existing entry for path - sharedChoices->updatePath(currentIdx, loadedPath); - } - else if (sharedChoices->containsPath(loadedPath + validPathErrorTag)) - { - unsigned int currentIdx = - (unsigned int) sharedChoices->getIndexForPath(loadedPath + validPathErrorTag); - - // Remove error tag from existing entry for path - sharedChoices->updatePath(currentIdx, loadedPath); - } - else - { - // Add a new entry for custom path - sharedChoices->addNewPath(loadedPath); - - lastSelectedPathIndex = sharedChoices->getIndexForPath(loadedPath); - } - } - - lastLoadedPathIndex = sharedChoices->getIndexForPath(loadedPath); - - setFinishedState(); - } - - void setUnsuccessfulState(const Error& error) - { - bool wasValidPath = true; - - if (const auto* e = std::get_if<ClientError>(&error)) - { - wasValidPath = false; - } - - if (const auto* e = std::get_if<HttpError>(&error)) - { - if (e->type == HttpError::Type::BadStatusCode && e->statusCode == 404) - { - wasValidPath = false; - } - } - - if (! wasValidPath) - { - if (modelPathComboBox.getSelectedItemIndex() == 0) - { - openCustomPathPopup(selectedPath); - - return; - } - } - - std::string originalEntry = selectedPath.toStdString(); - std::string updatedEntry = selectedPath.toStdString(); - - if (const auto* e = std::get_if<HttpError>(&error)) - { - if (e->type == HttpError::Type::ConnectionFailed - && e->request == HttpError::Request::POST) - { - updatedEntry += validPathTryAgainTag; - } - if (e->type == HttpError::Type::BadStatusCode && e->statusCode == 503) - { - updatedEntry += validPathBrokenTag; - } - } - else - { - updatedEntry += validPathErrorTag; - } - - // Check for previously added unsuccessful tags before querying - if (sharedChoices->containsPath(originalEntry + validPathErrorTag)) - { - originalEntry += validPathErrorTag; - } - if (sharedChoices->containsPath(originalEntry + validPathBrokenTag)) - { - originalEntry += validPathBrokenTag; - } - if (sharedChoices->containsPath(originalEntry + validPathTryAgainTag)) - { - originalEntry += validPathTryAgainTag; - } - - if (sharedChoices->containsPath(updatedEntry)) - { - // Path has already been updated - } - else if (sharedChoices->containsPath(originalEntry)) - { - unsigned int currentIdx = (unsigned int) sharedChoices->getIndexForPath(originalEntry); - - // Update entry with tag for existing path - sharedChoices->updatePath(currentIdx, updatedEntry); - } - else - { - // Add a new entry with tag for custom path - sharedChoices->addNewPath(updatedEntry); - } - - lastSelectedPathIndex = lastLoadedPathIndex; - - selectedPath.clear(); - - setFinishedState(); - } - - void changeListenerCallback(ChangeBroadcaster* /*source*/) { resetModelPathComboBox(); } - -private: - void resetModelPathComboBox() - { - modelPathComboBox.clear(); - - for (unsigned int i = 0; i < sharedChoices->savedModelPaths.size(); ++i) - { - // Add saved path to combo box (skipping 0 for custom path) - modelPathComboBox.addItem(sharedChoices->savedModelPaths[i], static_cast<int>(i) + 1); - } - } - - void initializeModelPathComboBox() - { - modelPathComboBox.setTextWhenNothingSelected("click here to select a model..."); - - resetModelPathComboBox(); - - modelPathComboBox.onChange = [this] - { - if (modelPathComboBox.getSelectedItemIndex() == -1) - { - DBG_AND_LOG("ModelSelectionWidget::modelPathComboBox::onChange: Combo box reset."); - } - else - { - if (modelPathComboBox.getSelectedItemIndex() == 0) - { - DBG_AND_LOG( - "ModelSelectionWidget::modelPathComboBox::onChange: Custom path selected."); - - openCustomPathPopup(); - } - else - { - lastSelectedPathIndex = modelPathComboBox.getSelectedItemIndex(); - - DBG_AND_LOG("ModelSelectionWidget::modelPathComboBox::onChange: Entry " - << lastSelectedPathIndex << " selected."); - } - - loadModelButton.setEnabled(true); - } - }; - - addAndMakeVisible(modelPathComboBox); - - modelPathComboBoxHandler.onMouseEnter = [this]() - { - if (instructionsMessage != nullptr) - { - instructionsMessage->setMessage( - "A drop-down menu with featured available models. Any custom paths " - "successfully loaded will automatically be added to the list."); - } - }; - modelPathComboBoxHandler.onMouseExit = [this]() - { - if (instructionsMessage != nullptr) - { - instructionsMessage->clearMessage(); - } - }; - modelPathComboBoxHandler.attach(); - } - - void initializeLoadModelButton() - { - std::function<void()> loadCallback = [this]() - { - if (modelPathComboBox.getSelectedItemIndex() != 0) - { - selectedPath = modelPathComboBox.getText(); - - if (selectedPath.contains(validPathBrokenTag)) - { - selectedPath = selectedPath.replace(validPathBrokenTag, ""); - } - - if (selectedPath.contains(validPathTryAgainTag)) - { - selectedPath = selectedPath.replace(validPathTryAgainTag, ""); - } - - if (selectedPath.contains(validPathErrorTag)) - { - selectedPath = selectedPath.replace(validPathErrorTag, ""); - } - - sendChangeMessage(); - } - }; - - // Mode when a model is selected and not currently being loaded (load enabled) - loadButtonActiveInfo = MultiButton::Mode { "Load", - "Click to load currently selected model path.", - loadCallback, - MultiButton::DrawingMode::TextOnly }; - loadModelButton.addMode(loadButtonActiveInfo); - loadModelButton.setMode(loadButtonActiveInfo.displayLabel); - addAndMakeVisible(loadModelButton); - } - - /** - * Create callbacks for and launch the custom path popup. - */ - void openCustomPathPopup(const String& prefillText = "") - { - std::function<void(String)> loadCallback = [this](String path) - { - DBG_AND_LOG("ModelSelectionWidget::openCustomPathPopup::loadCallback: " - << "Custom path \"" << path << "\" entered."); - - loadModelBypass(path); - }; - - std::function<void()> cancelCallback = [this]() - { - DBG_AND_LOG("ModelSelectionWidget::openCustomPathPopup::cancelCallback: " - << "Custom path selection canceled."); - - if (lastLoadedPathIndex >= 0) - { - // Set combo box selection to last successfully loaded model - modelPathComboBox.setSelectedId(lastLoadedPathIndex + 1); - modelPathComboBox.setEnabled(true); - } - else - { - resetState(); - } - }; - - CustomPathComponent* content = - new CustomPathComponent(std::move(loadCallback), std::move(cancelCallback)); - - if (prefillText.isNotEmpty()) - { - content->setTextFieldValue(prefillText); - } - - DialogWindow::LaunchOptions options; - options.dialogTitle = "Enter Custom Path"; - options.dialogBackgroundColour = Colours::darkgrey; - options.content.setOwned(content); - - options.useNativeTitleBar = false; - options.resizable = false; - options.escapeKeyTriggersCloseButton = true; - options.componentToCentreAround = getParentComponent(); - - options.launchAsync(); - } - - const float marginSize = 2; - - SharedResourcePointer<SharedChoices> sharedChoices; - - ComboBox modelPathComboBox; - HoverHandler modelPathComboBoxHandler { modelPathComboBox }; - - int lastLoadedPathIndex; // Keep track of last loaded index for load failure cases - int lastSelectedPathIndex; - - const std::string validPathErrorTag = " [ERROR]"; - const std::string validPathBrokenTag = " [DOWN]"; - const std::string validPathTryAgainTag = " [TRY AGAIN]"; - - String selectedPath; - - MultiButton loadModelButton; - MultiButton::Mode loadButtonActiveInfo; - - SharedResourcePointer<InstructionsMessage> instructionsMessage; -}; diff --git a/src/widgets/ModelStyle.h b/src/widgets/ModelStyle.h new file mode 100644 index 00000000..65c73c5b --- /dev/null +++ b/src/widgets/ModelStyle.h @@ -0,0 +1,308 @@ +/** + * @file ModelStyle.h + * @brief Shared look for presenting models, on the Home tab and in model tabs alike: cards, + * tag chips, badges, and section headers. + * @author cwitkowitz + */ + +#pragma once + +#include <vector> + +#include <juce_gui_basics/juce_gui_basics.h> + +#include "../gui/Palette.h" + +#include "../utils/Interface.h" +#include "../utils/Messages.h" +#include "../utils/ModelCatalog.h" +#include "../utils/ModelTags.h" + +using namespace juce; + +namespace ModelStyle +{ +/* Cards */ + +inline constexpr float cornerSize = 6.0f; + +inline void + drawCard(Graphics& g, Rectangle<float> bounds, bool isHighlighted = false, bool isDown = false) +{ + g.setColour(getUIColourIfAvailable(LookAndFeel_V4::ColourScheme::UIColour::widgetBackground) + .brighter(isDown ? 0.14f : (isHighlighted ? 0.1f : 0.06f))); + g.fillRoundedRectangle(bounds, cornerSize); + + g.setColour(isHighlighted ? accent.withAlpha(0.6f) : outline); + g.drawRoundedRectangle(bounds, cornerSize, 1.0f); +} + +/* The lines text breaks into at a width, at most maxLines of them, the last of which takes + the rest of the text. Line breaks in the text are treated as spaces. Laying text out is + slow, so a caller that draws the same text repeatedly keeps the result. */ +inline StringArray getTruncatedLines(const String& text, const Font& f, int width, int maxLines) +{ + auto words = StringArray::fromTokens(text, " \t\r\n", ""); + words.removeEmptyStrings(); + const String flattened = words.joinIntoString(" "); + + AttributedString attributed; + attributed.append(flattened, f); + + TextLayout layout; + layout.createLayout(attributed, (float) width); + + StringArray lines; + + const int numLines = jmin(maxLines, layout.getNumLines()); + + for (int i = 0; i < numLines; ++i) + { + const auto range = layout.getLine(i).stringRange; + + lines.add((i == numLines - 1 ? flattened.substring(range.getStart()) + : flattened.substring(range.getStart(), range.getEnd())) + .trim()); + } + + return lines; +} + +/* Draws lines from getTruncatedLines, which divide the area evenly, in the current font and + colour, and ends the last line with an ellipsis if it does not fit. (drawFittedText shrinks + the font instead, and ignores the limit altogether for text with line breaks.) */ +inline void + drawTruncatedLines(Graphics& g, const StringArray& lines, Rectangle<int> area, int maxLines) +{ + const int lineHeight = area.getHeight() / jmax(1, maxLines); + + for (int i = 0; i < lines.size(); ++i) + { + g.drawText(lines[i], + area.removeFromTop(lineHeight), + Justification::centredLeft, + i == lines.size() - 1); + } +} + +/* Tag chips */ + +inline constexpr int chipHeight = 18; +inline constexpr int chipGap = 4; + +inline Font chipFont() { return font(11.0f, true); } + +inline int getChipWidth(const String& text) { return getTextWidth(chipFont(), text) + 12; } + +// Draws a rounded label at the given position and returns the x position just past it +inline int drawChip(Graphics& g, const String& text, int x, int y, Colour textColour, Colour fill) +{ + const auto bounds = Rectangle<int>(x, y, getChipWidth(text), chipHeight).toFloat(); + + g.setColour(fill); + g.fillRoundedRectangle(bounds, 4.0f); + g.setColour(textColour.withAlpha(0.45f)); + g.drawRoundedRectangle(bounds.reduced(0.5f), 4.0f, 1.0f); + + g.setColour(textColour); + g.setFont(chipFont()); + g.drawText(text, bounds.toNearestInt(), Justification::centred, false); + + return x + (int) bounds.getWidth() + chipGap; +} + +// Draws as many labels as fit in a row, followed by a count of the rest (e.g. "+2") +inline void drawTagRow(Graphics& g, const StringArray& labels, Rectangle<int> row) +{ + const int y = row.getCentreY() - chipHeight / 2; + int x = row.getX(); + + for (int i = 0; i < labels.size(); ++i) + { + const int remainingAfter = labels.size() - i - 1; + const int reserved = + remainingAfter > 0 ? chipGap + getChipWidth("+" + String(remainingAfter)) : 0; + + if (x + getChipWidth(labels[i]) + reserved > row.getRight()) + { + drawChip(g, + "+" + String(labels.size() - i), + x, + y, + Colours::lightgrey, + Colours::transparentBlack); + return; + } + + x = drawChip(g, labels[i], x, y, chipText, chipBackground); + } +} + +// Everything the tags say, one line per aspect, for the instructions box +inline String describeTags(const ModelTags& tags) +{ + StringArray lines; + + // Each one listed separately here, where there is room for a model that takes + // or returns several things + if (! tags.inputs.empty()) + lines.add("Inputs: " + ModelTags::describe(tags.inputs)); + + if (! tags.outputs.empty()) + lines.add("Outputs: " + ModelTags::describe(tags.outputs)); + + // Spelled out as well as badged, since a bare identifier does not say it is a license + if (const String terms = tags.describeLicense(); terms.isNotEmpty()) + lines.add("License: " + terms); + + if (const StringArray labels = tags.getDisplayLabels(); ! labels.isEmpty()) + lines.add("Tags: " + labels.joinIntoString(", ")); + + return lines.joinIntoString("\n"); +} + +/* Badges */ + +struct Badge +{ + String text; + Colour colour; +}; + +/* Notes on a model's deployment. Whether it can be expected to load is only worth noting + before it is loaded, so that is left out once it has been. */ +inline std::vector<Badge> getBadges(const CatalogEntry& entry, bool includeAvailability = true) +{ + std::vector<Badge> badges; + + if (includeAvailability) + { + if (const auto note = entry.getAvailabilityNote(); note.text.isNotEmpty()) + badges.push_back({ note.text, note.isProblem ? problem : information }); + } + + if (entry.isZeroGPU()) + badges.push_back({ "ZeroGPU", zeroGPU }); + + if (entry.provider != "Hugging Face") + badges.push_back({ entry.provider, Colours::lightgrey }); + + /* Last, so that it is drawn leftmost and reads straight after the name. The + availability note and the hardware stay at the right edge, where a reader + who is scanning a column of cards expects to find them. */ + if (const String terms = entry.tags.describeLicense(); terms.isNotEmpty()) + badges.push_back({ terms, license }); + + return badges; +} + +inline Font badgeFont() { return font(11.0f, true); } + +inline int getBadgeWidth(const Badge& badge) { return getTextWidth(badgeFont(), badge.text) + 12; } + +// Left edge of the badges drawBadges would draw in a row, or its right edge if there are none +inline int getBadgesLeft(const std::vector<Badge>& badges, Rectangle<int> row) +{ + int left = row.getRight() + chipGap; + + for (const auto& badge : badges) + left -= getBadgeWidth(badge) + chipGap; + + return left - (badges.empty() ? chipGap : 0); +} + +// Draws badges from the right of a row leftwards, and returns the left edge of the last one +inline int drawBadges(Graphics& g, const std::vector<Badge>& badges, Rectangle<int> row) +{ + int right = row.getRight(); + + for (const auto& badge : badges) + { + const int width = getBadgeWidth(badge); + const auto bounds = Rectangle<int>(right - width, row.getCentreY() - 9, width, 18); + + g.setColour(badge.colour.withAlpha(0.18f)); + g.fillRoundedRectangle(bounds.toFloat(), 9.0f); + g.setColour(badge.colour); + g.setFont(badgeFont()); + g.drawText(badge.text, bounds, Justification::centred, false); + + right = bounds.getX() - chipGap; + } + + return right + (badges.empty() ? 0 : chipGap); +} + +/* Section headers */ + +inline constexpr int sectionHeaderHeight = 26; + +// Draws a heading followed by a rule to the end of the area, or only the rule where the +// heading does not fit, since a heading cut short is harder to read than none at all +inline void drawSectionHeader(Graphics& g, const String& title, Rectangle<int> area) +{ + const Font titleFont = font(15.0f, true); + const int titleWidth = getTextWidth(titleFont, title); + + int lineX = area.getX(); + + if (titleWidth <= area.getWidth()) + { + g.setColour(Colours::white); + g.setFont(titleFont); + g.drawText(title, area, Justification::centredLeft, false); + + lineX += titleWidth + 10; + } + + g.setColour(accent.withAlpha(0.5f)); + g.fillRect(lineX, area.getCentreY(), jmax(0, area.getRight() - lineX), 1); +} + +class SectionHeader : public Component +{ +public: + explicit SectionHeader(const String& headerTitle) : title(headerTitle) + { + setInterceptsMouseClicks(false, false); + } + + void paint(Graphics& g) override { drawSectionHeader(g, title, getLocalBounds()); } + +private: + const String title; +}; + +/** + * A row of tag chips, which describes all of the tags in the instructions box while + * hovering over it (including any that did not fit). + */ +class TagRow : public Component +{ +public: + void setTags(const ModelTags& newTags) + { + tags = newTags; + labels = tags.getDisplayLabels(); + + repaint(); + } + + bool isEmpty() const { return labels.isEmpty(); } + + void paint(Graphics& g) override { drawTagRow(g, labels, getLocalBounds()); } + + void mouseEnter(const MouseEvent&) override + { + instructionsMessage->setMessage(describeTags(tags)); + } + + void mouseExit(const MouseEvent&) override { instructionsMessage->clearMessage(); } + +private: + ModelTags tags; + StringArray labels; + + SharedResourcePointer<InstructionsMessage> instructionsMessage; +}; +} // namespace ModelStyle diff --git a/src/widgets/PreviewPaneWidget.h b/src/widgets/PreviewPaneWidget.h new file mode 100644 index 00000000..ab985c77 --- /dev/null +++ b/src/widgets/PreviewPaneWidget.h @@ -0,0 +1,360 @@ +/** + * @file PreviewPaneWidget.h + * @brief Pane at the bottom of the media clipboard for previewing the selected file. + * @author NatalieElizabeth + */ + +#pragma once + +#include <juce_gui_basics/juce_gui_basics.h> + +#include "../gui/MultiButton.h" +#include "../gui/ResizeEdge.h" +#include "../gui/RoundIconButton.h" + +#include "../media/AudioDisplayComponent.h" +#include "../media/MediaDisplayComponent.h" +#include "../media/MidiDisplayComponent.h" + +#include "ModelStyle.h" + +using namespace juce; + +class PreviewPaneWidget : public Component +{ +public: + PreviewPaneWidget() + { + initializeButtons(); + updateButtonState(); + + resizeEdge.onDragStart = [this] { heightAtDragStart = getHeight(); }; + resizeEdge.onDrag = [this](int distanceDown) + { + if (onResize) + { + onResize(heightAtDragStart - distanceDown); + } + }; + resizeEdge.onDragEnd = [this] + { + if (onResizeFinished) + { + onResizeFinished(); + } + }; + addAndMakeVisible(resizeEdge); + } + + // Called with the height the pane is asking for while its top edge is dragged + std::function<void(int)> onResize; + // Called once a drag of its top edge ends + std::function<void()> onResizeFinished; + // Called after the pane has been minimized to its title bar, or is restored + std::function<void()> onMinimizeChanged; + // Called when the close button is clicked + std::function<void()> onClose; + + bool isMinimized() const { return minimized; } + + // Playback carries on while minimized, since the title bar keeps its controls + void setMinimized(bool shouldBeMinimized) + { + if (minimized == shouldBeMinimized) + { + return; + } + + minimized = shouldBeMinimized; + + minimizeButton.setPointingUp(minimized); + minimizeButton.setInstructions(minimized ? "Click to restore the preview pane." + : "Click to minimize the preview pane."); + resized(); + + if (onMinimizeChanged) + { + onMinimizeChanged(); + } + } + + /** + * Shows a media file in the pane, replacing whatever was shown before. A file + * that is neither audio nor MIDI leaves the pane empty. + */ + void showTrack(const URL& filePath) + { + clearTrack(); + + const String extension = filePath.getLocalFile().getFileExtension(); + + // Each display is created the first time it is needed, then kept and reused + if (AudioDisplayComponent::getSupportedExtensions().contains(extension, true)) + { + if (audioDisplay == nullptr) + { + audioDisplay = std::make_unique<AudioDisplayComponent>( + "Preview", false, false, DisplayMode::Preview); + audioDisplay->onPlaybackStateChanged = [this] { updateButtonState(); }; + addAndMakeVisible(*audioDisplay); + } + + currentDisplay = audioDisplay.get(); + } + else if (MidiDisplayComponent::getSupportedExtensions().contains(extension, true)) + { + if (midiDisplay == nullptr) + { + midiDisplay = std::make_unique<MidiDisplayComponent>( + "Preview", false, false, DisplayMode::Preview); + midiDisplay->onPlaybackStateChanged = [this] { updateButtonState(); }; + addAndMakeVisible(*midiDisplay); + } + + currentDisplay = midiDisplay.get(); + } + + if (currentDisplay != nullptr) + { + currentDisplay->initializeDisplay(filePath); + } + + updateButtonState(); + resized(); + repaint(); + } + + // Stops playback and returns to the start, keeping the track shown + void stopPlayback() + { + if (currentDisplay != nullptr) + { + currentDisplay->stop(); + } + } + + // Stops playback and empties the pane + void clearTrack() + { + if (currentDisplay != nullptr) + { + currentDisplay->stop(); + currentDisplay->resetDisplay(); + currentDisplay = nullptr; + } + + updateButtonState(); + resized(); + repaint(); + } + + void paint(Graphics& g) override + { + Rectangle<int> contentArea = getContentArea(); + + // Headed like a section of a model tab, with the buttons at the end of its rule + ModelStyle::drawSectionHeader( + g, "Preview", getTitleArea().withTrimmedRight(getButtonsWidth() + buttonGroupGap)); + + // Behind the display, like a track, or in its place until there is one to show + g.setColour(Colours::darkgrey); + g.fillRect(contentArea); + + if (currentDisplay == nullptr) + { + g.setColour(ModelStyle::secondaryText); + g.setFont(ModelStyle::font(13.0f)); + g.drawText("No track selected.", contentArea, Justification::centred); + } + } + + void resized() override + { + // A minimized pane cannot be resized, and shows nothing below its title bar + resizeEdge.setBounds(0, 0, getWidth(), minimized ? 0 : resizeEdgeHeight); + + const Rectangle<int> contentArea = getContentArea(); + + // Buttons sit at the right end of the title bar + FlexBox buttonsFlexBox; + buttonsFlexBox.flexDirection = FlexBox::Direction::row; + buttonsFlexBox.justifyContent = FlexBox::JustifyContent::flexEnd; + buttonsFlexBox.alignItems = FlexBox::AlignItems::center; + + for (Component* button : std::initializer_list<Component*> { + &playPauseButton, &stopButton, &minimizeButton, &closeButton }) + { + // The pane's own buttons are round, like a tab's, and set apart from playback + const bool isPaneButton = button == &minimizeButton || button == &closeButton; + const float size = (float) (isPaneButton ? paneButtonSize : playbackButtonSize); + const float leftMargin = button == &minimizeButton ? (float) buttonGroupGap : 0.0f; + + buttonsFlexBox.items.add(FlexItem(*button).withWidth(size).withHeight(size).withMargin( + { 0, (float) buttonMargin, 0, leftMargin })); + } + + buttonsFlexBox.performLayout(getTitleArea()); + + // Only the display in use is given any space + if (audioDisplay != nullptr) + { + audioDisplay->setBounds(currentDisplay == audioDisplay.get() ? contentArea + : Rectangle<int>()); + } + + if (midiDisplay != nullptr) + { + midiDisplay->setBounds(currentDisplay == midiDisplay.get() ? contentArea + : Rectangle<int>()); + } + } + + static constexpr int defaultHeight = 150; + static constexpr int minimumHeight = 100; + static constexpr int titleBarHeight = ModelStyle::sectionHeaderHeight; + +private: + void initializeButtons() + { + // Mode when a track is shown and is not playing (play enabled) + playButtonActiveInfo = MultiButton::Mode { "Play-Active", + "Click to start playback.", + [this] { playCallback(); }, + MultiButton::DrawingMode::IconOnly, + Colours::limegreen, + fontaudio::Play }; + // Mode when no track is shown (play disabled) + playButtonInactiveInfo = MultiButton::Mode { + "Play-Inactive", "Nothing to play.", [] {}, MultiButton::DrawingMode::IconOnly, + Colours::lightgrey, fontaudio::Play + }; + // Mode during playback (pause enabled) + pauseButtonInfo = MultiButton::Mode { "Pause", + "Click to pause playback.", + [this] { pauseCallback(); }, + MultiButton::DrawingMode::IconOnly, + Colours::yellow, + fontaudio::Pause }; + playPauseButton.addMode(playButtonActiveInfo); + playPauseButton.addMode(playButtonInactiveInfo); + playPauseButton.addMode(pauseButtonInfo); + addAndMakeVisible(playPauseButton); + + // Mode while playing or paused (stop enabled) + stopButtonActiveInfo = + MultiButton::Mode { "Stop-Active", + "Click to stop playback and return to the start.", + [this] { stopPlayback(); }, + MultiButton::DrawingMode::IconOnly, + Colours::orangered, + fontaudio::Stop }; + // Mode when there is nothing to stop (stop disabled) + stopButtonInactiveInfo = MultiButton::Mode { + "Stop-Inactive", "Nothing to stop.", [] {}, MultiButton::DrawingMode::IconOnly, + Colours::lightgrey, fontaudio::Stop + }; + stopButton.addMode(stopButtonActiveInfo); + stopButton.addMode(stopButtonInactiveInfo); + addAndMakeVisible(stopButton); + + minimizeButton.onClick = [this] { setMinimized(! minimized); }; + addAndMakeVisible(minimizeButton); + + closeButton.onClick = [this] + { + if (onClose) + { + onClose(); + } + }; + addAndMakeVisible(closeButton); + } + + // Sets both buttons to match what the display is doing + void updateButtonState() + { + if (currentDisplay == nullptr) + { + playPauseButton.setMode(playButtonInactiveInfo.displayLabel); + stopButton.setMode(stopButtonInactiveInfo.displayLabel); + + return; + } + + const bool playing = currentDisplay->isPlaying(); + const bool paused = currentDisplay->isPaused(); + + playPauseButton.setMode(playing ? pauseButtonInfo.displayLabel + : playButtonActiveInfo.displayLabel); + stopButton.setMode(playing || paused ? stopButtonActiveInfo.displayLabel + : stopButtonInactiveInfo.displayLabel); + } + + void playCallback() + { + if (currentDisplay != nullptr) + { + currentDisplay->start(); + } + } + + void pauseCallback() + { + if (currentDisplay != nullptr) + { + currentDisplay->pause(); + } + } + + Rectangle<int> getTitleArea() const { return getLocalBounds().removeFromTop(titleBarHeight); } + + // Below the title bar, with a gap like the one under a model tab's section header + Rectangle<int> getContentArea() const + { + if (minimized) + { + return {}; + } + + return getLocalBounds().withTrimmedTop(titleBarHeight + contentGap); + } + + static constexpr int getButtonsWidth() + { + return 2 * (playbackButtonSize + buttonMargin) + 2 * (paneButtonSize + buttonMargin) + + buttonGroupGap; + } + + static constexpr int resizeEdgeHeight = 4; + static constexpr int contentGap = 2; + static constexpr int playbackButtonSize = 24; + static constexpr int paneButtonSize = 18; + static constexpr int buttonMargin = 2; + static constexpr int buttonGroupGap = 6; + + MultiButton playPauseButton; + MultiButton::Mode playButtonActiveInfo; + MultiButton::Mode playButtonInactiveInfo; + MultiButton::Mode pauseButtonInfo; + + MultiButton stopButton; + MultiButton::Mode stopButtonActiveInfo; + MultiButton::Mode stopButtonInactiveInfo; + + ChevronButton minimizeButton { "Click to minimize the preview pane." }; + CloseButton closeButton { + "Click to close the preview pane. It can be shown again from the View menu." + }; + + // Declared after the buttons, since their playback callbacks update the buttons + std::unique_ptr<AudioDisplayComponent> audioDisplay; + std::unique_ptr<MidiDisplayComponent> midiDisplay; + + // Whichever of the two displays is showing a track, or nullptr when the pane is empty + MediaDisplayComponent* currentDisplay = nullptr; + + ResizeEdge resizeEdge { ResizeEdge::Side::Top }; + int heightAtDragStart = defaultHeight; + + bool minimized = false; +}; diff --git a/src/widgets/StatusAreaWidget.h b/src/widgets/StatusAreaWidget.h index 3c7189a3..65b72766 100644 --- a/src/widgets/StatusAreaWidget.h +++ b/src/widgets/StatusAreaWidget.h @@ -1,98 +1,211 @@ /** * @file StatusAreaWidget.h * @brief Defines shared resources and components for instructions and status. - * @author xribene, cwitkowitz + * @author cwitkowitz, saumya-pailwan, xribene */ #pragma once #include <juce_gui_basics/juce_gui_basics.h> +#include "../utils/Messages.h" + using namespace juce; -struct SharedMessage : public ChangeBroadcaster +/** + * Shows the latest instructions, at a smaller size if that is what it takes to fit the box + * (down to a minimum), and cut short with an ellipsis if they do not fit even then. + */ +class InstructionsBox : public Component, ChangeListener { - void setMessage(const String& m) +public: + InstructionsBox() { sharedMessage->addChangeListener(this); } + + ~InstructionsBox() override { sharedMessage->removeChangeListener(this); } + + void paint(Graphics& g) override + { + g.setColour(Colour(0x33, 0x33, 0x33)); + g.fillAll(); + + g.setColour(Colour(0x44, 0x44, 0x44)); + g.drawRect(getLocalBounds(), 1); + + layout.draw(g, layoutArea); + + if (lastLine.isNotEmpty()) + { + g.setColour(textColour); + g.setFont(lastLineFont); + g.drawText(lastLine, + lastLineArea, + textJustification.getOnlyHorizontalFlags() + | Justification::verticallyCentred, + true); + } + } + + void resized() override { fitMessage(); } + + void changeListenerCallback(ChangeBroadcaster* /*source*/) override { - message = m; - sendChangeMessage(); + message = sharedMessage->getMessage(); + + fitMessage(); + repaint(); + } + +private: + TextLayout layOut(const String& text, float fontHeight, float width) const + { + AttributedString attributed; + attributed.append(text, Font(FontOptions(fontHeight)), textColour); + attributed.setJustification(textJustification.getOnlyHorizontalFlags()); + + TextLayout result; + result.createLayout(attributed, width); + + return result; } - void clearMessage() + /* Lays the message out at the largest size, from maximumFontHeight down to + minimumFontHeight, at which all of it fits. If it does not fit even at the smallest, the + lines that fit are kept, and the last of them takes the rest of the message, cut short + with an ellipsis. (A Label would draw text with line breaks past its bounds instead.) */ + void fitMessage() { - message.clear(); - sendChangeMessage(); + const auto area = getLocalBounds().reduced(textInsetX, textInsetY).toFloat(); + + layout = TextLayout(); + lastLine = {}; + + if (message.isEmpty() || area.isEmpty()) + return; + + float fontHeight = maximumFontHeight; + layout = layOut(message, fontHeight, area.getWidth()); + + while (layout.getHeight() > area.getHeight() && fontHeight > minimumFontHeight) + { + fontHeight = jmax(minimumFontHeight, fontHeight - 0.5f); + layout = layOut(message, fontHeight, area.getWidth()); + } + + if (layout.getHeight() <= area.getHeight()) + { + layoutArea = area.withSizeKeepingCentre(area.getWidth(), layout.getHeight()); + return; + } + + int numLines = 1; + + while (numLines < layout.getNumLines() + && layout.getLine(numLines).getLineBoundsY().getEnd() <= area.getHeight()) + { + ++numLines; + } + + const auto& last = layout.getLine(numLines - 1); + const int lastStart = last.stringRange.getStart(); + const auto lastBounds = last.getLineBoundsY(); + + lastLine = message.substring(lastStart).replaceCharacters("\r\n", " ").trim(); + lastLineFont = Font(FontOptions(fontHeight)); + lastLineArea = + area.withTrimmedTop(lastBounds.getStart()).withHeight(lastBounds.getLength()); + + // The lines before it break where they did, since they are laid out the same way + layout = layOut(message.substring(0, lastStart).trimEnd(), fontHeight, area.getWidth()); + layoutArea = area; } + static constexpr float maximumFontHeight = 15.0f; + static constexpr float minimumFontHeight = 12.0f; + static constexpr int textInsetX = 6; + static constexpr int textInsetY = 4; + + const Justification textJustification { Justification::centred }; + const Colour textColour { 0xffe0e0e0 }; + + SharedResourcePointer<InstructionsMessage> sharedMessage; String message; -}; -struct StatusMessage : SharedMessage -{ -}; -struct InstructionsMessage : SharedMessage -{ + TextLayout layout; + Rectangle<float> layoutArea; + + // The last line shown when the message is cut short, drawn with an ellipsis + String lastLine; + Font lastLineFont { FontOptions() }; + Rectangle<float> lastLineArea; }; -template <typename MessageType> -class MessageBox : public Component, ChangeListener +class StatusHistoryBox : public Component, ChangeListener { public: - MessageBox(float fontSize = 15.0f, Justification justification = Justification::centred) + StatusHistoryBox() { - messageLabel.setFont(fontSize); - messageLabel.setColour(Label::textColourId, Colour(0xE0, 0xE0, 0xE0)); - - messageLabel.setJustificationType(justification); - addAndMakeVisible(messageLabel); + historyEditor.setMultiLine(true); + historyEditor.setReadOnly(true); + historyEditor.setScrollbarsShown(true); + historyEditor.setCaretVisible(false); + historyEditor.setPopupMenuEnabled(false); + // Transparent, so that the fill from paint() shows through as in the InstructionsBox + historyEditor.setColour(TextEditor::backgroundColourId, Colours::transparentBlack); + historyEditor.setColour(TextEditor::textColourId, Colour(0xE0, 0xE0, 0xE0)); + historyEditor.setColour(TextEditor::outlineColourId, Colours::transparentBlack); + historyEditor.setColour(TextEditor::focusedOutlineColourId, Colours::transparentBlack); + + addAndMakeVisible(historyEditor); + + historyEditor.setText(sharedMessage->getHistoryText(), false); + historyEditor.moveCaretToEnd(); sharedMessage->addChangeListener(this); } - ~MessageBox() override { sharedMessage->removeChangeListener(this); } + ~StatusHistoryBox() override { sharedMessage->removeChangeListener(this); } - void paint(Graphics& g) + void paint(Graphics& g) override { g.setColour(Colour(0x33, 0x33, 0x33)); g.fillAll(); - g.setColour(Colour(0x44, 0x44, 0x44)); g.drawRect(getLocalBounds(), 1); } - void resized() { messageLabel.setBounds(getLocalBounds()); } + void resized() override { historyEditor.setBounds(getLocalBounds()); } - void changeListenerCallback(ChangeBroadcaster* /*source*/) + void changeListenerCallback(ChangeBroadcaster* /*source*/) override { - messageLabel.setText(sharedMessage->message, dontSendNotification); + /* Rebuild the full text on every change. The history is capped at + StatusMessage::maxHistoryEntries short lines, so this is cheap, and + it avoids incremental-append bookkeeping that can drop or duplicate + entries when messages arrive from worker threads. */ + historyEditor.setText(sharedMessage->getHistoryText(), false); + historyEditor.moveCaretToEnd(); } private: - SharedResourcePointer<MessageType> sharedMessage; - Label messageLabel; + SharedResourcePointer<StatusMessage> sharedMessage; + TextEditor historyEditor; }; -using StatusBox = MessageBox<StatusMessage>; -using InstructionsBox = MessageBox<InstructionsMessage>; - class StatusAreaWidget : public Component { public: StatusAreaWidget() { addAndMakeVisible(instructionsBox); - addAndMakeVisible(statusBox); + addAndMakeVisible(statusHistoryBox); } - ~StatusAreaWidget() {} - void resized() override { FlexBox statusArea; statusArea.flexDirection = FlexBox::Direction::row; + statusArea.items.add(FlexItem(statusHistoryBox).withFlex(1).withMargin(marginSize)); statusArea.items.add(FlexItem(instructionsBox).withFlex(1).withMargin(marginSize)); - statusArea.items.add(FlexItem(statusBox).withFlex(1).withMargin(marginSize)); statusArea.performLayout(getLocalBounds()); } @@ -101,5 +214,5 @@ class StatusAreaWidget : public Component const float marginSize = 2; InstructionsBox instructionsBox; - StatusBox statusBox; + StatusHistoryBox statusHistoryBox; }; diff --git a/src/widgets/TrackAreaWidget.h b/src/widgets/TrackAreaWidget.h index f81f7765..382d6150 100644 --- a/src/widgets/TrackAreaWidget.h +++ b/src/widgets/TrackAreaWidget.h @@ -1,7 +1,7 @@ /** * @file TrackAreaWidget.h * @brief Component that displays a group of tracks in the GUI. - * @author xribene, cwitkowitz + * @author cwitkowitz, NatalieElizabeth, xribene */ #pragma once @@ -9,24 +9,122 @@ #include <juce_gui_basics/juce_gui_basics.h> #include "../media/AudioDisplayComponent.h" +#include "../media/FileDisplayComponent.h" #include "../media/MediaDisplayComponent.h" #include "../media/MidiDisplayComponent.h" +#include "../gui/Palette.h" + #include "../utils/Controls.h" #include "../utils/Interface.h" #include "../utils/Logging.h" using namespace juce; +class DragOverlayComponent : public Component +{ +public: + DragOverlayComponent() { setInterceptsMouseClicks(false, false); } + + void startDrag(Image snapshot, Point<int> offset) + { + if (isActive) + return; + dragImage = snapshot; + dragOffset = offset; + isActive = true; + setVisible(true); + repaint(); + } + + // Called every mouseDrag event with the current mouse position + void updatePosition(Point<int> newPos) + { + if (newPos == currentPos) + return; + + // Clear old position + repaint(currentPos.x - dragOffset.x, + currentPos.y - dragOffset.y, + dragImage.getWidth(), + dragImage.getHeight()); + + currentPos = newPos; + + // Repaint only new position + repaint(currentPos.x - dragOffset.x, + currentPos.y - dragOffset.y, + dragImage.getWidth(), + dragImage.getHeight()); + } + + void stopDrag() + { + isActive = false; + dragImage = Image(); // Releases the image data + setVisible(false); + repaint(); + } + + void paint(Graphics& g) override + { + if (! isActive || dragImage.isNull()) + return; + + g.drawImage(dragImage, + currentPos.x - dragOffset.x, + currentPos.y - dragOffset.y, + dragImage.getWidth(), + dragImage.getHeight(), + 0, + 0, + dragImage.getWidth(), + dragImage.getHeight()); + } + +private: + Image dragImage; + Point<int> currentPos; + Point<int> dragOffset; + bool isActive = false; +}; + +class GhostTrackComponent : public Component +{ +public: + void setImage(const Image& img) + { + ghostImage = img; + repaint(); + } + + void paint(Graphics& g) override + { + if (ghostImage.isNull()) + return; + + g.setOpacity(0.4f); + g.drawImage(ghostImage, getLocalBounds().toFloat(), RectanglePlacement::stretchToFit); + } + +private: + Image ghostImage; +}; + class TrackAreaWidget : public Component, public ChangeListener, public ChangeBroadcaster, + public DragAndDropTarget, public FileDragAndDropTarget { public: - TrackAreaWidget(DisplayMode mode = DisplayMode::Input, int trackHeight = 0) - : displayMode(mode), fixedTrackHeight(trackHeight) + TrackAreaWidget(DisplayMode mode = DisplayMode::Input, + int trackHeight = 0, + DragOverlayComponent* overlay = nullptr) + : displayMode(mode), fixedTrackHeight(trackHeight), dragOverlay(overlay) { + addMouseListener(this, true); + addChildComponent(ghostTrack); } ~TrackAreaWidget() { resetState(); } @@ -34,8 +132,62 @@ class TrackAreaWidget : public Component, void paint(Graphics& g) override { g.fillAll(getUIColourIfAvailable(LookAndFeel_V4::ColourScheme::UIColour::windowBackground)); + + // An empty media clipboard says how to fill it, as an empty track does + if (isThumbnailWidget() && getNumTracks() == 0) + { + g.setColour(ModelStyle::secondaryText); + g.setFont(ModelStyle::font(13.0f)); + g.drawFittedText("Drag tracks or files here, or add files with the folder icon.", + getLocalBounds().reduced(12), + Justification::centred, + 3); + } } + /** Tracks that expand to fill the space given to them. */ + int getNumFlexibleTracks() const + { + int count = 0; + + for (const auto& m : mediaDisplays) + { + if (m->getFixedHeight() <= 0) + { + count++; + } + } + + return count; + } + + /** + * Height needed to show every track at its minimum size, which for a track with a + * fixed height is that height. + * + * The margin applied to each track lives here, so the calculation does too: a + * caller reserving space with its own margin will come up short, by more for every + * additional track. + */ + int getMinimumHeight() const + { + int total = 0; + + for (const auto& m : mediaDisplays) + { + const int fixed = m->getFixedHeight(); + + total += (fixed > 0 ? fixed : m->getMinimumHeight()) + 2 * static_cast<int>(marginSize); + } + + return total; + } + + static_assert(MediaDisplayComponent::minimumUsefulHeight + > MediaDisplayComponent::fixedChromeHeight, + "A track must be taller than its fixed strips, or the media content is " + "laid out with a negative height."); + void resized() override { FlexBox mainBox; @@ -47,22 +199,65 @@ class TrackAreaWidget : public Component, if (getNumTracks() > 0) { + int draggedIndex = isDraggingTrack ? getDraggedTrackIndex() : -1; + + int visualGapIndex = dragInsertIndex; + if (isDraggingTrack && draggedIndex >= 0 && dragInsertIndex > draggedIndex) + visualGapIndex = dragInsertIndex - 1; + + int layoutIndex = 0; + + /* The space the dragged track would drop into. While the track is dragged outside + the list, a faded copy marks the place it returns to. */ + auto addDragGap = [&] + { + ghostTrack.setVisible(isDraggingOutside); + + FlexItem gap = isDraggingOutside ? FlexItem(ghostTrack) : FlexItem(); + + if (fixedTrackHeight) + gap = gap.withHeight((float) fixedTrackHeight); + else + gap = gap.withFlex(1).withMinHeight(MediaDisplayComponent::minimumUsefulHeight); + + mainBox.items.add(gap.withMargin(marginSize)); + }; + for (auto& m : mediaDisplays) { + // Don't draw dragged track + if (m.get() == draggedTrack && isDraggingTrack) + continue; + + if (isDraggingTrack && dragInsertIndex >= 0 && layoutIndex == visualGapIndex) + addDragGap(); + FlexItem i = FlexItem(*m); + int fixedH = m->getFixedHeight(); + if (fixedTrackHeight) { - i = i.withHeight(fixedTrackHeight); + i = i.withHeight((float) fixedTrackHeight); + } + else if (fixedH > 0) + { + i = i.withHeight((float) fixedH).withFlex(0); } else { - i = i.withFlex(1).withMinHeight(50); + i = i.withFlex(1).withMinHeight(static_cast<float>(m->getMinimumHeight())); } mainBox.items.add(i.withMargin(marginSize)); + + layoutIndex++; } + // If the drag gap is at the end of the list + if (isDraggingTrack && dragInsertIndex >= 0 && layoutIndex == visualGapIndex) + addDragGap(); + if (fixedTrackHeight) { int individualTrackHeight = fixedTrackHeight + static_cast<int>(2 * marginSize); @@ -115,34 +310,6 @@ class TrackAreaWidget : public Component, return nullptr; } - Rectangle<int> getFirstTrackFolderButtonBounds() - { - if (mediaDisplays.size() > 0) - { - auto display = mediaDisplays[0].get(); - auto bounds = display->getChooseFileButtonBounds(); - - // Convert to TrackAreaWidget coordinates - return getLocalArea(display, bounds); - } - - return {}; - } - - Rectangle<int> getFirstTrackPlayButtonBounds() - { - if (mediaDisplays.size() > 0) - { - auto display = mediaDisplays[0].get(); - auto bounds = display->getPlayButtonBounds(); - - // Convert to TrackAreaWidget coordinates - return getLocalArea(display, bounds); - } - - return {}; - } - std::vector<MediaDisplayComponent*> getDAWLinkedDisplays() { std::vector<MediaDisplayComponent*> linkedDisplays; @@ -158,12 +325,9 @@ class TrackAreaWidget : public Component, return linkedDisplays; } - int getNumTracks() { return mediaDisplays.size(); } + int getNumTracks() const { return (int) mediaDisplays.size(); } - bool isInputWidget() { return (displayMode == DisplayMode::Input) || isHybridWidget(); } - bool isOutputWidget() { return (displayMode == DisplayMode::Output) || isHybridWidget(); } - bool isHybridWidget() { return displayMode == DisplayMode::Hybrid; } - bool isThumbnailWidget() { return displayMode == DisplayMode::Thumbnail; } + bool isThumbnailWidget() const { return displayMode == DisplayMode::Thumbnail; } void setLoadTrackEnabled(bool enabled) { @@ -178,6 +342,32 @@ class TrackAreaWidget : public Component, return isThumbnailWidget(); } + /* Tracks drag their files within the window as a JUCE drag rather than an + operating system one, and only a DragAndDropTarget receives those. */ + bool isInterestedInDragSource(const DragAndDropTarget::SourceDetails& details) override + { + if (! isThumbnailWidget() || ! details.description.isString()) + { + return false; + } + + // Its own tracks count too, so a drop here does not animate the drag image back + return isParentOf(details.sourceComponent.get()) + || MediaDisplayComponent::getSupportedExtensions().contains( + File(details.description.toString()).getFileExtension(), true); + } + + void itemDropped(const DragAndDropTarget::SourceDetails& details) override + { + // Moving a track within this widget is handled on mouse up + if (isParentOf(details.sourceComponent.get())) + { + return; + } + + addTrackFromFilePath(URL(File(details.description.toString()))); + } + void setFixedTotalDimensions(int totalWidth, int totalHeight) { fixedTotalWidth = totalWidth; @@ -229,20 +419,44 @@ class TrackAreaWidget : public Component, m->setMediaInstructions(trackInfo->info); } - //m->setDisplayID(trackInfo->id); m->addChangeListener(this); + m->onMediaDoubleClick = [this] + { + if (onTrackMediaDoubleClicked) + { + onTrackMediaDoubleClicked(); + } + }; addAndMakeVisible(m.get()); mediaDisplays.push_back(std::move(m)); resized(); - if (isThumbnailWidget()) - { - mediaDisplays.back()->selectTrack(); - } + // The first track replaces the hint an empty clipboard shows + if (isThumbnailWidget() && getNumTracks() == 1) + repaint(); } } + void addFileOutputFromComponentInfo(FileComponentInfo* fileInfo) + { + std::string label = fileInfo->label.empty() ? "File Output" : fileInfo->label; + + auto m = std::make_unique<FileDisplayComponent>(String(label), false, false, displayMode); + + m->setTrackID(fileInfo->id); + m->setInstanceFileTypes(fileInfo->fileTypes); + + if (! fileInfo->info.empty()) + m->setMediaInstructions(fileInfo->info); + + m->addChangeListener(this); + addAndMakeVisible(m.get()); + mediaDisplays.push_back(std::move(m)); + + resized(); + } + void updateTracks(const ModelComponentInfoList& trackComponents) { resetState(); @@ -253,9 +467,13 @@ class TrackAreaWidget : public Component, { addTrackFromComponentInfo(trackInfo); } + else if (auto* fileInfo = dynamic_cast<FileComponentInfo*>(info.get())) + { + addFileOutputFromComponentInfo(fileInfo); + } else { - // Invalid input track + // Invalid track component jassertfalse; } } @@ -282,13 +500,13 @@ class TrackAreaWidget : public Component, } String ext = f.getFileExtension(); - String label = filePath.getFileName(); + String label = f.getFileName(); bool validExt = true; std::unique_ptr<TrackComponentInfo> trackInfo; - if (AudioDisplayComponent::getSupportedExtensions().contains(ext)) + if (AudioDisplayComponent::getSupportedExtensions().contains(ext, true)) { auto audioTrackInfo = std::make_unique<AudioTrackComponentInfo>(); @@ -297,7 +515,7 @@ class TrackAreaWidget : public Component, trackInfo = std::move(audioTrackInfo); } - else if (MidiDisplayComponent::getSupportedExtensions().contains(ext)) + else if (MidiDisplayComponent::getSupportedExtensions().contains(ext, true)) { auto midiTrackInfo = std::make_unique<MidiTrackComponentInfo>(); @@ -318,7 +536,13 @@ class TrackAreaWidget : public Component, { addTrackFromComponentInfo(trackInfo.get(), fromDAW); mediaDisplays.back()->initializeDisplay(filePath); - mediaDisplays.back()->setTrackName(filePath.getFileName()); + mediaDisplays.back()->setTrackName(label); + + // Selected only once loaded, so listeners never see a track without its media + if (isThumbnailWidget()) + { + mediaDisplays.back()->selectTrack(); + } } } @@ -334,8 +558,42 @@ class TrackAreaWidget : public Component, mediaDisplays.erase(it, mediaDisplays.end()); resized(); + + // The hint an empty clipboard shows returns with the last track removed + if (isThumbnailWidget() && getNumTracks() == 0) + repaint(); } + void reorderTrack(MediaDisplayComponent* draggedDisplay, int newIndex) + { + auto it = + std::find_if(mediaDisplays.begin(), + mediaDisplays.end(), + [draggedDisplay](const auto& ptr) { return ptr.get() == draggedDisplay; }); + + if (it == mediaDisplays.end()) + return; + + // Track the old index for downward reordering + const int oldIndex = (int) std::distance(mediaDisplays.begin(), it); + + auto draggedPtr = std::move(*it); + mediaDisplays.erase(it); + + // Decrement index if moving downward to account for shifting indices + if (newIndex > oldIndex) + newIndex--; + + newIndex = jlimit(0, (int) mediaDisplays.size(), newIndex); + + mediaDisplays.insert(mediaDisplays.begin() + newIndex, std::move(draggedPtr)); + + resized(); + } + + // Called when the media of a thumbnail track is double-clicked + std::function<void()> onTrackMediaDoubleClicked; + void filesDropped(const StringArray& files, int /*x*/, int /*y*/) override { for (String f : files) @@ -365,12 +623,190 @@ class TrackAreaWidget : public Component, } } + int getInsertIndexAtY(int y) + { + int trackSlotHeight = fixedTrackHeight + static_cast<int>(2 * marginSize); + + if (isDraggingTrack && draggedTrack != nullptr) + { + int draggedIndex = getDraggedTrackIndex(); + + int draggedSlotTop = draggedIndex * trackSlotHeight; + + if (draggedIndex >= 0 && y > draggedSlotTop) + y += trackSlotHeight; + } + + int index = y / trackSlotHeight; + return jlimit(0, getNumTracks(), index); + } + + int getDraggedTrackIndex() const + { + if (draggedTrack == nullptr) + return -1; + + for (int i = 0; i < getNumTracks(); i++) + { + if (mediaDisplays[(size_t) i].get() == draggedTrack) + return i; + } + + return -1; + } + + void mouseDown(const MouseEvent& e) override + { + if (! isThumbnailWidget()) + return; + + // Reset drag state at the start of every click + draggedTrack = nullptr; + isDraggingTrack = false; + + Component* clicked = e.eventComponent; + + MediaDisplayComponent* clickedDisplay = nullptr; + Component* c = clicked; + while (c != nullptr && c != this) + { + // Clicks inside a track name being edited do not start a drag + if (dynamic_cast<TextEditor*>(c) != nullptr) + { + return; + } + + if (auto* md = dynamic_cast<MediaDisplayComponent*>(c)) + { + clickedDisplay = md; + break; + } + c = c->getParentComponent(); + } + + if (clickedDisplay) + { + draggedTrack = clickedDisplay; + dragOriginIndex = getDraggedTrackIndex(); + + Point<int> mouseInThis = e.getEventRelativeTo(this).getPosition(); + Point<int> trackTopLeft = draggedTrack->getBounds().getTopLeft(); + dragClickOffset = mouseInThis - trackTopLeft; + } + else + { + for (auto& m : mediaDisplays) + { + m->deselectTrack(); + } + } + } + + void mouseDrag(const MouseEvent& e) override + { + if (! isThumbnailWidget() || draggedTrack == nullptr) + return; + + Point<int> posInThis = e.getEventRelativeTo(this).getPosition(); + + if (! isDraggingTrack && e.getDistanceFromDragStart() > 5) + { + isDraggingTrack = true; + + // Takes a snapshot of the track at the moment dragging starts + if (dragOverlay != nullptr) + { + Image snapshot = + draggedTrack->createComponentSnapshot(draggedTrack->getLocalBounds()); + ghostTrack.setImage(snapshot); + dragOverlay->startDrag(snapshot, dragClickOffset); + draggedTrack->setVisible(false); + } + } + + if (isDraggingTrack) + { + int newInsertIndex = -1; + bool wasOutside = isDraggingOutside; + + if (getLocalBounds().contains(posInThis)) + { + newInsertIndex = getInsertIndexAtY(posInThis.y); + isDraggingOutside = false; + } + else + { + newInsertIndex = dragOriginIndex; + isDraggingOutside = true; + } + + if (newInsertIndex != dragInsertIndex || isDraggingOutside != wasOutside) + { + dragInsertIndex = newInsertIndex; + resized(); + } + + // Converts the position to the overlay's coordinate space + if (dragOverlay != nullptr) + { + Point<int> posInOverlay = dragOverlay->getLocalPoint(this, posInThis); + dragOverlay->updatePosition(posInOverlay); + } + } + } + + void mouseUp(const MouseEvent& e) override + { + if (! isThumbnailWidget()) + return; + + // Tracks the release position of the mouse + Point<int> releasePos = e.getEventRelativeTo(this).getPosition(); + + // Only reorders if the mouse was released inside the widget + if (isDraggingTrack && draggedTrack != nullptr && getLocalBounds().contains(releasePos)) + { + int currentIndex = getDraggedTrackIndex(); + + if (dragInsertIndex != currentIndex) + { + reorderTrack(draggedTrack, dragInsertIndex); + } + } + + // Restore the track's appearance and stop the overlay + if (draggedTrack != nullptr) + draggedTrack->setVisible(true); + if (dragOverlay != nullptr) + dragOverlay->stopDrag(); + + // Reset the drag state + draggedTrack = nullptr; + dragInsertIndex = -1; + dragOriginIndex = -1; + isDraggingTrack = false; + isDraggingOutside = false; + ghostTrack.setVisible(false); + + resized(); + } + const DisplayMode displayMode; const int fixedTrackHeight = 0; - const float marginSize = 4; + static constexpr float marginSize = 4; int fixedTotalWidth = 0; int minTotalHeight = 0; + // For reordering tracks via dragging + MediaDisplayComponent* draggedTrack = nullptr; + DragOverlayComponent* dragOverlay = nullptr; + GhostTrackComponent ghostTrack; + int dragInsertIndex = -1; + int dragOriginIndex = -1; + bool isDraggingTrack = false; + bool isDraggingOutside = false; + Point<int> dragClickOffset { 0, 0 }; + std::vector<std::unique_ptr<MediaDisplayComponent>> mediaDisplays; }; diff --git a/src/windows/AboutWindow.h b/src/windows/AboutWindow.h index 2b633457..5170a4bb 100644 --- a/src/windows/AboutWindow.h +++ b/src/windows/AboutWindow.h @@ -1,7 +1,7 @@ /** * @file AboutWindow.h * @brief Window containing basic information and links. - * @author hugofloresgarcia + * @author cwitkowitz, hugofloresgarcia */ #pragma once diff --git a/src/windows/CustomPathWindow.h b/src/windows/CustomPathWindow.h new file mode 100644 index 00000000..10025fa0 --- /dev/null +++ b/src/windows/CustomPathWindow.h @@ -0,0 +1,190 @@ +/** + * @file CustomPathWindow.h + * @brief Popup for entering the path of a model that is not listed on the Home tab. + * @author cwitkowitz, xribene + */ + +#pragma once + +#include <functional> + +#include <juce_gui_basics/juce_gui_basics.h> + +#include "../utils/Clients.h" +#include "../utils/Interface.h" +#include "../utils/Logging.h" + +using namespace juce; + +class CustomPathComponent : public Component +{ +public: + explicit CustomPathComponent(std::function<void(String)> onLoad) + : onLoadCallback(std::move(onLoad)) + { + pathEditor.setMultiLine(false); + pathEditor.setReturnKeyStartsNewLine(false); + pathEditor.setTextToShowWhenEmpty("e.g., user/space-name or a Gradio URL", Colours::grey); + pathEditor.onTextChange = [this] + { + loadButton.setEnabled(! pathEditor.isEmpty()); + errorLabel.setText({}, dontSendNotification); + }; + pathEditor.onReturnKey = [this] + { + if (loadButton.isEnabled()) + { + loadButton.triggerClick(); + } + }; + addAndMakeVisible(pathEditor); + + errorLabel.setColour(Label::textColourId, Colours::orange); + errorLabel.setJustificationType(Justification::centredLeft); + addAndMakeVisible(errorLabel); + + loadButton.setEnabled(false); + loadButton.onClick = [this] { submit(); }; + addAndMakeVisible(loadButton); + + cancelButton.onClick = [this] { closePopup(); }; + addAndMakeVisible(cancelButton); + + setSize(popupWidth, popupHeight); + } + + void visibilityChanged() override + { + if (isVisible()) + { + SafePointer<CustomPathComponent> safeThis(this); + + MessageManager::callAsync( + [safeThis] + { + if (safeThis != nullptr) + safeThis->pathEditor.grabKeyboardFocus(); + }); + } + } + + void resized() override + { + Rectangle<int> fullArea = getLocalBounds(); + + /* TopLevelWindow::centreAroundComponent shrinks a dialog to fit the area it + is centered within, so this can be laid out smaller than the size asked + for. Fixed items would then exceed the space and leave the rest negative. */ + if (fullArea.isEmpty()) + { + for (auto* child : getChildren()) + child->setBounds({}); + + return; + } + + const int editorHeight = jmin(editorRowHeight, fullArea.getHeight()); + + FlexBox fullPopup; + fullPopup.flexDirection = FlexBox::Direction::column; + + fullPopup.items.add(FlexItem(pathEditor) + .withHeight((float) editorHeight) + .withMargin(jmin(2.0f, (float) fullArea.getHeight() / 4.0f))); + + fullPopup.items.add( + FlexItem(errorLabel).withHeight((float) errorRowHeight).withMinHeight(0.0f)); + + FlexBox buttonsArea; + buttonsArea.flexDirection = FlexBox::Direction::row; + + // Margins have to shrink with the popup, or they consume more than there is + const float buttonMargin = + jmin(10.0f, (float) jmin(fullArea.getWidth(), fullArea.getHeight()) / 8.0f); + + buttonsArea.items.add(FlexItem(loadButton).withFlex(1).withMargin(buttonMargin)); + buttonsArea.items.add(FlexItem().withFlex(0.25)); + buttonsArea.items.add(FlexItem(cancelButton).withFlex(1).withMargin(buttonMargin)); + + fullPopup.items.add(FlexItem(buttonsArea).withFlex(1).withMinHeight(0.0f)); + + fullPopup.performLayout(fullArea); + + // FlexBox can still hand out negative sizes when the space runs out + for (auto* child : getChildren()) + { + if (child->getWidth() < 0 || child->getHeight() < 0) + { + child->setBounds(child->getBounds().withSize(jmax(0, child->getWidth()), + jmax(0, child->getHeight()))); + } + } + } + + void paint(Graphics& g) override + { + g.fillAll(getUIColourIfAvailable(LookAndFeel_V4::ColourScheme::UIColour::windowBackground)); + } + + /** + * Launches the popup centered around a component. The path entered is passed to + * onLoad, reduced to the form its provider recognizes. + */ + static void launch(Component* componentToCentreAround, std::function<void(String)> onLoad) + { + DialogWindow::LaunchOptions options; + options.dialogTitle = "Enter Custom Path"; + options.dialogBackgroundColour = Colours::darkgrey; + options.content.setOwned(new CustomPathComponent(std::move(onLoad))); + + options.useNativeTitleBar = false; + options.resizable = false; + options.escapeKeyTriggersCloseButton = true; + options.componentToCentreAround = componentToCentreAround; + + options.launchAsync(); + } + +private: + void submit() + { + const String path = canonicalizeModelPath(pathEditor.getText().trim()); + + // Caught here rather than by opening a tab that could only fail + if (! isSupportedModelPath(path)) + { + errorLabel.setText("No supported provider recognizes this path.", dontSendNotification); + + return; + } + + DBG_AND_LOG("CustomPathComponent::submit: Custom path \"" << path << "\" entered."); + + if (onLoadCallback) + { + onLoadCallback(path); + } + + closePopup(); + } + + void closePopup() + { + if (auto* popup = findParentComponentOfClass<DialogWindow>()) + { + popup->exitModalState(0); + } + } + + static constexpr int popupWidth = 400; + static constexpr int popupHeight = 100; + static constexpr int editorRowHeight = 30; + static constexpr int errorRowHeight = 20; + + TextEditor pathEditor; + Label errorLabel; + TextButton loadButton { "Load" }; + TextButton cancelButton { "Cancel" }; + + std::function<void(String)> onLoadCallback; +}; diff --git a/src/windows/WelcomeWindow.h b/src/windows/WelcomeWindow.h deleted file mode 100644 index 6bca1ee6..00000000 --- a/src/windows/WelcomeWindow.h +++ /dev/null @@ -1,771 +0,0 @@ -/** - * @file WelcomeWindow.h - * @brief Window containing instructions and walkthrough. - * @author saumya-pailwan - */ - -#pragma once - -#include <functional> -#include <vector> - -#include <juce_gui_basics/juce_gui_basics.h> - -#include "../utils/Settings.h" -#include "../utils/Tutorial.h" - -using namespace juce; - -// Forward declaration for TutorialStep -class MainComponent; - -struct TutorialStep -{ - String title; - String description; - std::function<Rectangle<int>(MainComponent*)> getHighlightBounds; - std::function<std::vector<Rectangle<int>>(MainComponent*)> getExtraHighlights = nullptr; -}; - -class WelcomeWindow : public DocumentWindow, public ChangeListener -{ -public: - WelcomeWindow(MainComponent* mainComp) - : DocumentWindow("Welcome to HARP", - Desktop::getInstance().getDefaultLookAndFeel().findColour( - ResizableWindow::backgroundColourId), - DocumentWindow::closeButton), - mainComponent(mainComp) - { - setUsingNativeTitleBar(true); - WelcomeContent* c = new WelcomeContent(*this); - setContentOwned(c, true); - content = c; - - setAlwaysOnTop(true); - setResizable(false, false); - - setConstrainer(&constrainer); - constrainer.setMinimumSize(content->getWidth(), content->getHeight()); - constrainer.setMaximumSize(content->getWidth(), content->getHeight()); - constrainer.setMinimumOnscreenAmounts(40, 40, 40, 40); - - positionOnMainComponentDisplay(); - - if (mainComponent) - { - mainComponent->getModelTab()->addChangeListener(this); - mainComponent->setTutorialActive(true); - } - - rebuildSteps(); - updateStep(); - } - - ~WelcomeWindow() override - { - if (mainComponent) - { - mainComponent->getModelTab()->removeChangeListener(this); - mainComponent->setTutorialActive(false); - } - } - - void changeListenerCallback(ChangeBroadcaster*) override - { - // Model loaded/changed - if (pendingTutorialFallbackLoad) - { - if (mainComponent != nullptr) - { - auto model = mainComponent->getModelTab()->getModel(); - auto loadedPath = model ? model->getLoadedPath() : String(); - - autoLoadedByTutorialFallback = (loadedPath == TutorialConstants::fallbackModelPath); - } - pendingTutorialFallbackLoad = false; - } - else if (autoLoadedByTutorialFallback && mainComponent != nullptr) - { - auto model = mainComponent->getModelTab()->getModel(); - auto loadedPath = model ? model->getLoadedPath() : String(); - if (loadedPath != TutorialConstants::fallbackModelPath) - autoLoadedByTutorialFallback = false; - } - - rebuildSteps(); - - if (content->currentStep > 0) - { - // Auto-jump to Step 3 ("Quick Start") if user loads a new model - // (Index 2 corresponds to "Quick Start") - if (steps.size() > 2) - { - content->currentStep = 2; - } - } - - updateStep(); - } - - void refreshHighlightForCurrentStep() { updateStep(); } - - void rebuildSteps() - { - steps.clear(); - - // 1. Static Intro - steps.push_back( - { "Welcome to HARP", - "HARP is your hub for hosted, asynchronous, remote audio processing.\n\n" - "HARP operates as a standalone app or plugin-like editor within your DAW, allowing you to process tracks using ML models hosted on platforms like Hugging Face or Stability AI.\n\n" - "This quick tutorial will guide you through the main features of the application. Click 'Learn more' for additional online documentation.", - [](MainComponent*) { return Rectangle<int>(); } }); - - // 2. Select Model - steps.push_back( - { "Select a Model", - "Select a model from the dropdown menu at the top and click Load.\n\n" - "Once loaded, the model's details and controls will appear below.\n\n" - "If you click Next without loading a model, HARP loads Demucs for guidance.", - [](MainComponent* c) { return c->getModelSelectBounds(); } }); - - String modelName = "current model"; - - if (mainComponent) - { - auto model = mainComponent->getModelTab()->getModel(); - if (model && model->isLoaded()) - { - modelName = model->getMetadata().name; - } - } - - // 3. Quick Start (Dynamic) - String stepTitle = "Quick Start"; - - steps.push_back( - { stepTitle, - "This is the " + modelName + ".\n" + getFriendlyModelSummary(modelName) - + "\n\n" - "1. Add input\n" - "Drag an audio file into the Input track, use the folder icon to browse, or the play button to preview.\n\n" - "2. Process\n" - "Click Process to run the model.\n\n" - "3. Output\n" - "Processed tracks appear in the Output section, where you can play or save them.", - [](MainComponent* c) - { - auto bounds = c->getLocalBounds(); - auto clipboard = c->getClipboardBounds(); - if (! clipboard.isEmpty()) - { - // Exclude clipboard (assume it's on the right) - bounds.setRight(clipboard.getX()); - } - return bounds; - }, - // Highlight entire interface except clipboard - [](MainComponent* c) - { - std::vector<Rectangle<int>> v; - v.push_back(c->getInputFolderBounds()); - v.push_back(c->getInputPlayBounds()); - v.push_back(c->getProcessButtonBounds()); - return v; - } }); - - // 4. Configure Parameters (Dynamic) - if (mainComponent) - { - auto model = mainComponent->getModelTab()->getModel(); - if (model && model->isLoaded()) - { - String controlsStepTitle = "Configure Parameters (Optional)"; - String baseText = - "Some models will have controls to customize model behavior. Typical controls include ones that balance quality vs speed, allow selection of model variants, or provide advanced options for experienced users.\n\n" - "To see descriptions of what controls do for a model Click on the button below. Do this now to see the control descriptions for " - + modelName + ".\n\n"; - - String fullText = baseText; - - if (content->showingDetails) - { - fullText += - "--------------------------------------------------\nDetailed Control Descriptions:\n\n"; - auto controls = model->getControls(); - if (controls.empty()) - { - fullText += "- No adjustable controls for this model."; - } - else - { - int index = 1; - String modelNameForCtrl = model->getMetadata().name; - - // First pass: All except Model - for (const auto& info : controls) - { - if (String(info->label).equalsIgnoreCase("Model")) - continue; - - String friendlyCtrlDesc = getFriendlyControlDescription( - modelNameForCtrl, info->label, info->info); - - fullText += String(index++) + ". " + info->label + ": " - + friendlyCtrlDesc + "\n\n"; - } - - // Second pass: Only Model - for (const auto& info : controls) - { - if (! String(info->label).equalsIgnoreCase("Model")) - continue; - - String friendlyCtrlDesc = getFriendlyControlDescription( - modelNameForCtrl, info->label, info->info); - - fullText += String(index++) + ". " + info->label + ": " - + friendlyCtrlDesc + "\n\n"; - } - fullText += - "Tip: We suggest sticking with the default parameters for the best balance of processing time vs. quality."; - } - } - - steps.push_back({ controlsStepTitle, - fullText, - [](MainComponent* c) { return c->getControlsBounds(); } }); - } - } - - // 5. Manage Tracks - steps.push_back( - { "Manage Tracks", - "The highlighted panel contains your input and output tracks.\n\n" - "You can drag and drop audio/MIDI files here, or click on the folder icon in the input audio section to choose from a local folder.\n\n" - "Processed results appear in the output tracks section.\n\n" - "Please note that these tracks interact with the model.", - [](MainComponent* c) { return c->getTracksBounds(); }, - nullptr }); - - steps.push_back( - { "Track Status", - "The bottom bar shows the connection and processing status.\n\n" - "LOADED: The model is ready to use.\n\n" - "PROCESSING: The model is busy working. Please wait.\n\n" - "ERROR: Something went wrong. Check the description below it and try reloading the model.", - [](MainComponent* c) { return c->getInfoBarBounds(); }, - nullptr }); - - steps.push_back( - { "Media Clipboard", - "This clipboard is a scratch space and does not feed model inputs directly.\n\n" - "You can add tracks with the folder icon in the clipboard toolbar, rename a selected track in the text box above the list, remove, play, or save a selected entry, and send selected tracks to your DAW with the send icon.\n\n" - "Use it to stash useful outputs and reuse them across model runs.", - [](MainComponent* c) { return c->getClipboardBounds(); }, - [](MainComponent* c) - { - std::vector<Rectangle<int>> v; - v.push_back(c->getClipboardControlsBounds()); - return v; - } }); - - // 8. Interface Summary - steps.push_back( - { "Interface Summary", - "Top Bar: Select a model from the dropdown and click Load to initialize it.\n\n" - "Left Panel: This is where your Input and Output tracks live. Models read from and write to these tracks.\n\n" - "Right Panel: A scratch pad (Clipboard) to stash tracks you want to save or reuse later.", - [](MainComponent*) { return Rectangle<int>(); }, - [](MainComponent* c) - { - std::vector<Rectangle<int>> v; - v.push_back(c->getModelSelectBounds()); - v.push_back(c->getInputTrackBounds()); - v.push_back(c->getClipboardBounds()); - return v; - } }); - - steps.push_back( - { "All Set!", - "Most models in HARP are hosted on external service like Hugging Face and Stability AI. To use them, you will need API tokens.\n\n" - "An API token is a private access key that lets HARP securely connect to these services on your behalf. It tells the service that you are allowed to use the model.\n\n" - "You can get these tokens from your account settings on the respective service websites. You can find the exact link in 'File -> Settings' and select the tab of the service whose tokens you need to use.\n\n" - "You are now ready to start creating!", - [](MainComponent*) { return Rectangle<int>(); } }); - - // Refresh if we are showing the tutorial - if (isVisible()) - { - if (content->currentStep >= (int) steps.size()) - content->currentStep = (int) steps.size() - 1; - updateStep(); - } - } - - String getFriendlyModelSummary(const String& name) - { - if (name.containsIgnoreCase("demucs")) - { - return "It separates music audio into drums, bass, vocals, and 'other' stems."; - } - if (name.containsIgnoreCase("megatts") || name.containsIgnoreCase("voice")) - { - return "It generates realistic speech or clones voices from reference audio."; - } - if (name.containsIgnoreCase("musicgen") || name.containsIgnoreCase("audioldm")) - { - return "It generates new music or audio based on text descriptions."; - } - if (name.containsIgnoreCase("stability") || name.containsIgnoreCase("stable audio")) - { - return "It generates or transforms audio using state-of-the-art diffusion models."; - } - - return "It is an advanced audio processing model hosted in the cloud."; - } - - String getFriendlyModelOverview(const String& name) - { - // DEMUCS (Default or Selected) - if (name.containsIgnoreCase("demucs")) - { - return "About:\n" - "The Demucs model is a state-of-the-art music source separation model. " - "It separates a stereo specific mixture into four distinct tracks: Drums, Bass, Vocals, and Other instruments.\n\n" - "Inputs:\n" - "A generic audio file (mp3, wav, etc.) containing a song or music mix.\n\n" - "Outputs:\n" - "Four separate audio tracks corresponding to the separated stems."; - } - - // MEGATTS / Voice - if (name.containsIgnoreCase("megatts") || name.containsIgnoreCase("voice")) - { - return "About:\n" - "This model generates realistic speech or clones voices from reference audio.\n\n" - "Inputs:\n" - "Reference audio of the voice to clone and the text you want it to speak.\n\n" - "Outputs:\n" - "A generated audio clip of the spoken text."; - } - - // MUSICGEN / Audio Generation - if (name.containsIgnoreCase("musicgen") || name.containsIgnoreCase("audioldm")) - { - return "About:\n" - "Generates new music or audio based on text descriptions.\n\n" - "Inputs:\n" - "A text prompt describing the music (e.g., 'lo-fi beat with piano').\n\n" - "Outputs:\n" - "A generated audio track matching the description."; - } - - // Fallback - return "About:\n" - "This is an advanced audio processing model hosted in the cloud.\n\n" - "Inputs:\n" - "Refer to the specific model documentation or parameters for input requirements.\n\n" - "Outputs:\n" - "Processed audio or MIDI results."; - } - - String getFriendlyModelDescription(const String& /*name*/, const String& rawDesc) - { - // Unused but kept for API consistency if needed later - return rawDesc; - } - - String getFriendlyControlDescription(const String& modelName, - const String& label, - const String& rawInfo) - { - // DEMUCS - if (modelName.containsIgnoreCase("demucs")) - { - if (label.equalsIgnoreCase("shifts")) - { - // Roman numerals for sub-points - return "Determines how many times the audio is shifted and processed.\n" - " i. Increasing this value can improve quality but will take longer to process.\n" - " ii. A value of 0 or 1 is usually sufficient for good results."; - } - if (label.equalsIgnoreCase("overlap")) - { - return "Controls the smoothness between processed audio chunks. The default value is optimized for a seamless sound."; - } - } - - // STABILITY AI (Text-to-Audio / Audio-to-Audio) - if (modelName.containsIgnoreCase("stability") - || modelName.containsIgnoreCase("text-to-audio") - || modelName.containsIgnoreCase("audio-to-audio") - || modelName.containsIgnoreCase("stable audio")) - { - if (label.containsIgnoreCase("Duration")) - return "Sets the length of the generated audio in seconds."; - - if (label.containsIgnoreCase("steps")) - return "Number of diffusion steps. Higher values improve quality but take longer to process."; - - if (label.containsIgnoreCase("cfg")) - return "Classifier Free Guidance. Higher values make the output adhere more strictly to the prompt."; - - if (label.containsIgnoreCase("Output Format")) - return "The file format for the generated audio (e.g. wav)."; - - if (label.containsIgnoreCase("Text Prompt")) - return "Description of the audio content you want to generate."; - } - - // MEGATTS 3 - if (modelName.containsIgnoreCase("megatts")) - { - if (label.containsIgnoreCase("timesteps")) - { - return "Controls the quality of the voice generation.\n" - " i. Higher values (e.g. 50+) result in clearer, higher-quality audio but take longer to generate."; - } - if (label.containsIgnoreCase("intelligibility")) - { - return "Ensures the generated speech is clear and easy to understand.\n" - " i. Increase this value if the output sounds mumbled or unclear."; - } - if (label.containsIgnoreCase("similarity")) - { - return "Controls how closely the cloned voice mimics the reference audio.\n" - " i. Increase this for a stronger likeness to the original voice."; - } - if (label.containsIgnoreCase("text")) - { - return "Enter the text you want the cloned voice to speak."; - } - } - - // TRIA - if (modelName.containsIgnoreCase("tria")) - { - if (label.equalsIgnoreCase("Model")) - { - return "Choose which variant to you want to use. Currently this is the only one available, however more models would be available soon."; - } - } - - // GENERIC FALLBACK - if (rawInfo.isNotEmpty()) - return rawInfo; - - return "Controls this specific parameter of the model."; - } - - void closeButtonPressed() override - { - // Reset state before closing - if (mainComponent) - { - mainComponent->setTutorialHighlight({}); - mainComponent->setTutorialActive(false); - } - - if (onClose) - onClose(); - } - - std::function<void()> onClose; - - void paint(Graphics& g) override - { - g.fillAll(getLookAndFeel().findColour(ResizableWindow::backgroundColourId)); - } - - void positionOnMainComponentDisplay() - { - const int width = content != nullptr ? content->getWidth() : getWidth(); - const int height = content != nullptr ? content->getHeight() : getHeight(); - setSize(width, height); - - Rectangle<int> targetBounds; - - if (mainComponent != nullptr) - { - if (auto* topLevel = mainComponent->getTopLevelComponent()) - targetBounds = topLevel->getScreenBounds(); - } - - if (targetBounds.isEmpty()) - { - centreWithSize(width, height); - return; - } - - setTopLeftPosition(targetBounds.getCentreX() - width / 2, - targetBounds.getCentreY() - height / 2); - } - -private: - class WelcomeContent : public Component - { - public: - explicit WelcomeContent(WelcomeWindow& ownerRef) : owner(ownerRef) - { - // UI Init - addAndMakeVisible(titleLabel); - titleLabel.setFont(Font(24.0f, Font::bold)); - titleLabel.setJustificationType(Justification::centred); - - addAndMakeVisible(descriptionEditor); - descriptionEditor.setMultiLine(true); - descriptionEditor.setReadOnly(true); - descriptionEditor.setScrollbarsShown(true); - descriptionEditor.setCaretVisible(false); - // Make it look like a label (transparent) - descriptionEditor.setColour(TextEditor::backgroundColourId, Colours::transparentBlack); - descriptionEditor.setColour(TextEditor::outlineColourId, Colours::transparentBlack); - descriptionEditor.setFont(Font(16.0f)); - - addAndMakeVisible(learnMoreLink); - learnMoreLink.setButtonText("Learn more"); - learnMoreLink.setURL(URL("https://harp3.netlify.app/content/intro.html")); - - addAndMakeVisible(copyrightLabel); - copyrightLabel.setText("Copyright 2026 TEAMuP. All rights reserved.", - dontSendNotification); - copyrightLabel.setJustificationType(Justification::centred); - copyrightLabel.setFont(Font(12.0f)); - copyrightLabel.setColour(Label::textColourId, Colours::grey); - - addAndMakeVisible(nextButton); - nextButton.onClick = [this] { owner.nextStep(); }; - - addAndMakeVisible(prevButton); - prevButton.onClick = [this] { owner.prevStep(); }; - - addAndMakeVisible(skipButton); - skipButton.onClick = [this] { owner.skipTutorial(); }; - - addAndMakeVisible(dontShowAgainToggle); - bool notToggleState = Settings::getBoolValue("view.showWelcomePopup", true); - dontShowAgainToggle.setToggleState(! notToggleState, dontSendNotification); - - addAndMakeVisible(pageIndicator); - pageIndicator.setJustificationType(Justification::centredLeft); - pageIndicator.setFont(Font(12.0f)); - pageIndicator.setInterceptsMouseClicks(false, false); - - addAndMakeVisible(showDetailsButton); - showDetailsButton.onClick = [this] - { - showingDetails = ! showingDetails; - showDetailsButton.setButtonText(showingDetails - ? "Hide detailed control descriptions" - : "Show detailed control descriptions"); - owner.rebuildSteps(); - owner.updateStep(); - }; - - setSize(500, 420); - } - - void resized() override - { - auto area = getLocalBounds().reduced(20); - - titleLabel.setBounds(area.removeFromTop(40)); - area.removeFromTop(10); - - auto footerArea = area.removeFromBottom(72); - auto footer = footerArea.removeFromTop(30); - auto toggleRow = footerArea.removeFromBottom(30); - - const int buttonHeight = 30; - const int buttonPaddingX = 28; - const int buttonWidth = - jmax(skipButton.getBestWidthForHeight(buttonHeight) + buttonPaddingX, - nextButton.getBestWidthForHeight(buttonHeight) + buttonPaddingX); - skipButton.setBounds(footer.removeFromLeft(buttonWidth)); - nextButton.setBounds(footer.removeFromRight(buttonWidth)); - footer.removeFromRight(10); - prevButton.setBounds(footer.removeFromRight(buttonWidth)); - - const int middleRightX = - prevButton.isVisible() ? (prevButton.getX() - 10) : nextButton.getX(); - const int middleX = skipButton.getRight() + 12; - const int middleW = jmax(0, middleRightX - middleX); - copyrightLabel.setBounds(middleX, footer.getY(), middleW, footer.getHeight()); - - constexpr int toggleHeight = 26; - const int toggleTextWidth = - Font(16.0f).getStringWidth(dontShowAgainToggle.getButtonText()); - const int toggleWidth = 24 + toggleTextWidth + 16; - const int toggleY = toggleRow.getY() + 12; - const int toggleX = toggleRow.getRight() - toggleWidth; - dontShowAgainToggle.setBounds(toggleX, toggleY, toggleWidth, toggleHeight); - - const int stepWidth = - jmax(skipButton.getWidth(), - pageIndicator.getFont().getStringWidth(pageIndicator.getText()) + 16); - pageIndicator.setBounds(toggleRow.getX(), toggleY, stepWidth, 26); - - area.removeFromBottom(8); - - if (currentStep == 0) - { - auto learnMoreArea = area.removeFromBottom(36); - learnMoreLink.setBounds(learnMoreArea.withSizeKeepingCentre(180, 30)); - area.removeFromBottom(4); - descriptionEditor.setBounds(area); - } - else - { - if (showDetailsButton.isVisible()) - { - auto btnArea = area.removeFromBottom(30); - showDetailsButton.setBounds(btnArea.reduced(20, 0)); - area.removeFromBottom(8); - } - - descriptionEditor.setBounds(area); - learnMoreLink.setVisible(false); - } - } - - Label titleLabel; - TextEditor descriptionEditor; - - TextButton nextButton { "Next" }; - TextButton prevButton { "Back" }; - TextButton skipButton { "Skip Tutorial" }; - ToggleButton dontShowAgainToggle { "Don't show again" }; - - Label pageIndicator; - HyperlinkButton learnMoreLink { "Learn more", - URL("https://harp3.netlify.app/content/intro.html") }; - Label copyrightLabel; - - TextButton showDetailsButton { "Show detailed control descriptions" }; - - int currentStep = 0; - bool showingDetails = false; - - private: - WelcomeWindow& owner; - }; - - void updateStep() - { - if (steps.empty()) - return; - - const auto& step = steps[size_t(content->currentStep)]; - - content->titleLabel.setText(step.title, dontSendNotification); - content->descriptionEditor.setText(step.description); - // Scroll to top when changing steps - content->descriptionEditor.setCaretPosition(0); - - // Highlight logic - { - auto bounds = step.getHighlightBounds(mainComponent); - mainComponent->setTutorialHighlight(bounds); - - if (step.getExtraHighlights) - { - mainComponent->setTutorialExtraHighlights(step.getExtraHighlights(mainComponent)); - } - else - { - mainComponent->setTutorialExtraHighlights({}); - } - } - - // Buttons - content->prevButton.setVisible(content->currentStep > 0); - - bool isLast = content->currentStep == (int) steps.size() - 1; - bool isFirst = content->currentStep == 0; - // bool isSecond = content->currentStep == 1; // "Select a Model" - - content->nextButton.setButtonText(isLast ? "Finish" : "Next"); - content->skipButton.setVisible(true); - content->dontShowAgainToggle.setVisible(true); - - // Items specific to first page - content->learnMoreLink.setVisible(isFirst); - content->copyrightLabel.setVisible(isFirst); - - // Item specific to second page (Step 2) - // hostingEditor removed - // pyHarpLink removed - - content->pageIndicator.setText("Step " + String(content->currentStep + 1) + " of " - + String(steps.size()), - dontSendNotification); - - // Show details button only on Configure Parameters step - bool isConfigParams = step.title.contains("Configure Parameters"); - content->showDetailsButton.setVisible(isConfigParams); - - // Trigger layout update - content->resized(); - } - - void nextStep() - { - if (content->currentStep == 1 && mainComponent != nullptr) - { - auto model = mainComponent->getModelTab()->getModel(); - if (! model || ! model->isLoaded()) - { - pendingTutorialFallbackLoad = true; - mainComponent->ensureTutorialModelLoaded(); - return; - } - } - - if (content->currentStep < (int) steps.size() - 1) - { - content->currentStep++; - updateStep(); - } - else - { - finishTutorial(); - } - } - - void prevStep() - { - if (content->currentStep > 0) - { - content->currentStep--; - updateStep(); - } - } - - void skipTutorial() { finishTutorial(); } - - void finishTutorial() - { - String showWelcomePopupUponNextStartup = - ! content->dontShowAgainToggle.getToggleState() ? "1" : "0"; - Settings::setValue("view.showWelcomePopup", showWelcomePopupUponNextStartup, true); - - if (autoLoadedByTutorialFallback && mainComponent != nullptr) - mainComponent->resetTutorialAutoLoadedModel(); - - closeButtonPressed(); - } - - bool pendingTutorialFallbackLoad = false; - bool autoLoadedByTutorialFallback = false; - - std::vector<TutorialStep> steps; - - MainComponent* mainComponent; - WelcomeContent* content = nullptr; - - ComponentBoundsConstrainer constrainer; - - JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(WelcomeWindow) -}; diff --git a/src/windows/settings/AudioSettingsTab.cpp b/src/windows/settings/AudioSettingsTab.cpp deleted file mode 100644 index bd25afcb..00000000 --- a/src/windows/settings/AudioSettingsTab.cpp +++ /dev/null @@ -1,30 +0,0 @@ -#include "AudioSettingsTab.h" - -AudioSettingsTab::AudioSettingsTab() -{ - deviceInfoLabel.setText("Audio Settings", dontSendNotification); - deviceInfoLabel.setJustificationType(Justification::centredLeft); - addAndMakeVisible(deviceInfoLabel); - - openAudioSettingsButton.setButtonText("Configure Audio Device"); - openAudioSettingsButton.onClick = [this] { handleOpenAudioSettings(); }; - addAndMakeVisible(openAudioSettingsButton); -} - -void AudioSettingsTab::resized() -{ - Rectangle<int> area = getLocalBounds().reduced(10); - - deviceInfoLabel.setBounds(area.removeFromTop(30)); - - area.removeFromTop(10); // Filler space - - openAudioSettingsButton.setBounds(area.removeFromTop(30)); -} - -void AudioSettingsTab::handleOpenAudioSettings() -{ - // Open JUCE's built-in audio device selector (placeholder) - AlertWindow::showMessageBoxAsync( - AlertWindow::InfoIcon, "Audio Settings", "Audio settings configuration will go here."); -} diff --git a/src/windows/settings/AudioSettingsTab.h b/src/windows/settings/AudioSettingsTab.h deleted file mode 100644 index 4d91b28d..00000000 --- a/src/windows/settings/AudioSettingsTab.h +++ /dev/null @@ -1,28 +0,0 @@ -/** - * @file AudioSettingsTab.h - * @brief Placeholder tab for audio settings. - * @author lindseydeng - */ - -#pragma once - -#include <JuceHeader.h> - -using namespace juce; - -class AudioSettingsTab : public Component -{ -public: - AudioSettingsTab(); - ~AudioSettingsTab() override = default; - - void resized() override; - -private: - void handleOpenAudioSettings(); - - Label deviceInfoLabel; - TextButton openAudioSettingsButton; - - JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(AudioSettingsTab) -}; diff --git a/src/windows/settings/GeneralSettingsTab.cpp b/src/windows/settings/GeneralSettingsTab.cpp index ff7c2c05..b1884e8e 100644 --- a/src/windows/settings/GeneralSettingsTab.cpp +++ b/src/windows/settings/GeneralSettingsTab.cpp @@ -1,6 +1,9 @@ #include "GeneralSettingsTab.h" -GeneralSettingsTab::GeneralSettingsTab() +#include "../../utils/ModelCatalog.h" + +GeneralSettingsTab::GeneralSettingsTab(std::function<void()> onRestore) + : onRestoreDefaults(std::move(onRestore)) { // Set up button to open log folder openLogFolderButton.setButtonText("Open Log Folder"); @@ -11,6 +14,16 @@ GeneralSettingsTab::GeneralSettingsTab() openSettingsButton.setButtonText("Open Settings File"); openSettingsButton.onClick = [this] { handleOpenSettings(); }; addAndMakeVisible(openSettingsButton); + + // Set up button to clear logs + clearLogsButton.setButtonText("Clear Logs"); + clearLogsButton.onClick = [this] { handleClearLogs(); }; + addAndMakeVisible(clearLogsButton); + + // Set up button to restore default settings + restoreDefaultsButton.setButtonText("Restore Default Settings"); + restoreDefaultsButton.onClick = [this] { handleRestoreDefaults(); }; + addAndMakeVisible(restoreDefaultsButton); } void GeneralSettingsTab::resized() @@ -18,10 +31,15 @@ void GeneralSettingsTab::resized() Rectangle<int> area = getLocalBounds().reduced(10); openLogFolderButton.setBounds(area.removeFromTop(30)); + area.removeFromTop(10); - area.removeFromTop(10); // Filler space + clearLogsButton.setBounds(area.removeFromTop(30)); + area.removeFromTop(10); openSettingsButton.setBounds(area.removeFromTop(30)); + area.removeFromTop(10); + + restoreDefaultsButton.setBounds(area.removeFromTop(30)); } void GeneralSettingsTab::handleOpenLogFolder() @@ -35,8 +53,69 @@ void GeneralSettingsTab::handleOpenSettings() { settings->getFile().startAsProcess(); } - else +} + +void GeneralSettingsTab::handleClearLogs() +{ + OpResult result = HARPLogger::getInstance()->clearLog(); + + if (result.failed()) + { + AlertWindow::showMessageBoxAsync( + AlertWindow::WarningIcon, "Clear Logs Failed", toUserMessage(result.getError()), "Ok"); + + return; + } + + AlertWindow::showMessageBoxAsync( + AlertWindow::InfoIcon, + "Logs Cleared", + "All logs (main.log and launch.log) have been successfully cleared.", + "Ok"); +} + +void GeneralSettingsTab::handleRestoreDefaults() +{ + bool saved = false; + + if (auto* settings = Settings::getUserSettings()) + { + /* The API keys and the custom models are the user's own data rather than + preferences, so they are kept. The catalog also holds the custom models in + memory, and would write them back the next time one is added or removed. */ + const String apiKeyPrefix = String(SharedAPIKeys::settingsPrefix) + "."; + + /* Copied first, since getAllKeys() is the live list. Removing from it while + iterating would shift each next key into the slot just visited, which skips + every other key and leaves those settings in place. */ + const StringArray keys = settings->getAllProperties().getAllKeys(); + + for (const auto& key : keys) + { + if (! key.startsWith(apiKeyPrefix) && key != ModelCatalog::customPathsKey) + settings->removeValue(key); + } + + saved = settings->saveIfNeeded(); + } + + if (onRestoreDefaults) + onRestoreDefaults(); + + if (! saved) { - // TODO - handler error case + AlertWindow::showMessageBoxAsync(AlertWindow::WarningIcon, + "Restore Defaults Failed", + "The settings file could not be written.", + "Ok"); + + return; } + + AlertWindow::showMessageBoxAsync( + AlertWindow::InfoIcon, + "Settings Restored", + "All settings have been restored to their defaults. API keys and custom models are " + "kept.", + "Ok"); } diff --git a/src/windows/settings/GeneralSettingsTab.h b/src/windows/settings/GeneralSettingsTab.h index 2462303b..61e6c244 100644 --- a/src/windows/settings/GeneralSettingsTab.h +++ b/src/windows/settings/GeneralSettingsTab.h @@ -1,13 +1,14 @@ /** * @file GeneralSettingsTab.h - * @brief Placeholder tab for general settings. - * @author lindseydeng + * @brief Tab with general settings and maintenance actions. + * @author saumya-pailwan, cwitkowitz, lindseydeng, huiranyu */ #pragma once #include <JuceHeader.h> +#include "../../clients/Client.h" #include "../../utils/Logging.h" #include "../../utils/Settings.h" @@ -16,7 +17,7 @@ using namespace juce; class GeneralSettingsTab : public Component { public: - GeneralSettingsTab(); + explicit GeneralSettingsTab(std::function<void()> onRestore = {}); ~GeneralSettingsTab() override = default; void resized() override; @@ -24,9 +25,15 @@ class GeneralSettingsTab : public Component private: void handleOpenLogFolder(); void handleOpenSettings(); + void handleClearLogs(); + void handleRestoreDefaults(); + + std::function<void()> onRestoreDefaults; TextButton openLogFolderButton; + TextButton clearLogsButton; TextButton openSettingsButton; + TextButton restoreDefaultsButton; JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(GeneralSettingsTab) }; diff --git a/src/windows/settings/LoginTab.cpp b/src/windows/settings/LoginTab.cpp index d764067e..8d8f439f 100644 --- a/src/windows/settings/LoginTab.cpp +++ b/src/windows/settings/LoginTab.cpp @@ -1,13 +1,12 @@ #include "LoginTab.h" -ProviderPage::ProviderPage(Provider p, String n) : provider(p), displayName(n) +ProviderPage::ProviderPage(Provider p) : provider(p) { - // Load appropriate client - client = multiplexClients(provider); + client = createClient(provider); + client->setRequestRegistry(&requestRegistry); - String titleText = "Update or remove your API key for " + displayName + "."; - - titleLabel.setText(titleText, dontSendNotification); + titleLabel.setText("Update or remove your API key for " + getDisplayName() + ".", + dontSendNotification); titleLabel.setJustificationType(Justification::centred); getTokenLink.setButtonText("Click here to register a new API key."); @@ -17,60 +16,46 @@ ProviderPage::ProviderPage(Provider p, String n) : provider(p), displayName(n) addAndMakeVisible(titleLabel); addAndMakeVisible(getTokenLink); - String emptyText = "Enter API key here"; - - tokenEditor.setTextToShowWhenEmpty(emptyText, Colours::grey); + tokenEditor.setTextToShowWhenEmpty("Enter API key here", Colours::grey); tokenEditor.setReturnKeyStartsNewLine(false); tokenEditor.setSelectAllWhenFocused(true); - tokenEditor.onTextChange = [this]() - { - String currentText = tokenEditor.getText().trim(); - if (currentText.isNotEmpty()) - { - if (sharedTokens->savedTokens.contains(provider)) - { - String savedToken = sharedTokens->savedTokens[provider]; - - if (currentText == savedToken) - { - updateButton.setEnabled(false); - removeButton.setEnabled(true); - } - else - { - updateButton.setEnabled(true); - removeButton.setEnabled(false); - } - } - else - { - updateButton.setEnabled(true); - removeButton.setEnabled(false); - } - } - else - { - updateButton.setEnabled(false); - removeButton.setEnabled(false); - } - }; + // Keep the key obscured by default so it is not revealed by a screen share + tokenEditor.setPasswordCharacter(passwordCharacter); + tokenEditor.onTextChange = [this] { updateButtons(); }; tokenEditor.onReturnKey = [this] { updateButton.triggerClick(); }; addAndMakeVisible(tokenEditor); + revealTokenButton.onClick = [this] + { + tokenEditor.setPasswordCharacter(revealTokenButton.getToggleState() ? 0 + : passwordCharacter); + }; + + addAndMakeVisible(revealTokenButton); + updateButton.addShortcut(KeyPress(KeyPress::returnKey)); updateButton.onClick = [this] { updateTokenCallback(); }; removeButton.onClick = [this] { removeTokenCallback(); }; - resetState(); - addAndMakeVisible(updateButton); addAndMakeVisible(removeButton); statusLabel.setJustificationType(Justification::centred); addAndMakeVisible(statusLabel); + + updateButtons(); +} + +ProviderPage::~ProviderPage() +{ + /* A check in flight would leave its worker blocked in a network call, and destroying + the pool under it would force-kill the thread. Abort the request so the worker + returns at once, then wait for it. */ + requestRegistry.abortActiveRequests(); + checkPool.removeAllJobs(true, 5000); } void ProviderPage::resized() @@ -81,6 +66,7 @@ void ProviderPage::resized() pageArea.items.add(FlexItem(titleLabel).withHeight(rowHeight).withMargin(marginSize)); pageArea.items.add(FlexItem().withHeight(gapSize)); // Filler space pageArea.items.add(FlexItem(tokenEditor).withHeight(rowHeight).withMargin(marginSize)); + pageArea.items.add(FlexItem(revealTokenButton).withHeight(rowHeight).withMargin(marginSize)); FlexBox buttonsArea; buttonsArea.flexDirection = FlexBox::Direction::row; @@ -105,75 +91,126 @@ void ProviderPage::resized() void ProviderPage::resetState() { - if (sharedTokens->savedTokens.contains(provider)) - { - String savedToken = sharedTokens->savedTokens[provider]; + const String savedKey = sharedTokens->getKey(provider); - OpResult result = client->validateToken(savedToken); + tokenEditor.setText(savedKey, dontSendNotification); + updateButtons(); - if (result.failed()) - { - // TODO - could be due to connection failure or invalid response as well - statusLabel.setText(std::move(tokenInvalidMessage), dontSendNotification); - } - else - { - statusLabel.setText(std::move(tokenLoadedMessage), dontSendNotification); - } + if (savedKey.isEmpty()) + { + // Supersedes any check still in flight + ++latestCheckID; - tokenEditor.setText(savedToken); - removeButton.setEnabled(true); + statusLabel.setText("No key has been added for this provider.", dontSendNotification); } else { - statusLabel.setText(std::move(tokenMissingMessage), dontSendNotification); - updateButton.setEnabled(false); - removeButton.setEnabled(false); + checkKey(savedKey, false); } } +void ProviderPage::updateButtons() +{ + const String enteredKey = tokenEditor.getText().trim(); + const String savedKey = sharedTokens->getKey(provider); + + // A new key can be saved, and the saved one removed while it is the one shown + updateButton.setEnabled(enteredKey.isNotEmpty() && enteredKey != savedKey); + removeButton.setEnabled(savedKey.isNotEmpty() && enteredKey == savedKey); +} + void ProviderPage::updateTokenCallback() { - String enteredToken = tokenEditor.getText().trim(); + const String enteredKey = tokenEditor.getText().trim(); - if (enteredToken.isNotEmpty()) + if (enteredKey.isNotEmpty()) { - OpResult result = client->validateToken(enteredToken); + updateButton.setEnabled(false); - if (result.failed()) - { - // TODO - could be due to connection failure or invalid response as well - statusLabel.setText(std::move(tokenInvalidMessage), dontSendNotification); - } - else + checkKey(enteredKey, true); + } +} + +void ProviderPage::removeTokenCallback() +{ + sharedTokens->removeKey(provider); + + tokenEditor.clear(); + + // Supersedes any check still in flight + ++latestCheckID; + + statusLabel.setText("No key has been added for this provider.", dontSendNotification); + + updateButtons(); +} + +void ProviderPage::checkKey(const String& key, bool saveIfValid) +{ + const int checkID = ++latestCheckID; + + statusLabel.setText("Checking the key...", dontSendNotification); + + SafePointer<ProviderPage> safeThis(this); + + checkPool.addJob( + [this, safeThis, key, saveIfValid, checkID] { - sharedTokens->updateKey(provider, enteredToken); + OpResult result = client->validateToken(key); - statusLabel.setText(std::move(tokenLoadedMessage), dontSendNotification); - removeButton.setEnabled(true); - } + MessageManager::callAsync( + [safeThis, result, key, saveIfValid, checkID] + { + if (safeThis != nullptr) + safeThis->showCheckResult(result, key, saveIfValid, checkID); + }); + }); +} - updateButton.setEnabled(false); +void ProviderPage::showCheckResult(const OpResult& result, + const String& key, + bool saveIfValid, + int checkID) +{ + if (checkID != latestCheckID) + { + return; + } + + if (result.failed()) + { + statusLabel.setText(describeCheckFailure(result.getError()), dontSendNotification); + } + else if (saveIfValid) + { + sharedTokens->updateKey(provider, key); + + statusLabel.setText("This key is valid, and has been saved.", dontSendNotification); } else { - statusLabel.setText(std::move(tokenMissingMessage), dontSendNotification); + statusLabel.setText("This key is valid.", dontSendNotification); } + + updateButtons(); } -void ProviderPage::removeTokenCallback() +String ProviderPage::describeCheckFailure(const Error& error) const { - if (sharedTokens->savedTokens.contains(provider)) + // An unreachable provider says nothing about the key itself + if (const auto* e = std::get_if<HttpError>(&error); + e != nullptr && e->type == HttpError::Type::ConnectionFailed) { - sharedTokens->removeKey(provider); - - tokenEditor.clear(); - - statusLabel.setText(std::move(tokenMissingMessage), dontSendNotification); + return "Could not reach " + getDisplayName() + " to check this key."; + } - updateButton.setEnabled(false); - removeButton.setEnabled(false); + if (const auto* e = std::get_if<ClientError>(&error); + e != nullptr && e->type == ClientError::Type::InsufficientPermissions) + { + return "This key does not have sufficient permissions."; } + + return "This key is invalid."; } void PageSwitcher::resized() @@ -184,28 +221,42 @@ void PageSwitcher::resized() } } -void PageSwitcher::showPage(int idx) +int PageSwitcher::getIndexForProvider(Provider provider) const { for (int i = 0; i < (int) entries.size(); ++i) { - if (i == idx) + if (entries[(size_t) i].page->getProvider() == provider) { - entries[i].page->setVisible(true); - entries[i].page->resetState(); + return i; } - else + } + + return -1; +} + +void PageSwitcher::showPage(int idx) +{ + for (int i = 0; i < getNumPages(); ++i) + { + auto& page = *entries[(size_t) i].page; + + page.setVisible(i == idx); + + if (i == idx) { - entries[i].page->setVisible(false); + page.resetState(); } } } void PageSwitcher::addPage(std::unique_ptr<ProviderPage> page) { - addAndMakeVisible(page.get()); - page->setVisible(entries.empty()); + // Shown, and so checked, once its row is selected + addChildComponent(page.get()); - entries.push_back({ std::move(page->getDisplayName()), std::move(page) }); + const String displayName = page->getDisplayName(); + + entries.push_back({ displayName, std::move(page) }); } LoginTab::LoginTab() @@ -215,14 +266,39 @@ LoginTab::LoginTab() sidebar.setOutlineThickness(0); addAndMakeVisible(sidebar); - loginPages.addPage(std::make_unique<ProviderPage>(Provider::HuggingFace, "Hugging Face")); - loginPages.addPage(std::make_unique<ProviderPage>(Provider::Stability, "Stability AI")); + loginPages.addPage(std::make_unique<ProviderPage>(Provider::HuggingFace)); + loginPages.addPage(std::make_unique<ProviderPage>(Provider::Stability)); addAndMakeVisible(loginPages); sidebar.updateContent(); sidebar.selectRow(0); } +void LoginTab::showProvider(Provider provider) +{ + const int idx = loginPages.getIndexForProvider(provider); + + if (idx < 0) + { + DBG_AND_LOG("LoginTab::showProvider: No page serves the requested provider."); + + return; + } + + /* Selecting the row rather than the page directly, so the sidebar highlight and + the visible page cannot disagree. */ + sidebar.selectRow(idx); +} + +void LoginTab::selectedRowsChanged(int row) +{ + // Clicking below the last row deselects every row, which leaves the page showing + if (row >= 0) + { + loginPages.showPage(row); + } +} + void LoginTab::resized() { Rectangle<int> area = getLocalBounds(); diff --git a/src/windows/settings/LoginTab.h b/src/windows/settings/LoginTab.h index 7e2808e2..ae791412 100644 --- a/src/windows/settings/LoginTab.h +++ b/src/windows/settings/LoginTab.h @@ -1,7 +1,7 @@ /** * @file LoginTab.h * @brief Tab with login for each API provider. - * @author huiranyu, cwitkowitz + * @author cwitkowitz, huiranyu */ #pragma once @@ -19,43 +19,65 @@ using namespace juce; class ProviderPage : public Component { public: - ProviderPage(Provider p, String n); + explicit ProviderPage(Provider p); + ~ProviderPage() override; void resized() override; - String getDisplayName() { return displayName; } + String getDisplayName() const { return getProviderName(provider); } + Provider getProvider() const { return provider; } + + // Shows the saved key, and checks it with the provider void resetState(); private: void updateTokenCallback(); void removeTokenCallback(); + // Enables the buttons that apply to the key in the editor + void updateButtons(); + + /* Checks a key with the provider in the background, since that is a network request, + and saves it once it proves valid if saveIfValid is set */ + void checkKey(const String& key, bool saveIfValid); + void showCheckResult(const OpResult& result, const String& key, bool saveIfValid, int checkID); + + String describeCheckFailure(const Error& error) const; + const float marginSize = 2; const float gapSize = 10; const float rowHeight = 26; const float buttonWidth = 120; + // Character used to obscure the API key in the editor + static constexpr juce_wchar passwordCharacter = 0x2022; // Bullet + Provider provider; - String displayName; Label titleLabel; HyperlinkButton getTokenLink; TextEditor tokenEditor; + ToggleButton revealTokenButton { "Show key" }; TextButton updateButton { "Update" }; TextButton removeButton { "Remove" }; - std::unique_ptr<Client> client; - - String tokenMissingMessage = "No token has been added for this provider."; - String tokenInvalidMessage = "This token is invalid."; - String tokenLoadedMessage = "This token has been loaded."; Label statusLabel; + // Identifies the latest check, so that the result of an earlier one is not shown + int latestCheckID = 0; + SharedResourcePointer<SharedAPIKeys> sharedTokens; + // Declared before the client, which holds on to it + RequestRegistry requestRegistry; + std::unique_ptr<Client> client; + + // Declared last so that it is destroyed first, while what its job uses still exists + ThreadPool checkPool { 1 }; + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(ProviderPage) }; @@ -70,10 +92,13 @@ class PageSwitcher : public Component void resized() override; - int getNumPages() { return entries.size(); } + int getNumPages() const { return (int) entries.size(); } const String& getNameForIndex(int idx) const { return entries[(size_t) idx].displayName; } + /** Index of the page for a provider, or -1 when none of them serves it. */ + int getIndexForProvider(Provider provider) const; + void showPage(int idx); void addPage(std::unique_ptr<ProviderPage> page); @@ -88,6 +113,9 @@ class LoginTab : public Component, private ListBoxModel void resized() override; + /** Brings the page for a provider to the front. */ + void showProvider(Provider provider); + private: int getNumRows() override { return loginPages.getNumPages(); } @@ -97,7 +125,7 @@ class LoginTab : public Component, private ListBoxModel int height, bool rowIsSelected) override; - void selectedRowsChanged(int row) override { loginPages.showPage(row); } + void selectedRowsChanged(int row) override; ListBox sidebar; PageSwitcher loginPages; diff --git a/src/windows/settings/SettingsWindow.h b/src/windows/settings/SettingsWindow.h index 3bdc9dd2..0069763b 100644 --- a/src/windows/settings/SettingsWindow.h +++ b/src/windows/settings/SettingsWindow.h @@ -1,14 +1,13 @@ /** * @file SettingsWindow.h * @brief Window containing tabbed settings for HARP. - * @author lindseydeng + * @author cwitkowitz, lindseydeng */ #pragma once #include <JuceHeader.h> -#include "AudioSettingsTab.h" #include "GeneralSettingsTab.h" #include "LoginTab.h" @@ -17,11 +16,18 @@ using namespace juce; class SettingsWindow : public Component { public: - SettingsWindow() : tabComponent(TabbedButtonBar::TabsAtTop) + explicit SettingsWindow(std::function<void()> onRestoreDefaults = {}) + : tabComponent(TabbedButtonBar::TabsAtTop) { - tabComponent.addTab("General", Colours::darkgrey, new GeneralSettingsTab(), true); - tabComponent.addTab("API Keys", Colours::darkgrey, new LoginTab(), true); - //tabComponent.addTab("Audio", Colours::darkgrey, new AudioSettingsTab(), true); + tabComponent.addTab( + "General", Colours::darkgrey, new GeneralSettingsTab(onRestoreDefaults), true); + + // Recorded as the tab is added rather than assumed, so that reordering the tabs + // cannot leave this addressing the wrong one + apiKeysTab = new LoginTab(); + apiKeysTabIndex = tabComponent.getNumTabs(); + + tabComponent.addTab("API Keys", Colours::darkgrey, apiKeysTab, true); addAndMakeVisible(tabComponent); setSize(400, 300); @@ -31,8 +37,22 @@ class SettingsWindow : public Component void resized() override { tabComponent.setBounds(getLocalBounds()); } + void showAPIKeysFor(Provider provider) + { + tabComponent.setCurrentTabIndex(apiKeysTabIndex); + + if (apiKeysTab != nullptr) + { + apiKeysTab->showProvider(provider); + } + } + private: TabbedComponent tabComponent; + // Owned by the TabbedComponent, held here only to address it + LoginTab* apiKeysTab = nullptr; + int apiKeysTabIndex = 0; + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(SettingsWindow) }; diff --git a/src/windows/tutorial/TutorialOverlay.h b/src/windows/tutorial/TutorialOverlay.h new file mode 100644 index 00000000..ce614afd --- /dev/null +++ b/src/windows/tutorial/TutorialOverlay.h @@ -0,0 +1,98 @@ +/** + * @file TutorialOverlay.h + * @brief Dims the window around whatever the current tutorial step points at. + * @author cwitkowitz, saumya-pailwan + */ + +#pragma once + +#include <vector> + +#include <juce_gui_basics/juce_gui_basics.h> + +#include "TutorialTargets.h" + +using namespace juce; + +/** + * Laid over an area of the window (without taking any of its mouse clicks), dimming all of it + * except the targets of the current step, which are outlined. + * + * The targets are located again a few times a second, so that the highlights follow them as + * the window is resized, a tab is scrolled or switched, or a model finishes loading, without + * any of those having to notify the tutorial. + */ +class TutorialOverlay : public Component, private Timer +{ +public: + explicit TutorialOverlay(Component& areaToCover) : area(areaToCover) + { + setInterceptsMouseClicks(false, false); + area.addAndMakeVisible(this); + + update(); + startTimerHz(refreshRateHz); + } + + ~TutorialOverlay() override { area.removeChildComponent(this); } + + // What to highlight, or nothing to dim the whole area + void setTargets(std::vector<TutorialTargets::Target> newTargets) + { + targets = std::move(newTargets); + + update(); + } + + void paint(Graphics& g) override + { + g.setColour(Colours::black.withAlpha(0.6f)); + + { + Graphics::ScopedSaveState state(g); + + for (const auto& highlight : highlights) + g.excludeClipRegion(highlight); + + g.fillAll(); + } + + g.setColour(Colours::white); + + for (const auto& highlight : highlights) + g.drawRoundedRectangle(highlight.toFloat().reduced(1.0f), 5.0f, 2.0f); + } + +private: + void timerCallback() override { update(); } + + void update() + { + if (getBounds() != area.getLocalBounds()) + setBounds(area.getLocalBounds()); + + // Other overlays (e.g., for dragging tracks) may have been brought forward since + toFront(false); + + std::vector<Rectangle<int>> located; + + for (const auto& target : targets) + { + if (const auto bounds = TutorialTargets::locate(area, target); ! bounds.isEmpty()) + located.push_back(bounds.expanded(2).getIntersection(getLocalBounds())); + } + + if (located != highlights) + { + highlights = std::move(located); + repaint(); + } + } + + static constexpr int refreshRateHz = 8; + + Component& area; + + std::vector<TutorialTargets::Target> targets; + std::vector<Rectangle<int>> highlights; +}; diff --git a/src/windows/tutorial/TutorialTargets.h b/src/windows/tutorial/TutorialTargets.h new file mode 100644 index 00000000..5c85e831 --- /dev/null +++ b/src/windows/tutorial/TutorialTargets.h @@ -0,0 +1,104 @@ +/** + * @file TutorialTargets.h + * @brief Parts of the interface the tutorial can point at, and how it finds them. + * @author cwitkowitz, saumya-pailwan + * + * A component is marked for the tutorial by giving it one of these IDs (e.g., + * processButton.setComponentID(TutorialTargets::processButton)). The tutorial then finds + * it in the window by that ID, so nothing else about it needs to be exposed. + */ + +#pragma once + +#include <juce_gui_basics/juce_gui_basics.h> + +using namespace juce; + +namespace TutorialTargets +{ +inline constexpr const char* tabBar = "tutorial.tabBar"; +inline constexpr const char* modelTabs = "tutorial.modelTabs"; +inline constexpr const char* modelSearch = "tutorial.modelSearch"; +inline constexpr const char* modelList = "tutorial.modelList"; +inline constexpr const char* controls = "tutorial.controls"; +inline constexpr const char* inputTracks = "tutorial.inputTracks"; +inline constexpr const char* outputTracks = "tutorial.outputTracks"; +inline constexpr const char* trackFolderButton = "tutorial.trackFolderButton"; +inline constexpr const char* trackPlayButton = "tutorial.trackPlayButton"; +inline constexpr const char* processButton = "tutorial.processButton"; +inline constexpr const char* statusArea = "tutorial.statusArea"; +inline constexpr const char* clipboard = "tutorial.clipboard"; +inline constexpr const char* clipboardControls = "tutorial.clipboardControls"; + +/** + * Something for the tutorial to highlight: the first showing component with an ID, + * optionally only within another (e.g., the folder button of the first input track), or + * failing that, another component (e.g., the tab bar when the Home tab is not showing). + */ +struct Target +{ + String id; + String within = {}; + String otherwise = {}; +}; + +// The first showing component with an ID, searching depth-first in child order +inline Component* findShowing(Component& root, const String& id) +{ + for (auto* child : root.getChildren()) + { + if (! child->isVisible()) + continue; + + if (child->getComponentID() == id && child->isShowing()) + return child; + + if (auto* found = findShowing(*child, id)) + return found; + } + + return nullptr; +} + +/* Where a component is in root's coordinates, clipped to the part that is actually on screen + (e.g., within a scrolled viewport), or empty if none of it is */ +inline Rectangle<int> getVisibleArea(Component& root, Component& component) +{ + Rectangle<int> area = component.getLocalBounds(); + + for (auto* c = &component; c != &root; c = c->getParentComponent()) + { + auto* parent = c->getParentComponent(); + + if (parent == nullptr) + return {}; + + area = (area + c->getPosition()).getIntersection(parent->getLocalBounds()); + } + + return area; +} + +inline Rectangle<int> locate(Component& root, const String& id, const String& within) +{ + Component* scope = &root; + + if (within.isNotEmpty()) + scope = findShowing(root, within); + + auto* component = scope != nullptr ? findShowing(*scope, id) : nullptr; + + return component != nullptr ? getVisibleArea(root, *component) : Rectangle<int>(); +} + +// Where a target is in root's coordinates, or empty if it is not on screen +inline Rectangle<int> locate(Component& root, const Target& target) +{ + const auto area = locate(root, target.id, target.within); + + if (area.isEmpty() && target.otherwise.isNotEmpty()) + return locate(root, target.otherwise, {}); + + return area; +} +} // namespace TutorialTargets diff --git a/src/windows/tutorial/TutorialWindow.h b/src/windows/tutorial/TutorialWindow.h new file mode 100644 index 00000000..beffdb1b --- /dev/null +++ b/src/windows/tutorial/TutorialWindow.h @@ -0,0 +1,539 @@ +/** + * @file TutorialWindow.h + * @brief Window walking a new user through HARP, step by step. + * @author cwitkowitz, saumya-pailwan + */ + +#pragma once + +#include <functional> +#include <memory> +#include <vector> + +#include <juce_gui_basics/juce_gui_basics.h> + +#include "../../ModelTabContainer.h" + +#include "../../utils/Settings.h" + +#include "TutorialOverlay.h" +#include "TutorialTargets.h" + +using namespace juce; + +/** + * What the tutorial needs from the rest of the application. + */ +struct TutorialHost +{ + virtual ~TutorialHost() = default; + + // The area the tutorial dims, within which it finds what it points at + virtual Component& getTutorialArea() = 0; + + virtual ModelTabContainer& getModelTabs() = 0; + + // Parts of the window the user can hide, which a step that points at one shows again + enum class Panel + { + statusArea, + mediaClipboard + }; + + virtual void showPanel(Panel panel) = 0; +}; + +struct TutorialStep +{ + String title; + String description; + std::vector<TutorialTargets::Target> highlights = {}; + std::vector<TutorialHost::Panel> panelsToShow = {}; +}; + +class TutorialWindow : public DocumentWindow, private ChangeListener +{ +public: + // Loaded on the user's behalf when they continue past model selection without one + static constexpr const char* fallbackModelPath = "teamup-tech/Demucs"; + + explicit TutorialWindow(TutorialHost& tutorialHost) + : DocumentWindow("Welcome to HARP", + Desktop::getInstance().getDefaultLookAndFeel().findColour( + ResizableWindow::backgroundColourId), + DocumentWindow::closeButton), + host(tutorialHost), + overlay(host.getTutorialArea()) + { + setUsingNativeTitleBar(true); + content = new TutorialContent(*this); + setContentOwned(content, true); + + setAlwaysOnTop(true); + setResizable(false, false); + + setConstrainer(&constrainer); + constrainer.setMinimumSize(content->getWidth(), content->getHeight()); + constrainer.setMaximumSize(content->getWidth(), content->getHeight()); + constrainer.setMinimumOnscreenAmounts(40, 40, 40, 40); + + positionOnHostDisplay(); + + host.getModelTabs().addChangeListener(this); + + rebuildSteps(); + updateStep(); + } + + ~TutorialWindow() override { host.getModelTabs().removeChangeListener(this); } + + void closeButtonPressed() override + { + if (onClose) + onClose(); + } + + std::function<void()> onClose; + + void paint(Graphics& g) override + { + g.fillAll(getLookAndFeel().findColour(ResizableWindow::backgroundColourId)); + } + + void positionOnHostDisplay() + { + const int width = content->getWidth(); + const int height = content->getHeight(); + setSize(width, height); + + Rectangle<int> targetBounds; + + if (auto* topLevel = host.getTutorialArea().getTopLevelComponent()) + targetBounds = topLevel->getScreenBounds(); + + if (targetBounds.isEmpty()) + { + centreWithSize(width, height); + return; + } + + setTopLeftPosition(targetBounds.getCentreX() - width / 2, + targetBounds.getCentreY() - height / 2); + } + +private: + /* --Model state-- */ + + // Called whenever a model tab is opened, closed, or selected, or finishes loading + void changeListenerCallback(ChangeBroadcaster*) override + { + rebuildSteps(); + + auto model = getCurrentModel(); + + if (model == nullptr || ! model->isLoaded()) + { + updateStep(); + return; + } + + // Once a different model is showing, whether it just loaded or its tab was selected, + // restart at "Quick Start", since the steps from there on describe that model + if (content->currentStep > 0 && model != describedModel.lock()) + content->currentStep = quickStartStep; + + describedModel = model; + + updateStep(); + } + + // The tutorial describes the tab that is showing + std::shared_ptr<Model> getCurrentModel() const + { + auto* tab = host.getModelTabs().getCurrentModelTab(); + return tab != nullptr ? tab->getModel() : nullptr; + } + + bool isModelLoadedInCurrentTab() const + { + auto model = getCurrentModel(); + return model != nullptr && model->isLoaded(); + } + + bool isModelLoadingInCurrentTab() const + { + auto* tab = host.getModelTabs().getCurrentModelTab(); + return tab != nullptr && tab->isLoading(); + } + + void loadFallbackModel() + { + // A model tab that is showing either has a model or is loading one, and in both + // cases the tutorial describes that one + if (host.getModelTabs().getCurrentModelTab() == nullptr) + openedTab = host.getModelTabs().openModelTab(fallbackModelPath); + } + + /* --Steps-- */ + + void rebuildSteps() + { + using namespace TutorialTargets; + + steps.clear(); + + steps.push_back( + { "Welcome to HARP", + "HARP (hosted, asynchronous, remote processing) runs machine learning models on your audio and MIDI. The models are hosted online, e.g., as Hugging Face Spaces, so there is nothing to install.\n\n" + "Use HARP as a standalone app, or as an external sample editor in your DAW.\n\n" + "This short tutorial covers the main parts of the interface." }); + + steps.push_back( + { "Select a Model", + "The Home tab lists the available models by category. Search them by name, task, or tag, or filter them by category, and hover over one to see its full description.\n\n" + "Click a model to open it in a tab of its own. To add one that is not listed, such as a Gradio app of your own, click Custom Path.\n\n" + "If you click Next without opening a model, HARP opens Demucs, which separates music into stems.", + { { modelSearch, {}, tabBar }, { modelList } } }); + + auto model = getCurrentModel(); + const bool isLoaded = model != nullptr && model->isLoaded(); + const String modelName = isLoaded ? String(model->getMetadata().name) : String(); + + /* The model is pointed out rather than described here, since its card is already shown + at the top of its tab, and a long description would push the steps out of view */ + steps.push_back( + { "Quick Start", + (isLoaded ? "This is " + modelName + ". Its details are at the top of its tab.\n\n" + : String()) + + "1. Add input\n" + "Drag an audio or MIDI file onto an input track, or click its folder icon to choose one. Its play icon previews it.\n\n" + "2. Process\n" + "Click Process to run the model on your input. While it runs, the same button cancels it.\n\n" + "3. Get the output\n" + "The results appear in the output tracks, ready to play or save.", + { { modelTabs }, + { trackFolderButton, inputTracks }, + { trackPlayButton, inputTracks }, + { processButton } } }); + + // Only a model with controls has any to point out + if (isLoaded && ! model->getControls().empty()) + { + steps.push_back({ controlsStepTitle, describeControls(*model), { { controls } } }); + } + + steps.push_back( + { "Tracks", + "Input tracks hold what the model reads, and output tracks what it produces.\n\n" + "Click or drag within a track to move its playback position, and scroll over it to zoom or pan.\n\n" + "To use a track elsewhere, drag it onto another track, into the media clipboard, or into your DAW. Output tracks can also be saved, or copied for pasting elsewhere.", + { { inputTracks }, { outputTracks } } }); + + steps.push_back( + { "Status and Instructions", + "The bar at the bottom has two halves.\n\n" + "The left half is a history of what each model has done, e.g., READY once it has loaded, or PROCESSING while it runs. If something fails, the reason appears here.\n\n" + "The right half explains whatever is under the mouse. Hover over any part of HARP to learn what it does.", + { { statusArea } }, + { TutorialHost::Panel::statusArea } }); + + steps.push_back( + { "Media Clipboard", + "The media clipboard keeps files at hand across models and tabs. Drag tracks into it, or add files with its folder icon.\n\n" + "Select a file to play it in the preview pane beneath the list, or to save or remove it. Double-click its name to rename it, and drag it to reorder the list or onto a track.\n\n" + "When your DAW opened HARP with a file, the send icon replaces that file with the selected one, which brings the result back into your project.", + { { clipboard }, { clipboardControls } }, + { TutorialHost::Panel::mediaClipboard } }); + + steps.push_back( + { "All Set!", + "Models on Hugging Face work without an account, but a Hugging Face token raises your allowance for models marked ZeroGPU, and gives access to private Spaces. Stability AI models require a Stability AI key.\n\n" + "Add these under File > Settings > API Keys, which also links to where each service issues them.\n\n" + "To see this tutorial again, choose Help > Welcome Tutorial." }); + + content->currentStep = jlimit(0, (int) steps.size() - 1, content->currentStep); + } + + String describeControls(Model& model) const + { + String text = + "Most models have controls that adjust how they run, e.g., to trade quality for speed, or to choose a variant of the model. The defaults are a good place to start.\n\n" + "Hover over a control to see what it does, or click below to list all of them."; + + if (! content->showingDetails) + return text; + + text += "\n"; + + for (const auto& info : model.getControls()) + { + const auto description = String(info->info).trim(); + + text += "\n- " + String(info->label) + ": " + + (description.isNotEmpty() ? description : "No description provided."); + } + + return text; + } + + /* --Navigation-- */ + + void updateStep() + { + const auto& step = steps[(size_t) content->currentStep]; + + for (const auto panel : step.panelsToShow) + host.showPanel(panel); + + overlay.setTargets(step.highlights); + + content->showStep( + step, content->currentStep, (int) steps.size(), step.title == controlsStepTitle); + } + + void nextStep() + { + if (content->currentStep == selectModelStep && ! isModelLoadedInCurrentTab()) + { + // A load is already running, and the step advances by itself once the model + // arrives (see changeListenerCallback) + if (isModelLoadingInCurrentTab()) + return; + + if (! attemptedFallbackLoad) + { + attemptedFallbackLoad = true; + loadFallbackModel(); + return; + } + + // The fallback load did not succeed. Carry on rather than leaving the user stuck + // on this step with a button that does nothing. + } + + if (content->currentStep < (int) steps.size() - 1) + { + content->currentStep++; + updateStep(); + } + else + { + finish(); + } + } + + void previousStep() + { + if (content->currentStep > 0) + { + content->currentStep--; + updateStep(); + } + } + + void toggleControlDetails() + { + content->showingDetails = ! content->showingDetails; + + rebuildSteps(); + updateStep(); + } + + void finish() + { + Settings::setValue("view.showWelcomePopup", + content->dontShowAgainToggle.getToggleState() ? "0" : "1", + true); + + // Close the tab the tutorial opened on the user's behalf. Resetting it in place would + // leave a blank tab behind, since models are chosen on the Home tab. + if (auto* tab = openedTab.getComponent()) + host.getModelTabs().closeTab(tab); + + closeButtonPressed(); + } + + /* --Layout-- */ + + class TutorialContent : public Component + { + public: + explicit TutorialContent(TutorialWindow& ownerWindow) : owner(ownerWindow) + { + titleLabel.setFont(Font(FontOptions(24.0f, Font::bold))); + titleLabel.setJustificationType(Justification::centred); + addAndMakeVisible(titleLabel); + + descriptionEditor.setMultiLine(true); + descriptionEditor.setReadOnly(true); + descriptionEditor.setScrollbarsShown(true); + descriptionEditor.setCaretVisible(false); + // Look like a label + descriptionEditor.setColour(TextEditor::backgroundColourId, Colours::transparentBlack); + descriptionEditor.setColour(TextEditor::outlineColourId, Colours::transparentBlack); + descriptionEditor.setFont(Font(FontOptions(16.0f))); + addAndMakeVisible(descriptionEditor); + + addAndMakeVisible(learnMoreLink); + + copyrightLabel.setText("Copyright 2026 TEAMuP. All rights reserved.", + dontSendNotification); + copyrightLabel.setJustificationType(Justification::centred); + copyrightLabel.setFont(Font(FontOptions(12.0f))); + copyrightLabel.setColour(Label::textColourId, Colours::grey); + addAndMakeVisible(copyrightLabel); + + nextButton.onClick = [this] { owner.nextStep(); }; + addAndMakeVisible(nextButton); + + prevButton.onClick = [this] { owner.previousStep(); }; + addChildComponent(prevButton); + + skipButton.onClick = [this] { owner.finish(); }; + addAndMakeVisible(skipButton); + + dontShowAgainToggle.setToggleState( + ! Settings::getBoolValue("view.showWelcomePopup", true), dontSendNotification); + addAndMakeVisible(dontShowAgainToggle); + + pageIndicator.setJustificationType(Justification::centredLeft); + pageIndicator.setFont(Font(FontOptions(12.0f))); + pageIndicator.setInterceptsMouseClicks(false, false); + addAndMakeVisible(pageIndicator); + + showDetailsButton.onClick = [this] { owner.toggleControlDetails(); }; + addChildComponent(showDetailsButton); + + setSize(500, 420); + } + + void showStep(const TutorialStep& step, int index, int numSteps, bool offersDetails) + { + titleLabel.setText(step.title, dontSendNotification); + descriptionEditor.setText(step.description); + descriptionEditor.setCaretPosition(0); + + const bool isLastStep = index == numSteps - 1; + + prevButton.setVisible(index > 0); + skipButton.setVisible(! isLastStep); + nextButton.setButtonText(isLastStep ? "Finish" : "Next"); + + learnMoreLink.setVisible(index == 0); + copyrightLabel.setVisible(index == 0); + + pageIndicator.setText("Step " + String(index + 1) + " of " + String(numSteps), + dontSendNotification); + + showDetailsButton.setVisible(offersDetails); + showDetailsButton.setButtonText(showingDetails ? "Hide control descriptions" + : "List control descriptions"); + + resized(); + } + + void resized() override + { + auto area = getLocalBounds().reduced(20); + + titleLabel.setBounds(area.removeFromTop(40)); + area.removeFromTop(10); + + auto footerArea = area.removeFromBottom(72); + auto footer = footerArea.removeFromTop(30); + auto toggleRow = footerArea.removeFromBottom(30); + + const int buttonHeight = 30; + const int buttonPaddingX = 28; + const int buttonWidth = + jmax(skipButton.getBestWidthForHeight(buttonHeight) + buttonPaddingX, + nextButton.getBestWidthForHeight(buttonHeight) + buttonPaddingX); + skipButton.setBounds(footer.removeFromLeft(buttonWidth)); + nextButton.setBounds(footer.removeFromRight(buttonWidth)); + footer.removeFromRight(10); + prevButton.setBounds(footer.removeFromRight(buttonWidth)); + + const int middleRightX = + prevButton.isVisible() ? (prevButton.getX() - 10) : nextButton.getX(); + const int middleX = skipButton.getRight() + 12; + copyrightLabel.setBounds( + middleX, footer.getY(), jmax(0, middleRightX - middleX), footer.getHeight()); + + constexpr int toggleHeight = 26; + const int toggleTextWidth = GlyphArrangement::getStringWidthInt( + Font(FontOptions(16.0f)), dontShowAgainToggle.getButtonText()); + const int toggleWidth = 24 + toggleTextWidth + 16; + const int toggleY = toggleRow.getY() + 12; + dontShowAgainToggle.setBounds( + toggleRow.getRight() - toggleWidth, toggleY, toggleWidth, toggleHeight); + + const int stepWidth = jmax(skipButton.getWidth(), + GlyphArrangement::getStringWidthInt(pageIndicator.getFont(), + pageIndicator.getText()) + + 16); + pageIndicator.setBounds(toggleRow.getX(), toggleY, stepWidth, toggleHeight); + + area.removeFromBottom(8); + + if (learnMoreLink.isVisible()) + { + learnMoreLink.setBounds(area.removeFromBottom(36).withSizeKeepingCentre(180, 30)); + area.removeFromBottom(4); + } + else if (showDetailsButton.isVisible()) + { + showDetailsButton.setBounds(area.removeFromBottom(30).reduced(20, 0)); + area.removeFromBottom(8); + } + + descriptionEditor.setBounds(area); + } + + Label titleLabel; + TextEditor descriptionEditor; + + TextButton nextButton { "Next" }; + TextButton prevButton { "Back" }; + TextButton skipButton { "Skip Tutorial" }; + ToggleButton dontShowAgainToggle { "Don't show again" }; + + Label pageIndicator; + HyperlinkButton learnMoreLink { "Learn more", + URL("https://harp3.netlify.app/content/intro.html") }; + Label copyrightLabel; + + TextButton showDetailsButton; + + int currentStep = 0; + bool showingDetails = false; + + private: + TutorialWindow& owner; + }; + + static constexpr int selectModelStep = 1; + static constexpr int quickStartStep = 2; + static inline const String controlsStepTitle { "Controls (Optional)" }; + + TutorialHost& host; + + // Declared after host, which it needs to be constructed + TutorialOverlay overlay; + + TutorialContent* content = nullptr; + std::vector<TutorialStep> steps; + + // Whether the tutorial has already asked for the fallback model on the user's behalf, so + // that a failed load does not trap them on that step + bool attemptedFallbackLoad = false; + + // The tab the tutorial opened on the user's behalf, closed again at the end + Component::SafePointer<ModelTab> openedTab; + + // The model the steps were last written for, so that only a different one restarts them + std::weak_ptr<Model> describedModel; + + ComponentBoundsConstrainer constrainer; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(TutorialWindow) +}; diff --git a/website/content/contributing/build_source.md b/website/content/contributing/build_source.md index e683e287..95e06063 100644 --- a/website/content/contributing/build_source.md +++ b/website/content/contributing/build_source.md @@ -20,7 +20,7 @@ cmake .. -DCMAKE_BUILD_TYPE=Debug ``` #### ARM vs. x86 MacOS -The OSX architecture for the build can be specified explicitly by setting `CMAKE_OSX_ARCHITECTURES` to either `arm64` or `x86_64`: +The OSX architecture for the build can be specified explicitly by setting `CMAKE_OSX_ARCHITECTURES` to either `arm64` or `x86_64`, or to `"arm64;x86_64"` for a universal binary: ```bash cmake .. -DCMAKE_OSX_ARCHITECTURES=x86_64 ``` diff --git a/website/content/contributing/dist.md b/website/content/contributing/dist.md index 56711a72..b1dcfa6f 100644 --- a/website/content/contributing/dist.md +++ b/website/content/contributing/dist.md @@ -1,33 +1,34 @@ # Distribution +HARP is built and packaged for macOS (universal), Windows (x64), and Linux (x64 and arm64) by [GitHub Actions](https://github.com/TEAMuP-dev/HARP/blob/main/.github/workflows/build.yml) on every push. +Pushing a tag of the form `v*` also publishes the packages as a release, with `RELEASE.md` as its notes. +The macOS and Windows packages are code signed, as described in [SIGNING.md](https://github.com/TEAMuP-dev/HARP/blob/main/docs/SIGNING.md). + ### MacOS -Codesigning and packaging for distribution is done through the script located at `packaging/package.sh`. -You'll need to set up a developer account with Apple and create a certificate in order to sign the plugin. -For more information on codesigning and notarization for macOS, please refer to the [pamplejuce](https://github.com/sudara/pamplejuce) template. -The script requires the following variables to be passed: -``` -# Retrieve values from either environment variables or command-line arguments -DEV_ID_APPLICATION # Developer ID Application certificate -ARTIFACTS_PATH # should be packaging/dmg/HARP.app -PROJECT_NAME # "HARP" -PRODUCT_NAME # "HARP" -NOTARIZATION_USERNAME # Apple ID -NOTARIZATION_PASSWORD # App-specific password for notarization -TEAM_ID # Team ID for notarization +A build can also be packaged locally into a DMG with [dmgbuild](https://github.com/dmgbuild/dmgbuild) (`pip install dmgbuild`): +```bash +packaging/package.sh build/HARP_artefacts/Release/HARP.app HARP.dmg ``` -Usage: +To sign and notarize the DMG, set the following environment variables first: ```bash -./HARP/packaging/package.sh <DEV_ID_APPLICATION> <ARTIFACTS_PATH> <PROJECT_NAME> <PRODUCT_NAME> <NOTARIZATION_USERNAME> <NOTARIZATION_PASSWORD> <TEAM_ID> +export MACOS_SIGNING_IDENTITY="Developer ID Application: <NAME> (<TEAM_ID>)" # Certificate in your keychain +export APPLE_ID=<APPLE_ID> # Apple Account email address +export APPLE_APP_PASSWORD=<APP_SPECIFIC_PASSWORD> # App-specific password for notarization ``` -After running `package.sh`, you should have a signed and notarized dmg file in the `packaging/` directory. +### Linux -### Windows +A Linux binary requires at least the versions of glibc and libstdc++ used to build it, so the workflow builds on the oldest Ubuntu available, 22.04 (glibc 2.35, and libstdc++ from GCC 12). +The packages therefore run on these distributions: -(Coming soon) +- Ubuntu 22.04 or newer, and distributions based on it, such as Linux Mint 21 and Pop!_OS 22.04 +- Debian 12 or newer, including 64-bit Raspberry Pi OS (with the `arm64` package) +- Fedora 36 or newer, RHEL 10 and its rebuilds, openSUSE Tumbleweed and Leap 16, and Arch Linux -### Linux +They do not run on Ubuntu 20.04, Debian 11, RHEL 8 or 9, or openSUSE Leap 15. +Reaching those would mean building inside an older Linux container, and reaching every distribution would mean publishing a Flatpak. -(Coming soon) +At runtime, HARP needs ALSA, FreeType, and X11, and loads libcurl for network requests, all of which come with a standard desktop installation. +On Wayland, it runs through XWayland, which GNOME and KDE enable by default. diff --git a/website/content/contributing/version_compat.md b/website/content/contributing/version_compat.md index fadd601a..d6ff31b1 100644 --- a/website/content/contributing/version_compat.md +++ b/website/content/contributing/version_compat.md @@ -4,6 +4,8 @@ The currently available versions of HARP and PyHARP are mutually compatible. Bel | HARP | PyHARP | | :-: | :-: | +| 4.0.0 | 0.4.1 | +| 3.1.0 | 0.3.0 | | 3.0.0 | 0.3.0 | | 2.2.0 | 0.2.1 | | 2.1.x | 0.2.0 | diff --git a/website/content/install/linux.md b/website/content/install/linux.md index 521edc5d..2bdd74d7 100644 --- a/website/content/install/linux.md +++ b/website/content/install/linux.md @@ -1,9 +1,9 @@ # Install HARP on Linux -To install HARP on Linux: +To install HARP on Linux (requires glibc 2.35 or later, _e.g._ Ubuntu 22.04, Debian 12, Fedora 36, or newer): -* Download the ZIP file for your operating system version from the [releases](https://github.com/TEAMuP-dev/HARP/releases) page +* Download the archive for your architecture (`x64` or `arm64`) from the [releases](https://github.com/TEAMuP-dev/HARP/releases) page. -* Extract the contents of the ZIP file. +* Extract `HARP` from the archive (_e.g._ `tar -xzf HARP-<version>-Linux-x64.tar.gz`). -* From these contents, move the directory containing `HARP` to your chosen location (_e.g._ `/usr/local/bin/`). +* Move `HARP` to your chosen location (_e.g._ `/usr/local/bin/`). diff --git a/website/content/install/macos.md b/website/content/install/macos.md index 8950f728..8bc14e49 100644 --- a/website/content/install/macos.md +++ b/website/content/install/macos.md @@ -1,10 +1,10 @@ -# Install HARP on Mac OS +# Install HARP on macOS -To install HARP on Mac OS (`arm` or `x86`): +To install HARP on macOS 11 or later (Apple silicon or Intel): -* Download the ZIP file for your operating system version from the [releases](https://github.com/TEAMuP-dev/HARP/releases) page. +* Download the DMG file from the [releases](https://github.com/TEAMuP-dev/HARP/releases) page. -* Unzip and open the DMG file: +* Open the DMG file: <p align="center"> <img width="528" src="https://github.com/TEAMuP-dev/HARP/assets/33099118/3f0278b2-8ace-41a1-8383-9c4aed894d56"> </p> diff --git a/website/content/install/windows.md b/website/content/install/windows.md index 9647c89d..2ada26c3 100644 --- a/website/content/install/windows.md +++ b/website/content/install/windows.md @@ -2,7 +2,7 @@ To install HARP on Windows: -* Download the ZIP file for your operating system version from the [releases](https://github.com/TEAMuP-dev/HARP/releases) page. +* Download the ZIP file (`HARP-<version>-Windows-x64.zip`) from the [releases](https://github.com/TEAMuP-dev/HARP/releases) page. * Extract the contents of the ZIP file. diff --git a/website/content/intro.md b/website/content/intro.md index ec65c5d2..64368ef1 100644 --- a/website/content/intro.md +++ b/website/content/intro.md @@ -2,10 +2,23 @@ [![HARP Repo](https://github-readme-stats.vercel.app/api/pin/?username=TEAMuP-dev&repo=HARP)](https://github.com/TEAMuP-dev/HARP) -HARP is a sample editor for **h**osted, **a**synchronous, **r**emote **p**rocessing of audio with machine learning. HARP operates as a standalone application or a plugin-like editor within your DAW, and routes audio, MIDI, and metadata through [Gradio](https://www.gradio.app) endpoints for processing. Gradio apps can be hosted locally or remotely (_e.g._, [HuggingFace Spaces](https://huggingface.co/spaces)), allowing users of DAWs (_e.g._ [REAPER](https://www.reaper.fm), [Logic Pro X](https://www.apple.com/logic-pro/), [Ableton Live](https://www.ableton.com/en/live/)) to access powerful cloud-based models seamlessly from within their DAW. - -<!-- TODO - add link to HARP 3.0 paper --> -To learn how to install, use, or contribute to HARP, see the linked documentation in the left sidebar, our [NeurIPS workshop paper](https://neuripscreativityworkshop.github.io/2023/papers/ml4cd2023_paper23.pdf), or our [ISMIR Late Breaking Demo](https://arxiv.org/abs/2503.02977). +HARP is a sample editor for **h**osted, **a**synchronous, **r**emote **p**rocessing of audio with +machine learning. It runs as a standalone application or as a plugin-like editor within a DAW, and +sends audio, MIDI, and metadata to [Gradio](https://www.gradio.app) endpoints for processing. + +Most machine learning models for audio are impractical to run alongside a session. They expect a +GPU, a particular version of PyTorch, and weights measured in gigabytes, and they take far longer +than a buffer period to produce a result. HARP leaves each model where it is already installed, +whether that is a [Hugging Face Space](https://huggingface.co/spaces) or a machine on your own +network, and returns the result to the editor. Processing is asynchronous and happens outside the +audio thread, so a model that runs for a minute delays nothing but itself. + +This gives users of DAWs that support external sample editors, among them +[REAPER](https://www.reaper.fm), [Logic Pro](https://www.apple.com/logic-pro/), and +[Ableton Live](https://www.ableton.com/en/live/), access to these models without leaving the +session. + +To learn how to install, use, or contribute to HARP, see the linked documentation in the left sidebar, or our papers: [HARP 3.0](https://openreview.net/pdf?id=Ha2z921r51) (NeurIPS 2025 Workshop on AI for Music), [HARP 2.0](https://ismir2024program.ismir.net/lbd_497.html) (ISMIR 2024 Late Breaking Demo), and [HARP](https://neuripscreativityworkshop.github.io/2023/papers/ml4cd2023_paper23.pdf) (NeurIPS 2023 Workshop on Machine Learning for Creativity and Design). <br/><br/> diff --git a/website/content/pyharp_docs/example.md b/website/content/pyharp_docs/example.md index 97b14144..488ed352 100644 --- a/website/content/pyharp_docs/example.md +++ b/website/content/pyharp_docs/example.md @@ -4,13 +4,19 @@ To get the hang of building PyHARP applications, we'll deploy a state-of-the-art ### TIGER: a Powerful and Lightweight Speech Separation Model -DAW users such as video editors and podcasters often work with noisy speech recordings captured in less-than-ideal conditions, and "cleaning up" these recordings can be difficult and time-intensive. Luckily, there are a number of cutting-edge deep learning models capable of automating this clean-up process by separating a speech signal from any background sounds in a recording, including environment noise and even other speakers. In this tutorial, we'll look at [TIGER](https://arxiv.org/abs/2410.01469), a lightweight speech separation model capable of running efficiently on a laptop CPU. This means that you won't need a GPU for this tutorial -- just working HARP and PyHARP installations! +DAW users such as video editors and podcasters often work with noisy speech recordings captured in less-than-ideal conditions, and "cleaning up" these recordings can be difficult and time-intensive. Luckily, there are a number of cutting-edge deep learning models capable of automating this cleanup process by separating a speech signal from any background sounds in a recording, including environment noise and even other speakers. In this tutorial, we'll look at [TIGER](https://arxiv.org/abs/2410.01469), a lightweight speech separation model capable of running efficiently on a laptop CPU. This means that you won't need a GPU for this tutorial -- just working HARP and PyHARP installations! -To get started, we'll install [the code](https://github.com/JusperLee/TIGER) needed to run TIGER: +To get started, please set the Python version to `>=3.10, <=3.12` and install PyHARP. Then, we'll install [the code](https://github.com/JusperLee/TIGER) needed to run TIGER: ``` git clone https://github.com/JusperLee/TIGER.git -cd TIGER && pip install -r requirements.txt +cd TIGER +``` + +In the `requirements.txt`, remove `triton==3.1.0` and add `typer==0.16.1` to avoid dependency issues. Then pip install: + +``` +pip install -r requirements.txt ``` Next, we'll look at how TIGER processes audio files to separate speech. From [`TIGER/inference_speech.py`](https://github.com/JusperLee/TIGER/blob/main/inference_speech.py), we can see that TIGER requires audio files to be resampled to 16kHz and formatted as a PyTorch tensor of shape `[1, C, T]` where `C` is the number of channels and `T` is the number of audio samples; then, the model can be applied in one line of code: @@ -21,11 +27,12 @@ Next, we'll look at how TIGER processes audio files to separate speech. From [`T ests_speech = model(audio_input) # Expected output shape: [1, num_spk, T] ``` -Here, `num_spk` is the estimated number of speakers in the recording. For our example application, we'll only return audio of the first speaker. +Here, `num_spk` is the estimated number of speakers in the recording. The TIGER model sets `num_spk` to two. We are going to take both outputs from the model: ``` -# Select audio of first speaker -output = ests_speech[:, 0, :] # Expected output shape: [1, T] +# Select audio of each speaker independently +output_1 = ests_speech[:, 0, :] # Expected output shape: [1, T] +output_2 = ests_speech[:, 1, :] # Expected output shape: [1, T] ``` And that's it -- to deploy TIGER in HARP, we need to write a Gradio application for processing audio files like this, sprinkle in some PyHARP functions, and then run our application in the background to handle any audio sent by HARP. @@ -42,17 +49,28 @@ Now that we have a grip on how to run TIGER, let's revisit the [elements](/conte name="TIGER", description="The TIGER speech separation model of Xu et al. (https://arxiv.org/abs/2410.01469)", author="Your name", - tags=["example", "speech separation"], - midi_in=False, - midi_out=False + tags=["example", "speech separation"] ) ``` -* We need to define a list of __Gradio interactive components__ specifying the interface. In our case, because we only require audio input/output, we'll leave this list empty: +* We need to define a list of __Gradio interactive components__ specifying the interface. In our case, we need one audio input and two audio outputs: ``` # Define Gradio Components - components = [] - ``` -* We need to define a __processing function__ for handling file input and output. This function will load an audio file from a given path, format it for TIGER and run separation as discussed above, save the output to a new file, and return the path of this output file. + # Input + input_audio = gr.Audio( + label="Input Audio", + type="filepath", + sources=["upload", "microphone"] + ) + # Outputs + output_audio_1 = gr.Audio( + type="filepath", + label="Output Audio 1" + ) + output_audio_2 = gr.Audio( + type="filepath", + label="Output Audio 2" + ) +* Then we define a __processing function__ for handling file input and output. This function will load an audio file from a given path, format it for TIGER and run separation as discussed above, save the outputs to new files, and return the paths of the output files. Note that the parameters of the function correspond to the input components and the return values correspond to the output components. ``` # Define the processing function @torch.inference_mode() @@ -61,31 +79,49 @@ Now that we have a grip on how to run TIGER, let's revisit the [elements](/conte # By default, load audio as a Descript-AudioTools `AudioSignal` object sig = load_audio(input_audio_path) # Wraps a tensor of shape [1, C, T] - audio_input = sig.resample(16_000).audio_data # Tensor of shape [1, C, T] + audio_input = sig.resample(16_000).audio_data.to(device) # Tensor of shape [1, C, T] # Apply TIGER ests_speech = model(audio_input) # Expected output shape: [1, num_spk, T] - output = ests_speech[:, 0, :] # Expected output shape: [1, T] + output_1 = ests_speech[:, 0, :] # Expected output shape: [1, T] + output_2 = ests_speech[:, 1, :] - sig.audio_data = output - output_audio_path = save_audio(sig) + # Create two new audio + sig_1 = AudioSignal(output_1.cpu().numpy().astype("float32"), sample_rate=16000) + sig_2 = AudioSignal(output_2.cpu().numpy().astype("float32"), sample_rate=16000) - # Because this application does not apply labels to audio, return an empty - # label list - output_labels = LabelList() + # save to files + output_dir = Path("_outputs").resolve() + output_dir.mkdir(exist_ok=True, parents=True) - return output_audio_path, output_labels + output_audio_path_1 = output_dir / "sig1.wav" + output_audio_path_2 = output_dir / "sig2.wav" + + save_audio(sig_1, output_audio_path_1) + save_audio(sig_2, output_audio_path_2) + + return output_audio_path_1, output_audio_path_2 + ``` +* After we define the __model card__, __I/O components__ and the __processing function__, we aggregate them into an endpoint: + ``` + # Build Endpoint + app = build_endpoint( + model_card=model_card, + input_components = [input_audio], + output_components = [output_audio_1, output_audio_2], + process_fn=process_fn + ) ``` -Adding imports and some model-loading code, our final `app.py` should look like this: +Adding imports and model-loading code, our final `app.py` should look like this: ``` from pyharp import * +from audiotools import AudioSignal -import yaml import os +from pathlib import Path import gradio as gr -import torchaudio import torch import look2hear.models @@ -107,9 +143,7 @@ model_card = ModelCard( name="TIGER", description="The TIGER speech separation model of Xu et al. (https://arxiv.org/abs/2410.01469)", author="Your name", - tags=["example", "speech separation"], - midi_in=False, - midi_out=False + tags=["example", "speech separation"] ) # Define the processing function @@ -123,25 +157,47 @@ def process_fn(input_audio_path): # Apply TIGER ests_speech = model(audio_input) # Expected output shape: [1, num_spk, T] - output = ests_speech[:, :1, :] # Expected output shape: [1, 1, T] - - sig.audio_data = output - output_audio_path = save_audio(sig.cpu()) # Ensure our output signal is on CPU + output_1 = ests_speech[:, 0, :] # Expected output shape: [1, 1, T] + output_2 = ests_speech[:, 1, :] + + sig_1 = AudioSignal(output_1.cpu().numpy().astype("float32"), sample_rate=16000) + sig_2 = AudioSignal(output_2.cpu().numpy().astype("float32"), sample_rate=16000) + + output_dir = Path("_outputs").resolve() + output_dir.mkdir(exist_ok=True, parents=True) + + output_audio_path_1 = output_dir / "sig1.wav" + output_audio_path_2 = output_dir / "sig2.wav" - # Because this application does not apply labels to audio, return an empty - # label list - output_labels = LabelList() + save_audio(sig_1, output_audio_path_1) + save_audio(sig_2, output_audio_path_2) - return output_audio_path, output_labels + return output_audio_path_1, output_audio_path_2 # Build Gradio endpoint with gr.Blocks() as demo: # Define Gradio Components - components = [] - - app = build_endpoint(model_card=model_card, - components=components, - process_fn=process_fn) + input_audio = gr.Audio( + label="Input Audio", + type="filepath", + sources=["upload", "microphone"] + ) + output_audio_1 = gr.Audio( + type="filepath", + label="Output Audio 1" + ) + output_audio_2 = gr.Audio( + type="filepath", + label="Output Audio 2" + ) + # output_labels = gr.JSON(label="Separation") + + app = build_endpoint( + model_card=model_card, + input_components = [input_audio], + output_components = [output_audio_1, output_audio_2], # , output_labels], + process_fn=process_fn + ) demo.queue() demo.launch(share=True, show_error=True) @@ -149,7 +205,7 @@ demo.launch(share=True, show_error=True) ### Deploying Our App -For now, we'll put `app.py` inside the `TIGER/` directory to avoid further installation steps. Note that while PyHARP's utilities use [Descript-AudioTools](https://github.com/descriptinc/audiotools) under the hood to handle audio loading and saving, you're free to use whichever libraries you want as long as they can read and produce valid audio files. +For now, we'll put `app.py` inside the cloned `TIGER/` directory to avoid further installation steps. Note that while PyHARP's utilities use [Descript-AudioTools](https://github.com/descriptinc/audiotools) under the hood to handle audio loading and saving, you're free to use whichever libraries you want as long as they can read and produce valid audio files. With our application up and running, it's time to link it to HARP. Run: @@ -166,4 +222,4 @@ Your terminal should display something like this: This share link expires in 1 week. For free permanent hosting and GPU upgrades, run `gradio deploy` from the terminal in the working directory to deploy to Hugging Face Spaces (https://huggingface.co/spaces) ``` -Copy the public URL and open up HARP. Paste the URL as a `custom path...`, click `Load`, and voila -- you should now be able to run state-of-the-art speech separation in your DAW! \ No newline at end of file +Copy the public URL and open up HARP. Click `Custom Path...` on the Home tab, paste the URL, and click `Load`. You can now run state-of-the-art speech separation in your DAW. diff --git a/website/content/pyharp_docs/host.md b/website/content/pyharp_docs/host.md index 54ccb031..3af743d0 100644 --- a/website/content/pyharp_docs/host.md +++ b/website/content/pyharp_docs/host.md @@ -1,3 +1,123 @@ -# Hosting PyHARP Apps in the Cloud +# Hosting PyHARP Apps in the Cloud (HuggingFace Spaces) +The share links Gradio generates expire after a week. To keep an endpoint active and share it with other users, host your PyHARP app on [HuggingFace Spaces](https://huggingface.co/docs/hub/spaces-overview) (similar hosting services are also available). Our [deployment guidelines](https://github.com/TEAMuP-dev/HARP/blob/main/docs/DEPLOYMENT.md) describe the conventions the Spaces we host follow, from licensing and tags to hardware. -(Coming soon) \ No newline at end of file +### Gradio Endpoints +This is the most convenient solution for hosting a PyHARP app. If you are a Hugging Face PRO subscriber, you can use [ZeroGPU](https://huggingface.co/docs/hub/spaces-zerogpu) to dynamically allocate GPU resources according to user requests without any additional charges. Non-PRO users can select from CPU environments or paid GPU options. + +1. Create a new [HuggingFace Space](https://huggingface.co/new-space). +2. Choose Gradio as the SDK along with the blank template. +3. Select the desired hardware option. +4. Create the space and clone the initialized repository locally: +```bash +git clone https://huggingface.co/spaces/<USERNAME>/<SPACE_NAME> +``` +5. Add your files to the repository, commit, then push to the `main` branch: +```bash +git add . +git commit -m "initial commit" +git push -u origin main +``` +6. Configure the following repository files: + - `README.md` + + Set __sdk_version__ to __6.24.0__, the recommended version of `gradio`. HARP requires at least `6.13.0`, since earlier versions do not forward the reason a job failed. + + - `requirements.txt` + + Place all of the required **pip** packages in this file. It should also include `pyharp`, pinned to the commit of a release rather than to a tag or branch, which can move: + ``` + # pyharp v0.4.1 + git+https://github.com/TEAMuP-dev/pyharp.git@9a31acb35563c0bcf5a93f968d8285c7c3180aba + ``` + Do not include `gradio` in this file, since `sdk_version` sets it. + + - `packages.txt` + + Place any necessary **apt-get install** debian packages in this file. Some models may require these. + +### Docker Endpoints +Some models may have been developed with older versions of Python, and attempting to deploy them would lead to dependency issues. For example, the `numpy.float` and `numpy.int` deprecation in `numpy==1.24` breaks older packages such as `madmom`. Therefore, we may need to patch any corresponding source files during the deployment process. However, this is not supported by the highly-modularized Gradio SDK. + +Using Docker endpoints can help circumvent these issues. Docker will allow you to customize the deployment, which makes room for any necessary patches. Note however that ZeroGPU is not available for Docker spaces, meaning you must pay to use GPU resources with this option. + +1. Create a new [HuggingFace Space](https://huggingface.co/new-space). +2. Choose Docker as the SDK along with the blank template. +3. Select the desired hardware option. +4. Create the space and clone the initialized repository locally: +```bash +git clone https://huggingface.co/spaces/<USERNAME>/<SPACE_NAME> +``` +5. Add your files to the repository, commit, then push to the `main` branch: +```bash +git add . +git commit -m "initial commit" +git push -u origin main +``` +6. Configure the following repository files: + - `README.md` + + Set **app_port** to any valid `<PORT>`. + + - `requirements.txt` + + Place all of the required **pip** packages in this file. It should also include the recommended version of `gradio`, and `pyharp` pinned to the commit of a release: + ``` + gradio==6.24.0 + # pyharp v0.4.1 + git+https://github.com/TEAMuP-dev/pyharp.git@9a31acb35563c0bcf5a93f968d8285c7c3180aba + ``` + + - `packages.txt` + + Place any necessary **apt-get install** debian packages in this file. Some models may require these. + + - `Dockerfile` + + Installs the required **pip** and **apt-get** packages, and supports manual patching (_e.g._ of `madmom` in the following example): + ```Docker + FROM python:3.10-slim # Set python version + + WORKDIR /app + COPY packages.txt /app/packages.txt + + # System dependencies for building packages from source + RUN apt-get update + RUN xargs apt-get install -y --no-install-recommends < /app/packages.txt + RUN rm -rf /var/lib/apt/lists/* + + COPY requirements.txt /app/requirements.txt + # Disable build isolation so Cython installed in the environment is visible at build time + ENV PIP_NO_BUILD_ISOLATION=1 + RUN pip install --no-cache-dir -U pip wheel Cython + RUN pip install --no-cache-dir setuptools==80.9.0 + RUN pip install --no-cache-dir -r /app/requirements.txt + RUN pip install --no-cache-dir --no-build-isolation madmom + + # Patch madmom package + COPY patch_madmom.py /app/scripts/patch_madmom.py # Script to patch madmom source files + RUN python /app/scripts/patch_madmom.py + RUN python -c "import madmom; print('madmom import OK')" + + # Copy remainder of the repo + COPY . /app + + # HF Spaces route traffic to <PORT> + # Gradio should listen accordingly + ENV PORT=<PORT> # <PORT> in README.md + EXPOSE <PORT> + + # Run the app + CMD ["python", "app.py"] + ``` + +--- +Here are a few tips and best practices when dealing with HuggingFace Spaces: +- Spaces operate based off of the files in the `main` branch +- An [access token](https://huggingface.co/docs/hub/security-tokens) may be required to push commits to HuggingFace Spaces +- A `.gitignore` file should be added to maintain repository orderliness (_e.g._, to ignore `src/_outputs`) +- Pin versions for packages such as `numpy`, `torch`, and `torchaudio` to avoid unexpected build issues caused by their latest versions. ZeroGPU requires `torch>=2.8.0` + +For more information, please refer to the official documentation from Hugging Face about [Spaces](https://huggingface.co/docs/hub/spaces). + +## Accessing Within HARP +PyHARP apps deployed to HuggingFace will begin running at `https://huggingface.co/spaces/<USERNAME>/<SPACE_NAME>`. The shorthand `<USERNAME>/<SPACE_NAME>` can also be used within HARP to reference the endpoint. The two deployment methods above produce identical UIs and functionality. diff --git a/website/content/pyharp_docs/install.md b/website/content/pyharp_docs/install.md index b888406f..f5bc0b9b 100644 --- a/website/content/pyharp_docs/install.md +++ b/website/content/pyharp_docs/install.md @@ -4,3 +4,5 @@ PyHARP can be installed using `pip`: - **Clone the PyHarp repository:** `git clone https://github.com/TEAMuP-dev/pyharp` - **Install:** `pip install -e pyharp && cd pyharp` + +Note that PyHARP depends on [Gradio](https://www.gradio.app/). PyHARP 0.4.1 requires `gradio>=6.13.0,<7`, which requires `python>=3.10`. diff --git a/website/content/pyharp_docs/overview.md b/website/content/pyharp_docs/overview.md index 76ee1b03..df900688 100644 --- a/website/content/pyharp_docs/overview.md +++ b/website/content/pyharp_docs/overview.md @@ -6,7 +6,7 @@ We can use a variety of [state-of-the-art models within HARP](/content/usage/mod Under the hood, HARP simply routes data (audio, MIDI, etc.) to Python applications for processing and renders the results in the editor. To make this work, we require (1) that these applications are built using [Gradio](https://www.gradio.app/), and (2) that these applications follow a simple set of specifications defined in the [PyHARP](https://github.com/TEAMuP-dev/pyharp) library. Crucially, PyHARP wraps a number of interactive components from Gradio (e.g. knobs, sliders, text boxes) to allow for rendering application-defined interfaces within the HARP editor. -Within these constraints, developers can build a wide variety of audio and MIDI-processing applications. For instance, HARP is compatible with any Python deep learning framework because it only requires that applications consume and produce audio files, MIDI files, or structrued labels -- it places no limits on how these objects are created or modified. +Within these constraints, developers can build a wide variety of audio and MIDI-processing applications. For instance, HARP is compatible with any Python deep learning framework because it only requires that applications consume and produce audio files, MIDI files, or structured labels -- it places no limits on how these objects are created or modified. ### Host Your Endpoint Locally or in the Cloud diff --git a/website/content/pyharp_docs/pyharp_app.md b/website/content/pyharp_docs/pyharp_app.md index 77dd21fd..eef7453d 100644 --- a/website/content/pyharp_docs/pyharp_app.md +++ b/website/content/pyharp_docs/pyharp_app.md @@ -32,7 +32,7 @@ Your terminal should display something like this: This share link expires in 1 week. For free permanent hosting and GPU upgrades, run `gradio deploy` from the terminal in the working directory to deploy to Hugging Face Spaces (https://huggingface.co/spaces) ``` -Copy the public URL -- in this example, `https://8661b0cf18d5cf17ec.gradio.live` -- and open up HARP. As shown below, we can link HARP to our application by selecting `custom path...` from the model drop-down menu and pasting our URL. +Copy the public URL (in this example, `https://8661b0cf18d5cf17ec.gradio.live`) and open up HARP. As shown below, we can link HARP to our application by clicking `Custom Path...` on the Home tab and pasting our URL. <div style="max-width:720px;margin:1.5rem auto;"> <video controls preload="metadata" width="100%"> diff --git a/website/content/setup/standalone.md b/website/content/setup/standalone.md index 9d766fa2..96f78578 100644 --- a/website/content/setup/standalone.md +++ b/website/content/setup/standalone.md @@ -2,11 +2,11 @@ After following the installation instructions for your operating system version, you can open HARP as a standalone application to start processing audio, MIDI, and metadata. -- **MacOS:** Launch HARP (`HARP.app`) from your `Applications` folder. +- **macOS:** Launch HARP (`HARP.app`) from your `Applications` folder. - **Windows:** Launch HARP (`HARP.exe`) from your chosen installation directory. - **Linux:** Launch HARP (`HARP`) from your chosen installation directory. -Upon launching, you should see an "empty" HARP window that looks like this: +Upon launching, HARP opens on its Home tab, which lists the available models. The first time, a short tutorial walks through the interface: <!-- TODO - Update screenshot --> <p align="center"> diff --git a/website/content/supported_os.md b/website/content/supported_os.md index 0e61b36c..b235d125 100644 --- a/website/content/supported_os.md +++ b/website/content/supported_os.md @@ -1,7 +1,15 @@ # Supported Operating Systems -HARP has been tested on the following operating systems: +Every release of HARP is built for each platform below. -| OS | ![macOS ARM](https://img.shields.io/badge/mac%20os%20(ARM)-000000?style=for-the-badge&logo=macos&logoColor=F0F0F0) | ![macOS x86](https://img.shields.io/badge/mac%20os%20(x86)-000000?style=for-the-badge&logo=macos&logoColor=F0F0F0) | ![Windows](https://img.shields.io/badge/Windows-0078D6?style=for-the-badge&logo=windows&logoColor=white) | ![Ubuntu](https://img.shields.io/badge/Ubuntu-E95420?style=for-the-badge&logo=ubuntu&logoColor=white) | -| :-: | :-: | :-: | :-: | :-: | -| Versions | 13.0, 13.4, 14.2.1, 14.5 | 10.15 | 10, 11 | 22.04 | +| Platform | Package | Requires | +| :-- | :-- | :-- | +| macOS | Universal binary, signed and notarized | macOS 11 or newer, on Apple silicon or Intel | +| Windows | x64 executable | Windows 10 or 11 | +| Linux | x64 and arm64 archives | glibc 2.35 or newer | + +The Linux packages are built on the oldest supported Ubuntu, which sets that glibc requirement. They +run on Ubuntu 22.04, Debian 12, Fedora 36, RHEL 10, openSUSE Leap 16, and Arch, or anything newer. +At runtime HARP needs ALSA, FreeType, and X11, and loads libcurl for network requests, all of +which a desktop installation provides. Under Wayland it runs through XWayland, which GNOME and +KDE enable by default. diff --git a/website/content/usage/models.md b/website/content/usage/models.md index 54a9c7f2..423776c5 100644 --- a/website/content/usage/models.md +++ b/website/content/usage/models.md @@ -1,8 +1,6 @@ # Available Models for HARP -<!-- TODO - organize this list by task / application --> - -The following models are always available for use within HARP, and can be found in the drop-down menu at the top of the main panel: +HARP's Home tab lists every model below, along with every other Space of the [TEAMuP organization](https://huggingface.co/teamup-tech) on Hugging Face, grouped by category and searchable by name, task, or tag. Among them: - **Stable Audio (text-to-audio)** \([stability/text-to-audio](https://stableaudio.com/user-guide/text-to-audio)\) can generate music, sound effects and/or soundscapes, based on a text description. @@ -29,3 +27,9 @@ The following models are always available for use within HARP, and can be found - **Voice Cloning** \([teamup-tech/MegaTTS3-Voice-Cloning](https://huggingface.co/spaces/teamup-tech/MegaTTS3-Voice-Cloning)\) generates speech conditioned on another speech recording following a text prompt. - **Midi Synthesis** \([teamup-tech/midi-synthesizer](https://huggingface.co/spaces/teamup-tech/midi-synthesizer)\) synthesizes MIDI into audio using the standard MuseScore SoundFont. + +Each model is deployed under its original license, which HARP displays on a badge of its own beside +the model, so that the terms are apparent before the output is used. Authors who would rather their +model were not featured, or whose license we have stated incorrectly, are asked to +[open an issue](https://github.com/TEAMuP-dev/HARP/issues) so that we can correct or remove it +promptly. diff --git a/website/content/usage/workflow.md b/website/content/usage/workflow.md index 8245fa15..8198e79d 100644 --- a/website/content/usage/workflow.md +++ b/website/content/usage/workflow.md @@ -1,16 +1,42 @@ # Basic HARP Workflow -HARP supports a simple workflow: pick an existing model for processing or provide a URL to your own, load the model and it's corresponding interface, tweak controls to your liking, and process. +- Open HARP, either standalone or from a DAW. +- On the `Home` tab, browse or search the models hosted by + [TEAMuP on Hugging Face](https://huggingface.co/teamup-tech), which are grouped by function, + or choose `Custom Path...` and supply the URL of any HARP-compatible Gradio endpoint. A custom + path keeps a card on the `Home` tab until it is removed with the X button on that card. +- Select a model to open it in a tab of its own. Several models can be open at once. +- Load audio or MIDI with the folder icon of an input track, or by dragging a file onto the track. +- Set the controls, then select `Process`. Results are rendered in place as they arrive. +- Save the result with the `Save` icon, or return it to the DAW with `Send-to-DAW`. -To get started: +### Tracks -- Open HARP as a standalone application or within your DAW -- Select an existing model using the drop-down menu at the top of the screen, or select `custom path...` and provide a URL to any HARP-compatible Gradio endpoint -- Load the selected model (and its corresponding interface) using the `Load` button. -- Import audio or MIDI data to process with the model either via the `Open File` button or by dragging and dropping a file into HARP -- Adjust controls to taste in the interface -- Click `Process` to run the model; outputs will automatically be rendered in HARP -- To save your outputs, click the `Save` button or select `Save As` from the `File` menu +- Click or drag within a track to move its playback position, during playback as well. The play + icon becomes a pause icon while the track plays, and a paused track resumes where it stopped. The + stop icon ends playback and returns to the start. +- Scroll over a track to zoom in time, or hold `Shift` while scrolling to pan. On a MIDI track, + hold `Ctrl` (`Cmd` on macOS) while scrolling to zoom in pitch, or `Ctrl+Shift` to move through + pitch. +- Drag a track onto another track, into the media clipboard, or out of HARP into a DAW. +- Save an output track with its `Save` icon, or copy it with its `Copy` icon and paste it into a DAW + or a file browser. + +### Media Clipboard + +The media clipboard holds tracks independently of any one model, so a result from one model can be +carried into another as its input. Show or hide it with `View > Media Clipboard`. It also opens on +its own when a file is added with `File > Open`. + +- Add a file with the folder icon, or drag one in from a track or from elsewhere. +- Select a track to play it in the preview pane beneath the list, which has its own play, pause, and + stop icons. Drag the top edge of the pane to resize it, or minimize or close it from its title + bar. `View > Preview Pane` shows it again, as does double-clicking a track's waveform or piano roll. +- Double-click a track's name to rename it. +- Drag a track up or down to reorder the list, or onto a model's track to use it as input. +- Drag the left edge of the clipboard to change its width. +- When a DAW opened HARP with a file, the send icon overwrites that file with the selected track, + which brings the result back into the DAW project. In the example below, we use the [Demucs](https://github.com/facebookresearch/demucs) source-separation model to extract the vocals from a music recording. @@ -22,4 +48,4 @@ In the example below, we use the [Demucs](https://github.com/facebookresearch/de </video> </div> -Note that HARP is a __destructive__ editor -- if you use it to edit regions in your DAW, saving will automatically overwrite input regions. You may therefore wish to create a duplicate or "bounced" region to pass to HARP as input. \ No newline at end of file +Note that HARP is a __destructive__ editor. If you use it to edit regions in your DAW, saving overwrites the input regions, so you may wish to pass HARP a duplicate or "bounced" region as input. diff --git a/website/netlify.toml b/website/netlify.toml index 2545a8a7..7a0e77a8 100644 --- a/website/netlify.toml +++ b/website/netlify.toml @@ -3,4 +3,4 @@ publish = ".vitepress/dist" [build.environment] - NODE_VERSION = "18" \ No newline at end of file + NODE_VERSION = "18"