diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..8b170e36 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume", + "hooks": [ + { + "type": "command", + "command": "bash \"$CLAUDE_PROJECT_DIR\"/deploy/linux/cloud-session.sh start --if-cloud" + } + ] + } + ] + } +} diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..fbb18bf2 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,19 @@ +# Copilot instructions + +The instructions for AI agents in this repository are in [`AGENTS.md`](../AGENTS.md) at +the repository root, and the documentation they point to is in [`docs/`](../docs/). Read +`AGENTS.md` first and follow it; this file exists only so that tools which look here find +their way there. + +The essentials, for a context that cannot follow the link: + +- Fork of Tony (Qt6 / C++17, meson + ninja, Sonic Visualiser libraries) adding singing + practice features. The fork's own code is in `main/`; tests are in `main/test/`. +- Build from Git Bash with + `export PATH="/c/msys64/mingw64/bin:$PATH" MINGW_PREFIX="C:/msys64/mingw64"` and + `ninja -j 3 -C build_mingw Tony.exe test-tony-core.exe test-tony-app.exe`, logging to a + file under `tmp/`. From PowerShell use `.\build.bat` and `.\build.bat test`. +- `svcore/`, `svgui/`, `svapp/` and the other top-level library directories are separate, + gitignored repositories. See `docs/forks.md` before changing one. +- Match the surrounding code, keep changes in scope, and give every behaviour a test that + can fail. diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 00000000..1271d875 --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,94 @@ +name: Android CI + +on: [push, pull_request] + +# The APK, built by the scripts in deploy/android/ as the cloud container +# builds it (docs/building.md). Qt for Android and the C libraries are +# built from source there, which takes most of an hour: each is cached, +# keyed on the script that builds it and the toolchain's, and saved as +# soon as it is built, so that a later step's failure does not cost it + +env: + # The runner's own SDK and NDK are other versions; everything that + # looks for one in the environment is to find the scripts' + ANDROID_HOME: /opt/android/sdk + ANDROID_SDK_ROOT: /opt/android/sdk + ANDROID_NDK: /opt/android/sdk/ndk/27.2.12479018 + ANDROID_NDK_HOME: /opt/android/sdk/ndk/27.2.12479018 + ANDROID_NDK_ROOT: /opt/android/sdk/ndk/27.2.12479018 + ANDROID_NDK_LATEST_HOME: /opt/android/sdk/ndk/27.2.12479018 + +jobs: + build: + + runs-on: ubuntu-24.04 + + steps: + - uses: actions/checkout@v4 + with: + # build-apk.sh counts the commits for the version code + fetch-depth: 0 + # The runner's own Android SDK and .NET take disk the Qt build needs + - name: free-disk + run: | + df -h / + sudo rm -rf /usr/local/lib/android /usr/share/dotnet /opt/ghc /usr/local/.ghcup + df -h / + - name: prepare + run: | + sudo mkdir -p /opt/android + sudo chown "$(id -u):$(id -g)" /opt/android + - name: install-packages + run: | + sudo apt-get update + sudo apt-get install -y git mercurial smlnj + - name: repoint + run: ./repoint install + - name: setup-toolchain + run: deploy/android/setup-toolchain.sh + - name: restore-qt + id: qt + uses: actions/cache/restore@v4 + with: + path: /opt/android/qt + key: android-qt-${{ hashFiles('deploy/android/build-qt.sh', 'deploy/android/setup-toolchain.sh') }} + - name: build-qt + run: deploy/android/build-qt.sh + - name: save-qt + if: steps.qt.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: /opt/android/qt + key: ${{ steps.qt.outputs.cache-primary-key }} + - name: restore-deps + id: deps + uses: actions/cache/restore@v4 + with: + path: /opt/android/deps-arm64-v8a + key: android-deps-${{ hashFiles('deploy/android/build-deps.sh', 'deploy/android/setup-toolchain.sh') }} + - name: build-deps + run: deploy/android/build-deps.sh + - name: save-deps + if: steps.deps.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: /opt/android/deps-arm64-v8a + key: ${{ steps.deps.outputs.cache-primary-key }} + - name: build-tony + run: deploy/android/build-tony.sh + - name: build-apk + run: deploy/android/build-apk.sh + - name: upload-apk + uses: actions/upload-artifact@v4 + with: + name: Tony-debug-apk + path: build-android/apk/Tony-debug.apk + if-no-files-found: error + # The scripts show the end of a failing log; the whole of each is here + - name: upload-logs + if: failure() + uses: actions/upload-artifact@v4 + with: + name: android-logs + path: /opt/android/logs + if-no-files-found: ignore diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml index 9c2de2d5..3a926e02 100644 --- a/.github/workflows/linux.yml +++ b/.github/workflows/linux.yml @@ -5,21 +5,21 @@ on: [push, pull_request] jobs: build: - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v2 - name: install-packages run: | sudo apt-get update - sudo apt-get install build-essential libbz2-dev libfftw3-dev libfishsound1-dev libid3tag0-dev liblo-dev liblrdf0-dev libmad0-dev liboggz2-dev libopus-dev libopusfile-dev libpulse-dev libsamplerate-dev libsndfile-dev libsord-dev libxml2-utils portaudio19-dev qt6-base-dev qt6-pdf-dev qt6-base-dev-tools libqt6svg6-dev raptor2-utils git mercurial autoconf automake libtool smlnj capnproto libcapnp-dev ninja-build libglib2.0-dev libboost-all-dev + sudo apt-get install build-essential libbz2-dev libfftw3-dev libfishsound1-dev libid3tag0-dev liblo-dev liblrdf0-dev libmad0-dev liboggz2-dev libopus-dev libopusfile-dev libpulse-dev libsamplerate0-dev libsndfile1-dev libsord-dev libxml2-utils portaudio19-dev qt6-base-dev qt6-pdf-dev qt6-base-dev-tools qt6-svg-dev raptor2-utils git mercurial autoconf automake libtool smlnj capnproto libcapnp-dev ninja-build libglib2.0-dev libboost-all-dev - name: install-meson run: | mkdir -p tmp/meson cd tmp/meson wget https://github.com/mesonbuild/meson/releases/download/1.3.1/meson-1.3.1.tar.gz tar xvf meson-1.3.1.tar.gz - sudo ln -s $(pwd)/meson-1.3.1/meson.py /usr/bin/meson + sudo ln -sf $(pwd)/meson-1.3.1/meson.py /usr/bin/meson - name: install-rubberband run: | mkdir -p tmp/rubberband @@ -33,10 +33,21 @@ jobs: - name: repoint run: ./repoint install - name: configure - run: | - qtchooser -install qt6 $(which qmake6) - QT_SELECT=qt6 meson setup build --buildtype release + run: meson setup build --buildtype release - name: make run: ninja -C build - name: test - run: meson test -C build + id: test + run: meson test -C build --print-errorlogs --num-processes 1 + # meson shows at most the last 100 lines of a failing log, and one + # test executable runs several QTest suites: name every failed test, + # with the lines QTest indents under it (compared values, where it + # failed) and the totals of each suite that failed + - name: test-failures + if: failure() && steps.test.outcome == 'failure' + run: | + awk '/^test:/ { print; next } + /^Totals:/ { if ($0 !~ /, 0 failed/) print; next } + /^(FAIL!|XPASS|QFATAL)|Received signal/ { print; fail = 1; next } + /^[A-Z!]+ *: / { fail = 0 } + fail && /^ / { print }' build/meson-logs/testlog.txt diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml index 89f5318c..0bada740 100644 --- a/.github/workflows/macos.yml +++ b/.github/workflows/macos.yml @@ -18,12 +18,23 @@ jobs: - name: make run: ninja -C build - name: test - run: meson test -C build - - name: deploy-app + id: test + run: meson test -C build --print-errorlogs --num-processes 1 + # meson shows at most the last 100 lines of a failing log, and one + # test executable runs several QTest suites: name every failed test, + # with the lines QTest indents under it (compared values, where it + # failed) and the totals of each suite that failed + - name: test-failures + if: failure() && steps.test.outcome == 'failure' run: | - ls -lR /usr/local/opt - QTDIR=/usr/local/opt/qt6 ./deploy/macos/deploy.sh "Tony" + awk '/^test:/ { print; next } + /^Totals:/ { if ($0 !~ /, 0 failed/) print; next } + /^(FAIL!|XPASS|QFATAL)|Received signal/ { print; fail = 1; next } + /^[A-Z!]+ *: / { fail = 0 } + fail && /^ / { print }' build/meson-logs/testlog.txt + # No app bundle: the packaging scripts in deploy/osx are qmake's, + # and this build makes none. The app it does build has to start - name: check run: | - otool -L ./"Tony.app/Contents/MacOS/Tony" - ./"Tony.app/Contents/MacOS/Tony" --version + otool -L build/tony + build/tony --version diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index 50ec1d9a..6eb6171f 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -2,54 +2,100 @@ name: Windows CI on: [push, pull_request] +# Built as it is on the development machine (docs/building.md): MSYS2's +# mingw64 toolchain, Qt and libraries, with meson and ninja + jobs: build: - runs-on: windows-2019 + runs-on: windows-2022 steps: - uses: actions/checkout@v3 - uses: ProjectSavanna/setup-sml@v1.2.0 with: smlnj-version: '110.99.4' - - uses: actions/setup-python@v1 - with: - python-version: '3.x' - - uses: jurplel/install-qt-action@v3 - with: - version: '6.6.1' - host: 'windows' - target: 'desktop' - arch: 'win64_msvc2019_64' - - uses: MarkusJx/install-boost@v2.4.5 - id: install-boost - with: - boost_version: 1.84.0 - platform_version: 2019 - toolset: msvc - - uses: ilammy/msvc-dev-cmd@v1 - with: - vsversion: '2019' - - name: install-meson - run: pip install meson ninja + # repoint checks six of the libraries out with Mercurial + - name: mercurial + run: if (-not (Get-Command hg -ErrorAction SilentlyContinue)) { pip install mercurial } - name: repoint run: ./repoint install - - name: configure - run: meson setup build --buildtype release - env: - BOOST_ROOT: ${{ steps.install-boost.outputs.BOOST_ROOT }} + - uses: msys2/setup-msys2@v2 + with: + msystem: MINGW64 + update: true + install: >- + mingw-w64-x86_64-gcc + mingw-w64-x86_64-meson + mingw-w64-x86_64-ninja + mingw-w64-x86_64-pkgconf + mingw-w64-x86_64-qt6-base + mingw-w64-x86_64-qt6-svg + mingw-w64-x86_64-boost + mingw-w64-x86_64-bzip2 + mingw-w64-x86_64-zlib + mingw-w64-x86_64-fftw + mingw-w64-x86_64-libsamplerate + mingw-w64-x86_64-rubberband + mingw-w64-x86_64-libsndfile + mingw-w64-x86_64-flac + mingw-w64-x86_64-libogg + mingw-w64-x86_64-libvorbis + mingw-w64-x86_64-opus + mingw-w64-x86_64-opusfile + mingw-w64-x86_64-libmad + mingw-w64-x86_64-libid3tag + mingw-w64-x86_64-portaudio + mingw-w64-x86_64-serd + mingw-w64-x86_64-sord - - name: copy-qt-etc + # meson.build reads MINGW_PREFIX, which an MSYS2 shell sets to the + # POSIX path /mingw64: gcc wants the Windows one. Every step sets it, + # as ninja may run meson again + - name: configure + shell: msys2 {0} run: | - foreach ($lib in 'Core','Gui','Widgets','Network','Xml','Svg','Test') { copy "..\Qt\6.6.1\msvc2019_64\bin\Qt6$lib.dll" build } - cp sv-dependency-builds\win64-msvc\lib\libsndfile-1.dll build + export MINGW_PREFIX=$(cygpath -m /mingw64) + meson setup build --buildtype release - - name: build - run: ninja -C build + - name: make + shell: msys2 {0} + run: | + export MINGW_PREFIX=$(cygpath -m /mingw64) + ninja -C build + # Tony's suites also write their results to files: here QTest's + # output to stdout does not reach meson's log - name: test - run: meson test -C build - + id: test + shell: msys2 {0} + run: | + export MINGW_PREFIX=$(cygpath -m /mingw64) + mkdir -p build/test-results + export TONY_TEST_LOG_DIR=$(cygpath -m "$PWD/build/test-results") + meson test -C build --print-errorlogs --num-processes 1 + # meson shows at most the last 100 lines of a failing log, and one + # test executable runs several QTest suites: name every failed test, + # with the lines QTest indents under it (compared values, where it + # failed) and the totals of each suite that failed + - name: test-failures + if: failure() && steps.test.outcome == 'failure' + shell: msys2 {0} + run: | + awk '/^test:/ { print; next } + /^Totals:/ { if ($0 !~ /, 0 failed/) print; next } + /^(FAIL!|XPASS|QFATAL)|Received signal/ { print; fail = 1; next } + /^[A-Z!]+ *: / { fail = 0 } + fail && /^ / { print }' build/meson-logs/testlog.txt + awk 'function show(line) { + if (!shown) { print "suite: " FILENAME; shown = 1 } + print line + } + FNR == 1 { shown = 0; fail = 0 } + /^Totals:/ { if ($0 !~ /, 0 failed/) show($0); next } + /^(FAIL!|XPASS|QFATAL)/ { show($0); fail = 1; next } + /^[A-Z!]+ *: / { fail = 0 } + fail && /^ / { show($0) }' build/test-results/*.txt diff --git a/.gitignore b/.gitignore index bcc2de54..4bb61996 100644 --- a/.gitignore +++ b/.gitignore @@ -34,9 +34,12 @@ chp pyin glob:.repoint* build +build-android +build-linux-ci build_win32 build_win64 build_win64_debug +build_linux *.AppImage *.AppDir Dockerfile*.gen @@ -49,4 +52,8 @@ test-svcore-* *.dmg .notarization-uuid Dockerfile*.gen - +tmp +# Claude Code's local files (settings.local.json, worktrees); only the +# shared settings, with the cloud session's hook, are tracked +.claude/* +!.claude/settings.json diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..1a154839 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,137 @@ +# Instructions for AI agents + +This is a fork of [Tony](https://github.com/sonic-visualiser/tony) (Qt6 / C++17, meson + +ninja, built on the Sonic Visualiser libraries) that turns it into a **singing practice +aid**: a reference pitch track and a singing pitch track in one pane, live pitch dots +while recording, partial recordings, takes, undo. Development happens on Windows with +MSYS2 MinGW-w64. The default branch is `default`. + +All of the fork's own code is in `main/`. Detailed documentation is in [`docs/`](docs/); +**read the page for the area you are about to change before changing it** — the rules +there were each learned from a crash or a wrong result, and most cannot be seen in the +code. + +| Read | Before | +| --- | --- | +| [docs/building.md](docs/building.md) | building for the first time in a session | +| [docs/testing.md](docs/testing.md) | running or writing tests | +| [docs/architecture.md](docs/architecture.md) | touching layers, models, the document, commands, playback or the session file | +| [docs/recording.md](docs/recording.md) | touching `record()`, the Stop path, latency, pre-roll, the live tracker | +| [docs/takes.md](docs/takes.md) | touching takes, the audio swap, ranged analysis, undo, the coverage strip, save/restore | +| [docs/calibrate-audio.md](docs/calibrate-audio.md) | touching Calibrate Audio (`AudioCheckRunner`, `CalibrateAudioDialog`), the dev checks (`main/dev/`) or the measured latency (`LatencyCheck`, `LatencyCalibration`) | +| [docs/audio-drivers.md](docs/audio-drivers.md) | touching the Audio Driver or Audio Latency menus (`AudioDriverSettings`, `AudioDriverMenus`), how the device is opened (`MainWindow::createAudioIO()`), or the bqaudioio fork | +| [docs/forks.md](docs/forks.md) | needing a change in `svcore/`, `svgui/`, `svapp/`, `bqaudiostream/`, `bqaudioio/` | +| [docs/open-points.md](docs/open-points.md), [docs/manual-checklist.md](docs/manual-checklist.md) | choosing what to do next, or saying what the user should try by hand | +| [docs/mobile-port.md](docs/mobile-port.md), then [docs/port-android.md](docs/port-android.md) or [docs/port-sailfish.md](docs/port-sailfish.md) | starting or working on a phone port | + +## Build and test + +From **Git Bash** (the usual agent shell) on the Windows machine. `build.bat` does not run +from sh. A Linux cloud session builds otherwise: see the end of this section. + +```sh +export PATH="/c/msys64/mingw64/bin:$PATH" MINGW_PREFIX="C:/msys64/mingw64" +ninja -j 4 -C build_mingw Tony.exe test-tony-core.exe test-tony-app.exe test-tony-dev.exe > tmp/build.log 2>&1 +echo "exit:$?" >> tmp/build.log; tail -20 tmp/build.log +``` + +- Only `mingw64/bin` on PATH (never `/c/msys64/usr/bin`); always set `MINGW_PREFIX`, + spelled exactly so; always `-j 4`; always log to a file and never pipe ninja; targets + need `.exe`. The reasons are in [docs/building.md](docs/building.md). +- Incremental builds take under a minute, a few minutes after `MainWindow.cpp`; a clean + build up to 30 minutes. If `cc1plus.exe` runs out of memory, run the command again. + +Run tests from `build_mingw/` with the same environment: + +```sh +mkdir -p ../tmp/tl +TONY_TEST_LOG_DIR=../tmp/tl ./test-tony-core.exe > ../tmp/test.log 2>&1; echo "exit:$?" +TONY_TEST_LOG_DIR=../tmp/tl ./test-tony-app.exe > ../tmp/test.log 2>&1; echo "exit:$?" # ~12 min, real time +TONY_TEST_LOG_DIR=../tmp/tl ./test-tony-app.exe undo_two_takes_in_order > ../tmp/test.log 2>&1 +grep -a "^FAIL\|^ Loc\|^Totals" ../tmp/tl/*.txt +``` + +- Read results from the per-suite files in `TONY_TEST_LOG_DIR`, not from stdout. +- A test name on the command line goes to **every** suite in the executable; the suites + that lack it fail, so the exit status is only meaningful for a run with no names. +- Run named tests while working; run **both whole suites** before calling anything done. +- `test-tony-dev.exe` (development builds only) holds the development checks' suite, + about seven minutes of real-time takes. Run it as well, whole, when a change touches + the take path (`record()`, Stop, latency, pre-roll), `AudioCheckRunner`, + `CalibrateAudioDialog` or `main/dev/`; "both whole suites" then means all three. +- From PowerShell or cmd, `.\build.bat test` runs everything through `meson test`. +- Give the app suite a tool timeout of 20 minutes, or run it in the background. + +In a **Linux cloud session** the commands are others. The session's hook has started a +build into `build/` in the background; run `deploy/linux/cloud-session.sh wait` before the +first build or test (if it says none was started, run `deploy/linux/cloud-session.sh start` +first). The rest is in [docs/building.md](docs/building.md#building-on-linux). + +## Rules for working here + +### Scope + +- Build what was asked. Where the request is silent, take the simpler option and say so. + No drive-by refactors. If the plan turns out wrong or impossible, do not improvise + another design: finish what can be finished, leave the tree building and green, report. +- `main/MainWindow.cpp` and `main/test/TestRecordWorkflow.h` are each over 9000 lines. + Never read them whole: search, then read a range. +- `svcore/`, `svgui/`, `svapp/`, `pyin/` and the other top-level library directories are + **separate git repositories, gitignored here**, so ripgrep-based search tools skip them + unless given the directory explicitly. Five are forks that may be changed + ([docs/forks.md](docs/forks.md)); the rest are upstream and stay untouched. A sub-agent + does not edit a fork: it reports the change it needs. + +### Code + +- Match the surrounding code: naming, comment density, idiom. Comments say *why*, in plain + words. Every source file starts with the project's GPL header. +- State gets a class and files of its own; `MainWindow` only wires it + (`AlternatePitchTrack`, `CoverageStrip`, `TakeLayers`, `TakeCommands` are the pattern). +- Logic that needs no window goes in `tony_core` as pure functions or plain structs + (`TakeTiming`, `TakeEvents`), where it is cheap to test. +- The handful of rules most often broken — each explained in + [docs/architecture.md](docs/architecture.md): + - Tear a layer down with `m_document->deleteLayer(layer, true)` and nothing else. Never + `removeLayerFromView()` first; never `ModelById::release()` a model the document knows. + - Add Tony's own layers with `Document::attachLayerToView()`, never `addLayerToView()`. + - Never push a command, or call `openPath()`, from anything reachable from an undo or + redo. Commands hold values, never layer or model pointers. + - Remove a pane only with `pruneExtraPane()`, and only after another layer holds the model. + - Temporary mute/hide goes straight to the play parameters / `showLayer()`; + `Analyser::setAudible()` / `setVisible()` write settings shared by both analysers. + - Use member-pointer `connect`; string-based connects with `sv::` types fail silently. + - Stop `RealtimePitchTracker` before releasing the model it reads. All model writes + happen on the GUI thread. + +### Tests + +- Every behaviour gets a test **that can fail**. For the ones that matter, prove it: break + the code briefly, see the test fail, put the code back, and say which you checked. +- Never weaken or delete a test to get green. If the behaviour was meant to change, change + the test and say so. +- App tests run in real time against a fake audio device: keep them short. + +### Git + +- Commit only when asked, and only with both whole suites green. One commit per coherent + step, staged by file name (never `git add -A`; `tmp/` and all of `.claude/` but + `settings.json` stay out). +- Messages: `feat:` / `fix:` / `test:` / `docs:`, lower case, then a short what-and-why + body. End with a `Co-Authored-By` trailer naming the model that wrote the code. +- Use the `gh` CLI for anything on GitHub. + +### Reporting + +Say what was built, by file; copy the `Totals` lines of the final full test runs verbatim; +say which tests were seen to fail; list deviations and anything fragile or unfinished. A +problem reported is cheap, one found later is not. If a change needs a real device or real +eyes to judge, name the items of [docs/manual-checklist.md](docs/manual-checklist.md) the +user should try. + +### Keeping the docs true + +`docs/` records what the code cannot say: why, in what order, and what must not be done. +When a change makes a statement there false, fix it in the same commit. Do not add +narratives of fixed bugs, lists of members or methods, or commit hashes — the code and +`git log` have those. `tmp/` is gitignored scratch space for logs and working notes. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..43c994c2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/README.md b/README.md index f8352299..6768cd9c 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,185 @@ +Tony for singing practice +========================= + +A singing practice aid: record yourself over a reference song and see your +pitch next to the singer's, phrase by phrase, with takes and timed lyrics. + +This is a fork of [Tony](https://github.com/sonic-visualiser/tony), the +melody transcription program from Queen Mary, University of London. It keeps +Tony's pitch and note analysis (the pYIN plugin) and builds a practice tool +around it. It is developed separately and is not a release of Tony: the Tony +home page and its downloads are for the original program, not this one. + +It runs on Windows, Linux and macOS, and on Android phones. There are no +releases of this fork yet: the desktop version is built from source (see +[Building](#building) below), and the Android one comes as a test build (see +[On Android](#on-android)). + + +How it works +------------ + +Open a recording of the song with File -> Open. The pitch tracker follows one +voice, so the reference works best as the vocal on its own, such as a stem +separated from the mix. Tony analyses it and draws its pitch as a black line. + +In the toolbar, turn on the speaker button after "While recording:", which +plays the reference while you record. Put on headphones (a microphone that +hears the reference picks up its pitch as well as yours), then press Record +(Ctrl+Space) and sing along. Your pitch appears as orange dots as you sing. +When you press Stop, the recording is analysed in full and becomes an orange +pitch track in the same pane as the reference's, so you can see where you were +sharp, flat, early or late. + + +Recording +--------- + + * record from the playback position, not only from the start of the song, so + you can practise one phrase without singing everything before it + * an optional pre-roll: the reference starts a few seconds early and the + status bar counts you in, and nothing sung during the lead-in is kept + * an optional "record into the selection only": select the phrase, and the + recording starts and stops at the ends of the selection by itself. This + and the pre-roll are buttons in the toolbar, after "While recording:" + * the microphone and the output are chosen in Playback -> Audio Input Device + and Playback -> Audio Output Device + * on Windows, Playback -> Audio Driver chooses MME, DirectSound or WASAPI, and + Playback -> Audio Latency how much latency is asked of it (10 to 200 ms); + each driver keeps its own devices, latency and measured round trip. WASAPI + at 20 ms is the default + * the latency of the audio devices is taken off the start of each recording, + so that it lines up with the reference. Drivers often report that latency + wrongly: Playback -> Calibrate Audio... measures it, with one earcup of + wired headphones held against the microphone, and "Use this latency" places + every take with the measured figure. Playback -> Forget Measured Latency + goes back to the figure the driver reports + * the audio device is kept open between takes, so that the latency stays the + same from one take to the next. The microphone therefore shows as in use + from the first take until Tony quits + * File -> Load Singing Track... (Ctrl+Shift+R) loads a recording made + elsewhere as the singing track instead + + +Takes and editing +----------------- + + * a take is one recording or many. A strip along the bottom of the pane shows + where there is singing and where there is not; recording over a part of it + replaces just that part, and only the part that changed is analysed again + * Edit -> Select Recording at Playhead and Edit -> Erase Singing in Selection + (Ctrl+D) remove, trim or split what has been recorded. A range is selected by + dragging in the thin ruler strip below the pane + * recordings and erases can be undone and redone + * several takes of a song in one session (the Takes menu and the "Take:" box in + the toolbar): each keeps its own audio, pitch track and notes, and switching + between them needs no re-analysis. The audio of a session's takes is kept in + a folder named after the session beside it, so the two can be moved together + + +Following the reference +----------------------- -Tony -==== - -Tony is a program for computer-aided melody annotation. It has a -graphical interface based on the Sonic Visualiser libraries, and uses -the pYIN Vamp plugin to extract pitch track and notes from monophonic -audio. - -![Tony small screenshot](https://code.soundsoftware.ac.uk/attachments/download/1069/tony-screeny-20140328-30pc.png) - -Home page and downloads: https://code.soundsoftware.ac.uk/projects/tony - - -Features --------- + * an alternate pitch track: a copy of the reference's pitch track moved by + one to three octaves, for a song written for a higher or lower voice than + yours. Turn it on with the button after "Follow:" in the toolbar and move + it with the 8vb and 8va buttons. While it is on, the reference's own track + is hidden as you record, so that the one you follow is the one on show + * background music: File -> Load Background Music... loads a second audio + file, such as the accompaniment or the full mix, that plays along with the + reference, while recording as well, but is never analysed. The speaker + button after "Background:" in the toolbar turns it on and off. The session + remembers it, with its level and pan and whether it was on + + +Timed lyrics +------------ + + * File -> Import Lyrics... reads a TTML or LRC file, timed by word or by line, + and shows the words in boxes along the bottom of the pane, at the time and + for as long as each is sung. The word at the playback position is + highlighted, while playing, while recording and wherever you click, and the + waveform is faded while the words are on show so that they can be read over + it. They are saved with the session; View -> Show Lyrics hides them, File -> + Remove Lyrics takes them out, and File -> Export Lyrics... writes them, as + they are now, to a TTML file. The reference must be the recording the lyrics + were timed to (a Moises stem and its original mix share a timeline) + * the lyrics can be corrected by ear, with the song there to listen to: with + Edit -> Edit Lyrics on, drag the start or end of a word in its box, + double-click a word to change its text, or right-click to delete a word or + add one between words. Lyrics that are all early or late move together: + Shift-drag in the row, or Edit -> Shift Lyrics... by a number of seconds. + Each edit can be undone; the edits are saved with the session, and Export + Lyrics writes them + * lyrics can be exported from Moises with the Moises-Lyric-Exporter browser + extension. Set it to TTML, word by word, with the offset at 0 (otherwise + every line is 0.2 s early, and Tony cannot tell): TTML gives every word + its end as well as its start, which LRC cannot. Its gap threshold only + matters for LRC. It is unofficial and not made by Moises: install it + unpacked from a commit that has been reviewed, and do not update it without + reviewing the change + + +Sizes and layout +---------------- + + * View -> Plot Size draws the pitch tracks, the live dots and the notes at + 100, 150 or 200 % of their normal size, and View -> Lyrics Size the words of + the lyrics at 35 to 100 % + * View -> Compact Layout puts one toolbar of large buttons in place of the + menu bar and the other toolbars, as on a phone (see below). Tony starts + with it when given --compact on the command line + + +On Android +---------- + +Tony also runs on Android phones (Android 9 or later, 64-bit ARM), laid out +for a phone held in landscape. On the phone it is for practising: open a +session, choose a take, select a phrase, record, listen, erase and undo. +Editing notes and the reference is left to the desktop, and their tools are +hidden. + + * there is no release yet. Each run of the Android CI workflow (under + Actions) builds a test version and keeps it as its Tony-debug-apk + artifact, which GitHub lets you download once signed in. Unzip it and open + Tony-debug.apk on the phone, allowing the app you open it with to install + unknown apps + * one toolbar of large buttons replaces the menus and the other toolbars: a + menu button that holds every menu, Play, Record and Record into Selection, + the take box, Undo and Redo, Erase, Zoom In and Zoom Out, and a button + that shows and hides the Show and Play controls. A thin strip above the + pane shows the whole song, with a faint line of the reference's pitch: + drag it, or tap it, to move the view along the song + * one finger works as the mouse does: a tap moves the playhead, and a drag + in the ruler strip below the pane selects. Pinch to zoom, across the pane + for time and up and down it for the pitch range; drag with two fingers to + scroll; press and hold for the pane's menu + * with All files access (Android 11 and later; Tony asks the first time it + needs it) Tony opens and saves files where they are, as on the desktop. + Keep sessions, with their audio and takes folder beside them, in a folder + of the phone's storage, such as one a sync app (Syncthing, FolderSync) + keeps in step with a computer. Without it, Tony copies audio into its own + storage and cannot open a session kept elsewhere. Nor can a session be + opened from a cloud app such as Drive, which hands Tony the one file only + * M4A (AAC), FLAC and Ogg files are read through Android's own decoders + * Tony plays and records through whatever the phone is using: its speaker and + microphone, a wired or USB headset, or Bluetooth. Calibrate Audio keeps a + figure for each, since a Bluetooth route is 100 to 200 ms longer than the + speaker's. The screen stays on while the check runs + * when Tony goes into the background, a take being recorded is stopped and + kept, as Stop does, and a session that already has a file is saved. The + audio device is let go then, and after a while idle + * pitch tracks and notes are drawn at 150 % and lyrics at 50 % by default + * Help -> Save Log... saves a copy of Tony's log, to send when something has + gone wrong + + +From Tony +--------- + +Everything the original program does is still here, for the reference and the +singing alike: * robust monophonic pitch track extraction (using pYIN) * note track extraction @@ -23,6 +189,20 @@ Features * import/export of pitch track and note track +Building +-------- + +The fork is developed on Windows with MSYS2's MinGW-w64 toolchain, Qt 6, +meson and ninja; it also builds on Linux and macOS. How to build, and every way +the build has gone wrong, is in [docs/building.md](docs/building.md). The rest +of [docs/](docs/) is the developer documentation. + +The Android app is cross-compiled on Linux by the scripts in +[deploy/android/](deploy/android/): `setup-toolchain.sh`, `build-qt.sh`, +`build-deps.sh`, `build-tony.sh` and `build-apk.sh`, in that order, as the +Android CI workflow runs them. + + Authors, Citation, License and Use ---------------------------------- @@ -34,6 +214,9 @@ London, and the Tony project authors: Matthias Mauch, George Fazekas, Justin Salamon, and Rachel Bittner, except where indicated in the individual source files. Thanks also to Simon Dixon and Juan Bello. +The singing practice features were added in this fork, under the same +license. + If you make use of this software for any public or commercial purpose, we ask you to kindly mention the authors and Queen Mary, University of London in your user-visible documentation. We're very happy to see @@ -41,8 +224,7 @@ this sort of use but would much appreciate being credited, separately from the requirements of the software license itself (see below). If you make use of this software for academic purposes, please cite -one of the publications indicated on the Publications page: -https://code.soundsoftware.ac.uk/publications?project_id=tony +the Tony paper given in the [CITATION](CITATION) file. This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by @@ -51,7 +233,7 @@ your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A ARTICULAR PURPOSE. See the GNU +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see http://www.gnu.org/licenses/. @@ -60,7 +242,7 @@ not, see http://www.gnu.org/licenses/. Automated build reports ----------------------- - * Linux CI build: [![Build Status](https://github.com/sonic-visualiser/tony/workflows/Linux%20CI/badge.svg)](https://github.com/sonic-visualiser/tony/actions?query=workflow%3A%22Linux+CI%22) - * macOS CI build: [![Build Status](https://github.com/sonic-visualiser/tony/workflows/macOS%20CI/badge.svg)](https://github.com/sonic-visualiser/tony/actions?query=workflow%3A%22macOS+CI%22) - * Windows CI build: [![Build Status](https://github.com/sonic-visualiser/tony/workflows/Windows%20CI/badge.svg)](https://github.com/sonic-visualiser/tony/actions?query=workflow%3A%22Windows+CI%22) - + * Linux CI build: [![Build Status](https://github.com/jhhr/tony/actions/workflows/linux.yml/badge.svg)](https://github.com/jhhr/tony/actions/workflows/linux.yml) + * macOS CI build: [![Build Status](https://github.com/jhhr/tony/actions/workflows/macos.yml/badge.svg)](https://github.com/jhhr/tony/actions/workflows/macos.yml) + * Windows CI build: [![Build Status](https://github.com/jhhr/tony/actions/workflows/windows.yml/badge.svg)](https://github.com/jhhr/tony/actions/workflows/windows.yml) + * Android CI build: [![Build Status](https://github.com/jhhr/tony/actions/workflows/android.yml/badge.svg)](https://github.com/jhhr/tony/actions/workflows/android.yml) diff --git a/build.bat b/build.bat new file mode 100644 index 00000000..afbe0884 --- /dev/null +++ b/build.bat @@ -0,0 +1,114 @@ +@echo off +setlocal + +set MSYS2_MINGW=%MSYS2_MINGW% +if "%MSYS2_MINGW%"=="" set MSYS2_MINGW=C:\msys64\mingw64 + +if not exist "%MSYS2_MINGW%\bin\ninja.exe" ( + echo ERROR: ninja.exe not found under %MSYS2_MINGW%\bin + echo Set MSYS2_MINGW to your mingw64 prefix if it is not C:\msys64\mingw64 + exit /b 1 +) + +set PATH=%MSYS2_MINGW%\bin;%MSYS2_MINGW%\..\usr\bin;%PATH% + +:: ── Parse arguments ────────────────────────────────────────────────────────── +:: build.bat → build only +:: build.bat run → build then launch Tony.exe +:: build.bat launch → launch Tony.exe without building +:: build.bat test → build and run the automated tests +:: build.bat clean → wipe build_mingw and reconfigure +:: build.bat help → print this help +:: ───────────────────────────────────────────────────────────────────────────── + +set BUILD_DIR=%~dp0build_mingw +set ACTION=%1 +if "%ACTION%"=="" set ACTION=build + +if /i "%ACTION%"=="help" goto :help +if /i "%ACTION%"=="clean" goto :clean +if /i "%ACTION%"=="launch" goto :launch +if /i "%ACTION%"=="run" goto :build +if /i "%ACTION%"=="build" goto :build +if /i "%ACTION%"=="test" goto :test + +echo ERROR: Unknown action "%ACTION%". Run "build.bat help" for usage. +exit /b 1 + +:: ── clean ──────────────────────────────────────────────────────────────────── +:clean +echo Removing %BUILD_DIR% ... +if exist "%BUILD_DIR%" rmdir /s /q "%BUILD_DIR%" +echo Reconfiguring with meson ... +meson setup "%BUILD_DIR%" --buildtype=debugoptimized +if errorlevel 1 exit /b %errorlevel% +echo Done. Run build.bat to compile. +exit /b 0 + +:: ── build ──────────────────────────────────────────────────────────────────── +:build +if not exist "%BUILD_DIR%\build.ninja" ( + echo Build directory not configured. Running meson setup ... + meson setup "%BUILD_DIR%" --buildtype=debugoptimized + if errorlevel 1 exit /b %errorlevel% +) + +echo Building Tony.exe ... +ninja -C "%BUILD_DIR%" Tony.exe +if errorlevel 1 ( + echo. + echo BUILD FAILED. See errors above. + exit /b %errorlevel% +) + +echo. +echo Build succeeded: %BUILD_DIR%\Tony.exe +if /i "%ACTION%"=="run" goto :launch +exit /b 0 + +:: ── test ───────────────────────────────────────────────────────────────────── +:test +if not exist "%BUILD_DIR%\build.ninja" ( + echo Build directory not configured. Running meson setup ... + meson setup "%BUILD_DIR%" --buildtype=debugoptimized + if errorlevel 1 exit /b %errorlevel% +) + +echo Building and running tests ... +meson test -C "%BUILD_DIR%" --print-errorlogs +exit /b %errorlevel% + +:: ── launch ─────────────────────────────────────────────────────────────────── +:launch +set EXE=%BUILD_DIR%\Tony.exe +if not exist "%EXE%" ( + echo ERROR: %EXE% not found. Run "build.bat" first. + exit /b 1 +) +echo Launching %EXE% ... +start "" "%EXE%" +exit /b 0 + +:: ── help ───────────────────────────────────────────────────────────────────── +:help +echo. +echo build.bat [action] +echo. +echo Actions: +echo (none) Build Tony.exe (default) +echo run Build Tony.exe then launch it +echo launch Launch Tony.exe without rebuilding +echo test Build and run the automated tests (meson test) +echo clean Delete the build directory and reconfigure from scratch +echo help Show this message +echo. +echo Environment: +echo MSYS2_MINGW Path to the mingw64 prefix (default: C:\msys64\mingw64) +echo The bin\ subdirectory must contain ninja.exe, gcc.exe etc. +echo. +echo The script prepends %%MSYS2_MINGW%%\bin and the msys2 usr\bin to PATH +echo before invoking ninja, so moc.exe and the GCC runtime DLLs are found +echo automatically regardless of which shell (PowerShell, cmd, git bash) you +echo call this from. +echo. +exit /b 0 diff --git a/deploy/android/build-apk.sh b/deploy/android/build-apk.sh new file mode 100755 index 00000000..cf79970b --- /dev/null +++ b/deploy/android/build-apk.sh @@ -0,0 +1,301 @@ +#!/bin/bash +# +# Tony +# An intonation analysis and annotation tool +# Centre for Digital Music, Queen Mary, University of London. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; either version 2 of the +# License, or (at your option) any later version. See the file +# COPYING included with this distribution for more information. +# +# Packages what deploy/android/build-tony.sh built into an APK signed +# with the debug key, which can be installed on a phone by hand: +# +# build-android/apk/Tony-debug.apk +# +# androiddeployqt reads what to package from a deployment settings file, +# which Qt's CMake support writes for a CMake build; this writes it for +# the meson one (build-android/android-Tony-deployment-settings.json). +# androiddeployqt then adds Qt's libraries and plugins and the NDK's C++ +# library to build-android/android-build/, a Gradle project, and runs +# Gradle, which strips the libraries (it knows the NDK's version) and +# signs the APK with ~/.android/debug.keystore, made on its first run. +# +# The manifest is deploy/android/package/AndroidManifest.xml; the icon is +# added from icons/. +# +# The Vamp plugins go in as libpyin.so and libchp.so, beside the +# application's library in lib/arm64-v8a/: Android installs nothing else +# from an APK. They are put there directly rather than named as +# android-extra-libs, which Qt's launcher would load at every start. The +# libraries are packaged the legacy way, compressed and unpacked when the +# app is installed, so that they exist as files for svcore's scan to open; +# main.cpp links the plugins under their own names (see AndroidFiles). +# +# Gradle fetches the Android Gradle plugin and its dependencies from +# Google's repository and Maven Central on its first run, and Maven +# Central has answered 429 Too Many Requests: a Gradle run that fails for +# want of the network is tried again, three times at most. The log is +# /opt/android/logs/tony-apk.log. It ends by checking the APK. +# +# Usage, from anywhere: +# deploy/android/build-apk.sh + +set -eu -o pipefail + +if [ "$#" -ne 0 ]; then + echo "Usage: $0" 1>&2 + exit 2 +fi + +android=/opt/android +sdk=$android/sdk +ndk=$sdk/ndk/27.2.12479018 +build_tools=$sdk/build-tools/36.0.0 +platform=android-36 +qt=$android/qt/6.11.2 +qt_android=$qt/android_arm64_v8a +qt_host=$qt/gcc_64 +logs=$android/logs +abi=arm64-v8a + +repo=$(cd "$(dirname "$0")/../.." && pwd) +build=$repo/build-android +out=$build/android-build +package=$build/android-package +settings=$build/android-Tony-deployment-settings.json +apk_dir=$build/apk +apk=$apk_dir/Tony-debug.apk + +llvm=$ndk/toolchains/llvm/prebuilt/linux-x86_64/bin + +export JAVA_HOME=/usr/lib/jvm/java-21-openjdk-$(dpkg --print-architecture) + +# Qt's tools warn at every start in the container's C locale +export LC_ALL=C.UTF-8 + +if [ ! -x "$JAVA_HOME/bin/java" ] || [ ! -d "$build_tools" ] || + [ ! -d "$sdk/platforms/$platform" ] || [ ! -f "$ndk/source.properties" ]; then + echo "ERROR: no JDK 21, SDK or NDK: run deploy/android/setup-toolchain.sh first" 1>&2 + exit 1 +fi +if [ ! -x "$qt_host/bin/androiddeployqt" ] || [ ! -d "$qt_android/src/android/templates" ]; then + echo "ERROR: no Qt for Android at $qt: run deploy/android/build-qt.sh first" 1>&2 + exit 1 +fi +for f in libTony_arm64-v8a.so pyin.so chp.so version.h; do + if [ ! -f "$build/$f" ]; then + echo "ERROR: no build-android/$f: run deploy/android/build-tony.sh first" 1>&2 + exit 1 + fi +done + +mkdir -p "$logs" + +# 1. Gather the libraries, the manifest and the icon + +echo "Gathering the libraries" + +# Emptied first, so that nothing from an earlier run is packaged by +# mistake; androiddeployqt puts Qt's libraries back +rm -rf "$out/libs/$abi" +mkdir -p "$out/libs/$abi" +cp "$build/libTony_arm64-v8a.so" "$out/libs/$abi/" +cp "$build/pyin.so" "$out/libs/$abi/libpyin.so" +cp "$build/chp.so" "$out/libs/$abi/libchp.so" + +# Stripped here as Gradle strips them, so that the APK is small whatever +# Gradle does (Qt's libraries come without debug information). Those in +# build-android/ keep theirs, for symbolising a crash with the NDK's +# ndk-stack +for so in libTony_arm64-v8a.so libpyin.so libchp.so; do + "$llvm/llvm-strip" --strip-unneeded "$out/libs/$abi/$so" +done + +rm -rf "$package" +cp -r "$repo/deploy/android/package" "$package" +# 128 pixels is about the 48 dp of a launcher icon at xxhdpi (3x) +mkdir -p "$package/res/drawable-xxhdpi" +cp "$repo/icons/tony-128x128.png" "$package/res/drawable-xxhdpi/icon.png" + +# 2. The deployment settings + +# The version name says which commit this is, with a + if the working +# tree had changes; the version code grows with each commit, so that a +# newer build installs over an older one +version=$(sed -n 's/^#define TONY_VERSION "\(.*\)"$/\1/p' "$build/version.h") +commit=$(git -C "$repo" rev-parse --short HEAD) +if ! git -C "$repo" diff --quiet HEAD --; then + commit="$commit+" +fi +version_name="$version ($commit)" +version_code=$(git -C "$repo" rev-list --count HEAD) + +# The Qt plugins are those Qt's CMake support chose for an application +# using the same Qt modules (the model in $logs): without the list, +# androiddeployqt takes every plugin of each module, test platforms and +# all +plugins= +for p in platforms/libplugins_platforms_qtforandroid \ + styles/libplugins_styles_qandroidstyle \ + imageformats/libplugins_imageformats_qgif \ + imageformats/libplugins_imageformats_qico \ + imageformats/libplugins_imageformats_qjpeg \ + imageformats/libplugins_imageformats_qsvg \ + iconengines/libplugins_iconengines_qsvgicon \ + networkinformation/libplugins_networkinformation_qandroidnetworkinformation \ + tls/libplugins_tls_qcertonlybackend; do + f=$qt_android/plugins/${p}_$abi.so + if [ ! -f "$f" ]; then + echo "ERROR: no Qt plugin $f" 1>&2 + exit 1 + fi + plugins="$plugins${plugins:+;}$f" +done + +cat > "$settings" <> "$log" + if "$qt_host/bin/androiddeployqt" \ + --input "$settings" --output "$out" \ + --android-platform "$platform" --jdk "$JAVA_HOME" \ + --apk "$apk" --verbose >> "$log" 2>&1; then + break + fi + this_attempt=$(sed -n "/^=== attempt $attempt\$/,\$p" "$log") + if [ "$attempt" -lt 3 ] && + echo "$this_attempt" | + grep -qE "status code 429|Too Many Requests|Connection reset|timed out"; then + echo " Gradle was turned away for now; trying again in $attempt minute(s)" + sleep $((60 * attempt)) + attempt=$((attempt + 1)) + continue + fi + echo "$this_attempt" | grep -E "error|FAILED|What went wrong|Could not GET" | + sort -u | tail -20 1>&2 || true + refused=$(echo "$this_attempt" | grep "status code 403" | + grep -o "https://[^/']*" | sort -u | tr '\n' ' ' || true) + if [ -n "$refused" ]; then + echo "ERROR: refused with 403 (is the host allowed on this network?): $refused" 1>&2 + fi + echo "ERROR: packaging failed; the whole log is $log" 1>&2 + exit 1 +done + +# 4. Check + +echo +echo "Checking $apk" + +listing=$(unzip -Z1 "$apk") +for f in lib/$abi/libTony_arm64-v8a.so lib/$abi/libpyin.so lib/$abi/libchp.so \ + lib/$abi/libc++_shared.so lib/$abi/libQt6Core_arm64-v8a.so \ + lib/$abi/libplugins_platforms_qtforandroid_arm64-v8a.so; do + if ! echo "$listing" | grep -qxF "$f"; then + echo "ERROR: the APK has no $f" 1>&2 + exit 1 + fi +done +echo " it holds $(echo "$listing" | grep -c "^lib/$abi/.*\.so$") libraries in lib/$abi/" + +# Stripped, and each loading nothing that is neither in the APK nor one +# of Android's own libraries +unpacked=$(mktemp -d) +trap 'rm -rf "$unpacked"' EXIT +unzip -q "$apk" "lib/$abi/*" -d "$unpacked" +system_libraries='lib(c|m|dl|log|z|android|jnigraphics|EGL|GLESv2|GLESv3|vulkan|mediandk)\.so' +for so in "$unpacked/lib/$abi/"*.so; do + name=$(basename "$so") + if "$llvm/llvm-readelf" -SW "$so" | grep -q "\.debug_info"; then + echo "ERROR: $name in the APK is not stripped" 1>&2 + exit 1 + fi + for needed in $("$llvm/llvm-readelf" -d "$so" | sed -n 's/.*(NEEDED).*\[\(.*\)\]/\1/p'); do + if [ ! -f "$unpacked/lib/$abi/$needed" ] && ! echo "$needed" | grep -qxE "$system_libraries"; then + echo "ERROR: $name loads $needed, which is neither in the APK nor Android's" 1>&2 + exit 1 + fi + done +done +echo " they are stripped, and load nothing but each other and Android's libraries" + +"$build_tools/apksigner" verify "$apk" +echo " apksigner: verified" +"$build_tools/zipalign" -c -P 16 4 "$apk" +echo " zipalign: aligned for 16 KB pages" + +badging=$("$build_tools/aapt2" dump badging "$apk") +echo "$badging" | grep -E "^package:|^application-label:|^sdkVersion:|^targetSdkVersion:|^uses-permission:" | sed 's/^/ /' +for permission in RECORD_AUDIO MANAGE_EXTERNAL_STORAGE; do + if ! echo "$badging" | grep -q "name='android.permission.$permission'"; then + echo "ERROR: the APK does not ask for $permission" 1>&2 + exit 1 + fi +done +manifest=$("$build_tools/aapt2" dump xmltree --file AndroidManifest.xml "$apk") +if ! echo "$manifest" | grep -q 'screenOrientation.*=6'; then + echo "ERROR: the activity is not sensorLandscape" 1>&2 + exit 1 +fi +if echo "$manifest" | grep -q 'extractNativeLibs.*=false'; then + echo "ERROR: the libraries would not be unpacked on install" 1>&2 + exit 1 +fi +echo " landscape, and the libraries are unpacked on install" + +cat </share/tony-deps +# names the version below is left alone; delete the stamp to build it +# again. The cross file is written every time. It ends by building a +# small shared library that uses every library, found through +# pkg-config with the cross file as Tony's build will, and checking +# that it is an arm64 shared object with none of them left to load. +# +# Usage, from anywhere: +# deploy/android/build-deps.sh + +set -eu -o pipefail + +if [ "$#" -ne 0 ]; then + echo "Usage: $0" 1>&2 + exit 2 +fi + +android=/opt/android +ndk=$android/sdk/ndk/27.2.12479018 +logs=$android/logs + +abi=arm64-v8a +api=28 +triple=aarch64-linux-android +prefix=$android/deps-$abi +cross=$android/cross-$abi.ini + +toolchain=$ndk/toolchains/llvm/prebuilt/linux-x86_64 +cc=$toolchain/bin/$triple$api-clang +cxx=$toolchain/bin/$triple$api-clang++ + +jobs=$(nproc) + +ubuntu=https://archive.ubuntu.com/ubuntu/pool +github=https://github.com + +if [ ! -x "$cc" ]; then + echo "ERROR: no NDK at $ndk: run deploy/android/setup-toolchain.sh first" 1>&2 + exit 1 +fi + +mkdir -p "$logs" "$prefix/share/tony-deps" +work=$(mktemp -d "$android/tmp.XXXXXX") +trap 'rm -rf "$work"' EXIT + +# 1. The cross file. c_args and link args apply to what is built for +# Android. The page size is for Android 15's 16 KB pages, which NDK r27 +# does not set by itself. There is no sys_root: meson would prefix it +# to every path in the prefix's .pc files. boost_root keeps meson's +# Boost lookup out of the build machine's /usr. + +cat > "$cross" < "$prefix/lib/pkgconfig/bzip2.pc" <&2 + exit 1 + fi + meson_build "$work/rubberband" -Dfft=builtin -Dresampler=builtin \ + -Djni=disabled -Dladspa=disabled -Dlv2=disabled -Dvamp=disabled \ + -Dcmdline=disabled -Dtests=disabled +} + +build_boost() { + # Ubuntu 24.04's Boost, as the desktop build. The tarball is Boost's + # modular layout, each library's headers under libs//include + # (numeric/ for four): merged here as Boost's "b2 headers" + # would. Tony uses only headers. + local archive=$work/boost.tar.xz + curl -sSfL --retry 5 --retry-all-errors -o "$archive" \ + "$ubuntu/main/b/boost1.83/boost1.83_1.83.0.orig.tar.xz" + echo "404df4b4072fc7f2d4483d4fc2d61ff6f554dd80c9a812652684d5952e881c91 $archive" | + sha256sum -c --quiet + tar xf "$archive" -C "$work" --wildcards 'boost/libs/*/include/boost/*' + rm "$archive" + rm -rf "$prefix/include/boost" + mkdir -p "$prefix/include/boost" + local include + for include in "$work"/boost/libs/*/include "$work"/boost/libs/numeric/*/include; do + cp -R "$include/boost/." "$prefix/include/boost/" + done + grep -q '^#define BOOST_LIB_VERSION "1_83"' "$prefix/include/boost/version.hpp" +} + +build_oboe() { + # The newest 1.x release. Oboe opens libaaudio.so and libOpenSLES.so + # with dlopen() and defines OpenSL ES's interface IDs itself, so it + # links against liblog alone. Its CMake install puts the library in + # lib//, moved here to lib/ beside the others; it writes no .pc + # file. + git clone -q -c advice.detachedHead=false --depth 1 --branch 1.11.0 \ + "$github/google/oboe.git" "$work/oboe" + if [ "$(git -C "$work/oboe" rev-parse HEAD)" != b115f47593969fd67a21e9f63640ffef749b5067 ]; then + echo "ERROR: tag 1.11.0 of oboe is not at the expected commit" 1>&2 + exit 1 + fi + rm -rf "$prefix/include/oboe" "$prefix/lib/$abi" + cmake_build "$work/oboe" + mv "$prefix/lib/$abi/liboboe.a" "$prefix/lib/liboboe.a" + rmdir "$prefix/lib/$abi" + cat > "$prefix/lib/pkgconfig/oboe.pc" < "$log" 2>&1 + local status=$? + set -e + if [ "$status" -ne 0 ]; then + tail -30 "$log" 1>&2 + echo "ERROR: $name failed; the whole log is $log" 1>&2 + exit 1 + fi + rm -rf "${work:?}"/* + echo "$version" > "$stamp" +} + +echo +echo "Libraries in $prefix:" +install_lib bzip2 1.0.8 +install_lib ogg 1.3.5 +install_lib opus 1.5.2 +install_lib opusfile 0.12 +install_lib sndfile 1.2.2 +install_lib samplerate 0.2.2 +install_lib fftw3 3.3.10 +install_lib mad 0.16.4 +install_lib id3tag 0.16.3 +install_lib zix 0.4.2 +install_lib serd 0.32.2 +install_lib sord 0.16.16 +install_lib rubberband 3.3.0 +install_lib boost 1.83.0 +install_lib oboe 1.11.0 + +shared=$(find "$prefix/lib" -name "*.so*") +if [ -n "$shared" ]; then + echo "ERROR: shared libraries in $prefix/lib, where there should be none:" 1>&2 + echo "$shared" 1>&2 + exit 1 +fi + +# 4. Check: a shared library using every library, found as Tony's +# build will find them. meson links shared libraries with +# --no-undefined, so a missing library fails the link. + +echo +echo "Checking" +check=$work/check +mkdir -p "$check" +cat > "$check/meson.build" <<'EOF' +project('tony-deps-check', 'cpp', default_options: ['cpp_std=c++17']) +deps = [dependency('boost')] +foreach name : ['sndfile', 'samplerate', 'fftw3', 'rubberband', 'sord-0', + 'serd-0', 'mad', 'id3tag', 'opusfile', 'bzip2', 'oboe'] + deps += dependency(name, static: true) +endforeach +shared_library('tonydepscheck', 'check.cpp', dependencies: deps) +EOF +cat > "$check/check.cpp" <<'EOF' +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// Something from each library, so that the static linker has to find it +extern "C" int tony_deps_check(const unsigned char *data, int size) +{ + int n = sf_version_string()[0]; + n += src_is_valid_ratio(2.0); + + double *in = fftw_alloc_real(16); + fftw_complex *out = fftw_alloc_complex(9); + fftw_plan plan = fftw_plan_dft_r2c_1d(16, in, out, FFTW_ESTIMATE); + fftw_execute(plan); + fftw_destroy_plan(plan); + fftw_free(out); + fftw_free(in); + + RubberBand::RubberBandStretcher stretcher + (44100, 1, RubberBand::RubberBandStretcher::OptionEngineFiner); + n += int(stretcher.getStartDelay()); + + SordWorld *world = sord_world_new(); + n += int(sord_num_nodes(world)); + sord_world_free(world); + n += serd_strerror(SERD_SUCCESS)[0]; + + struct mad_stream stream; + struct mad_frame frame; + mad_stream_init(&stream); + mad_frame_init(&frame); + mad_stream_buffer(&stream, data, size); + n += mad_frame_decode(&frame, &stream); + mad_frame_finish(&frame); + mad_stream_finish(&stream); + + struct id3_tag *tag = id3_tag_parse(data, size); + if (tag) id3_tag_delete(tag); + + int error = 0; + OggOpusFile *opus = op_open_memory(data, size, &error); + if (opus) op_free(opus); + n += error; + + n += BZ2_bzlibVersion()[0]; + + boost::math::normal_distribution normal(0.0, 1.0); + n += int(boost::math::cdf(normal, double(size)) * 10); + + std::shared_ptr audio; + oboe::AudioStreamBuilder builder; + builder.setDirection(oboe::Direction::Output); + if (size > 0 && builder.openStream(audio) == oboe::Result::OK) { + n += audio->getSampleRate(); + audio->close(); + } + + return n; +} +EOF +log=$logs/deps-check.log +set +e +( set -e + meson setup "$check/build" "$check" --cross-file "$cross" --buildtype release + ninja -C "$check/build" ) > "$log" 2>&1 +status=$? +set -e +if [ "$status" -ne 0 ]; then + tail -30 "$log" 1>&2 + echo "ERROR: the check did not build; the whole log is $log" 1>&2 + exit 1 +fi + +so=$check/build/libtonydepscheck.so +kind=$(file -b "$so") +needed=$("$toolchain/bin/llvm-readelf" -d "$so" | sed -n 's/.*(NEEDED).*\[\(.*\)\]/\1/p' | tr '\n' ' ') +align=$("$toolchain/bin/llvm-readelf" -lW "$so" | awk '$1 == "LOAD" { print $NF }' | sort -u | tr '\n' ' ') +echo " $(basename "$so"): $(echo "$kind" | cut -d, -f1-2)" +echo " it loads: $needed" +echo " segment alignment: $align" +case "$kind" in + "ELF 64-bit LSB shared object, ARM aarch64"*) ;; + *) echo "ERROR: not an arm64 shared object" 1>&2; exit 1 ;; +esac +for lib in $needed; do + case "$lib" in + libc.so|libm.so|libdl.so|libz.so|liblog.so|libc++_shared.so) ;; + *) echo "ERROR: it loads $lib, which is not part of Android or the NDK" 1>&2; exit 1 ;; + esac +done +if [ "$align" != "0x4000 " ]; then + echo "ERROR: its segments are not aligned for 16 KB pages" 1>&2 + exit 1 +fi + +cat <&2 + exit 2 +fi + +android=/opt/android +sdk=$android/sdk +ndk=$sdk/ndk/27.2.12479018 +logs=$android/logs + +qt_version=6.11.2 +qtbase_commit=ef55f427f2c8b410d34f8a7681020a3000cf6866 +qtsvg_commit=17ca512f903f935282ebeca496aac5d11ba4199a + +qt=$android/qt/$qt_version +qt_host=$qt/gcc_64 +qt_android=$qt/android_arm64_v8a + +jobs=$(nproc) + +export JAVA_HOME=/usr/lib/jvm/java-21-openjdk-$(dpkg --print-architecture) + +# Qt's tools warn at every start in the container's C locale +export LC_ALL=C.UTF-8 + +if [ ! -x "$JAVA_HOME/bin/javac" ] || [ ! -f "$ndk/source.properties" ] || + [ ! -d "$sdk/platforms/android-36" ]; then + echo "ERROR: no JDK 21, NDK or SDK platform: run deploy/android/setup-toolchain.sh first" 1>&2 + exit 1 +fi + +mkdir -p "$logs" +work=$(mktemp -d "$android/tmp.XXXXXX") +trap 'rm -rf "$work"' EXIT + +# Runs a command with its output in a log file, and shows the end of +# the log if it fails +logged() { + local log="$1" + shift + if ! "$@" >> "$log" 2>&1; then + tail -30 "$log" 1>&2 + echo "ERROR: failed; the whole log is $log" 1>&2 + exit 1 + fi +} + +fetch() { + local name="$1" commit="$2" + if [ ! -d "$work/$name" ]; then + echo " fetching $name $qt_version" + git clone -q -c advice.detachedHead=false --depth 1 \ + --branch "v$qt_version" "https://github.com/qt/$name.git" "$work/$name" + if [ "$(git -C "$work/$name" rev-parse HEAD)" != "$commit" ]; then + echo "ERROR: tag v$qt_version of $name is not at $commit" 1>&2 + exit 1 + fi + fi +} + +qt_at() { + if [ -x "$1/bin/qmake" ]; then + "$1/bin/qmake" -query QT_VERSION 2>/dev/null || true + fi +} + +# Configures, builds and installs one Qt module from source directory +# $3, in build directory $2, logging to $1; the rest are the +# configure arguments +build_module() { + local log="$1" build="$2" configure="$3" + shift 3 + rm -f "$log" + mkdir -p "$build" + (cd "$build" && logged "$log" "$configure" "$@") + logged "$log" cmake --build "$build" --parallel "$jobs" + logged "$log" cmake --install "$build" + rm -rf "$build" +} + +# 1. The host Qt + +echo +if [ "$(qt_at "$qt_host")" = "$qt_version" ]; then + echo "Host Qt $qt_version: in $qt_host already" +else + echo "Building host Qt $qt_version in $qt_host (log: $logs/qt-host.log)" + start=$SECONDS + fetch qtbase "$qtbase_commit" + rm -rf "$qt_host" + build_module "$logs/qt-host.log" "$work/build-host" "$work/qtbase/configure" \ + -prefix "$qt_host" -release -nomake tests -nomake examples \ + -qt-zlib -qt-pcre -qt-doubleconversion -qt-freetype -qt-harfbuzz \ + -qt-libpng -qt-libjpeg -no-icu -no-glib -no-dbus -no-opengl \ + -no-xcb -no-gtk -no-zstd -no-feature-sql -no-feature-printsupport + echo " done in $(( (SECONDS - start) / 60 )) min" +fi + +# 2. Qt for Android: qtbase, then qtsvg on top of it + +echo +if [ "$(qt_at "$qt_android")" = "$qt_version" ] && + [ -f "$qt_android/lib/libQt6Core_arm64-v8a.so" ]; then + echo "Qt $qt_version for Android: in $qt_android already" +else + echo "Building Qt $qt_version for Android in $qt_android (log: $logs/qt-android.log)" + start=$SECONDS + fetch qtbase "$qtbase_commit" + rm -rf "$qt_android" + build_module "$logs/qt-android.log" "$work/build-android" "$work/qtbase/configure" \ + -platform android-clang -prefix "$qt_android" \ + -android-sdk "$sdk" -android-ndk "$ndk" -android-abis arm64-v8a \ + -qt-host-path "$qt_host" -release -nomake tests -nomake examples \ + -no-feature-sql -no-feature-printsupport + echo " done in $(( (SECONDS - start) / 60 )) min" +fi + +echo +if [ -f "$qt_android/lib/libQt6Svg_arm64-v8a.so" ]; then + echo "Qt Svg for Android: in $qt_android already" +else + echo "Building Qt Svg $qt_version for Android (log: $logs/qtsvg-android.log)" + fetch qtsvg "$qtsvg_commit" + build_module "$logs/qtsvg-android.log" "$work/build-svg" \ + "$qt_android/bin/qt-configure-module" "$work/qtsvg" +fi + +# 3. Check. qt-cmake configures and builds a small program using the +# modules Tony uses, as the Android build will: this needs Qt for +# Android, the host Qt, the NDK and the SDK to agree. + +echo +echo "Checking" +echo " host Qt: $("$qt_host/bin/qmake" -query QT_VERSION) at $("$qt_host/bin/qmake" -query QT_INSTALL_PREFIX)" +echo " Android Qt: $("$qt_android/bin/qmake" -query QT_VERSION) at $("$qt_android/bin/qmake" -query QT_INSTALL_PREFIX), host prefix $("$qt_android/bin/qmake" -query QT_HOST_PREFIX)" + +check=$work/check +mkdir -p "$check/src" +cat > "$check/src/CMakeLists.txt" <<'EOF' +cmake_minimum_required(VERSION 3.22) +project(check LANGUAGES CXX) +find_package(Qt6 REQUIRED COMPONENTS Core Gui Widgets Xml Network Svg Test) +qt_add_executable(check check.cpp) +target_link_libraries(check PRIVATE Qt6::Widgets Qt6::Xml Qt6::Network Qt6::Svg) +EOF +cat > "$check/src/check.cpp" <<'EOF' +#include +#include +#include +#include +#include +int main(int argc, char **argv) +{ + QApplication app(argc, argv); + QDomDocument doc; + QNetworkAccessManager network; + QSvgRenderer svg; + QLabel label("Tony"); + label.show(); + return app.exec(); +} +EOF +log=$logs/qt-check.log +rm -f "$log" +logged "$log" "$qt_android/bin/qt-cmake" -S "$check/src" -B "$check/build" -G Ninja \ + -DCMAKE_BUILD_TYPE=Release -DANDROID_SDK_ROOT="$sdk" -DANDROID_NDK_ROOT="$ndk" +# The library only: the default target goes on to run androiddeployqt +# and Gradle, which is the APK build's business +logged "$log" cmake --build "$check/build" --target check +echo " qt-cmake: $(file -b "$check/build/libcheck_arm64-v8a.so" | cut -d, -f1-2)" +# The deployment settings Qt's CMake support wrote, kept as a model for +# the file Tony's build has to write for androiddeployqt +cp "$check/build/android-check-deployment-settings.json" "$logs/" + +# It prints its usage and exits with 1 +log=$logs/androiddeployqt-help.log +"$qt_host/bin/androiddeployqt" --help > "$log" 2>&1 || true +if ! grep -q -- "--output " "$log"; then + cat "$log" 1>&2 + echo "ERROR: androiddeployqt does not run" 1>&2 + exit 1 +fi +echo " androiddeployqt: runs" + +cat <&2 + exit 2 +fi + +android=/opt/android +ndk=$android/sdk/ndk/27.2.12479018 +logs=$android/logs +cross=$android/cross-arm64-v8a.ini + +repo=$(cd "$(dirname "$0")/../.." && pwd) +qt_cross=$repo/deploy/android/qt-arm64-v8a.ini +build=$repo/build-android + +readelf=$ndk/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-readelf + +jobs=$(nproc) + +# Qt's qmake, which meson runs, warns at every start in the container's +# C locale +export LC_ALL=C.UTF-8 + +if [ ! -f "$cross" ]; then + echo "ERROR: no $cross: run deploy/android/build-deps.sh first" 1>&2 + exit 1 +fi +qmake=$(sed -n "s/^qmake6 = '\(.*\)'$/\1/p" "$qt_cross") +if [ ! -x "$qmake" ]; then + echo "ERROR: no Qt for Android at $qmake: run deploy/android/build-qt.sh first" 1>&2 + exit 1 +fi + +mkdir -p "$logs" + +# 1. Configure + +setup_args=(--buildtype=debugoptimized --cross-file "$cross" --cross-file "$qt_cross") + +log=$logs/tony-setup.log +if [ -f "$build/build.ninja" ] && [ "$wipe" = "no" ]; then + echo "build-android/: configured already" +else + if [ -f "$build/build.ninja" ]; then + echo "Configuring build-android/ from scratch" + setup_args+=(--wipe) + else + echo "Configuring build-android/" + fi + if ! meson setup "$build" "$repo" "${setup_args[@]}" > "$log" 2>&1; then + tail -30 "$log" 1>&2 + echo "ERROR: meson setup failed; the whole log is $log" 1>&2 + exit 1 + fi +fi + +# 2. Build: the application library and the plugins, which on Android +# are all there is (meson.build leaves the tests out) + +echo "Building" +log=$logs/tony-build.log +if ! ninja -j "$jobs" -C "$build" > "$log" 2>&1; then + if grep -q "error:" "$log"; then + grep "error:" "$log" | head -30 1>&2 + else + tail -30 "$log" 1>&2 + fi + echo "ERROR: the build failed; the whole log is $log" 1>&2 + exit 1 +fi + +# 3. Check + +echo +echo "Checking" + +# check +check() { + local so="$build/$1" symbol="$2" allowed="$3" + local kind needed align + kind=$(file -b "$so") + needed=$("$readelf" -d "$so" | sed -n 's/.*(NEEDED).*\[\(.*\)\]/\1/p' | tr '\n' ' ') + align=$("$readelf" -lW "$so" | awk '$1 == "LOAD" { print $NF }' | sort -u | tr '\n' ' ') + echo " $1: $(echo "$kind" | cut -d, -f1-2)" + echo " it loads: $needed" + case "$kind" in + "ELF 64-bit LSB shared object, ARM aarch64"*) ;; + *) echo "ERROR: not an arm64 shared object" 1>&2; exit 1 ;; + esac + for lib in $needed; do + if ! echo "$lib" | grep -qxE "$allowed"; then + echo "ERROR: it loads $lib, which is not among the libraries it may load" 1>&2 + exit 1 + fi + done + if [ "$align" != "0x4000 " ]; then + echo "ERROR: its segments are not aligned for 16 KB pages" 1>&2 + exit 1 + fi + if ! "$readelf" --dyn-syms -W "$so" | + awk -v s="$symbol" '$8 == s && $5 == "GLOBAL" && $6 == "DEFAULT" && $7 != "UND" { found = 1 } + END { exit !found }'; then + echo "ERROR: it does not export $symbol" 1>&2 + exit 1 + fi + echo " it exports $symbol" +} + +system_libraries='lib(c|m|dl|log|z|android|mediandk|c\+\+_shared)\.so' + +check libTony_arm64-v8a.so main "$system_libraries|libQt6[A-Za-z]+_arm64-v8a\.so" +check pyin.so vampGetPluginDescriptor "$system_libraries" +check chp.so vampGetPluginDescriptor "$system_libraries" + +cat < + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/deploy/android/qt-arm64-v8a.ini b/deploy/android/qt-arm64-v8a.ini new file mode 100644 index 00000000..e4e5aaa1 --- /dev/null +++ b/deploy/android/qt-arm64-v8a.ini @@ -0,0 +1,17 @@ +# Meson cross file for Tony's Android build (arm64-v8a): where Qt for +# Android is. It goes on top of the NDK cross file that +# deploy/android/build-deps.sh writes, as deploy/android/build-tony.sh +# does: +# +# meson setup build-android \ +# --cross-file /opt/android/cross-arm64-v8a.ini \ +# --cross-file deploy/android/qt-arm64-v8a.ini +# +# Qt for Android installs no pkg-config files, so meson finds it through +# this qmake, Qt's wrapper for the target. meson then takes moc, rcc and +# uic from the QT_HOST_LIBEXECS that qmake reports, which is the host +# Qt's libexec/, built by deploy/android/build-qt.sh with the same +# version. + +[binaries] +qmake6 = '/opt/android/qt/6.11.2/android_arm64_v8a/bin/qmake' diff --git a/deploy/android/setup-toolchain.sh b/deploy/android/setup-toolchain.sh new file mode 100755 index 00000000..b212a5ae --- /dev/null +++ b/deploy/android/setup-toolchain.sh @@ -0,0 +1,171 @@ +#!/bin/bash +# +# Tony +# An intonation analysis and annotation tool +# Centre for Digital Music, Queen Mary, University of London. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; either version 2 of the +# License, or (at your option) any later version. See the file +# COPYING included with this distribution for more information. +# +# Installs the Android SDK, the NDK and JDK 21 for Tony's Android build +# in the Ubuntu 24.04 cloud container, with the apt packages that this +# script, build-qt.sh and build-deps.sh need: +# +# /opt/android/sdk command-line tools, platform +# android-36, build tools 36.0.0, +# platform tools +# /opt/android/sdk/ndk/27.2.12479018 NDK r27c, where Gradle looks +# for it +# /usr/lib/jvm/java-21-openjdk-amd64 JDK 21, from apt +# +# The versions are those Qt 6.11 documents for Android +# (docs/port-android.md, "Qt for Android"). Then run, in this order, +# deploy/android/build-qt.sh and deploy/android/build-deps.sh. +# +# Safe to run again: what is installed already is left alone. +# +# Usage, from anywhere: +# deploy/android/setup-toolchain.sh + +set -eu -o pipefail + +if [ "$#" -ne 0 ]; then + echo "Usage: $0" 1>&2 + exit 2 +fi + +sudo="" +if [ "$(id -u)" -ne 0 ]; then + sudo=sudo +fi + +android=/opt/android +sdk=$android/sdk + +# The command-line tools 22.0: the last release whose sdkmanager is +# the Java one. In 23.0 sdkmanager became a wrapper around a new +# "android" tool, with other package names. The SHA-1 is the one +# Google's repository2-3.xml gives; the SHA-256 was recorded here. +cmdline_tools_build=15859902 +cmdline_tools_sha1=040d3996a65543d22ec4bf73e4c37aa37a8d4af4 +cmdline_tools_sha256=4e4c464f145a7512b57d088ac6c278c03c9eea610886b35a5e0804e74eedf583 + +ndk_version=27.2.12479018 +ndk=$sdk/ndk/$ndk_version + +# sdkmanager takes the latest revision of each of these, and checks +# the SHA-1 of what it downloads. The NDK's path names its exact version. +sdk_packages="platforms;android-36 build-tools;36.0.0 platform-tools ndk;$ndk_version" + +# 1. Packages: the JDK, the host compiler and build tools for Qt and +# the libraries, and "file" for the checks. The JDK is found by its +# path, not by /usr/bin/java, which another JDK may own. + +jdk_package=openjdk-21-jdk-headless +java_home=/usr/lib/jvm/java-21-openjdk-$(dpkg --print-architecture) + +packages=" +$jdk_package curl ca-certificates unzip xz-utils git patch file +build-essential pkg-config cmake ninja-build meson +" + +missing="" +for p in $packages; do + if ! dpkg-query -W -f='${Status}' "$p" 2>/dev/null | grep -q "install ok installed"; then + missing="$missing $p" + fi +done + +if [ -n "$missing" ]; then + echo "Installing packages:$missing" + # Some of the container's own apt sources (PPAs) are blocked; + # apt-get update warns about them and carries on. + $sudo apt-get update -q + $sudo env DEBIAN_FRONTEND=noninteractive \ + apt-get install -y -q --no-install-recommends $missing +else + echo "Packages: all installed" +fi + +if [ ! -x "$java_home/bin/javac" ]; then + echo "ERROR: no JDK 21 at $java_home" 1>&2 + exit 1 +fi +export JAVA_HOME=$java_home + +# Everything below runs as the user who owns /opt/android +if [ ! -d "$android" ]; then + $sudo mkdir -p "$android" + $sudo chown "$(id -u):$(id -g)" "$android" +fi +work=$(mktemp -d "$android/tmp.XXXXXX") +trap 'rm -rf "$work"' EXIT + +# 2. The SDK's command-line tools, in the layout sdkmanager expects: +# sdk/cmdline-tools/latest. + +sdkmanager=$sdk/cmdline-tools/latest/bin/sdkmanager + +echo +if [ -x "$sdkmanager" ]; then + echo "SDK command-line tools: installed already" +else + echo "Installing the SDK command-line tools ($cmdline_tools_build) in $sdk" + zip=$work/cmdline-tools.zip + # The container's proxy now and then answers 502 for a moment + curl -sSfL --retry 5 --retry-all-errors -o "$zip" \ + "https://dl.google.com/android/repository/commandlinetools-linux-${cmdline_tools_build}_latest.zip" + echo "$cmdline_tools_sha1 $zip" | sha1sum -c --quiet + echo "$cmdline_tools_sha256 $zip" | sha256sum -c --quiet + unzip -q "$zip" -d "$work" + rm "$zip" + mkdir -p "$sdk/cmdline-tools" + rm -rf "$sdk/cmdline-tools/latest" + mv "$work/cmdline-tools" "$sdk/cmdline-tools/latest" +fi + +# 3. SDK packages and the NDK. Installing them means accepting the +# SDK's licences, as every unattended Android build does. + +missing="" +for p in $sdk_packages; do + if [ ! -f "$sdk/${p//;//}/source.properties" ]; then + missing="$missing $p" + fi +done + +echo +if [ -n "$missing" ]; then + echo "Installing SDK packages:$missing" + log=$work/sdkmanager.log + { yes || true; } | "$sdkmanager" --sdk_root="$sdk" --licenses > "$log" 2>&1 || + { tail -20 "$log" 1>&2; exit 1; } + "$sdkmanager" --sdk_root="$sdk" --install $missing >> "$log" 2>&1 || + { tail -20 "$log" 1>&2; exit 1; } + for p in $missing; do + if [ ! -f "$sdk/${p//;//}/source.properties" ]; then + tail -20 "$log" 1>&2 + echo "ERROR: sdkmanager did not install $p" 1>&2 + exit 1 + fi + done +else + echo "SDK packages: all installed" +fi + +if ! grep -q "^Pkg.Revision = $ndk_version\$" "$ndk/source.properties"; then + echo "ERROR: $ndk is not NDK $ndk_version" 1>&2 + exit 1 +fi + +cat <&2 + exit 2 +} + +quick=no +if [ "${1:-}" = "--quick" ]; then + quick=yes + shift +fi +[ "$#" -eq 1 ] || usage +case "$1" in + linux|android|all) what=$1 ;; + *) usage ;; +esac + +cd "$(dirname "$0")/../.." +root=$(pwd) +logs=$root/tmp/ci-local +mkdir -p "$logs" + +sudo="" +if [ "$(id -u)" -ne 0 ]; then + sudo=sudo +fi + +# Runs a command with its output in a log file; on failure shows the end +# of the log and returns its status +logged() { + local log="$1" + shift + "$@" > "$log" 2>&1 + local status=$? + if [ "$status" -ne 0 ]; then + tail -20 "$log" | sed 's/^/ /' + echo " FAILED; the whole log is ${log#$root/}" + fi + return "$status" +} + +minutes() { + echo "$(( ($1 + 30) / 60 )) min" +} + +# The libraries at their pins and the packages. It is quick once the +# session's hook has run it, and needed when it has not +prepare() { + if [ -f tmp/cloud-session.log ]; then + echo "Waiting for the session's background build" + deploy/linux/cloud-session.sh wait > /dev/null 2>&1 + fi + echo "Libraries and packages (container-setup.sh)" + logged "$logs/setup.log" deploy/linux/container-setup.sh +} + +linux_job() { + local build=build-linux-ci + local start=$SECONDS + + echo + echo "== Linux: Ubuntu's Qt 6.4, a release build in $build/" + + # Ubuntu's Qt, as the workflow installs it (less what nothing here uses) + local packages="qt6-base-dev qt6-base-dev-tools qt6-svg-dev" missing="" p + for p in $packages; do + if ! dpkg-query -W -f='${Status}' "$p" 2>/dev/null | grep -q "install ok installed"; then + missing="$missing $p" + fi + done + if [ -n "$missing" ]; then + echo "Installing$missing" + logged "$logs/linux-packages.log" \ + bash -c "$sudo apt-get update -q && $sudo env DEBIAN_FRONTEND=noninteractive apt-get install -y -q --no-install-recommends$missing" || + return 1 + fi + + if [ ! -f "$build/build.ninja" ]; then + echo "Configuring $build/" + # No pkg-config path of conda's, so that meson finds Ubuntu's Qt. + # mold as in build/: the linker changes nothing that is tested + local linker=() + if command -v mold > /dev/null; then + linker=(env CC_LD=mold CXX_LD=mold) + fi + logged "$logs/linux-setup.log" \ + env -u PKG_CONFIG_PATH "${linker[@]}" \ + meson setup "$build" --buildtype release || return 1 + fi + local qt + qt=$(sed -n 's/^Run-time dependency qt6 (modules: [^)]*) found: YES \([0-9.]*\).*/\1/p' \ + "$build/meson-logs/meson-log.txt" | tail -1) + case "$qt" in + 6.4.*) ;; + *) echo " ERROR: $build/ is configured with Qt ${qt:-(not found in its log)}, not Ubuntu's 6.4; delete it and run again" + return 1 ;; + esac + + echo "Building (log: tmp/ci-local/linux-build.log)" + logged "$logs/linux-build.log" ninja -j "$(nproc)" -C "$build" || return 1 + + local status=0 + if [ "$quick" = "yes" ]; then + # One process per core, half of run-tests.sh's default, so that + # the timed tests run on a machine loaded less (docs/testing.md) + echo "Testing, $(nproc) processes" + local exe + for exe in test-tony-core test-tony-app; do + deploy/linux/run-tests.sh -j "$(nproc)" "$build" "$exe" \ + > "$logs/linux-$exe.log" 2>&1 < /dev/null + if [ "$?" -ne 0 ]; then + status=1 + grep -v ' 0 failed' "$logs/linux-$exe.log" | tail -30 | sed 's/^/ /' + else + echo " $(head -1 "$logs/linux-$exe.log")" + fi + done + logged "$logs/linux-svcore.log" \ + meson test -C "$build" --no-rebuild --print-errorlogs \ + svcore-base svcore-system svcore-data-model svcore-data-fileio || + status=1 + else + echo "Testing: every meson test in one process, as the workflow (log: tmp/ci-local/linux-test.log)" + meson test -C "$build" --no-rebuild --print-errorlogs --num-processes 1 \ + > "$logs/linux-test.log" 2>&1 < /dev/null + status=$? + sed -n '/^Ok:/,/^Timeout:/p' "$logs/linux-test.log" | sed 's/^/ /' + if [ "$status" -ne 0 ]; then + # The workflow's test-failures step + awk '/^test:/ { print; next } + /^Totals:/ { if ($0 !~ /, 0 failed/) print; next } + /^(FAIL!|XPASS|QFATAL)|Received signal/ { print; fail = 1; next } + /^[A-Z!]+ *: / { fail = 0 } + fail && /^ / { print }' "$build/meson-logs/testlog.txt" | + sed 's/^/ /' + fi + fi + + echo "Linux: $([ "$status" -eq 0 ] && echo passed || echo FAILED) in $(minutes $((SECONDS - start)))" + return "$status" +} + +android_job() { + local start=$SECONDS s + + echo + echo "== Android: the APK, with deploy/android/'s scripts" + for s in setup-toolchain build-qt build-deps build-tony build-apk; do + local step=$SECONDS + echo "$s (log: tmp/ci-local/android-$s.log)" + logged "$logs/android-$s.log" "deploy/android/$s.sh" < /dev/null || { + echo "Android: FAILED in $(minutes $((SECONDS - start)))" + return 1 + } + echo " done in $(minutes $((SECONDS - step)))" + done + grep -E '^ package:|^Done:' "$logs/android-build-apk.log" | sed 's/^ */ /' + echo "Android: passed in $(minutes $((SECONDS - start)))" +} + +prepare || exit 1 + +status=0 +if [ "$what" = "linux" ] || [ "$what" = "all" ]; then + linux_job || status=1 +fi +if [ "$what" = "android" ] || [ "$what" = "all" ]; then + android_job || status=1 +fi +exit "$status" diff --git a/deploy/linux/cloud-environment.sh b/deploy/linux/cloud-environment.sh new file mode 100755 index 00000000..ec4fed86 --- /dev/null +++ b/deploy/linux/cloud-environment.sh @@ -0,0 +1,276 @@ +#!/bin/bash +# +# Tony +# An intonation analysis and annotation tool +# Centre for Digital Music, Queen Mary, University of London. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; either version 2 of the +# License, or (at your option) any later version. See the file +# COPYING included with this distribution for more information. +# +# The setup script of the Claude cloud environment Tony is developed in. +# It is pasted, whole, into the environment's "Setup script" field; this +# copy is where it is reviewed and versioned, and a change here reaches +# sessions only once it is pasted there too. +# +# The platform runs it as root after cloning the repository, the first +# time a session starts after the script or the network settings change, +# or after the environment's cache expires (about a week). It then keeps +# a snapshot of the disk, and later sessions start from that snapshot +# without running the script. The snapshot is kept only when the script +# finishes within about five minutes, so the script stops its open-ended +# steps at $deadline seconds. +# +# What it leaves in the snapshot: +# +# - The apt packages that container-setup.sh and +# deploy/android/setup-toolchain.sh install, and ccache and mold. +# - Qt 6.11.2 from conda-forge in /opt/qt6-conda, where +# container-setup.sh looks for it. +# - /etc/ccache.conf. meson uses ccache by itself when it is installed. +# - An autoMode entry in /root/.claude/settings.json by which auto mode +# trusts the library forks as it does the session's own repository. +# - The Android SDK and NDK in /opt/android/sdk, where +# deploy/android/setup-toolchain.sh installs them, when dl.google.com is +# reachable. +# - ccache filled with as much of a build of the libraries as fits before +# the deadline, svcore first: the checkout is set up with its own +# container-setup.sh, built into build/, and left as it was cloned. +# The libraries' objects do not depend on main/, so they are found in +# ccache by every branch whose pins are the same. +# +# Only a failure to install the packages or Qt is worth a warning; the +# script ends with status 0 whatever happens, because a failing setup +# script stops the session from starting, and a session can run +# container-setup.sh itself. The logs are in /var/log/tony-environment. + +set -u -o pipefail + +# Seconds from the start after which nothing more is started, leaving a +# minute for the platform +deadline=240 + +logs=/var/log/tony-environment +mkdir -p "$logs" + +say() { + echo "[${SECONDS}s] $*" +} + +left() { + echo $((deadline - SECONDS)) +} + +# timeout, with what is left before the deadline; "timeout 0" would wait +# for ever +until_deadline() { + local t + t=$(left) + if [ "$t" -le 0 ]; then + return 124 + fi + timeout "$t" "$@" +} + +# 1. ccache. meson passes relative source paths, but -g puts the build +# directory into every result's key unless hash_dir is off, and then a +# second build directory or a worktree finds nothing. + +cat > /etc/ccache.conf <<'EOF' +# Written by Tony's cloud environment setup script +cache_dir = /root/.cache/ccache +max_size = 8G +hash_dir = false +base_dir = /home/user +EOF + +# 2. Auto mode's trust, as the user chose it: the library forks are the +# user's own repositories. Out of the box auto mode trusts only the +# repository a session started in and its remotes, and blocks committing +# in a fork's checkout, attaching the fork and pushing to it +# (docs/forks.md). It reads autoMode from the user's settings, never +# from the repository's .claude/settings.json, and combines them with +# the platform's own (--settings); `claude auto-mode config` shows the +# result. + +TONY_AUTOMODE_SETTINGS=${TONY_AUTOMODE_SETTINGS:-/root/.claude/settings.json} python3 - <<'EOF' +import json, os +path = os.environ["TONY_AUTOMODE_SETTINGS"] +entries = [ + "Trusted repo: besides the working repository github.com/jhhr/tony, the user's own " + "forks of its libraries, github.com/jhhr/svcore, github.com/jhhr/svgui, " + "github.com/jhhr/svapp and github.com/jhhr/bqaudiostream, checked out inside the " + "working directory as svcore/, svgui/, svapp/ and bqaudiostream/", + "Source control: github.com/jhhr/tony and those four forks. Committing in the fork " + "checkouts, attaching the forks to the session with push access and pushing branches " + "to them is routine work on Tony (docs/forks.md)", +] +settings = {} +if os.path.exists(path): + with open(path) as f: + settings = json.load(f) +environment = settings.setdefault("autoMode", {}).setdefault("environment", []) +if not environment: + environment.append("$defaults") +for entry in entries: + if entry not in environment: + environment.append(entry) +os.makedirs(os.path.dirname(path), exist_ok=True) +with open(path, "w") as f: + json.dump(settings, f, indent=2) + f.write("\n") +EOF + +# 3. Packages, Qt and the Android SDK, side by side + +packages=" +build-essential pkg-config ninja-build meson git python3 curl ca-certificates +libboost-dev libbz2-dev libfftw3-dev libsndfile1-dev libsamplerate0-dev +librubberband-dev libsord-dev libserd-dev liboggz2-dev libfishsound1-dev +libmad0-dev libid3tag0-dev libopus-dev libopusfile-dev libopusenc-dev +libjack-jackd2-dev libpulse-dev libasound2-dev portaudio19-dev +fonts-dejavu-core ccache mold +openjdk-21-jdk-headless unzip xz-utils patch file cmake +" + +install_packages() { + # The image's PPAs are blocked; apt-get update warns and carries on + apt-get update -q + DEBIAN_FRONTEND=noninteractive apt-get install -y -q \ + --no-install-recommends -o Acquire::Retries=3 $packages +} + +install_qt() { + local micromamba=/opt/micromamba/bin/micromamba + if [ "$(PKG_CONFIG_PATH=/opt/qt6-conda/lib/pkgconfig pkg-config --modversion Qt6Core 2>/dev/null)" = 6.11.2 ]; then + echo "Qt 6.11.2 is installed already" + return 0 + fi + mkdir -p "$(dirname "$micromamba")" + curl -sSfL --retry 5 --retry-all-errors --max-time 60 -o "$micromamba" \ + https://github.com/mamba-org/micromamba-releases/releases/download/2.9.0-0/micromamba-linux-64 && + chmod +x "$micromamba" && + MAMBA_ROOT_PREFIX=/opt/micromamba "$micromamba" create -y -q \ + -p /opt/qt6-conda -c conda-forge qt6-main=6.11.2 || return 1 + # Only Qt's own .pc files, as container-setup.sh makes them + mkdir -p /opt/qt6-conda/tony-pkgconfig + ln -sf /opt/qt6-conda/lib/pkgconfig/Qt6*.pc /opt/qt6-conda/tony-pkgconfig/ +} + +# The versions and checks of deploy/android/setup-toolchain.sh, which +# finds these installed and installs anything it wants that is not +install_android() { + local sdk=/opt/android/sdk + local build=15859902 + local sha256=4e4c464f145a7512b57d088ac6c278c03c9eea610886b35a5e0804e74eedf583 + local zip=/opt/android/cmdline-tools.zip + if [ -f "$sdk/ndk/27.2.12479018/source.properties" ]; then + echo "The Android SDK and NDK are installed already" + return 0 + fi + if ! curl -sSf -o /dev/null --max-time 20 \ + https://dl.google.com/android/repository/repository2-3.xml; then + echo "dl.google.com is not reachable: no Android SDK" + return 0 + fi + mkdir -p "$sdk/cmdline-tools" + until_deadline curl -sSfL --retry 5 --retry-all-errors -o "$zip" \ + "https://dl.google.com/android/repository/commandlinetools-linux-${build}_latest.zip" && + echo "$sha256 $zip" | sha256sum -c --quiet && + rm -rf "$sdk/cmdline-tools/latest" /opt/android/cmdline-tools && + unzip -q "$zip" -d /opt/android && + mv /opt/android/cmdline-tools "$sdk/cmdline-tools/latest" || return 1 + rm -f "$zip" + # sdkmanager is Java: the proxy has to be named to it unless + # JAVA_TOOL_OPTIONS does it already + local proxy=() + local hostport=${HTTPS_PROXY:-} + hostport=${hostport#*://} + hostport=${hostport%%/*} + if [ -n "$hostport" ]; then + proxy=(--proxy=http "--proxy_host=${hostport%:*}" "--proxy_port=${hostport##*:}") + fi + local sdkmanager=$sdk/cmdline-tools/latest/bin/sdkmanager + export JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64 + { yes || true; } | "$sdkmanager" --sdk_root="$sdk" "${proxy[@]}" --licenses > /dev/null + until_deadline "$sdkmanager" --sdk_root="$sdk" "${proxy[@]}" --install \ + "platforms;android-36" "build-tools;36.0.0" platform-tools "ndk;27.2.12479018" +} + +say "Installing packages, Qt and the Android SDK (logs in $logs)" +install_packages > "$logs/packages.log" 2>&1 & +packages_pid=$! +install_qt > "$logs/qt.log" 2>&1 & +qt_pid=$! +install_android > "$logs/android.log" 2>&1 & +android_pid=$! + +wait "$packages_pid" +packages_status=$? +say "Packages: exit $packages_status" +wait "$qt_pid" +qt_status=$? +say "Qt: exit $qt_status" + +# 4. ccache, from a build of the libraries in the checkout, until the +# deadline + +fill_ccache() { + local repo="" d + for d in "$PWD" /home/user/*; do + if [ -f "$d/repoint-project.json" ] && [ -x "$d/deploy/linux/container-setup.sh" ]; then + repo=$d + break + fi + done + if [ -z "$repo" ]; then + echo "No checkout with deploy/linux/container-setup.sh" + return 0 + fi + echo "Checkout: $repo" + + # What container-setup.sh adds to the checkout, to take away again + local added=() + for d in build $(python3 -c 'import json; print(" ".join(json.load(open("'"$repo"'/repoint-project.json"))["libraries"]))'); do + [ -e "$repo/$d" ] || added+=("$repo/$d") + done + + CC_LD=mold CXX_LD=mold until_deadline "$repo/deploy/linux/container-setup.sh" && + ( + cd "$repo/build" || exit 1 + # svcore, then svgui and svapp (in libtonyapp.a with main/), + # then the rest; each step until the deadline + svlibs=$(ninja -t targets all | sed -n 's/^\(libtonyapp\.a\.p\/sv\(gui\|app\)_[^:]*\.o\):.*/\1/p') + for step in libsvcore.a "$svlibs" "tony pyin.so test-tony-core test-tony-app test-tony-dev"; do + [ -n "$step" ] || continue + [ "$(left)" -gt 10 ] || break + until_deadline nice ninja -j "$(nproc)" $step > /dev/null + echo "Built up to: ${step:0:40}...: exit $?" + done + ) + ccache -s | grep -iE 'hits|misses|cache size' || true + + rm -rf "${added[@]}" + git -C "$repo" status --short +} + +if [ "$packages_status" -eq 0 ] && [ "$qt_status" -eq 0 ]; then + say "Filling ccache until ${deadline}s" + fill_ccache > "$logs/ccache.log" 2>&1 + say "ccache: $(ccache -s | grep -a 'Cache size' | head -1 | sed 's/^ *//')" +else + say "WARNING: packages or Qt failed; see $logs. Sessions will install them with container-setup.sh." +fi + +wait "$android_pid" +android_status=$? +if [ -f /opt/android/sdk/ndk/27.2.12479018/source.properties ]; then + say "Android SDK: exit $android_status, the SDK and NDK are in /opt/android/sdk" +else + say "Android SDK: exit $android_status ($(tail -1 "$logs/android.log"))" +fi + +say "Done" +exit 0 diff --git a/deploy/linux/cloud-session.sh b/deploy/linux/cloud-session.sh new file mode 100755 index 00000000..292a1615 --- /dev/null +++ b/deploy/linux/cloud-session.sh @@ -0,0 +1,107 @@ +#!/bin/bash +# +# Tony +# An intonation analysis and annotation tool +# Centre for Digital Music, Queen Mary, University of London. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; either version 2 of the +# License, or (at your option) any later version. See the file +# COPYING included with this distribution for more information. +# +# Builds Tony in the background at the start of a cloud session, so that +# the build runs while the session reads and edits instead of after: the +# library directories at their pins and build/ configured +# (container-setup.sh), then Tony, the pYIN plugin and the four test +# executables. +# +# The environment's setup script (cloud-environment.sh) has installed the +# packages and Qt already and filled ccache with the libraries' objects, +# so this takes a few minutes at most, and ninja carries on from there +# when the session builds again. The build runs at low priority, so that +# the session's own commands come first. +# +# build/ links with mold when it is installed: Tony and the test +# executables link in a few seconds instead of about twelve with GNU ld, +# which every change to main/ pays. +# +# Usage, from anywhere: +# deploy/linux/cloud-session.sh start start the build and return at +# once; nothing if it is running +# deploy/linux/cloud-session.sh wait wait for it to finish, show the +# end of its log, exit as it did +# +# "start --if-cloud" does nothing outside a cloud session: it is what the +# SessionStart hook in .claude/settings.json runs, at the start and the +# resumption of every session, including those on the Windows machine. +# The log is tmp/cloud-session.log. + +set -u -o pipefail + +if [ "${1:-} ${2:-}" = "start --if-cloud" ] && [ "${CLAUDE_CODE_REMOTE:-}" != "true" ]; then + exit 0 +fi + +cd "$(dirname "$0")/../.." +root=$(pwd) +mkdir -p tmp +log=$root/tmp/cloud-session.log +lock=$root/tmp/cloud-session.lock +script=$root/deploy/linux/cloud-session.sh + +# As AGENTS.md's. test-tony-dev is in every build but a release one, and +# container-setup.sh configures debugoptimized +targets="tony pyin.so test-tony-core test-tony-app test-tony-dev" + +case "${1:-} ${2:-}" in + "start "|"start --if-cloud") + if ! flock -n "$lock" true; then + echo "The background build is running already (log: tmp/cloud-session.log)." + exit 0 + fi + # Detached, so that it outlives the command that started it + setsid flock -n "$lock" "$script" run < /dev/null > /dev/null 2>&1 & + # Until it holds the lock, so that a wait straight after this waits for it + for i in $(seq 50); do + flock -n "$lock" true || break + sleep 0.1 + done + echo "Started the background build: libraries, build/, then $targets (log: tmp/cloud-session.log)." + echo "Run deploy/linux/cloud-session.sh wait before building or testing: two ninjas must not share build/." + ;; + "wait ") + if [ ! -f "$log" ]; then + echo "No background build was started (deploy/linux/cloud-session.sh start)." + exit 1 + fi + flock "$lock" true + tail -5 "$log" + status=$(sed -n 's/^exit:\([0-9]*\)$/\1/p' "$log" | tail -1) + exit "${status:-1}" + ;; + "run ") + # Holding the lock, from start + exec > "$log" 2>&1 + echo "Started $(date -u '+%Y-%m-%d %H:%M:%S') UTC" + start=$SECONDS + if command -v mold > /dev/null; then + # Read by meson setup only: a build/ configured already keeps its linker + export CC_LD=mold CXX_LD=mold + fi + nice -n 10 deploy/linux/container-setup.sh + status=$? + if [ "$status" -eq 0 ]; then + echo + echo "Building $targets" + nice -n 10 ninja -j "$(nproc)" -C build $targets + status=$? + fi + echo "Finished in $((SECONDS - start)) s" + echo "exit:$status" + ;; + *) + echo "Usage: $0 start [--if-cloud] | wait" 1>&2 + exit 2 + ;; +esac diff --git a/deploy/linux/container-setup.sh b/deploy/linux/container-setup.sh new file mode 100755 index 00000000..f6e68fb3 --- /dev/null +++ b/deploy/linux/container-setup.sh @@ -0,0 +1,293 @@ +#!/bin/bash +# +# Tony +# An intonation analysis and annotation tool +# Centre for Digital Music, Queen Mary, University of London. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; either version 2 of the +# License, or (at your option) any later version. See the file +# COPYING included with this distribution for more information. +# +# Makes a fresh Ubuntu 24.04 cloud container able to build Tony and run +# its test suites: installs the apt packages and Qt, checks out the +# library directories at the revisions pinned in repoint-lock.json, and +# configures build/ with meson. +# +# It exists because "./repoint install" cannot work in the cloud +# container: the Mercurial libraries are on hg.sr.ht, which cannot be +# reached from there. Their Git mirrors on GitHub are +# checked out instead, at the commits in the table below. The Git +# libraries are cloned from GitHub at their pins, as repoint would. +# sv-dependency-builds is skipped: only the macOS and Windows branches +# of meson.build use it. +# +# Qt is conda-forge's qt6-main, the 6.11 of the development machine and +# the Android build, not Ubuntu's Qt 6.4: 6.4 does not match a SIGNAL()/ +# SLOT() string naming ModelId or sv_frame_t against a slot moc recorded +# as taking sv::ModelId or sv::sv_frame_t, so such a connection fails +# silently there and works on the development machine +# (docs/building.md). +# +# Safe to run again. Packages already installed, a Qt of the right +# version and libraries already at their pins are left alone, and a +# library with local changes or local commits is never moved. +# +# Usage, from anywhere: +# deploy/linux/container-setup.sh set up and configure build/ +# deploy/linux/container-setup.sh --build the same, then build Tony, +# the pYIN plugin and the +# core and app test +# executables + +set -eu -o pipefail + +build=no +case "${1:-}" in + "") ;; + --build) build=yes ;; + *) echo "Usage: $0 [--build]" 1>&2; exit 2 ;; +esac + +cd "$(dirname "$0")/../.." +root=$(pwd) +echo "Setting up $root" + +sudo="" +if [ "$(id -u)" -ne 0 ]; then + sudo=sudo +fi + +qt_version=6.11.2 +qt_prefix=/opt/qt6-conda +micromamba_version=2.9.0-0 +micromamba=/opt/micromamba/bin/micromamba + +# Only Qt's own .pc files, so that pkg-config finds everything else +# (alsa, for one, which the conda prefix has too) on the system +qt_pkgconfig=$qt_prefix/tony-pkgconfig + +# 1. Packages. The names are those of .github/workflows/linux.yml where +# meson.build needs them, as spelled on Ubuntu 24.04, less Qt. Rubber +# Band is Ubuntu's librubberband-dev (3.3), which meets meson.build's +# ">= 3.0.0", so the CI's tarball from breakfastquay.com (blocked here +# too) is not needed. + +packages=" +build-essential pkg-config ninja-build meson git python3 curl ca-certificates +libboost-dev libbz2-dev libfftw3-dev libsndfile1-dev libsamplerate0-dev +librubberband-dev libsord-dev libserd-dev liboggz2-dev libfishsound1-dev +libmad0-dev libid3tag0-dev libopus-dev libopusfile-dev libopusenc-dev +libjack-jackd2-dev libpulse-dev libasound2-dev portaudio19-dev +fonts-dejavu-core +" + +missing="" +for p in $packages; do + if ! dpkg-query -W -f='${Status}' "$p" 2>/dev/null | grep -q "install ok installed"; then + missing="$missing $p" + fi +done + +if [ -n "$missing" ]; then + echo + echo "Installing packages:$missing" + # Some of the container's own apt sources (PPAs) are blocked too; + # apt-get update warns about them and carries on. + $sudo apt-get update -q + $sudo env DEBIAN_FRONTEND=noninteractive \ + apt-get install -y -q --no-install-recommends $missing +else + echo + echo "Packages: all installed" +fi + +# 2. Qt, from conda-forge through micromamba (both from hosts the +# container can reach: GitHub releases and conda.anaconda.org). + +echo +if [ -x "$micromamba" ]; then + echo "micromamba: installed already" +else + echo "Installing micromamba $micromamba_version in $(dirname "$micromamba")" + $sudo mkdir -p "$(dirname "$micromamba")" + # The container's proxy now and then answers 502 for a moment + $sudo curl -sSfL --retry 5 --retry-all-errors -o "$micromamba.part" \ + "https://github.com/mamba-org/micromamba-releases/releases/download/$micromamba_version/micromamba-linux-64" + $sudo chmod +x "$micromamba.part" + $sudo mv "$micromamba.part" "$micromamba" +fi + +installed_qt="" +if [ -f "$qt_prefix/lib/pkgconfig/Qt6Core.pc" ]; then + installed_qt=$(PKG_CONFIG_PATH="$qt_prefix/lib/pkgconfig" pkg-config --modversion Qt6Core) +fi + +if [ "$installed_qt" = "$qt_version" ]; then + echo "Qt: $qt_version in $qt_prefix already" +else + echo "Installing Qt $qt_version (conda-forge qt6-main) in $qt_prefix" + if [ -d "$qt_prefix/conda-meta" ]; then + action=install + else + action=create + fi + $sudo env MAMBA_ROOT_PREFIX="$(dirname "$(dirname "$micromamba")")" \ + "$micromamba" $action -y -q -p "$qt_prefix" -c conda-forge \ + "qt6-main=$qt_version" +fi + +$sudo mkdir -p "$qt_pkgconfig" +for pc in "$qt_prefix"/lib/pkgconfig/Qt6*.pc; do + $sudo ln -sf "$pc" "$qt_pkgconfig/" +done + +# 3. Libraries. +# +# repoint-lock.json pins the Mercurial libraries by Mercurial hash, +# which their Git mirrors (github.com/breakfastquay/) do not +# carry, and a conversion back to Mercurial does not reproduce it. Each +# pin was matched to a mirror commit by hand, by date and message: +# +# - The pins were taken by upstream Tony's "Update Repoint +# locations and revisions" (2024-06-25). For each, the commit is the +# last one on the mirror's master before that date, and Sonic +# Visualiser's repoint-lock.json took the same Mercurial pin shortly +# after that commit's date: +# dataquay "Fix warning", 2024-01-04 (SV pinned it the same +# day; the mirror's head is newer, from 2024-09) +# bqvec "Adjust local include policy", 2023-06-27 (head) +# bqfft "Update CI for SLEEF", 2022-08-09 (head) +# bqresample "Adjust local include policy", 2023-06-27 (head) +# bqthingfactory "Copyright dates", 2021-01-08 (head) +# +# When a pin in repoint-lock.json changes, this table must change with +# it; the script stops if they disagree. + +mirror_commit() { + case "$1 $2" in + "dataquay 79623fb778da") echo 2dbf1bed112c1a7eaaf43335abbe5ddb5c03d0ff ;; + "bqvec 291cde50db9d") echo ddfcd1716576c6bb44218c5f5696bf24a248960a ;; + "bqfft d41a117b8cbe") echo 68dc4c5735c1e0da099e8473fc4562acf2895cb8 ;; + "bqresample 38c3e524416a") echo 6cef06961f15399f8cecc414f1364dac7d83c3db ;; + "bqthingfactory 2e4bd170f57f") echo 8468b3f98d9d1768561d6f1fc95d1689af193a3e ;; + *) echo "" ;; + esac +} + +warnings=0 + +checkout() { + local name="$1" url="$2" branch="$3" commit="$4" + local short="${commit:0:12}" + if [ -e "$name/.git" ]; then + local head + head=$(git -C "$name" rev-parse HEAD) + if [ "$head" = "$commit" ]; then + echo " $name: at $short already" + return + fi + if [ -n "$(git -C "$name" status --porcelain --untracked-files=no)" ]; then + echo " $name: WARNING: has local changes and is not at $short; left alone" + warnings=1 + return + fi + # A library that has moved, as bqaudioio did from its mirror to + # the fork, is fetched from where it is now + if [ "$(git -C "$name" remote get-url origin 2>/dev/null)" != "$url" ]; then + echo " $name: origin is now $url" + git -C "$name" remote set-url origin "$url" + fi + if ! git -C "$name" cat-file -e "$commit^{commit}" 2>/dev/null; then + echo " $name: fetching from $url" + git -C "$name" fetch -q origin + fi + # Local commits that are on no remote branch would be lost from + # sight by moving the checkout + if [ -z "$(git -C "$name" branch -r --contains HEAD)" ]; then + echo " $name: WARNING: has local commits and is not at $short; left alone" + warnings=1 + return + fi + # Detached, so that no local branch is moved + git -C "$name" checkout -q --detach "$commit" + elif [ -e "$name" ] && [ -n "$(ls -A "$name")" ]; then + echo "ERROR: $name exists but is not a Git checkout; move it away and run again" 1>&2 + exit 1 + else + echo " $name: cloning $url" + git clone -q ${branch:+--branch "$branch"} "$url" "$name" + # As repoint does: the local branch at the pin, where there is one + if [ -n "$branch" ]; then + git -C "$name" checkout -q -B "$branch" "$commit" + else + git -C "$name" checkout -q --detach "$commit" + fi + fi + echo " $name: checked out $short" +} + +echo +echo "Libraries:" + +# One line per library: name, vcs, owner, repository, branch, pin +libraries=$(python3 - <<'EOF' +import json +project = json.load(open("repoint-project.json"))["libraries"] +lock = json.load(open("repoint-lock.json"))["libraries"] +for name, lib in project.items(): + print(name, lib["vcs"], lib["owner"], lib.get("repository", name.split("/")[-1]), + lib.get("branch", "-"), lock[name]["pin"]) +EOF +) + +while read -r name vcs owner repository branch pin; do + [ "$branch" = "-" ] && branch="" + if [ "$name" = "sv-dependency-builds" ]; then + echo " $name: skipped, not used on Linux" + continue + fi + if [ "$vcs" = "git" ]; then + checkout "$name" "https://github.com/$owner/$repository.git" "$branch" "$pin" + else + commit=$(mirror_commit "$name" "$pin") + if [ -z "$commit" ]; then + echo "ERROR: repoint-lock.json pins $name at $pin, which this script does not map to a commit of its Git mirror; update the table in $0" 1>&2 + exit 1 + fi + checkout "$name" "https://github.com/$owner/$repository.git" "" "$commit" + fi +done <<< "$libraries" + +# 4. Configure. The same build type as the Windows build (build.bat): +# optimised, with asserts. meson keeps the pkg-config path in build/, so +# the reconfigures ninja runs by itself find the same Qt, and it puts +# Qt's library directory in the executables' RPATH. + +setup_args="--buildtype=debugoptimized --pkg-config-path=$qt_pkgconfig" + +echo +if [ -f build/build.ninja ] && + grep -q "$qt_pkgconfig" build/meson-info/intro-buildoptions.json; then + echo "build/: configured already" +elif [ -f build/build.ninja ]; then + echo "build/ is configured with another Qt: configuring it again from scratch" + meson setup --wipe build $setup_args +else + echo "Configuring build/" + meson setup build $setup_args +fi + +if [ "$build" = "yes" ]; then + echo + echo "Building" + ninja -j 4 -C build tony pyin.so test-tony-core test-tony-app +fi + +echo +if [ "$warnings" -ne 0 ]; then + echo "Done, with warnings above" +else + echo "Done" +fi diff --git a/deploy/linux/run-tests.sh b/deploy/linux/run-tests.sh new file mode 100755 index 00000000..81d0f34e --- /dev/null +++ b/deploy/linux/run-tests.sh @@ -0,0 +1,115 @@ +#!/bin/bash +# +# Tony +# An intonation analysis and annotation tool +# Centre for Digital Music, Queen Mary, University of London. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; either version 2 of the +# License, or (at your option) any later version. See the file +# COPYING included with this distribution for more information. +# +# Runs a test executable as several processes at once, each running its +# shard of every suite (TONY_TEST_SHARD, main/test/RunSuite.h), and adds +# up what they report. The app suite spends nearly all of its time +# waiting on FakeAudioIO, which plays in real time: on the cloud +# container's 4 cores, 8 processes run it in under two minutes instead +# of about ten, and the load stays under 2. +# +# Each process has a log directory of its own, and runs under an +# application name of its own (shardApplicationName(), +# main/test/RunSuite.h). The name keeps its settings, data location and +# svcore temp directory apart from the other processes', on Linux and on +# Windows alike, so they all share the user's HOME. The script runs from +# Git Bash on Windows too, with the environment AGENTS.md gives and +# executable names with .exe. +# +# Usage, from anywhere: +# deploy/linux/run-tests.sh [-j N] [BUILD_DIR] EXECUTABLE +# +# deploy/linux/run-tests.sh test-tony-app +# deploy/linux/run-tests.sh -j 6 build_mingw test-tony-app.exe +# +# N defaults to twice the number of cores, BUILD_DIR to build. The +# results are in tmp/tl/EXECUTABLE/SHARD/SUITE.txt; the summary gives +# each suite's counts, less initTestCase and cleanupTestCase, which +# every shard runs, and every failure with its location. The exit status +# is 0 only if every shard's was. + +set -u -o pipefail + +jobs=$(( $(nproc) * 2 )) +if [ "${1:-}" = "-j" ]; then + jobs=${2:-} + shift 2 +fi +root=$(cd "$(dirname "$0")/../.." && pwd) +case "$#" in + 1) build=$root/build; exe=$1 ;; + 2) build=$1; exe=$2 ;; + *) echo "Usage: $0 [-j N] [BUILD_DIR] EXECUTABLE" 1>&2; exit 2 ;; +esac +if ! [ "$jobs" -ge 1 ] 2>/dev/null; then + echo "Usage: $0 [-j N] [BUILD_DIR] EXECUTABLE" 1>&2 + exit 2 +fi + +# BUILD_DIR as given, from where this was run +if ! dir=$(cd "$build" 2>/dev/null && pwd); then + echo "No build directory $build" 1>&2 + exit 2 +fi +build=$dir +if [ ! -x "$build/$exe" ]; then + echo "No executable $build/$exe" 1>&2 + exit 2 +fi + +out=$root/tmp/tl/$exe +rm -rf "$out" +mkdir -p "$out" + +start=$SECONDS +for i in $(seq 0 $((jobs - 1))); do + dir=$out/$i + mkdir -p "$dir" + ( + cd "$build" && + TONY_TEST_SHARD=$i/$jobs TONY_TEST_LOG_DIR=$dir \ + "./$exe" > "$dir/stdout.log" 2>&1 + echo $? > "$dir/exit" + ) & +done +wait +echo "$exe: $jobs processes, $((SECONDS - start)) s" + +status=0 +for i in $(seq 0 $((jobs - 1))); do + code=$(cat "$out/$i/exit" 2>/dev/null || echo "none") + if [ "$code" != 0 ]; then + status=1 + fi + # 1 is failed tests, which the summary lists; anything else is a crash + if [ "$code" != 0 ] && [ "$code" != 1 ]; then + echo "Process $i ended with status $code: see $out/$i/stdout.log" + for f in "$out/$i"/*.txt; do + [ -f "$f" ] || continue + if ! grep -aq "^Totals" "$f"; then + echo " it was running $(basename "$f" .txt): last line $(grep -a '^[A-Z]' "$f" | tail -1)" + fi + done + fi +done + +for suite in $(ls "$out"/*/*.txt 2>/dev/null | xargs -n1 basename | sort -u); do + cat "$out"/*/"$suite" | awk -v suite="${suite%.txt}" ' + /^(PASS|XFAIL) / && !/::(initTestCase|cleanupTestCase)\(\)/ { passed++ } + /^(FAIL!|XPASS) / { failed++ } + /^SKIP / { skipped++ } + END { printf "%s: %d passed, %d failed, %d skipped\n", suite, passed, failed, skipped }' +done + +grep -a "^FAIL!\|^XPASS\|^ Loc" "$out"/*/*.txt | sed "s#^$out/##" + +exit $status diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..953ab4b3 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,26 @@ +# Developer documentation + +For people and AI agents developing the singing-practice features of this Tony fork. What +the features *are*, for a user, is in the [top-level README](../README.md); how to work in +the repository is in [AGENTS.md](../AGENTS.md). + +These pages hold what the code cannot say for itself: why things are in the order they are +in, which rules of the Sonic Visualiser libraries the code depends on, what was decided +and rejected, and what is known to be weak. They do not list classes, members or +methods, and they do not tell the story of fixed bugs. + +| Page | What is in it | +| --- | --- | +| [building.md](building.md) | The MinGW build, and every way the environment has gone wrong; the Linux build of a cloud session; the Android build, its toolchain and the APK; CI | +| [testing.md](testing.md) | The test executables (core, app, and the dev checks'), the fakes and helpers, how tests turned out to be worthless, races, and testing the phone's code on the desktop | +| [architecture.md](architecture.md) | What the fork adds, `tony_core` / `tony_app`, who owns what, and the rules for layers, models, commands, playback and session files; the smaller features, the lyrics and their editor among them | +| [recording.md](recording.md) | Record to Stop, step by step and why in that order; latency; pre-roll; record into selection; the live tracker | +| [takes.md](takes.md) | Takes: decisions, the audio swap, ranged analysis and merge, undo, the coverage strip, files and sessions, limitations | +| [forks.md](forks.md) | The `jhhr/*` library forks: how to change one, what each adds, known defects | +| [open-points.md](open-points.md) | Decisions waiting for the user, things not built, weak spots | +| [manual-checklist.md](manual-checklist.md) | What needs a real device, real ears or real eyes, starting with the device check (Calibrate Audio with the dev checks, on each driver) and the driver menus, and what to try on the phone. Tried so far: Calibrate Audio and dev runs on the user's PC and on the user's phone, the phone's everyday use, and the looks from cloud screenshots; each section says what is left | +| [mobile-port.md](mobile-port.md) | Porting to a phone: decisions, what was built that any port shares (the compact layout, touch, zoom, sizes, the song scroll bar), Android against Sailfish OS, what stays open | +| [port-android.md](port-android.md), [port-sailfish.md](port-sailfish.md) | Platform facts with sources, confirmed or disproved by the port; for Android what was built (the Oboe audio, files and storage, the lifecycle) and its known limitations; for Sailfish OS the research and the work a port would take | +| [calibrate-audio.md](calibrate-audio.md) | Calibrate Audio, which measures the round trip through an earcup held to the mic, and the dev checks that settle the checklist's device items: what they do, what each verdict and check means, which numbers to send back, design, tests, decisions, open points, the user's runs | +| [windows-shards.md](windows-shards.md) | Plan, built and proven on Linux, left to try on Windows: sharded test runs from Git Bash on Windows, each shard kept apart by an application name of its own | +| [audio-drivers.md](audio-drivers.md) | Playback > Audio Driver and Audio Latency on Windows (MME, DirectSound, WASAPI): why, decisions, facts, the design in Tony, tests, what the measurements on the user's PC are to settle, open points | diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 00000000..40e8986a --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,507 @@ +# Architecture of the singing-practice fork + +What this fork adds to upstream Tony, who owns what, and the rules of the Sonic Visualiser +(SV) libraries that the code depends on and that are not visible from Tony's own sources. +Member lists, slots and method names are in the headers; they are not repeated here. + +## What the fork is for + +Upstream Tony analyses the pitch of one recording. This fork makes it a singing practice aid: + +1. **Two pitch tracks in one pane**: the reference (black) and the singing (orange). +2. **Live pitch dots** while recording, from a fast YIN tracker on its own thread. +3. **pYIN analysis of what was just recorded** replaces the dots when the take stops. +4. **Partial recordings and takes**: record from the playhead into part of the song, keep + the rest, erase, undo, and keep several takes. See [takes.md](takes.md). +5. Around that: play the reference while recording, latency compensation (with a round + trip Calibrate Audio can measure), pre-roll, record into selection, an octave-shifted + "alternate" pitch track to follow, timed lyrics along the bottom of the pane with the + word being sung highlighted and every word editable in place, and a background music + track that is played but never analysed. +6. **An Android build** ([port-android.md](port-android.md)): a compact layout of large + buttons, touch gestures on the panes, pitch and lyrics drawn at a size for a small + screen, sessions opened and saved where they are, and an audio backend of its own. The + compact layout and the gestures work on the desktop too, where the suites test them. + +The user-facing description is in the [README](../README.md). + +## Where things are + +Everything of Tony's own is in `main/`. The sibling directories (`svcore/`, `svgui/`, +`svapp/`, `pyin/`, `bq*/` ...) are separate repositories checked out by repoint and +**gitignored here**; see [forks.md](forks.md). + +`meson.build` splits `main/` into two static libraries so that the test executables link +only what they need: `test-tony-core` links `tony_core` alone, `test-tony-app` and +`test-tony-dev` both. `tony_app` also compiles svgui and svapp in. The application links +both **whole** (`link_whole`), which keeps objects nothing refers to, such as +`AndroidMediaReadStream`'s registration with bqaudiostream. On Android the application is +a shared library (`libTony_arm64-v8a.so`) whose `main()` Qt's Java launcher calls, and no +test is built. + +| Library | Rule | Contents | +| --- | --- | --- | +| `tony_core` | No GUI, no document, no layers. Unit-tested without a window. | `RealtimePitchTracker`, `LiveDotsFeed`, `OctaveSlips`, `Coverage`, `TakeAudio`, `TakeEvents`, `SingingTakes`, `TakesFile`, `TakeTiming`, `TakeDiff`, `Lyrics`, `LyricsTtml`, `LyricsEdit`, `LatencyUtils.h`, `LatencyCheck`, `LatencyCalibration`, `AudioDriverSettings`, `AudioRoute`, `StreamLatency`, `PinchZoom`, `VerticalZoom`, `SongScroll`, `PopupArea`, `AndroidFiles`, `DecodedPcm`, `LogFile`; on Android only, `AndroidMediaReadStream` | +| `tony_app` | Anything that touches a `Document`, a `Layer` or a window. | `MainWindow`, `Analyser`, `AlternatePitchTrack`, `CoverageStrip`, `LyricsTrack`, `LyricsEditor`, `LyricsSize`, `PlotSize`, `TakeCommands`, `TakeLayers`, `PaneUtils`, `AudioCheckRunner`, `CalibrateAudioDialog`, `AudioCheckIndicator`, `AudioDriverMenus`, `CompactLayout`, `SongScrollBar`, `TouchGestures`, `TouchMenuStyle`; on Android only, `OboeAudioIO`, `AndroidStorage`, `AndroidScreen`; in development builds only, `main/dev/` (`DevChecks`, `TakeObserver`) | + +Android-only files are in the `if system == 'android'` additions to those lists, and +Android-only code elsewhere is under `#ifdef Q_OS_ANDROID`; neither may change what the +desktop does. What only the Android build calls but needs no Android API (`AndroidFiles`, +`StreamLatency`, `DecodedPcm`, `PopupArea`, `LogFile`, `TouchMenuStyle`) is built +everywhere, so that the desktop suites test it. Qt defines `Q_OS_LINUX` on Android too: +code for the Linux desktop alone needs `!defined(Q_OS_ANDROID)` as well. + +Development builds are every build type but `release`: they define `TONY_DEV_CHECKS` and +compile `main/dev/`, and everything that uses it elsewhere is inside `#ifdef +TONY_DEV_CHECKS`, so that a release build compiles with no `main/dev/` file +([calibrate-audio.md](calibrate-audio.md), §6). + +When adding a file: put it in the right `*_files` list, and in the matching `*_moc_files` +list **only if** it has `Q_OBJECT`. Logic that can be written as pure functions or a plain +struct goes in `tony_core` so that it can be tested cheaply — `TakeTiming` (all the frame +arithmetic of a take) and `TakeEvents` (what an erase does to events) are the models to +follow. `MainWindow` then only fills the struct in and puts the answer on screen. + +## Who owns what + +- `MainWindow` has two `Analyser`s: `m_analyser` for the reference (the document's main + model) and `m_analyser2` for the **active** singing take. `Analyser` takes a + `ColorScheme`; the secondary one makes no spectrogram and mutes its pitch and notes. +- **Both analysers share pane 0.** Pane 1 is the time ruler. Never put the singing track + in a pane of its own. +- An `Analyser` recognises a pitch or notes layer as its own when the layer's model has + the analyser's audio model as its **source model**. This one rule is what session + restore, the audio swap and take switching all rest on, and it is why layers of + inactive takes have their source model cleared (see [takes.md](takes.md)). +- `Analyser::fileClosed()` clears the layers but **not** `m_fileModel`. +- Helper objects that own one layer each and are only wired by `MainWindow`: + `AlternatePitchTrack`, `CoverageStrip`, `LyricsTrack`. All three watch + `Document::layerAboutToBeDeleted` in case someone else deletes their layer, and all + three must be deleted in `~MainWindow` **before** the base class deletes the document + (as must `m_analyser2`). `LyricsEditor` owns no layer: it finds the lyrics through + `LyricsTrack` at every event, and is deleted before it. +- `RealtimePitchTracker` is a `QThread` that only **reads** the recording's + `WritableWaveFileModel` and keeps its estimates for `takeEstimates()`. It never touches + the pitch model; `LiveDotsFeed` (a member of `MainWindow`) takes the estimates on the GUI + thread every 40 ms and `MainWindow::onRealtimePitchDetected()` writes them there. Every + hop passes, on the tracker's thread, through an `OctaveSlips` of the tracker's own. Stop + the feed, then the tracker, **before** releasing the model it reads + (`stopRealtimePitchTracker()`). +- **Calibrate Audio** ([calibrate-audio.md](calibrate-audio.md), §9): `MainWindow` owns + the `AudioCheckRunner` and, in development builds, the `DevChecks` (both made with the + window), and the `CalibrateAudioDialog` (made the first time it is asked for). + `DevChecks` drives the runner and owns a `TakeObserver`. The runner, the dev checks and + the observer are `friend`s of `MainWindow`, as they drive the take path and read the + take's state; the development ones only under `#ifdef TONY_DEV_CHECKS`. `~MainWindow` + deletes the dialog, then the dev checks, then the runner, before anything they read; + `closeSession()` tells the runner, then the dev checks. They are driven by timers and + signals, never a nested event loop: the window can be closed during a run. The dialog + owns the `AudioCheckIndicator` it shrinks to while a check runs, which `calibrateAudio()` + puts at the right end of the status bar. + +For a phone, all of which the desktop builds too: + +- **`CompactLayout`**: `MainWindow` makes it before the menus and hands it the parts in + `setupCompactLayout()`: the actions for its one toolbar, what to hide and what to show + instead. `main()` switches it on before the window is shown, on Android always and on + the desktop with `--compact`; View > Compact Layout switches it. Switching off restores + what switching on saved. Anything new that a phone should not show goes into those + parts. A shortcut works only while a visible widget holds its action or its menu, so + the toolbar's menu button holds every menu of the hidden menu bar. +- **`SongScrollBar`** stands in for svgui's `Overview`, in its grid cell, in the compact + layout, which shows it. `syncSongScrollBar()` hands it the reference's audio and pitch + model ids whenever they may have changed (the analyser's layers changed, its analysis + completed or merged, the main model changed, the session closed); it holds ids, not + models. It moves the panes through the `ViewManager`, as the overview does, and leaves + the playhead alone. `SongScroll` is its arithmetic. +- **`TouchGestures`**: `paneAdded()` gives every pane one, which the pane owns, with a + `VerticalRange` of callbacks into the window. `PinchZoom` and `VerticalZoom` are its + arithmetic (see [Touch](#smaller-features) below). +- **`PlotSize`** and **`LyricsSize`**, made before the menus: a View submenu each, + remembered in QSettings, with defaults of their own on Android (150 % and 50 %, against + 100 %). `PlotSize` sets the view manager's plot scale (svgui fork); `LyricsSize` goes to + `LyricsTrack::setTextScale()`. +- **`TouchMenuStyle`** is the application's style on Android only, set by `main()` before + any widget is made, with the main window for its safe area margins. `PopupArea` is its + arithmetic, which `CalibrateAudioDialog` places itself by too. + +Android only: + +- **`OboeAudioIO`** is the audio device ([port-android.md](port-android.md)): Android's + `MainWindow::createAudioIO()` makes it, with input once recording has been asked for and + the microphone allowed, else for output alone; a 250 ms timer (`checkAudioDevice()`) + stops whatever is going on and replaces one that has failed, up to three times in ten + seconds. `StreamLatency` is its latency arithmetic, and `AudioRoute` the route it + reports (as an `AudioRouteReporter`, which the tests' `FakeAudioIO` is too), by which a + measured round trip is kept ([calibrate-audio.md](calibrate-audio.md), §5). +- **`AndroidStorage`** (All files access; documents read and written through a picker's + grant) is owned by `MainWindow`. `AndroidFiles` is the path work behind it and behind + `main()`'s links to the Vamp plugins. `AndroidScreen` keeps the screen on while + `CalibrateAudioDialog` runs a check. `LogFile` is the copy of the system log that + `main()` writes and Help > Save Log... saves. +- **`AndroidMediaReadStream`** reads what the Android build's libraries cannot (M4A, AAC, + FLAC, Ogg and the like) through Android's decoders. It registers itself with + bqaudiostream's reader factory, so svcore's reader finds it; nothing refers to it, which + is why the libraries are linked whole. `DecodedPcm` makes the decoder's buffers into + float frames. + +The reference is the pane's **work model** (`Pane::setWorkModel()`, svgui fork, set in +`analyseNewMainModel()`). Without that the pane greys itself out from the end of the +take's audio file, or of the recording being written. + +Colours: reference pitch black / notes bright blue; singing and live dots orange / notes +bright purple; alternate pitch faded brown, dark brown while a take is recorded; both +waveforms grey, pale grey while lyrics are on show over them. + +## Rules of the SV libraries + +These were all learned from crashes or wrong behaviour. They hold for any new code. + +### Models and layers + +- `Document` owns all models and layers. Register a model with `addNonDerivedModel(id)`. + **Never call `ModelById::release()` on a model the document knows** — double free. +- `Document::releaseModel()` silently does nothing while any layer still references the + model. So *some layer must hold a model* for as long as it is wanted: that is the only + reason the recording and the background music have hidden waveform layers. +- Silent teardown is **`deleteLayer(layer, true)` and nothing else**. It removes the layer + from every view, releases the model if unreferenced and deletes the layer. +- **Never `removeLayerFromView()` followed by `deleteLayer()`** on the same layer: + the first pushes a `RemoveLayerCommand` holding a raw pointer onto the undo stack. +- `Pane::removeLayer()` / `View::removeLayer()` do **not** update + `Document::m_layerViewMap`. Deleting a pane whose layers are still in that map leaves + a dangling `View*` that the next `deleteLayer()` dereferences. Remove a pane only with + `pruneExtraPane()` (`main/PaneUtils.cpp`), which force-deletes the layer that owns the + given model and `detachLayerFromView()`s the shared ones (time ruler) first. + Precondition: another layer already references that model. +- `createLayer()` + `setModel()`, not `createEmptyLayer()`: the latter makes a throwaway + model that enters the play source. +- `View::removeLayer()` fails to disconnect `layerMeasurementRectsChanged`; + `TakeLayers::raise()` does it by hand. + +### Tony's own layers make no undo commands + +Every layer Tony makes for itself — analysers' layers, pitch candidates, live dots, the +recording's hidden waveform, the alternate pitch track, the coverage strip, the lyrics, +background music — is added with **`Document::attachLayerToView()`** (svapp fork): in the view and in +the layer-view map, so the session keeps it, but no command and no modified flag. +`addLayerToView()` (the undoable Add Layer) must not be used for these: Undo after a take +has to find the take. Whoever attaches the layer calls `documentModified()` if the change +should count. Such a layer is shown and hidden with `showLayer()` and removed with +`deleteLayer(layer, true)`, never by command: an undo that takes a layer out of the pane +leaves whoever keeps a pointer to it holding a layer that the redo stack owns and deletes. + +### Commands + +- `CommandHistory::addCommand()` clears **and deletes** the redo stack. A command pushed + while an undo or redo is running therefore destroys the command that is running. + Nothing reachable from `execute()` / `unexecute()` may push a command. +- `openPath()` pushes an `AddPaneCommand`. That is why a take's audio is opened with + `MainWindow::openTakeAudioFile()` (a `ReadOnlyWaveFileModel` + `addNonDerivedModel()`, + no pane, no layer, no command, no Recent Files entry) and never `openPath()`. +- A command whose work is already done is pushed with `addCommand(command, false)`. +- Commands hold **values only** (paths, `Coverage`, event lists), never layer or model + pointers: by the time an undo runs the models have been made again several times. + +### Playback + +- The play source takes in the model of **every layer in a view**, playable or not, and + what it holds decides where playback ends. Models that must not extend playback + (coverage strip, lyrics, layers of inactive takes) are taken out with + `m_playSource->removeModel()`. A session load adds each layer to its view and so puts + their models back in: take them out after a load too. +- The svapp fork emits `Document::modelAboutToBeReleased(ModelId)` and `MainWindowBase` + removes the model from the play source on it. Upstream only did so from + `RemoveLayerCommand`, which forced deletes never run. +- Mute with `getPlayParameters()->setPlayAudible(false)` directly. `Analyser::setAudible()` + and `Analyser::setVisible()` **write QSettings keys that both analysers share**; use them + only for the user's own toggles, never for temporary states such as "during a take". + For temporary hiding use `showLayer(pane, false)`. +- The toolbar's level controls (`LevelPanToolButton`) answer a gain between their notches + by moving to the nearest notch and emitting it, and the window then sets that gain + through `Analyser::setGain()` and `setAudible()`, writing the shared settings. A play gain + set directly on the reference (as the audio check's is) needs the control moved first, + under a `QSignalBlocker`, before `updateLayerStatuses()` shows it. + +### Selection and tools + +- Tony's pane stack is built with `NoPropertyStacks`, so `Pane::getSelectedLayer()` is + always null and **`Analyser::stackLayers()` / `PaneStack::setCurrentLayer()` do nothing**. + A tool acts on the topmost layer of its kind (`Pane::getTopFlexiNoteLayer()`, which + skips dormant layers in the svgui fork). Layer order is changed with `TakeLayers::raise()`. +- Making any selection starts `Analyser::reAnalyseSelection()` on the reference, so a + transformer is usually running afterwards. Tests cannot assert + `!haveRunningTransformers()` after selecting. + +### Drawing + +- A pane draws its layers into an image at the **whole** pixel ratio above the screen's + (3 on a phone at 2.75), through a `ViewProxy` whose coordinates are those physical + pixels, while mouse events come in the pane's logical pixels. A size a layer gives in + pixels is therefore physical: size a plot element with `scalePlotSize()` (it follows + View > Plot Size), anything else with `scalePixelSize()` (it follows the font), and a + hit area as what is drawn. The coverage strip and the lyrics use the latter. +- A change to the model of a layer in the pane's cache has the pane draw every cached + layer again. A layer whose model changes many times a second is kept out of the cache + (`Layer::setCachedInView(false)`, as the live dots are), and whatever lies in front of + it is then drawn at every paint too ([recording.md](recording.md#the-live-tracker)). + +### Signals + +- Connect with **member pointers**, not `SIGNAL()`/`SLOT()` strings, for anything whose + signature has `sv::` types when the receiving class is outside namespace `sv`: the + string form need not match, and then fails silently at run time (this kept + `Analyser::layerAboutToBeDeleted` from ever being called). Whether it matches depends + on the type and the Qt version: a pointer (`Layer *` against `sv::Layer *`) never + matches; a registered value type (`ModelId`, `sv_frame_t`) matches from Qt 6.5 but not + in 6.4. Member pointers are checked when compiling and match under any version. +- `audioFileLoaded()` is emitted for `CreateAdditionalModel` too (singing track, background + music). `analyseNewMainModel()` returns early if the main model is the one it already + analysed (`m_analysedMainModelId`); handing the reference to `m_analyser` twice forgets + its pitch candidates and double-connects `regionOutlined()`. +- `recordStatusChanged(true)` fires inside `startRecording()`, **before** the base class + has added the recording's model to the document. Work that needs the model is deferred + with `QTimer::singleShot(0)`. + +### Session files + +- `Document::toXml()` writes a model only if a layer **in a view** uses it. Layers that + store data (inactive takes) therefore stay in pane 0, hidden. +- A layer with `setSavedInSession(false)` (svgui/svapp forks) is left out, as is a model + that only such layers show. Used for layers Tony makes again by itself on load. +- `SVFileReader` only warns about unknown elements, which is what lets the `.ton` carry + Tony's `` element. +- An event's value is written with six significant figures. After a round trip compare + frames exactly and values with a tolerance. +- Layer **object names carry identity** across a save: `"Alternate Pitch Track -1"` holds + the octave count, `"Take 2 Pitch"` links a layer to its take, `"Lyrics"` marks the + lyrics, `"Background Music"` the background music. They are not translated. + +### Miscellaneous + +- `floatvec_t` (bqvec allocator) is not `std::vector`; copy before handing to code + that wants the latter. +- `ModelById` is in `data/model/Model.h` in the pinned svcore. +- `WritableWaveFileModel::addSamples()` does not update the read view; the record target + calls `updateModel()` on a timer (10 ms in the svapp fork, ~200 ms upstream). Readers + of a recording in progress poll and wait. +- The MinGW build force-includes `main/mingw_byte_fix.h` to resolve the C++17 + `std::byte` / `byte` clash; do not remove it. +- A path, a URI or a name the user typed goes into a message in one `arg()` call with + all the arguments (`arg(a, b)`), or the last of a chain: a `%1` or `%3A` in it (a take + called "a %1", a `content://` URI) is taken for a placeholder by the `arg()` after it. + +## Smaller features + +**Alternate pitch track** (`AlternatePitchTrack`): a copy of the reference pitch model +shifted by -3..+3 octaves (never 0), in a model of its own with **no source model** so no +analyser claims it. It rebuilds 50 ms after a burst of changes to the source (queued: +pYIN writes from its own thread), and `MainWindow::syncAlternatePitchTrack()` re-points +it on `m_analyser::layersChanged()`, because Analyse Now replaces the reference's pitch +layer and model. Found again after a session load by its object name (`adopt()`). During +a take the reference pitch layer is hidden with `showLayer()` and comes back the moment +recording stops. + +**Background music**: loaded with `openPath(CreateAdditionalModel)` under the +`m_loadingBackgroundMusic` flag so `modelAdded()` does not take it for a singing track; +given a hidden waveform layer of Tony's own **before** the extra pane is pruned. The +session saves that layer and its model, and with the model its mute, gain and pan; the +file is looked for as the reference is, and one that is not found leaves the session +incomplete. On opening, `adoptBackgroundMusic()` finds the layer by its object name +`"Background Music"`, **before** `dropRestoredSingingTrack()`, which would otherwise take +its model for a singing track's, and drops the layer of music that could not be read (a +waveform layer may be without a model). Sessions saved before the layer had its name +open without their music. + +**Load Singing Track** follows the same order: `analyseNewSingingModel()` synchronously +after `openPath()`, and only then prune the extra pane — the imported waveform in that pane +is the only reference to the model until `m_analyser2` has a layer of its own. It ends with +`clearTakeHistory()`, which also disposes of the "Import" command for the pruned pane. + +**Touch** (`TouchGestures`, one per pane: an event filter in `main/`, not a change to +svgui, so that synthetic touch events test it on the desktop). One finger is left to Qt, +which makes mouse events of a touch nothing accepts, so taps, drags and selections go +through the pane's mouse handling as before; the first press is held back until the +finger moves, lifts or has been down 500 ms, so that a long press or a second finger +leaves no drag, selection or playhead move behind. Why the pane subscribes to a gesture +that never happens is in `TouchGestures.cpp`: it is how the second finger reaches the pane +rather than the scroll area. Two fingers zoom and scroll time; a long press opens the +pane's right-button menu, which then waits for a tap. Spread up the pane and moved up and +down, the fingers zoom and scroll the **frequency range**, which is the reference +analyser's spectrogram's, dormant or not: every pitch and note layer in the pane defers +its scale to the topmost Hz layer with a scale of its own, which is that one. So the range +is zoomed only in the reference's pane and only while it draws Hz on it, between A0 and C8 +and never narrower than a major third (`VerticalZoom::pitchLimits()`). It is not undoable +and marks nothing modified; the session saves it with the spectrogram, and a new +reference resets it. A zoom is anchored at the middle of the pitch on show (both +analysers' pitch tracks and notes, those not hidden, in the pane's time range), pulled +towards the pane's middle as it zooms in, so that a low voice stays in view; with none on +show, about the fingers. The median was rejected as the anchor: it centres a lopsided +phrase badly while it still fits. Not built: the range following the pitch in playback, +and a "fit the pitch" action. + +**Song scroll bar** (`SongScrollBar`, compact layout only): the whole song, a faint +contour of the reference's pitch (per pixel column its lowest to highest, on a log scale; +unvoiced frames leave gaps), a thumb for what the panes show and the playhead. The contour +is drawn into an image at the pixel ratio and made again only when the pitch model has +settled after a change, on a resize or a new ratio; playback's paging moves the panes +without a signal, so the thumb is checked each time the playhead moves and the strip +repainted only when something moved a pixel. The song's extent is the reference's audio +(a take past its end is not shown), and nothing of the singing or the takes is drawn: the +simpler option. + +**Lyrics** (`Lyrics` and `LyricsTtml` read the file, `LyricsTrack` owns the layer, +`LyricsEditor` edits the words): one `RegionModel` in pane 0 on the reference's timeline, +a region per word (or per line, for a line with no word times): frame = start, +duration = end - start (at least one frame), label = the word, value = the line's index, +which is what the bold line starts go by. It is drawn by the +svgui fork's `PlotLyrics` style ([forks.md](forks.md)), in boxes along the bottom of the +pane just above the coverage strip, because a session restores only layers +`LayerFactory` can make. Found again after a session load by its untranslated object +name `"Lyrics"`, in `analyseNewMainModel()` after the alternate pitch track. Its model is +taken out of the play source after an import and again after a load: a word past the end +of the reference would hold playback open. It is **never the pane's top layer**, because +the pane takes its hover readout and vertical scale from the top layer and this one has +neither. `show()` raises the layer that was on top before; `adopt()` raises the one under +the lyrics if the session was saved with them on top; and when any other layer is deleted +(the alternate pitch track turned off, a take deleted) a zero-time timer does the same, +because that layer is still in the pane when `layerAboutToBeDeleted` arrives. Not tied to +takes: the take code finds layers by take name, source model or extra pane, so it never +finds this one, and it stays on show during a take, when the singer needs the words most. +Import and Remove push no command and leave the undo history alone, like Load Background +Music: the simpler option, and the file is still there to import again (edits made in +Tony since are not: Export Lyrics keeps them). Show Lyrics is the layer's own +visibility, which the session saves, not a QSettings key. Both parsers strip control +characters (and U+FFFE, U+FFFF, which the UTF-8 decoder lets through) from every label, +and the editor cleans a typed text the same way: XML 1.0 cannot hold them, and one in a +label would make the `.ton` unreadable. + +**Lyrics files.** `parseLyrics()` reads the file as TTML if its first character that is +not blank, after a byte order mark, is `<`, and as LRC otherwise: an LRC file never +starts with `<`, so the content decides whatever the file is called. TTML is read as +Apple Music, the Moises-Lyric-Exporter and AMLL TTML Tool write it, a `

` per line and +a timed `` per word, with elements matched by local name in any namespace (files +without the TTML namespace exist). Its times are read as **absolute**: strict TTML makes +a child's time relative to its parent's, but no lyrics tool writes them so, and read that +way every word would move by its line's start. Timed spans with no white space between +them are syllables of one word and are joined: Tony edits words, and the exporter writes +punctuation as a span of its own straight after the word. Background vocals, +translations and romanisations (`ttm:role` `x-bg`, `x-translation`, `x-roman`, +`x-romanization`) are skipped with a warning: a translation is not what is sung, and +background vocals overlap the lead's words in a row that has room for one word at a time. +A `

` with no timed spans is one word, the whole line. Ends the file does not give are +inferred as for LRC; the exporter's TTML gives them all, which is why the README says to +set the exporter to TTML. A file with a ` Edit Lyrics is on. In the box row it keeps from the pane only +what it acts on: a left press on an edge and the drag it starts, up to the release; a +left press with Shift held anywhere in the row and the drag it starts; a +double-click inside a word; a right press, for the words' menu; and moves with no button +held, where the cursor and the context help are its own. Everything else reaches the pane +as it would without edit mode, so a click still moves the playback cursor. Editing needs +a mode because words touch: the row is edges nearly everywhere, and an editor always on +would take the clicks meant for the cursor. + +- **Edit mode goes off in one place**, `updateMenuStates()`, whenever + `lyricsEditAllowed()` is false: the lyrics removed or hidden, recording started, the + session closed (through `documentRestored()`). Each of those ends in + `updateMenuStates()`. An import switches it off itself, before the words there go. + Going off finishes a drag in progress, as a release would, and so pushes its command; + when the lyrics have gone first (closing the session deletes the document before + `updateMenuStates()` runs), the drag is dropped unpushed instead, as below. + `updateMenuStates()` runs during undo and redo too, where a push would delete the + command running, so nothing an undo or redo does may make `lyricsEditAllowed()` false: + the lyrics' presence and visibility stay out of commands. +- **One `ChangeEventsCommand` per edit** ("Move Word Start", "Move Word End", "Change Word + Text", "Add Word", "Delete Word", "Shift Lyrics"), holding the model's id and `Event` values, pushed + done with `addCommand(command, false)`; `CommandHistory` marks the session modified, and + the session saves the model as it is. A drag changes the model at every move, so that + the word follows the pointer and the layer lays the words out again (svgui fork), and + its command is pushed **only at the release**; a drag that ends where it began pushes + nothing. The editor pushes only from a mouse event or a menu choice, never from + anything an undo or redo reaches. Take operations clear the history, lyrics steps with + it; Import and Remove do not, and a lyrics step left from before them does nothing, its + model being gone. +- **The drag rules are fed the words as they were at the press**, at every move: the + limits (the neighbour, the 20 ms minimum) come from where the word was then, so a drag + back puts the word back. Fed the moved words, the limits would travel with the word. + The edge moves as far as the pointer has, in frames, since the press, so a press a few + pixels off the edge makes no jump, and a view that scrolls in the middle of a drag + changes nothing. +- **The model can change under a drag** (a keyboard undo that takes the word away, the + lyrics removed or replaced). The editor finds the layer and model through `LyricsTrack` at every event + and checks that the word is still what the drag made it; if not, the drag's command is + deleted unpushed and the rest of the drag, to the release, is swallowed, as the pane + never saw its press. +- **The box row is the layer's** (`getLyricsBoxRow()`), never worked out again from the + pane: the layer knows where it painted, in the pane's logical coordinates, the ones a + mouse event has, which on a high-DPI screen are half those of the proxy it paints + through. The row is empty before the first paint, and hidden lyrics keep the row they + were last painted in, so the editor checks visibility as well. The boxes' x come from + the pane's `getXForFrame()`, as the layer paints them. +- **After a question, look again.** The text dialog runs an event loop of its own, in + which anything can happen: an import, a session closed, an undo. So after it the edit + looks the model up again (the same id, edit mode still on) and the word in it (still + there, unchanged), and does nothing if either has gone; Add Word works its span and line + out again from the words as they are then. The words' menu is `popup()`ed, as Tony's + own menu is, so that the question is not asked from inside the pane's event handling; + its entries hold values (the model's id, the word, a frame) and are looked up again + when chosen. +- Add Word goes at the **last** frame of the column clicked: the first can be inside the + word before, whose end lies in the column after its box. +- **Shifting all the words** (a Shift-drag in edit mode, or Edit > Shift Lyrics..., which + is to be had whenever `lyricsEditAllowed()` is, edit mode on or not): every start and + end moves by one amount, clamped by `LyricsEdit::clampShift()` so that the first word + does not go before frame 0; a shift that comes to 0 pushes nothing. What a press is, + an edge's drag or all the words', is decided at the press, whatever Shift does after. + The command takes **all the words out, then puts all the shifted ones in**: one out + and one in at a time, a shifted word could meet an original still in the model. + A Shift-drag must **not fill its command as it goes**, as an edge drag does: + `ChangeEventsCommand` folds an add and the remove of the same event only when the two + are next to each other, which they never are when all the words go out and come back + at each move, so the command would keep every word's every step and its undo would + replay them all. The drag moves the model itself, with no command, checking at each + move that the model holds exactly the words it last put there (anything else, and the + drag is dropped, as an edge drag is); at the release it puts the words back as they + were and pushes one command from the words at the press to the words at the release. + The dialog's shift, like the text question, looks the lyrics up again after the + dialog (the same model, editing still allowed) and finishes a drag in progress first. +- Pane 0 of a reference has no context-help connection (only `newSession()` makes one), + so the editor's help goes straight to the status bar, and the editor clears it itself + when the pointer leaves the row. diff --git a/docs/audio-drivers.md b/docs/audio-drivers.md new file mode 100644 index 00000000..fdbca7d2 --- /dev/null +++ b/docs/audio-drivers.md @@ -0,0 +1,321 @@ +# Audio drivers: WASAPI next to MME + +On Windows, **Playback > Audio Driver** chooses which of Windows' audio APIs Tony plays and +records through (MME, DirectSound or WASAPI), and **Playback > Audio Latency** how much +latency it asks that driver for (10 to 200 ms). Each driver keeps its own devices, its own +latency and its own measured round trip. WASAPI at 20 ms is the default where it is built +in, and MME, which every stream went through before there was a choice, elsewhere: the +user's runs (§7) gave WASAPI a third of MME's round trip, with takes placed no less +steadily. + +This page is the design as built: why, the decisions, the facts it rests on, the design in +Tony, the tests, what the measurements on the user's PC are to settle, and what is open. +The bqaudioio fork's side is in [forks.md](forks.md#bqaudioio); how the latency asked for +differs from the one takes are placed with, in [recording.md](recording.md#latency); what +to try by hand, in [manual-checklist.md](manual-checklist.md), sections 1 and 2. + +## 1. Why + +- **MME is slow and unsteady.** On the user's PC Calibrate Audio measured a round trip of + about 300 ms, with every stream asking for 0.2 s on both sides, and the offset between + input and output moved by about 13 ms from one take to the next (every take then + restarted the stream), 5 to 20 ms over three calibrations, while the sweeps within one + take agreed to 0.3 ms ([calibrate-audio.md](calibrate-audio.md), §10). No one stored + figure then placed every take; the dev checks' items 1 and 2 failed on it. +- **WASAPI** is the Windows audio engine itself; MME and DirectSound are layers over it. + Its shared mode mixes with other programs as MME does, with buffers as small as the + engine's period (typically 10 ms). It turned out no steadier across stream restarts + (§7), so the stream is now kept running between takes (§5). +- **The latency asked for was fixed at 0.2 s.** At that figure WASAPI would keep MME's + buffers and gain nothing. +- **The device menus mixed every host API.** PortAudio lists every device once per host + API (MME, DirectSound, WASAPI, WDM-KS). bqaudioio's `port` takes the first name that + matches, usually MME's, but MME cuts names to 31 characters, so a long name picked from + the menu could only match WASAPI's or WDM-KS's entry, which then opened at the Windows + mixer's rate (usually 48 kHz). That is where the "48 kHz device" of the calibrate-audio + work came from. Each driver now lists its own devices only. + +## 2. What the user sees + +- **Audio Driver**, in the Playback menu before the two device submenus: MME, DirectSound, + WASAPI, in that order, the one in use ticked. **Audio Latency** next to it: 10, 20, 50, + 100 and 200 ms, the one chosen for that driver ticked; where none has been, 20 ms on + WASAPI and 200 ms on the others. Both are shown only where two drivers or more are built + in, which is on Windows. Linux has none of the three. Nor has Android, where Tony opens + a device of its own, `OboeAudioIO` ([port-android.md](port-android.md#audio)), on + whatever route the phone has: there is no driver, device or latency to choose, and the + compact layout hides these menus and the device submenus as well. +- **Choosing a driver** stops playback and opens the device again through it, with the + devices chosen under it (the driver's own default devices where none are) and its + latency. The device submenus then list that driver's devices and write its keys; going + back to another driver finds its devices as they were left. **Choosing a latency** stops + playback and opens the device again. Choosing what is ticked does nothing. +- Both are **greyed out during a take and while a check runs**: either would open the + device again under the take. +- **The first start with the menus** names WASAPI (MME where there is no WASAPI) and + carries the devices chosen before over to it. A device name it does not list shows as + "(not connected)", ticked, and its own default device opens instead. A driver already + named, by the user or by an earlier build, is left as it is. +- **A round trip measured before the menus** was kept under no driver, and is not carried + over as the devices are: the Playback menu's latency line reads "driver's figure" until + Calibrate Audio is run, and its figure kept, on each driver used. +- **The reports** name the driver: Calibrate Audio's instructions and result page + ("Driver:"), and `DevChecks.txt`'s header ("Audio driver:", and "Latency asked for:"), + so that runs on two drivers, or at two latencies, can be told apart. + +## 3. Decisions + +| Decision | By, when | Why | +| --- | --- | --- | +| Work on `feat/wasapi`, branched from `feat/tonyandroid` after the calibrate-audio merge, merged back when done | user, 2026-09-26 | Another session works on `feat/tonyandroid` | +| **WASAPI at 20 ms is the default** where it is built in, MME elsewhere; a driver already named stays | user, 2026-09-26, from the runs of §7 | A third of MME's round trip and the dots half as far behind the cursor, takes as steady; 10 ms gained 7 ms more with half the buffer. Until measured, MME had stayed the default | +| The driver changes are in the fork `jhhr/bqaudioio`; Tony pins it | user, 2026-09-26 | bqaudioio chooses the host API, the stream's rate and its latency; upstream is on sourcehut | +| **A driver is a bqaudioio implementation**: `mme`, `directsound`, `wasapi`, each PortAudio restricted to that host API; `port` stays as it was (all host APIs) and is not offered | lead | Tony's device menus, the saved devices, svapp's `createAudioIO()` and the stored round trip (`LatencyCalibration::Key::implementation`) were already per implementation: no svapp change | +| WASAPI in **shared** mode, with `paWinWasapiAutoConvert` on both sides | lead | The input's and the output's mixers can run at different rates, and the stream opens at the output's; shared mode leaves other programs' sound alone | +| A device at 48 kHz needs nothing more for placement | `feat/tonyandroid` | The recording is resampled to the reference's rate before the splice, and `TakeTiming` converts device frames; Calibrate Audio judges such a device like any other ([calibrate-audio.md](calibrate-audio.md), §1) | +| The latency chosen per driver from 10, 20, 50, 100 and 200 ms; when unset, 20 on WASAPI and 200 on the others | lead, 2026-09-26; WASAPI's from the runs of §7 | 200 ms is what every stream asked for before; a choice lets the measurements compare | +| The menus only with two drivers or more | lead | One driver is no choice; elsewhere the device menus are all there is | +| The default named where no driver is, before the first device opens and before the Playback menu shows the device menus, not at start-up | lead | With four PortAudio implementations the device menus have no driver to list the devices of; the first device opens lazily, with the first file or take | + +## 4. Facts checked + +- **PortAudio on the Windows build** is MSYS2's `mingw-w64-x86_64-portaudio` 19.7.0, built + with CMake's defaults: `PA_USE_WMME`, `PA_USE_DS`, `PA_USE_WASAPI` and `PA_USE_WDMKS` are + all ON, and `pa_win_wasapi.h` is installed. Its `PaWasapiStreamInfo` has + `paWinWasapiAutoConvert` (`1 << 6`). (MSYS2's PKGBUILD and PortAudio's `v19.7.0` + `CMakeLists.txt`, 2026-09-26.) An ASIO build is a separate package, not used. +- **bqaudioio's `port`**, as upstream has it: its device lists hold every host API's + devices; `getDeviceIndex()` returns the first whose name matches, else PortAudio's + global default device (the default host API's, MME's on Windows). The stream opens at + the output device's `defaultSampleRate`, as neither side of svapp asks for a rate; + `paFramesPerBufferUnspecified`, then 1024 if that fails, then 2×2 channels. +- **The fork's drivers**: a device name their host API does not list opens that host + API's default device, as no name does. +- **svapp** `MainWindowBase::createAudioIO()` reads `Preferences/audio-target` and the + devices `audio-record-device` / `audio-playback-device`, suffixed `-` + when one is named; `auto` means none. +- **Tony's device submenus** are rebuilt on `aboutToShow`, from the implementation + `audioImplementationName()` gives: `audio-target` if set, else the only implementation + built in. On Windows there are four (`port` and the three drivers), so with no driver + named the menus would be empty: hence the default. +- **`LatencyCalibration::Key`** holds `implementation` (from `audio-target`), both device + names and the recording rate: a figure is kept per driver, and not per latency. +- **Every take restarted the stream** (`MainWindowBase::stop()` suspended, `record()` + resumed: `Pa_StopStream` / `Pa_StartStream`). The svapp fork's `stop()` now asks + `suspendAudioOnStop()` first. bqaudioio's `PortAudioIO::resume()` and `suspend()` do + nothing when the stream is already in that state, so a running stream survives the + next take's `resume()`; svapp copes with a device that never stops, as it never + suspends JACK. +- **At 48 kHz**: `SingingTakes::spliceRecording()` resamples the recording to the + reference's rate, `TakeTiming` keeps the latency and the frames received in device + frames, and the cursor keeps the reference's pace during a take (the svgui fork). + +## 5. Design in Tony + +The fork's part (an implementation per host API, WASAPI's rate conversion, the settable +latency) is in [forks.md](forks.md#bqaudioio). Linux builds none of its Windows code; it +is checked by cross-compiling ([building.md](building.md#checking-the-forks-windows-code)). + +- **`AudioDriverSettings`** (`tony_core`): the Preferences, as plain functions: which + implementations are drivers and in what order, the latencies offered, the default's + naming and the carry-over of the devices. Its keys, all in `Preferences`: + `audio-target` (the driver), `audio-playback-device-` and + `audio-record-device-`, and `audio-latency-` in seconds, as text, as + `LatencyCalibration` keeps its figures. +- **`AudioDriverMenus`** (`tony_app`): the two submenus. Rebuilt as either opens, so that + the ticks follow the Preferences, which the other menu or the default may have changed. + A choice is written to the Preferences, then signalled; `MainWindow` does the rest. +- **`MainWindow::createAudioIO()`** (desktop): names the default driver, hands the + latency chosen for the driver to bqaudioio (`AudioFactory::setSuggestedLatency()`, for + the streams opened after), then `openAudioIO()`, which opens the device as svapp does. + Every device is opened through it: the first, and each recreate. The Playback menu names + the default too, as it opens, before the device menus read the driver. On Android + `createAudioIO()` opens `OboeAudioIO` instead, and none of this applies. +- **A choice** stops playback, forgets the device's rate when the driver changed (another + driver may record at another rate, as another device may), and recreates the audio IO. +- **The report's latency** is the one last handed to bqaudioio, not the Preferences', so + that it says what the device was opened with. +- **The stream kept running between takes**: `MainWindow::suspendAudioOnStop()` + is false, so neither Stop nor the end of a take suspends the device, and every take + until the device is opened again shares one alignment of input against output. Opening + it again (a driver, a latency or a device chosen, either device menu opened, Tony + started again) moves the alignment, by up to about 8 ms on the user's PC. The input + stays open from the first take on, and Windows shows the microphone in use until Tony + quits. Android keeps the stream running too, but suspends the device once it has idled, + neither playing nor recording, for two minutes (`audioIdleSuspendMillis()`, never on + desktop), and when Tony goes to the background, so that a phone does not keep its + microphone open and its battery draining; the next Play or Record resumes it, which + moves the alignment as opening it again does ([recording.md](recording.md#latency)). + +## 6. Tests + +- **Core** (`TestAudioDriverSettings`): the drivers picked out of any list in their order; + WASAPI named where nothing or `auto` is (MME where there is no WASAPI), the unsuffixed + devices carried over only where the driver named has none of its own, and nothing named + again, nor where another driver is named or neither is built in; the latency kept per + driver, read back from a file, 20 ms on WASAPI and 200 ms on the others where unset or + unreadable. +- **App** (`TestAudioCheck`): the menus from a given list, in order, with the ticks, before + the device menus, and hidden with one driver; WASAPI chosen, its setting written, the + device opened again with WASAPI's devices, MME's kept, and a device then chosen written + to WASAPI's key; the default named before the first device and as the Playback menu + opens, and a named driver left alone; the latency kept per driver and handed over at + each opening; a round trip kept for MME not used under WASAPI, and used again on the way + back, with the dialog naming the driver; both menus greyed out during a take and a + check. The drivers are given through `TestMainWindow` ([testing.md](testing.md)), as + Linux's bqaudioio has none. +- **Dev** (`TestDevChecks`): the report's header names the driver and the latency. +- **The stream kept running**, with a fake whose loopback moves 10 ms at each restart: + `TestAudioCheck`'s check lands its punch-ins 10 ms apart when the window suspends at + Stop, and alike when it keeps the stream running, as the application does, resumed + once and never suspended; `TestDevChecks`' whole run passes kept running + ([testing.md](testing.md), "The audio check and the dev checks"). +- **The idle suspend** (`TestRecordWorkflow`): kept running with an idle time of 1.5 s, + the fake runs on after a take and after playback and is suspended no sooner than that + after either, not while it plays for longer, and the next take resumes it; the + application's idle time is never on desktop. Not tested: the suspend when Android sends + Tony to the background, which no desktop build has. +- Seen failing with the code broken: the default, the latency per driver, the greying; + the kept-running check and dev run with the stream suspended at Stop (items 1, 2, 7 and + 13), and the check with the end of a take suspending again; the idle suspend with the + take's playback or recording counted as idle. +- Not tested: that a choice stops playback. Nothing here runs the fork's Windows code. + +## 7. State, and the measurements on the user's PC + +**Built**, on `feat/wasapi`: the fork, pinned; Calibrate Audio at any device rate; the two +menus, the default and the reports; the stream kept running between takes (§5). The +fork's Windows part was compiled by the cross-compile only until the user's runs below. +Nothing has been measured through DirectSound. + +**The user's runs, 2026-09-26** (Windows; wired headphones with one earcup against the +microphone; (System Default) devices; on each, Calibrate Audio and Use this latency, then +a whole dev run): + +| | MME, 200 ms | WASAPI, 20 ms | WASAPI, 10 ms | +| --- | --- | --- | --- | +| Latencies reported, out / in | 200.6 / 28.7 ms | 40.0 / 30.0 ms | 32.0 / 22.0 ms | +| Round trip measured | 297.7 ms | 98.7 ms | 91.3 ms | +| The dev run's punch-ins, lowest to highest offset | −7.8 to +7.8 ms | +3.0 to +19.4 ms | −4.4 to +9.6 ms | +| Start gap, per take | 0 | 0, 10 or 20 ms | 10 or 20 ms | +| Live dots behind the cursor, median | 361 ms | 171 ms | 167 ms | +| Totals | 5 passed, 5 failed, 1 measured | 4, 6, 1 | 4, 6, 1 | + +What they showed: + +- **WASAPI's round trip is a third of MME's**, and the dots trail the cursor half as far. + 10 ms gains 7 ms over 20 ms. +- **Takes are no steadier.** On every driver each take landed up to about 8 ms either way, + 15 ms from lowest to highest, while one take's sweeps agreed within 0.3 ms: the stream's + restart at every take moved input against output, whatever the driver. Items 1, 2, 7 + and 13 failed on it on all three. +- WASAPI's start gap comes in whole periods of its engine, 10 ms. +- Two failures were the checks' own, since fixed: item 14 misread a 48 kHz take's overrun + (it reads about 0.3 s, as on MME), and item 3 judged dots in a sound's first window, + which through a real room are off pitch on every driver + ([calibrate-audio.md](calibrate-audio.md), §7). +- Whether 10 ms crackled was not reported. + +**Decided** (the user, 2026-09-26): WASAPI at 20 ms is the default, and the stream is +kept running between takes (§5). + +**The stream kept running**, WASAPI at 20 ms, 2026-09-26: Calibrate Audio, then a whole +dev run without the device opened again in between; headphones on a PCI sound card (Xonar +Essence STX), the microphone on a USB one. Round trip 91.8 ms. The punch-ins, in the +order they were taken: −3.2, −3.7, −4.5, −5.2, +4.0, +3.4, +2.9, +2.2 ms: 9 ms from lowest +to highest, where the restarts had given 15. + +- **The alignment drifts, then slips by a period.** Each take lands about 0.6 ms earlier + than the one before, and once, between the fourth and the fifth, the offset jumps back + by 9 ms: WASAPI's 10 ms period, less the drift. Two sound cards run on two clocks, and + the output's runs fast against the input's; kept running, the output uses up its margin + and plays a period of silence (the next run's recordings, below). (One start gap read + 478 frames where every other read 0 or 480.) +- So a running stream keeps every take within one period, 10 ms, of the others; where that + window sits against the kept figure depends on when Calibrate Audio measured it. On + one device, one clock, there would be no drift; not measured. +- Consecutive takes agree: item 10's second punch-in against the first, −0.6 ms. +- Items 4, 9, 10, 12 and 14 passed. Item 3 failed on three dots at a tone's end, as at + onsets, and sets those apart since ([calibrate-audio.md](calibrate-audio.md), §7). + +**Decided** (the user, 2026-09-26): accept it. Items 1, 2, 7 and 13 allow ±6 ms instead of +±2 ms: half a period and a margin, as this run's figures lay. A run whose calibration +happened near a slip can read up to about ±10 ms; calibrating again then puts it back in +the middle. Whether Windows shows the microphone in use until Tony quits, and whether a +take starts with a click, were not watched for. + +**Kept running, again**, WASAPI at 20 ms, 2026-09-27, the same setup and order, with the +±6 ms. Round trip 98.5 ms. The sweeps, in the order taken: −3.3, −3.7, −4.6, −4.8, −5.3, ++4.5, +3.8, +3.2, +2.7, +2.1 ms. 9 passed, 1 failed, 1 measured. + +- **The same course as the run before**, take by take within 0.3 ms, although the two + calibrations read 6.7 ms apart: each run placed its takes against its own calibration. + Both runs began with Calibrate Audio, and in both it measured about 5 ms from either + edge of the window; the third, below, did not. +- **A slip can fall inside a take**: this time between the fourth punch-in's two sweeps, + which landed 9.8 ms apart. Items 1, 2, 7 and 13 passed; the largest offset, −5.3 ms, was + 0.7 ms inside the limit. +- Windows showed the microphone in use for the whole run, as it should with the stream + kept running; no take was heard to start with a click. +- Item 3 failed on four dots in silence, at about 300 Hz, 0.45 s or more from any of the + reference's sounds; none of the four runs before had one. The live tracker has no level + floor ([calibrate-audio.md](calibrate-audio.md), §10). + +The run's recordings (`recorded-*.wav`, 48 kHz, the microphone on both inputs), which the +user sent, show what the report could not: + +- **The slip is a gap in the playback.** In the fourth punch-in, at 18.56 s, the recorded + 220.5 Hz tone fades out over 5 ms and back 5 ms later, and everything after it arrives + 9.7 ms later: the headphones played about one period of silence. Within each take the + sweeps arrive 0.1 ms earlier every second, the two clocks about 100 ppm apart, so a + stream running on these two cards has such a gap about every 100 s; heard as a tick + when it falls in something playing. +- **The dots in silence are the room's noise.** A steady floor of about −66.5 dBFS in + every recording, the calibration's too, most of it below 400 Hz, with two lines at 306 + and 334 Hz some 30 dB over the rest; the mains' 50 Hz line is 8 dB below them. Fans, + most likely, not hum. Now and then YIN takes a window of it for a pitch, just under its + threshold (0.12 to 0.148 against 0.15): one or two dots in almost every recording, all + between −64.5 and −67.5 dBFS, where no dot on a tone read below −42 dBFS. The live + tracker has had a level floor of −60 dBFS since + ([recording.md](recording.md#the-live-tracker)). + +**Kept running, a third time**, WASAPI at 20 ms, 2026-09-27, with the level floor. Round +trip 85.4 ms. The sweeps, in the order taken: +6.9, +6.4, +5.6, +5.4, +5.0, +4.7, +4.1, ++3.5, +3.0, then +12.4 ms after a slip. 8 passed, 2 failed, 1 measured. + +- **Calibrated near a slip**: the takes lay within 9.4 ms, as before, but the calibration + sat at the window's lower edge, not in its middle. Items 1 and 2 failed on the first two + punch-ins and the last; 7 and 13, whose takes happened to lie within 6 ms, passed. Which + place in the window a calibration lands in is not in Tony's hands. +- Item 3 passed: no dot off the reference's sounds. + +**Decided** (the user, 2026-09-27): good enough; the work on the drivers and the dev +checks' placement ends here. + +## 8. Open points + +- **A round trip is kept per driver, not per latency.** After a latency change the kept + figure is used unless the latencies the device reports moved by more than 1 ms (then it + is stale, and the menu line says so): calibrate again after changing the latency. +- **Before a device's first take** the menu line, Forget Measured Latency and the dialog + look the figure up at the session's rate, and choosing a driver forgets the device's rate + as choosing a device does. On a device at 48 kHz they then show the driver's figure + although a 48 kHz one is kept; takes use the kept one. A fix needs the device's rate + before the first take, from svapp ([calibrate-audio.md](calibrate-audio.md), §5). +- WDM-KS (in PortAudio's build too) and WASAPI's exclusive mode would be lower still, but + take the device from every other program; not built. +- **Kept running on two sound cards** the alignment drifts and slips by a period (§7): + every take within 10 ms of the others, and each slip is a period of silence in the + playback, about every 100 s on the user's cards. Of three runs that began with Calibrate + Audio, two measured in the middle of that window and passed items 1, 2, 7 and 13; the + third measured at its edge and failed items 1 and 2. On one device it should not drift; + not measured. (A phone's Bluetooth output drifted against its microphone by about 1 ms a + minute: [calibrate-audio.md](calibrate-audio.md), §14.) + Opening the device again still moves it, so a figure kept from an earlier session is up + to about 8 ms off: calibrate at the start of a session for the best placement. On + Windows the microphone shows as in use from the first take until Tony quits. +- On the loopback fake at 48 kHz, two runs in four read an output peak near 0 dBFS in the + first block after a stream started, where the reference peaks at −12 dBFS; never at + 44.1 kHz. It may be an audible click at a take's start on a 48 kHz device. +- Tony does not ask WASAPI for raw capture, so Windows' enhancements apply on every + driver ([calibrate-audio.md](calibrate-audio.md), §10). diff --git a/docs/building.md b/docs/building.md new file mode 100644 index 00000000..2c26fff4 --- /dev/null +++ b/docs/building.md @@ -0,0 +1,399 @@ +# Building + +The development machine builds with meson + ninja under MSYS2's `mingw64` toolchain +(default prefix `C:\msys64\mingw64`) into `build_mingw/`: that is most of this page. An +agent in a cloud session builds on Linux instead, into `build/`: see +[Building on Linux](#building-on-linux). The Android app is built on Linux as well, in a +cloud session or on CI, into `build-android/`: see +[Building for Android](#building-for-android) at the end. The Windows CI workflow in +`.github/workflows/` builds as the development machine does, from MSYS2's packages; the +Linux and macOS ones build the upstream way. All three run the test suites +([testing.md](testing.md)). The Android one builds the APK with the scripts a cloud +session uses, and runs no tests. + +# Building on Windows (MSYS2 MinGW-w64) + +## From cmd or PowerShell: `build.bat` + +``` +.\build.bat build Tony.exe +.\build.bat run build, then launch +.\build.bat launch launch without building +.\build.bat test meson test: tony-core, tony-app, tony-dev and four svcore suites +.\build.bat clean wipe build_mingw and reconfigure (only for a broken build directory) +``` + +It reads `MSYS2_MINGW` and puts `mingw64\bin` and msys2's `usr\bin` on `PATH` itself. +From PowerShell its output is safe to capture: `.\build.bat *> tmp\build.log`. + +## From Git Bash (what an agent's Bash tool usually is) + +`build.bat` cannot be called from sh. Call ninja directly: + +```sh +export PATH="/c/msys64/mingw64/bin:$PATH" MINGW_PREFIX="C:/msys64/mingw64" +ninja -j 4 -C build_mingw Tony.exe test-tony-core.exe test-tony-app.exe test-tony-dev.exe > tmp/build.log 2>&1 +echo "exit:$?" >> tmp/build.log +tail -20 tmp/build.log +``` + +Each part of that is there because of something that went wrong: + +- **Only `mingw64/bin` on PATH, not `/c/msys64/usr/bin`.** With the latter, msys2's + coreutils shadow Git's and run under a different msys runtime: quoted arguments held in + variables arrive empty (`grep: Usage...`, `mkdir: missing operand`). ninja, meson, gcc, + moc and the Qt DLLs are all under `mingw64`. +- **`MINGW_PREFIX` must be set.** `meson.build` reads it, and Git Bash sets it to Git's + own `/mingw64`, which fails with `Include dir C:/Program Files/Git/mingw64/include/opus + does not exist`. ninja reconfigures by itself whenever `meson.build` changed (a new + source file is enough), so this bites on ordinary incremental builds. +- **Spell `MINGW_PREFIX` exactly `C:/msys64/mingw64`.** A reconfigure with a different + spelling than the build directory was set up with changes the include flags and + rebuilds everything (about 560 steps). +- **`-j 4`**, one job per core. It relies on Windows' page file being on. Without one, + Windows can promise programs no more memory than the RAM, and with an editor and a + browser open a large rebuild ran out (`cc1plus.exe: out of memory`, bash cannot fork) + while RAM was still free. If it happens, run the same command again; ninja carries on + where it stopped. +- **Redirect to a log and never pipe ninja.** The output is large and can stall or time out + the tool. `tmp/` is gitignored and is the place for logs. +- **Write ninja's exit status into the log.** The status of a `ninja ...; tail ...` chain + is that of `tail`. +- **Targets carry the extension**: `test-tony-app.exe`, not `test-tony-app`. +- `Tony.exe` cannot be relinked while it is running. Close the app first. + +Success is `[N/N] Linking target ...` and `exit:0`; nothing to do is `ninja: no work to +do.` and `exit:0`. + +Timings: incremental under a minute, a few minutes after touching `MainWindow.cpp` or a +widely included header, up to 30 minutes from clean. Give long builds a 30-minute timeout. + +Reconfigure from scratch (rarely needed): + +```sh +export PATH="/c/msys64/mingw64/bin:$PATH" MINGW_PREFIX="C:/msys64/mingw64" +meson setup --wipe build_mingw > tmp/build.log 2>&1 && ninja -j 4 -C build_mingw Tony.exe >> tmp/build.log 2>&1 +echo "exit:$?" >> tmp/build.log +``` + +## What is particular about this `meson.build` + +- A MinGW/GCC win64 branch that upstream does not have: msys2 system libraries, + `-include main/mingw_byte_fix.h` on every translation unit (the C++17 `std::byte` versus + `rpcndr.h`'s `byte` clash), `--export-dynamic-symbol` for the vamp entry point, explicit + include directories for `opus`, `sord-0`, `serd-0`. +- `-DHAVE_MEDIAFOUNDATION` with `-lmfplat -lmfreadwrite -lmfuuid -lpropsys`; needs the + `bqaudiostream` fork. +- `general_defines` goes on every target, svcore and the plugins included, so a change to + it recompiles everything (about 620 steps, 20 minutes). A define that only Tony's code + reads goes in `tony_defines`, which only Tony's own targets get. `tony_app` compiles + svgui and svapp too, so a change there still recompiles those, but not svcore. +- `tony_core` / `tony_app` static libraries and the test executables; see + [architecture.md](architecture.md) for what goes where. A new source file goes into + `tony_core_files` or `tony_app_files` (an Android-only one into the lists' additions + under `if system == 'android'`), and its header into the matching moc list only if it + declares `Q_OBJECT`. +- Any build type but `release` (`build.bat`'s is `debugoptimized`) is a development build: + `-DTONY_DEV_CHECKS` for the compiler and for moc, `main/dev/` compiled into `tony_app`, + and `test-tony-dev` built. `meson.build`'s default and the desktop CI workflows use + `release`, which has none of it; the Android build, on CI too, is `debugoptimized`. + After a change to how the dev checks are wired in, set up a `release` build directory + and build it: it must compile with no `main/dev/` file + ([calibrate-audio.md](calibrate-audio.md), §6). +- Windows headers define macros named `near` and `far` (empty), `min`, `max`, `ERROR`, `IN` + and `OUT`. Do not use them as identifiers: a build on Linux does not catch it. +- The macOS SDK's `MacTypes.h` declares `normal`, `bold`, `italic`, `underline`, + `outline`, `shadow`, `condense` and `extend` in the global namespace. A function of one + of those names, even in an anonymous namespace, makes each unqualified call to it + ambiguous on macOS, and nothing else catches it. + +# Building on Linux + +For a cloud session: Ubuntu 24.04, 4 cores, 16 GB, root, no sound card, and no Windows. +repoint does not run there, and hg.sr.ht, where five of the libraries live, cannot be +reached. Three scripts in `deploy/linux/` do the work: + +- **`cloud-environment.sh` is the cloud environment's setup script.** Its text is pasted + into the environment's settings, with the network access and variables below; the copy in + the repository does nothing by itself. The platform runs it once and keeps a snapshot of + the disk, which later sessions start from, until the script or the allowed hosts change + or about a week has passed. It installs the packages, Qt, ccache and mold, the Android SDK + and NDK when `dl.google.com` is reachable, and spends what is left of four minutes filling + ccache from a build of the libraries. Qt for Android and the Android build's C libraries + are not in the snapshot: they take far longer than it allows. It also writes an + `autoMode` entry to `/root/.claude/settings.json` by which auto mode trusts four of the + library forks (all but `bqaudioio`) as it does Tony's own repository + ([forks.md](forks.md#changing-a-fork)): auto mode reads that from the user's settings, + never from the repository's `.claude/settings.json`. The snapshot is kept only when the + script ends within about five minutes, so any change to it has to keep to that. Its logs + are in `/var/log/tony-environment/`. +- **`container-setup.sh`** makes any fresh Ubuntu 24.04 able to build: packages, Qt, the + library directories at their pins, `meson setup build`. Safe to run again. After the + environment's snapshot it only checks out the libraries and configures. +- **`cloud-session.sh start` runs at the start and resumption of every cloud session**, + from the SessionStart hook in `.claude/settings.json` (`start --if-cloud`, which does + nothing outside the cloud). It runs `container-setup.sh` and then builds everything into + `build/` in the background, at low priority: about 4 minutes, while the session reads. + It builds nothing for Android. The hook itself returns at once. The log is + `tmp/cloud-session.log`. `cloud-session.sh wait` waits for it and exits as it did. + **Wait before the first build or test**: two ninjas must not work in one build + directory. A session with several repositories runs no repository's hooks; there, run + `cloud-session.sh start` by hand. + +The environment's settings: + +- Network access **Custom**, with the default list of package hosts, and `dl.google.com` + added for the Android build (the SDK, the NDK, and Gradle's Google repository, which + `maven.google.com` redirects to). Without it there is no Android SDK, and Gradle cannot + fetch the Android Gradle plugin: `build-apk.sh` then names the host it was refused. + GitHub, conda-forge, PyPI, Ubuntu's archive and Maven Central are reachable. Not + reachable, and not to be looked for elsewhere: hg.sr.ht; Qt's download mirrors, to which + download.qt.io redirects every archive (so no Qt from Qt's own archives or installer); + `breakfastquay.com` and the other upstream sites of the Android build's C libraries + (`xiph.org`, `fftw.org`, `codeberg.org`, `download.drobilla.net`); the image's own PPAs + (`ppa.launchpadcontent.net`), about which `apt-get update` warns and carries on. The + scripts fetch only from reachable hosts, and retry, because the proxy now and then + answers 502 for a moment. A push to one of the forks is another matter: with this Custom + access it is refused (HTTP 403) until the fork is attached to the session, and goes + through once it is ([forks.md](forks.md#changing-a-fork)). +- Variables `BASH_DEFAULT_TIMEOUT_MS=600000` and `BASH_MAX_TIMEOUT_MS=1800000`, so that a + build or a suite run is not moved to the background after the tool's default two minutes, + and a 30-minute timeout can be given at all. + +Then, from the repository root: + +```sh +ninja -j 4 -C build tony pyin.so test-tony-core test-tony-app test-tony-dev > tmp/build.log 2>&1 +echo "exit:$?" >> tmp/build.log; tail -20 tmp/build.log +deploy/linux/run-tests.sh test-tony-core # a few seconds +deploy/linux/run-tests.sh test-tony-app # under two minutes +deploy/linux/run-tests.sh test-tony-dev # when AGENTS.md says to run it; two minutes +``` + +No `.exe` on Linux; the plugin target is `pyin.so`. `run-tests.sh` runs an executable as +several processes, each with a shard of every suite ([testing.md](testing.md#running)). +The one-process runs of AGENTS.md work too, from `build/`. + +Measured on 2026-09-26 (the sharded runs on 2026-09-27 as well): + +| | | +| --- | --- | +| Full build, nothing in ccache | 6.6 minutes: 1570 CPU-seconds, nearly all compiling | +| Full build, everything in ccache | 4 to 6 seconds | +| A session's first build, with the setup script's ccache | 3.7 minutes, in the background | +| Linking `tony` and the three test executables | 3 s with mold, 12 s with GNU ld | +| Core suite | 5 to 8 s in eight processes | +| App suite | 628 s in one process; 98 to 113 s in eight | +| Development checks' suite | 444 s in one process; 110 to 121 s in eight | + +Why each part is as it is: + +- **Qt 6.11 from conda-forge, not Ubuntu's 6.4**: the Qt of the Windows machine. Qt 6.4 + does not match a `SIGNAL()`/`SLOT()` string naming `ModelId` or `sv_frame_t` against a + slot moc recorded with `sv::`, so such a connection fails silently there and works on + Windows (member-pointer connects, which AGENTS.md asks for, work on both). + download.qt.io's mirrors are blocked by the session's proxy; conda-forge is not. Only + Qt's own `.pc` files are put on meson's pkg-config path (`/opt/qt6-conda/tony-pkgconfig`), + so that nothing else of conda's is picked up; meson puts Qt's library directory in the + executables' RPATH. +- **Rubber Band is Ubuntu's** (3.3.0), which meets `meson.build`'s `>= 3.0.0`: the Linux + CI workflow builds it from a tarball on `breakfastquay.com`, which is blocked. +- **The Mercurial libraries come from their GitHub mirrors.** hg.sr.ht is blocked, and + `repoint-lock.json` pins them by Mercurial hash, which the mirrors do not carry. + `container-setup.sh` has a table from pin to mirror commit and stops at a pin it does not + know. +- **ccache**, which meson uses by itself when it is installed, with `hash_dir = false` + (`/etc/ccache.conf`). With `-g` every result's key otherwise holds the build directory, + and a second build directory or a worktree found 0.4 % of a full cache. The compiler's + name is part of the key too: a directory configured with `CC=gcc CXX=g++` finds nothing + that meson's own `cc` and `c++` put there. Configure through `container-setup.sh`. +- **mold**: `cloud-session.sh` has `meson setup` take it (`CC_LD=mold CXX_LD=mold`) for a + new `build/`; a build directory keeps the linker it was set up with. Every change to + `main/` relinks all four executables. +- **Run the app suite with nothing else building**: it records in real time. +- `TestTakesFile` checks Windows paths (backslashes, drive letters, case) on Windows only; + elsewhere it checks that names are case-sensitive and uses a POSIX absolute path. +- Measured and left alone: `-g1` compiles svcore in 19 % less time than `-g`, but Windows + builds `debugoptimized`, with full debug information; clang is no faster than GCC; and a + unity build fails in the libraries, which define the same names in several files. + +## The CI jobs in a cloud session + +`deploy/linux/ci-local.sh` runs here what the Linux and Android workflows run, so that a +change can be checked without them. The Windows workflow's build and suites are +`build.bat test` on the Windows machine; the macOS one has no stand-in. + +```sh +deploy/linux/ci-local.sh linux # about 12 min, and 4 more for a session's first build +deploy/linux/ci-local.sh --quick linux # about 3 min, and the same 4 +deploy/linux/ci-local.sh android # 2 min once Qt for Android is built; see below +deploy/linux/ci-local.sh all # Linux, then Android +``` + +- **`linux`** installs Ubuntu's Qt 6.4 if it is missing, builds a release build against + it in `build-linux-ci/`, runs every meson test in one process and lists the tests that + failed, as the workflow's steps do. `--quick` runs the core and app suites in one + process per core instead, half of `run-tests.sh`'s default, to load the machine less: + the tests that share a process are then others ([testing.md](testing.md#running)). + Expect `live_dots_under_the_cursor` to fail here either way + ([testing.md](testing.md#running)). +- **`android`** runs `deploy/android/`'s five scripts in the workflow's order. The first + run in a session builds Qt for Android and the C libraries into `/opt/android`, which + the environment's snapshot cannot hold: about 25 minutes more. +- It waits for the session's background build first, which would load the machine while + the app suite records in real time, and writes its logs to `tmp/ci-local/`. +- **Where it differs from the runner**: the libraries are `container-setup.sh`'s, not + repoint's; meson and Rubber Band are Ubuntu's; and `libopusenc` is installed here and not + on the runner, so this build can write Opus files (`HAVE_OPUS_READ_ONLY` is not + defined). + +## Checking the fork's Windows code + +The bqaudioio fork's drivers ([forks.md](forks.md#bqaudioio)) are under `#ifdef _WIN32`, +so the Linux build compiles none of them. Here they are checked by compiling the two files +for Windows with MinGW-w64, against the headers of PortAudio 19.7.0, the version of MSYS2's +package: + +```sh +apt-get install -y g++-mingw-w64-x86-64-posix +mkdir -p tmp/pa197 +for h in portaudio.h pa_win_wasapi.h pa_win_waveformat.h; do + curl -sSfo tmp/pa197/$h https://raw.githubusercontent.com/PortAudio/portaudio/v19.7.0/include/$h +done +for f in PortAudioIO AudioFactory; do + x86_64-w64-mingw32-g++ -std=c++17 -fsyntax-only -DHAVE_PORTAUDIO -Itmp/pa197 \ + -Ibqaudioio/bqaudioio -Ibqaudioio/src -Ibqvec bqaudioio/src/$f.cpp +done +``` + +Both must compile without a word. WASAPI's stream info is included only where +`__has_include` finds `pa_win_wasapi.h`, and a missing header drops it without an error: +run the line for `PortAudioIO` with `-E` in place of `-fsyntax-only` and look for +`wasapiInfo.flags` in the output. Nothing more of the Windows part can be tried here: it +runs only on the user's PC. + +# Building for Android + +The Android app is built on Linux, by the scripts in `deploy/android/`: in a cloud +session, or on CI by `.github/workflows/android.yml`. They run in this order. Each is safe +to run again (the first three leave alone what is there already at its version), ends by +checking what it made, and shows the end of its log when it fails. All but Tony's own build +goes under `/opt/android`, the logs in `/opt/android/logs`. + +| Script | What it makes | +| --- | --- | +| `setup-toolchain.sh` | JDK 21 and the host's build tools from apt; the SDK's command-line tools, platform `android-36`, build tools 36.0.0, the platform tools and NDK r27c (27.2.12479018), in `/opt/android/sdk` | +| `build-qt.sh` | Qt 6.11.2 from source: a host Qt for its tools, and qtbase and qtsvg for Android, in `/opt/android/qt/6.11.2/gcc_64` and `android_arm64_v8a` | +| `build-deps.sh` | the C libraries, static, for arm64-v8a at API 28, in `/opt/android/deps-arm64-v8a`, and the NDK's meson cross file `/opt/android/cross-arm64-v8a.ini` | +| `build-tony.sh` | `build-android/`: `libTony_arm64-v8a.so` and the plugins `pyin.so` and `chp.so`; `--wipe` configures afresh, for a new NDK or Qt | +| `build-apk.sh` | `build-android/apk/Tony-debug.apk`, signed with the debug key, through androiddeployqt and Gradle | + +The first three took 21 minutes in a fresh container, 18 of them Qt's; `build-tony.sh` +takes about 4 minutes from scratch. The cloud environment's snapshot holds the SDK and the +NDK, not Qt for Android or the libraries, so a new session runs the first three, about 20 +minutes, before its first APK. After that, a change to Tony needs only: + +```sh +deploy/android/build-tony.sh && deploy/android/build-apk.sh +``` + +The APK is installed on the phone by hand. Why each part is as it is: + +- **meson with two cross files**, not an Android-only `CMakeLists.txt`, which is Qt's + supported route: a CMake copy would have to keep `meson.build`'s source lists in step. + One cross file is the NDK's, which `build-deps.sh` writes; the other, + `deploy/android/qt-arm64-v8a.ini`, names Qt for Android's `qmake`. Qt for Android + installs no `.pc` files, so meson finds Qt through that qmake, and takes moc, rcc and uic + from the host Qt it names. The meson issues feared for this (13018, 6089) did not arise + with Ubuntu's meson 1.3.2. +- **`meson.build`'s `android` branch** is the Linux branch less what a phone has no use + for (JACK, PulseAudio, ALSA and RtMidi's ALSA defines, PortAudio, oggz and fishsound), + with Opus read-only. Every library is found with `static: true`: only then does + pkg-config name their private libraries (ogg, opus, zix, serd). Tony is the shared + library `libTony_arm64-v8a.so`, the name androiddeployqt looks for, which Qt's Java + launcher loads to call its `main()`; `--exclude-libs,ALL` keeps everything from the + static libraries out of its symbol table. `link_whole` is what keeps + `AndroidMediaReadStream` in it: the reader registers itself with bqaudiostream, and + nothing refers to it. The test executables are not built for Android. +- **`debugoptimized`**, as the desktop builds: asserts on, and the development checks + compiled in ([calibrate-audio.md](calibrate-audio.md)). The libraries in `build-android/` + keep their debug information (Tony's is over 100 MB), for the NDK's `ndk-stack` on a + crash from the phone. `build-apk.sh` strips the APK's copies and checks that every + library in the APK is stripped. +- **Qt from source.** download.qt.io answers every archive with a redirect to a mirror, + and the mirrors are blocked. The sources are Qt's own repositories on GitHub at tag + `v6.11.2`, checked against the commit: the Qt of the desktop builds. The host Qt is built + for its tools only, with Qt's bundled third-party libraries, and **without zstd**: where + zstd is found (CI's runner has `libzstd-dev`), its rcc compresses Tony's resources with + it, which Qt for Android cannot read, and Tony's link fails. Qt for Android leaves out SQL + and printing, and has no OpenSSL: Qt Network works, without TLS. +- **The C libraries** are release tarballs from GitHub or from Ubuntu's archive pool, which + keeps a file unchanged for good, each checked against a SHA-256 in the script; Rubber + Band and Oboe are Git tags checked against the commit. Rubber Band, zix, serd, sord and + Boost are at the versions of Ubuntu 24.04, which the Linux build uses. Beyond what + `meson.build` names: bzip2, because svcore's `BZipFileDevice` includes `bzlib.h` + whatever the defines say, and the NDK has none; zix, which sord needs; Boost's headers, + for pYIN. fftw3 is double only + (`meson.build` defines `FFTW_DOUBLE_ONLY` everywhere), with NEON, and with Debian's patch + to its NEON probe, which runs an ARMv7 instruction that on arm64 is another one and + clobbers a register. libmad and libid3tag are the maintained fork, 0.16, which has `.pc` + files. Rubber Band uses its built-in FFT and resampler. Oboe opens AAudio and OpenSL ES + with `dlopen()`, so it links `liblog` alone. zlib is the NDK's. A library is built again + when its version in the script changes, or when its stamp in + `/opt/android/deps-arm64-v8a/share/tony-deps/` is deleted. +- **16 KB pages**, which Android 15 can have: NDK r27 does not align for them by itself, + so the cross file links with `-z max-page-size=16384`, and `build-deps.sh`, + `build-tony.sh` and `build-apk.sh` each check the alignment. +- **The SDK's command-line tools are 22.0**: in 23.0 `sdkmanager` became a wrapper around + a new tool, with other package names. `sdkmanager` takes the newest revision of platform + `android-36` and of the platform tools, so those two are not pinned. Java tools need the + proxy named to them: in a session `JAVA_TOOL_OPTIONS` does it, for sdkmanager and Gradle; + `cloud-environment.sh` names it to sdkmanager itself. + +What `build-apk.sh` does, and why: + +- **It writes the deployment settings** that androiddeployqt reads, which CMake and qmake + write for their own builds (`build-android/android-Tony-deployment-settings.json`). They + follow the file Qt's CMake wrote for `build-qt.sh`'s check, kept as + `/opt/android/logs/android-check-deployment-settings.json`, and name the Qt plugins that + file names: without a list, androiddeployqt packages every plugin of every module, test + platforms and all. androiddeployqt then makes `build-android/android-build/`, a Gradle + project, and runs Gradle. +- **The manifest** is `deploy/android/package/AndroidManifest.xml`: Qt's template with the + package name `io.github.jhhr.tony`, `RECORD_AUDIO` (androiddeployqt adds it only for Qt + Multimedia, which Tony does not use), `MANAGE_EXTERNAL_STORAGE`, and `sensorLandscape`. + Lint's `ScopedStorage` check, which is for Google Play's sake, is told to ignore the + storage permission: Tony is installed by hand. +- **The plugins** go in as `libpyin.so` and `libchp.so`, beside Tony's library in + `lib/arm64-v8a/`: Android installs nothing else from an APK. Not as `android-extra-libs`, + which Qt's launcher would load at every start. The libraries are packaged the legacy way, + compressed and unpacked at install, so that they exist as files for svcore's plugin scan + (how Tony finds them: [port-android.md](port-android.md)). +- **The version.** The version name is `TONY_VERSION` and the short commit, with a `+` when + tracked files differ from that commit; the version code is the number of commits. The + first line of the phone's log names the version name, so that a report from the phone + says which commit ran. Hence: + - CI's checkout fetches the whole history (`fetch-depth: 0`). A cloud session's clone is + shallow and counts fewer commits. Android refuses an APK with a lower version code than + the one installed, so an APK from a session does not install over one from CI without + uninstalling first, which loses the app's settings and log. + - `repoint-lock.json` ends with a newline, as repoint writes it. CI runs `./repoint + install`, which writes the file afresh: without the newline its tree counted as changed, + and every APK it built said `+`. +- **It deletes Gradle's last APK first.** Gradle packages a debug APK over its last one, + and a library that changed leaves the old one's space in the file: 7 MB more each time + Tony's library changes. +- **It runs Gradle again**, up to three runs in all, when one fails on 429 Too Many + Requests, a reset or a timeout: Gradle's first run fetches the Android Gradle plugin from + Google's repository and more from Maven Central, which has answered 429. On a 403 it + names the host that was refused. + +On CI, `android.yml` runs the five scripts on GitHub's `ubuntu-24.04`, after `./repoint +install` as the other workflows do. It first deletes the runner's own Android SDK and .NET, +for the disk the Qt build needs, and points the `ANDROID_*` variables at the scripts' SDK +and NDK, the runner's being other versions. Qt for Android and the C libraries are cached, +each under a key made from the script that builds it and `setup-toolchain.sh`, and saved as +soon as they are built, so that a later step's failure does not cost them; a change to one +of those scripts builds it again, and the two from nothing take most of an hour there. The +APK is kept as the run's artifact `Tony-debug-apk`; after a failure, `/opt/android/logs` as +`android-logs`. diff --git a/docs/calibrate-audio.md b/docs/calibrate-audio.md new file mode 100644 index 00000000..df2a5903 --- /dev/null +++ b/docs/calibrate-audio.md @@ -0,0 +1,887 @@ +# Calibrate Audio and the development checks + +**Playback ▸ Calibrate Audio…**, in every build, measures the round trip of the audio path +(from Tony handing a sound to the output device to the microphone's recording of it coming +back) through the ordinary take path, with one earcup of wired headphones (or an earbud) +held against the microphone, or a phone's own speaker and microphone in a quiet room. +**Use this latency** then places every take on those devices (on a phone, that route) +with the measured figure instead of the one the driver reports. In development builds, +the Android one among them, the same dialog can carry on into the **dev checks**: a +scripted run that records test references through the air and settles, with a verdict and +numbers, the device items of the manual checklist. + +This page is the design as built: why, what the button and the dev run do, what each +verdict and check means, which numbers to send back, the architecture, the tests, the +decisions and what is still open. How to run it is in +[manual-checklist.md](manual-checklist.md), section 1. The research behind the approach +(OboeTester, Bucket Brigade, Ardour's MTDM, PortAudio's latency reporting, Audacity's +measurements) was a separate report, not kept in the repository. + +## 1. Why + +- **The driver's latency is wrong.** A take is placed by taking the round trip and the + start gap off the front of its recording ([recording.md](recording.md#latency)). Without + a measured figure the round trip is the sum of the output and input latency the device + reports. The start gap is measured and right; the reported pair comes from + `Pa_GetStreamInfo()`, which on MME, DirectSound and WASAPI is buffer sizes only. + Audacity measured it off by −5 to +155 ms. bqaudioio opens the stream with the + `suggestedLatency` chosen for the driver under Playback > Audio Latency on both sides, + 0.2 s unless another is chosen ([recording.md](recording.md#latency)). On a phone the + pair is Oboe's, estimated from timestamps, and moves by several ms from one start of the + streams to the next (§5); a Bluetooth route is 100 to 200 ms longer than the speaker's. +- **The device need not run at the reference's rate.** It opens at PortAudio's default + rate: through MME most likely 44.1 kHz, through WASAPI the rate of Windows' mixer, often + 48 kHz ([audio-drivers.md](audio-drivers.md)); a phone's usually at 48 kHz. A + recording is converted to the reference's rate as it is spliced, and the round trip is + counted in seconds and turned into frames of the recording + ([recording.md](recording.md#latency)), so a check on such a device is judged, and its + figure kept, like any other. The result names the device's rate among its figures (§4). +- **The manual checklist's device items had never been run.** Many ask whether logic the + app suite proves on `FakeAudioIO` holds on a real device. A loopback run in the real app + answers that without a person listening. + +## 2. The button + +**The Playback menu**, after the two audio device submenus: + +- **Calibrate Audio…**, disabled during any take and while a check runs; +- a line that cannot be chosen, the latency takes are placed with now: "Latency: measured + 281 ms, 26 Sep" (the year only when it is not this one), or "Latency: driver's figure, + 279 ms", with "(the measured one is out of date)" when a stored figure is stale, or "not + known yet" while the device reports nothing. Brought up to date whenever the menu opens; +- **Forget Measured Latency**, enabled while a figure is kept for these devices, stale or + not. + +While a check runs, Record, the driver and latency menus and both device submenus are +disabled as well. + +**The dialog** is not modal: the check's session is in the window and can be looked at +meanwhile. It is about 64 characters wide and as tall as its page's text, never more than +the window less a phone's bars (its safe area margins), and centred there when shown +(`PopupArea::place()`); text that does not fit scrolls. On Android its text is 85 % of +the phone's font, its buttons three quarters of a finger high, and it scrolls with one +finger. + +**While a check runs it is out of the way.** Start hides it, and `AudioCheckIndicator`, a +bar and one line of status ("Recording punch-in 2 of 4, 25 s left", "Dev checks, stage 1 +of 6: Long song"), stands in for it at the right end of the status bar: the status line's +own text (the countdown, the sung note) is at the left, and the status bar holds nothing of +the pane, so that corner covers nothing. On a phone the indicator grows the bar to two +thirds of a finger. A tap or click brings back the progress page, whose +**Make Small** hides it again; the end of the run, however it ends, brings the dialog back +on the result page. Closing the dialog while its check runs cancels the check, since +nothing else would show how the run ended; hidden, it has to be brought back first. + +Three pages: + +1. **Instructions:** one earcup against the microphone, off your ears (on a phone, plug in + first what you will sing with, since each route is calibrated on its own; with nothing + plugged in, the phone's own speaker and microphone make the loop); a moderate volume + and a quiet room; the driver, the output and input devices and the latency in use (on a + phone, the route, as far as it is known yet: §5); how long it takes. In development + builds the checkbox **Run the dev checks after calibrating**, on every time the page is + shown and not remembered. **Start**. +2. **Progress:** the step and the punch-in, a bar and the time left (the recording to come, + plus a guess of 3 s for each analysis), **Make Small**, **Cancel**. During the dev + checks, their stage. +3. **Result** (§4): **Use this latency** (only when the calibration is usable), **Copy** + (the page's text, and the dev checks' report after a dev run: §8), on Android **Save + Report...**, **Check Again**, **Close**. On a desktop the text can be selected too; on + a phone, where a selection cannot be copied and a finger dragged over the text scrolls + it, it cannot. + +**On Android, Start asks for the microphone first**, and the check starts once it is +given. The runner, too, ends the run rather than start a take without it ("may not use +the microphone"): a take asks for the microphone itself, and the answer, coming after the +check had given up, would start a take of the user's. From Start to the end of the run the +screen is kept on (`AndroidScreen`): no one touches the phone meanwhile, and a screen that +goes off sends Tony to the background, which stops the take +(`MainWindow::applicationStateChanged()`). + +**What a check does:** + +1. **A session of its own.** Tony writes the calibration reference (§3), a WAV of sweeps + and tones, into the application data directory and opens it as File ▸ Open does, + replacing the session. It asks to save the session open first, unless that is a + check's own: never saved, and playing a reference from that directory. The reference is + `calibrate-audio-reference-N.wav` with the lowest N free, after every other such file + in the directory is removed: a check never writes over the file the session open now + plays (Windows will not let it), and Recent Files, which lists every file opened and has + no remove, gets two of these names at most. +2. **Four punch-ins of three sweeps each** into the reference's take, about 6 s each. + Each is an ordinary take: its range is selected, `record()` is called with Record into + Selection, Play Reference While Recording and a 1 s pre-roll whatever the toolbar says, + and the take stops itself at the end of the selection through the Stop path, is spliced + and analysed. So every punch-in meets the audio stream as a real take does (kept + running between takes), and is placed with the round trip every take is placed with, + which is what is being measured. + The next punch-in waits for the take's analysis, which keeps pYIN's load out of its + timing. +3. **The check's playback.** Tony normalises every audio file to full scale as it reads it + and pans the reference hard left and its pitch and notes sonification hard right. The + check's session plays the reference centred, at a gain that brings it back to the + −12 dBFS it was made at, and the sonification silent. It stays so after the run; a + session opened afterwards plays as before. +4. **Judged from the take's file** (`AudioCheckRunner::readTakeFile()`), mixed to one + channel at the file's rate (the reference's), never from the take's model: the model + is normalised to full scale as it is read, so every take would read as clipped. + +Under a minute in all. Every step has a limit and ends the run with a reason: 60 s for the +reference's analysis, 30 s for a take's (four times those on Android, §7), a take's +lead-in and range plus 10 s to stop itself. A device that opens but has delivered not one +frame 2 s after the take's lead-in and range should have gone by ends the run with "The +audio device delivered no input", stopping the take through the Stop path, which then +finds nothing to splice. Timed from the take's own length, not from `record()`: a device +slow to start (a Bluetooth headset switching to its microphone) is never taken for a dead +one. A device that cannot be opened ends it with "The take did not start". Cancel, and a +session closed during a run, stop a take in progress through the Stop path. The window's +destructor abandons a run without the Stop path, which would splice and start pYIN in the +middle of the teardown. + +The test session stays open afterwards, so that its pitch tracks can be looked at. The +song comes back through Recent Files. + +## 3. Finding the sweeps, and the verdicts + +**The reference** (`LatencyCheck`). Each event is a linear sweep from 1 to 8 kHz, 200 ms, +with 10 ms raised-cosine edges and a −12 dBFS peak; 0.1 s of silence; a tone of 0.8 s at +196, 220.5, 245 or 294 Hz in turn (a whole number of samples per period at 44.1 kHz, or +pYIN reports a subharmonic: [testing.md](testing.md)); silence to the next event. Each tone +is voice-like, with the same peak: its pitch and every harmonic up to 4 kHz, the n-th at +1/n, in Newman's phases (so that they do not pile up into a sawtooth's edge, which item 10 +would read as a step), with a vibrato of ±10 cents at 5.5 Hz. The harmonics are for a +phone's speaker, or an earbud held to the microphone, which give out almost nothing at the +pitch itself: the pure tones never reached pYIN or the live tracker there, while the +sweeps did; the pitch is found from the harmonics' common period, the fundamental there or +not. The vibrato is for pYIN: a tone that repeats exactly repeats at two and three periods +as well as at one, pYIN weighs those alike, and it took a subharmonic for some tones, pure +or not. Their partials from 1 kHz up are in the sweep's band; they come after the sweep, 40 +dB under it at the finder's output, and change nothing it finds. Linear +rather than exponential: its spectrum is flat, so its matched filter gives the narrowest +peak, and the harmonics a small speaker adds to an exponential sweep match the sweep itself +shifted 67 and 106 ms earlier, where the earliest-peak rule looks. The spacings from sweep to +sweep are irregular, 1.6 to 2.6 s and all different by 0.1 s at least, so that a take +misplaced by whole events cannot look right. + +- *Calibration*, 26 s: twelve events, sweeps at 1.0, 3.1, 4.7, 7.2, 9.1, 10.8, 13.1, 15.7, + 17.7, 20.1, 21.9 and 24.1 s. The order of the spacings is what lets four punch-ins of + three events fit: where one punch-in ends and the next begins, 1.9 s of spacing is lost. +- *Dev*, 40 s: the calibration's events, then three with 3 s held tones, after sweeps at + 26.9, 30.9 and 35.2 s. +- *Long*, 240 s or any length (the start of the 240 s one): the calibration's spacings over + and over. No more than eleven spacings can differ by 0.1 s within 1.6 to 2.6 s. + +**The finder** looks within ±0.8 s of where a sweep should be: an FFT matched filter, the +envelope of its output, and the **earliest** peak within 6 dB of the largest (at least 1 ms +before it), so that a reflection stronger than the direct sound is not taken for it. A sweep +is *found* when its peak stands 15 dB over the window's median and 6 dB over the highest +peak more than 10 ms from it. Its **offset** is where it was found minus where the +reference has it: positive is late. + +**Judging a take.** An event is judged in a punch-in only when all the finder reads for it +(its window, and a sweep's length past it) lies inside the punch-in's range with 50 ms to +spare: the splice cuts and crossfades at the range's ends, and a sweep cut in half says +nothing about the audio path. Where a later punch-in overlaps an earlier one, the event is +judged in the later only. Each punch-in has the median and spread of its offsets; across +punch-ins, the offset is the median of their medians (each take counts once), with +their spread and a line fitted over position. + +**Verdicts**, in order of precedence: the first that applies is the verdict, and all are +kept. + +| Verdict | When | What it means, and the fix the result page gives | Usable | +| --- | --- | --- | --- | +| NoSignal | fewer than 2/3 of the judged sweeps found, or none | the microphone did not hear the sweeps: volume, a muted or wrong input, Windows' audio enhancements, a Bluetooth headset's Hands-Free device; on a phone, the microphone permission, or the phone's echo cancellation or noise suppression | no | +| Clipped | the input reached −0.2 dBFS in the punch-ins | too loud, which can move where sweeps are found: turn down, or hold the earcup a little away | no | +| Fading | the sweeps' level fell by 10 dB from the first half of the judged events to the second (six at least) | something filters the microphone: echo cancellation, noise suppression, audio enhancements | no | +| PositionDependent | over three punch-ins at least, the offsets lie on a line steeper than 0.5 % that leaves less than 5 ms | recording and playback run at different speeds | no | +| Scattered | the offsets disagree by more than 15 ms, across punch-ins or within one | the driver's timing varies too much for one latency: close other programs that use sound | no | +| Unsteady | they disagree by 5 to 15 ms | small enough: the measured round trip is the middle of it, and a take may land up to half the spread off | yes | +| Ok | none of these | the sweeps came back steadily | yes | + +One finding stands beside the verdict: **an echo**, a second peak at the same delay, +within 3 ms, after more than half of the sweeps heard and three at least, 20 ms late or +more and no more than 30 dB down: the input is played back out somewhere (Windows' "Listen +to this device", an interface's monitor) and heard again. A paragraph on the result page; +not a verdict. An echo under 20 ms is not seen: that is where the tail of a close +reflection lies. + +A device at another rate than the reference's is judged like any other: no verdict comes +from the rates. + +**The calibrated round trip** is the one the takes were placed with plus the median offset. +A take that landed late was spliced from too early a frame of its recording, so the round +trip grows. + +Every threshold is a starting value. The finder's were checked on synthetic takes (noise +alone reads 6 to 12 dB over the median, against 15; noise at 0 and −10 dB SNR still reads 38 +and 28 dB over it); none has been tuned on a real device yet. + +## 4. The result page + +The verdict in one sentence and its fix; the echo, if one was heard; then a table: the +round trip measured (not for NoSignal) against the driver's, output plus input; what the +takes were placed with (measured before, or the driver's figure); where each punch-in +landed (+ is late); the spread; sweeps found of those judged; both rates ("recorded at +48000 Hz, converted to the reference's 44100 Hz" where they differ); the input peak in +dBFS; the echo; the driver and the devices; on a phone, how each stream was opened +("Streams:"), which a kept figure is checked against (§5). A failed run shows why it +ended instead. + +**Use this latency** keeps the calibrated round trip for the devices the check started on, +not for those the Preferences name when it is pressed (the result stays on show for as long +as the user likes, and the device submenus open again when the run ends), and for the rate +the takes were recorded at. On a phone it is kept for the route the first punch-in was +recorded through, with how its streams were opened. + +## 5. The measured round trip in use + +`LatencyCalibration` keeps one figure per **key**: the audio driver and the playback and +record devices as the Preferences name them for `MainWindowBase::createAudioIO()` +(`audio-target`, then `audio-playback-device` and `audio-record-device`, each suffixed with +the driver when one is named), and the rate the device records at. In QSettings: +`LatencyCalibration/||//`. Device names can hold `/` and +non-ASCII characters; only `%`, `/`, `\` and `|` are percent-encoded, so that no subgroups +are made and a long non-ASCII name stays within the 255 characters of a Windows registry +key. The key follows `createAudioIO()`, not `audioDeviceSettingKey()`: they differ only for +an `audio-target` of "auto", which Tony never writes. + +Stored: the round trip and its spread in seconds, the date, and the output and input +latency the device reported then, in seconds. That pair is the **staleness fingerprint**: +when either latency the device reports now differs by more than 1 ms, its buffers have +changed and the round trip with them, and takes go back to the reported pair until the +check is run again. A stale figure is not deleted: it applies again if the driver goes back +to its old buffers. + +**On Android** there are no device settings: `OboeAudioIO` opens whatever route the phone +has and reports it (`AudioRoute`, through `AudioRouteReporter`): each device's type and +product name from `AudioManager`, the rate, and how each stream was opened. The key is +then the driver `oboe` and the two devices' type and product name, never their ids, which +a headset changes each time it is plugged in; so the speaker, a wired headset and a +Bluetooth one each keep a figure. The device is opened again when its streams are +disconnected, as they are when the route changes, and the menu line follows the new +route. Until the first take the device is open for output only, and the key takes the one +input a figure is kept with for that output, if there is exactly one. That is a guess: the +menu line and the instructions may show the figure of an input the first take does not +open, though the take itself looks its figure up with the input it got. + +Oboe's latencies come from timestamps and move by several ms between starts of the same +streams (5.2, 8.4 and 4.4 ms out on the first phone tried), which the 1 ms rule would take +for new buffers at every take. So on a route the fingerprint is how each stream was +**opened** (the audio API, MMAP or not, rate, channels, format, performance and sharing +mode, burst, buffer and capacity, and the input's preset), stored with the figure: it is +stale when a stream it describes opened otherwise, never because the reported pair moved, +and a stream not open (the input before the first take) is not compared. For the same +reason the check counts each punch-in as if placed with the first one's round trip +(`LatencyCheck::PunchIn::placedWith`), so that a reported pair that moved between +punch-ins does not read as spread. + +At every take, `MainWindow::roundTripAt()` gives the stored figure for the devices and the +recording's rate if there is one and it is not stale, else the reported sum, each reported +latency converted to seconds at the rate it is counted in. Then it is turned into frames of +the recording ([recording.md](recording.md#latency)). With no figure stored, at 44.1 kHz, +the round trip is exactly the old sum; a core test checks it over a grid of values. + +The menu line, Forget Measured Latency and the dialog's instructions use the rate of the +last take placed with a round trip, or before any take the session's: on a desktop the +device's rate is not known before a take (`AudioCallbackRecordTarget` has no getter for +it). Choosing a device or a driver from the menu resets it. So on a device at another +rate than the session's, the three see a figure kept for it only once a take has been +recorded since Tony started or the device or driver was chosen; takes are placed with it +from the first. A device that reports its route says its rate as soon as it is open, and +records at it, so on a phone the three use that. + +A dev run places its takes with the round trip the calibration before it measured, for the +run only: nothing is stored, the menu line goes on describing the window's own figure, and +the log calls it the audio check's own. + +## 6. Development builds + +`meson.build`: a build type that does not start with `release` (debug, debugoptimized, +plain, minsize, custom) adds `-DTONY_DEV_CHECKS` to the compiler's and moc's defines, +compiles `main/dev/`, and builds `test-tony-dev` (not for Android, which builds no tests). +`build.bat` sets `build_mingw` up as `debugoptimized`, so the development machine's builds +have the checks; `meson.build`'s default and the desktop CI workflows use `release`, so +desktop packages have none of the code. The Android build (`deploy/android/build-tony.sh`, +by hand and on CI) is `debugoptimized`: the phone's APK has the checks. Every use of +`main/dev/` elsewhere (a `friend` line, a member, the dialog's checkbox, the test class) is +inside `#ifdef TONY_DEV_CHECKS`: a `release` build must compile with no `main/dev/` file, +and building one is how to check it. + +Verified by the lead on 2026-09-26, on Linux: `meson setup build_release +--buildtype=release`, then `tony`, `test-tony-core` and `test-tony-app` built. No compile +command carried `-DTONY_DEV_CHECKS`, no `main/dev/` file was compiled, there was no +`test-tony-dev`, and `nm -C tony` found no `DevChecks` or `TakeObserver` symbol. Both +release suites were green: the core suite but for the four `TestTakesFile` tests that fail +on Linux only ([building.md](building.md)), the app suite whole (`TestAudioCheck` 25 passed, +`TestRecordWorkflow` 99, `TestUiChecks` 19, among others). `TestAudioCheck` runs the same in +both kinds of build: its fixture deletes the dev checks of a development build's window. + +Not done: a runtime flag, so that a release build on someone else's PC could run the +checks. It would ship the check code in every package. + +## 7. The dev run + +It starts once the calibration is done, when the checkbox was on and the calibration is +usable (otherwise the result page says why it did not run), and ends on the result page: +the calibration as above, a line for each check, and the report file's path. Cancel ends it. +A few minutes in all. + +### Stages + +| # | Stage | What it records | Items | +| --- | --- | --- | --- | +| 1 | Long song | a 240 s long reference as a session of its own; two punch-ins of about 2 s, the shortest ranges that judge the first sweep from a quarter and from five eighths of the way in (61.0–63.0 and 150.8–152.8 s) | 9; and 1, 2, 4 | +| 2 | Fresh punch-ins | the dev reference as a session of its own; [6.3, 10.2] and [16.8, 21.2] s, judging two sweeps each | 1, 2, 3, 4, 5 | +| 3 | Re-record | into the same take, [19.2, 21.2] s over the end of stage 2's second punch-in, with a 2.4 s pre-roll | 7, 12, 14; and 1, 2, 4 | +| 4 | Pre-roll near the start | [1.0, 4.2] s, judging the sweep at 3.1 s, asking for a 3 s pre-roll | 13, 14; and 1, 2, 4 | +| 5 | Joins | [26.0, 28.7] then [28.7, 32.0] s, meeting at J = 28.7 s, the middle of the held tone from 27.2 to 30.2 s | 10; and 1, 2, 4 | +| 6 | Save and reopen | the session saved into a scratch folder the way Save As saves once it has a name, opened again, and its take's file judged again | 1 | + +Why so: + +- **The long song first**, so that the dev reference then replaces its session as a + check's own, unsaved, without asking, and the run still ends on the saved session: no + saved session is ever replaced. Far into a song is also where a take converted at the + wrong rate would show. +- **Stage 2's ranges** leave room for the rest: the start (before 4.3 s) for stage 4; the + held tones for stage 5; and each range holds two sweeps, so that stage 3 can start inside + the second one past its first sweep and still judge the other. +- **Stage 3** starts at 19.2 s: the shortest range that judges the sweep at 20.1 s, with a + whole note of the take (18 to 18.8 s) before it that the ranged analysis must leave + alone. Its lead-in plays from 16.8 s over what stage 2 recorded: two gaps where the + reference is silent (16.8–17.7 and 18.8–19.2 s), a sweep and a tone. Two gaps, so that a + window held up over one still has the other for item 12 to judge. +- **Stage 5's** second punch-in's 1 s lead-in plays over what the first recorded; J lies + 1.5 s from either end of the tone, further than item 10 looks around it. + +**Limits.** A stage has the runner's limit for the reference's analysis, two takes' +analysis limits and two minutes more, 4 minutes on a desktop, and the reopen 1 minute: a +backstop behind the runner's own limits, which end a run first and give their reason. A +stage that fails ends the run; the checks are then worked out from the stages it got +through, and the rest are Skipped with the reason. On Android the runner's analysis +limits, and with them the stages' (10 minutes, the reopen 4), are four times a desktop's +(`AudioCheckRunner::kAnalysisTimeFactor`): the long song's 240 s took about 10 s on the +cloud machine, a phone's core was taken to be 3 to 6 times slower, and four times leaves +the long song as long to be analysed as it plays. The user's Pixel 9a turned out no slower +than the cloud machine (the runner logs each "was analysed in"): the calibration's 26 s +reference in 0.47 s, the long song's 240 s in 6.2 s, the dev reference's 40 s in 1.3 s, +and each punch-in's range in 0.6 to 0.75 s. The factor is kept, as a margin for slower +phones. + +A **`TakeObserver`** watches every punch-in from its Recording step until its analysis is +done. Every 20 ms it keeps the cursor (the `ViewManager`'s playback frame), the frames +received just before and just after reading the output levels, the output levels, the input +levels (from the level meter's signal), the status bar's text and whether a dialog is up; +each live dot as it first appears, with the cursor at that moment; the raw recording's +path, when the take stopped, and Play Singing Audio before and after. A dialog is a Qt +modal widget, or an event loop run deeper than the one the observer started in: Android +draws its own dialogs (message boxes, the file picker), which are no modal widget of Qt's, +and only the loop their `exec()` runs shows them. + +### The checks + +The items are numbered as the manual checklist was when they were planned, before `default` +rewrote it; the report and [manual-checklist.md](manual-checklist.md) §1 use these numbers. +"Placed within ±6 ms" means every judged sweep found, with its offset within ±6 ms: one +WASAPI period of drift between two sound cards' clocks, which the user accepted +([audio-drivers.md](audio-drivers.md), §7); ±2 ms until then. A phone is held to the same. + +| Item | Check | Passes when | Numbers | +| --- | --- | --- | --- | +| 1 | `latency_on_this_machine` | every punch-in of every stage placed within ±6 ms; after the reopen, the take's file judged again over the dev take's punch-ins gives the same offsets to the frame, and the pitch and notes the session restored are the same events (values as the file rounds them) | offsets of each punch-in, the largest, the round trip used, the same after reopening | +| 2 | `several_phrases_in_one_take` | at least two punch-ins; each wholly in the take's coverage, its median offset within ±6 ms, its own start gap measured | each punch-in's median offset and start gap | +| 3 | `live_dots` | stage 2: more than 10 dots in each punch-in, each on one of the reference's sounds, and those on tones within 50 cents of the tone. A sound's dots lie from its start, less a hop, to half the tracker's window and a hop past its end: a dot is drawn at the middle of its window, but YIN hears mostly the first half. Counted apart and not judged: dots on the sweeps (a subharmonic of their top), and dots at an edge, whose window straddles it: within one window of the tracker (46 ms) after a tone's start or the punch-in's, or within half a window (23 ms) either side of a tone's end, where the window holds the tone's decay through the room and what follows. On the fake they are on pitch, but through a real speaker, room and microphone they wander 50 to 75 cents (`TakeDiff::placeLiveDot()`) | dots per punch-in, on tones, at edges, on sweeps, elsewhere, and the message says how many were at edges; how far behind the cursor they appeared, median and spread | +| 4 | `nothing_of_the_take_in_the_speakers` | no echo in any stage; an output level of exactly 0 at every look that lies wholly in one of the reference's silent gaps; Play Singing Audio the same after each take as before, and the take heard or not as it says | the second arrival; the largest output level in the gaps, and over how many looks; the largest output level; the margin of a look | +| 5 | `mic_on_input_2` | stage 2: judged only when the microphone is on input 2 alone (an input within 20 dB of the loudest carries it), and then passes when every punch-in drew more than 10 dots; otherwise Measured, "not applicable here", as on a device that records one input channel (a phone's microphone); a Fail when no input recorded anything | each input's peak in each punch-in; which inputs carry the microphone | +| 7 | `record_from_a_position` | stage 3: placed within ±6 ms; outside the selection the take's audio the same bit for bit, and its pitch and notes beyond ±0.25 s unchanged | the range, offsets, audio, pitch and notes outside | +| 9 | `stop_on_a_long_song` | stage 1: each punch-in's time from Stop to its pitch merged under half the whole song's analysis time, and the take's pitch beyond ±0.25 s of each range unchanged, which shows only the range was analysed | the whole song's analysis, and each punch-in's time and share of it | +| 10 | `the_joins` | stage 5, at J: no step in the samples (the largest first difference within ±2 ms of J at most 10 dB over the 95th percentile of the 50 ms around); the pitch track running through (no gap over one hop within ±0.5 s, no frame twice, in order); exactly one note holding J and no note beginning or ending within 0.5 s; outside the two ranges ± 0.25 s, pitch and notes unchanged. A failure names its part: "step:", "pitch:", "note:", "outside:" | offsets, the second punch-in against the first, the step in dB, the pitch across J, the notes at J and within 1 s, the nearest note edge | +| 12 | `nothing_heard_or_changed_in_the_lead_in` | stage 3: before P the take's audio the same bit for bit and its pitch and notes beyond 0.25 s unchanged; and item 4's looks that end by P, now over the take's own audio, read 0 in the silent gaps | audio, pitch and notes before P; the output in the lead-in's gaps, and the looks; the longest wait between two looks | +| 13 | `pre_roll_near_the_start` | stage 4: a lead-in no longer than the 1 s of song before P; playback from frame 0, and the cursor never before it; a countdown shown, ending at 1, and starting no higher than the lead-in, round trip and start gap (and 50 ms) rounded up; placed within ±6 ms | the lead-in, where playback started, the cursor's lowest, the countdown, offsets | +| 14 | `record_into_selection_stops_by_itself` | stages 3 and 4: what was recorded past the selection's end, from the raw recording, between 0 and 0.25 s plus one look of the take timer (100 ms) and a block; the coverage after is the coverage before plus the selection, exactly; no dialog from the take's start to its analysis done | seconds past the end, and allowed; the coverage after; dialogs | + +Item 10 does not judge placement: items 1 and 2 do. Item 14 works out what the take needed +from the round trip, start gap, lead-in and range itself, not with `TakeTiming`, whose +margin is part of what it checks; each in seconds at its own rate, since the raw +recording, the round trip and the start gap count the device's frames and the lead-in and +range the reference's. Added up as frames, they read a take on a 48 kHz device (a phone's, +or WASAPI's) about 8 % of its lead-in and range too long, 0.34 and 0.36 s for stages 4 and +3, and fail it. + +Item 3 judges the dots as the live tracker draws them, after its level floor and with its +octave slips dropped ([recording.md](recording.md#the-live-tracker)): the floor keeps out a +room's steady noise, which failed item 3 on the user's PC, and the octave slips failed it +on the user's phone (§14). Both are what a singer would have seen too. + +**Pitch that is not there.** When the take has no pitch where a check compares it (as on a +phone whose speaker played none of the tones), the pitch part of items 1, 7, 9 and 12, and +item 10's pitch, note and "outside" parts, are **not judged**: the message says so, and the +verdict is the other parts', so the check can read Pass (§10). A pitch track that is there +and changed, or has a hole at the join, is judged and fails as before. Item 3 fails with no +dots: that is what it checks. + +**How items 4 and 12 read the output.** A look's output level is the loudest sample handed +to the device since the look before. It is placed on the reference's timeline from the +frames received, since each callback takes in a block of input and then hands out a block +of output, and from the measured start gap; not from the clock or the output latency, since +the levels are of what was handed to the device, before its latency. Either side of a look +lies a margin of one block: the most frames received between two looks that were not held +up. A look counts only when it lies wholly in a gap where the reference is silent. + +- A take played back out shows in a gap only if the take holds something there. A real + microphone records the room; a noiseless loopback would record the reference alone, and + the tests give the fake a noise floor for that reason. +- A stall of the GUI thread (a disk, the system) makes the frames received jump, and a look + across it spans a sound and is left out. When no look at all lay in a gap, that part is + **not judged**: the message says so, with the longest wait and the margin, and the + verdict is left to the check's other parts, so it can read Pass (§10). + +**Verdicts:** Pass; Fail; Measured, numbers only (item 5 when the microphone is not on +input 2 alone, or there is one input); Skipped, when the run ended before the stage the +check needs, or the long song was left out (`Options::longSeconds` of 0, for tests only). + +### The report + +`DevChecks.txt` in `TONY_TEST_LOG_DIR` if that is set, else in the application data +directory (`%APPDATA%\sonic-visualiser\Tony` on Windows; on a phone Tony's own, which +nothing else can read), written over by each run. Its header: the date, the output and +input devices, the audio driver they were opened through and the latency asked of it, the +audio drivers built in (on Android `oboe`, which bqaudioio's factory does not know), the +playback and record latencies the device reports (frames and ms), the round trip for the +run, the scratch folder, the session saved, and why the run ended early if it did. For a +device that reports its route, also the driver in use and how the output and input streams +opened for the first punch-in, on which those latencies depend. Then each check under "Item +N", with its verdict, message and numbers, and last `Totals: N passed, N failed, N +measured, N skipped`, as the suites end. + +The run saves its session as `dev-checks.ton` in a scratch folder `dev-checks-N` in the +application data directory, so that its take files land in a folder of their own, and +leaves it open to be looked at and played. The next run removes every such folder the +session open then does not use. + +## 8. What to send back + +After a run on a new machine or device: the result page's text and the whole +`DevChecks.txt`. **Copy** on the result page takes both (the report file is appended to +the page's text), and on Android **Save Report...** saves the same through the file picker +(`tony-dev-checks-

%2

That needs All files access, which Android " + "has from version 11 on.

").arg(app, why)); + return false; + } + + QMessageBox box(QMessageBox::Question, tr("All files access"), + tr("Allow %1 to use files where they are?" + "

%2

This needs All files access. " + "Open Settings, switch on Allow access to manage " + "all files for %1, and come back.

") + .arg(app, why), + QMessageBox::NoButton, m_parent); + QAbstractButton *open = + box.addButton(tr("Open Settings"), QMessageBox::AcceptRole); + box.addButton(tr("Not Now"), QMessageBox::RejectRole); + box.exec(); + if (box.clickedButton() != open) return false; + + // Up while the settings page is, and there when the user comes back: + // it goes by itself if access was given there, and otherwise waits + // to be told, which also covers a settings page shown beside Tony + // (split screen), when Tony never goes away. Qt's own box, not + // Android's: Qt cannot close Android's from here + bool away = false; + QMessageBox wait(QMessageBox::Information, tr("All files access"), + tr("Waiting for All files access

Switch on " + "Allow access to manage all files for %1 on " + "the settings page, then come back here.

") + .arg(app), + QMessageBox::NoButton, m_parent); + wait.setOption(QMessageBox::Option::DontUseNativeDialog); + wait.addButton(tr("Continue"), QMessageBox::AcceptRole); + wait.addButton(tr("Cancel"), QMessageBox::RejectRole); + QObject::connect(qApp, &QGuiApplication::applicationStateChanged, &wait, + [&](Qt::ApplicationState state) { + if (state != Qt::ApplicationActive) { + away = true; + } else if (away && hasAllFilesAccess()) { + wait.accept(); + } + }); + + if (!openSettings(true) && !openSettings(false)) { + QMessageBox::warning + (m_parent, tr("All files access"), + tr("The settings page could not be opened

Open the " + "phone's Settings, then Apps, %1, and allow All files " + "access there (on some phones it is under Special app " + "access).

").arg(app)); + return hasAllFilesAccess(); + } + + wait.exec(); + + bool allowed = hasAllFilesAccess(); + cerr << "AndroidStorage: All files access " + << (allowed ? "given" : "not given") << endl; + return allowed; +} diff --git a/main/AndroidStorage.h b/main/AndroidStorage.h new file mode 100644 index 00000000..58fb38f8 --- /dev/null +++ b/main/AndroidStorage.h @@ -0,0 +1,140 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TONY_ANDROID_STORAGE_H +#define TONY_ANDROID_STORAGE_H + +#include +#include +#include + +class QWidget; + +/** + * Android's "All files access" (MANAGE_EXTERNAL_STORAGE), with which + * Tony reads and writes the phone's own storage by path, as on the + * desktop: a session opens and saves where it is, with its audio and + * takes folder beside it, in a folder a sync app keeps in step with a + * computer. Android only; the path of a picked file is + * AndroidFiles::pathFromContentUri(), tested on the desktop. + */ +class AndroidStorage +{ + Q_DECLARE_TR_FUNCTIONS(AndroidStorage) + +public: + // parent is what the boxes asking for access are shown over + AndroidStorage(QWidget *parent); + + // Whether Tony may use the phone's shared storage by path. Never + // before Android 11, which has no such permission + static bool hasAllFilesAccess(); + + // The root of the phone's own shared storage, normally + // /storage/emulated/0; "" if Android does not say + static QString primaryRoot(); + + // The real path of the file a content:// URI the file picker gave + // names, if it is in the phone's own storage: from the URI, or from + // MediaStore, which shows Tony other apps' files only with All files + // access (AndroidFiles::pathLookupFor()). Else "", with why saying + // why not. Whether the file is there is not checked. The URI as + // Android wrote it (AndroidFiles::grantedUri()): the picker's grant + // is for that string only + static QString pathFor(QString uri, QString &why); + + // The name the file's provider gives the document at uri; "" if it + // gives none + static QString displayName(QString uri); + + /** + * A document opened through its provider, from the URI as Android + * wrote it: not through QFile, whose content file engine rebuilds + * the URI, differently for names with parentheses, and then has no + * grant for it. fd() is read or written as it is (QFile's + * DontCloseHandle) and stays the provider's ParcelFileDescriptor's: + * close() closes it through that, which is what tells a provider + * that watches for the close (MediaStore behind Downloads, a cloud + * app) that Tony has finished with the document. A descriptor taken + * from it instead (detachFd()) tells the provider so at once, before + * anything is written, and a provider may then keep nothing. + */ + class Document + { + public: + Document(); + // Closes the document if it is still open + ~Document(); + + // Opens the document at uri to read ("r") or write ("wt", which + // leaves only what is written now); false on failure, with error + // saying why + bool open(QString uri, QString mode, QString &error); + + bool isOpen() const { return m_fd >= 0; } + int fd() const { return m_fd; } + + // The size of the file behind the descriptor, as fstat() gives + // it; -1 for a pipe, which a provider may give instead of a file + qint64 fileSize() const; + + // Closes the document through its provider. False if that failed, + // or if the provider has said through a pipe that it went wrong + // at its end, with error saying why + bool close(QString &error); + + private: + QJniObject m_descriptor; + int m_fd; + + Document(const Document &) = delete; + Document &operator=(const Document &) = delete; + }; + + // The size of the document at uri as its provider gives it (its + // _size column): "" if it gives none, or could not be asked, with + // error saying why (see AndroidFiles::savedSize()) + static QString sizeOf(QString uri, QString &error); + + // Removes the document at uri if it is empty: the one the picker + // makes for a save, when the save is not made there. True if it did + static bool removeIfEmpty(QString uri); + + // The file Tony's output is kept in as well as the system log + // (main.cpp), which Help > Save Log... saves a copy of (LogFile) + static QString logPath(); + + // The installed package's version name, which build-apk.sh makes the + // version and the commit it was built from: the log's first line + // says which build ran. "" if Android does not say + static QString versionName(); + + // Asks for All files access: why, in a box, and then the system's + // settings page for it; back from there, checks again. True if Tony + // has it now + bool ask(QString why); + + // Whether ask() has been called since Tony started + bool hasAsked() const { return m_asked; } + +private: + QWidget *m_parent; + bool m_asked; + + // The settings page for All files access, Tony's own or the list of + // apps; false if it could not be opened + static bool openSettings(bool thisApp); +}; + +#endif diff --git a/main/AudioCheckIndicator.cpp b/main/AudioCheckIndicator.cpp new file mode 100644 index 00000000..9b8383f0 --- /dev/null +++ b/main/AudioCheckIndicator.cpp @@ -0,0 +1,140 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#include "AudioCheckIndicator.h" + +#include +#include +#include +#include +#include + +#include + +#ifdef Q_OS_ANDROID +#include "PopupArea.h" +#include +#include +#endif + +AudioCheckIndicator::AudioCheckIndicator(QWidget *parent) : + QWidget(parent), + m_pressed(false) +{ + QHBoxLayout *layout = new QHBoxLayout; + layout->setContentsMargins(4, 0, 4, 0); + setLayout(layout); + + m_bar = new QProgressBar; + m_bar->setRange(0, 1000); + m_bar->setValue(0); + m_bar->setTextVisible(false); + m_label = new QLabel; + layout->addWidget(m_bar); + layout->addWidget(m_label); + + // The whole of it is the one thing to press + m_bar->setAttribute(Qt::WA_TransparentForMouseEvents); + m_label->setAttribute(Qt::WA_TransparentForMouseEvents); + setCursor(Qt::PointingHandCursor); + +#ifdef Q_OS_ANDROID + // Tall enough for a finger at the bottom of the screen: the status + // bar grows by it while the check runs + if (QScreen *screen = QGuiApplication::primaryScreen()) { + setMinimumHeight(PopupArea::fingerWidth + (screen->physicalDotsPerInchY()) * 2 / 3); + } +#endif + + updateSizes(); +} + +AudioCheckIndicator::~AudioCheckIndicator() +{ +} + +void +AudioCheckIndicator::setText(QString text) +{ + m_text = text; + setToolTip(tr("%1. Show the check, with Cancel").arg(text)); + updateLabel(); +} + +void +AudioCheckIndicator::setProgress(int permille) +{ + if (permille < 0) { + m_bar->setRange(0, 0); + return; + } + m_bar->setRange(0, 1000); + m_bar->setValue(std::min(permille, 1000)); +} + +int +AudioCheckIndicator::progress() const +{ + if (m_bar->maximum() == 0) return -1; + return m_bar->value(); +} + +void +AudioCheckIndicator::mousePressEvent(QMouseEvent *e) +{ + if (e->button() != Qt::LeftButton) { + QWidget::mousePressEvent(e); + return; + } + m_pressed = true; + e->accept(); +} + +void +AudioCheckIndicator::mouseReleaseEvent(QMouseEvent *e) +{ + if (e->button() != Qt::LeftButton || !m_pressed) { + QWidget::mouseReleaseEvent(e); + return; + } + m_pressed = false; + e->accept(); + // As a button: a press that slides off and lifts elsewhere is not one + if (rect().contains(e->position().toPoint())) emit clicked(); +} + +void +AudioCheckIndicator::changeEvent(QEvent *e) +{ + QWidget::changeEvent(e); + if (e->type() == QEvent::FontChange) updateSizes(); +} + +void +AudioCheckIndicator::updateSizes() +{ + // The label's font, which a phone may give labels of their own + const int character = m_label->fontMetrics().averageCharWidth(); + m_bar->setFixedWidth(character * 10); + m_label->setFixedWidth(character * textWidth); + updateLabel(); +} + +void +AudioCheckIndicator::updateLabel() +{ + m_label->setText(m_label->fontMetrics().elidedText + (m_text, Qt::ElideRight, m_label->width())); +} diff --git a/main/AudioCheckIndicator.h b/main/AudioCheckIndicator.h new file mode 100644 index 00000000..568df8c3 --- /dev/null +++ b/main/AudioCheckIndicator.h @@ -0,0 +1,71 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TONY_AUDIO_CHECK_INDICATOR_H +#define TONY_AUDIO_CHECK_INDICATOR_H + +#include + +class QLabel; +class QProgressBar; + +/** + * The Calibrate Audio dialog made small while its check runs: a bar and + * one line of text (the step, the punch-in, the time left), which the + * window puts at the right end of its status bar, below the panes, so + * that the check's takes can be watched as they are drawn. A tap or a + * click on it says clicked(), and the dialog comes back with Cancel. + * + * The line keeps one width whatever it says, so that the status bar + * does not jump at each step, and is cut short with an ellipsis where + * it is longer; the whole of it is in the tooltip. + */ +class AudioCheckIndicator : public QWidget +{ + Q_OBJECT + +public: + explicit AudioCheckIndicator(QWidget *parent = nullptr); + virtual ~AudioCheckIndicator(); + + void setText(QString text); + QString text() const { return m_text; } + + /// How far the run has got, from 0 to 1000; -1 while that is not + /// known, for a bar that says only that something is going on + void setProgress(int permille); + int progress() const; + + /// The line's width, in average characters of the font + static const int textWidth = 40; + +signals: + void clicked(); + +protected: + void mousePressEvent(QMouseEvent *e) override; + void mouseReleaseEvent(QMouseEvent *e) override; + void changeEvent(QEvent *e) override; + +private: + QString m_text; + QLabel *m_label; + QProgressBar *m_bar; + bool m_pressed; + + void updateSizes(); + void updateLabel(); +}; + +#endif diff --git a/main/AudioCheckRunner.cpp b/main/AudioCheckRunner.cpp new file mode 100644 index 00000000..af350e6d --- /dev/null +++ b/main/AudioCheckRunner.cpp @@ -0,0 +1,792 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#include "AudioCheckRunner.h" + +#include "MainWindow.h" +#include "Analyser.h" +#include "SingingTakes.h" + +#include "audio/AudioCallbackRecordTarget.h" +#include "base/PlayParameterRepository.h" +#include "base/PlayParameters.h" +#include "base/Preferences.h" +#include "base/Selection.h" +#include "data/fileio/FileSource.h" +#include "data/fileio/WavFileReader.h" +#include "data/fileio/WavFileWriter.h" +#include "data/model/ReadOnlyWaveFileModel.h" +#include "data/model/WaveFileModel.h" +#include "layer/Layer.h" +#include "transform/ModelTransformerFactory.h" +#include "view/ViewManager.h" +#include "widgets/LevelPanToolButton.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +using std::cerr; +using std::endl; +using std::vector; + +using namespace sv; + +bool +AudioCheckResult::calibrationUsable() const +{ + if (failure != "") return false; + return summary.verdict == LatencyCheck::Verdict::Ok || + summary.verdict == LatencyCheck::Verdict::Unsteady; +} + +AudioCheckRunner::AudioCheckRunner(MainWindow *window) : + QObject(window), + m_window(window), + m_timer(new QTimer(this)), + m_step(Step::Idle), + m_punchIn(0), + m_openingReference(false), + m_inPoll(false), + m_stepLimitMs(0), + m_takeMs(0) +{ + m_timer->setInterval(kPollMs); + connect(m_timer, &QTimer::timeout, this, &AudioCheckRunner::poll); +} + +AudioCheckRunner::~AudioCheckRunner() +{ + // Deleted by the window in its destructor, before anything it reads + // goes, so the window is whole here. A run ends without a word: + // whoever was waiting for it goes with the window. A take in + // progress is left to the window, as any take is when it is deleted: + // stopping it here would splice it and start its analysis in the + // middle of the window's teardown + if (m_step != Step::Idle) { + cerr << "AudioCheckRunner: the window is going; the run ends" << endl; + } + abandon(); +} + +void +AudioCheckRunner::abandon() +{ + m_timer->stop(); + if (m_step == Step::Idle) return; + cerr << "AudioCheckRunner: the run is abandoned" << endl; + clearOverride(); + m_step = Step::Idle; +} + +AudioCheckRunner::Plan +AudioCheckRunner::calibrationPlan() +{ + Plan plan; + plan.layout = LatencyCheck::calibrationLayout(); + plan.punchIns = 4; + plan.eventsEach = 3; + return plan; +} + +vector +AudioCheckRunner::punchInsOf(const Plan &plan) +{ + if (plan.ranges.empty()) { + return LatencyCheck::punchInsFor + (plan.layout, plan.punchIns, plan.eventsEach); + } + if (plan.layout.rate <= 0) return {}; + + // One after another along the timeline, as judgeTake() is told they + // were recorded. Two may meet, as takes into adjacent selections do. + // Written so that a range of NaNs fails too + const double length = double(plan.layout.length) / plan.layout.rate; + double free = 0.0; + for (const LatencyCheck::PunchIn &p : plan.ranges) { + if (!(p.start >= free) || !(p.end > p.start) || !(p.end <= length)) { + return {}; + } + free = p.end; + } + return plan.ranges; +} + +QString +AudioCheckRunner::referenceDirectory() +{ + return QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); +} + +QString +AudioCheckRunner::nextReferencePath(QString directory, QString inUse) +{ + const QString stem = "calibrate-audio-reference"; + const QDir dir(directory); + const QFileInfo held(inUse); + + // One that cannot be removed (another program has it open, say) + // stays, and its number is passed over below + const QStringList names = + dir.entryList(QStringList() << stem + "*.wav", QDir::Files); + for (const QString &name : names) { + const QString path = dir.absoluteFilePath(name); + if (inUse != "" && QFileInfo(path) == held) continue; + if (!QFile::remove(path)) { + cerr << "AudioCheckRunner: an earlier reference could not be " + << "removed: " << path << endl; + } + } + + for (int n = 1; ; ++n) { + const QString path = + dir.absoluteFilePath(QString("%1-%2.wav").arg(stem).arg(n)); + if (!QFileInfo::exists(path)) return path; + } +} + +bool +AudioCheckRunner::start(const Plan &plan) +{ + if (m_step != Step::Idle) return false; + + if (m_window->m_recordTarget && m_window->m_recordTarget->isRecording()) { + cerr << "AudioCheckRunner::start: a take is being recorded" << endl; + return false; + } + + vector punchIns = punchInsOf(plan); + if (punchIns.empty()) { + if (plan.ranges.empty()) { + cerr << "AudioCheckRunner::start: the layout has no room for " + << plan.punchIns << " punch-ins of " << plan.eventsEach + << " events" << endl; + } else { + cerr << "AudioCheckRunner::start: the punch-ins given overlap, " + << "are out of order, are empty or reach outside the layout" + << endl; + } + return false; + } + + // Written so that a NaN fails too + if (!(plan.preRoll >= 0.0)) { + cerr << "AudioCheckRunner::start: a pre-roll of " << plan.preRoll + << " s" << endl; + return false; + } + + if (plan.keepSession && !m_window->getMainModel()) { + cerr << "AudioCheckRunner::start: there is no session to record into" + << endl; + return false; + } + + m_plan = plan; + m_result = AudioCheckResult(); + m_reported = Progress(); + + // The devices the takes will be recorded on. The window's device + // menus are shut while the run lasts; the rate comes with the takes, + // and so does the route of a device that reports one, which may open + // its input only for the first of them + m_result.key = m_window->latencyKey(0); + m_punchIns = punchIns; + m_starts.clear(); + m_ends.clear(); + m_punchIn = 0; + + cerr << "AudioCheckRunner::start: " << m_punchIns.size() << " punch-ins"; + if (m_plan.ranges.empty()) { + cerr << " of " << m_plan.eventsEach << " events"; + } + if (m_plan.keepSession) cerr << ", into the session open now"; + if (m_plan.roundTrip >= 0.0) { + cerr << ", placed with a round trip of its own, " + << m_plan.roundTrip * 1000.0 << " ms"; + } + if (m_plan.preRoll != kPreRollSeconds) { + cerr << ", with a pre-roll of " << m_plan.preRoll << " s"; + } + cerr << endl; + + // Nothing is done before the first poll, so that however the run + // ends, the caller hears of it through finished() and never from + // inside this call + setStep(Step::OpeningReference, 0); + m_timer->start(); + return true; +} + +void +AudioCheckRunner::cancel() +{ + if (m_step == Step::Idle) return; + stopTake(); + end(tr("The check was cancelled.")); +} + +bool +AudioCheckRunner::isRunning() const +{ + return m_step != Step::Idle; +} + +void +AudioCheckRunner::sessionClosing() +{ + // Opening the reference closes the session it replaces + if (m_step == Step::Idle || m_openingReference) return; + stopTake(); + end(tr("The session was closed during the check.")); +} + +void +AudioCheckRunner::poll() +{ + if (m_inPoll) return; + m_inPoll = true; + + // Before the step, so that the first poll says the reference is + // being opened before it is, and after it, so that a step begun here + // is reported as it begins and not a poll later + reportProgress(); + + switch (m_step) { + + case Step::Idle: + m_timer->stop(); + break; + + case Step::OpeningReference: + openReference(); + break; + + case Step::AnalysingReference: + if (!analysing(m_window->m_analyser)) { + // How fast this machine analyses, for the limits in the + // header. A session kept has its analysis done already + if (!m_plan.keepSession) { + cerr << "AudioCheckRunner: the reference, " + << double(m_plan.layout.length) / m_plan.layout.rate + << " s, was analysed in " + << double(m_stepClock.elapsed()) / 1000.0 << " s" + << endl; + } + startPunchIn(); + } else if (stepTimedOut()) { + end(tr("The test reference was not analysed in time.")); + } + break; + + case Step::Recording: + if (!m_window->m_recordTarget || + !m_window->m_recordTarget->isRecording()) { + takeStopped(); + } else if (deliveredNothing()) { + // Such a take never stops itself: it stops when what it has + // received reaches the end of its range + stopTake(); + end(tr("The audio device delivered no input: it opened, but in " + "%1 s of recording not one frame came from it. Is the " + "microphone connected, and allowed to be used?") + .arg(double(m_stepClock.elapsed()) / 1000.0, 0, 'f', 1)); + } else if (stepTimedOut()) { + stopTake(); + end(tr("A take did not stop at the end of its range.")); + } + break; + + case Step::AnalysingTake: + // Not needed for the judgement, which reads the take's audio: it + // keeps pYIN's load out of the timing of the next take + if (!analysing(m_window->m_analyser2)) { + // From the take stopped to its pitch and notes merged + cerr << "AudioCheckRunner: punch-in " << (m_punchIn + 1) + << " was analysed in " + << double(m_stepClock.elapsed()) / 1000.0 << " s" << endl; + if (++m_punchIn < int(m_starts.size())) { + startPunchIn(); + } else { + judge(); + } + } else if (stepTimedOut()) { + end(tr("A take was not analysed in time.")); + } + break; + } + + reportProgress(); + + m_inPoll = false; +} + +void +AudioCheckRunner::openReference() +{ + // The session open now is the reference, as the caller says, and + // stays open with its take + if (m_plan.keepSession) { + if (!m_window->getMainModel()) { + end(tr("There is no session to record into.")); + return; + } + referenceOpen(); + return; + } + + // Each call below may show a dialog, and while one is up the run may + // end: the session closed, or the check cancelled. A check's own + // session holds nothing of the user's, and its takes have marked it + // modified: it is let go as answering "No" does + if (sessionIsACheck()) { + cerr << "AudioCheckRunner: replacing the session of an earlier " + << "check without asking" << endl; + m_window->m_documentModified = false; + } else if (!m_window->checkSaveModified()) { + if (m_step == Step::OpeningReference) { + end(tr("Your session was kept open, so the check did not " + "replace it.")); + } + return; + } + if (m_step != Step::OpeningReference) return; + + // Made from the plan and written afresh every time, to a file of its + // own: the session open now may be the check before, and on Windows + // the file it plays cannot be written over. A plan that names a + // file has that one written over + QString path = m_plan.referencePath; + if (path == "") { + path = nextReferencePath(referenceDirectory(), mainModelFile()); + m_plan.referencePath = path; + } + cerr << "AudioCheckRunner: writing the reference to " << path << endl; + QDir().mkpath(QFileInfo(path).absolutePath()); + vector samples = LatencyCheck::generate(m_plan.layout); + WavFileWriter writer(path, m_plan.layout.rate, 1, + WavFileWriter::WriteToTarget); + const float *data = samples.data(); + if (!writer.isOK() || + !writer.writeSamples(&data, sv_frame_t(samples.size())) || + !writer.close()) { + end(tr("The test reference could not be written to \"%1\".") + .arg(path)); + return; + } + + m_openingReference = true; + MainWindowBase::FileOpenStatus status = + m_window->openPath(path, MainWindowBase::ReplaceSession); + m_openingReference = false; + if (m_step != Step::OpeningReference) return; + + if (status != MainWindowBase::FileOpenSucceeded || + !m_window->getMainModel()) { + end(tr("The test reference \"%1\" could not be opened.").arg(path)); + return; + } + + referenceOpen(); +} + +bool +AudioCheckRunner::sessionIsACheck() const +{ + // Saved, it is the user's to keep, whatever it holds + if (m_window->m_sessionFile != "") return false; + + const QString file = mainModelFile(); + if (file == "") return false; + + // Canonical paths, where both exist + return QFileInfo(file).absoluteDir() == QDir(referenceDirectory()); +} + +QString +AudioCheckRunner::mainModelFile() const +{ + // Not ReadOnlyWaveFileModel::getLocalFilename(): with the "normalise + // audio" preference on, as Tony has it, that is the file the reader + // decoded the audio into, in the temporary directory. The location + // is what the model was opened from, resolved here as it was then + auto model = std::dynamic_pointer_cast + (m_window->getMainModel()); + if (!model) return ""; + FileSource source(model->getLocation()); + if (source.isRemote()) return ""; + return source.getLocalFilename(); +} + +void +AudioCheckRunner::referenceOpen() +{ + // The punch-ins from here on are as the takes record them: in whole + // frames of the session, which is what the selections are made of + const sv_samplerate_t rate = m_window->getMainModel()->getSampleRate(); + m_result.referenceRate = rate; + for (LatencyCheck::PunchIn &p : m_punchIns) { + m_starts.push_back(sv_frame_t(std::llround(p.start * rate))); + m_ends.push_back(sv_frame_t(std::llround(p.end * rate))); + p = LatencyCheck::PunchIn(double(m_starts.back()) / rate, + double(m_ends.back()) / rate); + } + + // Its layers were made as it opened, or are there already + setPlayback(); + + setStep(Step::AnalysingReference, kReferenceTimeoutMs); +} + +void +AudioCheckRunner::startPunchIn() +{ + // A take of the user's own is never stopped by this record(), which + // would be a Stop + if (m_window->m_recordTarget && m_window->m_recordTarget->isRecording()) { + end(tr("Another take was being recorded.")); + return; + } + +#ifdef Q_OS_ANDROID + // Without the microphone, record() would ask for it and start a take + // of its own once it is given, long after this run had ended. The + // dialog asks for it before it starts a check + if (!m_window->microphoneAllowed()) { + end(tr("%1 may not use the microphone. Allow it, and check again.") + .arg(QCoreApplication::applicationName())); + return; + } +#endif + + const sv_frame_t from = m_starts[m_punchIn]; + const sv_frame_t to = m_ends[m_punchIn]; + const Step step = m_step; + + cerr << "AudioCheckRunner: punch-in " << (m_punchIn + 1) << " of " + << m_starts.size() << ", [" << from << "," << to << ")" << endl; + + // The selection is what Record into Selection records. Made quietly: + // a selection the user makes has the reference's pitch analysed + // again inside it, which would load the machine during the take + ViewManager *viewManager = m_window->m_viewManager; + viewManager->clearSelections(); + viewManager->addSelectionQuietly(Selection(from, to)); + + // Again for every take: whatever has happened since the last, the + // takes are all recorded with the same playback + setPlayback(); + + m_window->m_audioCheckTakes = true; + m_window->m_audioCheckRoundTrip = m_plan.roundTrip; + m_window->m_audioCheckPreRoll = m_plan.preRoll; + m_window->record(); + if (m_step != step) return; + + if (!m_window->m_recordTarget || + !m_window->m_recordTarget->isRecording()) { + end(tr("The take did not start: the audio device could not be " + "opened, or it refused to record.")); + return; + } + + // The lead-in and the range, then time for the latency and for the + // take to see that it has reached the end + const double seconds = + double(m_window->m_takePreRoll + (to - from)) / m_result.referenceRate; + m_takeMs = qint64(seconds * 1000.0); + setStep(Step::Recording, m_takeMs + kTakeStopTimeoutMs); +} + +void +AudioCheckRunner::takeStopped() +{ + clearOverride(); + m_result.takes.push_back(m_window->m_takeLatency); + + // The splice went wrong (the window has said so), or the recording + // was too short to hold anything + const SingingTakes *takes = m_window->m_takes; + if (!takes->haveTake() || + !takes->getCoverage().contains(m_starts[m_punchIn])) { + end(tr("A take was not added to the singing track.")); + return; + } + + setStep(Step::AnalysingTake, kTakeAnalysisTimeoutMs); +} + +void +AudioCheckRunner::judge() +{ + // The file holds what was recorded, at the reference's rate + vector mono; + sv_samplerate_t rate = 0; + const QString error = + readTakeFile(m_window->m_takes->getAudioPath(), mono, rate); + if (error != "") { + end(error); + return; + } + + // Each punch-in was placed with the round trip in use when it started, + // which on a phone moves from one to the next with what Oboe measured + // (the reported pair; a stored figure does not move): judgeTake() + // takes that out + for (int i = 0; i < int(m_punchIns.size()) && + i < int(m_result.takes.size()); ++i) { + const TakeLatency &t = m_result.takes[i]; + m_punchIns[i].placedWith = t.recordingSeconds(t.roundTrip); + } + + m_result.summary = LatencyCheck::judgeTake + (m_plan.layout, mono.data(), sv_frame_t(mono.size()), rate, + m_punchIns); + + end(""); +} + +QString +AudioCheckRunner::readTakeFile(QString path, vector &mono, + sv_samplerate_t &rate) +{ + mono.clear(); + rate = 0; + + FileSource source(path); + WavFileReader reader(source); + if (!reader.isOK() || reader.getChannelCount() < 1) { + return tr("The take's audio file \"%1\" could not be read: %2") + .arg(path).arg(reader.getError()); + } + + const int channels = reader.getChannelCount(); + const floatvec_t data = reader.getInterleavedFrames + (0, reader.getFrameCount()); + const sv_frame_t count = sv_frame_t(data.size()) / channels; + mono.assign(count, 0.f); + for (sv_frame_t i = 0; i < count; ++i) { + float sum = 0.f; + for (int c = 0; c < channels; ++c) sum += data[i * channels + c]; + mono[i] = sum / float(channels); + } + rate = reader.getSampleRate(); + return ""; +} + +void +AudioCheckRunner::setPlayback() +{ + const float gain = float(referenceGain()); + + // The reference's model: its waveform layer, and any other layer on + // it, has these parameters. Audible even if the user has muted the + // reference in their own sessions: the check has to hear it + if (auto params = PlayParameterRepository::getInstance() + ->getPlayParameters(m_window->getMainModelId().untyped)) { + params->setPlayAudible(true); + params->setPlayPan(0.f); + params->setPlayGain(gain); + } + + Analyser *reference = m_window->m_analyser; + for (Analyser::Component c : { Analyser::PitchTrack, Analyser::Notes }) { + Layer *layer = reference ? reference->getLayer(c) : nullptr; + if (!layer) continue; + if (auto params = layer->getPlayParameters()) { + params->setPlayAudible(false); + } + } + + // The toolbar's level control shows the reference's gain. Given one + // between its notches, it moves to the nearest and says so, and the + // window sets that gain through Analyser::setGain() and setAudible(), + // which write the shared settings. Moved here first without a word, + // it has nothing to say when the window shows the gain + if (LevelPanToolButton *control = m_window->m_audioLPW) { + QSignalBlocker quiet(control); + control->setLevel(gain); + control->setPan(0.f); + } + m_window->updateLayerStatuses(); +} + +double +AudioCheckRunner::referenceGain() +{ + // Made with its peak at kPeakDbfs, and read normalised to full scale, + // as MainWindow has every audio file read + if (!Preferences::getInstance()->getNormaliseAudio()) return 1.0; + return std::pow(10.0, LatencyCheck::kPeakDbfs / 20.0); +} + +AudioCheckRunner::Progress +AudioCheckRunner::currentProgress() const +{ + Progress p; + p.step = m_step; + p.punchIns = int(m_punchIns.size()); + if (m_step == Step::Recording || m_step == Step::AnalysingTake) { + p.punchIn = m_punchIn + 1; + } + + // The lead-in of a take to come is the whole of the plan's, unless + // its range starts sooner than that + for (int i = m_punchIn; i < int(m_punchIns.size()); ++i) { + const LatencyCheck::PunchIn &range = m_punchIns[i]; + double seconds = std::min(m_plan.preRoll, range.start) + + (range.end - range.start); + if (i == m_punchIn) { + if (m_step == Step::AnalysingTake) continue; + if (m_step == Step::Recording) { + seconds = std::max(0.0, seconds - + double(m_stepClock.elapsed()) / 1000.0); + } + } + p.secondsLeft += seconds; + } + return p; +} + +void +AudioCheckRunner::reportProgress() +{ + if (m_step == Step::Idle) return; + const Progress p = currentProgress(); + if (p.step == m_reported.step && p.punchIn == m_reported.punchIn && + std::ceil(p.secondsLeft) == std::ceil(m_reported.secondsLeft)) { + return; + } + m_reported = p; + emit progress(p); +} + +void +AudioCheckRunner::setStep(Step step, qint64 limitMs) +{ + m_step = step; + m_stepLimitMs = limitMs; + m_stepClock.start(); +} + +bool +AudioCheckRunner::stepTimedOut() const +{ + return m_stepLimitMs > 0 && m_stepClock.elapsed() > m_stepLimitMs; +} + +bool +AudioCheckRunner::deliveredNothing() const +{ + // The count starts again at 0 with every take (startRecording()). + // One frame is enough to wait for the take as usual: a device that + // delivers late or too little is what stepTimedOut() is for + return m_step == Step::Recording && m_window->m_recordTarget && + m_window->m_recordTarget->getFramesReceived() == 0 && + m_stepClock.elapsed() > m_takeMs + kNoInputTimeoutMs; +} + +void +AudioCheckRunner::stopTake() +{ + if (m_step != Step::Recording) return; + if (m_window->m_recordTarget && m_window->m_recordTarget->isRecording()) { + // The Stop button's path, which is also how a take ends itself + m_window->record(); + } +} + +void +AudioCheckRunner::end(QString failure) +{ + if (m_step == Step::Idle) return; + + m_timer->stop(); + m_step = Step::Idle; + clearOverride(); + + m_result.failure = failure; + if (!m_result.takes.empty()) { + // The reported pair as the take path worked it out, and compares + // a stored figure's fingerprint with + const TakeLatency &first = m_result.takes.front(); + m_result.usedRoundTrip = first.recordingSeconds(first.roundTrip); + m_result.reportedOutputLatency = first.reportedOutput; + m_result.reportedInputLatency = first.reportedInput; + m_result.recordingRate = first.recordingRate; + m_result.key.rate = first.recordingRate; + if (first.route.driver != "") { + m_result.route = first.route; + m_result.key = LatencyCalibration::routeKey + (first.route, first.recordingRate); + } + } + if (failure == "") { + m_result.calibratedRoundTrip = LatencyCheck::calibratedRoundTrip + (m_result.usedRoundTrip, m_result.summary.medianOffset); + } + + if (failure != "") { + cerr << "AudioCheckRunner: the check ended: " << failure << endl; + } else { + const LatencyCheck::TakeSummary &s = m_result.summary; + cerr << "AudioCheckRunner: " << LatencyCheck::verdictName(s.verdict) + << ", " << s.found << " of " << s.judged << " sweeps found, " + << "median offset " << s.medianOffset * 1000.0 << " ms, spread " + << s.spread * 1000.0 << " ms, input peak " << s.inputPeak + << "; round trip used " << m_result.usedRoundTrip * 1000.0 + << " ms, measured " << m_result.calibratedRoundTrip * 1000.0 + << " ms; recorded at " << m_result.recordingRate + << " Hz, reference at " << m_result.referenceRate << " Hz" + << endl; + } + + // A copy: whoever hears of it may start another run + AudioCheckResult result = m_result; + emit finished(result); +} + +void +AudioCheckRunner::clearOverride() +{ + m_window->m_audioCheckTakes = false; + m_window->m_audioCheckRoundTrip = -1.0; + m_window->m_audioCheckPreRoll = kPreRollSeconds; +} + +bool +AudioCheckRunner::analysing(Analyser *a) +{ + // What the app suite waits for: the first analysis complete, that of + // a recorded range merged, and the transform threads gone as well. + // Except that an analyser with no pitch track has nothing to wait + // for: with automatic analysis switched off, the reference is never + // analysed, and the check needs no analysis of its own + if (ModelTransformerFactory::getInstance()->haveRunningTransformers()) { + return true; + } + if (!a) return false; + if (a->isAnalysingRange()) return true; + return a->getLayer(Analyser::PitchTrack) && + a->getInitialAnalysisCompletion() < 100; +} diff --git a/main/AudioCheckRunner.h b/main/AudioCheckRunner.h new file mode 100644 index 00000000..ea73b9ed --- /dev/null +++ b/main/AudioCheckRunner.h @@ -0,0 +1,405 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TONY_AUDIO_CHECK_RUNNER_H +#define TONY_AUDIO_CHECK_RUNNER_H + +#include "LatencyCalibration.h" +#include "LatencyCheck.h" +#include "LatencyUtils.h" + +#include "base/BaseTypes.h" + +#include +#include +#include + +#include + +class MainWindow; +class Analyser; +class QTimer; + +/** + * What an audio check found. Times are in seconds. + */ +struct AudioCheckResult +{ + /// Why the run ended before it judged the take; empty if it did. + /// The rest is filled in only as far as the run got + QString failure; + + LatencyCheck::TakeSummary summary; + + /// What each punch-in was placed with, in the order recorded + std::vector takes; + + /// The round trip the first punch-in was placed with, and the two + /// latencies the device reported then, as the take path had them + /// (see TakeLatency) + double usedRoundTrip; + double reportedOutputLatency; + double reportedInputLatency; + + /// The rate the device recorded at, and the session's, which the + /// reference was made at. They may differ: a recording is converted + /// to the reference's rate before it is spliced, and the round trip + /// is counted in seconds, so a check at another rate is judged, and + /// its figure kept, like any other + sv::sv_samplerate_t recordingRate; + sv::sv_samplerate_t referenceRate; + + /// The round trip that would have placed the takes right + /// (LatencyCheck::calibratedRoundTrip()); see calibrationUsable() + double calibratedRoundTrip; + + /// What the figure is kept under (MainWindow::storeMeasuredLatency()): + /// the devices as the Preferences named them when the run started + /// (MainWindow::latencyKey()), or the route the first take was + /// recorded through, and the rate the takes were recorded at. Not the devices named + /// when the figure is kept: the result is on show for as long as the + /// user likes, and another device may have been chosen by then + LatencyCalibration::Key key; + + /// The route the first punch-in was recorded through, for a device + /// that reports one (TakeLatency::route): the key names it, and its + /// streams are the figure's fingerprint. Its driver is "" otherwise + AudioRoute::Route route; + + /// Whether calibratedRoundTrip means anything: the run was judged + /// Ok or Unsteady + bool calibrationUsable() const; + + AudioCheckResult() : usedRoundTrip(0), reportedOutputLatency(0), + reportedInputLatency(0), recordingRate(0), + referenceRate(0), calibratedRoundTrip(0) { } +}; + +/** + * The audio check: a test reference, written out and opened as a + * session of its own, then punch-ins recorded against it through the + * ordinary take path, and the take that comes out of them judged by + * LatencyCheck::judgeTake(). Every punch-in is a take as the user's + * are (the stream kept running between them), and is placed with the + * latency the window uses for any take, which is what is being + * measured. + * + * The takes are recorded with Record into Selection, Play Reference + * While Recording and the plan's pre-roll (kPreRollSeconds unless it + * says otherwise), whatever the toolbar says: MainWindow::record() and + * the rest consult an override the runner sets for each of its takes, + * since the toolbar's toggles write the user's settings. A punch-in's + * range is made the selection + * (the previous one cleared, then this one selected: "Select" steps in + * the history, as when the user selects), and record() is called; the + * take stops itself at the end of the selection, through the same path + * as the Stop button. + * + * The check's session plays the reference centred and at the level it + * was made at, LatencyCheck::kPeakDbfs, and leaves the pitch and notes + * sonification silent: an earcup is held to the microphone, and Tony + * otherwise plays the reference in the left channel only, normalised + * to full scale, with the sonification in the right. This is set on + * the play parameters of the session's own models, never through + * Analyser::setAudible() and the like, which write the settings every + * session reads. It stays so after the run, as the session does, and + * a session opened afterwards plays as before. + * + * Driven by a polling timer, like MainWindow's own take polling, and + * never by a nested event loop: this runs in every build, and the + * window can be closed at any moment. MainWindow owns it, deletes it + * in its destructor after those who drive it (the dialog, the dev + * checks) and before anything it reads, and tells it when the session + * closes. + * A friend of MainWindow: it drives the window's take path, reads what + * the take was placed with, and sets the playback of the session it + * opened, but changes nothing else there. + */ +class AudioCheckRunner : public QObject +{ + Q_OBJECT + +public: + /// The lead-in of the check's takes, unless the plan asks for another + static constexpr double kPreRollSeconds = 1.0; + + /// How often the runner looks at how a step is going + static constexpr int kPollMs = 50; + + /// How many times a desktop's the analyses a run waits for may take + /// here. pYIN analysed the dev checks' long song, 240 s, in about + /// 10 s on the machine the limits below were set on, and the + /// calibration's reference in about 1.3 s. A phone's core is 3 to 6 + /// times slower, slower still when it is hot or the work lands on a + /// small core, and no phone has been timed yet: its log says how long + /// each analysis took ("was analysed in"). Four times leaves the + /// long song as long to be analysed as it plays +#ifdef Q_OS_ANDROID + static constexpr int kAnalysisTimeFactor = 4; +#else + static constexpr int kAnalysisTimeFactor = 1; +#endif + + /// How long a step may take before the run gives up on it: the + /// first analysis of the reference, a take's analysis, and a take + /// beyond its own length (lead-in and range) to stop itself. The + /// last is in real time, whatever the machine + static constexpr int kReferenceTimeoutMs = 60000 * kAnalysisTimeFactor; + static constexpr int kTakeAnalysisTimeoutMs = 30000 * kAnalysisTimeFactor; + static constexpr int kTakeStopTimeoutMs = 10000; + + /// How long past its own length (lead-in and range) a take may go + /// without a single frame from the device before the run says the + /// device delivered no input. Not sooner: a stream that is only slow + /// to start (a Bluetooth headset switching to its microphone, say) + /// delivers its first block within a second or two, and a device + /// that has sent nothing for the whole length of the take plus this + /// has recorded none of it anyway + static constexpr int kNoInputTimeoutMs = 2000; + + /// What a run records + struct Plan { + LatencyCheck::Layout layout; + int punchIns; + int eventsEach; + + /// The punch-ins themselves, in seconds on the reference's + /// timeline, in the order they are recorded. When there are + /// any, they are what is recorded, and punchIns and eventsEach + /// are not used. They may meet but not overlap, and lie within + /// the layout (punchInsOf()) + std::vector ranges; + + /// Where the reference is written, over whatever is there; "" + /// for a new file in referenceDirectory() (nextReferencePath()) + QString referencePath; + + /// Record into the session open now, which the caller says is + /// a reference made from this plan's layout, and into its take: + /// no reference is written or opened, and nothing is asked. + /// What earlier runs recorded stays in the take, and only this + /// run's punch-ins are judged + bool keepSession; + + /// The round trip this run's takes are placed with, in seconds, + /// in place of the one the window places every take with; + /// negative for the window's own. For the run only: nothing is + /// stored, and the window goes on saying it uses its own + double roundTrip; + + /// The pre-roll this run's takes ask for, in seconds. As the + /// user's does, it gets shorter near the start of the song + /// (TakeTiming::preRollBefore()) + double preRoll; + + Plan() : punchIns(0), eventsEach(0), keepSession(false), + roundTrip(-1.0), preRoll(kPreRollSeconds) { } + }; + + /// The steps of a run, in order; the last two come once for each + /// punch-in + enum class Step { + Idle, + OpeningReference, + AnalysingReference, + Recording, + AnalysingTake + }; + + /// How far a run has got + struct Progress { + Step step; + + /// Punch-in punchIn of punchIns: the one being recorded, or + /// whose take is being analysed, counting from 1; 0 before the + /// first + int punchIn; + int punchIns; + + /// Seconds of recording still to come: the rest of the take + /// being recorded, and the lead-in and range of each one after + /// it. The waits for the analyses between them are not in it: + /// their length is not known + double secondsLeft; + + Progress() : step(Step::Idle), punchIn(0), punchIns(0), + secondsLeft(0) { } + }; + + explicit AudioCheckRunner(MainWindow *window); + virtual ~AudioCheckRunner(); + + /// The plan of Playback > Calibrate Audio: four punch-ins of three + /// events each on the calibration layout + static Plan calibrationPlan(); + + /// The punch-ins a run of the plan records, in seconds: its ranges + /// if it has any, else as many as it asks for from its layout + /// (LatencyCheck::punchInsFor()). Empty if they cannot be + /// recorded: ranges that overlap, come out of order, are empty or + /// reach outside the layout, or a layout with no room for the + /// punch-ins asked for + static std::vector punchInsOf(const Plan &plan); + + /// Where the reference is written unless the plan names a file: + /// the application's data directory + static QString referenceDirectory(); + + /** + * A file in the directory to write the next reference to, never + * the one inUse names: that is the session open now, perhaps the + * check before, and on Windows a file that is open cannot be + * written over. The references in the directory that inUse does + * not name are removed first, as no session holds them; and the + * lowest free number is taken, so the names go 1, 2, 1, 2 and + * Recent Files, where each one opened is listed, gets two at most. + */ + static QString nextReferencePath(QString directory, QString inUse); + + /** + * Begin a run. False, with nothing started, if one is running + * already, if a take is being recorded, if the plan's punch-ins + * cannot be recorded (punchInsOf()), if its pre-roll is negative, or + * if it keeps the session and there is none. Otherwise finished() + * comes once, at the end, + * however the run ends. + * + * A run that replaces the session asks the user whether to save it + * first (MainWindow::checkSaveModified()), unless it is a check's + * own: never saved, and playing a reference in referenceDirectory(). + * Nothing of the user's is in that, and Check Again would otherwise + * ask every time, the takes having changed it. + */ + bool start(const Plan &plan); + + /// End the run: a take in progress is stopped through the Stop + /// path, and finished() says the run was cancelled + void cancel(); + + /// End the run at once, with no finished() and no Stop path: for + /// the window's teardown, where whoever waits for the run goes as + /// well and a take in progress is left to the window, as any take + /// is when it is deleted. The destructor does this + void abandon(); + + bool isRunning() const; + + /// Called by MainWindow::closeSession() once the session is sure to + /// close: a run ends then, unless it is the run replacing it + void sessionClosing(); + + /// Whether the analyser, or any transform, is still at work: what + /// a run waits for after opening the reference and after each take + static bool analysing(Analyser *analyser); + + /** + * A take's audio file, mixed to one channel, at the file's rate + * (the reference's: a recording at another rate is converted before + * it is spliced). The file, and not the take's model: the model is + * normalised to full scale as it is read (the "normalise audio" + * preference), which would have every take clipped, and resampled + * to the session's rate. "" on success, else what went wrong. + */ + static QString readTakeFile(QString path, std::vector &mono, + sv::sv_samplerate_t &rate); + +signals: + void finished(const AudioCheckResult &result); + + /// When a step begins, and each time the whole seconds left go + /// down while a take is recorded. Never from inside start() or + /// cancel() + void progress(const AudioCheckRunner::Progress &state); + +private: + MainWindow *m_window; + QTimer *m_timer; + Step m_step; + Plan m_plan; + AudioCheckResult m_result; + + /// The last progress reported + Progress m_reported; + + /// The punch-ins in seconds, from the plan, until the reference is + /// open; from then on as the takes record them, in whole frames of + /// the session + std::vector m_punchIns; + std::vector m_starts; + std::vector m_ends; + int m_punchIn; + + /// Set while the runner replaces the session itself + bool m_openingReference; + + /// Set while poll() runs: a dialog shown from inside a step runs + /// an event loop of its own, in which the timer goes on firing + bool m_inPoll; + + QElapsedTimer m_stepClock; + qint64 m_stepLimitMs; + + /// The length of the take being recorded, lead-in and range, in ms + qint64 m_takeMs; + + /// The take has gone kNoInputTimeoutMs past its length with not one + /// frame from the device + bool deliveredNothing() const; + + void poll(); + void openReference(); + + /// The session open now is a check's own (see start()), and can be + /// replaced without asking + bool sessionIsACheck() const; + + /// The audio file the session's main model was opened from, or "" + QString mainModelFile() const; + + /// The session is open with the reference in it: the punch-ins in + /// frames of the session, its playback, and on to its analysis + void referenceOpen(); + + void startPunchIn(); + void takeStopped(); + void judge(); + + /// The check session's playback, set on the play parameters of the + /// reference and of its pitch and notes: see the class comment + void setPlayback(); + + /// The gain that brings the reference, as the session's model + /// has it, down to the level it was made at + static double referenceGain(); + + void setStep(Step step, qint64 limitMs); + bool stepTimedOut() const; + + Progress currentProgress() const; + + /// Emit progress() if the step, the punch-in or the whole seconds + /// left have changed since it was last emitted + void reportProgress(); + + /// Stop a take the check is recording, through the Stop path + void stopTake(); + + /// End the run and say so; failure is empty for a run judged + void end(QString failure); + + /// Take the override for the check's takes away from the window + void clearOverride(); +}; + +#endif diff --git a/main/AudioDriverMenus.cpp b/main/AudioDriverMenus.cpp new file mode 100644 index 00000000..84d778ec --- /dev/null +++ b/main/AudioDriverMenus.cpp @@ -0,0 +1,158 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#include "AudioDriverMenus.h" +#include "AudioDriverSettings.h" + +#include + +#include +#include +#include +#include + +#include + +AudioDriverMenus::AudioDriverMenus(QMenu *menu, + std::function implementations, + QObject *parent) : + QObject(parent), + m_implementations(implementations), + m_appliedLatency(0.0) +{ + m_driverMenu = menu->addMenu(tr("Audio Dri&ver")); + m_driverMenu->setStatusTip + (tr("Choose which audio driver Tony plays and records through")); + m_driverGroup = new QActionGroup(this); + m_driverGroup->setExclusive(true); + + m_latencyMenu = menu->addMenu(tr("Audio &Latency")); + m_latencyMenu->setStatusTip + (tr("Choose how much latency Tony asks the audio driver for: less " + "answers sooner, more is safer from dropouts")); + m_latencyGroup = new QActionGroup(this); + m_latencyGroup->setExclusive(true); + + // The ticks follow the Preferences, which a choice in the other menu, + // or the driver named by default, may have changed since + for (QMenu *m : { m_driverMenu, m_latencyMenu }) { + connect(m, &QMenu::aboutToShow, this, &AudioDriverMenus::rebuild); + } + + rebuild(); +} + +AudioDriverMenus::~AudioDriverMenus() +{ +} + +void +AudioDriverMenus::rebuild() +{ + const QStringList drivers = + AudioDriverSettings::drivers(m_implementations()); + + for (QActionGroup *group : { m_driverGroup, m_latencyGroup }) { + for (QAction *a : group->actions()) group->removeAction(a); + } + m_driverMenu->clear(); + m_latencyMenu->clear(); + + // One driver is no choice: the menus are for Windows, where there + // are three, and elsewhere the device menus are all there is + const bool shown = (drivers.size() >= 2); + m_driverMenu->menuAction()->setVisible(shown); + m_latencyMenu->menuAction()->setVisible(shown); + if (!shown) return; + + QSettings settings; + const QString current = + AudioDriverSettings::currentImplementation(settings); + const double latency = AudioDriverSettings::latency(settings, current); + + for (const QString &name : drivers) { + QAction *action = m_driverMenu->addAction(driverName(name)); + action->setCheckable(true); + action->setChecked(name == current); + action->setData(name); + m_driverGroup->addAction(action); + connect(action, &QAction::triggered, + this, [this, name]() { driverTriggered(name); }); + } + + for (double seconds : AudioDriverSettings::latencyChoices()) { + QAction *action = m_latencyMenu->addAction + (tr("%1 ms").arg(int(std::lround(seconds * 1000.0)))); + action->setCheckable(true); + action->setChecked(AudioDriverSettings::sameLatency(seconds, latency)); + action->setData(seconds); + m_latencyGroup->addAction(action); + connect(action, &QAction::triggered, + this, [this, seconds]() { latencyTriggered(seconds); }); + } +} + +void +AudioDriverMenus::setEnabled(bool enabled) +{ + m_driverMenu->menuAction()->setEnabled(enabled); + m_latencyMenu->menuAction()->setEnabled(enabled); +} + +double +AudioDriverMenus::applyLatency() +{ + QSettings settings; + const double seconds = AudioDriverSettings::latency + (settings, AudioDriverSettings::currentImplementation(settings)); + breakfastquay::AudioFactory::setSuggestedLatency(seconds); + m_appliedLatency = seconds; + return seconds; +} + +QString +AudioDriverMenus::driverName(QString implementation) +{ + // Tony's own on Android (OboeAudioIO, AudioRoute::driver), which + // bqaudioio's factory has never heard of and calls "(unknown)" + if (implementation == "oboe") return "Oboe"; + return QString::fromStdString + (breakfastquay::AudioFactory::getImplementationDescription + (implementation.toStdString())); +} + +void +AudioDriverMenus::driverTriggered(QString implementation) +{ + QSettings settings; + if (AudioDriverSettings::currentImplementation(settings) == + implementation) { + return; + } + AudioDriverSettings::setCurrentImplementation(settings, implementation); + emit driverChosen(implementation); +} + +void +AudioDriverMenus::latencyTriggered(double seconds) +{ + QSettings settings; + const QString current = + AudioDriverSettings::currentImplementation(settings); + if (AudioDriverSettings::sameLatency + (AudioDriverSettings::latency(settings, current), seconds)) { + return; + } + AudioDriverSettings::setLatency(settings, current, seconds); + emit latencyChosen(seconds); +} diff --git a/main/AudioDriverMenus.h b/main/AudioDriverMenus.h new file mode 100644 index 00000000..c7f4ee8a --- /dev/null +++ b/main/AudioDriverMenus.h @@ -0,0 +1,92 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TONY_AUDIO_DRIVER_MENUS_H +#define TONY_AUDIO_DRIVER_MENUS_H + +#include +#include +#include + +#include + +class QMenu; +class QActionGroup; + +/** + * Playback > Audio Driver and Audio Latency: the driver the device is + * opened through (MME, DirectSound, WASAPI) and the latency asked of it, + * each kept in the Preferences (AudioDriverSettings). Both are shown + * only where more than one driver is built in, which is on Windows. + * + * A choice is written to the Preferences here, and then said with + * driverChosen() or latencyChosen(): MainWindow stops what is playing + * and opens the device again, which is where the choice takes effect. + * Before it opens one, it hands the latency chosen for the driver to + * bqaudioio with applyLatency(). + */ +class AudioDriverMenus : public QObject +{ + Q_OBJECT + +public: + /** + * Add the two submenus to the end of the menu given. The list gives + * the implementations bqaudioio has, whenever the menus are built. + */ + AudioDriverMenus(QMenu *menu, std::function implementations, + QObject *parent = nullptr); + virtual ~AudioDriverMenus(); + + QMenu *driverMenu() const { return m_driverMenu; } + QMenu *latencyMenu() const { return m_latencyMenu; } + + /** + * Build both again from the implementations there are and the + * Preferences: shown if there are two drivers or more, with the + * driver named and its latency ticked. Done as either opens. + */ + void rebuild(); + + /// Not while a take is being recorded or an audio check runs + void setEnabled(bool enabled); + + /** + * Hand bqaudioio the latency chosen for the driver the Preferences + * name, for the streams opened from now on. Returns it. + */ + double applyLatency(); + + /// What applyLatency() last handed over, in seconds; 0 before it has + double appliedLatency() const { return m_appliedLatency; } + + /// A driver's name as the user knows it: "WASAPI" for "wasapi" + static QString driverName(QString implementation); + +signals: + void driverChosen(QString implementation); + void latencyChosen(double seconds); + +private: + QMenu *m_driverMenu; + QActionGroup *m_driverGroup; + QMenu *m_latencyMenu; + QActionGroup *m_latencyGroup; + std::function m_implementations; + double m_appliedLatency; + + void driverTriggered(QString implementation); + void latencyTriggered(double seconds); +}; + +#endif diff --git a/main/AudioDriverSettings.cpp b/main/AudioDriverSettings.cpp new file mode 100644 index 00000000..970c5676 --- /dev/null +++ b/main/AudioDriverSettings.cpp @@ -0,0 +1,136 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#include "AudioDriverSettings.h" + +#include + +#include + +namespace AudioDriverSettings { +namespace { + +const char *const preferencesGroup = "Preferences"; +const char *const targetKey = "audio-target"; + +} + +QStringList +drivers(const QStringList &implementations) +{ + QStringList result; + for (QString name : { QString("mme"), QString("directsound"), + QString("wasapi") }) { + if (implementations.contains(name)) result << name; + } + return result; +} + +std::vector +latencyChoices() +{ + return { 0.010, 0.020, 0.050, 0.100, 0.200 }; +} + +QString +currentImplementation(QSettings &settings) +{ + settings.beginGroup(preferencesGroup); + QString implementation = settings.value(targetKey, "").toString(); + settings.endGroup(); + if (implementation == "auto") return ""; + return implementation; +} + +void +setCurrentImplementation(QSettings &settings, QString implementation) +{ + settings.beginGroup(preferencesGroup); + settings.setValue(targetKey, implementation); + settings.endGroup(); +} + +QString +defaultDriver(const QStringList &implementations) +{ + for (QString driver : { QString("wasapi"), QString("mme") }) { + if (implementations.contains(driver)) return driver; + } + return {}; +} + +double +defaultLatency(QString implementation) +{ + return implementation == "wasapi" ? kWasapiDefaultLatency : kDefaultLatency; +} + +QString +latencySettingKey(QString implementation) +{ + return "audio-latency-" + implementation; +} + +double +latency(QSettings &settings, QString implementation) +{ + if (implementation == "") return kDefaultLatency; + settings.beginGroup(preferencesGroup); + bool ok = false; + // As text, as LatencyCalibration keeps its figures: every settings + // format keeps it whole + double seconds = settings.value(latencySettingKey(implementation), "") + .toString().toDouble(&ok); + settings.endGroup(); + if (!ok || !(seconds > 0.0)) return defaultLatency(implementation); + return seconds; +} + +void +setLatency(QSettings &settings, QString implementation, double seconds) +{ + if (implementation == "") return; + settings.beginGroup(preferencesGroup); + settings.setValue(latencySettingKey(implementation), + QString::number(seconds, 'g', 17)); + settings.endGroup(); +} + +bool +sameLatency(double a, double b) +{ + // The choices are whole milliseconds apart + return std::fabs(a - b) < 0.0005; +} + +bool +nameDefaultDriver(QSettings &settings, const QStringList &implementations) +{ + if (currentImplementation(settings) != "") return false; + const QString driver = defaultDriver(implementations); + if (driver == "") return false; + + settings.beginGroup(preferencesGroup); + settings.setValue(targetKey, driver); + const QString suffix = "-" + driver; + for (QString key : { QString("audio-playback-device"), + QString("audio-record-device") }) { + if (settings.contains(key) && !settings.contains(key + suffix)) { + settings.setValue(key + suffix, settings.value(key)); + } + } + settings.endGroup(); + return true; +} + +} diff --git a/main/AudioDriverSettings.h b/main/AudioDriverSettings.h new file mode 100644 index 00000000..35b2b13c --- /dev/null +++ b/main/AudioDriverSettings.h @@ -0,0 +1,95 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TONY_AUDIO_DRIVER_SETTINGS_H +#define TONY_AUDIO_DRIVER_SETTINGS_H + +#include +#include + +#include + +class QSettings; + +/** + * The audio driver Tony opens its device through, and the latency it + * asks that driver for, as the Preferences keep them. + * + * A driver is one of bqaudioio's implementations that is PortAudio + * restricted to one Windows host API: "mme", "directsound", "wasapi". + * The driver is "audio-target", which MainWindowBase::createAudioIO() + * reads, and with it the devices, which are kept per driver + * ("audio-playback-device-" and "audio-record-device-"). + * The latency is kept per driver as well, in seconds, as + * "audio-latency-". All in the group "Preferences". + */ +namespace AudioDriverSettings +{ + /// What a driver asks for when no latency has been chosen for it: + /// what every stream asked for before there was a choice + constexpr double kDefaultLatency = 0.2; + + /// What WASAPI asks for when none has been chosen: the user's runs + /// placed takes as well at 20 ms as at 200 ms on MME, with a third + /// of the round trip, and 10 ms gained little more + constexpr double kWasapiDefaultLatency = 0.02; + + /// The driver named when none is: WASAPI where it is built in, else + /// MME, which every stream went through before there was a choice; + /// "" where neither is + QString defaultDriver(const QStringList &implementations); + + /// What a driver asks for when no latency has been chosen for it + double defaultLatency(QString implementation); + + /// Those of the implementations given that are drivers, in the order + /// they are offered in + QStringList drivers(const QStringList &implementations); + + /// The latencies offered, in seconds, shortest first + std::vector latencyChoices(); + + /// The implementation the Preferences name, "" when none is ("auto" + /// counts as none, as MainWindowBase::createAudioIO() has it) + QString currentImplementation(QSettings &settings); + + /// Name the implementation in the Preferences. The devices chosen for + /// it are then the ones read + void setCurrentImplementation(QSettings &settings, QString implementation); + + /// The key of the latency chosen for an implementation, in the group + /// "Preferences" + QString latencySettingKey(QString implementation); + + /// The latency chosen for an implementation, in seconds, or its + /// defaultLatency() where none has been (or none is named) + double latency(QSettings &settings, QString implementation); + + void setLatency(QSettings &settings, QString implementation, + double seconds); + + /// Whether two latencies are the same choice + bool sameLatency(double a, double b); + + /** + * Where no implementation is named and there is a defaultDriver() + * among the ones given, name it, and give it the devices chosen + * before there were drivers (the keys without a suffix) where none + * are chosen for it. Once it is named this does nothing again. True if it named + * the driver. + */ + bool nameDefaultDriver(QSettings &settings, + const QStringList &implementations); +} + +#endif diff --git a/main/AudioRoute.cpp b/main/AudioRoute.cpp new file mode 100644 index 00000000..2ed58ecf --- /dev/null +++ b/main/AudioRoute.cpp @@ -0,0 +1,71 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#include "AudioRoute.h" + +namespace AudioRoute { + +QString +typeName(int type) +{ + // AudioDeviceInfo's TYPE_ constants, API 36 + switch (type) { + case 1: return "Earpiece"; + case 2: return "Built-in speaker"; + case 3: return "Wired headset"; + case 4: return "Wired headphones"; + case 5: return "Analog line"; + case 6: return "Digital line"; + case 7: return "Bluetooth headset (call)"; + case 8: return "Bluetooth"; + case 9: return "HDMI"; + case 10: return "HDMI ARC"; + case 11: return "USB device"; + case 12: return "USB accessory"; + case 13: return "Dock"; + case 14: return "FM"; + case 15: return "Built-in microphone"; + case 16: return "FM tuner"; + case 17: return "TV tuner"; + case 18: return "Telephony"; + case 19: return "Aux line"; + case 20: return "IP"; + case 21: return "Bus"; + case 22: return "USB headset"; + case 23: return "Hearing aid"; + case 24: return "Built-in speaker (safe)"; + case 25: return "Remote submix"; + case 26: return "Bluetooth LE headset"; + case 27: return "Bluetooth LE speaker"; + case 28: return "Echo reference"; + case 29: return "HDMI eARC"; + case 30: return "Bluetooth LE broadcast"; + case 31: return "Analog dock"; + case 32: return "Multichannel group"; + default: break; + } + return QString("Device of type %1").arg(type); +} + +QString +deviceName(const Device &device) +{ + // The id is left out: a headset gets another each time it is + // plugged in, and its figure would be lost with it + const QString type = (device.type > 0 ? typeName(device.type) : + QString("Unknown device")); + if (device.productName.trimmed() == "") return type; + return type + " (" + device.productName.trimmed() + ")"; +} + +} diff --git a/main/AudioRoute.h b/main/AudioRoute.h new file mode 100644 index 00000000..818b5405 --- /dev/null +++ b/main/AudioRoute.h @@ -0,0 +1,98 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TONY_AUDIO_ROUTE_H +#define TONY_AUDIO_ROUTE_H + +#include "base/BaseTypes.h" + +#include + +/** + * The route an audio device opened, where the device can say, as + * Oboe's can on a phone: which output and input it plays and records + * through (the speaker, a wired or USB headset, Bluetooth) and how it + * opened its streams. The Preferences name no device on a phone, which + * opens whatever route it has, and a Bluetooth route is 100 to 200 ms + * longer than the speaker's: a measured round trip is kept for the + * route, and is out of date once the streams open otherwise + * (LatencyCalibration). + */ +namespace AudioRoute +{ + /// A device as Android's AudioManager describes it (AudioDeviceInfo) + struct Device { + /// What AAudio opens it by. A device plugged in again gets + /// another, so it is logged but names nothing + int id; + + /// One of AudioDeviceInfo's TYPE_ constants; 0 if not known + int type; + + /// Its product name: the phone's model for its own speaker and + /// microphone, a headset's name for a Bluetooth one + QString productName; + + Device() : id(0), type(0) { } + }; + + struct Route { + /// The driver that reports it; "" for a device that reports no + /// route, whose devices the Preferences name (the desktop's) + QString driver; + + Device output; + + /// The input, while it is open; hasInput is false for a device + /// opened for playback only + bool hasInput; + Device input; + + /// The rate both streams run at + sv::sv_samplerate_t rate; + + /// How each stream was opened (the audio API, shared or + /// exclusive, its burst and buffer), on which its latency + /// depends; "" for a stream not open + QString outputStreams; + QString inputStreams; + + Route() : hasInput(false), rate(0) { } + }; + + /// A type of device in a few words, as AudioDeviceInfo's TYPE_ + /// constant of that number names it; "device of type N" for one + /// this does not know + QString typeName(int type); + + /// A device as a figure is kept for it and the user is told of it: + /// its type, and its product name if it has one, never its id + QString deviceName(const Device &device); +} + +/** + * An audio device that knows its route (OboeAudioIO; the tests' fake, + * when told one). The window asks for it by casting the device it + * opened; a device that is not one reports no route. + */ +class AudioRouteReporter +{ +public: + virtual ~AudioRouteReporter() { } + + /// The route as it was opened: its driver is "" if the device + /// cannot say + virtual AudioRoute::Route getAudioRoute() const = 0; +}; + +#endif diff --git a/main/CalibrateAudioDialog.cpp b/main/CalibrateAudioDialog.cpp new file mode 100644 index 00000000..ddc78b6a --- /dev/null +++ b/main/CalibrateAudioDialog.cpp @@ -0,0 +1,1251 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#include "CalibrateAudioDialog.h" + +#include "AudioCheckIndicator.h" +#include "AudioDriverMenus.h" +#include "MainWindow.h" +#include "PopupArea.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef TONY_DEV_CHECKS +#include +#include +#endif + +#ifdef Q_OS_ANDROID +#include "AndroidScreen.h" +#include +#endif + +#include +#include +#include + +using std::cerr; +using std::endl; + +using namespace sv; + +namespace { + +using LatencyCheck::Verdict; + +// How long a run of the plan takes, roughly: the recording, as the +// runner counts what is still to come, and each analysis waited for +double +expectedSeconds(const AudioCheckRunner::Plan &plan) +{ + const std::vector ranges = + AudioCheckRunner::punchInsOf(plan); + double seconds = 0.0; + for (const LatencyCheck::PunchIn &r : ranges) { + seconds += std::min(AudioCheckRunner::kPreRollSeconds, r.start) + + (r.end - r.start); + } + return seconds + CalibrateAudioDialog::kSecondsPerAnalysis * + double(ranges.size() + 1); +} + +// How far the offsets disagree, as judgeTake() looks at it for Unsteady +// and Scattered: across punch-ins or within one, whichever is more +double +timingSpread(const LatencyCheck::TakeSummary &s) +{ + double spread = s.spread; + for (const LatencyCheck::PunchInResult &p : s.punchIns) { + if (p.found > 0) spread = std::max(spread, p.spread); + } + return spread; +} + +QString +paragraph(QString html) +{ + return "

" + html + "

"; +} + +// Not "bold": the macOS SDK declares an enumerator of that name in the +// global namespace, which makes every unqualified call ambiguous there +QString +boldHtml(QString html) +{ + return "" + html + ""; +} + +// A page's text, scrolling where it is longer than the dialog can be +// tall. It asks for the text's height at the width it is given, so that +// the dialog is no taller than its text needs, and for a few lines at +// the least, so that the dialog can be as short as a phone's window +class TextArea : public QScrollArea +{ +public: + TextArea(QLabel *text) { + setWidget(text); + setWidgetResizable(true); + setFrameShape(QFrame::NoFrame); + setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); +#ifdef Q_OS_ANDROID + // A finger dragged over the text scrolls it, as on a phone; Qt's + // own gesture on the viewport wants two + QScroller::grabGesture(viewport(), QScroller::LeftMouseButtonGesture); +#endif + } + + bool hasHeightForWidth() const override { + return true; + } + + int heightForWidth(int width) const override { + const int frame = 2 * frameWidth(); + return widget()->heightForWidth(width - frame) + frame; + } + + QSize minimumSizeHint() const override { + const QFontMetrics metrics(widget()->font()); + return QSize(metrics.averageCharWidth() * 20, + metrics.lineSpacing() * 3 + 2 * frameWidth()); + } +}; + +} + +CalibrateAudioDialog::CalibrateAudioDialog(MainWindow *window, + AudioCheckRunner *runner) : + QDialog(window), + m_window(window), + m_runner(runner), + m_plan(AudioCheckRunner::calibrationPlan()), + m_running(false), + m_latencyKept(false), + m_expectedSeconds(0), + m_shownPermille(0), + m_collapsed(false) +#ifdef TONY_DEV_CHECKS + , + m_devChecksBox(nullptr), + m_devRunning(false), + m_haveDevReport(false) +#endif +{ + setWindowTitle(tr("Calibrate Audio")); + setModal(false); + +#ifdef Q_OS_ANDROID + // A phone's widget font is sized for text read on its own: in a + // window 400 px high the pages show more of themselves at a little + // less, and scroll less + QFont smaller = font(); + if (smaller.pixelSize() > 0) { + smaller.setPixelSize + (std::max(10, int(std::lround(smaller.pixelSize() * 0.85)))); + } else if (smaller.pointSizeF() > 0) { + smaller.setPointSizeF(smaller.pointSizeF() * 0.85); + } + setFont(smaller); + cerr << "CalibrateAudioDialog: text " << QFontInfo(font()).pixelSize() + << " px, the window's " << QFontInfo(window->font()).pixelSize() + << " px" << endl; +#endif + + QVBoxLayout *layout = new QVBoxLayout; + setLayout(layout); + + m_pages = new QStackedWidget; + layout->addWidget(m_pages, 1); + + auto textLabel = []() { + QLabel *label = new QLabel; + label->setWordWrap(true); + label->setTextFormat(Qt::RichText); + label->setAlignment(Qt::AlignLeft | Qt::AlignTop); + return label; + }; + + QWidget *instructions = new QWidget; + QVBoxLayout *instructionsLayout = new QVBoxLayout; + instructionsLayout->setContentsMargins(0, 0, 0, 0); + instructions->setLayout(instructionsLayout); + m_instructions = textLabel(); + m_instructionsArea = new TextArea(m_instructions); + instructionsLayout->addWidget(m_instructionsArea, 1); +#ifdef TONY_DEV_CHECKS + m_devChecksBox = new QCheckBox(tr("Run the dev checks after calibrating")); + m_devChecksBox->setChecked(true); + instructionsLayout->addWidget(m_devChecksBox); +#endif + m_pages->addWidget(instructions); + + QWidget *progress = new QWidget; + QVBoxLayout *progressLayout = new QVBoxLayout; + progressLayout->setContentsMargins(0, 0, 0, 0); + progress->setLayout(progressLayout); + m_step = new QLabel; + m_step->setWordWrap(true); + m_bar = new QProgressBar; + m_bar->setRange(0, 1000); + m_bar->setTextVisible(false); + m_timeLeft = new QLabel; + progressLayout->addWidget(m_step); + progressLayout->addWidget(m_bar); + progressLayout->addWidget(m_timeLeft); + progressLayout->addStretch(1); + m_pages->addWidget(progress); + + // Selectable, so that the figures can be copied and passed on. Not + // on a phone, whose selection cannot be copied (Copy does it), and + // where a finger dragged over the text scrolls it + m_resultText = textLabel(); +#ifndef Q_OS_ANDROID + m_resultText->setTextInteractionFlags(Qt::TextSelectableByMouse); +#endif + m_resultArea = new TextArea(m_resultText); + m_pages->addWidget(m_resultArea); + + m_buttons = new QHBoxLayout; + m_useButton = new QPushButton(tr("Use this latency")); + m_againButton = new QPushButton(tr("Check Again")); + m_startButton = new QPushButton(tr("Start")); + m_smallButton = new QPushButton(tr("Make Small")); + m_cancelButton = new QPushButton(tr("Cancel")); + m_closeButton = new QPushButton(tr("Close")); + m_copyButton = new QPushButton(tr("Copy")); + m_buttons->addWidget(m_useButton); + m_buttons->addStretch(1); + m_buttons->addWidget(m_copyButton); +#ifdef Q_OS_ANDROID + m_saveButton = new QPushButton(tr("Save Report...")); + m_buttons->addWidget(m_saveButton); +#endif + m_buttons->addWidget(m_againButton); + m_buttons->addWidget(m_startButton); + m_buttons->addWidget(m_smallButton); + m_buttons->addWidget(m_cancelButton); + m_buttons->addWidget(m_closeButton); + layout->addLayout(m_buttons); + +#ifdef Q_OS_ANDROID + // Tall enough for a finger, whatever the font + if (QScreen *screen = QGuiApplication::primaryScreen()) { + const int height = + PopupArea::fingerWidth(screen->physicalDotsPerInchY()) * 3 / 4; + for (QPushButton *button : { m_useButton, m_againButton, + m_startButton, m_smallButton, + m_cancelButton, m_closeButton, + m_copyButton, m_saveButton }) { + button->setMinimumHeight(height); + } + } +#endif + + // Put in the window's status bar by the window, and shown there while + // the check runs with the dialog hidden + m_indicator = new AudioCheckIndicator; + m_indicator->hide(); + connect(m_indicator, &AudioCheckIndicator::clicked, + this, &CalibrateAudioDialog::expand); + + connect(m_startButton, &QPushButton::clicked, + this, &CalibrateAudioDialog::startCheck); + connect(m_againButton, &QPushButton::clicked, + this, &CalibrateAudioDialog::startCheck); + connect(m_cancelButton, &QPushButton::clicked, + this, &CalibrateAudioDialog::cancelCheck); + connect(m_smallButton, &QPushButton::clicked, + this, &CalibrateAudioDialog::collapse); + connect(m_useButton, &QPushButton::clicked, + this, &CalibrateAudioDialog::useLatency); + connect(m_closeButton, &QPushButton::clicked, + this, &CalibrateAudioDialog::reject); + connect(m_copyButton, &QPushButton::clicked, + this, &CalibrateAudioDialog::copyReport); +#ifdef Q_OS_ANDROID + connect(m_saveButton, &QPushButton::clicked, + this, &CalibrateAudioDialog::saveReport); +#endif + + // Direct: the runner's signals carry types with no metatype + connect(m_runner, &AudioCheckRunner::progress, + this, &CalibrateAudioDialog::runnerProgress); + connect(m_runner, &AudioCheckRunner::finished, + this, &CalibrateAudioDialog::runnerFinished); + + showPage(Page::Instructions); +} + +CalibrateAudioDialog::~CalibrateAudioDialog() +{ + // The window's status bar holds it, unless the window is gone first + delete m_indicator; +} + +void +CalibrateAudioDialog::setPlan(const AudioCheckRunner::Plan &plan) +{ + m_plan = plan; +} + +#ifdef TONY_DEV_CHECKS + +void +CalibrateAudioDialog::setDevChecks(DevChecks *devChecks) +{ + if (m_devChecks) disconnect(m_devChecks, nullptr, this, nullptr); + m_devChecks = devChecks; + if (!devChecks) return; + + // Direct, as the runner's: the report has no metatype + connect(devChecks, &DevChecks::progress, + this, &CalibrateAudioDialog::devProgress); + connect(devChecks, &DevChecks::finished, + this, &CalibrateAudioDialog::devFinished); +} + +bool +CalibrateAudioDialog::devChecksWanted() const +{ + return m_devChecksBox->isChecked(); +} + +void +CalibrateAudioDialog::setDevChecksWanted(bool wanted) +{ + m_devChecksBox->setChecked(wanted); +} + +void +CalibrateAudioDialog::setDevOptions(const DevChecks::Options &options) +{ + m_devOptions = options; +} + +bool +CalibrateAudioDialog::startDevChecks(const AudioCheckResult &calibration) +{ + if (!m_devChecksBox->isChecked() || !m_devChecks) return false; + + // They place their takes with what the calibration measured, which + // means nothing unless it can be used + if (!calibration.calibrationUsable()) { + m_devNote = tr("The dev checks did not run: they need a calibration " + "that can be used."); + return false; + } + + DevChecks::Options options = m_devOptions; + options.roundTrip = calibration.calibratedRoundTrip; + if (!m_devChecks->start(options)) { + m_devNote = tr("The dev checks could not start."); + return false; + } + + m_devRunning = true; + m_step->setText(tr("Calibrated. Starting the dev checks...")); + m_timeLeft->setText(QString()); + m_bar->setRange(0, 0); + indicate(tr("Calibrated. Starting the dev checks"), -1); + return true; +} + +void +CalibrateAudioDialog::devProgress(QString stage, int stageNumber, int stages) +{ + if (!m_devRunning) return; + m_step->setText(tr("Dev checks, stage %1 of %2: %3...") + .arg(stageNumber).arg(stages).arg(stage)); + m_timeLeft->setText(QString()); + indicate(tr("Dev checks, stage %1 of %2: %3") + .arg(stageNumber).arg(stages).arg(stage), -1); +} + +void +CalibrateAudioDialog::devFinished(const DevReport &report) +{ + // Only those that carried on from a calibration of this dialog's + if (!m_devRunning) return; + m_devRunning = false; + m_devReport = report; + m_haveDevReport = true; + m_bar->setRange(0, 1000); + runEnded(); +} + +QString +CalibrateAudioDialog::devHtml() const +{ + QString html; + if (m_devNote != "") html += paragraph(m_devNote.toHtmlEscaped()); + if (!m_haveDevReport) return html; + + html += paragraph(boldHtml(tr("Dev checks"))); + if (m_devReport.failure != "") { + html += paragraph(tr("They ended early: %1") + .arg(m_devReport.failure.toHtmlEscaped())); + } + html += "
    "; + for (const CheckResult &c : m_devReport.checks) { + html += "
  • " + tr("Item %1, %2: %3").arg(c.item) + .arg(c.name.toHtmlEscaped()) + .arg(CheckResult::verdictName(c.verdict)) + "
  • "; + } + html += "
"; + html += paragraph(m_devReport.reportPath != "" ? + tr("Report: %1").arg(m_devReport.reportPath + .toHtmlEscaped()) : + tr("The report could not be written.")); +#ifdef Q_OS_ANDROID + // Where a phone keeps it, only Tony can read it + if (m_devReport.reportPath != "") { + html += paragraph(tr("Copy and Save Report... take the report with " + "this page.")); + } +#endif + return html; +} + +QString +CalibrateAudioDialog::devReportText() const +{ + if (!m_haveDevReport || m_devReport.reportPath == "") return QString(); + + // As the file has it, which is what a run on the desktop sends back + QFile file(m_devReport.reportPath); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + cerr << "CalibrateAudioDialog: the dev checks' report " + << m_devReport.reportPath << " could not be read: " + << file.errorString() << endl; + return "\n" + tr("The dev checks' report, %1, could not be read: %2") + .arg(m_devReport.reportPath, file.errorString()) + "\n"; + } + return "\n" + tr("The dev checks' report, %1:") + .arg(m_devReport.reportPath) + "\n\n" + + QString::fromUtf8(file.readAll()); +} + +#endif + +CalibrateAudioDialog::Page +CalibrateAudioDialog::page() const +{ + return Page(m_pages->currentIndex()); +} + +QString +CalibrateAudioDialog::pageText() const +{ + QTextDocument document; + switch (page()) { + case Page::Instructions: + document.setHtml(m_instructions->text()); + return document.toPlainText(); + case Page::Progress: + return m_step->text() + "\n" + m_timeLeft->text(); + case Page::Result: + document.setHtml(m_resultText->text()); + return document.toPlainText(); + } + return QString(); +} + +bool +CalibrateAudioDialog::canUseLatency() const +{ + return page() == Page::Result && + !m_useButton->isHidden() && m_useButton->isEnabled(); +} + +void +CalibrateAudioDialog::present() +{ + // The devices and the latency may have changed since it was last + // shown; a check of its own that is running stays on show + if (!m_running) { + m_instructions->setText(instructionsHtml()); +#ifdef TONY_DEV_CHECKS + // On each time the instructions are shown afresh + m_devChecksBox->setChecked(true); +#endif + showPage(Page::Instructions); + } + expand(); +} + +void +CalibrateAudioDialog::collapse() +{ + if (!m_running || !m_indicator) return; + m_collapsed = true; + m_indicator->show(); + hide(); +} + +void +CalibrateAudioDialog::expand() +{ + m_collapsed = false; + if (m_indicator) m_indicator->hide(); + fitToWindow(); + show(); + // Again, with the frame a desktop gives it when shown, if it is known + // by now: the dialog stays where it is, made smaller if it must be + fitToWindow(); + raise(); + activateWindow(); +} + +void +CalibrateAudioDialog::indicate(QString text, int permille) +{ + if (!m_indicator) return; + m_indicator->setText(text); + m_indicator->setProgress(permille); +} + +void +CalibrateAudioDialog::startCheck() +{ + if (m_running) return; + +#ifdef Q_OS_ANDROID + // The takes need the microphone, which Android asks the user for, and + // answers later: the check starts then, if this is still on show. + // A take started without it would ask, and start once it is given, + // long after the check had given up (AudioCheckRunner) + if (!m_window->microphoneAllowed()) { + QPointer dialog(this); + m_window->askForMicrophone([dialog]() { + if (dialog && dialog->isVisible()) dialog->startCheck(); + }); + return; + } +#endif + + m_result = AudioCheckResult(); + m_latencyKept = false; + m_expectedSeconds = expectedSeconds(m_plan); + m_shownPermille = 0; + m_bar->setRange(0, 1000); + m_bar->setValue(0); +#ifdef TONY_DEV_CHECKS + m_devRunning = false; + m_haveDevReport = false; + m_devReport = DevReport(); + m_devNote = QString(); +#endif + m_step->setText(tr("Starting the check...")); + m_timeLeft->setText(QString()); + indicate(tr("Starting the check"), 0); + + // The runner says nothing from inside start(), so the page is set + // for what comes after it either way + m_running = true; + showPage(Page::Progress); + + if (!m_runner->start(m_plan)) { + m_running = false; + AudioCheckResult refused; + refused.failure = tr("The check could not start while something " + "is being recorded. Stop the recording, then " + "try again."); + showResult(refused); + return; + } + +#ifdef Q_OS_ANDROID + // No one touches the phone for as long as the check runs, a few + // minutes with the dev checks, and a screen gone off would stop its + // take (AndroidScreen) + AndroidScreen::keepOn(true, "a check of the audio is running"); +#endif + + // Out of the way of the takes it records, which are the thing to + // watch while it runs + collapse(); +} + +void +CalibrateAudioDialog::cancelCheck() +{ +#ifdef TONY_DEV_CHECKS + // Its dev checks, which end at once and report through devFinished() + if (m_devRunning) { + if (m_devChecks) { + m_devChecks->cancel(); + } else { + // Gone with nothing said (the window is going) + m_devRunning = false; + runEnded(); + } + return; + } +#endif + + // The run ends at once, and its end comes back through + // runnerFinished() + if (m_running) m_runner->cancel(); +} + +void +CalibrateAudioDialog::useLatency() +{ + if (!canUseLatency()) return; + if (m_window->storeMeasuredLatency(m_result)) { + m_latencyKept = true; + } else { + cerr << "CalibrateAudioDialog: the measured latency was not kept" + << endl; + } + m_resultText->setText(resultHtml()); + showPage(Page::Result); +} + +QString +CalibrateAudioDialog::reportText() const +{ + QTextDocument document; + document.setHtml(resultHtml()); + QString text = tr("%1, Calibrate Audio, %2") + .arg(QCoreApplication::applicationName(), + QDateTime::currentDateTime().toString(Qt::ISODate)) + + "\n\n" + document.toPlainText() + "\n"; +#ifdef TONY_DEV_CHECKS + text += devReportText(); +#endif + return text; +} + +void +CalibrateAudioDialog::copyReport() +{ + QGuiApplication::clipboard()->setText(reportText()); + m_copyButton->setText(tr("Copied")); +} + +#ifdef Q_OS_ANDROID +void +CalibrateAudioDialog::saveReport() +{ + QString name = "tony-calibration"; +#ifdef TONY_DEV_CHECKS + if (m_haveDevReport) { + name = "tony-dev-checks"; + cerr << "CalibrateAudioDialog: the report saved holds the dev " + << "checks' report, " << m_devReport.reportPath << endl; + } +#endif + m_window->saveTextThroughPicker + (this, reportText().toUtf8(), tr("Save the report"), + QString("%1-%2.txt").arg(name) + .arg(QDateTime::currentDateTime().toString("yyyyMMdd-HHmmss")), + tr("report")); +} +#endif + +void +CalibrateAudioDialog::showResult(const AudioCheckResult &result) +{ + m_result = result; +#ifdef TONY_DEV_CHECKS + m_haveDevReport = false; + m_devReport = DevReport(); + m_devNote = QString(); +#endif + showResultPage(); +} + +void +CalibrateAudioDialog::showResultPage() +{ + m_latencyKept = false; + m_resultText->setText(resultHtml()); + showPage(Page::Result); +} + +void +CalibrateAudioDialog::runEnded() +{ + m_running = false; +#ifdef Q_OS_ANDROID + AndroidScreen::keepOn(false, "the check has ended"); +#endif + showResultPage(); + // Back from small by itself, to say how the run went + if (m_collapsed) expand(); +} + +void +CalibrateAudioDialog::reject() +{ + // Nothing else would show how the run went, and a check left running + // behind a closed dialog would go on playing chirps + if (m_running) cancelCheck(); + QDialog::reject(); +} + +void +CalibrateAudioDialog::runnerProgress(const AudioCheckRunner::Progress &p) +{ + if (!m_running) return; +#ifdef TONY_DEV_CHECKS + // The runner's part in the dev checks, which show their own stages + if (m_devRunning) return; +#endif + + // The step in full for the progress page, and in a few words for the + // indicator, whose line has the time left too + QString step, brief; + switch (p.step) { + case AudioCheckRunner::Step::Idle: + return; + case AudioCheckRunner::Step::OpeningReference: + step = tr("Opening the test session..."); + brief = tr("Opening the test session"); + break; + case AudioCheckRunner::Step::AnalysingReference: + step = tr("Getting the test reference ready..."); + brief = tr("Getting the test reference ready"); + break; + case AudioCheckRunner::Step::Recording: + step = tr("Recording punch-in %1 of %2. Keep the earcup against the " + "microphone.").arg(p.punchIn).arg(p.punchIns); + brief = tr("Recording punch-in %1 of %2") + .arg(p.punchIn).arg(p.punchIns); + break; + case AudioCheckRunner::Step::AnalysingTake: + step = tr("Analysing punch-in %1 of %2...") + .arg(p.punchIn).arg(p.punchIns); + brief = tr("Analysing punch-in %1 of %2") + .arg(p.punchIn).arg(p.punchIns); + break; + } + m_step->setText(step); + + // The recording still to come, and a rough time for each analysis + // not yet done: the reference's, and that of every take from the one + // being recorded or analysed on + int analyses = p.punchIns - std::max(p.punchIn, 1) + 1; + if (p.step == AudioCheckRunner::Step::OpeningReference || + p.step == AudioCheckRunner::Step::AnalysingReference) { + analyses = p.punchIns + 1; + } + const double left = p.secondsLeft + kSecondsPerAnalysis * analyses; + if (m_expectedSeconds > 0.0) { + int permille = int(1000.0 * (1.0 - left / m_expectedSeconds)); + m_shownPermille = + std::max(m_shownPermille, std::min(1000, std::max(0, permille))); + m_bar->setValue(m_shownPermille); + } + const int seconds = int(std::ceil(left)); + m_timeLeft->setText(tr("About %1 seconds left").arg(seconds)); + indicate(tr("%1, %2 s left").arg(brief).arg(seconds), m_shownPermille); +} + +void +CalibrateAudioDialog::runnerFinished(const AudioCheckResult &result) +{ + // A run started elsewhere is not this dialog's to show + if (!m_running) return; +#ifdef TONY_DEV_CHECKS + // Nor is the runner's part in the dev checks, which report for + // themselves. A calibration carries on into them if they are + // wanted, and is shown with their report when they end + if (m_devRunning) return; + m_result = result; + if (startDevChecks(result)) return; +#else + m_result = result; +#endif + runEnded(); +} + +void +CalibrateAudioDialog::showPage(Page page) +{ + m_pages->setCurrentIndex(int(page)); + + m_startButton->setVisible(page == Page::Instructions); + m_smallButton->setVisible(page == Page::Progress); + m_cancelButton->setVisible(page == Page::Progress); + m_againButton->setVisible(page == Page::Result); + m_closeButton->setVisible(page != Page::Progress); + m_copyButton->setVisible(page == Page::Result); + m_copyButton->setText(tr("Copy")); +#ifdef Q_OS_ANDROID + m_saveButton->setVisible(page == Page::Result); +#endif + m_useButton->setVisible(page == Page::Result && + m_result.calibrationUsable()); + m_useButton->setEnabled(!m_latencyKept); + + if (page == Page::Instructions) m_startButton->setDefault(true); + if (page == Page::Result) m_closeButton->setDefault(true); + + fitToWindow(); +} + +void +CalibrateAudioDialog::fitToWindow() +{ + ensurePolished(); + + const QRect area = windowArea(); + if (!area.isValid()) return; + + // The layouts keep the text areas' heights for a width until told + // that they may have changed, which a new text does not tell them + m_instructionsArea->updateGeometry(); + m_resultArea->updateGeometry(); + m_buttons->invalidate(); + + // A desktop's window frame, once the dialog has been shown in one; a + // phone draws none + const QSize frame = + (frameGeometry().size() - size()).expandedTo(QSize(0, 0)); + const QSize room = area.size() - frame; + + const int wanted = std::max(fontMetrics().averageCharWidth() * + preferredWidth, + minimumSizeHint().width()); + const int width = std::max(1, std::min(wanted, room.width())); + const int height = std::max(1, std::min(heightFor(width), room.height())); + resize(width, height); + + // Centred when it is about to be shown, as Qt would centre it, but + // clear of a phone's bars, which Qt's centring is not; where the user + // put it when it is on show + const QRect placed = + PopupArea::place(size() + frame, area, pos(), !isVisible()); + move(placed.topLeft()); + + // Said when it changes, for a report from a phone + const QRect now(pos(), size()); + if (now != m_fitted) { + m_fitted = now; + cerr << "CalibrateAudioDialog: " << now.width() << "x" + << now.height() << " at " << now.x() << "," << now.y() + << " within " << area.x() << "," << area.y() << " " + << area.width() << "x" << area.height() << endl; + } +} + +QRect +CalibrateAudioDialog::windowArea() const +{ + QScreen *screen = m_window->screen(); + if (!screen) screen = QGuiApplication::primaryScreen(); + if (!screen) return QRect(); + + // As TouchMenuStyle keeps menus inside it: on a phone the available + // geometry is the whole screen, bars and all, and the window's safe + // area margins are the bars + QRect window; + QMargins safeArea; + if (m_window->isVisible()) { + window = QRect(m_window->mapToGlobal(QPoint(0, 0)), m_window->size()); +#if QT_VERSION >= QT_VERSION_CHECK(6, 9, 0) + if (QWindow *handle = m_window->windowHandle()) { + safeArea = handle->safeAreaMargins(); + } +#endif + } + return PopupArea::usable(screen->availableGeometry(), window, safeArea, 0); +} + +int +CalibrateAudioDialog::heightFor(int width) const +{ + const QMargins margins = layout()->contentsMargins(); + const int inner = width - margins.left() - margins.right(); + + // The page on show only: the stack would give the tallest page's + QWidget *page = m_pages->currentWidget(); + const int pageHeight = page->hasHeightForWidth() ? + page->heightForWidth(inner) : page->sizeHint().height(); + + return margins.top() + pageHeight + std::max(0, layout()->spacing()) + + m_buttons->sizeHint().height() + margins.bottom(); +} + +QString +CalibrateAudioDialog::instructionsHtml() const +{ + // The devices the Preferences name, or on a phone the route it has + // open: the phone chooses it, and a figure is kept for each route + const LatencyCalibration::Key devices = m_window->latencyKey(0); + const AudioRoute::Route route = m_window->audioRoute(); +#ifdef Q_OS_ANDROID + // Before a file is opened no device is, and the route is not known + const bool phone = true; +#else + const bool phone = (route.driver != ""); +#endif + + // In fives of seconds: the analyses' share is a guess + const int seconds = 5 * int(std::ceil(expectedSeconds(m_plan) / 5.0)); + + QString html; + if (!phone) { + html += paragraph + (tr("Tony plays short chirps and records them, to measure how " + "late recordings arrive through your devices. What it " + "measures is used to place your takes on the reference.")); + html += paragraph(boldHtml(tr("Before you start:"))); + html += "
  • " + + tr("Hold one earcup of your headphones against the microphone, " + "%1: the chirps are sharp.").arg(boldHtml(tr("off your ears"))) + + "
  • " + + tr("Set a moderate volume, and keep the room quiet.") + + "
"; + } else { + html += paragraph + (tr("Tony plays short chirps and records them, to measure how " + "late recordings arrive through the phone's output and " + "input below. What it measures is used to place your takes " + "on the reference, whenever the phone has these two.")); + html += paragraph(boldHtml(tr("Before you start:"))); + html += "
  • " + + tr("Plug in what you will sing with first: each output and " + "input is calibrated on its own, and a Bluetooth headset " + "is much later than the phone's speaker.") + + "
  • " + + tr("With wired headphones, hold one earcup against the phone's " + "microphone (usually at its bottom edge), %1: the chirps " + "are sharp.").arg(boldHtml(tr("off your ears"))) + + "
  • " + + tr("With nothing plugged in, the phone's own speaker and " + "microphone make the loop: lay it down in a quiet room.") + + "
  • " + + tr("A headset with a microphone of its own records from that " + "microphone: hold the earcup against it.") + + "
  • " + + tr("Set a moderate volume, and keep the room quiet.") + + "
"; + } + // Open for playback only, a phone says which input it records from + // only when it records: the one calibrated with this output before, + // if there is one, else the phone's choice. With no device open, it + // says nothing yet + QString output = deviceName(devices.playbackDevice); + QString input = deviceName(devices.recordDevice); + if (phone && route.driver == "") { + output = tr("the phone chooses when the check starts"); + input = output; + } else if (phone && !route.hasInput) { + input = (devices.recordDevice != "" ? + tr("%1, as when it was calibrated; the phone chooses when " + "recording starts").arg(devices.recordDevice) : + tr("the phone chooses when recording starts")); + } + html += ""; + html += ""; + html += ""; + html += ""; + html += ""; + html += "
" + tr("Driver:") + "" + + AudioDriverMenus::driverName(devices.implementation).toHtmlEscaped() + + "
" + tr("Output:") + "" + + output.toHtmlEscaped() + "
" + tr("Input:") + "" + + input.toHtmlEscaped() + "
" + tr("Latency in use:") + "" + + describeLatency(m_window->latencyInUse()).toHtmlEscaped() + + "
"; + html += paragraph + (tr("The check replaces the session that is open with a test session " + "of its own, and asks you to save your work first. It takes about " + "%1 seconds. Your song is not changed: open it again from File " + "▸ Open Recent afterwards.").arg(seconds)); + return html; +} + +QString +CalibrateAudioDialog::resultHtml() const +{ +#ifdef TONY_DEV_CHECKS + return calibrationHtml() + devHtml(); +#else + return calibrationHtml(); +#endif +} + +QString +CalibrateAudioDialog::calibrationHtml() const +{ + const AudioCheckResult &r = m_result; + + if (r.failure != "") { + return paragraph(boldHtml(tr("The check did not finish."))) + + paragraph(r.failure.toHtmlEscaped()); + } + + const LatencyCheck::TakeSummary &s = r.summary; + const double driver = r.reportedOutputLatency + r.reportedInputLatency; + + // Recorded through a route a phone reported: its advice is a phone's + const bool phone = (r.route.driver != ""); + const QString measured = milliseconds(r.calibratedRoundTrip); + const QString timing = milliseconds(timingSpread(s)); + + // The verdict in plain words, and what to do about it + QString html; + switch (s.verdict) { + case Verdict::Ok: + html += paragraph(boldHtml(tr("The test sounds came back steadily, " + "%1 after they were played.") + .arg(measured))); + html += paragraph + (tr("Press Use this latency to place your takes with it.")); + break; + case Verdict::NoSignal: + html += paragraph(boldHtml(tr("Tony could not hear the test sounds: " + "it found %1 of %2.") + .arg(s.found).arg(s.judged))); + if (phone) { + html += "
  • " + + tr("Turn the volume up, and hold the earcup right against " + "the phone's microphone, or with nothing plugged in, " + "lay the phone down in a quiet room.") + "
  • " + + tr("Check that %1 may use the microphone (the phone's " + "Settings, Apps, %1, Permissions).") + .arg(QCoreApplication::applicationName()) + "
  • " + + tr("The phone may be cancelling echo or suppressing noise " + "on the microphone, which takes out what it plays " + "itself, although Tony asks for the microphone " + "unprocessed.") + "
"; + break; + } + html += "
  • " + + tr("Turn the volume up, and hold the earcup right against " + "the microphone.") + "
  • " + + tr("Check that the microphone is not muted, and that it is " + "the one Tony records from (Playback ▸ Audio Input " + "Device).") + "
  • " + + tr("In Windows, turn off Sound settings ▸ your microphone ▸ " + "Audio enhancements.") + "
  • " + + tr("Do not record through a Bluetooth headset's " + "\"Hands-Free\" device: it records at telephone quality.") + + "
"; + break; + case Verdict::Clipped: + html += paragraph(boldHtml(tr("The test sounds were too loud: the " + "recording reached full scale."))); + html += paragraph(tr("Turn the volume down, or hold the earcup a " + "little away from the microphone, and " + "check again.")); + break; + case Verdict::Fading: + html += paragraph(boldHtml(tr("The test sounds got quieter as the " + "check went on, by %1 dB.") + .arg(QLocale().toString + (s.fadingDb, 'f', 0)))); + if (phone) { + html += paragraph + (tr("Something on the phone is filtering the microphone, " + "such as echo cancellation or noise suppression, " + "although Tony asks for the microphone unprocessed. " + "Check again; if it fades again, send the report.")); + break; + } + html += paragraph + (tr("Something is filtering the microphone, such as echo " + "cancellation or audio enhancements. In Windows, turn " + "off Sound settings ▸ your microphone ▸ Audio " + "enhancements, and check again.")); + break; + case Verdict::PositionDependent: + html += paragraph(boldHtml(tr("The delay grew from one punch-in to " + "the next."))); + html += paragraph + (tr("The recording seems to run at another speed than the " + "playback, so no one latency places every take right.")); + break; + case Verdict::Scattered: + html += paragraph(boldHtml(tr("The driver's timing varies from take " + "to take by %1.").arg(timing))); + html += paragraph + (tr("No one latency places every take right when it varies " + "that much. Close other programs that use sound, and " + "check again.")); + break; + case Verdict::Unsteady: + html += paragraph(boldHtml(tr("The driver's timing varies from take " + "to take by %1.").arg(timing))); + html += paragraph + (tr("That is small enough to use: the measured round trip, " + "%1, is the middle of it. Press Use this latency to place " + "your takes with it.").arg(measured)); + break; + } + + if (s.echo.heard) { + html += paragraph + (tr("Your microphone is being played back somewhere (Windows " + "\"Listen to this device\", or an interface's direct " + "monitor): every test sound came back a second time, %1 " + "later. Turn that off, or your takes will be heard twice.") + .arg(milliseconds(s.echo.delaySeconds))); + } + + if (m_latencyKept) { + html += paragraph(boldHtml(tr("Kept.")) + " " + + tr("Takes on these devices are now placed with %1.") + .arg(measured)); + } + + // The figures, for whoever wants them, and for passing on + auto row = [](QString name, QString value) { + return "" + name + "" + value.toHtmlEscaped() + + ""; + }; + // What the sweeps found says how far the driver's figure is out even + // when it cannot be used, as long as enough of them were found + const bool haveMeasurement = s.found > 0 && + s.verdict != Verdict::NoSignal; + + html += ""; + html += row(tr("Round trip:"), + (haveMeasurement ? + tr("%1 measured").arg(measured) : tr("not measured")) + + tr("; the driver reports %1 (%2 out, %3 in)") + .arg(milliseconds(driver)) + .arg(milliseconds(r.reportedOutputLatency)) + .arg(milliseconds(r.reportedInputLatency))); + if (!r.takes.empty()) { + html += row(tr("Takes placed with:"), + (r.takes.front().measured ? + tr("%1, measured before") : tr("%1, the driver's figure")) + .arg(milliseconds(r.usedRoundTrip))); + } + QStringList offsets; + for (const LatencyCheck::PunchInResult &p : s.punchIns) { + offsets << (p.found > 0 ? + signedMilliseconds(p.medianOffset) : tr("nothing found")); + } + if (!offsets.isEmpty()) { + html += row(tr("Punch-ins landed:"), + tr("%1 (+ is late)").arg(offsets.join(", "))); + } + html += row(tr("Spread between punch-ins:"), milliseconds(s.spread)); + html += row(tr("Test sounds found:"), + tr("%1 of %2").arg(s.found).arg(s.judged)); + // A device at another rate than the reference's is a fact, not a + // fault: its recordings are converted as they are spliced + html += row(tr("Sample rates:"), + (r.recordingRate != r.referenceRate ? + tr("recorded at %1 Hz, converted to the reference's %2 Hz") : + tr("recorded at %1 Hz, reference at %2 Hz")) + .arg(hertz(r.recordingRate)).arg(hertz(r.referenceRate))); + html += row(tr("Input peak:"), + s.inputPeak > 0.0 ? + tr("%1 dBFS").arg(QLocale().toString + (20.0 * std::log10(s.inputPeak), 'f', 1)) : + tr("silence")); + html += row(tr("Echo:"), + s.echo.heard ? + tr("%1 after the sound, %2 dB %3") + .arg(milliseconds(s.echo.delaySeconds)) + .arg(QLocale().toString(std::fabs(s.echo.levelDb), 'f', 0)) + .arg(s.echo.levelDb <= 0.0 ? tr("quieter") : tr("louder")) : + tr("none heard")); + html += row(tr("Driver:"), + AudioDriverMenus::driverName(r.key.implementation)); + html += row(tr("Devices:"), + tr("output %1; input %2") + .arg(deviceName(r.key.playbackDevice), + deviceName(r.key.recordDevice))); + // What a figure kept for the route is checked against + if (phone) { + html += row(tr("Streams:"), tr("output %1; input %2") + .arg(r.route.outputStreams, r.route.inputStreams)); + } + html += "
"; + return html; +} + +QString +CalibrateAudioDialog::describeLatency(const LatencyCalibration::InUse &inUse) +{ + if (inUse.source == LatencyCalibration::Source::Measured) { + if (!inUse.date.isValid()) { + return tr("measured %1").arg(milliseconds(inUse.roundTrip)); + } + // The year only when it is not this one + const QDate day = inUse.date.toLocalTime().date(); + const QString format = + day.year() == QDate::currentDate().year() ? "d MMM" : "d MMM yyyy"; + return tr("measured %1, %2").arg(milliseconds(inUse.roundTrip)) + .arg(QLocale().toString(day, format)); + } + + // The device reports its latencies once it is open + if (!(inUse.roundTrip > 0.0)) { + return tr("driver's figure, not known yet"); + } + QString text = tr("driver's figure, %1").arg(milliseconds(inUse.roundTrip)); + if (inUse.stale) { + text = tr("%1 (the measured one is out of date)").arg(text); + } + return text; +} + +QString +CalibrateAudioDialog::milliseconds(double seconds) +{ + const double ms = seconds * 1000.0; + if (std::fabs(ms) < 9.95) { + double tenths = std::round(ms * 10.0) / 10.0; + if (tenths == 0.0) tenths = 0.0; // not "-0" + const int decimals = (tenths == std::round(tenths)) ? 0 : 1; + return tr("%1 ms").arg(QLocale().toString(tenths, 'f', decimals)); + } + return tr("%1 ms").arg(QLocale().toString(std::round(ms), 'f', 0)); +} + +QString +CalibrateAudioDialog::signedMilliseconds(double seconds) +{ + if (std::round(seconds * 10000.0) > 0.0) { + return "+" + milliseconds(seconds); + } + return milliseconds(seconds); +} + +QString +CalibrateAudioDialog::deviceName(QString name) +{ + // As the device menus call it + return name == "" ? tr("(System Default)") : name; +} + +QString +CalibrateAudioDialog::hertz(sv_samplerate_t rate) +{ + return QString::number(qint64(std::llround(rate))); +} diff --git a/main/CalibrateAudioDialog.h b/main/CalibrateAudioDialog.h new file mode 100644 index 00000000..bc3a335e --- /dev/null +++ b/main/CalibrateAudioDialog.h @@ -0,0 +1,294 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TONY_CALIBRATE_AUDIO_DIALOG_H +#define TONY_CALIBRATE_AUDIO_DIALOG_H + +#include "AudioCheckIndicator.h" +#include "AudioCheckRunner.h" +#include "LatencyCalibration.h" + +#ifdef TONY_DEV_CHECKS +#include "dev/DevChecks.h" +#include +class QCheckBox; +#endif + +#include +#include + +class MainWindow; +class QHBoxLayout; +class QLabel; +class QProgressBar; +class QPushButton; +class QScrollArea; +class QStackedWidget; + +/** + * Playback > Calibrate Audio: what the user sees of the audio check. + * Three pages: what to do before it starts, with the devices and the + * latency takes are placed with now; how far it has got, with Cancel; + * and what it found, in plain words, with the fix for whatever went + * wrong, the figures, and Use this latency when the round trip it + * measured can be used. + * + * Not modal: the check opens a session of its own in the window, which + * can be looked at while the dialog is up. A view over the runner and + * nothing more: it starts and cancels runs, shows what the runner + * reports of the runs it started (not of any other), and hands a result + * to MainWindow::storeMeasuredLatency(). It touches no model, layer or + * take. Closing it while its check runs cancels the check, since + * nothing else would show how the run ended. + * + * Small, to fit a phone held in landscape: never larger than the part + * of the window clear of the phone's bars, the pages' text scrolling + * where it is longer, and centred in that part when it is shown. While + * its check runs it is smaller still: it hides, and its indicator (a + * bar and a line of text in the window's status bar) says how far the + * check has got, out of the way of the takes being drawn. A tap on the + * indicator brings it back on the progress page, with Cancel and Make + * Small; when the run ends it comes back by itself, with the result. + * + * The window owns it, and makes it the first time it is asked for; it + * puts the indicator in its status bar. + * + * In development builds the instructions page has a checkbox, on by + * default and not remembered, to carry on into the dev checks + * (DevChecks) once the calibration is done and usable, with the round + * trip it measured. The progress page then follows them, Cancel ends + * whichever is running, and the result page adds a line for each check + * and the report file's path to the calibration's. + */ +class CalibrateAudioDialog : public QDialog +{ + Q_OBJECT + +public: + CalibrateAudioDialog(MainWindow *window, AudioCheckRunner *runner); + virtual ~CalibrateAudioDialog(); + + /// A rough time for each analysis a run waits for (the reference's, + /// and each take's), added to the recording still to come for the + /// progress shown: the runner cannot know how long pYIN takes + static constexpr double kSecondsPerAnalysis = 3.0; + + enum class Page { Instructions, Progress, Result }; + Page page() const; + + /// The words of the page on show, as plain text + QString pageText() const; + + /// Whether Use this latency is offered, and not yet pressed + bool canUseLatency() const; + + /// What the dialog becomes while its check runs, for the window's + /// status bar. Hidden until then; the dialog deletes it + AudioCheckIndicator *indicator() const { return m_indicator; } + + /// Whether the dialog is small: its check running, the dialog hidden + /// and the indicator on show + bool isCollapsed() const { return m_collapsed; } + + /// The width the dialog takes when the window has room, in average + /// characters of its font: about half a phone's width in landscape + static const int preferredWidth = 64; + + /// The result as plain text, under a line saying when, and in + /// development builds the dev checks' report file after it, whole, + /// if they wrote one: what Copy puts on the clipboard and Save + /// Report... saves. On a phone that file is where only Tony can + /// read it + QString reportText() const; + + /// What a check started here records: the calibration + /// (AudioCheckRunner::calibrationPlan()) unless set otherwise, as + /// the tests set a shorter one + void setPlan(const AudioCheckRunner::Plan &plan); + const AudioCheckRunner::Plan &plan() const { return m_plan; } + + /// The latency a take is placed with, and where it came from, in a + /// few words: "measured 187 ms, 25 Sep" or "driver's figure, 400 + /// ms". The Playback menu's line says the same + static QString describeLatency(const LatencyCalibration::InUse &inUse); + +#ifdef TONY_DEV_CHECKS + /// The dev checks a calibration carries on into; none until given + void setDevChecks(DevChecks *devChecks); + + /// "Run the dev checks after calibrating", on the instructions page + bool devChecksWanted() const; + void setDevChecksWanted(bool wanted); + + /// Where the dev checks write their report and scratch folders, as + /// the tests set them; the round trip is always the calibration's + void setDevOptions(const DevChecks::Options &options); +#endif + +public slots: + /// Show the dialog and bring it to the front: on the instructions, + /// with the devices and the latency as they are now, unless its + /// check is running + void present(); + + /// Start, and Check Again. The dialog goes small once the check + /// has started + void startCheck(); + + /// Make Small: while its check runs, the dialog hides and its + /// indicator shows how far the check has got + void collapse(); + + /// The dialog on show again, and its indicator hidden: a tap on the + /// indicator, and the end of a run + void expand(); + + /// Cancel: the run ends, and how it ended is the result shown. In + /// its dev checks, they end, and the calibration is shown with them + void cancelCheck(); + + /// Keep the round trip the check measured, for the devices it ran on + void useLatency(); + + /// The result page's Copy: reportText() on the clipboard, which on a + /// phone is how selected text would be copied, and cannot + void copyReport(); + +#ifdef Q_OS_ANDROID + /// The result page's Save Report...: reportText() through the save + /// picker, as Help > Save Log... saves the log + void saveReport(); +#endif + + /// The result page for this result + void showResult(const AudioCheckResult &result); + + /// Escape, the title bar's close button, and Close. A check still + /// running is cancelled first + void reject() override; + +private: + MainWindow *m_window; + AudioCheckRunner *m_runner; + AudioCheckRunner::Plan m_plan; + + /// A run this dialog started is going on + bool m_running; + + AudioCheckResult m_result; + bool m_latencyKept; + + /// The time the run was expected to take when it began, and the + /// share of it the progress bar has shown, which never goes back + double m_expectedSeconds; + int m_shownPermille; + + /// Small while the check runs + bool m_collapsed; + QPointer m_indicator; + + QStackedWidget *m_pages; + QLabel *m_instructions; + QScrollArea *m_instructionsArea; + QLabel *m_step; + QProgressBar *m_bar; + QLabel *m_timeLeft; + QLabel *m_resultText; + QScrollArea *m_resultArea; + + QHBoxLayout *m_buttons; + QRect m_fitted; + QPushButton *m_startButton; + QPushButton *m_cancelButton; + QPushButton *m_smallButton; + QPushButton *m_useButton; + QPushButton *m_againButton; + QPushButton *m_closeButton; + QPushButton *m_copyButton; +#ifdef Q_OS_ANDROID + QPushButton *m_saveButton; +#endif + + void runnerProgress(const AudioCheckRunner::Progress &progress); + void runnerFinished(const AudioCheckResult &result); + + void showPage(Page page); + + /// The result page for m_result as it stands + void showResultPage(); + + /// The end of a run this dialog started: the result page, on show + void runEnded(); + + /// The indicator's line and bar (-1 for one that says only that + /// something is going on) + void indicate(QString text, int permille); + + /// Sized for the page on show, within windowArea(), and placed there: + /// centred if the dialog is about to be shown, else kept where it is + void fitToWindow(); + + /// The part of the screen the dialog may cover: the window, less its + /// safe area margins (a phone's bars and camera cutout), within the + /// screen's available geometry; the screen's, while the window is + /// not on show + QRect windowArea() const; + + /// The dialog's height at width, all of the page's text on show + int heightFor(int width) const; + + QString instructionsHtml() const; + QString resultHtml() const; + QString calibrationHtml() const; + +#ifdef TONY_DEV_CHECKS + QPointer m_devChecks; + QCheckBox *m_devChecksBox; + DevChecks::Options m_devOptions; + + /// The run this dialog started is in its dev checks + bool m_devRunning; + + /// What they reported, or why they did not run + bool m_haveDevReport; + DevReport m_devReport; + QString m_devNote; + + /// Carry a calibration on into the dev checks, if they are wanted + /// and it can be used; false if the run ends here, with m_devNote + /// saying why when they were wanted + bool startDevChecks(const AudioCheckResult &calibration); + + void devProgress(QString stage, int stageNumber, int stages); + void devFinished(const DevReport &report); + + QString devHtml() const; + + /// The dev checks' report file as it is, under a line naming it, for + /// reportText(); "" if they wrote none + QString devReportText() const; +#endif + + /// Seconds in milliseconds, for reading: tenths below 10 ms, where + /// they say something, whole ones from there on + static QString milliseconds(double seconds); + static QString signedMilliseconds(double seconds); + + /// A device as the Preferences name it, "" being the default + static QString deviceName(QString name); + + static QString hertz(sv::sv_samplerate_t rate); +}; + +#endif diff --git a/main/CompactLayout.cpp b/main/CompactLayout.cpp new file mode 100644 index 00000000..45b592f5 --- /dev/null +++ b/main/CompactLayout.cpp @@ -0,0 +1,282 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#include "CompactLayout.h" + +#include "widgets/CommandHistory.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +CompactLayout::CompactLayout(QMainWindow *window) : + QObject(window), + m_window(window) +{ + m_action = new QAction(tr("&Compact Layout"), this); + m_action->setCheckable(true); + m_action->setStatusTip(tr("Show one toolbar of large buttons in place " + "of the menus and toolbars, as on a phone")); + connect(m_action, &QAction::toggled, this, &CompactLayout::setOn); +} + +CompactLayout::~CompactLayout() +{ + // The toolbar and its menu are the window's children, and go with it +} + +void +CompactLayout::setParts(const Parts &parts) +{ + m_parts = parts; +} + +bool +CompactLayout::isWantedAtStart(const QStringList &arguments) +{ +#ifdef Q_OS_ANDROID + (void)arguments; + return true; +#else + return arguments.contains("--compact"); +#endif +} + +void +CompactLayout::setOn(bool on) +{ + if (on != m_on) { + if (on) switchOn(); + else switchOff(); + } + m_action->setChecked(m_on); +} + +void +CompactLayout::makeToolBar() +{ + // Made the first time it is wanted, so that a window that is never + // compact has nothing of it + m_toolBar = new QToolBar(tr("Compact Toolbar"), m_window); + m_toolBar->setObjectName("Compact Toolbar"); + m_toolBar->setIconSize(QSize(iconSize, iconSize)); + + // Not to be dragged away by a finger, nor hidden from the window's + // menu of toolbars: the menus would go with it + m_toolBar->setMovable(false); + m_toolBar->toggleViewAction()->setVisible(false); + + // No icon to hand for the menus: the button shows the menu's title. + // It opens its popup on a tap, not on a press held down + m_menu = new QMenu(tr("Menu"), m_toolBar); + m_toolBar->addAction(m_menu->menuAction()); + m_menuButton = qobject_cast + (m_toolBar->widgetForAction(m_menu->menuAction())); + if (m_menuButton) { + m_menuButton->setPopupMode(QToolButton::InstantPopup); + } + m_toolBar->addSeparator(); + + for (QAction *action: { m_parts.play, m_parts.record, + m_parts.recordIntoSelection }) { + if (action) m_toolBar->addAction(action); + } + + // The take box is not the toolbar's for good: see moveTakeBoxIn() + m_takeBoxPlace = m_toolBar->addSeparator(); + + // The same Undo and Redo as the window's own toolbar, with their + // menus of what there is to undo and redo + sv::CommandHistory::getInstance()->registerToolbar(m_toolBar); + + if (m_parts.erase) m_toolBar->addAction(m_parts.erase); + m_toolBar->addSeparator(); + + for (QAction *action: { m_parts.zoomIn, m_parts.zoomOut }) { + if (action) m_toolBar->addAction(action); + } + m_toolBar->addSeparator(); + + // Again no icon: the name of the bar it brings up + m_panelAction = m_toolBar->addAction(tr("Show and Play")); + m_panelAction->setCheckable(true); + m_panelAction->setToolTip(tr("Show or hide what is shown and played, " + "the gains and the playback speed")); + connect(m_panelAction, &QAction::triggered, + this, &CompactLayout::showPanel); + + // A button that shows text is only as tall as the text: these are the + // toolbar's own buttons, and may grow to the height of the others + for (QAction *action: m_toolBar->actions()) { + if (!action->icon().isNull()) continue; + if (auto button = qobject_cast + (m_toolBar->widgetForAction(action))) { + button->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Preferred); + } + } +} + +void +CompactLayout::switchOn() +{ + if (!m_toolBar) makeToolBar(); + + // Before its action is hidden, which also disables it + if (m_parts.navigateTool && !m_parts.navigateTool->isChecked()) { + m_parts.navigateTool->trigger(); + } + + m_saved = Saved(); + + // isHidden(), not isVisible(): on Android this happens before the + // window is first shown, when nothing in it is visible yet + QMenuBar *menuBar = m_window->menuBar(); + m_saved.menuBarHidden = menuBar->isHidden(); + menuBar->hide(); + + for (QToolBar *toolBar: m_window->findChildren + (QString(), Qt::FindDirectChildrenOnly)) { + if (toolBar == m_toolBar) continue; + m_saved.toolBars.push_back({ toolBar, toolBar->isHidden() }); + toolBar->hide(); + } + + for (QWidget *widget: m_parts.hiddenWidgets) { + if (!widget) continue; + m_saved.widgets.push_back({ widget, widget->isHidden() }); + widget->hide(); + } + for (QWidget *widget: m_parts.shownWidgets) { + if (!widget) continue; + m_saved.widgets.push_back({ widget, widget->isHidden() }); + widget->show(); + } + + for (QAction *action: m_parts.hiddenActions) { + if (!action) continue; + m_saved.actions.push_back({ action, action->isVisible() }); + action->setVisible(false); + } + + // The popup holds the menu bar's own menus, the same objects + for (QAction *action: menuBar->actions()) { + if (QMenu *menu = action->menu()) disableTearOff(menu); + m_menu->addAction(action); + } + + moveTakeBoxIn(); + + m_panelAction->setChecked(false); + + m_window->addToolBar(Qt::TopToolBarArea, m_toolBar); + + // Explicitly: it was hidden when last taken away, and a toolbar + // added to a window on show is otherwise shown only later + m_toolBar->show(); + + m_on = true; +} + +void +CompactLayout::switchOff() +{ + moveTakeBoxBack(); + + // The menus are the menu bar's alone again. clear() deletes only + // actions of the popup's own, and these are the menus' + m_menu->clear(); + + m_window->removeToolBar(m_toolBar); + + for (const auto &saved: m_saved.tearOffs) { + if (saved.first) saved.first->setTearOffEnabled(saved.second); + } + for (const auto &saved: m_saved.actions) { + if (saved.first) saved.first->setVisible(saved.second); + } + for (const auto &saved: m_saved.widgets) { + if (saved.first) saved.first->setHidden(saved.second); + } + for (const auto &saved: m_saved.toolBars) { + if (saved.first) saved.first->setHidden(saved.second); + } + m_window->menuBar()->setHidden(m_saved.menuBarHidden); + + m_saved = Saved(); + m_on = false; +} + +void +CompactLayout::disableTearOff(QMenu *menu) +{ + m_saved.tearOffs.push_back({ menu, menu->isTearOffEnabled() }); + menu->setTearOffEnabled(false); + for (QAction *action: menu->actions()) { + if (QMenu *submenu = action->menu()) disableTearOff(submenu); + } +} + +void +CompactLayout::moveTakeBoxIn() +{ + // A widget added to a toolbar is held by a QWidgetAction of the + // toolbar's making, and it can be in one toolbar at a time: the + // action is taken out of the one it is in and put in this one, and + // the widget goes with it + QComboBox *box = m_parts.takeBox; + if (!box) return; + QToolBar *home = qobject_cast(box->parentWidget()); + if (!home) return; + + QList actions = home->actions(); + for (int i = 0; i < actions.size(); ++i) { + QWidgetAction *action = qobject_cast(actions[i]); + if (!action || action->defaultWidget() != box) continue; + m_saved.takeBoxAction = action; + m_saved.takeBoxHome = home; + m_saved.takeBoxNext = (i + 1 < actions.size() ? actions[i+1] : nullptr); + home->removeAction(action); + m_toolBar->insertAction(m_takeBoxPlace, action); + return; + } +} + +void +CompactLayout::moveTakeBoxBack() +{ + QAction *action = m_saved.takeBoxAction; + QToolBar *home = m_saved.takeBoxHome; + if (!action || !home) return; + + m_toolBar->removeAction(action); + + // Where it was: before the action that followed it, if that is still + // there, else at the end + QAction *next = m_saved.takeBoxNext; + if (next && !home->actions().contains(next)) next = nullptr; + home->insertAction(next, action); +} + +void +CompactLayout::showPanel(bool shown) +{ + for (QToolBar *toolBar: m_parts.panel) { + if (toolBar) toolBar->setVisible(shown); + } +} diff --git a/main/CompactLayout.h b/main/CompactLayout.h new file mode 100644 index 00000000..f9bf574f --- /dev/null +++ b/main/CompactLayout.h @@ -0,0 +1,162 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TONY_COMPACT_LAYOUT_H +#define TONY_COMPACT_LAYOUT_H + +#include +#include +#include +#include + +#include + +class QAction; +class QComboBox; +class QMainWindow; +class QMenu; +class QToolBar; +class QToolButton; +class QWidget; + +/** + * The window laid out for a phone held in landscape: one toolbar of + * touch-sized buttons in place of the menu bar and every other toolbar. + * It is on from the start on Android; on the desktop the View menu + * switches it (getAction()), and so does --compact at start. + * + * The toolbar holds the window's own actions, handed over by MainWindow + * in Parts: a menu button whose popup holds every menu of the menu bar, + * so that nothing becomes unreachable (and the menus' shortcuts keep + * working, which they would not with only the hidden menu bar holding + * them); Play, Record and Record into Selection; the take box; Undo and + * Redo; Erase; Zoom In and Zoom Out; and a button that shows and hides + * the panel, the Show and Play and speed toolbars at the bottom. + * + * The take box is moved into the toolbar and back rather than copied: + * it is the same widget, so it cannot drift out of step with the takes. + * + * Hidden while compact: the note-editing tools and the audio device + * menus (Parts::hiddenActions), the overview (Parts::hiddenWidgets) and + * the tear-off handles of the menus, which a finger would catch. Shown + * while compact: the thin song scroll bar in the overview's place + * (Parts::shownWidgets). + * + * Switching off puts back exactly what was there when it was switched + * on: the menu bar, which toolbars were on show, the hidden actions and + * widgets, the tear-off handles and the take box in its place. The + * compact toolbar leaves the window's layout, and so its state. The + * tool mode is the one exception: switching on selects Navigate, as the + * note-editing tool that could be selected is hidden, and switching off + * leaves it so. + */ +class CompactLayout : public QObject +{ + Q_OBJECT + +public: + /// The window's own actions and widgets that compact mode uses + struct Parts { + QAction *play = nullptr; + QAction *record = nullptr; + QAction *recordIntoSelection = nullptr; + QComboBox *takeBox = nullptr; + QAction *erase = nullptr; + QAction *zoomIn = nullptr; + QAction *zoomOut = nullptr; + + /// Selected on switching on: every other tool is hidden + QAction *navigateTool = nullptr; + + /// The toolbars the Show and Play button shows and hides + QList panel; + + /// Hidden while compact (a submenu is hidden by its menuAction()) + QList hiddenActions; + QList hiddenWidgets; + + /// Shown while compact, and as they were again when switched off + QList shownWidgets; + }; + + explicit CompactLayout(QMainWindow *window); + virtual ~CompactLayout(); + + /// Before it is first switched on + void setParts(const Parts &parts); + + /// The checkable switch, for the View menu + QAction *getAction() const { return m_action; } + + bool isOn() const { return m_on; } + + /// The compact toolbar and two of its buttons: null until first on + QToolBar *getToolBar() const { return m_toolBar; } + QToolButton *getMenuButton() const { return m_menuButton; } + QAction *getPanelAction() const { return m_panelAction; } + + /// The size of the compact toolbar's icons, in logical pixels: on a + /// phone Qt makes those Android's dp, where the style's 24 is small + /// for a finger + static const int iconSize = 40; + + /// Whether the window starts compact: always on Android, elsewhere + /// when the command line says --compact + static bool isWantedAtStart(const QStringList &arguments); + +public slots: + void setOn(bool on); + +private slots: + void showPanel(bool shown); + +private: + void makeToolBar(); + void switchOn(); + void switchOff(); + void moveTakeBoxIn(); + void moveTakeBoxBack(); + void disableTearOff(QMenu *menu); + + QMainWindow *m_window; + Parts m_parts; + QAction *m_action; + bool m_on = false; + + QToolBar *m_toolBar = nullptr; + QMenu *m_menu = nullptr; + QToolButton *m_menuButton = nullptr; + QAction *m_panelAction = nullptr; + QAction *m_takeBoxPlace = nullptr; // the take box goes before this + + // What switching on changed, to put back. The flags say what it + // was: for a widget whether it was hidden, for an action whether it + // was visible, for a menu whether it could be torn off + struct Saved { + bool menuBarHidden = false; + QList, bool>> toolBars; + QList, bool>> widgets; + QList, bool>> actions; + QList, bool>> tearOffs; + + // The take box's action, its toolbar and the action after it + // there (null if it was the last) + QPointer takeBoxAction; + QPointer takeBoxHome; + QPointer takeBoxNext; + }; + Saved m_saved; +}; + +#endif diff --git a/main/Coverage.cpp b/main/Coverage.cpp new file mode 100644 index 00000000..ac9aaa0d --- /dev/null +++ b/main/Coverage.cpp @@ -0,0 +1,126 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#include "Coverage.h" + +#include + +using namespace sv; + +void +Coverage::add(sv_frame_t start, sv_frame_t end) +{ + if (end <= start) return; + + Ranges result; + bool placed = false; + + for (const Range &r : m_ranges) { + if (r.end < start) { + result.push_back(r); + } else if (r.start > end) { + if (!placed) { + result.push_back(Range(start, end)); + placed = true; + } + result.push_back(r); + } else { + // overlaps or touches: becomes part of the new one + start = std::min(start, r.start); + end = std::max(end, r.end); + } + } + + if (!placed) result.push_back(Range(start, end)); + + m_ranges = result; +} + +void +Coverage::remove(sv_frame_t start, sv_frame_t end) +{ + if (end <= start) return; + + Ranges result; + + for (const Range &r : m_ranges) { + if (r.end <= start || r.start >= end) { + result.push_back(r); + continue; + } + if (r.start < start) result.push_back(Range(r.start, start)); + if (r.end > end) result.push_back(Range(end, r.end)); + } + + m_ranges = result; +} + +bool +Coverage::contains(sv_frame_t frame) const +{ + Range r; + return getRangeAt(frame, r); +} + +bool +Coverage::getRangeAt(sv_frame_t frame, Range &range) const +{ + for (const Range &r : m_ranges) { + if (frame >= r.start && frame < r.end) { + range = r; + return true; + } + } + return false; +} + +bool +Coverage::overlaps(sv_frame_t start, sv_frame_t end) const +{ + if (end <= start) return false; + for (const Range &r : m_ranges) { + if (r.start < end && r.end > start) return true; + } + return false; +} + +sv_frame_t +Coverage::getEndFrame() const +{ + if (m_ranges.empty()) return 0; + return m_ranges.back().end; +} + +EventVector +Coverage::toEvents() const +{ + EventVector events; + for (const Range &r : m_ranges) { + events.push_back(Event(r.start, 0.f, r.length(), regionLabel())); + } + return events; +} + +Coverage +Coverage::fromEvents(const EventVector &events) +{ + // add() sorts and joins, so regions in any order, and two that + // touch because the user recorded twice over the same place, come + // out as the ranges this coverage would have had all along + Coverage coverage; + for (const Event &e : events) { + coverage.add(e.getFrame(), e.getFrame() + e.getDuration()); + } + return coverage; +} diff --git a/main/Coverage.h b/main/Coverage.h new file mode 100644 index 00000000..2a597876 --- /dev/null +++ b/main/Coverage.h @@ -0,0 +1,89 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TONY_COVERAGE_H +#define TONY_COVERAGE_H + +#include "base/BaseTypes.h" +#include "base/Event.h" + +#include + +/** + * The parts of a singing take that hold recorded material, as frame + * ranges [start, end) on the reference's timeline. The rest of the + * take's audio file is silence. Ranges are kept sorted and apart: + * two that overlap or touch are one range. + */ +class Coverage +{ +public: + struct Range { + sv::sv_frame_t start; + sv::sv_frame_t end; + + Range() : start(0), end(0) { } + Range(sv::sv_frame_t s, sv::sv_frame_t e) : start(s), end(e) { } + + sv::sv_frame_t length() const { return end - start; } + bool operator==(const Range &r) const { + return start == r.start && end == r.end; + } + }; + + typedef std::vector Ranges; + + /// Empty ranges (end <= start) are ignored by add() and remove() + void add(sv::sv_frame_t start, sv::sv_frame_t end); + void remove(sv::sv_frame_t start, sv::sv_frame_t end); + void clear() { m_ranges.clear(); } + + bool isEmpty() const { return m_ranges.empty(); } + const Ranges &getRanges() const { return m_ranges; } + + bool contains(sv::sv_frame_t frame) const; + + /// The range that contains frame, if any + bool getRangeAt(sv::sv_frame_t frame, Range &range) const; + + bool overlaps(sv::sv_frame_t start, sv::sv_frame_t end) const; + + /// End of the last range, 0 if there is none + sv::sv_frame_t getEndFrame() const; + + bool operator==(const Coverage &c) const { return m_ranges == c.m_ranges; } + bool operator!=(const Coverage &c) const { return !(*this == c); } + + /** + * Coverage has no file format of its own: it is stored as the + * regions of the coverage strip's RegionModel, one region per + * range. These two are that conversion, and they are what a + * session load reads the coverage back with. + * + * A region's value is 0 (the strip shows where material is, not + * how much of anything) and its label is a single space, because a + * stock RegionLayer prints the value of any region that has no + * label: see CoverageStrip. + */ + sv::EventVector toEvents() const; + static Coverage fromEvents(const sv::EventVector &events); + + /// The label every coverage region carries, and why: see toEvents() + static QString regionLabel() { return " "; } + +private: + Ranges m_ranges; +}; + +#endif diff --git a/main/CoverageStrip.cpp b/main/CoverageStrip.cpp new file mode 100644 index 00000000..11049257 --- /dev/null +++ b/main/CoverageStrip.cpp @@ -0,0 +1,239 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#include "CoverageStrip.h" + +#include "TakeLayers.h" + +#include "framework/Document.h" +#include "view/Pane.h" +#include "layer/RegionLayer.h" +#include "layer/LayerFactory.h" +#include "layer/ColourDatabase.h" +#include "data/model/RegionModel.h" +#include "base/PlayParameters.h" + +#include + +using namespace sv; + +using std::cerr; +using std::endl; + +// Coverage is in frames of the reference's timeline, so the regions are +// exact: a resolution of more than one frame would round the end of the +// last range up +static const int coverageResolution = 1; + +// Used only if the session has no main model to take the rate from, +// which cannot happen while there is a take to show coverage of +static const sv_samplerate_t defaultSampleRate = 44100; + +CoverageStrip::CoverageStrip(QObject *parent) : + QObject(parent), + m_document(nullptr), + m_pane(nullptr), + m_layer(nullptr) +{ +} + +CoverageStrip::~CoverageStrip() +{ +} + +QString +CoverageStrip::layerName(QString takeName) +{ + // Not translated: it is stored in the session file and adopt() looks + // it up + return TakeLayers::nameFor(takeName, TakeLayers::Coverage); +} + +bool +CoverageStrip::show(Document *document, Pane *pane, QString takeName) +{ + if (m_layer) return true; + if (!document || !pane || takeName == "") return false; + + sv_samplerate_t rate = defaultSampleRate; + if (auto main = ModelById::get(document->getMainModel())) { + rate = main->getSampleRate(); + } + + auto model = std::make_shared(rate, coverageResolution); + model->setObjectName(tr("Singing Coverage")); + ModelId modelId = ModelById::add(model); + document->addNonDerivedModel(modelId); + + // Not createEmptyLayer(): see MainWindow::setupRealtimePitchLayer() + auto layer = qobject_cast + (document->createLayer(LayerFactory::Regions)); + if (!layer) { + cerr << "CoverageStrip::show: failed to create layer" << endl; + ModelById::release(modelId); + return false; + } + + document->setModel(layer, modelId); + takeLayer(document, pane, layer, takeName); + + // Not addLayerToView(): the strip is part of the take, not something + // the user added, so undo must not take it away and making it does + // not count as a change to the session + document->attachLayerToView(pane, layer); + + return true; +} + +bool +CoverageStrip::adopt(Document *document, Pane *pane, QString takeName) +{ + if (m_layer) return true; + if (!document || !pane || takeName == "") return false; + + for (int i = 0; i < pane->getLayerCount(); ++i) { + auto layer = qobject_cast(pane->getLayer(i)); + if (!layer || layer->objectName() != layerName(takeName)) continue; + if (!ModelById::isa(layer->getModel())) continue; + takeLayer(document, pane, layer, takeName); + return true; + } + + return false; +} + +void +CoverageStrip::takeLayer(Document *document, Pane *pane, RegionLayer *layer, + QString takeName) +{ + m_document = document; + m_pane = pane; + m_layer = layer; + m_takeName = takeName; + + connect(m_document, &Document::layerAboutToBeDeleted, + this, &CoverageStrip::layerAboutToBeDeleted, + Qt::UniqueConnection); + + configureLayer(); +} + +void +CoverageStrip::configureLayer() +{ + if (!m_layer) return; + + m_layer->setObjectName(layerName(m_takeName)); + m_layer->setPresentationName(tr("%1 Coverage").arg(m_takeName)); + + // The strip of a take that is not the active one is hidden, and this + // one is the active take's + m_layer->setLayerDormant(m_pane, false); + + // A band along the bottom of the pane, filled where there is + // singing: a plot style the svgui fork has for this. It has no + // vertical scale, no labels and takes no edits. EqualSpaced as well, + // so that nothing in the pane can align its scale to this layer + m_layer->setVerticalScale(RegionLayer::EqualSpaced); + m_layer->setPlotStyle(RegionLayer::PlotStrip); + + m_layer->setBaseColour + (ColourDatabase::getInstance()->getColourIndex(tr("Orange"))); + + // A RegionModel cannot play, so there are none; if that ever + // changes, the strip is still not something to hear + if (auto params = m_layer->getPlayParameters()) { + params->setPlayAudible(false); + } +} + +void +CoverageStrip::hide() +{ + RegionLayer *layer = m_layer; + m_layer = nullptr; + + // deleteLayer(force) and nothing else: see the notes on tearing a + // layer down silently in MainWindow::teardownRealtimePitchLayer(). + // The model goes with the layer, which is its only user + if (layer && m_document) { + m_document->deleteLayer(layer, true); + } + + if (m_document) { + disconnect(m_document, nullptr, this, nullptr); + } + m_document = nullptr; + m_pane = nullptr; + m_takeName = ""; +} + +void +CoverageStrip::release() +{ + // The layer stays where it is, hidden: it is the stored coverage of a + // take that is still in the session, only not the active one + if (m_layer && m_pane) { + m_layer->setLayerDormant(m_pane, true); + } + + m_layer = nullptr; + + if (m_document) { + disconnect(m_document, nullptr, this, nullptr); + } + m_document = nullptr; + m_pane = nullptr; + m_takeName = ""; +} + +void +CoverageStrip::layerAboutToBeDeleted(Layer *layer) +{ + // Someone else's doing, the document being closed for instance + if (layer && layer == m_layer) { + m_layer = nullptr; + hide(); + } +} + +void +CoverageStrip::setCoverage(const Coverage &coverage) +{ + if (!m_layer) return; + + auto model = ModelById::getAs(m_layer->getModel()); + if (!model) return; + + EventVector existing = model->getAllEvents(); + if (Coverage::fromEvents(existing) == coverage) return; + + for (const Event &e : existing) model->remove(e); + for (const Event &e : coverage.toEvents()) model->add(e); +} + +ModelId +CoverageStrip::getModelId() const +{ + return m_layer ? m_layer->getModel() : ModelId(); +} + +Coverage +CoverageStrip::getCoverage() const +{ + if (!m_layer) return {}; + auto model = ModelById::getAs(m_layer->getModel()); + if (!model) return {}; + return Coverage::fromEvents(model->getAllEvents()); +} diff --git a/main/CoverageStrip.h b/main/CoverageStrip.h new file mode 100644 index 00000000..a5940cc1 --- /dev/null +++ b/main/CoverageStrip.h @@ -0,0 +1,123 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TONY_COVERAGE_STRIP_H +#define TONY_COVERAGE_STRIP_H + +#include "Coverage.h" + +#include "base/ById.h" +#include "data/model/Model.h" + +#include +#include + +namespace sv { +class Document; +class Pane; +class Layer; +class RegionLayer; +} + +/** + * The coverage strip of a singing take: a RegionLayer in pane 0 with one + * region per range of the take's coverage. It shows the user where the + * take holds recorded material, and it is also where that coverage is + * stored: the layer and its model are ordinary document contents, so a + * session keeps them, and a session that has them is where the coverage + * of its take comes from when it is loaded again. + * + * Every take of the session has a strip of its own, under its own name, + * but only the active take's is held here: switching take lets one go + * (release(), which leaves it in the pane, hidden) and takes the other + * one up (adopt()). + * + * The strip is display only. It is never the pane's selected layer (the + * caller re-stacks the editable tracks after it is created), its model + * cannot be played (a RegionModel has no play parameters), and its + * vertical scale is the "equal spaced" one, which neither draws a scale + * of its own nor lets anything align to it, so the pane's own scale is + * untouched. + * + * It looks like the alternate pitch track's class and is used the same + * way: MainWindow only wires it. + */ +class CoverageStrip : public QObject +{ + Q_OBJECT + +public: + CoverageStrip(QObject *parent = nullptr); + virtual ~CoverageStrip(); + + /** + * Create the layer of the take of this name in the given pane, on top + * of whatever is there. Does nothing if the layer exists already. + * Returns false if it could not be created. + */ + bool show(sv::Document *document, sv::Pane *pane, QString takeName); + + /** + * Take over the layer of the take of this name that show() made + * before -- in an earlier run, that a session load has put back into + * the pane, or for a take that has been switched away from and back. + * Returns false if there is none; the coverage it holds is then read + * with getCoverage(). + */ + bool adopt(sv::Document *document, sv::Pane *pane, QString takeName); + + /// Delete the layer and its model from the document + void hide(); + + /** + * Let go of the layer without deleting it: it stays in the pane, + * hidden, holding the coverage of a take that is no longer the active + * one. adopt() takes it up again. + */ + void release(); + + bool isShown() const { return m_layer != nullptr; } + + /// The name of the take whose strip this is, or "" when there is none + QString getTakeName() const { return m_takeName; } + + /// The ranges the strip shows, which are the take's coverage + void setCoverage(const Coverage &coverage); + Coverage getCoverage() const; + + sv::RegionLayer *getLayer() const { return m_layer; } + + /// The model the regions are in, which is the stored coverage + sv::ModelId getModelId() const; + + /** + * The layer's object name, which is how adopt() knows it again: the + * name of its take and what it is (TakeLayers). + */ + static QString layerName(QString takeName); + +private slots: + void layerAboutToBeDeleted(sv::Layer *); + +private: + sv::Document *m_document; + sv::Pane *m_pane; + sv::RegionLayer *m_layer; + QString m_takeName; + + void takeLayer(sv::Document *, sv::Pane *, sv::RegionLayer *, QString); + void configureLayer(); +}; + +#endif diff --git a/main/DecodedPcm.cpp b/main/DecodedPcm.cpp new file mode 100644 index 00000000..35f125f0 --- /dev/null +++ b/main/DecodedPcm.cpp @@ -0,0 +1,186 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#include "DecodedPcm.h" + +#include +#include + +bool +DecodedPcm::isKnownEncoding(int encoding) +{ + return bytesPerSample(encoding) > 0; +} + +int +DecodedPcm::bytesPerSample(int encoding) +{ + switch (encoding) { + case Int16: return 2; + case Int8: return 1; + case Float: return 4; + case Int24Packed: return 3; + case Int32: return 4; + default: return 0; + } +} + +std::string +DecodedPcm::describe(int encoding) +{ + switch (encoding) { + case Int16: return "16-bit integer"; + case Int8: return "8-bit integer"; + case Float: return "float"; + case Int24Packed: return "24-bit integer"; + case Int32: return "32-bit integer"; + default: return "unknown encoding " + std::to_string(encoding); + } +} + +// One sample, from the device's byte order, which is little-endian on +// every Android ABI; memcpy, as a buffer need not be aligned for it +static float +sampleAt(const uint8_t *p, int encoding) +{ + switch (encoding) { + case DecodedPcm::Int16: { + int16_t v; + std::memcpy(&v, p, sizeof(v)); + return float(v) / 32768.f; + } + case DecodedPcm::Int8: + return (float(p[0]) - 128.f) / 128.f; + case DecodedPcm::Float: { + float v; + std::memcpy(&v, p, sizeof(v)); + return v; + } + case DecodedPcm::Int24Packed: { + // Into the top three bytes of an int32, which carries the sign + uint32_t u = (uint32_t(p[0]) << 8) | (uint32_t(p[1]) << 16) | + (uint32_t(p[2]) << 24); + int32_t v; + std::memcpy(&v, &u, sizeof(v)); + return float(double(v) / 2147483648.0); + } + case DecodedPcm::Int32: { + int32_t v; + std::memcpy(&v, p, sizeof(v)); + return float(double(v) / 2147483648.0); + } + default: + return 0.f; + } +} + +void +DecodedPcm::convert(const uint8_t *data, int encoding, + int sourceChannels, size_t frames, + float *out, int targetChannels) +{ + const int bytes = bytesPerSample(encoding); + if (bytes == 0 || sourceChannels < 1 || targetChannels < 1) return; + + const size_t frameBytes = size_t(bytes) * size_t(sourceChannels); + + for (size_t f = 0; f < frames; ++f) { + + const uint8_t *frame = data + f * frameBytes; + float *target = out + f * size_t(targetChannels); + + if (sourceChannels <= targetChannels) { + for (int c = 0; c < targetChannels; ++c) { + int s = c % sourceChannels; + target[c] = sampleAt(frame + s * bytes, encoding); + } + continue; + } + + for (int c = 0; c < targetChannels; ++c) { + float sum = 0.f; + int n = 0; + for (int s = c; s < sourceChannels; s += targetChannels) { + sum += sampleAt(frame + s * bytes, encoding); + ++n; + } + target[c] = sum / float(n); + } + } +} + +DecodedPcm::DecodedPcm(int channels) : + m_channels(std::max(channels, 1)), + m_readPosition(0), + m_added(0), + m_read(0), + m_dropped(0) +{ +} + +size_t +DecodedPcm::add(const void *data, size_t bytes, int encoding, int channels) +{ + const int sampleBytes = bytesPerSample(encoding); + if (sampleBytes == 0 || channels < 1 || !data) { + m_dropped += int64_t(bytes); + return 0; + } + + const size_t frameBytes = size_t(sampleBytes) * size_t(channels); + const size_t frames = bytes / frameBytes; + m_dropped += int64_t(bytes - frames * frameBytes); + if (frames == 0) return 0; + + // What has been read goes before more is added, once it is at least + // half of what is held, so that the buffer does not grow for ever + if (m_readPosition > 0 && m_readPosition * 2 >= m_samples.size()) { + m_samples.erase(m_samples.begin(), + m_samples.begin() + ptrdiff_t(m_readPosition)); + m_readPosition = 0; + } + + size_t start = m_samples.size(); + m_samples.resize(start + frames * size_t(m_channels)); + convert(static_cast(data), encoding, channels, frames, + m_samples.data() + start, m_channels); + + m_added += int64_t(frames); + return frames; +} + +size_t +DecodedPcm::getAvailable() const +{ + return (m_samples.size() - m_readPosition) / size_t(m_channels); +} + +size_t +DecodedPcm::read(float *out, size_t frames) +{ + size_t n = std::min(frames, getAvailable()); + if (n == 0) return 0; + + size_t samples = n * size_t(m_channels); + std::memcpy(out, m_samples.data() + m_readPosition, + samples * sizeof(float)); + m_readPosition += samples; + + if (m_readPosition == m_samples.size()) { + m_samples.clear(); + m_readPosition = 0; + } + + m_read += int64_t(n); + return n; +} diff --git a/main/DecodedPcm.h b/main/DecodedPcm.h new file mode 100644 index 00000000..4005bf05 --- /dev/null +++ b/main/DecodedPcm.h @@ -0,0 +1,112 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TONY_DECODED_PCM_H +#define TONY_DECODED_PCM_H + +#include +#include +#include +#include + +/** + * What a platform's audio decoder hands out, made into what svcore's + * readers give (bqaudiostream's AudioReadStream::getInterleavedFrames()): + * float frames, interleaved, at one channel count, read in pieces of + * any size. + * + * For AndroidMediaReadStream, whose decoder (Android's MediaCodec) + * gives buffers of PCM in whatever encoding its output format names, + * 16-bit integers when it names none, and may change that format part + * way through: the channel count the stream reports is fixed by its + * first buffer, and a buffer with another count is folded into it. + * + * Nothing here touches a decoder, so all of it is tested on the desktop + * (TestDecodedPcm). + */ +class DecodedPcm +{ +public: + /** + * Android's PCM encodings, the values of AudioFormat.ENCODING_PCM_* + * that a MediaFormat's "pcm-encoding" holds. Each sample is in the + * device's own byte order (little-endian), 24-bit ones as three + * bytes; 8-bit ones are unsigned about 128. + */ + enum Encoding { + Int16 = 2, + Int8 = 3, + Float = 4, + Int24Packed = 21, + Int32 = 22 + }; + + /// Whether the value is one of the encodings above + static bool isKnownEncoding(int encoding); + + /// Bytes per sample of an encoding, 0 for one not known + static int bytesPerSample(int encoding); + + /// The encoding in words, for the log: "16-bit integer" and so on + static std::string describe(int encoding); + + /** + * Convert frames of interleaved samples in an encoding, and in + * sourceChannels channels, into float frames in targetChannels. + * Fewer channels than wanted: channel c is source channel c modulo + * their number (mono goes to every channel). More: target channel c + * is the mean of the source channels whose index is c modulo the + * target's number (all of them for mono), so that no channel, and + * no voice in a centre channel, is lost. + */ + static void convert(const uint8_t *data, int encoding, + int sourceChannels, size_t frames, + float *out, int targetChannels); + + explicit DecodedPcm(int channels); + + int getChannelCount() const { return m_channels; } + + /** + * Take a buffer of bytes of samples in the encoding, interleaved in + * the given number of channels. Returns the whole frames taken; a + * part of a frame at the end, which a decoder should never give, is + * left out and counted (getBytesDropped()). Nothing is taken in an + * encoding that is not known, or with no channels. + */ + size_t add(const void *data, size_t bytes, int encoding, int channels); + + /// Frames taken and not yet read + size_t getAvailable() const; + + /** + * Copy up to the given number of frames into out, which has room + * for that many at getChannelCount(), and return how many there + * were. + */ + size_t read(float *out, size_t frames); + + int64_t getFramesAdded() const { return m_added; } + int64_t getFramesRead() const { return m_read; } + int64_t getBytesDropped() const { return m_dropped; } + +private: + int m_channels; + std::vector m_samples; + size_t m_readPosition; // in samples, into m_samples + int64_t m_added; + int64_t m_read; + int64_t m_dropped; +}; + +#endif diff --git a/main/LatencyCalibration.cpp b/main/LatencyCalibration.cpp new file mode 100644 index 00000000..968aa14a --- /dev/null +++ b/main/LatencyCalibration.cpp @@ -0,0 +1,264 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#include "LatencyCalibration.h" + +#include + +#include +#include + +using namespace sv; + +namespace LatencyCalibration { +namespace { + +const char *const settingsGroup = "LatencyCalibration"; + +// A device name as part of a group name. QSettings takes "/" and "\" +// as the start of a subgroup, so they are percent-encoded, and so is +// "%" itself, and the "|" that separates the names. Nothing else is: +// every settings format keeps any other character, non-ASCII ones +// included, and the Windows registry allows a key name 255 characters +// at most, which a name encoded whole could run past +QString encoded(QString name) +{ + name.replace("%", "%25"); + name.replace("/", "%2F"); + name.replace("\\", "%5C"); + name.replace("|", "%7C"); + return name; +} + +// The other way +QString decoded(QString name) +{ + name.replace("%7C", "|"); + name.replace("%5C", "\\"); + name.replace("%2F", "/"); + name.replace("%25", "%"); + return name; +} + +QString devicesGroup(const Key &key) +{ + return encoded(key.implementation) + "|" + + encoded(key.playbackDevice) + "|" + + encoded(key.recordDevice); +} + +QString rateGroup(const Key &key) +{ + return QString::number(qint64(std::llround(key.rate))); +} + +// As text, so that every settings format keeps all of the figure +QString number(double value) +{ + return QString::number(value, 'g', 17); +} + +double numberFrom(const QVariant &value, bool *ok = nullptr) +{ + return value.toString().toDouble(ok); +} + +} + +Key +currentKey(QSettings &settings, sv_samplerate_t recordingRate) +{ + Key key; + settings.beginGroup("Preferences"); + key.implementation = settings.value("audio-target", "").toString(); + QString suffix; + if (key.implementation != "") suffix = "-" + key.implementation; + key.playbackDevice = + settings.value("audio-playback-device" + suffix, "").toString(); + key.recordDevice = + settings.value("audio-record-device" + suffix, "").toString(); + settings.endGroup(); + key.rate = recordingRate; + return key; +} + +Key +routeKey(const AudioRoute::Route &route, sv_samplerate_t rate) +{ + Key key; + key.implementation = route.driver; + key.playbackDevice = AudioRoute::deviceName(route.output); + if (route.hasInput) { + key.recordDevice = AudioRoute::deviceName(route.input); + } + key.rate = rate; + return key; +} + +bool +onlyRecordDevice(QSettings &settings, const Key &key, QString &recordDevice) +{ + // The groups of the driver and playback device, whatever the record + // device: their names as devicesGroup() makes them, up to the record + // device's, which is encoded and so holds no "|" + const QString prefix = encoded(key.implementation) + "|" + + encoded(key.playbackDevice) + "|"; + QStringList found; + settings.beginGroup(settingsGroup); + for (const QString &group : settings.childGroups()) { + if (!group.startsWith(prefix)) continue; + settings.beginGroup(group); + const bool atRate = settings.childGroups().contains(rateGroup(key)); + settings.endGroup(); + if (atRate) found.push_back(decoded(group.mid(prefix.size()))); + } + settings.endGroup(); + if (found.size() != 1) return false; + recordDevice = found.front(); + return true; +} + +void +store(QSettings &settings, const Key &key, const Figure &figure) +{ + settings.beginGroup(settingsGroup); + settings.beginGroup(devicesGroup(key)); + settings.beginGroup(rateGroup(key)); + settings.setValue("roundTrip", number(figure.roundTrip)); + settings.setValue("spread", number(figure.spread)); + settings.setValue("date", + figure.date.toUTC().toString(Qt::ISODateWithMs)); + settings.setValue("reportedOutput", number(figure.reportedOutput)); + settings.setValue("reportedInput", number(figure.reportedInput)); + // Only for a device that describes its streams, so that the desktop's + // figures are kept as they always were + if (figure.outputStreams != "" || figure.inputStreams != "") { + settings.setValue("outputStreams", figure.outputStreams); + settings.setValue("inputStreams", figure.inputStreams); + } else { + settings.remove("outputStreams"); + settings.remove("inputStreams"); + } + settings.endGroup(); + settings.endGroup(); + settings.endGroup(); +} + +bool +load(QSettings &settings, const Key &key, Figure &figure) +{ + Figure f; + bool ok = false; + settings.beginGroup(settingsGroup); + settings.beginGroup(devicesGroup(key)); + settings.beginGroup(rateGroup(key)); + f.roundTrip = numberFrom(settings.value("roundTrip"), &ok); + f.spread = numberFrom(settings.value("spread")); + f.date = QDateTime::fromString(settings.value("date").toString(), + Qt::ISODateWithMs); + f.reportedOutput = numberFrom(settings.value("reportedOutput")); + f.reportedInput = numberFrom(settings.value("reportedInput")); + f.outputStreams = settings.value("outputStreams").toString(); + f.inputStreams = settings.value("inputStreams").toString(); + settings.endGroup(); + settings.endGroup(); + settings.endGroup(); + + if (!ok || !std::isfinite(f.roundTrip) || f.roundTrip < 0.0) { + return false; + } + figure = f; + return true; +} + +void +forget(QSettings &settings, const Key &key) +{ + settings.beginGroup(settingsGroup); + settings.beginGroup(devicesGroup(key)); + settings.remove(rateGroup(key)); + bool empty = settings.childGroups().isEmpty() && + settings.childKeys().isEmpty(); + settings.endGroup(); + // Figures for the devices at no other rate: nothing left of them + if (empty) settings.remove(devicesGroup(key)); + settings.endGroup(); +} + +bool +isStale(const Figure &figure, double reportedOutput, double reportedInput, + const QString &outputStreams, const QString &inputStreams) +{ + // Oboe's latencies come from timestamps and move by several ms from + // one start of the same streams to the next (5.2 then 8.4 then 4.4 ms + // out on the phone first tried), which the 1 ms below would take for + // new buffers at every take. What the round trip depends on there is + // how the streams were opened: MMAP or not, exclusive or shared, + // their burst and buffer + if (outputStreams != "" || inputStreams != "") { + return (outputStreams != "" && outputStreams != figure.outputStreams) || + (inputStreams != "" && inputStreams != figure.inputStreams); + } + return std::fabs(figure.reportedOutput - reportedOutput) > + kStaleToleranceSeconds || + std::fabs(figure.reportedInput - reportedInput) > + kStaleToleranceSeconds; +} + +const char * +sourceName(Source source) +{ + switch (source) { + case Source::Reported: return "reported"; + case Source::Measured: return "measured"; + } + return ""; +} + +InUse +roundTripInUse(const Figure *stored, + double reportedOutput, double reportedInput, + const QString &outputStreams, const QString &inputStreams) +{ + InUse inUse; + inUse.reportedOutput = reportedOutput; + inUse.reportedInput = reportedInput; + if (stored && !isStale(*stored, reportedOutput, reportedInput, + outputStreams, inputStreams)) { + inUse.source = Source::Measured; + inUse.roundTrip = stored->roundTrip; + inUse.date = stored->date; + return inUse; + } + inUse.source = Source::Reported; + inUse.roundTrip = std::max(reportedOutput, 0.0) + + std::max(reportedInput, 0.0); + inUse.stale = (stored != nullptr); + return inUse; +} + +double +reportedSeconds(sv_frame_t frames, sv_samplerate_t rate) +{ + if (frames <= 0 || !(rate > 0)) return 0.0; + return double(frames) / rate; +} + +sv_frame_t +toFrames(double seconds, sv_samplerate_t rate) +{ + if (!(seconds > 0.0) || !(rate > 0)) return 0; + return sv_frame_t(std::llround(seconds * rate)); +} + +} diff --git a/main/LatencyCalibration.h b/main/LatencyCalibration.h new file mode 100644 index 00000000..23c5ccf5 --- /dev/null +++ b/main/LatencyCalibration.h @@ -0,0 +1,183 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TONY_LATENCY_CALIBRATION_H +#define TONY_LATENCY_CALIBRATION_H + +#include "AudioRoute.h" + +#include "base/BaseTypes.h" + +#include +#include + +class QSettings; + +/** + * The round trip the audio check measured, kept for the devices it was + * measured on, and the choice between it and the latencies the device + * reports, which is what a take is placed with. + * + * A figure is kept per key: the audio driver and the playback and + * record devices, as the Preferences name them for + * MainWindowBase::createAudioIO(), which opens them, and the rate the + * device records at. Next to it are the latencies the device reported + * when it was measured. If the device reports others now, its buffers + * have changed (in the driver's control panel, say), the round trip + * has changed with them, and the figure is stale: takes go back to the + * reported pair until the check is run again. + * + * A device that knows its route (AudioRouteReporter: Oboe's, on a + * phone) is keyed by that instead, as the Preferences name no device + * there, and its fingerprint is how it opened its streams: Oboe works + * its latencies out from timestamps, and they move by several ms from + * one start of the same streams to the next. + * + * In the settings group "LatencyCalibration", one group for the driver + * and devices, within it one for the rate. All times are in seconds. + */ +namespace LatencyCalibration +{ + /// How far either reported latency may move before a stored figure + /// is stale. A device reports its latencies in whole frames, and the + /// output latency is rounded again, to frames of the session, when + /// the device runs at another rate: both well under this + constexpr double kStaleToleranceSeconds = 0.001; + + struct Key { + /// As the Preferences have them, "" for the default + QString implementation; + QString playbackDevice; + QString recordDevice; + + /// The rate the device records at + sv::sv_samplerate_t rate; + + Key() : rate(0) { } + }; + + /** + * The key for the devices the Preferences name, read as + * MainWindowBase::createAudioIO() reads them: "audio-target", then + * "audio-playback-device" and "audio-record-device", each suffixed + * with "-" and the driver when one is named. + */ + Key currentKey(QSettings &settings, sv::sv_samplerate_t recordingRate); + + /** + * The key for a route a device reports: its driver, and its output + * and input device as AudioRoute::deviceName() names them, the input + * "" while it is not open. + */ + Key routeKey(const AudioRoute::Route &route, sv::sv_samplerate_t rate); + + /** + * The record device of the one figure kept for the key's driver and + * playback device at its rate, whatever its record device: for a + * device open for playback only, which cannot say what it will + * record from. False if there is none, or more than one. + */ + bool onlyRecordDevice(QSettings &settings, const Key &key, + QString &recordDevice); + + struct Figure { + /// What the check measured + double roundTrip; + double spread; + QDateTime date; + + /// What the device reported then: the staleness fingerprint + double reportedOutput; + double reportedInput; + + /// How a device that knows its route had opened its streams + /// (AudioRoute::Route): its fingerprint instead; "" for others + QString outputStreams; + QString inputStreams; + + Figure() : roundTrip(0), spread(0), + reportedOutput(0), reportedInput(0) { } + }; + + /// Keep the figure for the key, over any kept for it before + void store(QSettings &settings, const Key &key, const Figure &figure); + + /// The figure kept for the key, if there is one + bool load(QSettings &settings, const Key &key, Figure &figure); + + /// Drop the figure kept for the key, if there is one + void forget(QSettings &settings, const Key &key); + + /** + * Whether the figure is out of date. For a device that describes + * its streams (outputStreams or inputStreams not ""), if either + * stream it describes was opened otherwise than when the figure was + * measured; a stream it does not describe (the input of a device + * open for playback only) is not compared, and the latencies it + * reports are not either. For any other device, if either latency + * it reports now differs from the one it reported then by more than + * kStaleToleranceSeconds. + */ + bool isStale(const Figure &figure, + double reportedOutput, double reportedInput, + const QString &outputStreams = QString(), + const QString &inputStreams = QString()); + + enum class Source { + Reported, ///< the device's reported output and input latency + Measured ///< a figure the audio check measured + }; + + const char *sourceName(Source source); + + /// The round trip a take is placed with, and where it came from + struct InUse { + Source source; + double roundTrip; + + /// When it was measured; null for the reported pair + QDateTime date; + + /// A figure was stored for the key, but it is stale + bool stale; + + /// The latencies the device reports now, which the choice was + /// made against, whichever source won: a figure measured now is + /// stored with these as its fingerprint + double reportedOutput; + double reportedInput; + + InUse() : source(Source::Reported), roundTrip(0), stale(false), + reportedOutput(0), reportedInput(0) { } + }; + + /** + * The stored figure, if there is one and it is not stale (isStale(), + * with the streams the device describes, if any); otherwise the sum + * of the two reported latencies. stored may be null. + */ + InUse roundTripInUse(const Figure *stored, + double reportedOutput, double reportedInput, + const QString &outputStreams = QString(), + const QString &inputStreams = QString()); + + /// A reported latency, counted in frames at the given rate, in + /// seconds. A latency reported as zero or less, or a rate not yet + /// known, counts as none + double reportedSeconds(sv::sv_frame_t frames, sv::sv_samplerate_t rate); + + /// Seconds in whole frames at the given rate, rounded to the nearest + sv::sv_frame_t toFrames(double seconds, sv::sv_samplerate_t rate); +} + +#endif diff --git a/main/LatencyCheck.cpp b/main/LatencyCheck.cpp new file mode 100644 index 00000000..2a24ab49 --- /dev/null +++ b/main/LatencyCheck.cpp @@ -0,0 +1,685 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#include "LatencyCheck.h" + +#include "bqfft/FFT.h" + +#include +#include +#include + +using namespace sv; +using std::vector; + +namespace LatencyCheck { +namespace { + +const double pi = 3.14159265358979323846; + +// Sweep to sweep, in tenths of a second so that the times are the same +// at every rate: each value from 1.6 to 2.6 s once, in an irregular order. +// The 4th, 7th and 10th are 1.9 s or more: a calibration's four punch-ins +// of three events each meet there, and the finder needs 1.9 s between +// the last sweep of one punch-in and the first of the next +const int calibrationSpacings[] = { 21, 16, 25, 19, 17, 23, 26, 20, 24, 18, 22 }; +const int calibrationSpacingCount = + int(sizeof(calibrationSpacings) / sizeof(calibrationSpacings[0])); + +// On from the last calibration event to the held tones and between +// them. None of these can be a calibration spacing, which are all +// taken, and after a held tone the next event has to wait for its end +const int heldSpacings[] = { 28, 40, 43 }; + +const int firstSweepTenths = 10; + +const double calibrationSeconds = 26.0; +const double devSeconds = 40.0; + +// The silence after the last event, as the calibration layout has it +const double tailSeconds = 0.8; + +const double pitches[] = { 220.5, 294.0, 196.0, 245.0 }; +const int pitchCount = int(sizeof(pitches) / sizeof(pitches[0])); + +sv_frame_t +framesAt(double seconds, sv_samplerate_t rate) +{ + return sv_frame_t(std::llround(seconds * rate)); +} + +double +ratioOf(double db) +{ + return std::pow(10.0, db / 20.0); +} + +// A ratio in dB that stays finite: silence against silence is 0 dB, +// and anything against silence is +/-200 +double +decibels(double value, double against) +{ + const double limit = 200.0; + if (value <= 0.0 && against <= 0.0) return 0.0; + if (value <= 0.0) return -limit; + if (against <= 0.0) return limit; + double db = 20.0 * std::log10(value / against); + return std::max(-limit, std::min(limit, db)); +} + +// A function of time rather than of frames, like everything else the +// reference is made of, so that it is the same reference at any rate +double +fade(double t, double duration) +{ + if (t < kFadeSeconds) { + return 0.5 * (1.0 - std::cos(pi * t / kFadeSeconds)); + } + if (t > duration - kFadeSeconds) { + return 0.5 * (1.0 - std::cos(pi * (duration - t) / kFadeSeconds)); + } + return 1.0; +} + +// One tone without its fades, scaled so that the loudest its waveform +// gets is 1: the pitch and its harmonics up to kToneTopHz, the n-th at +// 1/n, in Newman's phases (pi (n-1)^2 / N), which spread them over the +// period, all with the vibrato. The vibrato only goes through the same +// waveform faster and slower, so that loudest is read from one period, +// finely, and no sample is louder; and so the scale, and the tone, is +// the same at any rate. Each sample's partials come from powers of one +// rotation, so that a long song's tones take one sine and cosine a +// sample, not one a partial +vector +harmonicTone(double hz, sv_frame_t length, sv_samplerate_t rate) +{ + const int count = std::max(1, int(std::floor(kToneTopHz / hz))); + vector> partial(count); + for (int n = 1; n <= count; ++n) { + partial[n - 1] = std::polar(1.0 / n, + pi * double(n - 1) * double(n - 1) / count); + } + auto waveform = [&](double phase) { + const std::complex turn = std::polar(1.0, phase); + std::complex power = turn; + double v = 0.0; + for (int n = 0; n < count; ++n) { + v += (partial[n] * power).imag(); + power *= turn; + } + return v; + }; + + const int steps = 8192; + double loudest = 0.0; + for (int k = 0; k < steps; ++k) { + loudest = std::max(loudest, + std::fabs(waveform(2.0 * pi * k / steps))); + } + + // The vibrato: the frequency swings kVibratoCents either way, so + // the phase swings by the swing in Hz over the rate + const double swing = hz * (std::pow(2.0, kVibratoCents / 1200.0) - 1.0) / + kVibratoHz; + vector out(std::max(length, sv_frame_t(0)), 0.0); + for (sv_frame_t i = 0; i < length; ++i) { + const double t = double(i) / rate; + out[i] = waveform(2.0 * pi * hz * t + + swing * std::sin(2.0 * pi * kVibratoHz * t)) / + loudest; + } + return out; +} + +void +addEvent(Layout &layout, int sweepTenths, double toneSeconds) +{ + double at = sweepTenths / 10.0; + Event e; + e.sweepStart = framesAt(at, layout.rate); + e.toneStart = framesAt(at + kSweepSeconds + kPauseSeconds, layout.rate); + e.toneLength = framesAt(toneSeconds, layout.rate); + e.toneHz = pitches[layout.events.size() % pitchCount]; + layout.events.push_back(e); +} + +} // namespace +} // namespace LatencyCheck + +LatencyCheck::Layout +LatencyCheck::calibrationLayout(sv_samplerate_t rate) +{ + Layout layout; + layout.rate = rate; + + int at = firstSweepTenths; + addEvent(layout, at, kToneSeconds); + for (int spacing : calibrationSpacings) { + at += spacing; + addEvent(layout, at, kToneSeconds); + } + + layout.length = framesAt(calibrationSeconds, rate); + return layout; +} + +LatencyCheck::Layout +LatencyCheck::devLayout(sv_samplerate_t rate) +{ + Layout layout = calibrationLayout(rate); + + int at = firstSweepTenths; + for (int spacing : calibrationSpacings) at += spacing; + for (int spacing : heldSpacings) { + at += spacing; + addEvent(layout, at, kHeldToneSeconds); + } + + layout.length = framesAt(devSeconds, rate); + return layout; +} + +LatencyCheck::Layout +LatencyCheck::longLayout(sv_samplerate_t rate, double seconds) +{ + Layout layout; + layout.rate = rate; + + const double eventSeconds = kSweepSeconds + kPauseSeconds + kToneSeconds; + + int at = firstSweepTenths; + for (int i = 0; at / 10.0 + eventSeconds + tailSeconds <= seconds; + ++i) { + addEvent(layout, at, kToneSeconds); + at += calibrationSpacings[i % calibrationSpacingCount]; + } + + layout.length = framesAt(seconds, rate); + return layout; +} + +vector +LatencyCheck::sweep(sv_samplerate_t rate) +{ + const sv_frame_t length = framesAt(kSweepSeconds, rate); + const double amplitude = ratioOf(kPeakDbfs); + const double rise = (kSweepEndHz - kSweepStartHz) / kSweepSeconds; + + vector out(length); + for (sv_frame_t i = 0; i < length; ++i) { + double t = double(i) / rate; + double phase = 2.0 * pi * (kSweepStartHz * t + 0.5 * rise * t * t); + out[i] = float(amplitude * fade(t, kSweepSeconds) * std::sin(phase)); + } + return out; +} + +vector +LatencyCheck::generate(const Layout &layout) +{ + vector out(std::max(layout.length, sv_frame_t(0)), 0.f); + + const vector s = sweep(layout.rate); + const double amplitude = ratioOf(kPeakDbfs); + + for (const Event &e : layout.events) { + + for (sv_frame_t i = 0; i < sv_frame_t(s.size()); ++i) { + sv_frame_t f = e.sweepStart + i; + if (f >= 0 && f < layout.length) out[f] += s[i]; + } + + if (e.toneHz <= 0.0) continue; + const vector tone = + harmonicTone(e.toneHz, e.toneLength, layout.rate); + const double duration = double(e.toneLength) / layout.rate; + for (sv_frame_t i = 0; i < e.toneLength; ++i) { + sv_frame_t f = e.toneStart + i; + if (f < 0 || f >= layout.length) continue; + double t = double(i) / layout.rate; + out[f] += float(amplitude * fade(t, duration) * tone[size_t(i)]); + } + } + + return out; +} + +LatencyCheck::Arrival +LatencyCheck::findSweep(const float *take, sv_frame_t count, + sv_samplerate_t rate, double expectedSeconds) +{ + Arrival arrival; + if (!take || count <= 0 || rate <= 0) return arrival; + + // In frames of the take, at its own rate: the reference's frames + // would put the window in the wrong place in a take at any other + const sv_frame_t expected = framesAt(expectedSeconds, rate); + const sv_frame_t reach = framesAt(kSearchSeconds, rate); + const sv_frame_t first = std::max(sv_frame_t(0), expected - reach); + const sv_frame_t last = std::min(count - 1, expected + reach); + if (first > last) return arrival; + + // The sweep at the take's rate too: at the reference's, it would be + // the wrong length, and would match a stretched sweep, not this one + const vector reference = sweep(rate); + const sv_frame_t sweepLength = sv_frame_t(reference.size()); + + // The correlation at a lag reads a sweep's length on from it, so the + // stretch read runs that far past the last lag + const sv_frame_t end = std::min(count, last + sweepLength); + const int lags = int(last - first + 1); + + for (sv_frame_t i = first; i < end; ++i) { + arrival.inputPeak = std::max(arrival.inputPeak, + double(std::fabs(take[i]))); + } + + // Padded with zeros to a sweep's length beyond the stretch at least, + // so that no lag looked at wraps round the end + int size = 1; + while (size < (end - first) + sweepLength) size *= 2; + + vector x(size, 0.0), s(size, 0.0); + for (sv_frame_t i = first; i < end; ++i) x[i - first] = take[i]; + for (sv_frame_t i = 0; i < sweepLength; ++i) s[i] = reference[i]; + + breakfastquay::FFT fft(size); + const int bins = size / 2 + 1; + vector xre(bins), xim(bins), sre(bins), sim(bins); + fft.forward(x.data(), xre.data(), xim.data()); + fft.forward(s.data(), sre.data(), sim.data()); + + // The take's spectrum times the conjugate of the sweep's is their + // correlation: the matched filter. That product is itself the + // weighting to the sweep's band, since the sweep's spectrum is flat + // from 1 to 8 kHz and falls some 100 dB below that at the tones' + // pitches and at mains hum: a full-scale 100 Hz hum under the + // sweep comes through 100 dB down. A band mask on top of it + // changed nothing that could be measured, so there is none. + // The second spectrum is the first turned by -90 degrees, whose + // inverse is the correlation's Hilbert transform: the two together + // give its envelope (the magnitude of the analytic signal), which + // peaks where the sweep begins whatever the polarity or the phase + // of the carrier. The correlation itself oscillates at the band's + // centre, and its largest sample can be half a cycle off. + vector cre(bins), cim(bins), hre(bins), him(bins); + for (int k = 0; k < bins; ++k) { + cre[k] = xre[k] * sre[k] + xim[k] * sim[k]; + cim[k] = xim[k] * sre[k] - xre[k] * sim[k]; + hre[k] = cim[k]; + him[k] = -cre[k]; + } + hre[0] = him[0] = hre[bins-1] = him[bins-1] = 0.0; + + vector c(size), h(size); + fft.inverse(cre.data(), cim.data(), c.data()); + fft.inverse(hre.data(), him.data(), h.data()); + + // bqfft's inverse is unscaled. Scaled, the envelope of a take + // holding the reference's own sweep peaks at the sweep's energy + vector envelope(lags); + for (int j = 0; j < lags; ++j) { + envelope[j] = std::hypot(c[j], h[j]) / size; + } + + const int largest = int(std::max_element(envelope.begin(), envelope.end()) + - envelope.begin()); + + // The earliest peak within kEarliestPeakDb of the largest: in a + // room a reflection can be stronger than the direct sound, and the + // direct sound's time is the one wanted. A peak is a local maximum + // of the envelope, and only one at least kSeparateArrivalSeconds + // before the largest counts. One arrival has no earlier peak that + // high unless its spectrum has a hole in it: then the envelope + // beats, a row of near-equal peaks with deep dips between them, so + // a dip between two peaks does not tell arrivals apart, but the + // time does, since those peaks lie within about one over the width + // of what is left of the band. A reflection closer than the + // spacing is too close to matter: the larger peak is taken. + int chosen = largest; + const int apart = std::max(1, int(framesAt(kSeparateArrivalSeconds, rate))); + const double within = envelope[largest] * ratioOf(-kEarliestPeakDb); + for (int j = 1; j + apart <= largest; ++j) { + if (envelope[j] >= within && + envelope[j] > envelope[j-1] && envelope[j] >= envelope[j+1]) { + chosen = j; + break; + } + } + + vector ordered(envelope); + std::nth_element(ordered.begin(), ordered.begin() + lags / 2, + ordered.end()); + const double median = ordered[lags / 2]; + + // The chosen peak's own reflections are near it, and are not + // another candidate for where the sweep is + const int closeBy = int(framesAt(kSecondPeakSeconds, rate)); + double second = 0.0; + int secondAt = chosen; + for (int j = 0; j < lags; ++j) { + if (std::abs(j - chosen) > closeBy && envelope[j] > second) { + second = envelope[j]; + secondAt = j; + } + } + + double energy = 0.0; + for (float v : reference) energy += double(v) * double(v); + + const sv_frame_t at = first + chosen; + arrival.errorFrames = at - expected; + arrival.errorSeconds = double(at) / rate - expectedSeconds; + arrival.peakOverMedianDb = decibels(envelope[chosen], median); + arrival.peakOverSecondDb = decibels(envelope[chosen], second); + arrival.levelDb = decibels(envelope[chosen], energy); + arrival.secondDelaySeconds = double(secondAt - chosen) / rate; + arrival.secondLevelDb = decibels(second, envelope[chosen]); + arrival.found = + arrival.peakOverMedianDb >= kMinPeakOverMedianDb && + arrival.peakOverSecondDb >= kMinPeakOverSecondDb; + + return arrival; +} + +namespace LatencyCheck { +namespace { + +double +median(vector v) +{ + if (v.empty()) return 0.0; + std::sort(v.begin(), v.end()); + const size_t n = v.size(); + return (n % 2) ? v[n/2] : 0.5 * (v[n/2 - 1] + v[n/2]); +} + +double +spreadOf(const vector &v) +{ + if (v.empty()) return 0.0; + auto range = std::minmax_element(v.begin(), v.end()); + return *range.second - *range.first; +} + +// The echo, if more than half of the heard sweeps have a second peak +// after them at one delay. When those that agree are more than half +// of the candidates, the median of the candidates' delays is among +// theirs, so looking around it finds them +Echo +echoOf(const vector &events) +{ + Echo echo; + + int heard = 0; + vector candidates; + for (const EventResult &e : events) { + const Arrival &a = e.arrival; + if (a.peakOverMedianDb < kMinPeakOverMedianDb) continue; + ++heard; + if (a.secondDelaySeconds >= kEchoMinDelaySeconds && + a.secondLevelDb >= -kEchoMaxBelowDb) { + candidates.push_back(&a); + } + } + if (candidates.empty()) return echo; + + vector delays; + for (const Arrival *a : candidates) delays.push_back(a->secondDelaySeconds); + const double centre = median(delays); + + vector agreeing, levels; + for (const Arrival *a : candidates) { + if (std::fabs(a->secondDelaySeconds - centre) <= kEchoToleranceSeconds) { + agreeing.push_back(a->secondDelaySeconds); + levels.push_back(a->secondLevelDb); + } + } + + const int count = int(agreeing.size()); + if (count >= kMinEchoEvents && 2 * count > heard) { + echo.heard = true; + echo.delaySeconds = median(agreeing); + echo.levelDb = median(levels); + echo.events = count; + } + return echo; +} + +} // namespace +} // namespace LatencyCheck + +const char * +LatencyCheck::verdictName(Verdict verdict) +{ + switch (verdict) { + case Verdict::NoSignal: return "NoSignal"; + case Verdict::Clipped: return "Clipped"; + case Verdict::Fading: return "Fading"; + case Verdict::PositionDependent: return "PositionDependent"; + case Verdict::Scattered: return "Scattered"; + case Verdict::Unsteady: return "Unsteady"; + case Verdict::Ok: return "Ok"; + } + return "Ok"; +} + +bool +LatencyCheck::TakeSummary::flagged(Verdict v) const +{ + return std::find(flags.begin(), flags.end(), v) != flags.end(); +} + +LatencyCheck::TakeSummary +LatencyCheck::judgeTake(const Layout &layout, + const float *take, sv_frame_t count, + sv_samplerate_t rate, + const vector &punchIns) +{ + TakeSummary summary; + + const double takeSeconds = + (take && count > 0 && rate > 0) ? double(count) / rate : 0.0; + + // All that the finder reads for an event, around its expected time + const double before = kSearchSeconds + kJudgeMarginSeconds; + const double after = kSearchSeconds + kSweepSeconds + kJudgeMarginSeconds; + + for (int p = 0; p < int(punchIns.size()); ++p) { + + PunchInResult result; + result.range = punchIns[p]; + const double start = punchIns[p].start; + const double end = std::min(punchIns[p].end, takeSeconds); + + const sv_frame_t from = std::max(sv_frame_t(0), framesAt(start, rate)); + const sv_frame_t to = std::min(count, framesAt(end, rate)); + for (sv_frame_t f = from; f < to; ++f) { + summary.inputPeak = std::max(summary.inputPeak, + double(std::fabs(take[f]))); + } + + vector offsets; + for (int i = 0; i < int(layout.events.size()); ++i) { + + const double expected = + double(layout.events[i].sweepStart) / layout.rate; + const double first = expected - before; + const double last = expected + after; + if (first < start || last > end) continue; + + // A later punch-in over any of it replaced what this one + // recorded there + bool replaced = false; + for (int q = p + 1; q < int(punchIns.size()); ++q) { + if (first < punchIns[q].end && last > punchIns[q].start) { + replaced = true; + } + } + if (replaced) continue; + + EventResult e; + e.event = i; + e.punchIn = p; + e.expectedSeconds = expected; + e.arrival = findSweep(take, count, rate, expected); + + ++result.judged; + if (e.arrival.found) { + ++result.found; + offsets.push_back(e.arrival.errorSeconds); + } + summary.events.push_back(e); + } + + result.medianOffset = median(offsets); + result.spread = spreadOf(offsets); + summary.judged += result.judged; + summary.found += result.found; + summary.punchIns.push_back(result); + } + + // Across punch-ins: each one that found anything counts once, at + // its median, since what moves from one to the next is the take: a + // stream started again for each (Android, or svapp's default) moves + // its input against its output. A punch-in placed with a longer + // round trip than the first lands that much earlier, which is no + // movement of the device's + const double firstPlaced = + punchIns.empty() ? 0.0 : punchIns.front().placedWith; + vector positions, medians; + double within = 0.0; + for (const PunchInResult &r : summary.punchIns) { + if (r.found == 0) continue; + positions.push_back(r.range.start); + medians.push_back(r.medianOffset + (r.range.placedWith - firstPlaced)); + within = std::max(within, r.spread); + } + summary.medianOffset = median(medians); + summary.spread = spreadOf(medians); + + // Least squares. A device at another rate, placed frame for frame, + // misplaces a punch-in in proportion to where it starts + summary.slopeResidual = summary.spread; + if (positions.size() >= 2) { + double mx = 0.0, my = 0.0; + for (size_t k = 0; k < positions.size(); ++k) { + mx += positions[k]; + my += medians[k]; + } + mx /= double(positions.size()); + my /= double(positions.size()); + double sxx = 0.0, sxy = 0.0; + for (size_t k = 0; k < positions.size(); ++k) { + sxx += (positions[k] - mx) * (positions[k] - mx); + sxy += (positions[k] - mx) * (medians[k] - my); + } + if (sxx > 0.0) { + summary.slope = sxy / sxx; + vector residuals; + for (size_t k = 0; k < positions.size(); ++k) { + residuals.push_back(medians[k] - + (my + summary.slope * (positions[k] - mx))); + } + summary.slopeResidual = spreadOf(residuals); + } + } + + // The level rather than a confidence: over the median of a window + // of near silence a confidence is anything up to the 200 dB clamp, + // so its trend in a clean take is noise, while the level is what + // an echo canceller, noise suppressor or gain control changes. + // Events not found count too, at the level of whatever the finder + // took instead, which is lower when the sweep has gone + const int n = int(summary.events.size()); + if (n >= kMinFadingEvents) { + vector early, late; + for (int k = 0; k < n / 2; ++k) { + early.push_back(summary.events[k].arrival.levelDb); + } + for (int k = n - n / 2; k < n; ++k) { + late.push_back(summary.events[k].arrival.levelDb); + } + summary.fadingDb = median(early) - median(late); + } + + summary.echo = echoOf(summary.events); + + const double spread = std::max(summary.spread, within); + + if (summary.found == 0 || + double(summary.found) / double(summary.judged) < kMinFoundShare) { + summary.flags.push_back(Verdict::NoSignal); + } + if (summary.inputPeak >= ratioOf(kClippedDbfs)) { + summary.flags.push_back(Verdict::Clipped); + } + if (summary.fadingDb >= kFadingDb) { + summary.flags.push_back(Verdict::Fading); + } + if (int(positions.size()) >= kMinPunchInsForSlope && + std::fabs(summary.slope) > kPositionSlope && + std::max(summary.slopeResidual, within) <= kSteadySeconds) { + summary.flags.push_back(Verdict::PositionDependent); + } + if (spread > kScatteredSeconds) { + summary.flags.push_back(Verdict::Scattered); + } else if (spread > kSteadySeconds) { + summary.flags.push_back(Verdict::Unsteady); + } + + summary.verdict = summary.flags.empty() ? Verdict::Ok : summary.flags.front(); + return summary; +} + +vector +LatencyCheck::punchInsFor(const Layout &layout, int count, int eventsEach) +{ + if (count <= 0 || eventsEach <= 0 || layout.rate <= 0) return {}; + + // All that judgeTake() reads for an event, as it works it out, and a + // little more + const double before = + kSearchSeconds + kJudgeMarginSeconds + kPunchInSlackSeconds; + const double after = kSearchSeconds + kSweepSeconds + + kJudgeMarginSeconds + kPunchInSlackSeconds; + const double length = double(layout.length) / layout.rate; + const int n = int(layout.events.size()); + + auto at = [&](int i) { + return double(layout.events[i].sweepStart) / layout.rate; + }; + + vector punchIns; + double free = 0.0; + int i = 0; + while (int(punchIns.size()) < count) { + while (i < n && at(i) - before < free) ++i; + const int last = i + eventsEach - 1; + if (last >= n) return {}; + const PunchIn p(at(i) - before, at(last) + after); + if (p.end > length) return {}; + punchIns.push_back(p); + free = p.end; + i = last + 1; + } + return punchIns; +} + +double +LatencyCheck::calibratedRoundTrip(double usedRoundTripSeconds, + double medianOffsetSeconds) +{ + return usedRoundTripSeconds + medianOffsetSeconds; +} diff --git a/main/LatencyCheck.h b/main/LatencyCheck.h new file mode 100644 index 00000000..40f3cdd2 --- /dev/null +++ b/main/LatencyCheck.h @@ -0,0 +1,516 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TONY_LATENCY_CHECK_H +#define TONY_LATENCY_CHECK_H + +#include "base/BaseTypes.h" + +#include + +/** + * The test reference that Calibrate Audio plays, and the finder that + * locates its sweeps in what the microphone recorded. + * + * The reference is a row of events: a short sweep, which is what the + * finder looks for, then a tone that pYIN can track, then silence. A + * take recorded against it through a loopback (an earcup held to the + * mic) holds every sweep where the take path put it, so how far a + * sweep is found from where the reference has it is how far the take + * was misplaced. + * + * Pure functions over sample buffers: no model, no window and no + * device, so all of it is tested without them (TestLatencyCheck). + * judgeTake() gathers the finder's results over a take's punch-ins + * into a verdict, and calibratedRoundTrip() turns the offset it + * measured into the round trip to use instead. + */ +namespace LatencyCheck +{ + /// The session's rate, which the reference is made at unless asked + constexpr sv::sv_samplerate_t kReferenceRate = 44100; + + /** + * The sweep: linear from 1 to 8 kHz. Linear rather than + * exponential for two reasons. Its spectrum is flat over the + * band, so its matched filter gives the narrowest, most symmetric + * peak that band allows. And the harmonics a small speaker adds + * to a linear sweep rise at a different rate from it, so they do + * not match it; those of an exponential sweep match the sweep + * itself shifted earlier (by 67 ms for the second harmonic and + * 106 ms for the third, here), which is just where the + * earliest-peak rule below looks. + */ + constexpr double kSweepStartHz = 1000.0; + constexpr double kSweepEndHz = 8000.0; + constexpr double kSweepSeconds = 0.2; + + /// Raised-cosine fades at both ends of every sweep and tone, so + /// that no edge is a click + constexpr double kFadeSeconds = 0.01; + + /// The peak of every sweep and tone: well clear of room noise, and + /// no louder than that, since the earcups are held near the ears + constexpr double kPeakDbfs = -12.0; + + /// Silence between a sweep and its tone, so that the tone begins + /// as a note of its own + constexpr double kPauseSeconds = 0.1; + + /// How long the tone after a sweep lasts; the dev layout's held + /// tones are long enough for two punch-ins to meet inside one + constexpr double kToneSeconds = 0.8; + constexpr double kHeldToneSeconds = 3.0; + + /** + * Each tone is its pitch and the harmonics above it up to this, the + * n-th at 1/n of the fundamental's amplitude (-6 dB an octave, as a + * voice or a sawtooth has them). A phone's speaker, or an earbud + * held to the microphone without an ear canal to seal it, gives out + * almost nothing at the tones' pitches: a pure tone there reached + * neither pYIN nor the live tracker on a phone, while the sweeps + * came through. The harmonics come through too, and together they + * repeat at the pitch's period, which is what both find, with the + * fundamental or without it. Their phases are Newman's, so that + * they do not pile up into the sharp edge a sawtooth has: item 10 + * of the dev checks reads an edge that sharp as a step. Only the + * part from 1 kHz up is in the sweep's band, and it comes after the + * sweep and 35 dB or more under it at the finder's output + */ + constexpr double kToneTopHz = 4000.0; + + /** + * And a slight vibrato, as a voice has: the pitch swings this far + * either way, this often, about the tone's pitch. A tone that + * repeats exactly repeats at two and three periods as exactly as at + * one, and pYIN weighs those alike: it took a subharmonic for some + * tones (294 Hz as 73.5, 245 as 81.7), pure or with harmonics, and + * which ones changed with the noise added. Swinging, the waveform + * drifts further from itself the more periods on, and the period is + * the best match again (TestSingingAnalysis) + */ + constexpr double kVibratoCents = 10.0; + constexpr double kVibratoHz = 5.5; + + /** + * The finder's thresholds. Starting values: the report of every + * real run gives the numbers they are to be tuned from. + */ + + /// How far either side of the expected time a sweep is looked + /// for. Less than half the smallest spacing between events, so a + /// neighbour's sweep cannot be taken for this one + constexpr double kSearchSeconds = 0.8; + + /// A peak this much below the largest can still be the direct + /// sound, the largest being a reflection of it + constexpr double kEarliestPeakDb = 6.0; + + /// An earlier peak counts as an arrival of its own only if it is + /// at least this far before the largest; see findSweep() + constexpr double kSeparateArrivalSeconds = 0.001; + + /// The sweep is found when its peak stands this far above the + /// median of the window ... + constexpr double kMinPeakOverMedianDb = 15.0; + + /// ... and this far above anything in the window more than + /// kSecondPeakSeconds away from it + constexpr double kMinPeakOverSecondDb = 6.0; + constexpr double kSecondPeakSeconds = 0.01; + + /** + * One event of the reference, in frames of its layout's rate: a + * sweep, a pause, a tone, then silence until the next event. An + * event whose toneHz is not above 0 has silence for its tone. + */ + struct Event { + sv::sv_frame_t sweepStart; + sv::sv_frame_t toneStart; + sv::sv_frame_t toneLength; + double toneHz; + + Event() : sweepStart(0), toneStart(0), toneLength(0), toneHz(0) { } + }; + + /** + * A reference's length and events, at a rate. The events are in + * order, and each ends before the next begins. + */ + struct Layout { + sv::sv_samplerate_t rate; + sv::sv_frame_t length; + std::vector events; + + Layout() : rate(kReferenceRate), length(0) { } + }; + + /** + * The fixed layouts. The events are at irregular spacings (sweep + * to sweep) between 1.6 and 2.6 s, each spacing used once, so that + * no stretch of the reference matches another one some events + * along: a take misplaced by whole events cannot look right. The + * spacing is also what keeps the finder off a neighbour's sweep + * (kSearchSeconds). The tones take turns at 196, 220.5, 245 and + * 294 Hz: a whole number of samples per period at 44.1 kHz, which + * pYIN needs to report the pitch rather than a subharmonic + * (docs/testing.md), on average: with their harmonics (kToneTopHz) + * and a vibrato (kVibratoCents). + * + * All three give their events the same times in seconds at any + * rate, so a reference made at another rate is the same reference. + */ + + /// 26 s: twelve events, for the four punch-ins of a calibration + Layout calibrationLayout(sv::sv_samplerate_t rate = kReferenceRate); + + /// 40 s: the calibration events, then three with held tones. The + /// spacings into and between those are longer than any calibration + /// spacing, so all still differ, and a held tone's event (3.3 s) + /// ends before the next sweep + Layout devLayout(sv::sv_samplerate_t rate = kReferenceRate); + + /// How long the long layout is unless asked otherwise: a song + constexpr double kLongSeconds = 240.0; + + /// 4 minutes, or the length given, of events at the calibration + /// spacings, over and over, as many as end in time for the silence + /// the calibration layout ends with. No more than eleven spacings + /// can differ by 0.1 s inside 1.6 to 2.6 s, so here any eleven in a + /// row do. A shorter one is the start of the 4-minute one + Layout longLayout(sv::sv_samplerate_t rate = kReferenceRate, + double seconds = kLongSeconds); + + /// One sweep at the given rate, as the reference has it + std::vector sweep(sv::sv_samplerate_t rate); + + /// The reference: the layout's events, silence everywhere else + std::vector generate(const Layout &layout); + + /** + * Where a sweep was found in a take, and how sure the finder is. + * The error and the levels describe the best candidate even when + * it was not found; they mean something only when it was. + */ + struct Arrival { + /// Both confidences cleared their thresholds + bool found; + + /// Where the sweep arrived minus where it was expected, in + /// whole frames of the take counted from the frame nearest the + /// expected time. Positive is late + sv::sv_frame_t errorFrames; + + /// The same, in seconds, from the expected time itself + double errorSeconds; + + /// The two confidences, in dB + double peakOverMedianDb; + double peakOverSecondDb; + + /// How loud the sweep arrived against how it was generated: + /// 0 dB if the take holds the reference's sweep unchanged + double levelDb; + + /// The largest sample of the take in the stretch searched, + /// full scale being 1 + double inputPeak; + + /// The second peak, which peakOverSecondDb measures against: + /// how long after the chosen peak it comes (negative if + /// before), and its level against the chosen peak's, in dB. + /// A monitoring echo is a second peak at the same delay after + /// every sweep. With nothing more than kSecondPeakSeconds from + /// the chosen peak, the delay is 0 + double secondDelaySeconds; + double secondLevelDb; + + Arrival() : found(false), errorFrames(0), errorSeconds(0), + peakOverMedianDb(0), peakOverSecondDb(0), + levelDb(0), inputPeak(0), + secondDelaySeconds(0), secondLevelDb(0) { } + }; + + /** + * Look for the reference's sweep in a take, within kSearchSeconds + * of where it is expected; the window is cut short at the ends of + * the take. The take's samples are at the given rate, which need + * not be the reference's: the expected time is in seconds, and + * the sweep is matched at the take's own rate. + */ + Arrival findSweep(const float *take, sv::sv_frame_t count, + sv::sv_samplerate_t rate, double expectedSeconds); + + /** + * Judging a take. An offset is where a sweep was found minus + * where the reference has it (Arrival::errorSeconds): positive is + * late. Starting values like the finder's, to be tuned from the + * reports of real runs. + */ + + /// An event is judged in a punch-in only when all that the finder + /// reads for it (its window, and a sweep's length past the window's + /// end) lies inside the punch-in's range and this far from its + /// ends. The splice cuts the punch-in's audio at the range ends and + /// crossfades it over 5 ms into what was there, so a sweep near an + /// end may be cut, which says nothing about the audio path + constexpr double kJudgeMarginSeconds = 0.05; + + /// NoSignal: fewer than this share of the judged events were found + constexpr double kMinFoundShare = 2.0 / 3.0; + + /// Clipped: the input reached this close to full scale somewhere + /// in the punch-ins. Not at full scale exactly: a device's integer + /// samples, converted, stop a little short of it + constexpr double kClippedDbfs = -0.2; + + /// Unsteady: the offsets found disagree by more than this, across + /// punch-ins or within one ... + constexpr double kSteadySeconds = 0.005; + + /// ... Scattered: by more than this + constexpr double kScatteredSeconds = 0.015; + + /// PositionDependent: the punch-ins' offsets lie on a line over + /// their positions, steeper than this (0.5 %: a 44.1 kHz reference + /// recorded at 48 kHz and placed frame for frame is 8 %), and + /// leave less than kSteadySeconds about it ... + constexpr double kPositionSlope = 0.005; + + /// ... fitted over this many punch-ins at least, since a line + /// through two always fits + constexpr int kMinPunchInsForSlope = 3; + + /// Fading: the sweeps' level (Arrival::levelDb) over the judged + /// events, in the order they were recorded, fell by this much from + /// the first half to the second, each half taken by its median. + /// Through a steady path every sweep arrives at the same level, + /// within 0.5 dB even in noise at -10 dB SNR. An echo canceller + /// or noise suppressor taking the sweeps out brings them down + /// toward the noise, and gain control by whatever it corrects; 10 + /// dB leaves room for an earcup that shifts a little + constexpr double kFadingDb = 10.0; + + /// ... judged only from this many events on, so that each half has + /// three and one missing event cannot move its median + constexpr int kMinFadingEvents = 6; + + /** + * A monitoring echo: the take's own input played back out and heard + * again. It is the second peak (Arrival::secondDelaySeconds), at + * the same delay within kEchoToleranceSeconds, in more than half of + * the events whose sweep was heard, and at least kMinEchoEvents of + * them. Heard, not found: an echo within kMinPeakOverSecondDb of + * the direct sound leaves no sweep found, and should still be named. + * + * Other things come at a fixed delay after every sweep too, so an + * echo has to be at least kEchoMinDelaySeconds late and at most + * kEchoMaxBelowDb down. A reflection a little closer than + * kSecondPeakSeconds leaves the tail of its peak just past that, 9 + * to 23 dB down (measured for one 5.5 dB stronger than the direct + * sound, 9 ms after it, from a 1-2 kHz path to the full band), + * which is why the delay. The matched filter's own tail is over + * 80 dB down, and noise peaks come as loud as 26 dB down at 0 dB + * SNR but never agree on a delay. + * + * The Windows audio engine works in 10 ms periods, so what it + * monitors comes back two periods late at least: one to capture + * it and one to play it. An echo closer than that, such as an + * interface's direct monitor, is not seen, nor one quieter than + * the tail of a close reflection. + */ + constexpr double kEchoMinDelaySeconds = 0.02; + constexpr double kEchoMaxBelowDb = 30.0; + constexpr double kEchoToleranceSeconds = 0.003; + constexpr int kMinEchoEvents = 3; + + /** + * What a take says about the audio path. In this order of + * precedence: when several apply, the first is the verdict, and + * names what to fix first. Without the sweeps nothing else can be + * judged; a clipped or processed input can move where they are + * found; and a rate that misplaces punch-ins also scatters them. + */ + enum class Verdict { + NoSignal, ///< too few sweeps found + Clipped, ///< the input reached full scale + Fading, ///< the sweeps' level fell over the run + PositionDependent, ///< offset grows with position: a rate + ///< other than the reference's + Scattered, ///< offsets disagree by more than 15 ms + Unsteady, ///< offsets disagree by 5 to 15 ms + Ok + }; + + /// The verdict's name as the enum spells it, for logs + const char *verdictName(Verdict verdict); + + /// A range of the timeline that one punch-in recorded, in seconds + struct PunchIn { + double start; + double end; + + /// The round trip the punch-in was placed with, in seconds, if + /// known; see judgeTake() + double placedWith; + + PunchIn() : start(0), end(0), placedWith(0) { } + PunchIn(double s, double e) : start(s), end(e), placedWith(0) { } + }; + + /// One judged event: which of the layout's, in which punch-in, and + /// what the finder made of it + struct EventResult { + int event; + int punchIn; + double expectedSeconds; + Arrival arrival; + + EventResult() : event(0), punchIn(0), expectedSeconds(0) { } + }; + + /// One punch-in's figures. The offsets are those of its found + /// events, in seconds; with none found, both are 0 + struct PunchInResult { + PunchIn range; + int judged; + int found; + double medianOffset; + double spread; ///< largest offset minus smallest + + PunchInResult() : judged(0), found(0), medianOffset(0), spread(0) { } + }; + + /// A monitoring echo, if one was heard; see kEchoMaxBelowDb + struct Echo { + bool heard; + double delaySeconds; ///< after the direct sound, median + double levelDb; ///< against the direct sound, median + int events; ///< how many events it was heard in + + Echo() : heard(false), delaySeconds(0), levelDb(0), events(0) { } + }; + + /// What judgeTake() made of a take. Offsets in seconds + struct TakeSummary { + Verdict verdict; + + /// Every verdict that applied, in order of precedence; empty + /// for Ok + std::vector flags; + + std::vector punchIns; ///< as given + std::vector events; ///< judged, as recorded + + int judged; + int found; + + /// Across punch-ins, over those with an event found: the + /// median of their median offsets, which weighs every take + /// alike, and the largest minus the smallest of them. Each + /// counted as if its punch-in had been placed with the first + /// one's round trip (PunchIn::placedWith): what is left is how + /// the device moved, not how the placing did + double medianOffset; + double spread; + + /// The line fitted to those medians over the punch-ins' start + /// times: seconds of offset per second of position, and what + /// it leaves, as largest minus smallest. 0 and the spread + /// itself below two punch-ins + double slope; + double slopeResidual; + + /// The largest sample in the punch-ins' ranges, full scale 1 + double inputPeak; + + /// How far the sweeps' level fell, first half to second, in + /// dB; 0 below kMinFadingEvents judged events + double fadingDb; + + Echo echo; + + bool flagged(Verdict v) const; + + TakeSummary() : verdict(Verdict::NoSignal), judged(0), found(0), + medianOffset(0), spread(0), slope(0), + slopeResidual(0), inputPeak(0), fadingDb(0) { } + }; + + /** + * Judge a take recorded against the reference made from this + * layout. + * + * The take is one channel of samples, as its model gives them, at + * the take's own rate, which need not be the layout's. The punch- + * ins are the timeline ranges they recorded, in seconds, in the + * order they were recorded; where a later one overlaps an earlier + * one, the take holds the later one's audio there. So an event is + * judged in a punch-in that holds all the finder reads for it + * (kJudgeMarginSeconds) and where no later one overlaps that, and + * in no other. Ranges are cut short at the end of the take. + * + * NoSignal applies when fewer than kMinFoundShare of the judged + * events were found, and also when none was, judged or not: there + * is then nothing to measure. Unsteady and Scattered look at the + * spread across punch-ins and within each, whichever is larger. + * + * Punch-ins placed with different round trips (a device whose + * reported latencies move between starts, as Oboe's do) land that + * much apart for that reason alone: across punch-ins, each one's + * offset is taken as if it had been placed with the first one's + * round trip, so that calibratedRoundTrip() of the first one's and + * the median offset is the round trip the device had, and the + * spread and the line are its own. Each PunchInResult keeps where + * its punch-in landed. + */ + TakeSummary judgeTake(const Layout &layout, + const float *take, sv::sv_frame_t count, + sv::sv_samplerate_t rate, + const std::vector &punchIns); + + /// punchInsFor() leaves this much more room around what the finder + /// reads than judgeTake() asks for, so that a range rounded to whole + /// frames, at any rate, still holds all of it + constexpr double kPunchInSlackSeconds = 0.01; + + /** + * The ranges of a run of punch-ins against the reference made from + * this layout: count of them, one after another along the timeline + * and not overlapping, each holding eventsEach consecutive events + * that judgeTake() judges in it, and no other. + * + * An event is judged only where all that the finder reads for it + * lies inside one range, so where one range ends and the next + * begins, that much of the spacing between two events is lost (1.9 + * s): an event whose reading would begin before the previous range + * ends is left out. Empty if the layout has no room for them all. + */ + std::vector punchInsFor(const Layout &layout, + int count, int eventsEach); + + /** + * The round trip that would have placed the take right, in seconds: + * the one it was placed with plus the median offset measured. + * + * The offset is found minus expected, so positive means the take + * landed late: the latency used was too small, the take was spliced + * from too early a frame of the recording, and the round trip has + * to grow. + */ + double calibratedRoundTrip(double usedRoundTripSeconds, + double medianOffsetSeconds); +} + +#endif diff --git a/main/LatencyUtils.h b/main/LatencyUtils.h new file mode 100644 index 00000000..dea9348f --- /dev/null +++ b/main/LatencyUtils.h @@ -0,0 +1,101 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TONY_LATENCY_UTILS_H +#define TONY_LATENCY_UTILS_H + +#include "AudioRoute.h" + +#include "base/BaseTypes.h" + +/** + * Round-trip latency, in frames, to compensate for when a singing + * take is recorded while the reference is playing: the singer's + * response to reference frame 0 arrives in the recording at about + * frame (outputLatency + inputLatency). Either figure may be + * unavailable (reported as zero or negative); the result is never + * negative. + */ +inline sv::sv_frame_t +computeRecordingLatency(sv::sv_frame_t outputLatency, + sv::sv_frame_t inputLatency) +{ + if (outputLatency < 0) outputLatency = 0; + if (inputLatency < 0) inputLatency = 0; + return outputLatency + inputLatency; +} + +/** + * What a take was placed with: the round trip taken off the front of + * its recording (besides the start gap, below), whether + * that was a figure the audio check measured (a stored one, or one a + * check brought for its own run) or the sum of the output + * and input latencies the device reported, those two latencies, and the + * rate the device recorded at. All 0 until known; the round trip stays + * 0 for a take made without the reference playing, which is placed with + * none. + * + * The round trip is in frames of the recording, which it is taken off. + * The two reported latencies are in seconds, as MainWindow::roundTripAt() + * works them out from the frames each counts in (the play source's, the + * device's), and as it compares them with a stored figure's + * fingerprint: in frames they count at two different rates when the + * device's rate is not the session's. + * + * The start gap is the rest of what is taken off the front: the frames + * of the recording made before the reference began to play, also in + * frames of the recording. It is estimated when the reference is + * started, and measured once the audio callback has handed the device + * its first block; startGapMeasured says whether that happened, as it + * does for every take that plays the reference. + * + * The route is the one the device reported when the take started, for + * a device that knows its route (AudioRouteReporter): what a figure + * measured on the take is kept under, with its streams as the figure's + * fingerprint. Its driver is "" for any other device. + */ +struct TakeLatency +{ + sv::sv_frame_t roundTrip; + bool measured; + double reportedOutput; + double reportedInput; + sv::sv_samplerate_t recordingRate; + sv::sv_frame_t startGap; + bool startGapMeasured; + AudioRoute::Route route; + + TakeLatency() : roundTrip(0), measured(false), reportedOutput(0), + reportedInput(0), recordingRate(0), startGap(0), + startGapMeasured(false) { } + + /// Frames of the recording in seconds; 0 while its rate is unknown + double recordingSeconds(sv::sv_frame_t frames) const { + return recordingRate > 0 ? double(frames) / recordingRate : 0.0; + } +}; + +/** + * Where to draw a live pitch dot for sound found at the given frame + * of a take that is going to be shifted earlier by the given latency + * once it is finished. A negative result means the sound came before + * the reference started, and the dot should be dropped. + */ +inline sv::sv_frame_t +compensatedLiveFrame(sv::sv_frame_t frame, sv::sv_frame_t latency) +{ + if (latency < 0) latency = 0; + return frame - latency; +} + +#endif diff --git a/main/LiveDotsFeed.cpp b/main/LiveDotsFeed.cpp new file mode 100644 index 00000000..b6ea67c5 --- /dev/null +++ b/main/LiveDotsFeed.cpp @@ -0,0 +1,184 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#include "LiveDotsFeed.h" + +#include + +#include + +#if defined(Q_OS_LINUX) || defined(Q_OS_MACOS) +#include +#endif + +using namespace sv; + +// CPU time this thread has used, in ms; negative where there is no way +// of asking +static double +threadTimeMs() +{ +#if defined(Q_OS_LINUX) || defined(Q_OS_MACOS) + timespec ts; + if (clock_gettime(CLOCK_THREAD_CPUTIME_ID, &ts) == 0) { + return double(ts.tv_sec) * 1000.0 + double(ts.tv_nsec) / 1.0e6; + } +#endif + return -1.0; +} + +static double +elapsedMs(const QElapsedTimer &timer) +{ + return double(timer.nsecsElapsed()) / 1.0e6; +} + +void +LiveDotsFeed::Times::add(double ms) +{ + ++count; + totalMs += ms; + maxMs = std::max(maxMs, ms); +} + +LiveDotsFeed::LiveDotsFeed(int intervalMs) : + m_running(false), + m_threadTimeAtReport(-1.0) +{ + m_timer.setInterval(intervalMs); + connect(&m_timer, &QTimer::timeout, this, [this]() { tick(); }); +} + +LiveDotsFeed::~LiveDotsFeed() +{ + stop(); +} + +void +LiveDotsFeed::start(Source source, Handler handler) +{ + stop(); + m_source = source; + m_handler = handler; + m_running = true; + m_report.newestFrame = -1; + startPeriod(); + m_sinceTick.invalidate(); + m_timer.start(); +} + +void +LiveDotsFeed::stop() +{ + m_timer.stop(); + m_running = false; + m_source = {}; + m_handler = {}; + if (m_painted) m_painted->removeEventFilter(this); + m_painted = nullptr; +} + +void +LiveDotsFeed::timePaintsOf(QObject *widget) +{ + if (m_painted) m_painted->removeEventFilter(this); + m_painted = widget; + if (m_painted) m_painted->installEventFilter(this); +} + +void +LiveDotsFeed::startPeriod() +{ + sv_frame_t newest = m_report.newestFrame; + m_report = Report(); + m_report.newestFrame = newest; + m_sinceReport.start(); + m_threadTimeAtReport = threadTimeMs(); +} + +void +LiveDotsFeed::tick() +{ + if (!m_running) return; + + // How long since the last look, which is the interval when the GUI + // thread has the time, and longer when it has not + if (m_sinceTick.isValid()) { + m_report.intervals.add(elapsedMs(m_sinceTick)); + } + m_sinceTick.start(); + + Estimates batch = m_source(); + if (!batch.empty()) { + QElapsedTimer timer; + timer.start(); + m_handler(batch); + m_report.batches.add(elapsedMs(timer)); + m_report.estimates += int(batch.size()); + m_report.newestFrame = batch.back().frame; + // (the handler may have stopped the feed) + if (!m_running) return; + } + + if (m_reporter && m_sinceReport.elapsed() >= 1000) { + m_report.seconds = elapsedMs(m_sinceReport) / 1000.0; + double threadTime = threadTimeMs(); + if (threadTime >= 0.0 && m_threadTimeAtReport >= 0.0 && + m_report.seconds > 0.0) { + m_report.guiThreadShare = (threadTime - m_threadTimeAtReport) / + (m_report.seconds * 1000.0); + } + m_reporter(m_report); + startPeriod(); + } +} + +bool +LiveDotsFeed::eventFilter(QObject *object, QEvent *event) +{ + if (object != m_painted || event->type() != QEvent::Paint) { + return false; + } + + // Delivered here, because nothing comes after a paint event to say + // when it is over + QElapsedTimer timer; + timer.start(); + object->event(event); + m_report.paints.add(elapsedMs(timer)); + return true; +} + +QString +LiveDotsFeed::describe(const Report &r) +{ + QString text = QString("%1 batches of %2 dots, %3 ms each (at most %4), " + "every %5 ms (at most %6); pane painted %7 times, " + "%8 ms each (at most %9)") + .arg(r.batches.count) + .arg(r.batches.count > 0 ? double(r.estimates) / r.batches.count : 0.0, + 0, 'f', 1) + .arg(r.batches.averageMs(), 0, 'f', 2) + .arg(r.batches.maxMs, 0, 'f', 1) + .arg(r.intervals.averageMs(), 0, 'f', 0) + .arg(r.intervals.maxMs, 0, 'f', 0) + .arg(r.paints.count) + .arg(r.paints.averageMs(), 0, 'f', 2) + .arg(r.paints.maxMs, 0, 'f', 1); + if (r.guiThreadShare >= 0.0) { + text += QString("; GUI thread %1% of a core") + .arg(r.guiThreadShare * 100.0, 0, 'f', 0); + } + return text; +} diff --git a/main/LiveDotsFeed.h b/main/LiveDotsFeed.h new file mode 100644 index 00000000..5617dab3 --- /dev/null +++ b/main/LiveDotsFeed.h @@ -0,0 +1,123 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TONY_LIVE_DOTS_FEED_H +#define TONY_LIVE_DOTS_FEED_H + +#include "RealtimePitchTracker.h" + +#include +#include +#include +#include +#include + +#include + +/** + * Hands the live tracker's pitch estimates to the GUI thread in + * batches: everything found since the last batch, once an interval. + * + * The tracker finds about 170 estimates a second. Handed over one at a + * time, each is a call queued on the GUI thread, and a GUI thread that + * needs longer for one than the tracker takes to find the next (5.8 ms) + * falls behind for good: the dots trail the singing further and further + * for as long as the take lasts. A phone's GUI thread is several times + * slower than a desktop's. Taken in batches, a slower thread takes + * bigger batches, later, and nothing queues up. The interval is also + * how often the pane is told of new dots, each time a redraw of it. + * + * Once a second it reports what the batches, and the paints of the + * pane the dots are in, have cost the GUI thread: for the log, since + * whether the dots keep up on a phone cannot be seen from the desktop. + * + * Lives on the GUI thread, as the handler does. + */ +class LiveDotsFeed : public QObject +{ +public: + typedef RealtimePitchTracker::Estimates Estimates; + typedef std::function Source; + typedef std::function Handler; + + explicit LiveDotsFeed(int intervalMs); + ~LiveDotsFeed(); + + /// Take what the source has found once an interval, and give it to + /// the handler, if there is anything + void start(Source source, Handler handler); + + /// No more batches or reports, and no more paints timed. What the + /// source still holds is left with it + void stop(); + + bool isRunning() const { return m_running; } + + /// Time the paint events of this widget (the pane the dots are in) + /// for the reports, until stop(). The feed delivers them to the + /// widget itself, to see when they are over: event filters the + /// widget had before do not see its paint events meanwhile + void timePaintsOf(QObject *widget); + + struct Times { + int count = 0; + double totalMs = 0.0; + double maxMs = 0.0; + void add(double ms); + double averageMs() const { return count > 0 ? totalMs / count : 0.0; } + }; + + struct Report { + double seconds = 0.0; ///< since the last report + int estimates = 0; ///< handed over since then + /// Centre frame of the newest estimate handed over (not only + /// since the last report), or -1 + sv::sv_frame_t newestFrame = -1; + Times batches; ///< the handler's time, each batch + Times intervals; ///< between one look at the source and the next + Times paints; ///< the widget's paint events + /// Of one core, over the period; negative where unknown + double guiThreadShare = -1.0; + }; + + /// Called with what happened since the last report, once a second + /// while running + void setReporter(std::function reporter) { + m_reporter = reporter; + } + + /// The costs of a report, for a log line + static QString describe(const Report &report); + +protected: + bool eventFilter(QObject *object, QEvent *event) override; + +private: + void tick(); + void startPeriod(); + + QTimer m_timer; + bool m_running; + Source m_source; + Handler m_handler; + std::function m_reporter; + QPointer m_painted; + + Report m_report; + QElapsedTimer m_sinceTick; + QElapsedTimer m_sinceReport; + double m_threadTimeAtReport; +}; + +#endif diff --git a/main/LogFile.cpp b/main/LogFile.cpp new file mode 100644 index 00000000..dd599f6c --- /dev/null +++ b/main/LogFile.cpp @@ -0,0 +1,78 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#include "LogFile.h" + +#include +#include + +LogFile::LogFile(QString path, qint64 limit) : + m_path(path), + m_limit(limit) +{ +} + +QString +LogFile::olderPath(QString path) +{ + return path + ".1"; +} + +bool +LogFile::begin() +{ + // What is there now becomes the older part, the older part goes + if (QFileInfo::exists(m_path)) { + QFile::remove(olderPath(m_path)); + QFile::rename(m_path, olderPath(m_path)); + } + + m_file.setFileName(m_path); + return m_file.open(QIODevice::WriteOnly | QIODevice::Truncate); +} + +bool +LogFile::open() +{ + QDir().mkpath(QFileInfo(m_path).absolutePath()); + return begin(); +} + +void +LogFile::write(const QByteArray &line) +{ + if (!m_file.isOpen()) return; + + m_file.write(line); + m_file.write("\n", 1); + m_file.flush(); + + if (m_file.size() >= m_limit) { + m_file.close(); + begin(); + } +} + +QByteArray +LogFile::contents(QString path) +{ + QByteArray all; + for (QString part : { olderPath(path), path }) { + QFile file(part); + if (file.open(QIODevice::ReadOnly)) { + all += file.readAll(); + } + } + return all; +} diff --git a/main/LogFile.h b/main/LogFile.h new file mode 100644 index 00000000..216bf5d5 --- /dev/null +++ b/main/LogFile.h @@ -0,0 +1,61 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TONY_LOG_FILE_H +#define TONY_LOG_FILE_H + +#include +#include +#include + +/** + * A log file that stays small. On Android Tony's output goes to the + * system log, which no one reads without a computer and adb; a copy + * kept here can be saved from Tony (Help > Save Log...) and sent. + * + * When opened, the file an earlier run left becomes .1, in place + * of an older one, and whenever the file grows past limit bytes it goes + * the same way and a new one is begun: the two hold the last limit to + * twice limit bytes, the end of the run before included when this one's + * is short. One thread writes; it is not locked. + */ +class LogFile +{ +public: + LogFile(QString path, qint64 limit); + + // Moves an earlier run's file aside and begins a new one; false if + // it cannot be written + bool open(); + + bool isOpen() const { return m_file.isOpen(); } + + // Appends line and a newline, at once, in case Tony ends soon after + void write(const QByteArray &line); + + // .1, the older part + static QString olderPath(QString path); + + // The older part and the newer, in that order: the log to be sent + static QByteArray contents(QString path); + +private: + QString m_path; + qint64 m_limit; + QFile m_file; + + bool begin(); +}; + +#endif diff --git a/main/Lyrics.cpp b/main/Lyrics.cpp new file mode 100644 index 00000000..08c30ca2 --- /dev/null +++ b/main/Lyrics.cpp @@ -0,0 +1,588 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#include "Lyrics.h" +#include "LyricsTtml.h" + +#include +#include + +#include +#include +#include + +using namespace sv; + +namespace { + +QString tr(const char *text) +{ + return QCoreApplication::translate("Lyrics", text); +} + +// "1 line was" or "3 lines were": the status bar shows these +QString counted(int n, const char *one, const char *many) +{ + if (n == 1) return tr(one); + return tr(many).arg(n); +} + +// Every time in an LRC file is a whole number of milliseconds, and so +// is the offset, so they stay that way until the words are made: no +// rounding can then put a word before the one it follows +typedef qint64 Ms; + +const Ms msPerSecond = 1000; + +// Only to keep the arithmetic far from overflowing: no reference is a +// day long +const qint64 maxMinutes = 24 * 60; + +bool isAsciiDigit(QChar c) +{ + return c.unicode() >= '0' && c.unicode() <= '9'; +} + +int digitValue(QChar c) +{ + return c.unicode() - '0'; +} + +/** + * The length of the time tag at pos, or 0 if there is none there. A + * tag is [m:ss], [m:ss.f], [m:ss.ff] or [m:ss.fff], with ':' allowed in + * place of '.', and with open and close in place of the brackets: '<' + * and '>' for a word. Seconds of 60 or more make it no tag. + */ +int readTimeTag(const QString &s, int pos, char open, char close, Ms &time) +{ + const int n = s.size(); + int i = pos; + if (i >= n || s[i] != QLatin1Char(open)) return 0; + ++i; + + qint64 minutes = 0; + int minuteDigits = 0; + while (i < n && isAsciiDigit(s[i])) { + minutes = minutes * 10 + digitValue(s[i]); + if (minutes > maxMinutes) return 0; + ++minuteDigits; + ++i; + } + if (minuteDigits == 0 || i >= n || s[i] != QLatin1Char(':')) return 0; + ++i; + + if (i + 1 >= n || !isAsciiDigit(s[i]) || !isAsciiDigit(s[i + 1])) { + return 0; + } + int seconds = digitValue(s[i]) * 10 + digitValue(s[i + 1]); + if (seconds >= 60) return 0; + i += 2; + + Ms fraction = 0; + if (i < n && (s[i] == QLatin1Char('.') || s[i] == QLatin1Char(':'))) { + ++i; + int fractionDigits = 0; + while (i < n && isAsciiDigit(s[i]) && fractionDigits < 3) { + fraction = fraction * 10 + digitValue(s[i]); + ++fractionDigits; + ++i; + } + if (fractionDigits == 0) return 0; + // One digit is tenths, two hundredths, three thousandths + for (int d = fractionDigits; d < 3; ++d) fraction *= 10; + } + + if (i >= n || s[i] != QLatin1Char(close)) return 0; + ++i; + + time = (minutes * 60 + seconds) * msPerSecond + fraction; + return i - pos; +} + +/** + * A [key:value] line. A key of digits only is a time tag gone wrong, + * not metadata. The value runs to the last ']', so that it can hold + * brackets of its own: [ti:Song [Live]]. + */ +bool readMetadata(const QString &row, QString &key, QString &value) +{ + if (!row.startsWith(QLatin1Char('[')) || !row.endsWith(QLatin1Char(']'))) { + return false; + } + int colon = row.indexOf(QLatin1Char(':')); + if (colon < 0) return false; + + QString k = row.mid(1, colon - 1).trimmed(); + if (k.isEmpty() || k.contains(QLatin1Char('[')) || + k.contains(QLatin1Char(']'))) { + return false; + } + if (std::all_of(k.begin(), k.end(), isAsciiDigit)) return false; + + key = k.toLower(); + value = row.mid(colon + 1, row.size() - colon - 2).trimmed(); + return true; +} + +// Nothing but notes and space: the exporter's mark for a gap +bool isOnlyMusic(const QString &text) +{ + for (QChar c : text) { + if (c != QChar(0x266A) && c != QChar(0x266B) && !c.isSpace()) { + return false; + } + } + return true; +} + +/** + * The text of the file. A BOM says what it is; without one it ought + * to be UTF-8, and anything that is not is read as Latin-1. That + * decodes every byte, keeps the ä and ö of an older Finnish file, and + * gives the same answer on every system, as the system's own codec + * would not. Stateless, so that a sequence cut off at the end counts + * as an error too. + */ +QString decoded(const QByteArray &bytes, QStringList &warnings) +{ + const QStringConverter::Flags flags = QStringConverter::Flag::Stateless; + + std::optional bom = + QStringConverter::encodingForData(bytes); + if (bom) { + QStringDecoder decoder(*bom, flags); + QString text = decoder.decode(bytes); + if (decoder.hasError()) { + warnings << tr("Some characters in the file could not be read " + "and were replaced."); + } + return text; + } + + QStringDecoder utf8(QStringConverter::Utf8, flags); + QString text = utf8.decode(bytes); + if (!utf8.hasError()) return text; + + warnings << tr("The file is not UTF-8, so it was read as Latin-1."); + QStringDecoder latin1(QStringConverter::Latin1, flags); + return latin1.decode(bytes); +} + +// A word, or a whole line, before it has a line index +struct Entry +{ + Ms start = 0; + Ms end = 0; + bool haveEnd = false; + bool endGiven = false; + QString text; +}; + +struct TimedLine +{ + Ms stamp = 0; + + /// Its entries are words, not the whole line + bool hasWordTags = false; + + /// None: the line only marks where the one before it ends + QVector entries; +}; + +/** + * The entries of the text after a line's time tags. Words are split + * on word tags only, never on spaces: the exporter leaves the space + * out before a word with punctuation in it (onzestilo,). Each tag + * starts a word and ends the one before it; a tag with no text after + * it only ends the one before. Text before the first tag starts at + * the line's own time. + */ +TimedLine readLineText(const QString &text, Ms stamp, int &backwards) +{ + TimedLine line; + line.stamp = stamp; + + struct Piece { + Ms time; + bool tagged; + QString text; + }; + + QVector pieces; + Piece current { stamp, false, QString() }; + const int n = text.size(); + int i = 0; + while (i < n) { + Ms time = 0; + int length = readTimeTag(text, i, '<', '>', time); + if (length > 0) { + pieces.push_back(current); + current = Piece { time, true, QString() }; + line.hasWordTags = true; + i += length; + } else { + // Anything else, a '<' that starts no tag included, is text + current.text += text[i]; + ++i; + } + } + pieces.push_back(current); + + for (const Piece &piece : pieces) { + Ms time = piece.time; + if (piece.tagged && !line.entries.isEmpty()) { + Entry &previous = line.entries.last(); + if (time < previous.start) { + time = previous.start; + ++backwards; + } + if (!previous.haveEnd) { + previous.end = time; + previous.haveEnd = true; + previous.endGiven = true; + } + } + QString label = lyricsLabel(piece.text); + if (label.isEmpty()) continue; + Entry entry; + entry.start = time; + entry.text = label; + line.entries.push_back(entry); + } + + bool onlyMusic = true; + for (const Entry &e : line.entries) { + if (!isOnlyMusic(e.text)) onlyMusic = false; + } + if (onlyMusic) line.entries.clear(); + + return line; +} + +TimedLine shifted(TimedLine line, Ms by) +{ + line.stamp += by; + for (Entry &e : line.entries) { + e.start += by; + e.end += by; + } + return line; +} + +/** + * Where each line's last word ends when the line itself does not say: + * where a following empty or music-only line puts it, else at the + * next line, but a word no more than a short while after its start. + * The lines must be in time order. + */ +void inferEnds(QVector &lines) +{ + const Ms wordMs = Ms(std::llround(Lyrics::inferredWordSeconds * + msPerSecond)); + const Ms lastLineMs = Ms(std::llround(Lyrics::inferredLastLineSeconds * + msPerSecond)); + + for (int k = 0; k < lines.size(); ++k) { + + if (lines[k].entries.isEmpty()) continue; + + // The words before it all end where the next word tag is + Entry &last = lines[k].entries.last(); + if (last.haveEnd) continue; + + const bool isWord = lines[k].hasWordTags; + + if (k + 1 < lines.size()) { + const TimedLine &next = lines[k + 1]; + if (next.entries.isEmpty()) { + last.end = next.stamp; + last.endGiven = true; + } else if (isWord) { + last.end = std::min(next.stamp, last.start + wordMs); + } else { + last.end = next.stamp; + } + } else { + last.end = last.start + (isWord ? wordMs : lastLineMs); + } + + // A line whose words run past the next line's start: this one + // gets no length, which conversion makes one frame + last.end = std::max(last.end, last.start); + last.haveEnd = true; + } +} + +} // namespace + +QString +lyricsWithoutControls(const QString &text) +{ + QString out; + out.reserve(text.size()); + for (QChar c : text) { + const ushort u = c.unicode(); + if (u == '\t') { + out += QLatin1Char(' '); + continue; + } + // The line breaks too: an LRC row never has one, and to TTML + // they are whitespace, which its parser has made spaces + if (u < 0x20 || u == 0x7F || u == 0xFFFE || u == 0xFFFF) { + continue; + } + out += c; + } + return out; +} + +QString +lyricsLabel(const QString &text) +{ + QString s = lyricsWithoutControls(text).trimmed(); + if (s.size() > Lyrics::maxLabelLength) { + int n = Lyrics::maxLabelLength; + // Never half of a surrogate pair + if (s.at(n - 1).isHighSurrogate()) --n; + s = s.left(n).trimmed(); + } + return s; +} + +int +Lyrics::lineCount() const +{ + // Words need not be in line order: lines can overlap + int count = 0; + for (const LyricWord &w : words) count = std::max(count, w.line + 1); + return count; +} + +LyricsParseResult +parseLrc(const QByteArray &bytes) +{ + LyricsParseResult result; + + if (bytes.isEmpty()) { + result.error = tr("The file is empty."); + return result; + } + if (bytes.size() > Lyrics::maxFileBytes) { + result.error = tr("The file is over 1 MB, too big to be an LRC file."); + return result; + } + + QString text = decoded(bytes, result.warnings); + text.replace(QLatin1String("\r\n"), QLatin1String("\n")); + text.replace(QLatin1Char('\r'), QLatin1Char('\n')); + + Lyrics &lyrics = result.lyrics; + QVector lines; + Ms offset = 0; + int unrecognised = 0; + int backwards = 0; + + const QStringList rows = text.split(QLatin1Char('\n')); + for (const QString &raw : rows) { + + const QString row = lyricsWithoutControls(raw).trimmed(); + if (row.isEmpty()) continue; + + QVector stamps; + int pos = 0; + Ms stamp = 0; + while (int length = readTimeTag(row, pos, '[', ']', stamp)) { + stamps.push_back(stamp); + pos += length; + } + + if (!stamps.isEmpty()) { + TimedLine line = readLineText(row.mid(pos), stamps[0], backwards); + if (line.hasWordTags) lyrics.wordTimed = true; + // [00:12.00][01:30.00]chorus: the line again at each stamp, + // its word tags moving with it + for (Ms s : stamps) { + lines.push_back(shifted(line, s - stamps[0])); + } + continue; + } + + QString key, value; + if (readMetadata(row, key, value)) { + if (key == QLatin1String("offset")) { + bool ok = false; + int ms = value.toInt(&ok); + if (ok) { + offset = ms; + } else { + result.warnings << tr("The offset \"%1\" is not a whole " + "number of milliseconds and was " + "ignored.").arg(lyricsLabel(value)); + } + } else if (key == QLatin1String("ti")) { + lyrics.title = lyricsLabel(value); + } else if (key == QLatin1String("ar")) { + lyrics.artist = lyricsLabel(value); + } + continue; + } + + ++unrecognised; + } + + // The exporter does not promise stamps in order, and it clamps + // negative times to 0, so that several lines can share stamp 0: + // those stay in file order + std::stable_sort(lines.begin(), lines.end(), + [](const TimedLine &a, const TimedLine &b) { + return a.stamp < b.stamp; + }); + + inferEnds(lines); + + // A positive offset makes the lyrics appear sooner, as LRC has it + const Ms shift = -offset; + + int dropped = 0; + int lineIndex = 0; + for (const TimedLine &line : lines) { + bool any = false; + for (const Entry &e : line.entries) { + if (e.start + shift < 0) { + ++dropped; + continue; + } + LyricWord word; + word.start = double(e.start + shift) / msPerSecond; + word.end = double(e.end + shift) / msPerSecond; + word.text = e.text; + word.line = lineIndex; + word.endGiven = e.endGiven; + lyrics.words.push_back(word); + any = true; + } + if (any) ++lineIndex; + } + + std::stable_sort(lyrics.words.begin(), lyrics.words.end(), + [](const LyricWord &a, const LyricWord &b) { + return a.start < b.start; + }); + + if (unrecognised > 0) { + result.warnings << counted + (unrecognised, + "1 line was not LRC and was skipped.", + "%1 lines were not LRC and were skipped."); + } + if (backwards > 0) { + result.warnings << counted + (backwards, + "1 word tag went back in time and was moved up to the word " + "before it.", + "%1 word tags went back in time and were moved up to the " + "word before them."); + } + if (dropped > 0) { + result.warnings << counted + (dropped, + "1 word came before the start of the song after the offset " + "and was dropped.", + "%1 words came before the start of the song after the offset " + "and were dropped."); + } + + if (lyrics.words.isEmpty()) { + result.lyrics = Lyrics(); + result.error = tr("No timed lyrics were found: this is not an LRC " + "file, or it has no timed lines."); + } + + return result; +} + +LyricsParseResult +parseLyrics(const QByteArray &bytes) +{ + // UTF-16 and UTF-32 have to be decoded to be looked at; without a + // BOM, or with UTF-8's, whatever the encoding, the blanks and the + // '<' are single ASCII bytes, as Latin-1 reads them. The decoder + // drops the BOM. + std::optional bom = + QStringConverter::encodingForData(bytes); + QString text; + if (bom) { + text = QStringDecoder(*bom).decode(bytes); + } else { + text = QString::fromLatin1(bytes); + } + + for (QChar c : text) { + if (c.isSpace()) continue; + if (c == QLatin1Char('<')) return parseTtml(bytes); + break; + } + return parseLrc(bytes); +} + +EventVector +lyricsToEvents(const Lyrics &lyrics, sv_samplerate_t rate) +{ + EventVector events; + for (const LyricWord &w : lyrics.words) { + sv_frame_t frame = sv_frame_t(std::llround(w.start * rate)); + sv_frame_t end = sv_frame_t(std::llround(w.end * rate)); + sv_frame_t duration = std::max(sv_frame_t(1), end - frame); + // Two words at the same frame are two events: a model keeps + // both, whether or not their labels differ + events.push_back(Event(frame, float(w.line), duration, w.text)); + } + std::sort(events.begin(), events.end()); + return events; +} + +Lyrics +lyricsFromEvents(const EventVector &events, sv_samplerate_t rate) +{ + Lyrics lyrics; + if (rate <= 0) return lyrics; + + // In time order, then in line order, as the parser leaves them. + // Words of one line at one frame go as a model orders them, + // shortest first: all but the last of them end at that frame, so + // this is the parser's order too, but for a tie. The order the + // events come in makes no difference. + EventVector sorted(events); + std::sort(sorted.begin(), sorted.end(), + [](const Event &a, const Event &b) { + if (a.getFrame() != b.getFrame()) { + return a.getFrame() < b.getFrame(); + } + if (a.getValue() != b.getValue()) { + return a.getValue() < b.getValue(); + } + return a < b; + }); + + for (const Event &e : sorted) { + LyricWord word; + word.start = double(e.getFrame()) / rate; + word.end = double(e.getFrame() + e.getDuration()) / rate; + word.text = e.getLabel(); + word.line = int(std::lround(e.getValue())); + lyrics.words.push_back(word); + } + return lyrics; +} diff --git a/main/Lyrics.h b/main/Lyrics.h new file mode 100644 index 00000000..06bb0d12 --- /dev/null +++ b/main/Lyrics.h @@ -0,0 +1,151 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TONY_LYRICS_H +#define TONY_LYRICS_H + +#include "base/BaseTypes.h" +#include "base/Event.h" + +#include +#include +#include +#include + +/** + * Timed lyrics read from an LRC or TTML file, and the events of the + * region model that holds them in a session: one region per word (or + * per line, for a file that times only lines), with the line's index + * as its value. + * + * These are pure functions over bytes and event lists: they touch no + * model, so they can be tested without a window (TestLyrics, + * TestLyricsTtml). + */ + +/** + * One word of the lyrics, or a whole line when the file times only + * lines. + */ +struct LyricWord +{ + /// Seconds on the reference's timeline, after the file's [offset:] + double start = 0.0; + + /// Seconds; never before start, but it can be start itself, for a + /// word the next one starts at the same time as. Conversion gives + /// such a word one frame + double end = 0.0; + + /// One word, or a whole line + QString text; + + /// 0-based index of the line, in time order + int line = 0; + + /// The file gave the end; false if it was inferred + bool endGiven = false; +}; + +struct Lyrics +{ + /// Sorted by start + QVector words; + + /// From [ti:] and [ar:], or TTML's , for the layer's name + QString title; + QString artist; + + /// Any word tags, or TTML s with times, were seen + bool wordTimed = false; + + bool isEmpty() const { return words.isEmpty(); } + + /// How many lines have words: the lines are numbered from 0 up + int lineCount() const; + + /** + * How long a word lasts, at most, when the file does not say + * where it ends: the next line's start, or the end of the file, + * would otherwise draw its bar through an instrumental break. + */ + static constexpr double inferredWordSeconds = 2.0; + + /// How long the last line lasts in a file that times only lines + static constexpr double inferredLastLineSeconds = 5.0; + + /// A longer word or line is cut to this many characters + static constexpr int maxLabelLength = 200; + + /// Lyrics files are a few kB; a bigger file is not read at all + static constexpr qint64 maxFileBytes = 1024 * 1024; +}; + +struct LyricsParseResult +{ + Lyrics lyrics; + + /// Non-empty if there is nothing usable (not LRC or TTML, no timed + /// lines, too big); lyrics is then empty + QString error; + + /// Short sentences on what was skipped or changed, for the status bar + QStringList warnings; +}; + +/** + * Read an LRC file, with line timing ([mm:ss.xx]text) or word timing + * ([mm:ss.xx]word word ...). + */ +LyricsParseResult parseLrc(const QByteArray &bytes); + +/** + * Read a lyrics file of either kind: TTML (parseTtml(), in + * LyricsTtml.h) if its first character that is not blank, after a + * byte order mark, is '<', else LRC. + */ +LyricsParseResult parseLyrics(const QByteArray &bytes); + +/** + * The text without the characters a session file cannot hold: XML 1.0 + * has no C0 controls other than tab, nor U+FFFE and U+FFFF, which a + * UTF-8 decoder lets through, and one in a label would make the session + * unreadable. DEL is never meant as text either. A tab becomes a + * space, which is what it comes back as from a session file anyway: an + * XML attribute value is read with its tabs as spaces. + */ +QString lyricsWithoutControls(const QString &text); + +/** + * The text as a word's label is shown and saved: without controls, + * trimmed, and cut to Lyrics::maxLabelLength characters. Every parser + * makes its labels with this. + */ +QString lyricsLabel(const QString &text); + +/** + * The events of a region model holding the lyrics: frame = start, + * duration = end - start (at least 1 frame), value = line, label = + * text. Sorted as a model holds them, so that the two compare equal. + */ +sv::EventVector lyricsToEvents(const Lyrics &lyrics, sv::sv_samplerate_t rate); + +/** + * The words of those events, back again. The events keep only the + * words: title, artist, wordTimed and each endGiven are left empty + * and false. + */ +Lyrics lyricsFromEvents(const sv::EventVector &events, sv::sv_samplerate_t rate); + +#endif diff --git a/main/LyricsEdit.cpp b/main/LyricsEdit.cpp new file mode 100644 index 00000000..dacb2829 --- /dev/null +++ b/main/LyricsEdit.cpp @@ -0,0 +1,332 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#include "LyricsEdit.h" + +#include "Lyrics.h" + +#include +#include + +using namespace sv; + +namespace { + +// One past the box's last column. A word too short to reach the next +// column is still painted one column wide, and can be hit there +int columnAfter(const LyricsEdit::Box &box) +{ + return std::max(box.x1, box.x0 + 1); +} + +sv_frame_t endOf(const Event &word) +{ + return word.getFrame() + word.getDuration(); +} + +bool isWord(const EventVector &words, int word) +{ + return word >= 0 && word < int(words.size()); +} + +} // namespace + +sv_frame_t +LyricsEdit::framesFor(double seconds, sv_samplerate_t rate) +{ + if (rate <= 0) return 0; + return sv_frame_t(std::llround(seconds * rate)); +} + +sv_frame_t +LyricsEdit::minWordFrames(sv_samplerate_t rate) +{ + return framesFor(minWordSeconds, rate); +} + +sv_frame_t +LyricsEdit::newWordFrames(sv_samplerate_t rate) +{ + return framesFor(newWordSeconds, rate); +} + +LyricsEdit::Boxes +LyricsEdit::boxesFor(const EventVector &words, + const std::function &xForFrame) +{ + Boxes boxes; + boxes.reserve(words.size()); + for (const Event &w : words) { + boxes.push_back({ xForFrame(w.getFrame()), xForFrame(endOf(w)) }); + } + return boxes; +} + +int +LyricsEdit::wordAt(const Boxes &boxes, int x) +{ + int found = -1; + for (int i = 0; i < int(boxes.size()); ++i) { + const Box &box = boxes[i]; + if (x < box.x0 || x >= columnAfter(box)) continue; + // Later boxes are painted over earlier ones + if (found < 0 || box.x0 >= boxes[found].x0) found = i; + } + return found; +} + +LyricsEdit::Hit +LyricsEdit::hitTest(const Boxes &boxes, int x, int grab) +{ + Hit hit; + + int inside = wordAt(boxes, x); + if (inside >= 0) { + const Box &box = boxes[inside]; + int toStart = x - box.x0; + int toEnd = (columnAfter(box) - 1) - x; + bool onStart = (toStart < grab); + bool onEnd = (toEnd < grab); + hit.word = inside; + if (onStart && (!onEnd || toStart < toEnd)) { + hit.part = Part::Start; + } else if (onEnd) { + hit.part = Part::End; + } else { + hit.part = Part::Inside; + } + return hit; + } + + // In no box, every box is wholly to one side of the pointer: the + // edges that face it are the latest end to its left and the + // earliest start to its right + bool haveLeft = false, haveRight = false; + int leftEnd = 0, rightStart = 0; + for (const Box &box : boxes) { + int after = columnAfter(box); + if (after <= x) { + if (!haveLeft || after > leftEnd) leftEnd = after; + haveLeft = true; + } else { + if (!haveRight || box.x0 < rightStart) rightStart = box.x0; + haveRight = true; + } + } + + int toEnd = x - leftEnd; + int toStart = (rightStart - 1) - x; + bool onEnd = (haveLeft && toEnd < grab); + bool onStart = (haveRight && toStart < grab); + + // The word each edge belongs to is the one a pointer just inside + // it would find, so an edge is the same word's from either side + if (onEnd && (!onStart || toEnd <= toStart)) { + hit.word = wordAt(boxes, leftEnd - 1); + hit.part = Part::End; + } else if (onStart) { + hit.word = wordAt(boxes, rightStart); + hit.part = Part::Start; + } + return hit; +} + +sv_frame_t +LyricsEdit::clampStart(const EventVector &words, int word, + sv_frame_t wanted, sv_samplerate_t rate) +{ + if (!isWord(words, word)) return wanted; + + const sv_frame_t start = words[word].getFrame(); + const sv_frame_t end = endOf(words[word]); + + // Back to the latest end at or before the start; not back at all + // where an earlier word already overlaps it, since that would make + // the overlap longer + sv_frame_t lowest = 0; + for (int i = 0; i < int(words.size()); ++i) { + if (i == word) continue; + const Event &other = words[i]; + if (endOf(other) <= start) { + lowest = std::max(lowest, endOf(other)); + } else if (other.getFrame() < start) { + lowest = start; + } + } + // Every limit is where the start already is or before it, so the + // word can only move the way it is dragged. (A start before frame + // 0, which no parser makes, stays where it is.) + lowest = std::min(lowest, start); + + // Forward to 20 ms before the end, or nowhere for a word that is + // already shorter + const sv_frame_t highest = std::max(start, end - minWordFrames(rate)); + + return std::clamp(wanted, lowest, highest); +} + +sv_frame_t +LyricsEdit::clampEnd(const EventVector &words, int word, + sv_frame_t wanted, sv_samplerate_t rate) +{ + if (!isWord(words, word)) return wanted; + + const sv_frame_t start = words[word].getFrame(); + const sv_frame_t end = endOf(words[word]); + + // Back to 20 ms after the start, or nowhere for a word that is + // already shorter + const sv_frame_t lowest = std::min(end, start + minWordFrames(rate)); + + // Forward to the earliest start at or after the end; not forward at + // all where another word already reaches past the end. Nothing + // after the last word + bool limited = false; + sv_frame_t highest = end; + for (int i = 0; i < int(words.size()); ++i) { + if (i == word) continue; + const Event &other = words[i]; + sv_frame_t limit; + if (other.getFrame() >= end) { + limit = other.getFrame(); + } else if (endOf(other) > end) { + limit = end; + } else { + continue; + } + if (!limited || limit < highest) highest = limit; + limited = true; + } + + if (wanted < lowest) return lowest; + if (limited && wanted > highest) return highest; + return wanted; +} + +Event +LyricsEdit::startDraggedTo(const EventVector &words, int word, + sv_frame_t wanted, sv_samplerate_t rate) +{ + if (!isWord(words, word)) return Event(); + const Event &w = words[word]; + sv_frame_t start = clampStart(words, word, wanted, rate); + return w.withFrame(start).withDuration(endOf(w) - start); +} + +Event +LyricsEdit::endDraggedTo(const EventVector &words, int word, + sv_frame_t wanted, sv_samplerate_t rate) +{ + if (!isWord(words, word)) return Event(); + const Event &w = words[word]; + sv_frame_t end = clampEnd(words, word, wanted, rate); + return w.withDuration(end - w.getFrame()); +} + +bool +LyricsEdit::newWordSpan(const EventVector &words, sv_frame_t frame, + sv_samplerate_t rate, Span &span) +{ + if (rate <= 0 || frame < 0) return false; + + // The gap the frame is in, if it is in one + sv_frame_t gapStart = 0; + bool haveGapEnd = false; + sv_frame_t gapEnd = 0; + for (const Event &w : words) { + if (endOf(w) <= frame) { + gapStart = std::max(gapStart, endOf(w)); + } else if (w.getFrame() <= frame) { + return false; + } else if (!haveGapEnd || w.getFrame() < gapEnd) { + gapEnd = w.getFrame(); + haveGapEnd = true; + } + } + + sv_frame_t length = newWordFrames(rate); + sv_frame_t start = frame; + + if (haveGapEnd) { + sv_frame_t room = gapEnd - gapStart; + if (room < minWordFrames(rate)) return false; + // Up to the next word, and back from the click into the gap + // as far as that makes the word longer + length = std::min(length, room); + start = std::min(frame, gapEnd - length); + } + + span.start = start; + span.end = start + length; + return true; +} + +float +LyricsEdit::newWordLine(const EventVector &words, const Span &span) +{ + int before = -1, after = -1; + for (int i = 0; i < int(words.size()); ++i) { + const Event &w = words[i]; + if (endOf(w) <= span.start) { + if (before < 0 || endOf(w) >= endOf(words[before])) before = i; + } else if (w.getFrame() >= span.end) { + if (after < 0 || w.getFrame() < words[after].getFrame()) after = i; + } + } + + if (before < 0 && after < 0) return 0.f; + if (after < 0) return words[before].getValue(); + if (before < 0) return words[after].getValue(); + + // A word more often continues the line it follows than starts one + sv_frame_t gapBefore = span.start - endOf(words[before]); + sv_frame_t gapAfter = words[after].getFrame() - span.end; + return (gapBefore <= gapAfter ? + words[before].getValue() : words[after].getValue()); +} + +QString +LyricsEdit::cleanText(const QString &typed) +{ + return lyricsLabel(typed); +} + +sv_frame_t +LyricsEdit::clampShift(const EventVector &words, sv_frame_t wanted) +{ + if (words.empty()) return 0; + + // The earliest start, wherever it is in the vector: the order of the + // words is not relied on + sv_frame_t earliest = words[0].getFrame(); + for (const Event &w : words) earliest = std::min(earliest, w.getFrame()); + + // Back as far as frame 0, and not back at all for words that are + // before it already, so the answer is between 0 and wanted + const sv_frame_t lowest = std::min(sv_frame_t(0), -earliest); + return std::max(wanted, lowest); +} + +EventVector +LyricsEdit::shifted(const EventVector &words, sv_frame_t wanted) +{ + const sv_frame_t by = clampShift(words, wanted); + + // One amount for all keeps every word's place among the others, and + // so the order the model sorts them in + EventVector moved; + moved.reserve(words.size()); + for (const Event &w : words) moved.push_back(w.withFrame(w.getFrame() + by)); + return moved; +} diff --git a/main/LyricsEdit.h b/main/LyricsEdit.h new file mode 100644 index 00000000..acf8ece2 --- /dev/null +++ b/main/LyricsEdit.h @@ -0,0 +1,240 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TONY_LYRICS_EDIT_H +#define TONY_LYRICS_EDIT_H + +#include "base/BaseTypes.h" +#include "base/Event.h" + +#include + +#include +#include + +/** + * What editing a word of the lyrics may do: which edge the pointer is + * on, how far an edge can be dragged, where a new word goes and which + * line it joins, what a typed text becomes, and how far all the words + * can be shifted together. + * + * The words are the events of the lyrics' region model, in the order + * the model gives them (by start): a word's start is its frame and its + * end is frame + duration. Pure functions over those values, pixel + * positions and strings: no window and no model, so they are tested + * without either (TestLyricsEdit). The editor asks them and does as + * they say. + */ +namespace LyricsEdit +{ + /// No edit makes a word shorter than this (one already shorter + /// may stay so, but gets no shorter) + constexpr double minWordSeconds = 0.020; + + /// How long Add Word makes a word, where there is room for it + constexpr double newWordSeconds = 0.5; + + /** + * Seconds in frames at this rate, to the nearest frame, as + * lyricsToEvents() rounds times; negative seconds give negative + * frames. 0 for a rate that is not positive. + */ + sv::sv_frame_t framesFor(double seconds, sv::sv_samplerate_t rate); + + /** + * The two above in frames at this rate, as framesFor() gives them: + * 882 and 22050 frames at 44.1 kHz. + */ + sv::sv_frame_t minWordFrames(sv::sv_samplerate_t rate); + sv::sv_frame_t newWordFrames(sv::sv_samplerate_t rate); + + /** + * A word's box in the box row: the pixel columns x0 to x1 - 1 of + * the view the pointer is in. A box whose x1 is not past its x0 is + * painted, and hit, as the one column x0. + */ + struct Box { + int x0; + int x1; + }; + typedef std::vector Boxes; + + /** + * The boxes of the words, in their order: from the x of the word's + * start to the x of its end, as the lyrics layer paints them. + * xForFrame is the view's getXForFrame(). + */ + Boxes boxesFor(const sv::EventVector &words, + const std::function &xForFrame); + + /** + * The word whose box holds the column x, or -1 for none. Where + * boxes overlap, the one that starts latest, and of those starting + * in one column the last in order: the one painted on top. + */ + int wordAt(const Boxes &boxes, int x); + + enum class Part { + Nothing, ///< Not on a word nor near an edge + Start, ///< On the word's start edge + End, ///< On the word's end edge + Inside ///< In the word's box, away from its edges + }; + + struct Hit { + /// An index into the boxes, and so into the words they were + /// made from; -1 with Nothing + int word = -1; + Part part = Part::Nothing; + + bool isEdge() const { return part == Part::Start || part == Part::End; } + }; + + /** + * What the pointer at column x is on, with grab columns on each + * side of an edge. + * + * An edge is the line between two columns: a start is the line to + * the left of column x0, an end the line to the right of column + * x1 - 1. The pointer is on an edge if it is within grab columns + * of it, on either side: + * + * - In a box (wordAt()): its start if x - x0 < grab, its end if + * (x1 - 1) - x < grab, the nearer of the two if both (a narrow + * box), and the end if they are equally near; else Inside. + * - In no box: the end of the box to the left, if x - x1 < grab for + * the latest x1 at or before x; the start of the box to the + * right, if (x0 - 1) - x < grab for the earliest x0 after x; the + * nearer if both, and the end if they are equally near; else + * Nothing. Where several boxes have their edge there, the word + * is the one wordAt() gives in the column just inside it. + * + * So at an edge two words share, the pointer's side of it picks + * the word: the column to its left is the earlier word's end, the + * column to its right the later word's start. A grab of 0 finds + * no edges at all. + */ + Hit hitTest(const Boxes &boxes, int x, int grab); + + /** + * Where the start of words[word] goes when it is dragged to + * wanted. Pass the words as they were when the drag began, and + * the same ones at every move: the limits come from where the word + * was then, so a drag back puts it back. + * + * - Not before the end of a word that ends at or before its start + * (the previous word's, when words do not overlap), nor before + * frame 0. Where another word, starting earlier, already + * reaches past its start, the start does not move back at all: + * an overlap from a file may stay but does not grow. + * - Not later than 20 ms before its end; a word already shorter + * than 20 ms does not get shorter. + * + * The answer is always between where the start was and wanted, so + * a word never jumps, and its length is never negative. + */ + sv::sv_frame_t clampStart(const sv::EventVector &words, int word, + sv::sv_frame_t wanted, + sv::sv_samplerate_t rate); + + /** + * The end, likewise: not past the start of a word that starts at or + * after its end (the next word's), and not at all where another + * word, starting before its end, already reaches past it; not + * earlier than 20 ms after its start, or where it is for a word + * already shorter. After the last word there is no limit. + */ + sv::sv_frame_t clampEnd(const sv::EventVector &words, int word, + sv::sv_frame_t wanted, + sv::sv_samplerate_t rate); + + /** + * words[word] with its start dragged to wanted, as clampStart() + * allows, and its end where it was. Its text and line are kept. + */ + sv::Event startDraggedTo(const sv::EventVector &words, int word, + sv::sv_frame_t wanted, + sv::sv_samplerate_t rate); + + /// words[word] with its end dragged to wanted, as clampEnd() allows + sv::Event endDraggedTo(const sv::EventVector &words, int word, + sv::sv_frame_t wanted, + sv::sv_samplerate_t rate); + + struct Span { + sv::sv_frame_t start = 0; + sv::sv_frame_t end = 0; + + sv::sv_frame_t duration() const { return end - start; } + }; + + /** + * Where Add Word puts a word for a click at frame: from the frame, + * 0.5 s long, or up to the next word if that is nearer, moved back + * into the gap as far as that makes it longer (at most 0.5 s). + * The gap runs from the latest end at or before the frame (or + * frame 0) to the earliest start after it; after the last word it + * has no end. + * + * False, leaving span alone, if there is no room: the frame is in + * a word (a word holds its start frame but not its end frame, so a + * click exactly at a word's end is in the gap after it and the new + * word starts there), before frame 0, or in a gap shorter than + * 20 ms. + */ + bool newWordSpan(const sv::EventVector &words, sv::sv_frame_t frame, + sv::sv_samplerate_t rate, Span &span); + + /** + * The line (the event value) of a new word over span: that of the + * nearer of its neighbours, by the gap between them, and of the + * word before it if the gaps are equal; 0 with no words. The word + * before is the one ending latest at or before the span's start, + * the word after the one starting earliest at or after its end + * (the last and first in order of those, if several); a word + * overlapping the span, which newWordSpan() never gives, is + * neither. + */ + float newWordLine(const sv::EventVector &words, const Span &span); + + /** + * A typed text as a word's label: cleaned as the parsers clean + * theirs (lyricsLabel(): controls out, tab to space, trimmed, at + * most Lyrics::maxLabelLength characters). Empty if nothing is + * left, which the edit must refuse. + */ + QString cleanText(const QString &typed); + + /** + * How far all the words may move together, for a shift of wanted + * frames (negative: earlier): as far as wanted, except that the + * word starting earliest does not go back past frame 0. Words that + * already start before frame 0, which no parser makes, do not go + * back at all. No limit later. 0 with no words. + * + * The answer is always between 0 and wanted. + */ + sv::sv_frame_t clampShift(const sv::EventVector &words, + sv::sv_frame_t wanted); + + /** + * The words, every one moved by clampShift(words, wanted), start + * and end alike: lengths, texts and lines are kept, and so is their + * order. + */ + sv::EventVector shifted(const sv::EventVector &words, + sv::sv_frame_t wanted); +} + +#endif diff --git a/main/LyricsEditor.cpp b/main/LyricsEditor.cpp new file mode 100644 index 00000000..432f4856 --- /dev/null +++ b/main/LyricsEditor.cpp @@ -0,0 +1,759 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#include "LyricsEditor.h" + +#include "LyricsTrack.h" + +#include "view/Pane.h" +#include "layer/RegionLayer.h" +#include "data/model/RegionModel.h" +#include "data/model/EventCommands.h" +#include "widgets/CommandHistory.h" + +#include +#include +#include +#include + +#include + +using namespace sv; + +namespace { + +// An edit that is done already, as the command was filled, goes on the +// history as it is. CommandHistory marks the session modified +void +pushDone(ChangeEventsCommand *command) +{ + command = command->finish(); + if (command) { + CommandHistory::getInstance()->addCommand(command, false); + } +} + +// Straight in the model, with no command: all out, then all in, so that +// no word put in meets one of those still to come out +void +replaceWords(RegionModel *model, const EventVector &from, + const EventVector &to) +{ + for (const Event &e : from) model->remove(e); + for (const Event &e : to) model->add(e); +} + +} + +LyricsEditor::LyricsEditor(LyricsTrack *lyrics, QObject *parent) : + QObject(parent), + m_lyrics(lyrics), + m_enabled(false), + m_dragging(false), + m_shifting(false), + m_dragWord(-1), + m_dragPart(LyricsEdit::Part::Nothing), + m_dragEdgeFrame(0), + m_dragPressFrame(0), + m_dragCommand(nullptr), + m_cursorSet(false), + m_cursorShape(Qt::ArrowCursor), + m_helpShown(false) +{ +} + +LyricsEditor::~LyricsEditor() +{ + // Nothing is pushed from here: the history may be going as well + abandonDrag(); + m_dragging = false; + m_shifting = false; + restoreCursor(); + if (m_pane) m_pane->removeEventFilter(this); +} + +void +LyricsEditor::setEnabled(bool enabled) +{ + if (enabled == m_enabled) return; + + if (enabled) { + Pane *pane = m_lyrics ? m_lyrics->getPane() : nullptr; + if (!pane || !m_lyrics->getLayer()) return; + m_pane = pane; + m_enabled = true; + pane->installEventFilter(this); + return; + } + + // Off before anything else, so that whatever the push of the drag's + // command sets off finds edit mode off already, and does not come + // back here half way through + m_enabled = false; + finishDrag(); + restoreCursor(); + clearHelp(); + if (m_pane) m_pane->removeEventFilter(this); + m_pane = nullptr; +} + +RegionLayer * +LyricsEditor::currentLayer() const +{ + if (!m_pane || !m_lyrics || m_lyrics->getPane() != m_pane) return nullptr; + return m_lyrics->getLayer(); +} + +std::shared_ptr +LyricsEditor::dragModel() const +{ + RegionLayer *layer = currentLayer(); + if (!layer || m_dragModel.isNone() || layer->getModel() != m_dragModel) { + return {}; + } + return ModelById::getAs(m_dragModel); +} + +std::shared_ptr +LyricsEditor::editedModel(ModelId id) const +{ + // Edit mode gone off, or other lyrics than the ones the edit began + // in: an import or a new session in the meantime + RegionLayer *layer = currentLayer(); + if (!m_enabled || !layer || id.isNone() || layer->getModel() != id) { + return {}; + } + return ModelById::getAs(id); +} + +bool +LyricsEditor::wordsAt(QPoint pos, EventVector &words, + LyricsEdit::Boxes &boxes) const +{ + // Hidden lyrics keep the box row they were last painted with, which + // is not there to be clicked + RegionLayer *layer = currentLayer(); + if (!layer || !m_lyrics->isVisible()) return false; + + // As last painted, in the pane's own coordinates, which are the + // mouse's. Empty until the layer has been painted in the pane + QRect row = layer->getLyricsBoxRow(m_pane); + if (!row.contains(pos)) return false; + + auto model = ModelById::getAs(layer->getModel()); + if (!model) return false; + + words = model->getAllEvents(); + Pane *pane = m_pane; + boxes = LyricsEdit::boxesFor + (words, [pane](sv_frame_t frame) { return pane->getXForFrame(frame); }); + return true; +} + +bool +LyricsEditor::hitAt(QPoint pos, LyricsEdit::Hit &hit, + EventVector &words) const +{ + LyricsEdit::Boxes boxes; + if (!wordsAt(pos, words, boxes)) return false; + hit = LyricsEdit::hitTest(boxes, pos.x(), grabPixels); + return true; +} + +std::vector +LyricsEditor::menuEntriesAt(QPoint pos) const +{ + std::vector entries; + if (!m_enabled || m_dragging) return entries; + + EventVector words; + LyricsEdit::Boxes boxes; + if (!wordsAt(pos, words, boxes)) return entries; + + ModelId modelId = currentLayer()->getModel(); + auto model = ModelById::getAs(modelId); + if (!model) return entries; + + // On a word, anywhere in its box, near its edges too: the menu is for + // the word the pointer is on + int word = LyricsEdit::wordAt(boxes, pos.x()); + if (word >= 0) { + MenuEntry edit; + edit.text = tr("Edit Word Text..."); + edit.enabled = true; + edit.operation = MenuEntry::Operation::EditText; + edit.model = modelId; + edit.word = words[word]; + entries.push_back(edit); + + MenuEntry remove = edit; + remove.text = tr("Delete Word"); + remove.operation = MenuEntry::Operation::DeleteWord; + entries.push_back(remove); + return entries; + } + + // Between words, at the last frame the pointer's column shows. Its + // first can be inside the word before: that word's end is somewhere + // in the column just after its box + sv_frame_t frame = std::max(m_pane->getFrameForX(pos.x()), + m_pane->getFrameForX(pos.x() + 1) - 1); + LyricsEdit::Span span; + + MenuEntry add; + add.text = tr("Add Word..."); + add.enabled = LyricsEdit::newWordSpan(words, frame, + model->getSampleRate(), span); + add.operation = MenuEntry::Operation::AddWord; + add.model = modelId; + add.frame = frame; + entries.push_back(add); + return entries; +} + +void +LyricsEditor::choose(const MenuEntry &entry) +{ + if (!entry.enabled || m_dragging) return; + + switch (entry.operation) { + case MenuEntry::Operation::EditText: + editText(entry.model, entry.word); + break; + case MenuEntry::Operation::DeleteWord: + deleteWord(entry.model, entry.word); + break; + case MenuEntry::Operation::AddWord: + addWord(entry.model, entry.frame); + break; + } +} + +void +LyricsEditor::editText(ModelId modelId, Event word) +{ + if (!m_askText) return; + { + auto model = editedModel(modelId); + if (!model || !model->containsEvent(word)) return; + } + + QString text = word.getLabel(); + if (!m_askText(text, false)) return; + + // The question had an event loop of its own, and the lyrics may have + // gone, or the word been changed, meanwhile: then this edit is of + // something that is not there + auto model = editedModel(modelId); + if (!model || !model->containsEvent(word)) return; + + // Nothing left of the text is refused, and the word stays as it was: + // Delete Word is for deleting it + QString cleaned = LyricsEdit::cleanText(text); + if (cleaned == "" || cleaned == word.getLabel()) return; + + auto command = new ChangeEventsCommand + (modelId.untyped, tr("Change Word Text")); + command->remove(word); + command->add(word.withLabel(cleaned)); + pushDone(command); +} + +void +LyricsEditor::deleteWord(ModelId modelId, Event word) +{ + auto model = editedModel(modelId); + if (!model || !model->containsEvent(word)) return; + + auto command = new ChangeEventsCommand + (modelId.untyped, tr("Delete Word")); + command->remove(word); + pushDone(command); +} + +void +LyricsEditor::addWord(ModelId modelId, sv_frame_t frame) +{ + if (!m_askText) return; + { + auto model = editedModel(modelId); + LyricsEdit::Span span; + if (!model || + !LyricsEdit::newWordSpan(model->getAllEvents(), frame, + model->getSampleRate(), span)) { + return; + } + } + + QString text; + if (!m_askText(text, true)) return; + QString cleaned = LyricsEdit::cleanText(text); + if (cleaned == "") return; + + // Where the word goes, and the line it joins, from the words as they + // are after the question, which may not be as they were before it + auto model = editedModel(modelId); + if (!model) return; + EventVector words = model->getAllEvents(); + LyricsEdit::Span span; + if (!LyricsEdit::newWordSpan(words, frame, model->getSampleRate(), span)) { + return; + } + float line = LyricsEdit::newWordLine(words, span); + + // The layer draws the word bold if it is the first of its line now + auto command = new ChangeEventsCommand + (modelId.untyped, tr("Add Word")); + command->add(Event(span.start, line, span.duration(), cleaned)); + pushDone(command); +} + +bool +LyricsEditor::showMenu(QPoint pos) +{ + std::vector entries = menuEntriesAt(pos); + if (entries.empty()) return false; + + // As the pane does for its own menu + clearHelp(); + + // Shown and left to run by itself, as Tony's own menu is: the entry + // chosen is done from the menu's event handling, and its question + // is not asked from inside the pane's. Deleted once it is hidden, + // after the entry chosen is done + QMenu *menu = new QMenu(m_pane); + connect(menu, &QMenu::aboutToHide, menu, &QObject::deleteLater); + for (const MenuEntry &entry : entries) { + QAction *action = menu->addAction(entry.text); + action->setEnabled(entry.enabled); + connect(action, &QAction::triggered, this, + [this, entry]() { choose(entry); }); + } + menu->popup(m_pane->mapToGlobal(pos)); + return true; +} + +bool +LyricsEditor::eventFilter(QObject *object, QEvent *event) +{ + if (!m_enabled || !m_pane || object != m_pane) return false; + + switch (event->type()) { + + case QEvent::MouseButtonPress: + case QEvent::MouseButtonDblClick: + // A double-click comes in place of the second press: on an edge, + // that is where a drag begins as well + return mousePressed(static_cast(event)); + + case QEvent::MouseMove: + return mouseMoved(static_cast(event)); + + case QEvent::MouseButtonRelease: + return mouseReleased(static_cast(event)); + + case QEvent::Leave: + if (!m_dragging) leaveRow(); + return false; + + default: + return false; + } +} + +bool +LyricsEditor::mousePressed(QMouseEvent *e) +{ + // The pane never saw the press that began the drag, and must not see + // another button in the middle of it: a right press would open its + // menu with our button still held + if (m_dragging) return true; + + QPoint pos = e->position().toPoint(); + + // A right press in the row is for the words' menu, and never reaches + // the pane, which would open its own as well. Outside the row it is + // the pane's, and so is its menu + if (e->button() == Qt::RightButton) return showMenu(pos); + + if (e->button() != Qt::LeftButton) return false; + + LyricsEdit::Hit hit; + EventVector words; + if (!hitAt(pos, hit, words)) return false; + + RegionLayer *layer = currentLayer(); + if (!layer || layer->getModel().isNone()) return false; + + // Shift held: all the words, from anywhere in the row, an edge or a + // word or the space between. A double-click with Shift held is a + // press here as well, as on an edge + if ((e->modifiers() & Qt::ShiftModifier) && !words.empty()) { + m_dragging = true; + m_shifting = true; + m_dragModel = layer->getModel(); + m_dragWords = words; + m_shiftCurrent = words; + m_dragPressFrame = m_pane->getFrameForX(pos.x()); + m_dragCommand = nullptr; + setCursorShape(Qt::ClosedHandCursor); + return true; + } + + if (!hit.isEdge()) { + + // A double-click in a word, away from its edges, is for its + // text. The pane has had the first click of it, and moves the + // playback cursor there as for any click: it never sees the + // second, which would have called that off + if (e->type() == QEvent::MouseButtonDblClick && + hit.part == LyricsEdit::Part::Inside) { + editText(layer->getModel(), words[hit.word]); + return true; + } + return false; + } + + m_dragging = true; + m_shifting = false; + m_dragModel = layer->getModel(); + m_dragWords = words; + m_dragWord = hit.word; + m_dragPart = hit.part; + m_dragOriginal = words[hit.word]; + m_dragCurrent = m_dragOriginal; + m_dragEdgeFrame = m_dragOriginal.getFrame(); + if (m_dragPart == LyricsEdit::Part::End) { + m_dragEdgeFrame += m_dragOriginal.getDuration(); + } + m_dragPressFrame = m_pane->getFrameForX(pos.x()); + + // Nothing is made until the word moves: a click on an edge is no + // edit at all + m_dragCommand = nullptr; + + setCursorShape(Qt::SizeHorCursor); + return true; +} + +bool +LyricsEditor::mouseMoved(QMouseEvent *e) +{ + QPoint pos = e->position().toPoint(); + + if (m_dragging) { + if (e->buttons() & Qt::LeftButton) { + dragTo(pos.x()); + return true; + } + // The release went somewhere else, a dialog that opened in the + // middle of the drag perhaps: the drag ends where it got to + finishDrag(); + } + + // Someone else's drag, which began outside the row: the pane's + if (e->buttons() != Qt::NoButton) return false; + + return hover(pos); +} + +bool +LyricsEditor::mouseReleased(QMouseEvent *e) +{ + if (!m_dragging) return false; + + if (e->button() == Qt::LeftButton) { + QPoint pos = e->position().toPoint(); + dragTo(pos.x()); + finishDrag(); + hover(pos); + } + + // Any release in the middle of the drag is ours, as its press was + return true; +} + +bool +LyricsEditor::hover(QPoint pos) +{ + LyricsEdit::Hit hit; + EventVector words; + if (!hitAt(pos, hit, words)) { + leaveRow(); + return false; + } + + // Shift-drag moves all the words from anywhere in the row, which the + // help says wherever the pointer is + if (hit.isEdge()) { + setCursorShape(Qt::SizeHorCursor); + QString word = words[hit.word].getLabel(); + if (hit.part == LyricsEdit::Part::Start) { + showHelp(tr("Drag to move the start of \"%1\", " + "Shift-drag to move all the words").arg(word)); + } else { + showHelp(tr("Drag to move the end of \"%1\", " + "Shift-drag to move all the words").arg(word)); + } + } else if (hit.part == LyricsEdit::Part::Inside) { + restoreCursor(); + showHelp(tr("Double-click to change the text of \"%1\", " + "right-click to delete it, " + "Shift-drag to move all the words") + .arg(words[hit.word].getLabel())); + } else { + restoreCursor(); + showHelp(tr("Right-click to add a word, " + "drag a word's start or end to move it, " + "Shift-drag to move all the words")); + } + + // The row is ours while edit mode is on: the pane would only put its + // own help and cursor over ours + return true; +} + +void +LyricsEditor::leaveRow() +{ + // Cleared here: the pane's own help need not reach the status bar + // (Tony shows none for the reference's pane), and if it does, the + // pane gets the same event next and says what it has to say + restoreCursor(); + clearHelp(); +} + +void +LyricsEditor::dragTo(int x) +{ + if (!m_dragging || !m_pane || m_dragModel.isNone()) return; + + if (m_shifting) { + shiftTo(x); + return; + } + + // The model can change under a drag: a keyboard undo that takes the + // word away, or the lyrics removed or replaced. The word being + // dragged is then not what the drag last made it, and nothing it + // would do now is right. An undo that leaves the word as the drag + // made it goes unnoticed: see docs/open-points.md + auto model = dragModel(); + if (!model || !model->containsEvent(m_dragCurrent)) { + abandonDrag(); + return; + } + + // The edge moves as far as the pointer has, in time: the pointer + // need not have been exactly on it, and the view may scroll + sv_frame_t wanted = m_dragEdgeFrame + + (m_pane->getFrameForX(x) - m_dragPressFrame); + + sv_samplerate_t rate = model->getSampleRate(); + Event moved = (m_dragPart == LyricsEdit::Part::Start ? + LyricsEdit::startDraggedTo(m_dragWords, m_dragWord, + wanted, rate) : + LyricsEdit::endDraggedTo(m_dragWords, m_dragWord, + wanted, rate)); + if (moved == m_dragCurrent) return; + + // Each move takes the word out and puts the moved one in, and the + // command folds the two steps of each move before into this one: at + // the end it holds the original out and the last one in. The layer + // lays the words out again and repaints as the model changes + if (!m_dragCommand) { + m_dragCommand = new ChangeEventsCommand + (m_dragModel.untyped, + m_dragPart == LyricsEdit::Part::Start ? + tr("Move Word Start") : tr("Move Word End")); + } + m_dragCommand->remove(m_dragCurrent); + m_dragCurrent = moved; + m_dragCommand->add(m_dragCurrent); +} + +void +LyricsEditor::shiftTo(int x) +{ + // As for an edge: if anything but this drag has changed the words, + // nothing it would do now is right + auto model = dragModel(); + if (!model || model->getAllEvents() != m_shiftCurrent) { + abandonDrag(); + return; + } + + // As far as the pointer has moved, in time, from the words as they + // were at the press + sv_frame_t wanted = m_pane->getFrameForX(x) - m_dragPressFrame; + EventVector moved = LyricsEdit::shifted(m_dragWords, wanted); + if (moved == m_shiftCurrent) return; + + // Straight in the model, so that the words follow the pointer. A + // command filled at every move would keep every word's every step: + // ChangeEventsCommand folds an add and the remove of the same event + // only when they are next to each other, and all the words out and + // all in never are. The one command is made at the release + replaceWords(model.get(), m_shiftCurrent, moved); + m_shiftCurrent = moved; +} + +void +LyricsEditor::pushShift(ModelId modelId, const EventVector &from, + const EventVector &to) +{ + auto command = new ChangeEventsCommand + (modelId.untyped, tr("Shift Lyrics")); + for (const Event &e : from) command->remove(e); + for (const Event &e : to) command->add(e); + pushDone(command); +} + +double +LyricsEditor::shiftLyrics(ModelId modelId, double seconds) +{ + // A drag goes on the history before the shift, as it would if the + // button had been let go first + finishDrag(); + + RegionLayer *layer = m_lyrics ? m_lyrics->getLayer() : nullptr; + if (!layer || modelId.isNone() || layer->getModel() != modelId) { + return 0.0; + } + auto model = ModelById::getAs(modelId); + if (!model) return 0.0; + + sv_samplerate_t rate = model->getSampleRate(); + EventVector words = model->getAllEvents(); + sv_frame_t by = LyricsEdit::clampShift + (words, LyricsEdit::framesFor(seconds, rate)); + if (by == 0) return 0.0; + + pushShift(modelId, words, LyricsEdit::shifted(words, by)); + return double(by) / rate; +} + +void +LyricsEditor::finishDrag() +{ + if (!m_dragging) return; + m_dragging = false; + + if (m_shifting) { + m_shifting = false; + EventVector original = m_dragWords; + EventVector current = m_shiftCurrent; + m_dragWords.clear(); + m_shiftCurrent.clear(); + + // Changed under the drag, or dragged away and back: nothing to + // put on the history + auto model = dragModel(); + if (!model || model->getAllEvents() != current || + current == original) { + return; + } + + // The words back as they were, and then the one command that + // moves them, which holds the words at the press and the words + // at the release and nothing of the moves between + replaceWords(model.get(), current, original); + pushShift(m_dragModel, original, current); + return; + } + + ChangeEventsCommand *command = m_dragCommand; + m_dragCommand = nullptr; + m_dragWords.clear(); + if (!command) return; + + // As in dragTo(): if the model has changed under the drag, there is + // nothing right to put on the history + auto model = dragModel(); + if (!model || !model->containsEvent(m_dragCurrent)) { + delete command; + return; + } + + // Dragged away and back: the model holds the word as it was, and + // there is nothing to undo + if (m_dragCurrent == m_dragOriginal) { + delete command; + return; + } + + // Done already, as the drag went. CommandHistory marks the session + // modified + command = command->finish(); + if (command) { + CommandHistory::getInstance()->addCommand(command, false); + } +} + +void +LyricsEditor::abandonDrag() +{ + // Deleting a command does not touch the model: whatever the drag had + // done to it stays, as whatever changed it since left it + delete m_dragCommand; + m_dragCommand = nullptr; + m_dragWords.clear(); + m_shiftCurrent.clear(); + + // The rest of the drag, up to the release, moves nothing, and is + // still not the pane's: it never saw the press + m_dragModel = ModelId(); +} + +void +LyricsEditor::setCursorShape(Qt::CursorShape shape) +{ + if (!m_pane) return; + if (!m_cursorSet) { + m_savedCursor = m_pane->cursor(); + m_cursorSet = true; + } + m_cursorShape = shape; + m_pane->setCursor(shape); +} + +void +LyricsEditor::restoreCursor() +{ + if (!m_cursorSet) return; + m_cursorSet = false; + + // Unless the pane has put another of its own in the meantime, for a + // change of tool say: that one stays + if (m_pane && m_pane->cursor().shape() == m_cursorShape) { + m_pane->setCursor(m_savedCursor); + } +} + +void +LyricsEditor::showHelp(const QString &help) +{ + // Every time, as the pane does: something else may have written the + // status bar since + m_helpShown = true; + emit contextHelpChanged(help); +} + +void +LyricsEditor::clearHelp() +{ + if (!m_helpShown) return; + m_helpShown = false; + emit contextHelpChanged(""); +} diff --git a/main/LyricsEditor.h b/main/LyricsEditor.h new file mode 100644 index 00000000..9e681638 --- /dev/null +++ b/main/LyricsEditor.h @@ -0,0 +1,270 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TONY_LYRICS_EDITOR_H +#define TONY_LYRICS_EDITOR_H + +#include "LyricsEdit.h" + +#include "base/BaseTypes.h" +#include "base/Event.h" +#include "data/model/Model.h" + +#include +#include +#include +#include +#include + +#include +#include +#include + +class QMouseEvent; +class LyricsTrack; + +namespace sv { +class Pane; +class RegionLayer; +class RegionModel; +class ChangeEventsCommand; +} + +/** + * Edit mode for the lyrics (Edit > Edit Lyrics): the mouse in the box + * row of the lyrics, along the bottom of their pane, moves a word's + * start or end, with Shift held moves all the words together, changes + * a word's text on a double-click, and on a right click offers a small + * menu to change or delete the word there, or to add one in the space + * between words. + * + * The lyrics layer is never the pane's top layer, so the pane's tools + * never reach it: this watches the pane's mouse events through an + * event filter, which is on only while edit mode is. Only what it acts + * on is kept from the pane: a left press on an edge and the drag it + * starts, a left press with Shift held anywhere in the box row and the + * drag it starts, a double-click on a word, a right press anywhere in + * the box row, and moves with no button held in the box row, where the + * cursor and the context help are this one's. Everything else, a click + * anywhere to move the playback cursor included, goes to the pane as it + * would without edit mode. + * + * What an edit may do is LyricsEdit's to say; this does as it says. + * A drag edits the model as it goes, so the words move under the + * pointer, and is one command on the undo history when the button is + * let go, or nothing at all if the word is where it was. A change of + * text, an added word and a deleted one are a command each, and so is + * a shift of all the words, by a drag or by shiftLyrics(). + * + * The layer and the model are found through LyricsTrack at every + * event, and nothing of them is kept between drags: an import, a + * remove or another session can replace them at any time. For the + * same reason a menu entry holds the word as a value, and the word is + * looked for again when the entry is chosen and again once its text + * has been asked for. + * + * Like the lyrics track itself, MainWindow owns it and only wires it. + */ +class LyricsEditor : public QObject +{ + Q_OBJECT + +public: + LyricsEditor(LyricsTrack *lyrics, QObject *parent = nullptr); + virtual ~LyricsEditor(); + + /** + * Switch edit mode on, in the pane the lyrics are in, or off. On + * does nothing if there are no lyrics. Off finishes a drag that is + * in progress first, as letting go of the button would, then puts + * the pane's cursor back and takes the event filter off. + */ + void setEnabled(bool enabled); + bool isEnabled() const { return m_enabled; } + + /// True from the press that starts a drag to the release + bool isDragging() const { return m_dragging; } + + /// True while the drag is one of all the words (Shift was held at + /// its press), not of an edge + bool isShifting() const { return m_dragging && m_shifting; } + + /** + * Move all the words of the lyrics in this model by this many + * seconds, negative for earlier, as far as LyricsEdit::clampShift() + * lets them go: one command, "Shift Lyrics". Edit mode need not be + * on; a drag in progress is finished first. The seconds they moved + * by, 0 if they did not move, the shift being 0 or clamped to 0, or + * if the model is no longer that of the lyrics: nothing is pushed + * then. Not to be called from an undo or a redo. + */ + double shiftLyrics(sv::ModelId model, double seconds); + + /// How near an edge, in logical pixels on either side, grabs it + static constexpr int grabPixels = 6; + + /** + * How a word's text is asked for: given the text the word has ("" for + * a new word), true with the text the user gave in its place, false + * if they cancelled. MainWindow asks with a dialog, whose event loop + * runs while the question is open. With none set, no text is changed + * and no word added. + */ + typedef std::function TextQuestion; + void setTextQuestion(TextQuestion question) { m_askText = question; } + + /** + * One entry of the menu a right press in the box row opens. Values + * only: the word as it was when the menu was made, and the model it + * was in, which choose() looks for again. + */ + struct MenuEntry { + enum class Operation { EditText, DeleteWord, AddWord }; + + QString text; + bool enabled = false; + Operation operation = Operation::EditText; + sv::ModelId model; + + /// EditText, DeleteWord: the word the pointer was on + sv::Event word; + + /// AddWord: the frame the new word goes at + sv::sv_frame_t frame = 0; + }; + + /** + * The entries of the menu for a right press at this point of the + * pane, in the order shown: on a word "Edit Word Text..." and "Delete + * Word", elsewhere in the box row "Add Word...", disabled where + * there is no room for a word. None if the point is not in the box + * row, or edit mode is off: the press is then the pane's. + */ + std::vector menuEntriesAt(QPoint pos) const; + + /** + * Do what an entry says, as choosing it in the menu does: nothing + * if it is disabled, if edit mode is off, or if the lyrics have + * changed so that its word is not there as it was. The text is + * asked for first, where the entry needs one. + */ + void choose(const MenuEntry &entry); + +signals: + /// What the mouse does where the pointer is, "" when that is over + void contextHelpChanged(const QString &); + +protected: + bool eventFilter(QObject *, QEvent *) override; + +private: + LyricsTrack *m_lyrics; + bool m_enabled; + + // The pane whose events are filtered, while edit mode is on + QPointer m_pane; + + // The drag. The words are those of the model at the press, and the + // same ones go to LyricsEdit at every move: the limits of the edge + // come from where the word was then, so a drag back puts it back. + // Whether it moves an edge or all the words is decided at the press, + // whatever the Shift key does after + bool m_dragging; + bool m_shifting; + sv::ModelId m_dragModel; + sv::EventVector m_dragWords; + int m_dragWord; + LyricsEdit::Part m_dragPart; + sv::sv_frame_t m_dragEdgeFrame; + sv::sv_frame_t m_dragPressFrame; + sv::Event m_dragOriginal; + sv::Event m_dragCurrent; + sv::ChangeEventsCommand *m_dragCommand; + + // A drag of all the words: they are as they were at the press, moved + // together, and the model holds them so. They go into the model + // straight, with no command; the command is made at the release + sv::EventVector m_shiftCurrent; + + // The pane's own cursor, while one of ours is shown, and ours + bool m_cursorSet; + QCursor m_savedCursor; + Qt::CursorShape m_cursorShape; + + // Whether the context help is ours + bool m_helpShown; + + // The lyrics layer, if it is in the pane being edited, shown or + // hidden; and its model, if that is the one the drag began in + sv::RegionLayer *currentLayer() const; + std::shared_ptr dragModel() const; + + TextQuestion m_askText; + + // The words and their boxes, if the point is in the box row: false + // if it is not, or there are no lyrics on show. The words are the + // model's now + bool wordsAt(QPoint pos, sv::EventVector &words, + LyricsEdit::Boxes &boxes) const; + + // What the pointer is on, if it is in the box row, as wordsAt() + bool hitAt(QPoint pos, LyricsEdit::Hit &hit, + sv::EventVector &words) const; + + // The lyrics' model, if it is this one and is still being edited + std::shared_ptr editedModel(sv::ModelId) const; + + // The three edits. Each looks for its word in the model again, asks + // for a text where it needs one, and looks again after the question, + // whose event loop may have let anything happen + void editText(sv::ModelId model, sv::Event word); + void deleteWord(sv::ModelId model, sv::Event word); + void addWord(sv::ModelId model, sv::sv_frame_t frame); + + // The menu of menuEntriesAt(), shown at this point. False if there + // is none there + bool showMenu(QPoint pos); + + bool mousePressed(QMouseEvent *); + bool mouseMoved(QMouseEvent *); + bool mouseReleased(QMouseEvent *); + + // Cursor and context help for a pointer here with no button held. + // True if it is in the box row + bool hover(QPoint pos); + void leaveRow(); + + void dragTo(int x); + void shiftTo(int x); + + // All the words out, then all the moved ones in, as one command: + // never one out and one in at a time, or a moved word could meet an + // original that is still there + void pushShift(sv::ModelId model, const sv::EventVector &from, + const sv::EventVector &to); + + // Push the drag's command, if the word has moved + void finishDrag(); + + // Throw the drag's command away, without touching the model: the + // model has changed under the drag + void abandonDrag(); + + void setCursorShape(Qt::CursorShape); + void restoreCursor(); + void showHelp(const QString &); + void clearHelp(); +}; + +#endif diff --git a/main/LyricsSize.cpp b/main/LyricsSize.cpp new file mode 100644 index 00000000..d485ae78 --- /dev/null +++ b/main/LyricsSize.cpp @@ -0,0 +1,90 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#include "LyricsSize.h" + +#include +#include +#include + +static const char *settingsGroup = "MainWindow"; +static const char *settingsKey = "lyricssize"; + +LyricsSize::LyricsSize(QObject *parent) : + QObject(parent), + m_group(new QActionGroup(this)), + m_percent(0) +{ + m_group->setExclusive(true); + for (int percent : getSteps()) { + QAction *action = new QAction(tr("%1%").arg(percent), m_group); + action->setCheckable(true); + action->setData(percent); + action->setStatusTip + (tr("Draw the words of the lyrics at %1% of their normal size") + .arg(percent)); + connect(action, &QAction::triggered, + this, [this, percent]() { setPercent(percent); }); + } + + QSettings settings; + settings.beginGroup(settingsGroup); + int percent = settings.value(settingsKey, getDefaultPercent()).toInt(); + settings.endGroup(); + + // A value from elsewhere that is not a step gives the default + if (!getSteps().contains(percent)) { + percent = getDefaultPercent(); + } + apply(percent); +} + +QList +LyricsSize::getActions() const +{ + return m_group->actions(); +} + +int +LyricsSize::getDefaultPercent() +{ +#ifdef Q_OS_ANDROID + return 50; +#else + return 100; +#endif +} + +void +LyricsSize::setPercent(int percent) +{ + if (!getSteps().contains(percent)) return; + apply(percent); + + QSettings settings; + settings.beginGroup(settingsGroup); + settings.setValue(settingsKey, percent); + settings.endGroup(); +} + +void +LyricsSize::apply(int percent) +{ + bool changed = (percent != m_percent); + m_percent = percent; + for (QAction *action : m_group->actions()) { + if (action->data().toInt() == percent) action->setChecked(true); + } + if (changed) emit textScaleChanged(getTextScale()); +} diff --git a/main/LyricsSize.h b/main/LyricsSize.h new file mode 100644 index 00000000..f6b82141 --- /dev/null +++ b/main/LyricsSize.h @@ -0,0 +1,68 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TONY_LYRICS_SIZE_H +#define TONY_LYRICS_SIZE_H + +#include +#include + +class QAction; +class QActionGroup; + +/** + * How large the lyrics' words are drawn, as View > Lyrics Size sets + * it: a share of the size svgui's lyrics layer gives them, which + * grows with the zoom from twice the view's font to four times. + * Chosen, a step takes effect at once (textScaleChanged()) and is + * remembered in the settings. + * + * The default is 50% on Android, where the whole size left room for + * only three or four words in the pane, less than a verse, unless it + * was zoomed far in; 100% elsewhere. + */ +class LyricsSize : public QObject +{ + Q_OBJECT + +public: + /// Reads the setting; the scale is there from the start + explicit LyricsSize(QObject *parent); + + /// The checkable steps, one of them checked, for a submenu + QList getActions() const; + + int getPercent() const { return m_percent; } + double getTextScale() const { return m_percent / 100.0; } + + static QList getSteps() { return { 35, 50, 65, 80, 100 }; } + + /// 50 on Android, 100 elsewhere + static int getDefaultPercent(); + +signals: + void textScaleChanged(double scale); + +public slots: + /// Applies and remembers a step; anything else is ignored + void setPercent(int percent); + +private: + void apply(int percent); + + QActionGroup *m_group; + int m_percent; +}; + +#endif diff --git a/main/LyricsTrack.cpp b/main/LyricsTrack.cpp new file mode 100644 index 00000000..1b18b079 --- /dev/null +++ b/main/LyricsTrack.cpp @@ -0,0 +1,263 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#include "LyricsTrack.h" + +#include "TakeLayers.h" + +#include "framework/Document.h" +#include "view/Pane.h" +#include "layer/RegionLayer.h" +#include "layer/LayerFactory.h" +#include "layer/ColourDatabase.h" +#include "data/model/RegionModel.h" +#include "base/PlayParameters.h" + +#include +#include + +#include + +using namespace sv; + +using std::cerr; +using std::endl; + +// The words are in frames of the reference's timeline, as the parser +// worked them out: a coarser resolution would move them +static const int lyricsResolution = 1; + +// Used only if the session has no main model to take the rate from, +// which cannot happen while there is a reference to time the words by +static const sv_samplerate_t defaultSampleRate = 44100; + +LyricsTrack::LyricsTrack(QObject *parent) : + QObject(parent), + m_document(nullptr), + m_pane(nullptr), + m_layer(nullptr), + m_textScale(1.0) +{ +} + +LyricsTrack::~LyricsTrack() +{ +} + +void +LyricsTrack::setTextScale(double scale) +{ + if (!(scale > 0.0)) return; + m_textScale = scale; + if (m_layer) m_layer->setLyricsTextScale(m_textScale); +} + +QString +LyricsTrack::layerName() +{ + return "Lyrics"; +} + +bool +LyricsTrack::show(Document *document, Pane *pane, const EventVector &events, + QString presentationName) +{ + if (!document || !pane) return false; + + // One set of lyrics per session: a new import takes the place of the + // last, whose layer and model go with it + if (m_layer) hide(); + + sv_samplerate_t rate = defaultSampleRate; + if (auto main = ModelById::get(document->getMainModel())) { + rate = main->getSampleRate(); + } + + auto model = std::make_shared(rate, lyricsResolution); + model->setObjectName(tr("Lyrics")); + for (const Event &e : events) model->add(e); + ModelId modelId = ModelById::add(model); + + // Not createEmptyLayer(): see MainWindow::setupRealtimePitchLayer(). + // Before the model goes to the document, so that a failure here can + // release it: the document must never hold a released model + auto layer = qobject_cast + (document->createLayer(LayerFactory::Regions)); + if (!layer) { + cerr << "LyricsTrack::show: failed to create layer" << endl; + ModelById::release(modelId); + return false; + } + + document->addNonDerivedModel(modelId); + document->setModel(layer, modelId); + takeLayer(document, pane, layer); + m_layer->setPresentationName(presentationName); + + // The pane takes its hover readout and its vertical scale from its + // top layer, and this one has neither to give: whatever is on top + // now stays there + Layer *previousTop = pane->getTopLayer(); + + // Not addLayerToView(): an import is not undoable, and hide() takes + // the layer away with deleteLayer(force), which would leave an + // AddLayerCommand holding a deleted layer + document->attachLayerToView(pane, layer); + + if (previousTop) TakeLayers::raise(pane, previousTop); + + return true; +} + +bool +LyricsTrack::adopt(Document *document, Pane *pane) +{ + if (m_layer) return true; + if (!document || !pane) return false; + + for (int i = 0; i < pane->getLayerCount(); ++i) { + auto layer = qobject_cast(pane->getLayer(i)); + if (!layer || layer->objectName() != layerName()) continue; + if (!ModelById::isa(layer->getModel())) continue; + takeLayer(document, pane, layer); + + // The session puts the layers back in the order they were saved + // in, and the lyrics end up on top if the layer that was above + // them went before the save. Not to stay there, for the reason + // show() leaves the top layer where it is + keepUnderTop(); + + return true; + } + + return false; +} + +void +LyricsTrack::takeLayer(Document *document, Pane *pane, RegionLayer *layer) +{ + m_document = document; + m_pane = pane; + m_layer = layer; + + connect(m_document, &Document::layerAboutToBeDeleted, + this, &LyricsTrack::layerAboutToBeDeleted, + Qt::UniqueConnection); + + configureLayer(); +} + +void +LyricsTrack::configureLayer() +{ + if (!m_layer) return; + + m_layer->setObjectName(layerName()); + + // Words along the bottom of the pane: a plot style the svgui fork + // has for this. It has no vertical scale and takes no edits. + // EqualSpaced as well, so that nothing in the pane can align its + // scale to this layer + m_layer->setVerticalScale(RegionLayer::EqualSpaced); + m_layer->setPlotStyle(RegionLayer::PlotLyrics); + m_layer->setLyricsTextScale(m_textScale); + + // The words are dark on light boxes whatever this is; grey rather + // than the layer's default black wherever else its colour shows + m_layer->setBaseColour + (ColourDatabase::getInstance()->getColourIndex(tr("Grey"))); + + // A RegionModel cannot play, so there are none; if that ever + // changes, the words are still not something to hear + if (auto params = m_layer->getPlayParameters()) { + params->setPlayAudible(false); + } +} + +void +LyricsTrack::hide() +{ + RegionLayer *layer = m_layer; + m_layer = nullptr; + + // deleteLayer(force) and nothing else: see the notes on tearing a + // layer down silently in MainWindow::teardownRealtimePitchLayer(). + // The model goes with the layer, which is its only user + if (layer && m_document) { + m_document->deleteLayer(layer, true); + } + + if (m_document) { + disconnect(m_document, nullptr, this, nullptr); + } + m_document = nullptr; + m_pane = nullptr; +} + +void +LyricsTrack::setVisible(bool visible) +{ + if (!m_layer || !m_pane) return; + m_layer->showLayer(m_pane, visible); +} + +bool +LyricsTrack::isVisible() const +{ + return m_layer && m_pane && !m_layer->isLayerDormant(m_pane); +} + +void +LyricsTrack::setPlaybackFrame(sv_frame_t frame) +{ + // Hidden or not: shown again, the lyrics have the right word lit + // already, even with playback stopped + if (m_layer) m_layer->setHighlightFrame(frame); +} + +void +LyricsTrack::keepUnderTop() +{ + if (!m_layer || !m_pane) return; + int count = m_pane->getLayerCount(); + if (count > 1 && m_pane->getTopLayer() == m_layer) { + TakeLayers::raise(m_pane, m_pane->getLayer(count - 2)); + } +} + +void +LyricsTrack::layerAboutToBeDeleted(Layer *layer) +{ + // Someone else's doing, the document being closed for instance + if (layer && layer == m_layer) { + m_layer = nullptr; + hide(); + return; + } + + // Another layer going can leave the lyrics on top of the pane: the + // alternate pitch track turned off, a take deleted. It is still in + // the pane now, so this looks again once it has gone. A layer and a + // pane that have gone by then are not ours to look at + QPointer pane(m_pane); + QTimer::singleShot(0, this, [this, pane]() { + if (pane && pane == m_pane) keepUnderTop(); + }); +} + +ModelId +LyricsTrack::getModelId() const +{ + return m_layer ? m_layer->getModel() : ModelId(); +} diff --git a/main/LyricsTrack.h b/main/LyricsTrack.h new file mode 100644 index 00000000..4c04d0dc --- /dev/null +++ b/main/LyricsTrack.h @@ -0,0 +1,140 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TONY_LYRICS_TRACK_H +#define TONY_LYRICS_TRACK_H + +#include "base/ById.h" +#include "base/Event.h" +#include "data/model/Model.h" + +#include +#include + +namespace sv { +class Document; +class Pane; +class Layer; +class RegionLayer; +} + +/** + * The timed lyrics of the session: a RegionLayer in pane 0 with one + * region per word (lyricsToEvents()), drawn as words in boxes along the + * bottom of the pane by the lyrics plot style of the svgui fork, with + * the word at the playback position highlighted. + * + * The lyrics belong to the song, not to a take: one set per session, + * which only an import replaces. The layer and its model are ordinary + * document contents, so a session keeps them, and the layer's object + * name is how it is known again. + * + * The layer is display only. It is never the pane's top layer, because + * the pane takes the hover readout and the vertical scale from that one + * and this style has neither; it cannot be played (a RegionModel has no + * play parameters), and it takes no edits itself: LyricsEditor edits + * its words. + * + * It looks like the coverage strip's class and is used the same way: + * MainWindow only wires it. + */ +class LyricsTrack : public QObject +{ + Q_OBJECT + +public: + LyricsTrack(QObject *parent = nullptr); + virtual ~LyricsTrack(); + + /** + * Make a layer holding these events in the given pane, under the + * layer that is on top of the pane now. Lyrics already shown are + * replaced: their layer and model go first. The presentation name + * is what the user sees the layer called. Returns false if the + * layer could not be created. + */ + bool show(sv::Document *document, sv::Pane *pane, + const sv::EventVector &events, QString presentationName); + + /** + * Take over a layer that show() made in an earlier run, and that a + * session load has put back into the pane, with the visibility and + * the presentation name it was saved with. Returns false if there + * is none. + */ + bool adopt(sv::Document *document, sv::Pane *pane); + + /// Delete the layer and its model from the document + void hide(); + + bool isShown() const { return m_layer != nullptr; } + + /** + * The user's Show Lyrics toggle: the layer stays, hidden. Not kept + * in the settings: the pane writes each layer's visibility into the + * session, which is where it belongs. + */ + void setVisible(bool visible); + bool isVisible() const; + + /** + * The playback position, or the recording position during a take: + * the word there is the one highlighted. The layer repaints only + * when that is another word, so this can be called for every frame + * the view manager reports. Nothing is saved or marked modified. + */ + void setPlaybackFrame(sv::sv_frame_t frame); + + sv::RegionLayer *getLayer() const { return m_layer; } + + /// The pane the layer is in, null when there are no lyrics + sv::Pane *getPane() const { return m_layer ? m_pane : nullptr; } + + /// The model the words are in + sv::ModelId getModelId() const; + + /** + * The layer's object name, which is how it is known after a session + * load. Not translated: it is stored in the session file. + */ + static QString layerName(); + + /** + * The share of svgui's size the words are drawn at (View > Lyrics + * Size, LyricsSize), now and in any layer taken on later. 1 until + * set. + */ + double getTextScale() const { return m_textScale; } + +public slots: + void setTextScale(double scale); + +private slots: + void layerAboutToBeDeleted(sv::Layer *); + +private: + sv::Document *m_document; + sv::Pane *m_pane; + sv::RegionLayer *m_layer; + double m_textScale; + + void takeLayer(sv::Document *, sv::Pane *, sv::RegionLayer *); + void configureLayer(); + + // If the lyrics are the pane's top layer, put the one under them on + // top: see show() + void keepUnderTop(); +}; + +#endif diff --git a/main/LyricsTtml.cpp b/main/LyricsTtml.cpp new file mode 100644 index 00000000..9dea9d60 --- /dev/null +++ b/main/LyricsTtml.cpp @@ -0,0 +1,858 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#include "LyricsTtml.h" + +#include +#include +#include +#include + +#include +#include +#include + +namespace { + +QString tr(const char *text) +{ + return QCoreApplication::translate("Lyrics", text); +} + +// "1 line was" or "3 lines were": the status bar shows these +QString counted(int n, const char *one, const char *many) +{ + if (n == 1) return tr(one); + return tr(many).arg(n); +} + +const char *const ttmlNamespace = "http://www.w3.org/ns/ttml"; +const char *const itunesNamespace = "http://music.apple.com/lyric-ttml-internal"; +const char *const metadataNamespace = "http://www.w3.org/ns/ttml#metadata"; + +// Only to keep the arithmetic far from overflowing, as the LRC parser +// has it: no reference is a day long +const qint64 maxSeconds = 24 * 3600; + +// The biggest number a time can have in it: that day in milliseconds +const qint64 maxNumber = maxSeconds * 1000; + +// Digits kept after the point: microseconds are plenty +const int maxFractionDigits = 6; + +bool isAsciiDigit(QChar c) +{ + return c.unicode() >= '0' && c.unicode() <= '9'; +} + +int digitValue(QChar c) +{ + return c.unicode() - '0'; +} + +// What XML counts as white space; nothing else separates words +bool isXmlSpace(QChar c) +{ + const ushort u = c.unicode(); + return u == ' ' || u == '\t' || u == '\n' || u == '\r'; +} + +QStringView xmlTrimmed(QStringView s) +{ + qsizetype b = 0, e = s.size(); + while (b < e && isXmlSpace(s[b])) ++b; + while (e > b && isXmlSpace(s[e - 1])) --e; + return s.mid(b, e - b); +} + +// Each line break or tab a space, so that the cleaning, which drops +// controls, does not glue the words either side of it +QString spaced(QStringView s) +{ + QString out = s.toString(); + for (QChar &c : out) { + if (isXmlSpace(c)) c = QLatin1Char(' '); + } + return out; +} + +// Every run of white space one space +QString collapsed(QStringView s) +{ + QString out; + out.reserve(s.size()); + bool blank = false; + for (QChar c : s) { + if (isXmlSpace(c)) { + blank = true; + continue; + } + if (blank && !out.isEmpty()) out += QLatin1Char(' '); + blank = false; + out += c; + } + return out; +} + +/** + * At least one digit at i, which is moved past them; false if there + * is none, or so many that they could be no time. + */ +bool readNumber(QStringView s, qsizetype &i, qint64 &value) +{ + const qsizetype from = i; + value = 0; + while (i < s.size() && isAsciiDigit(s[i])) { + value = value * 10 + digitValue(s[i]); + if (value > maxNumber) return false; + ++i; + } + return i > from; +} + +/** + * A TTML time in seconds: a clock time [[h:]m:]s[.fraction], where + * the minutes can pass 59 when there are no hours (the exporter writes + * m:ss.mmm, AMLL TTML Tool mm:ss.mmm, Apple sometimes plain seconds), + * or an offset time h, m, s or ms. Frames and ticks (f, t, + * hh:mm:ss:ff) are not read: nothing is returned for them, as for + * anything else. + */ +std::optional readTime(QStringView text) +{ + const QStringView s = xmlTrimmed(text); + const qsizetype n = s.size(); + qsizetype i = 0; + + qint64 fields[3]; + int count = 0; + while (true) { + if (!readNumber(s, i, fields[count])) return {}; + ++count; + if (count < 3 && i < n && s[i] == QLatin1Char(':')) { + ++i; + continue; + } + break; + } + + qint64 fraction = 0; + qint64 scale = 1; + if (i < n && s[i] == QLatin1Char('.')) { + ++i; + int digits = 0; + while (i < n && isAsciiDigit(s[i])) { + if (digits < maxFractionDigits) { + fraction = fraction * 10 + digitValue(s[i]); + scale *= 10; + } + ++digits; + ++i; + } + if (digits == 0) return {}; + } + + // The number is in units of multiplier / divisor seconds + qint64 whole = 0; + qint64 multiplier = 1; + qint64 divisor = 1; + const QStringView metric = s.mid(i); + + if (count == 1) { + whole = fields[0]; + if (metric.isEmpty() || metric == QLatin1String("s")) { + } else if (metric == QLatin1String("ms")) { + divisor = 1000; + } else if (metric == QLatin1String("m")) { + multiplier = 60; + } else if (metric == QLatin1String("h")) { + multiplier = 3600; + } else { + return {}; + } + } else { + if (!metric.isEmpty()) return {}; + const qint64 hours = (count == 3 ? fields[0] : 0); + const qint64 minutes = fields[count - 2]; + const qint64 seconds = fields[count - 1]; + if (seconds >= 60) return {}; + if (count == 3 && minutes >= 60) return {}; + whole = (hours * 60 + minutes) * 60 + seconds; + } + + if (whole > maxSeconds * divisor / multiplier) return {}; + + // Whole milliseconds, as nearly every file has them, come out as + // the nearest double to the decimal, not a step away from it + const qint64 units = whole * scale + fraction; + const double result = + double(units) * double(multiplier) / (double(scale) * double(divisor)); + if (result > double(maxSeconds)) return {}; + return result; +} + +// What was skipped or changed, for the warnings +struct Counts +{ + int unreadableBegins = 0; + int unreadableEnds = 0; + int untimedLines = 0; + int droppedText = 0; + int skippedParts = 0; + int backwards = 0; +}; + +struct Timing +{ + /// There is a begin, whether or not it could be read + bool hasBegin = false; + + std::optional begin; + + /// The end, or else begin + dur + std::optional end; +}; + +/** + * An element's begin, end and dur, matched by local name in any + * namespace. An end that cannot be read is counted only when the + * begin can: a word is left out for its begin, with a warning of its + * own, but a line with timed words needs no begin. + */ +Timing readTiming(const QXmlStreamAttributes &attributes, Counts &counts) +{ + std::optional begin, end, dur; + for (const QXmlStreamAttribute &a : attributes) { + const QStringView name = a.name(); + if (name == QLatin1String("begin")) { + if (!begin) begin = a.value(); + } else if (name == QLatin1String("end")) { + if (!end) end = a.value(); + } else if (name == QLatin1String("dur")) { + if (!dur) dur = a.value(); + } + } + + Timing timing; + if (begin) { + timing.hasBegin = true; + timing.begin = readTime(*begin); + } + + if (end) { + timing.end = readTime(*end); + if (!timing.end && (timing.begin || !timing.hasBegin)) { + ++counts.unreadableEnds; + } + } + if (!timing.end && dur && timing.begin) { + std::optional length = readTime(*dur); + if (length) { + timing.end = *timing.begin + *length; + } else { + ++counts.unreadableEnds; + } + } + return timing; +} + +/** + * Background vocals, translations and romanisations: AMLL TTML Tool + * puts them in the line as spans with these roles. ttm:role is a + * list, in TTML 1. + */ +bool hasSkippedRole(const QXmlStreamAttributes &attributes) +{ + static const QStringList skipped = { + QStringLiteral("x-bg"), QStringLiteral("x-translation"), + QStringLiteral("x-roman"), QStringLiteral("x-romanization") + }; + for (const QXmlStreamAttribute &a : attributes) { + if (a.name() != QLatin1String("role")) continue; + // An attribute's line breaks and tabs are read as spaces + const QStringList roles = + a.value().toString().split(QLatin1Char(' '), Qt::SkipEmptyParts); + for (const QString &role : roles) { + if (skipped.contains(role)) return true; + } + } + return false; +} + +// What a line holds, in file order, before it is made into words +struct Piece +{ + enum Kind { + Timed, ///< A span with a begin + Text, ///< Text outside timed spans, with no blanks + Blank ///< White space, a
or a part left out + }; + + Kind kind = Blank; + QString text; + double begin = 0.0; + std::optional end; +}; + +void addBlank(QVector &pieces) +{ + if (!pieces.isEmpty() && pieces.last().kind == Piece::Blank) return; + pieces.push_back(Piece()); +} + +void addText(QStringView text, QVector &pieces) +{ + const qsizetype n = text.size(); + qsizetype i = 0; + while (i < n) { + const bool blank = isXmlSpace(text[i]); + qsizetype j = i; + while (j < n && isXmlSpace(text[j]) == blank) ++j; + if (blank) { + addBlank(pieces); + } else if (!pieces.isEmpty() && pieces.last().kind == Piece::Text) { + // The reader may hand one run of text over in parts + pieces.last().text += text.mid(i, j - i); + } else { + Piece piece; + piece.kind = Piece::Text; + piece.text = text.mid(i, j - i).toString(); + pieces.push_back(piece); + } + i = j; + } +} + +/** + * All the text in the element the reader is at, which is read to its + * end. A
is a space, and so is a skipped part, so that the + * words either side of it are not joined; elements other than spans + * (metadata, animation) hold no lyrics and are passed over. + */ +QString readAllText(QXmlStreamReader &reader, Counts &counts) +{ + QString text; + int depth = 1; + while (!reader.atEnd()) { + switch (reader.readNext()) { + case QXmlStreamReader::Characters: + text += reader.text(); + break; + case QXmlStreamReader::StartElement: + if (reader.name() == QLatin1String("br")) { + text += QLatin1Char(' '); + reader.skipCurrentElement(); + } else if (reader.name() != QLatin1String("span")) { + reader.skipCurrentElement(); + } else if (hasSkippedRole(reader.attributes())) { + ++counts.skippedParts; + text += QLatin1Char(' '); + reader.skipCurrentElement(); + } else { + ++depth; + } + break; + case QXmlStreamReader::EndElement: + if (--depth == 0) return text; + break; + default: + break; + } + } + return text; +} + +/** + * The content of the element the reader is at, a

or a wrapping + * , read to its end. + */ +void readInline(QXmlStreamReader &reader, QVector &pieces, + Counts &counts) +{ + while (!reader.atEnd()) { + + const QXmlStreamReader::TokenType token = reader.readNext(); + if (token == QXmlStreamReader::EndElement) return; + if (token == QXmlStreamReader::Characters) { + addText(reader.text(), pieces); + continue; + } + if (token != QXmlStreamReader::StartElement) continue; + + if (reader.name() == QLatin1String("br")) { + addBlank(pieces); + reader.skipCurrentElement(); + continue; + } + if (reader.name() != QLatin1String("span")) { + reader.skipCurrentElement(); + continue; + } + + const QXmlStreamAttributes attributes = reader.attributes(); + + if (hasSkippedRole(attributes)) { + // It can come straight after a word, and a word after it + ++counts.skippedParts; + addBlank(pieces); + reader.skipCurrentElement(); + continue; + } + + const Timing timing = readTiming(attributes, counts); + + if (!timing.hasBegin) { + // A wrapper: its content is read as if it were not there + readInline(reader, pieces, counts); + continue; + } + + const QString text = readAllText(reader, counts); + + if (!timing.begin) { + ++counts.unreadableBegins; + addBlank(pieces); + continue; + } + + // Blanks at either end of its text are blanks between words + if (!text.isEmpty() && isXmlSpace(text.front())) addBlank(pieces); + Piece piece; + piece.kind = Piece::Timed; + piece.text = xmlTrimmed(text).toString(); + piece.begin = *timing.begin; + piece.end = timing.end; + pieces.push_back(piece); + if (!text.isEmpty() && isXmlSpace(text.back())) addBlank(pieces); + } +} + +// A word before it has its line index, and perhaps its end +struct Entry +{ + double start = 0.0; + std::optional end; + bool endGiven = false; + QString text; +}; + +struct Line +{ + /// In time order + QVector words; + + /// The

's own end, if it has one + std::optional end; + + /// It had no timed spans: its one word is the whole line + bool lineTimed = false; +}; + +/** + * The

the reader is at, read to its end: one line, unless nothing + * in it is timed and it has no begin either, or it has no text. + */ +void readParagraph(QXmlStreamReader &reader, QVector &lines, + Counts &counts) +{ + const Timing timing = readTiming(reader.attributes(), counts); + + QVector pieces; + readInline(reader, pieces, counts); + + Line line; + line.end = timing.end; + + const bool anyTimed = + std::any_of(pieces.begin(), pieces.end(), [](const Piece &p) { + return p.kind == Piece::Timed; + }); + + if (!anyTimed) { + QString text; + for (const Piece &p : pieces) { + text += (p.kind == Piece::Blank ? QStringLiteral(" ") : p.text); + } + const QString label = lyricsLabel(collapsed(text)); + if (label.isEmpty()) return; + if (timing.hasBegin && !timing.begin) { + ++counts.unreadableBegins; + return; + } + if (!timing.begin) { + ++counts.untimedLines; + return; + } + Entry entry; + entry.start = *timing.begin; + entry.end = timing.end; + entry.text = label; + line.words.push_back(entry); + line.lineTimed = true; + lines.push_back(line); + return; + } + + // Timed spans with nothing but text between them are one word: + // syllables, or a word and the punctuation after it + std::optional current; + auto finish = [&]() { + if (!current) return; + current->text = lyricsLabel(spaced(current->text)); + if (!current->text.isEmpty()) line.words.push_back(*current); + current.reset(); + }; + + for (const Piece &p : pieces) { + switch (p.kind) { + case Piece::Blank: + finish(); + break; + case Piece::Timed: + if (current) { + current->text += p.text; + current->end = p.end; + } else { + Entry entry; + entry.start = p.begin; + entry.end = p.end; + entry.text = p.text; + current = entry; + } + break; + case Piece::Text: + if (current) { + current->text += p.text; + } else { + ++counts.droppedText; + } + break; + } + } + finish(); + + if (line.words.isEmpty()) return; + + std::stable_sort(line.words.begin(), line.words.end(), + [](const Entry &a, const Entry &b) { + return a.start < b.start; + }); + lines.push_back(line); +} + +/** + * Where each word ends when the file does not say: at the next word + * of its line, else at the end of the line, else as the LRC parser + * has it, a while after its start but not past the next line's start + * (a line-timed line: at the next line's start). The lines must be in + * time order. + */ +void inferEnds(QVector &lines, Counts &counts) +{ + for (int k = 0; k < lines.size(); ++k) { + Line &line = lines[k]; + for (int i = 0; i < line.words.size(); ++i) { + Entry &e = line.words[i]; + if (e.end) { + e.endGiven = true; + } else if (i + 1 < line.words.size()) { + e.end = line.words[i + 1].start; + } else if (line.end) { + e.end = line.end; + e.endGiven = true; + } else if (k + 1 < lines.size()) { + const double next = lines[k + 1].words[0].start; + if (line.lineTimed) { + e.end = next; + } else { + e.end = std::min(next, e.start + + Lyrics::inferredWordSeconds); + } + } else { + e.end = e.start + (line.lineTimed ? + Lyrics::inferredLastLineSeconds : + Lyrics::inferredWordSeconds); + } + + if (*e.end < e.start) { + if (e.endGiven) ++counts.backwards; + e.end = e.start; + } + } + } +} + +// Seconds as m:ss.mmm, to the nearest millisecond +QString ttmlTime(double seconds) +{ + const qint64 ms = std::max(qint64(0), qint64(std::llround(seconds * 1000))); + return QString("%1:%2.%3") + .arg(ms / 60000) + .arg((ms % 60000) / 1000, 2, 10, QLatin1Char('0')) + .arg(ms % 1000, 3, 10, QLatin1Char('0')); +} + +} // namespace + +LyricsParseResult +parseTtml(const QByteArray &bytes) +{ + LyricsParseResult result; + + if (bytes.isEmpty()) { + result.error = tr("The file is empty."); + return result; + } + if (bytes.size() > Lyrics::maxFileBytes) { + result.error = tr("The file is over 1 MB, too big to be a TTML " + "lyrics file."); + return result; + } + + // The reader finds the encoding from the BOM or the declaration + QXmlStreamReader reader(bytes); + + Counts counts; + QVector lines; + QString title; + bool inHead = false; + + while (!reader.atEnd()) { + const QXmlStreamReader::TokenType token = reader.readNext(); + + if (token == QXmlStreamReader::DTD) { + result.error = tr("The file has a document type declaration " + "(), which a TTML file never has, " + "so it was not read."); + return result; + } + + if (token == QXmlStreamReader::StartElement) { + const QStringView name = reader.name(); + if (name == QLatin1String("head")) { + inHead = true; + } else if (inHead) { + if (name == QLatin1String("title") && title.isEmpty()) { + title = lyricsLabel(collapsed + (reader.readElementText + (QXmlStreamReader::IncludeChildElements))); + } + } else if (name == QLatin1String("p")) { + readParagraph(reader, lines, counts); + } + } else if (token == QXmlStreamReader::EndElement && + reader.name() == QLatin1String("head")) { + inHead = false; + } + } + + if (reader.hasError()) { + result.error = tr("The file is not well-formed XML: %1 (line %2).") + .arg(reader.errorString()).arg(reader.lineNumber()); + return result; + } + + // Lines in time order; those that start together stay in file order + std::stable_sort(lines.begin(), lines.end(), + [](const Line &a, const Line &b) { + return a.words[0].start < b.words[0].start; + }); + + inferEnds(lines, counts); + + Lyrics &lyrics = result.lyrics; + lyrics.title = title; + for (int k = 0; k < lines.size(); ++k) { + if (!lines[k].lineTimed) lyrics.wordTimed = true; + for (const Entry &e : lines[k].words) { + LyricWord word; + word.start = e.start; + word.end = *e.end; + word.text = e.text; + word.line = k; + word.endGiven = e.endGiven; + lyrics.words.push_back(word); + } + } + + std::stable_sort(lyrics.words.begin(), lyrics.words.end(), + [](const LyricWord &a, const LyricWord &b) { + return a.start < b.start; + }); + + if (counts.unreadableBegins > 0) { + result.warnings << counted + (counts.unreadableBegins, + "1 word had a start time that could not be read and was " + "left out.", + "%1 words had start times that could not be read and were " + "left out."); + } + if (counts.unreadableEnds > 0) { + result.warnings << counted + (counts.unreadableEnds, + "1 end time could not be read and was ignored.", + "%1 end times could not be read and were ignored."); + } + if (counts.untimedLines > 0) { + result.warnings << counted + (counts.untimedLines, + "1 line had no time and was left out.", + "%1 lines had no time and were left out."); + } + if (counts.droppedText > 0) { + result.warnings << counted + (counts.droppedText, + "1 piece of text outside the timed words was left out.", + "%1 pieces of text outside the timed words were left out."); + } + if (counts.skippedParts > 0) { + result.warnings << counted + (counts.skippedParts, + "1 background vocal, translation or romanisation was " + "skipped.", + "%1 background vocals, translations or romanisations were " + "skipped."); + } + if (counts.backwards > 0) { + result.warnings << counted + (counts.backwards, + "1 word ended before it started and was given no length.", + "%1 words ended before they started and were given no " + "length."); + } + + if (lyrics.words.isEmpty()) { + result.lyrics = Lyrics(); + result.error = tr("No timed lyrics were found: this is not a TTML " + "file, or it has no timed lines."); + } + + return result; +} + +QByteArray +writeTtml(const Lyrics &lyrics) +{ + const QString tt = QString::fromLatin1(ttmlNamespace); + const QString itunes = QString::fromLatin1(itunesNamespace); + const QString ttm = QString::fromLatin1(metadataNamespace); + + // A

for each line that has words, in line order + QMap> lines; + for (const LyricWord &w : lyrics.words) lines[w.line].push_back(w); + + double first = 0.0; + double last = 0.0; + for (int i = 0; i < lyrics.words.size(); ++i) { + const LyricWord &w = lyrics.words[i]; + if (i == 0 || w.start < first) first = w.start; + last = std::max(last, std::max(w.start, w.end)); + } + + QByteArray out; + QXmlStreamWriter writer(&out); + + // Laid out by hand, as the exporter lays it out: automatic + // formatting would put white space between the spans of a line + auto newline = [&](int indent) { + writer.writeCharacters(QLatin1String("\n") + + QString(indent * 2, QLatin1Char(' '))); + }; + + writer.writeStartDocument(); + newline(0); + + writer.writeDefaultNamespace(tt); + writer.writeNamespace(itunes, QStringLiteral("itunes")); + writer.writeNamespace(ttm, QStringLiteral("ttm")); + writer.writeStartElement(tt, QStringLiteral("tt")); + writer.writeAttribute(itunes, QStringLiteral("timing"), + QStringLiteral("Word")); + + newline(1); + writer.writeStartElement(tt, QStringLiteral("head")); + newline(2); + writer.writeStartElement(tt, QStringLiteral("metadata")); + if (!lyrics.title.isEmpty()) { + newline(3); + writer.writeTextElement(ttm, QStringLiteral("title"), lyrics.title); + } + newline(3); + writer.writeEmptyElement(ttm, QStringLiteral("agent")); + writer.writeAttribute(QStringLiteral("type"), QStringLiteral("person")); + writer.writeAttribute(QStringLiteral("xml:id"), QStringLiteral("v1")); + newline(2); + writer.writeEndElement(); // metadata + newline(1); + writer.writeEndElement(); // head + + newline(1); + writer.writeStartElement(tt, QStringLiteral("body")); + writer.writeAttribute(QStringLiteral("dur"), ttmlTime(last)); + newline(2); + writer.writeStartElement(tt, QStringLiteral("div")); + writer.writeAttribute(QStringLiteral("begin"), ttmlTime(first)); + writer.writeAttribute(QStringLiteral("end"), ttmlTime(last)); + + int key = 0; + for (auto i = lines.begin(); i != lines.end(); ++i) { + + QVector words = i.value(); + std::stable_sort(words.begin(), words.end(), + [](const LyricWord &a, const LyricWord &b) { + return a.start < b.start; + }); + double begin = words[0].start; + double end = words[0].end; + for (const LyricWord &w : words) { + end = std::max(end, w.end); + } + + newline(3); + writer.writeStartElement(tt, QStringLiteral("p")); + writer.writeAttribute(QStringLiteral("begin"), ttmlTime(begin)); + writer.writeAttribute(QStringLiteral("end"), ttmlTime(end)); + writer.writeAttribute(ttm, QStringLiteral("agent"), + QStringLiteral("v1")); + writer.writeAttribute(itunes, QStringLiteral("key"), + QStringLiteral("L%1").arg(++key)); + + for (int j = 0; j < words.size(); ++j) { + if (j > 0) writer.writeCharacters(QStringLiteral(" ")); + writer.writeStartElement(tt, QStringLiteral("span")); + writer.writeAttribute(QStringLiteral("begin"), + ttmlTime(words[j].start)); + writer.writeAttribute(QStringLiteral("end"), + ttmlTime(words[j].end)); + writer.writeCharacters(words[j].text); + writer.writeEndElement(); + } + + writer.writeEndElement(); // p + } + + newline(2); + writer.writeEndElement(); // div + newline(1); + writer.writeEndElement(); // body + newline(0); + writer.writeEndElement(); // tt + writer.writeEndDocument(); + + // Qt ends the document with a line break; not every version need + if (!out.endsWith('\n')) out += '\n'; + + return out; +} diff --git a/main/LyricsTtml.h b/main/LyricsTtml.h new file mode 100644 index 00000000..3c774ecf --- /dev/null +++ b/main/LyricsTtml.h @@ -0,0 +1,49 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TONY_LYRICS_TTML_H +#define TONY_LYRICS_TTML_H + +#include "Lyrics.h" + +#include + +/** + * Timed lyrics in TTML, as Apple Music has them and as the + * Moises-Lyric-Exporter and AMLL TTML Tool write them: a

per line + * and a per word. Pure functions, as in Lyrics.h + * (TestLyricsTtml). + */ + +/** + * Read a TTML file. Its times are read as absolute, as every lyrics + * tool writes them, although strict TTML makes a child's times + * relative to its parent's. Timed spans with no whitespace between + * them are syllables of one word, and are joined; background vocals, + * translations and romanisations are skipped; a

with no timed + * spans is one word, the whole line. A file with a is + * refused: TTML has none, and it is how entity tricks get in. + */ +LyricsParseResult parseTtml(const QByteArray &bytes); + +/** + * The lyrics as TTML, UTF-8, in the exporter's Apple style: one agent, + * a

per line, a per word, times m:ss.mmm rounded to the + * millisecond. The title, if any, goes in ; the artist is + * not written. parseTtml() gives the same words back, to half a + * millisecond. + */ +QByteArray writeTtml(const Lyrics &lyrics); + +#endif diff --git a/main/MainWindow.cpp b/main/MainWindow.cpp index cd9fa4af..a0a34ba5 100644 --- a/main/MainWindow.cpp +++ b/main/MainWindow.cpp @@ -18,14 +18,55 @@ #include "MainWindow.h" #include "NetworkPermissionTester.h" #include "Analyser.h" +#include "CompactLayout.h" +#include "SongScrollBar.h" +#include "PlotSize.h" +#include "LyricsSize.h" +#include "AudioCheckRunner.h" +#include "AudioDriverMenus.h" +#include "AudioDriverSettings.h" +#include "CalibrateAudioDialog.h" +#include "LatencyUtils.h" +#include "Lyrics.h" +#include "LyricsTtml.h" +#include "PaneUtils.h" +#include "TakeEvents.h" +#include "TakeLayers.h" +#include "TakesFile.h" +#include "TouchGestures.h" + +#ifdef Q_OS_ANDROID +#include "AndroidFiles.h" +#include "AndroidStorage.h" +#include "LogFile.h" +#include "OboeAudioIO.h" +#include "data/fileio/AudioFileReaderFactory.h" +#include +#include +#include +#include +#include +#include +#include +#endif + +#ifdef TONY_DEV_CHECKS +#include "dev/DevChecks.h" +#endif #include "framework/Document.h" #include "framework/VersionTester.h" +#include + #include "view/Pane.h" #include "view/PaneStack.h" #include "data/model/WaveFileModel.h" +#include "data/model/ReadOnlyWaveFileModel.h" +#include "data/model/WritableWaveFileModel.h" +#include "data/model/SparseTimeValueModel.h" #include "data/model/NoteModel.h" +#include "data/model/RegionModel.h" #include "layer/FlexiNoteLayer.h" #include "view/ViewManager.h" #include "base/Preferences.h" @@ -34,6 +75,7 @@ #include "layer/WaveformLayer.h" #include "layer/TimeInstantLayer.h" #include "layer/TimeValueLayer.h" +#include "layer/RegionLayer.h" #include "layer/SpectrogramLayer.h" #include "widgets/Fader.h" #include "view/Overview.h" @@ -47,7 +89,9 @@ #include "base/Profiler.h" #include "base/UnitDatabase.h" #include "layer/ColourDatabase.h" +#include "layer/LayerFactory.h" #include "base/Selection.h" +#include "data/model/Model.h" #include "rdf/RDFImporter.h" #include "data/fileio/DataFileReaderFactory.h" @@ -58,6 +102,7 @@ #include "widgets/RangeInputDialog.h" #include "widgets/ActivityLog.h" +#include "widgets/InteractiveFileFinder.h" // For version information #include "vamp/vamp.h" @@ -69,6 +114,8 @@ #include #include +#include +#include #include #include #include @@ -76,6 +123,7 @@ #include #include #include +#include #include #include #include @@ -89,9 +137,16 @@ #include #include #include +#include +#include +#include +#include +#include +#include #include #include +#include #include using std::vector; @@ -108,12 +163,78 @@ MainWindow::MainWindow(AudioMode audioMode, MainWindowBase::MIDI_NONE, int(PaneStack::Option::NoPropertyStacks) | int(PaneStack::Option::NoPaneAccessories)), + m_analyser2(nullptr), + m_realtimePitchTracker(nullptr), + m_realtimePitchLayer(nullptr), + m_liveDotsFeed(40), m_overview(0), + m_songScroll(nullptr), + m_compactLayout(nullptr), + m_plotSize(nullptr), + m_lyricsSize(nullptr), + m_playAction(nullptr), + m_recordAction(nullptr), + m_zoomInAction(nullptr), + m_zoomOutAction(nullptr), + m_navigateToolAction(nullptr), + m_noteEditToolAction(nullptr), + m_playbackControlsToolBar(nullptr), + m_showAndPlayToolBar(nullptr), + m_showSingingPitch(nullptr), + m_showSingingNotes(nullptr), + m_playSingingAudio(nullptr), + m_playRefWhileRecording(nullptr), + m_preRoll(nullptr), + m_recordIntoSelection(nullptr), + m_loadSingingTrackAction(nullptr), + m_alternatePitch(nullptr), + m_showAlternatePitch(nullptr), + m_alternatePitchUpAction(nullptr), + m_alternatePitchDownAction(nullptr), + m_referencePitchHiddenForTake(false), + m_singingPitchHiddenForTake(false), + m_singingNotesHiddenForTake(false), + m_takes(nullptr), + m_coverageStrip(nullptr), + m_lyrics(nullptr), + m_importLyricsAction(nullptr), + m_exportLyricsAction(nullptr), + m_removeLyricsAction(nullptr), + m_showLyrics(nullptr), + m_lyricsEditor(nullptr), + m_editLyricsAction(nullptr), + m_shiftLyricsAction(nullptr), + m_takesMenu(nullptr), + m_takeCombo(nullptr), + m_newTakeAction(nullptr), + m_duplicateTakeAction(nullptr), + m_renameTakeAction(nullptr), + m_deleteTakeAction(nullptr), + m_updatingTakeCombo(false), + m_restoringSession(false), + m_eraseSingingAction(nullptr), + m_selectRecordingAction(nullptr), + m_openTakeCommand(nullptr), + m_takePosition(0), + m_takePreRoll(0), + m_takeEnd(-1), + m_takeTimer(nullptr), + m_backgroundMusicModelId(), + m_backgroundMusicLayer(nullptr), + m_loadBackgroundMusicAction(nullptr), + m_playBackgroundMusic(nullptr), + m_bgMusicLPW(nullptr), + m_loadingBackgroundMusic(false), m_mainMenusCreated(false), m_playbackMenu(0), m_recentFilesMenu(0), m_rightButtonMenu(0), m_rightButtonPlaybackMenu(0), + m_audioDeviceMenu(0), + m_audioDeviceGroup(0), + m_audioInputDeviceMenu(0), + m_audioInputDeviceGroup(0), + m_audioDriverMenus(nullptr), m_deleteSelectedAction(0), m_ffwdAction(0), m_rwdAction(0), @@ -122,10 +243,72 @@ MainWindow::MainWindow(AudioMode audioMode, m_keyReference(new KeyReference()), m_selectionAnchor(0), m_withSonification(withSonification), - m_withSpectrogram(withSpectrogram) + m_withSpectrogram(withSpectrogram), + m_recordingInProgress(false), + m_recordingAsSingingTrack(false), + m_singingAudioMutedForTake(false), + m_singingAudioAfterTake(true), + m_playSelectionLiftedForTake(false), + m_paneCountBeforeRecording(0), + m_currentRecordingModelId(), + m_recordingLayer(nullptr), + m_rebuildingTakeAudio(false), + m_recordingLatencyFrames(0), + m_recordingStartGapEstimate(0), + m_recordingStartGapMeasured(-1), + m_awaitingReferenceStart(false), + m_recordFramesPerPlayFrame(1.0), + m_takeLatency(), + m_audioCheck(nullptr), + m_audioCheckTakes(false), + m_audioCheckRoundTrip(-1.0), + m_audioCheckPreRoll(AudioCheckRunner::kPreRollSeconds), +#ifdef TONY_DEV_CHECKS + m_devChecks(nullptr), +#endif + m_calibrateAudioDialog(nullptr), + m_calibrateAudioAction(nullptr), + m_latencyLineAction(nullptr), + m_forgetLatencyAction(nullptr), + m_lastRecordingRate(0) { setWindowTitle(QApplication::applicationName()); + // Called from the audio callback: see recordingStarted() + if (m_playSource) { + m_playSource->setPlayStartCallback([this](int blockFrames) { + if (!m_awaitingReferenceStart.exchange(false)) return; + if (!m_recordTarget || !m_recordTarget->isRecording()) return; + // The device drivers deliver the input of a block before they + // ask for its output (PortAudioIO, JACKAudioIO), so the count + // already includes the input that goes with this first block. + // The block is counted at the play source's rate, the + // reference's, and the frames received at the device's + sv_frame_t block = sv_frame_t(std::llround + (blockFrames * + m_recordFramesPerPlayFrame.load())); + sv_frame_t gap = m_recordTarget->getFramesReceived() - block; + m_recordingStartGapMeasured = (gap > 0 ? gap : 0); + }); + } + +#ifdef Q_OS_ANDROID + m_audioDeviceReopens = 0; + m_audioDeviceCheck = new QTimer(this); + connect(m_audioDeviceCheck, &QTimer::timeout, + this, &MainWindow::checkAudioDevice); + m_audioDeviceCheck->start(250); + + m_storage = new AndroidStorage(this); + + m_suspendSaveTimer = new QTimer(this); + m_suspendSaveTimer->setInterval(250); + connect(m_suspendSaveTimer, &QTimer::timeout, + this, &MainWindow::saveWhenSuspended); + connect(qApp, &QGuiApplication::applicationStateChanged, + this, &MainWindow::applicationStateChanged); +#endif + #ifdef Q_OS_MAC #if (QT_VERSION >= QT_VERSION_CHECK(5, 2, 0)) setUnifiedTitleAndToolBarOnMac(true); @@ -151,6 +334,9 @@ MainWindow::MainWindow(AudioMode audioMode, cdb->setUseDarkBackground(cdb->addColour(Qt::green, tr("Bright Green")), true); cdb->setUseDarkBackground(cdb->addColour(QColor(225, 74, 255), tr("Bright Purple")), true); cdb->setUseDarkBackground(cdb->addColour(QColor(255, 188, 80), tr("Bright Orange")), true); + // The waveforms under the lyrics (Analyser::setWaveformFaded()). + // Last, so that the colours before it keep their indices + cdb->addColour(QColor(225, 225, 225), tr("Pale Grey")); Preferences::getInstance()->setResampleOnLoad(true); Preferences::getInstance()->setFixedSampleRate(44100); @@ -201,8 +387,10 @@ MainWindow::MainWindow(AudioMode audioMode, // We have a pane stack: it comes with the territory. However, we // have a fixed and known number of panes in it -- it isn't // variable - connect(m_paneStack, SIGNAL(doubleClickSelectInvoked(sv_frame_t)), - this, SLOT(doubleClickSelectInvoked(sv_frame_t))); + // By member pointer: the slot takes sv::sv_frame_t, which a SLOT() + // string saying sv_frame_t does not match under every Qt version + connect(m_paneStack, &PaneStack::doubleClickSelectInvoked, + this, &MainWindow::doubleClickSelectInvoked); scroll->setWidget(m_paneStack); m_overview = new Overview(frame); @@ -232,6 +420,11 @@ MainWindow::MainWindow(AudioMode audioMode, (ColourDatabase::getInstance()->getColourIndex(tr("Blue"))); } + // The overview takes too much of a phone's height: the compact layout + // shows this in its place instead (setupCompactLayout()) + m_songScroll = new SongScrollBar(m_viewManager, m_paneStack, frame); + m_songScroll->hide(); + m_fader = new Fader(frame, false); connect(m_fader, SIGNAL(mouseEntered()), this, SLOT(mouseEnteredWidget())); connect(m_fader, SIGNAL(mouseLeft()), this, SLOT(mouseLeftWidget())); @@ -260,6 +453,12 @@ MainWindow::MainWindow(AudioMode audioMode, connect(m_audioLPW, SIGNAL(levelChanged(float)), this, SLOT(audioGainChanged(float))); connect(m_audioLPW, SIGNAL(panChanged(float)), this, SLOT(audioPanChanged(float))); + m_bgMusicLPW = new LevelPanToolButton(frame); + m_bgMusicLPW->setIncludeMute(false); + m_bgMusicLPW->setObjectName(tr("Background Music Level and Pan")); + connect(m_bgMusicLPW, SIGNAL(levelChanged(float)), this, SLOT(backgroundMusicGainChanged(float))); + connect(m_bgMusicLPW, SIGNAL(panChanged(float)), this, SLOT(backgroundMusicPanChanged(float))); + if (m_withSonification) { m_pitchLPW = new LevelPanToolButton(frame); @@ -277,6 +476,7 @@ MainWindow::MainWindow(AudioMode audioMode, layout->setSpacing(4); layout->addWidget(m_overview, 0, 1); + layout->addWidget(m_songScroll, 0, 1); // never both on show layout->addWidget(scroll, 1, 1); layout->setColumnStretch(1, 10); @@ -289,6 +489,62 @@ MainWindow::MainWindow(AudioMode audioMode, connect(m_analyser, SIGNAL(layersChanged()), this, SLOT(updateMenuStates())); + m_alternatePitch = new AlternatePitchTrack(this); + connect(m_analyser, SIGNAL(layersChanged()), + this, SLOT(syncAlternatePitchTrack())); + + // The song scroll bar draws the reference's pitch, whose layer and + // model are replaced when the reference is analysed again + connect(m_analyser, &Analyser::layersChanged, + this, &MainWindow::syncSongScrollBar); + connect(m_analyser, &Analyser::initialAnalysisCompleted, + this, &MainWindow::syncSongScrollBar); + connect(m_analyser, &Analyser::rangedAnalysisMerged, + this, &MainWindow::syncSongScrollBar); + + m_takes = new SingingTakes(this); + m_coverageStrip = new CoverageStrip(this); + m_lyrics = new LyricsTrack(this); + m_lyricsEditor = new LyricsEditor(m_lyrics, this); + connect(m_lyricsEditor, &LyricsEditor::contextHelpChanged, + this, &MainWindow::contextHelpChanged); + m_lyricsEditor->setTextQuestion([this](QString &text, bool isNew) { + return askForLyricsWordText(text, isNew); + }); + + m_audioCheck = new AudioCheckRunner(this); + + // What may be chosen changes as a check begins and as it ends. The + // first progress comes from its first step, before anything is asked + connect(m_audioCheck, &AudioCheckRunner::progress, + this, [this]() { updateMenuStates(); }); + connect(m_audioCheck, &AudioCheckRunner::finished, + this, [this]() { updateMenuStates(); }); + +#ifdef TONY_DEV_CHECKS + // Likewise for the development checks, which run the check among + // stages of their own + m_devChecks = new DevChecks(this, m_audioCheck); + connect(m_devChecks, &DevChecks::progress, + this, [this]() { updateMenuStates(); }); + connect(m_devChecks, &DevChecks::finished, + this, [this]() { updateMenuStates(); }); +#endif + + // Often enough to stop a take that records into a selection well + // within the margin that follows the selection's end + m_takeTimer = new QTimer(this); + m_takeTimer->setInterval(100); + connect(m_takeTimer, SIGNAL(timeout()), this, SLOT(pollTakeProgress())); + + // Before the menus: their switches are in the View menu + m_compactLayout = new CompactLayout(this); + m_plotSize = new PlotSize(m_viewManager, this); + m_lyricsSize = new LyricsSize(this); + m_lyrics->setTextScale(m_lyricsSize->getTextScale()); + connect(m_lyricsSize, &LyricsSize::textScaleChanged, + m_lyrics, &LyricsTrack::setTextScale); + setupMenus(); setupToolbars(); setupHelpMenu(); @@ -297,6 +553,8 @@ MainWindow::MainWindow(AudioMode audioMode, finaliseMenus(); + setupCompactLayout(); + connect(m_viewManager, SIGNAL(activity(QString)), m_activityLog, SLOT(activityHappened(QString))); connect(m_playSource, SIGNAL(activity(QString)), @@ -308,6 +566,25 @@ MainWindow::MainWindow(AudioMode audioMode, connect(this, SIGNAL(replacedDocument()), this, SLOT(documentReplaced())); connect(this, SIGNAL(sessionLoaded()), this, SLOT(analyseNewMainModel())); connect(this, SIGNAL(audioFileLoaded()), this, SLOT(analyseNewMainModel())); + + // Connect record target signals for real-time pitch tracking + if (m_recordTarget) { + connect(m_recordTarget, SIGNAL(recordStatusChanged(bool)), + this, SLOT(recordingStarted())); + } + + // The device is kept running between takes (suspendAudioOnStop()), + // and suspended once it has idled for audioIdleSuspendMillis() + m_audioIdleTimer = new QTimer(this); + m_audioIdleTimer->setSingleShot(true); + connect(m_audioIdleTimer, &QTimer::timeout, + this, &MainWindow::suspendIdleAudio); + connect(m_playSource, &AudioCallbackPlaySource::playStatusChanged, + this, &MainWindow::audioActivityChanged); + if (m_recordTarget) { + connect(m_recordTarget, &AudioCallbackRecordTarget::recordStatusChanged, + this, &MainWindow::audioActivityChanged); + } m_activityLog->hide(); setAudioRecordMode(RecordReplaceSession); @@ -333,8 +610,66 @@ MainWindow::MainWindow(AudioMode audioMode, MainWindow::~MainWindow() { + // The check's dialog first, as it holds the runner and the dev + // checks; then the dev checks, which drive the runner; then a check + // still running ends here, before anything it reads goes + delete m_calibrateAudioDialog; + m_calibrateAudioDialog = nullptr; +#ifdef TONY_DEV_CHECKS + delete m_devChecks; + m_devChecks = nullptr; +#endif + delete m_audioCheck; + m_audioCheck = nullptr; + + // Nothing must poll a take while the window is coming down + stopTakePolling(); + + // The command history is a singleton and outlives the window, and a + // take's command holds the window it belongs to. closeSession() has + // usually cleared it already; this is for the paths that do not go + // through it. Before anything is torn down, while a command that has + // a layer to delete can still find the document + m_openTakeCommand = nullptr; + CommandHistory::getInstance()->clear(); + + // Clean up secondary state that may not have been torn down if the + // window was closed without going through closeSession() (e.g. on + // application exit via the window close button). + m_liveDotsFeed.stop(); + if (m_realtimePitchTracker) { + m_realtimePitchTracker->stop(); + delete m_realtimePitchTracker; + m_realtimePitchTracker = nullptr; + } + // m_realtimePitchLayer is a Layer* owned by the Document (registered via + // createEmptyLayer / deleteLayer). The Document is owned by MainWindowBase + // and will be destroyed after this destructor returns, so we only null + // the pointer here — the Document will release the layer. + m_realtimePitchLayer = nullptr; + + // m_analyser2 is a plain heap-allocated QObject (no Qt parent, not owned + // by the Document). We must delete it explicitly. + if (m_analyser2) { + delete m_analyser2; + m_analyser2 = nullptr; + } + // Before the base class deletes the document: it only watches the + // document's layers, it does not own them + delete m_alternatePitch; + m_alternatePitch = nullptr; + delete m_coverageStrip; + m_coverageStrip = nullptr; + // Before the lyrics track, which it finds the lyrics through + delete m_lyricsEditor; + m_lyricsEditor = nullptr; + delete m_lyrics; + m_lyrics = nullptr; delete m_analyser; delete m_keyReference; +#ifdef Q_OS_ANDROID + delete m_storage; +#endif Profiles::getInstance()->dump(); } @@ -350,6 +685,11 @@ MainWindow::setupMenus() // workaround, to remove the appmenu-qt5 package, but that is // awkward and the problem is so severe that it merits disabling // the system menubar integration altogether. Like this: + // + // Android defines Q_OS_LINUX as well, and there too the bar stays + // in the window: a native one would be an options menu in an + // action bar that Android adds above the window, taller than the + // bar it replaces. menuBar()->setNativeMenuBar(false); #endif @@ -365,6 +705,7 @@ MainWindow::setupMenus() setupEditMenu(); setupViewMenu(); setupAnalysisMenu(); + setupTakesMenu(); m_mainMenusCreated = true; } @@ -377,6 +718,9 @@ MainWindow::setupFileMenu() QMenu *menu = menuBar()->addMenu(tr("&File")); menu->setTearOffEnabled(true); QToolBar *toolbar = addToolBar(tr("File Toolbar")); + // The toolbars are named for QMainWindow::saveState(), which tells + // them apart by name (CompactLayout's tests compare what it saves) + toolbar->setObjectName("File Toolbar"); m_keyReference->setCategory(tr("File and Session Management")); @@ -436,6 +780,41 @@ MainWindow::setupFileMenu() menu->addSeparator(); + m_loadSingingTrackAction = new QAction(il.load("fileopen"), tr("Load &Singing Track..."), this); + m_loadSingingTrackAction->setShortcut(tr("Ctrl+Shift+R")); + m_loadSingingTrackAction->setStatusTip(tr("Load a second audio file to analyse as the singing track, overlaid on the reference track")); + connect(m_loadSingingTrackAction, SIGNAL(triggered()), this, SLOT(openSingingTrack())); + connect(this, SIGNAL(canPlay(bool)), m_loadSingingTrackAction, SLOT(setEnabled(bool))); + m_keyReference->registerShortcut(m_loadSingingTrackAction); + menu->addAction(m_loadSingingTrackAction); + + m_loadBackgroundMusicAction = new QAction(il.load("fileopen"), tr("Load &Background Music..."), this); + m_loadBackgroundMusicAction->setStatusTip(tr("Load an audio file to play as background music alongside the reference track (not analysed)")); + connect(m_loadBackgroundMusicAction, SIGNAL(triggered()), this, SLOT(openBackgroundMusic())); + connect(this, SIGNAL(canPlay(bool)), m_loadBackgroundMusicAction, SLOT(setEnabled(bool))); + menu->addAction(m_loadBackgroundMusicAction); + + // Enabled in updateMenuStates() + m_importLyricsAction = new QAction(il.load("fileopen"), tr("Import &Lyrics..."), this); + m_importLyricsAction->setStatusTip(tr("Import timed lyrics from a TTML or LRC file, to be shown along the bottom of the pane")); + m_importLyricsAction->setEnabled(false); + connect(m_importLyricsAction, &QAction::triggered, this, &MainWindow::importLyrics); + menu->addAction(m_importLyricsAction); + + m_exportLyricsAction = new QAction(tr("Expor&t Lyrics..."), this); + m_exportLyricsAction->setStatusTip(tr("Write the lyrics, as they are now, to a TTML file")); + m_exportLyricsAction->setEnabled(false); + connect(m_exportLyricsAction, &QAction::triggered, this, &MainWindow::exportLyrics); + menu->addAction(m_exportLyricsAction); + + m_removeLyricsAction = new QAction(tr("Remove Lyrics"), this); + m_removeLyricsAction->setStatusTip(tr("Take the imported lyrics out of the session")); + m_removeLyricsAction->setEnabled(false); + connect(m_removeLyricsAction, &QAction::triggered, this, &MainWindow::removeLyrics); + menu->addAction(m_removeLyricsAction); + + menu->addSeparator(); + action = new QAction(tr("I&mport Pitch Track Data..."), this); action->setStatusTip(tr("Import pitch-track data from a CSV, RDF, or layer XML file")); connect(action, SIGNAL(triggered()), this, SLOT(importPitchLayer())); @@ -494,6 +873,7 @@ MainWindow::setupEditMenu() tr("Double-click left button to select the region of time corresponding to a note")); QToolBar *toolbar = addToolBar(tr("Tools Toolbar")); + toolbar->setObjectName("Tools Toolbar"); CommandHistory::getInstance()->registerToolbar(toolbar); @@ -513,6 +893,7 @@ MainWindow::setupEditMenu() group->addAction(action); menu->addAction(action); m_keyReference->registerShortcut(action); + m_navigateToolAction = action; m_keyReference->setCategory (tr("Navigate Tool Mouse Actions")); @@ -536,6 +917,7 @@ MainWindow::setupEditMenu() group->addAction(action); menu->addAction(action); m_keyReference->registerShortcut(action); + m_noteEditToolAction = action; m_keyReference->setCategory (tr("Note Edit Tool Mouse Actions")); @@ -680,6 +1062,66 @@ MainWindow::setupEditMenu() connect(this, SIGNAL(canSnapNotes(bool)), action, SLOT(setEnabled(bool))); menu->addAction(action); m_rightButtonMenu->addAction(action); + + menu->addSeparator(); + m_rightButtonMenu->addSeparator(); + + m_keyReference->setCategory(tr("Singing Track")); + + // No shortcut for this one: the keys worth having in this menu are + // taken, and it is a step on the way to the erase rather than + // something to reach for on its own + m_selectRecordingAction = + new QAction(tr("Select Recording at Playhead"), this); + m_selectRecordingAction->setStatusTip + (tr("Select the range of recorded singing that the playback position is in")); + connect(m_selectRecordingAction, SIGNAL(triggered()), + this, SLOT(selectRecordingAtPlayhead())); + connect(this, SIGNAL(canSelectRecording(bool)), + m_selectRecordingAction, SLOT(setEnabled(bool))); + m_selectRecordingAction->setEnabled(false); + menu->addAction(m_selectRecordingAction); + m_rightButtonMenu->addAction(m_selectRecordingAction); + + m_eraseSingingAction = new QAction(tr("Erase Singing in Selection"), this); + // Ctrl+Backspace, the obvious partner to the Backspace of Delete + // Notes, is upstream Tony's Remove Pitches + m_eraseSingingAction->setShortcut(tr("Ctrl+D")); + // What a toolbar button shows when there is no icon: only the compact + // toolbar has one for it + m_eraseSingingAction->setIconText(tr("Erase")); + m_eraseSingingAction->setStatusTip + (tr("Remove the recorded singing within the selected region, leaving silence")); + m_keyReference->registerShortcut(m_eraseSingingAction); + connect(m_eraseSingingAction, SIGNAL(triggered()), + this, SLOT(eraseSingingInSelection())); + connect(this, SIGNAL(canEraseSinging(bool)), + m_eraseSingingAction, SLOT(setEnabled(bool))); + m_eraseSingingAction->setEnabled(false); + menu->addAction(m_eraseSingingAction); + m_rightButtonMenu->addAction(m_eraseSingingAction); + + menu->addSeparator(); + + // A mode, not a tool: the lyrics are never the pane's top layer, which + // is what the tools act on. Enabled and checked in updateMenuStates(). + // No shortcut: it is not switched on and off in the middle of things + m_editLyricsAction = new QAction(tr("Edit L&yrics"), this); + m_editLyricsAction->setCheckable(true); + m_editLyricsAction->setStatusTip(tr("Edit the words of the lyrics along the bottom of the pane: drag a start or end, Shift-drag to move all the words, double-click a word to change its text, right-click to add or delete one")); + m_editLyricsAction->setEnabled(false); + connect(m_editLyricsAction, &QAction::triggered, + this, &MainWindow::editLyricsToggled); + menu->addAction(m_editLyricsAction); + + // The same shift as a Shift-drag in edit mode, by a number: for an + // offset known beforehand, such as the lyrics exporter's + m_shiftLyricsAction = new QAction(tr("S&hift Lyrics..."), this); + m_shiftLyricsAction->setStatusTip(tr("Move all the words of the lyrics earlier or later by a number of seconds")); + m_shiftLyricsAction->setEnabled(false); + connect(m_shiftLyricsAction, &QAction::triggered, + this, &MainWindow::shiftLyrics); + menu->addAction(m_shiftLyricsAction); } void @@ -723,6 +1165,7 @@ MainWindow::setupViewMenu() connect(this, SIGNAL(canZoom(bool)), action, SLOT(setEnabled(bool))); m_keyReference->registerShortcut(action); menu->addAction(action); + m_zoomInAction = action; action = new QAction(il.load("zoom-out"), tr("Zoom &Out"), this); @@ -732,6 +1175,7 @@ MainWindow::setupViewMenu() connect(this, SIGNAL(canZoom(bool)), action, SLOT(setEnabled(bool))); m_keyReference->registerShortcut(action); menu->addAction(action); + m_zoomOutAction = action; action = new QAction(tr("Restore &Default Zoom"), this); action->setStatusTip(tr("Restore the zoom level to the default")); @@ -754,6 +1198,22 @@ MainWindow::setupViewMenu() action->setStatusTip(tr("Set the minimum and maximum frequencies in the visible display")); connect(action, SIGNAL(triggered()), this, SLOT(editDisplayExtents())); menu->addAction(action); + + menu->addSeparator(); + + menu->addAction(m_compactLayout->getAction()); + QMenu *plotSizeMenu = menu->addMenu(tr("Plot &Size")); + plotSizeMenu->addActions(m_plotSize->getActions()); + QMenu *lyricsSizeMenu = menu->addMenu(tr("Lyrics Si&ze")); + lyricsSizeMenu->addActions(m_lyricsSize->getActions()); + // Enabled and checked in updateLayerStatuses(). Not "Show &Lyrics": + // Peek Left has the L + m_showLyrics = new QAction(tr("Show L&yrics"), this); + m_showLyrics->setCheckable(true); + m_showLyrics->setStatusTip(tr("Show or hide the imported lyrics along the bottom of the pane")); + m_showLyrics->setEnabled(false); + connect(m_showLyrics, &QAction::triggered, this, &MainWindow::showLyricsToggled); + menu->addAction(m_showLyrics); } void @@ -816,6 +1276,63 @@ MainWindow::setupAnalysisMenu() updateAnalyseStates(); } +void +MainWindow::setupTakesMenu() +{ + if (m_mainMenusCreated) return; + + // The takes of the session: each is a whole singing performance of + // its own, with its own audio, pitch track and notes (spec 5.3). + // Switching between them is the combo box in the playback toolbar; + // making, copying, renaming and deleting them are here. None of it + // is undoable, and all of it clears the undo history but the rename + m_takesMenu = menuBar()->addMenu(tr("Ta&kes")); + m_takesMenu->setTearOffEnabled(true); + + m_keyReference->setCategory(tr("Takes")); + + m_newTakeAction = new QAction(tr("&New Empty Take"), this); + m_newTakeAction->setStatusTip + (tr("Start another take: an empty one, which the next recording " + "goes into, leaving this take as it is")); + connect(m_newTakeAction, SIGNAL(triggered()), this, SLOT(newEmptyTake())); + connect(this, SIGNAL(canChangeTakes(bool)), + m_newTakeAction, SLOT(setEnabled(bool))); + m_newTakeAction->setEnabled(false); + m_takesMenu->addAction(m_newTakeAction); + + m_duplicateTakeAction = new QAction(tr("&Duplicate Take"), this); + m_duplicateTakeAction->setStatusTip + (tr("Start another take holding a copy of this one, and carry on " + "in the copy")); + connect(m_duplicateTakeAction, SIGNAL(triggered()), + this, SLOT(duplicateTake())); + connect(this, SIGNAL(canActOnTake(bool)), + m_duplicateTakeAction, SLOT(setEnabled(bool))); + m_duplicateTakeAction->setEnabled(false); + m_takesMenu->addAction(m_duplicateTakeAction); + + m_takesMenu->addSeparator(); + + m_renameTakeAction = new QAction(tr("&Rename Take..."), this); + m_renameTakeAction->setStatusTip(tr("Give this take another name")); + connect(m_renameTakeAction, SIGNAL(triggered()), this, SLOT(renameTake())); + connect(this, SIGNAL(canActOnTake(bool)), + m_renameTakeAction, SLOT(setEnabled(bool))); + m_renameTakeAction->setEnabled(false); + m_takesMenu->addAction(m_renameTakeAction); + + m_deleteTakeAction = new QAction(tr("De&lete Take"), this); + m_deleteTakeAction->setStatusTip + (tr("Delete this take, with its pitch track and notes. Its audio " + "file is not deleted.")); + connect(m_deleteTakeAction, SIGNAL(triggered()), this, SLOT(deleteTake())); + connect(this, SIGNAL(canActOnTake(bool)), + m_deleteTakeAction, SLOT(setEnabled(bool))); + m_deleteTakeAction->setEnabled(false); + m_takesMenu->addAction(m_deleteTakeAction); +} + void MainWindow::resetAnalyseOptions() { @@ -990,10 +1507,20 @@ MainWindow::setupHelpMenu() connect(action, SIGNAL(triggered()), this, SLOT(whatsNew())); menu->addAction(action); - action = new QAction(tr("&About %1").arg(name), this); - action->setStatusTip(tr("Show information about %1").arg(name)); + action = new QAction(tr("&About %1").arg(name), this); + action->setStatusTip(tr("Show information about %1").arg(name)); connect(action, SIGNAL(triggered()), this, SLOT(about())); menu->addAction(action); + +#ifdef Q_OS_ANDROID + // The phone's system log needs a computer to read; this copy of what + // Tony wrote to it can be sent instead + menu->addSeparator(); + action = new QAction(tr("Save &Log..."), this); + action->setStatusTip(tr("Save a copy of %1's log, to send when something went wrong").arg(name)); + connect(action, &QAction::triggered, this, [this]() { saveLog(); }); + menu->addAction(action); +#endif } void @@ -1001,6 +1528,13 @@ MainWindow::setupRecentFilesMenu() { m_recentFilesMenu->clear(); vector files = m_recentFiles.getRecent(); +#ifdef Q_OS_ANDROID + // Only the files that are still where they were: nothing else could + // be opened from here, a content:// URI whose grant has gone included + QStringList usable = AndroidFiles::usableRecentFiles + (QStringList(files.begin(), files.end())); + files = vector(usable.begin(), usable.end()); +#endif for (size_t i = 0; i < files.size(); ++i) { QString path = files[i]; QAction *action = m_recentFilesMenu->addAction(path); @@ -1016,6 +1550,302 @@ MainWindow::setupRecentFilesMenu() } } +namespace { + +// The audio-device preference keys that MainWindowBase::createAudioIO() +// reads. The key is suffixed with the preferred driver when one has been +// pinned, so that a device name chosen for (say) JACK is not then offered +// to PortAudio. +static QString +audioDeviceSettingKey(QString base) +{ + QSettings settings; + settings.beginGroup("Preferences"); + QString implementation = settings.value("audio-target", "").toString(); + settings.endGroup(); + if (implementation == "" || implementation == "auto") return base; + return base + "-" + implementation; +} + +// Which bqaudioio implementation the device names should come from. The +// factory can only enumerate devices for a named implementation, so when +// the driver is left on "auto" we ask the only one that was built in -- +// which on Windows and macOS is always PortAudio. +static std::string +audioImplementationName() +{ + QSettings settings; + settings.beginGroup("Preferences"); + QString implementation = settings.value("audio-target", "").toString(); + settings.endGroup(); + + if (implementation != "" && implementation != "auto") { + return implementation.toStdString(); + } + + std::vector available = + breakfastquay::AudioFactory::getImplementationNames(); + if (available.size() == 1) return available[0]; + return {}; +} + +} + +void +MainWindow::buildAudioDeviceMenu(QMenu *menu, + QActionGroup *group, + const std::vector &names, + QString settingKey) +{ + QSettings settings; + settings.beginGroup("Preferences"); + QString current = settings.value(settingKey, "").toString(); + settings.endGroup(); + + for (QAction *a: group->actions()) { + group->removeAction(a); + } + menu->clear(); + + QAction *defaultAction = menu->addAction(tr("(System Default)")); + defaultAction->setCheckable(true); + defaultAction->setData(QString()); + defaultAction->setChecked(current == ""); + group->addAction(defaultAction); + + if (names.empty()) { + menu->addSeparator(); + menu->addAction(tr("(No devices found)"))->setEnabled(false); + return; + } + + menu->addSeparator(); + + bool haveCurrent = false; + + for (const std::string &name: names) { + QString qname = QString::fromStdString(name); + QAction *action = menu->addAction(qname); + action->setCheckable(true); + action->setData(qname); + if (qname == current) { + action->setChecked(true); + haveCurrent = true; + } + group->addAction(action); + } + + if (current != "" && !haveCurrent) { + // The chosen device has gone away. Show it anyway, marked as + // absent, rather than silently reverting the tick to the system + // default -- the setting is still in force and will take effect + // again when the device comes back. + menu->addSeparator(); + QAction *missing = menu->addAction + (tr("%1 (not connected)").arg(current)); + missing->setCheckable(true); + missing->setChecked(true); + missing->setData(current); + group->addAction(missing); + } +} + +void +MainWindow::rescanAudioDevices() +{ + if (!m_audioDeviceMenu || !m_audioInputDeviceMenu) return; + + // PortAudio enumerates the system's devices once, when it is + // initialised, and bqaudioio keeps it initialised for as long as an + // audio IO object exists. So a device that appeared after Tony started + // -- a Bluetooth speaker connected mid-session, typically -- is not in + // the list at all until the IO is torn down and rebuilt. Do that here, + // around the enumeration, so that opening either of these menus always + // shows what is actually connected now. + // + // Deleting the IO mid-recording would drop the take, so in that case + // leave the list as it stands. With no IO open there is nothing to + // tear down: the enumeration below initialises PortAudio itself and so + // gets a current list anyway. + bool canRebuild = (m_playTarget || m_audioIO) && + !(m_recordTarget && m_recordTarget->isRecording()); + + if (canRebuild) { + if (m_playSource && m_playSource->isPlaying()) { + stop(); + } + deleteAudioIO(); + } + + std::string implementation = audioImplementationName(); + + std::vector playbackNames = + breakfastquay::AudioFactory::getPlaybackDeviceNames(implementation); + std::vector recordNames = + breakfastquay::AudioFactory::getRecordDeviceNames(implementation); + + if (canRebuild) { + createAudioIO(); + } + + buildAudioDeviceMenu(m_audioDeviceMenu, + m_audioDeviceGroup, + playbackNames, + audioDeviceSettingKey("audio-playback-device")); + + buildAudioDeviceMenu(m_audioInputDeviceMenu, + m_audioInputDeviceGroup, + recordNames, + audioDeviceSettingKey("audio-record-device")); +} + +void +MainWindow::audioDeviceSelected(QAction *action) +{ + if (!action) return; + + QString key = audioDeviceSettingKey + (m_audioInputDeviceGroup->actions().contains(action) ? + "audio-record-device" : "audio-playback-device"); + + QSettings settings; + settings.beginGroup("Preferences"); + settings.setValue(key, action->data().toString()); + settings.endGroup(); + + if (m_playSource && m_playSource->isPlaying()) { + stop(); + } + + // Another device may record at another rate + m_lastRecordingRate = 0; + + recreateAudioIO(); +} + +QStringList +MainWindow::audioImplementationNames() const +{ + QStringList names; + for (const std::string &name : + breakfastquay::AudioFactory::getImplementationNames()) { + names << QString::fromStdString(name); + } + return names; +} + +void +MainWindow::nameDefaultAudioDriver() +{ + QSettings settings; + if (AudioDriverSettings::nameDefaultDriver + (settings, audioImplementationNames())) { + cerr << "MainWindow::nameDefaultAudioDriver: no audio driver was " + << "named; naming " + << AudioDriverSettings::currentImplementation(settings) + << endl; + } +} + +void +MainWindow::audioDriverChosen(QString) +{ + // As for another device: the driver opens the devices it names, + // which may record at another rate. The latency chosen for it is + // applied as the device is opened + if (m_playSource && m_playSource->isPlaying()) { + stop(); + } + m_lastRecordingRate = 0; + recreateAudioIO(); +} + +void +MainWindow::audioLatencyChosen(double) +{ + if (m_playSource && m_playSource->isPlaying()) { + stop(); + } + recreateAudioIO(); +} + +bool +MainWindow::suspendAudioOnStop() const +{ + // On the user's PC each take landed up to about 8 ms either way from + // the last, on MME and WASAPI alike, while one take's sweeps agreed + // within 0.3 ms: every start of the stream moved its input against + // its output. On the user's phone, through Bluetooth, takes landed + // up to 8.5 ms apart so. Kept running, every take of a session + // shares one alignment. Opening the device again (a driver, a + // latency or a device chosen, the device menus rescanning), or + // resuming it after it idled (audioIdleSuspendMillis()), still + // moves it + return false; +} + +int +MainWindow::audioIdleSuspendMillis() const +{ +#ifdef Q_OS_ANDROID + return 2 * 60 * 1000; +#else + return 0; +#endif +} + +void +MainWindow::audioActivityChanged() +{ + bool busy = (m_playSource && m_playSource->isPlaying()) || + (m_recordTarget && m_recordTarget->isRecording()); + int idle = audioIdleSuspendMillis(); + if (busy || idle <= 0) { + m_audioIdleTimer->stop(); + } else { + m_audioIdleTimer->start(idle); + } +} + +void +MainWindow::suspendIdleAudio() +{ + m_audioIdleTimer->stop(); + if ((m_playSource && m_playSource->isPlaying()) || + (m_recordTarget && m_recordTarget->isRecording())) { + return; + } + if (!m_audioIO && !m_playTarget) return; + + // The next Play or Record resumes it, as the first did + cerr << "MainWindow::suspendIdleAudio: suspending the audio device" + << endl; + if (m_audioIO) m_audioIO->suspend(); + else m_playTarget->suspend(); +} + +#ifndef Q_OS_ANDROID +void +MainWindow::createAudioIO() +{ + if (m_playTarget || m_audioIO) return; + + // The first device is opened lazily, with the first file or the + // first take, and svapp opens it for the driver the Preferences + // name, so a driver has to be named by then + nameDefaultAudioDriver(); + if (m_audioDriverMenus) m_audioDriverMenus->applyLatency(); + + openAudioIO(); +} + +void +MainWindow::openAudioIO() +{ + MainWindowBase::createAudioIO(); +} +#endif + void MainWindow::setupToolbars() { @@ -1029,6 +1859,7 @@ MainWindow::setupToolbars() m_rightButtonPlaybackMenu = m_rightButtonMenu->addMenu(tr("Playback")); QToolBar *toolbar = addToolBar(tr("Playback Toolbar")); + toolbar->setObjectName("Playback Toolbar"); QAction *rwdStartAction = toolbar->addAction(il.load("rewind-start"), tr("Rewind to Start")); @@ -1055,6 +1886,7 @@ MainWindow::setupToolbars() connect(m_playSource, SIGNAL(playStatusChanged(bool)), playAction, SLOT(setChecked(bool))); connect(this, SIGNAL(canPlay(bool)), playAction, SLOT(setEnabled(bool))); + m_playAction = playAction; m_ffwdAction = toolbar->addAction(il.load("ffwd"), tr("Fast Forward")); @@ -1074,16 +1906,44 @@ MainWindow::setupToolbars() tr("Record")); recordAction->setCheckable(true); recordAction->setShortcut(tr("Ctrl+Space")); - recordAction->setStatusTip(tr("Record a new audio file")); - connect(recordAction, SIGNAL(triggered()), this, SLOT(record())); + recordAction->setStatusTip(tr("Record a new audio file. If a reference track is already loaded, the recording is added as the singing track alongside it.")); + connect(recordAction, &QAction::triggered, + this, &MainWindow::recordPressed); connect(m_recordTarget, SIGNAL(recordStatusChanged(bool)), recordAction, SLOT(setChecked(bool))); connect(m_recordTarget, SIGNAL(recordCompleted()), this, SLOT(analyseNow())); connect(this, SIGNAL(canRecord(bool)), recordAction, SLOT(setEnabled(bool))); + m_recordAction = recordAction; + + // The takes of the session, beside the recording controls: choosing + // one shows it, with its audio, pitch track and notes (spec 5.3). + // Making and deleting takes is the Takes menu + { + QLabel *takeLabel = new QLabel(tr(" Take:")); + QFont f = takeLabel->font(); + f.setPointSize(f.pointSize() - 1); + takeLabel->setFont(f); + takeLabel->setEnabled(false); // greyed out — purely decorative + toolbar->addWidget(takeLabel); + } + + m_takeCombo = new QComboBox; + m_takeCombo->setObjectName(tr("Take")); + m_takeCombo->setToolTip(tr("The take that is shown and played: its " + "audio, its pitch track and its notes")); + m_takeCombo->setMinimumContentsLength(8); + m_takeCombo->setSizeAdjustPolicy(QComboBox::AdjustToContents); + m_takeCombo->setEnabled(false); + connect(m_takeCombo, SIGNAL(currentIndexChanged(int)), + this, SLOT(takeChosenInCombo(int))); + connect(this, SIGNAL(canChangeTakes(bool)), + m_takeCombo, SLOT(setEnabled(bool))); + toolbar->addWidget(m_takeCombo); toolbar = addToolBar(tr("Play Mode Toolbar")); + toolbar->setObjectName("Play Mode Toolbar"); QAction *psAction = toolbar->addAction(il.load("playselection"), tr("Constrain Playback to Selection")); @@ -1163,6 +2023,67 @@ MainWindow::setupToolbars() menu->addAction(recordAction); menu->addSeparator(); + // The driver and the latency asked of it, before the devices, which + // are the driver's own + m_audioDriverMenus = new AudioDriverMenus + (menu, [this]() { return audioImplementationNames(); }, this); + connect(m_audioDriverMenus, &AudioDriverMenus::driverChosen, + this, &MainWindow::audioDriverChosen); + connect(m_audioDriverMenus, &AudioDriverMenus::latencyChosen, + this, &MainWindow::audioLatencyChosen); + + // Before anything in this menu reads the driver: the device menus + // list its devices, and the latency line looks its figure up + connect(menu, &QMenu::aboutToShow, + this, &MainWindow::nameDefaultAudioDriver); + + m_audioDeviceMenu = menu->addMenu(tr("Audio Output &Device")); + m_audioDeviceMenu->setStatusTip(tr("Choose which device Tony plays through")); + m_audioDeviceGroup = new QActionGroup(this); + m_audioDeviceGroup->setExclusive(true); + + m_audioInputDeviceMenu = menu->addMenu(tr("Audio &Input Device")); + m_audioInputDeviceMenu->setStatusTip(tr("Choose which device Tony records from")); + m_audioInputDeviceGroup = new QActionGroup(this); + m_audioInputDeviceGroup->setExclusive(true); + + for (QMenu *m: { m_audioDeviceMenu, m_audioInputDeviceMenu }) { + connect(m, SIGNAL(aboutToShow()), this, SLOT(rescanAudioDevices())); + // Placeholder so that the submenu is not empty: an empty submenu is + // drawn disabled and never emits aboutToShow, which is where the + // real device list is built. + m->addAction(tr("(Scanning...)"))->setEnabled(false); + } + + for (QActionGroup *g: { m_audioDeviceGroup, m_audioInputDeviceGroup }) { + connect(g, SIGNAL(triggered(QAction *)), + this, SLOT(audioDeviceSelected(QAction *))); + } + + // The audio check, and the latency takes are placed with: a line to + // read, never chosen, brought up to date whenever the menu opens + m_calibrateAudioAction = menu->addAction(tr("&Calibrate Audio...")); + m_calibrateAudioAction->setStatusTip + (tr("Measure how late recordings arrive through these devices, with " + "an earcup held against the microphone")); + connect(m_calibrateAudioAction, &QAction::triggered, + this, &MainWindow::calibrateAudio); + + m_latencyLineAction = menu->addAction(QString()); + m_latencyLineAction->setEnabled(false); + + m_forgetLatencyAction = menu->addAction(tr("&Forget Measured Latency")); + m_forgetLatencyAction->setStatusTip + (tr("Place takes on these devices with the latency the driver " + "reports again")); + connect(m_forgetLatencyAction, &QAction::triggered, + this, [this]() { forgetMeasuredLatency(); }); + + connect(menu, &QMenu::aboutToShow, + this, &MainWindow::updateLatencyMenuLine); + updateLatencyMenuLine(); + menu->addSeparator(); + m_rightButtonPlaybackMenu->addAction(playAction); m_rightButtonPlaybackMenu->addAction(psAction); m_rightButtonPlaybackMenu->addAction(plAction); @@ -1208,13 +2129,27 @@ MainWindow::setupToolbars() m_rightButtonPlaybackMenu->addAction(normalAction); toolbar = new QToolBar(tr("Playback Controls")); + toolbar->setObjectName("Playback Controls"); addToolBar(Qt::BottomToolBarArea, toolbar); + m_playbackControlsToolBar = toolbar; toolbar->addWidget(m_playSpeed); toolbar->addWidget(m_fader); toolbar = addToolBar(tr("Show and Play")); + toolbar->setObjectName("Show and Play"); addToolBar(Qt::BottomToolBarArea, toolbar); + m_showAndPlayToolBar = toolbar; + + // "Reference:" label before the reference-track button group + { + QLabel *refLabel = new QLabel(tr(" Reference:")); + QFont f = refLabel->font(); + f.setPointSize(f.pointSize() - 1); + refLabel->setFont(f); + refLabel->setEnabled(false); // greyed out — purely decorative + toolbar->addWidget(refLabel); + } m_showAudio = toolbar->addAction(il.load("waveform"), tr("Show Audio")); m_showAudio->setCheckable(true); @@ -1285,6 +2220,216 @@ MainWindow::setupToolbars() m_playNotes = 0; } + // Singing track pitch track — preceded by a "Singing:" section label + spacer = new QLabel; + spacer->setFixedWidth(m_viewManager->scalePixelSize(30)); + toolbar->addWidget(spacer); + + { + QLabel *singLabel = new QLabel(tr("Singing:")); + QFont f = singLabel->font(); + f.setPointSize(f.pointSize() - 1); + singLabel->setFont(f); + singLabel->setEnabled(false); // greyed out — purely decorative + toolbar->addWidget(singLabel); + } + + // Use the "record" (microphone) icon to visually distinguish this from + // the reference pitch button which uses "values". + m_showSingingPitch = toolbar->addAction(il.load("record"), tr("Show Singing Pitch Track")); + m_showSingingPitch->setCheckable(true); + m_showSingingPitch->setToolTip(tr("Show/hide the singing track pitch (orange)")); + connect(m_showSingingPitch, &QAction::triggered, this, [this](bool checked) { + if (m_analyser2) { + m_analyser2->setVisible(Analyser::PitchTrack, checked); + } + if (m_realtimePitchLayer) { + m_realtimePitchLayer->showLayer(m_paneStack->getPane(0), checked); + } + }); + connect(this, SIGNAL(canShowRealtimePitch(bool)), m_showSingingPitch, SLOT(setEnabled(bool))); + m_showSingingPitch->setEnabled(false); + + // Singing track notes + spacer = new QLabel; + spacer->setFixedWidth(m_viewManager->scalePixelSize(10)); + toolbar->addWidget(spacer); + + m_showSingingNotes = toolbar->addAction(il.load("notes"), tr("Show Singing Notes")); + m_showSingingNotes->setCheckable(true); + m_showSingingNotes->setToolTip(tr("Show/hide the singing track notes (purple)")); + connect(m_showSingingNotes, &QAction::triggered, this, [this](bool checked) { + if (m_analyser2) { + m_analyser2->setVisible(Analyser::Notes, checked); + } + }); + connect(this, SIGNAL(canShowRealtimePitch(bool)), m_showSingingNotes, SLOT(setEnabled(bool))); + m_showSingingNotes->setEnabled(false); + + // Singing track audio playback toggle + spacer = new QLabel; + spacer->setFixedWidth(m_viewManager->scalePixelSize(10)); + toolbar->addWidget(spacer); + + m_playSingingAudio = toolbar->addAction(il.load("speaker"), tr("Play Singing Audio")); + m_playSingingAudio->setCheckable(true); + m_playSingingAudio->setChecked(true); + m_playSingingAudio->setToolTip(tr("Enable/disable playback of the recorded singing audio")); + connect(m_playSingingAudio, SIGNAL(triggered()), this, SLOT(playSingingAudioToggled())); + connect(this, SIGNAL(canShowRealtimePitch(bool)), m_playSingingAudio, SLOT(setEnabled(bool))); + m_playSingingAudio->setEnabled(false); + + // Play reference track while recording — lets the singer hear the + // reference audio through headphones to time their performance. + spacer = new QLabel; + spacer->setFixedWidth(m_viewManager->scalePixelSize(30)); + toolbar->addWidget(spacer); + + { + QLabel *recLabel = new QLabel(tr("While recording:")); + QFont f = recLabel->font(); + f.setPointSize(f.pointSize() - 1); + recLabel->setFont(f); + recLabel->setEnabled(false); + toolbar->addWidget(recLabel); + } + + m_playRefWhileRecording = toolbar->addAction(il.load("speaker"), + tr("Play Reference While Recording")); + m_playRefWhileRecording->setCheckable(true); + { + QSettings settings; + settings.beginGroup("MainWindow"); + m_playRefWhileRecording->setChecked( + settings.value("playrefwhilerecording", false).toBool()); + settings.endGroup(); + } + m_playRefWhileRecording->setToolTip( + tr("Play the reference track through speakers/headphones during recording " + "so you can time your singing against it")); + connect(m_playRefWhileRecording, &QAction::toggled, this, [this](bool on) { + QSettings settings; + settings.beginGroup("MainWindow"); + settings.setValue("playrefwhilerecording", on); + settings.endGroup(); + }); + connect(this, SIGNAL(canPlay(bool)), m_playRefWhileRecording, SLOT(setEnabled(bool))); + + // Pre-roll: a lead-in before the take's position, heard but not + // recorded over, so the singer comes in in time. Its length is the + // QSettings value MainWindow/prerollseconds, with no UI to change it. + m_preRoll = toolbar->addAction(il.load("rewind"), tr("Pre-roll")); + m_preRoll->setCheckable(true); + { + QSettings settings; + settings.beginGroup("MainWindow"); + m_preRoll->setChecked(settings.value("preroll", false).toBool()); + settings.endGroup(); + } + m_preRoll->setToolTip( + tr("Play the reference from a few seconds before the recording " + "position, counting down, so that you can come in in time. " + "Nothing before the position is recorded over.")); + connect(m_preRoll, &QAction::toggled, this, [this](bool on) { + QSettings settings; + settings.beginGroup("MainWindow"); + settings.setValue("preroll", on); + settings.endGroup(); + }); + connect(this, SIGNAL(canPlay(bool)), m_preRoll, SLOT(setEnabled(bool))); + + // Punch in and out: record the selected range and nothing else. + // The selection says where the take starts and where it stops, so + // the singer need not reach for Stop. + m_recordIntoSelection = toolbar->addAction(il.load("playselection"), + tr("Record into Selection")); + m_recordIntoSelection->setCheckable(true); + { + QSettings settings; + settings.beginGroup("MainWindow"); + m_recordIntoSelection->setChecked( + settings.value("recordintoselection", false).toBool()); + settings.endGroup(); + } + m_recordIntoSelection->setToolTip( + tr("Record into the selected range only: recording starts at the " + "start of the selection, whatever the playhead says, and stops " + "by itself at its end")); + connect(m_recordIntoSelection, &QAction::toggled, this, [this](bool on) { + QSettings settings; + settings.beginGroup("MainWindow"); + settings.setValue("recordintoselection", on); + settings.endGroup(); + }); + connect(this, SIGNAL(canPlay(bool)), m_recordIntoSelection, + SLOT(setEnabled(bool))); + + // The alternate pitch track: the reference pitch an octave or more + // up or down, for the singer to follow in place of the reference. + spacer = new QLabel; + spacer->setFixedWidth(m_viewManager->scalePixelSize(30)); + toolbar->addWidget(spacer); + + { + QLabel *followLabel = new QLabel(tr("Follow:")); + QFont f = followLabel->font(); + f.setPointSize(f.pointSize() - 1); + followLabel->setFont(f); + followLabel->setEnabled(false); + toolbar->addWidget(followLabel); + } + + m_showAlternatePitch = toolbar->addAction(il.load("values"), + tr("Alternate Pitch Track")); + m_showAlternatePitch->setCheckable(true); + connect(m_showAlternatePitch, SIGNAL(triggered()), + this, SLOT(alternatePitchToggled())); + + // No icons for these; the toolbar shows the text + m_alternatePitchDownAction = toolbar->addAction(tr("8vb")); + m_alternatePitchDownAction->setToolTip + (tr("Move the alternate pitch track down an octave")); + m_alternatePitchDownAction->setStatusTip + (tr("Move the alternate pitch track down an octave")); + connect(m_alternatePitchDownAction, SIGNAL(triggered()), + this, SLOT(alternatePitchDown())); + + m_alternatePitchUpAction = toolbar->addAction(tr("8va")); + m_alternatePitchUpAction->setToolTip + (tr("Move the alternate pitch track up an octave")); + m_alternatePitchUpAction->setStatusTip + (tr("Move the alternate pitch track up an octave")); + connect(m_alternatePitchUpAction, SIGNAL(triggered()), + this, SLOT(alternatePitchUp())); + + // Background music section: an additional audio track that plays + // alongside the reference track but is never analysed. + spacer = new QLabel; + spacer->setFixedWidth(m_viewManager->scalePixelSize(30)); + toolbar->addWidget(spacer); + + { + QLabel *bgLabel = new QLabel(tr("Background:")); + QFont f = bgLabel->font(); + f.setPointSize(f.pointSize() - 1); + bgLabel->setFont(f); + bgLabel->setEnabled(false); + toolbar->addWidget(bgLabel); + } + + m_playBackgroundMusic = toolbar->addAction(il.load("speaker"), tr("Mix Background Music")); + m_playBackgroundMusic->setCheckable(true); + m_playBackgroundMusic->setChecked(true); + m_playBackgroundMusic->setToolTip( + tr("Enable/disable mixing the background music track during playback and recording")); + m_playBackgroundMusic->setEnabled(false); + connect(m_playBackgroundMusic, SIGNAL(triggered()), this, SLOT(backgroundMusicToggled())); + + m_bgMusicLPW->setImageSize(lpwSize); + m_bgMusicLPW->setBigImageSize(bigLpwSize); + m_bgMusicLPW->setEnabled(false); + toolbar->addWidget(m_bgMusicLPW); + // Spectrogram spacer = new QLabel; spacer->setFixedWidth(m_viewManager->scalePixelSize(30)); @@ -1307,6 +2452,49 @@ MainWindow::setupToolbars() // QTimer::singleShot(500, this, SLOT(betaReleaseWarning())); } +void +MainWindow::setupCompactLayout() +{ + CompactLayout::Parts parts; + + parts.play = m_playAction; + parts.record = m_recordAction; + parts.recordIntoSelection = m_recordIntoSelection; + parts.takeBox = m_takeCombo; + parts.erase = m_eraseSingingAction; + parts.zoomIn = m_zoomInAction; + parts.zoomOut = m_zoomOutAction; + + parts.navigateTool = m_navigateToolAction; + + parts.panel = { m_playbackControlsToolBar, m_showAndPlayToolBar }; + + // Notes are edited on the desktop; a phone has no device to choose + parts.hiddenActions = { m_navigateToolAction, m_noteEditToolAction }; + for (QMenu *menu: { m_audioDeviceMenu, m_audioInputDeviceMenu }) { + if (menu) parts.hiddenActions.push_back(menu->menuAction()); + } + if (m_audioDriverMenus) { + for (QMenu *menu: { m_audioDriverMenus->driverMenu(), + m_audioDriverMenus->latencyMenu() }) { + parts.hiddenActions.push_back(menu->menuAction()); + } + } + + // For room: the panes are what a phone's height is wanted for. The + // song scroll bar, a thin strip, navigates the song in its place + parts.hiddenWidgets = { m_overview }; + parts.shownWidgets = { m_songScroll }; + + m_compactLayout->setParts(parts); +} + +void +MainWindow::setCompactLayout(bool on) +{ + m_compactLayout->setOn(on); +} + void MainWindow::moveOneNoteRight() @@ -1479,15 +2667,91 @@ MainWindow::updateMenuStates() emit canPlayPitch(havePitchTrack && haveMainModel && havePlayTarget); emit canPlayNotes(haveNotes && haveMainModel && havePlayTarget); - if (pitchCandidatesVisible) { - m_showCandidatesAction->setText(tr("Hide Pitch Candidates")); - m_showCandidatesAction->setStatusTip(tr("Remove the display of alternate pitch candidates for the selected region")); - } else { - m_showCandidatesAction->setText(tr("Show Pitch Candidates")); - m_showCandidatesAction->setStatusTip(tr("Show alternate pitch candidates for the selected region")); + // Enable singing-track toolbar buttons whenever a singing track analyser + // or a realtime (recording) pitch layer is active. + bool haveSinging = (m_analyser2 != nullptr) || (m_realtimePitchLayer != nullptr); + emit canShowRealtimePitch(haveSinging); + + // Editing the singing of a take: there has to be a take with + // something recorded in it, and no take being recorded just now. + // Erasing needs a selection to erase as well, and waits for the + // analysis of a recorded range: erasing swaps the take's audio, + // which would throw that analysis's result away (see + // eraseSingingInSelection()) + bool inTake = (m_recordTarget && m_recordTarget->isRecording()); + bool haveCoverage = m_takes && m_takes->haveTake() && + !m_takes->getCoverage().isEmpty(); + bool analysingRange = (m_analyser2 && m_analyser2->isAnalysingRange()); + emit canSelectRecording(haveCoverage && !inTake); + emit canEraseSinging(haveCoverage && !inTake && haveSelection && + !analysingRange); + + // Nor can playback be constrained to the selection during a take: + // see liftPlaySelectionForTake() + if (inTake) emit canPlaySelection(false); + + // The takes of the session: switching and making one need a session + // and nothing running, and the rest need a take to act on as well + bool canChange = takeOperationsAllowed(); + emit canChangeTakes(canChange); + emit canActOnTake(canChange && m_takes->getActiveIndex() >= 0); + + if (m_importLyricsAction) { + m_importLyricsAction->setEnabled(lyricsImportAllowed()); + } + if (m_exportLyricsAction) { + m_exportLyricsAction->setEnabled(m_lyrics && m_lyrics->isShown()); + } + if (m_removeLyricsAction) { + m_removeLyricsAction->setEnabled(m_lyrics && m_lyrics->isShown()); } - if (m_ffwdAction && m_rwdAction) { + // Edit mode goes off here whenever it is no longer to be had: Remove + // Lyrics, Show Lyrics, the base class's record() once the take has + // started, and closeSession() (by documentRestored()) all come + // through here. None of those is an undo or a redo, which come + // through here as well, and during which the drag that this finishes + // could not push its command + bool lyricsEditable = lyricsEditAllowed(); + if (!lyricsEditable && m_lyricsEditor && m_lyricsEditor->isEnabled()) { + setLyricsEditing(false); + } + if (m_editLyricsAction) { + m_editLyricsAction->setEnabled(lyricsEditable); + m_editLyricsAction->setChecked + (m_lyricsEditor && m_lyricsEditor->isEnabled()); + } + if (m_shiftLyricsAction) { + m_shiftLyricsAction->setEnabled(lyricsEditable); + } + + // The audio check records takes of its own, and keeps what it + // measures for the devices it started on. Record is shut after the + // base class has opened it: a press would stop the check's take, or + // record one of the user's into the check's session + bool checking = audioCheckRunning(); + if (checking) emit canRecord(false); + if (m_calibrateAudioAction) { + m_calibrateAudioAction->setEnabled(!inTake && !checking); + } + // Choosing either opens the device afresh + if (m_audioDriverMenus) { + m_audioDriverMenus->setEnabled(!inTake && !checking); + } + for (QMenu *m : { m_audioDeviceMenu, m_audioInputDeviceMenu }) { + if (m) m->menuAction()->setEnabled(!checking); + } + updateLatencyMenuLine(); + + if (pitchCandidatesVisible) { + m_showCandidatesAction->setText(tr("Hide Pitch Candidates")); + m_showCandidatesAction->setStatusTip(tr("Remove the display of alternate pitch candidates for the selected region")); + } else { + m_showCandidatesAction->setText(tr("Show Pitch Candidates")); + m_showCandidatesAction->setStatusTip(tr("Show alternate pitch candidates for the selected region")); + } + + if (m_ffwdAction && m_rwdAction) { if (haveCurrentTimeInstantsLayer) { m_ffwdAction->setText(tr("Fast Forward to Next Instant")); m_ffwdAction->setStatusTip(tr("Fast forward to the next time instant in the current layer")); @@ -1603,6 +2867,18 @@ MainWindow::playNotesToggled() updateLayerStatuses(); } +void +MainWindow::playSingingAudioToggled() +{ + if (m_singingAudioMutedForTake) { + // Muted whatever the button says; it takes effect after the take + m_singingAudioAfterTake = !m_singingAudioAfterTake; + } else if (m_analyser2) { + m_analyser2->toggleAudible(Analyser::Audio); + } + updateLayerStatuses(); +} + void MainWindow::updateLayerStatuses() { @@ -1625,6 +2901,86 @@ MainWindow::updateLayerStatuses() m_notesLPW->setPan(m_analyser->getPan(Analyser::Notes)); m_showSpect->setChecked(m_analyser->isVisible(Analyser::Spectrogram)); + + // Singing track controls: enabled when either a second analyser + // (post-recording full analysis) or the realtime pitch layer is active + bool haveSingingTrack = (m_analyser2 != nullptr) || (m_realtimePitchLayer != nullptr); + + if (m_showSingingPitch) { + m_showSingingPitch->setEnabled(haveSingingTrack); + if (m_analyser2) { + m_showSingingPitch->setChecked(m_analyser2->isVisible(Analyser::PitchTrack)); + } else if (m_realtimePitchLayer && m_paneStack && m_paneStack->getPaneCount() > 0) { + m_showSingingPitch->setChecked( + !m_realtimePitchLayer->isLayerDormant(m_paneStack->getPane(0))); + } else { + m_showSingingPitch->setChecked(false); + } + } + + if (m_showSingingNotes) { + m_showSingingNotes->setEnabled(m_analyser2 != nullptr); + if (m_analyser2) { + m_showSingingNotes->setChecked(m_analyser2->isVisible(Analyser::Notes)); + } else { + m_showSingingNotes->setChecked(false); + } + } + + if (m_playSingingAudio) { + m_playSingingAudio->setEnabled(m_analyser2 != nullptr); + if (m_singingAudioMutedForTake) { + m_playSingingAudio->setChecked(m_singingAudioAfterTake); + } else if (m_analyser2) { + m_playSingingAudio->setChecked(m_analyser2->isAudible(Analyser::Audio)); + } else { + m_playSingingAudio->setChecked(true); // default on when track arrives + } + } + + // Alternate pitch track: there to be had once there is a reference. + // No moving it during a take, when the singer is following it + if (m_showAlternatePitch) { + bool haveReference = (m_document && getMainModel() && + m_paneStack && m_paneStack->getPaneCount() > 0); + bool shown = m_alternatePitch->isShown(); + bool inTake = (m_recordTarget && m_recordTarget->isRecording()); + m_showAlternatePitch->setEnabled(haveReference && !inTake); + m_showAlternatePitch->setChecked(shown); + QString tip = tr("Show a copy of the reference pitch track %1 (brown), and follow that when recording") + .arg(AlternatePitchTrack::describe(m_alternatePitch->getOctaves())); + m_showAlternatePitch->setToolTip(tip); + m_showAlternatePitch->setStatusTip(tip); + m_alternatePitchUpAction->setEnabled + (shown && !inTake && m_alternatePitch->canStep(true)); + m_alternatePitchDownAction->setEnabled + (shown && !inTake && m_alternatePitch->canStep(false)); + } + + // Lyrics: shown or hidden once there are some + if (m_showLyrics && m_lyrics) { + m_showLyrics->setEnabled(m_lyrics->isShown()); + m_showLyrics->setChecked(m_lyrics->isVisible()); + } + + // Background music toggle: enabled when a background music track is loaded + if (m_playBackgroundMusic) { + bool haveBgMusic = (m_backgroundMusicLayer != nullptr); + m_playBackgroundMusic->setEnabled(haveBgMusic); + if (m_bgMusicLPW) m_bgMusicLPW->setEnabled(haveBgMusic); + if (haveBgMusic) { + auto params = m_backgroundMusicLayer->getPlayParameters(); + bool audible = params ? params->isPlayAudible() : true; + m_playBackgroundMusic->setChecked(audible); + if (m_bgMusicLPW) { + m_bgMusicLPW->setEnabled(audible); + m_bgMusicLPW->setLevel(params ? params->getPlayGain() : 1.f); + m_bgMusicLPW->setPan(params ? params->getPlayPan() : 0.f); + } + } else { + m_playBackgroundMusic->setChecked(true); // default on when track arrives + } + } } void @@ -1645,142 +3001,4939 @@ MainWindow::editDisplayExtents() "Hz", float(vmin), float(vmax), this); dialog.setRange(float(min), float(max)); - if (dialog.exec() == QDialog::Accepted) { - float fmin, fmax; - dialog.getRange(fmin, fmax); - min = fmin; - max = fmax; - if (min > max) { - double tmp = max; - max = min; - min = tmp; + if (dialog.exec() == QDialog::Accepted) { + float fmin, fmax; + dialog.getRange(fmin, fmax); + min = fmin; + max = fmax; + if (min > max) { + double tmp = max; + max = min; + min = tmp; + } + m_analyser->setDisplayFrequencyExtents(min, max); + } +} + +void +MainWindow::updateDescriptionLabel() +{ + // Nothing, we don't have one +} + +void +MainWindow::documentModified() +{ + MainWindowBase::documentModified(); +} + +void +MainWindow::documentRestored() +{ + MainWindowBase::documentRestored(); +} + +void +MainWindow::newSession() +{ + if (!checkSaveModified()) return; + + closeSession(); + createDocument(); + m_document->setAutoAlignment(true); + + Pane *pane = m_paneStack->addPane(); + pane->setPlaybackFollow(PlaybackScrollPage); + + m_viewManager->setGlobalCentreFrame + (pane->getFrameForX(width() / 2)); + + connect(pane, SIGNAL(contextHelpChanged(const QString &)), + this, SLOT(contextHelpChanged(const QString &))); + +// Layer *waveform = m_document->createMainModelLayer(LayerFactory::Waveform); +// m_document->addLayerToView(pane, waveform); + + m_overview->registerView(pane); + + CommandHistory::getInstance()->clear(); + CommandHistory::getInstance()->documentSaved(); + documentRestored(); + updateMenuStates(); +} + +void +MainWindow::documentReplaced() +{ + if (m_document) { + connect(m_document, SIGNAL(activity(QString)), + m_activityLog, SLOT(activityHappened(QString))); + } +} + +void +MainWindow::closeSession() +{ + if (!checkSaveModified()) return; + + // A check has nothing left to record into; a take of its own that is + // running is stopped through the Stop path, as the check's Cancel does. + // The runner first: one that is replacing the session for the dev + // checks carries on, and the dev checks see it running and carry on + // with it, as they do through a reopen of their own + if (m_audioCheck) m_audioCheck->sessionClosing(); +#ifdef TONY_DEV_CHECKS + if (m_devChecks) m_devChecks->sessionClosing(); +#endif + + // Nothing of a take that is still running outlives its session + stopTakePolling(); + + // Tear down singing track and realtime pitch layer before panes/document + // are destroyed, so they can cleanly remove their layers from the pane. + teardownRealtimePitchLayer(); + teardownRecordingLayer(); + teardownSingingTrackAnalyser(); + teardownBackgroundMusic(); + m_alternatePitch->hide(); + m_coverageStrip->hide(); + m_lyrics->hide(); + // The fade goes with the lyrics. m_analyser stays for the next file, + // and would make that one's waveform faded as well + updateWaveformFade(); + m_referencePitchHiddenForTake = false; + m_singingPitchHiddenForTake = false; + m_singingNotesHiddenForTake = false; + m_pendingSingingModelId = {}; + m_currentRecordingModelId = {}; + m_recordingAsSingingTrack = false; + m_singingAudioMutedForTake = false; + restorePlaySelectionAfterTake(); + m_analysedMainModelId = {}; + + // Nothing is left waiting for a merge, and the history that holds the + // commands is cleared at the end of this function + m_openTakeCommand = nullptr; + + // The takes of the session go with it, and so do the audio files this + // run wrote for them that nothing refers to any more (spec 5.4). Not + // the file the take is in, even in a session that was never saved: it + // is the only copy of the singing apart from the raw recordings. Not + // a file the user brought either -- only what Tony itself wrote + QStringList gone = m_takes->removeUnusedFiles(); + if (!gone.isEmpty()) { + cerr << "MainWindow::closeSession: deleted " << gone.size() + << " superseded take audio file(s)" << endl; + } + + // A takes folder this run made and that has nothing left in it goes as + // well (spec 6.4). One with anything at all in it stays: what is in + // it was not necessarily put there by us + for (const QString &folder : m_takeFoldersMade) { + QDir dir(folder); + if (!dir.exists()) continue; + if (!dir.isEmpty(QDir::AllEntries | QDir::Hidden | QDir::System | + QDir::NoDotAndDotDot)) continue; + if (QDir().rmdir(folder)) { + cerr << "MainWindow::closeSession: removed the empty takes folder " + << folder << endl; + } + } + m_takeFoldersMade.clear(); + + m_takes->clear(); + m_takePosition = 0; + m_takePreRoll = 0; + m_takeEnd = -1; + m_takeAnalysisRange = Coverage::Range(); + updateTakeCombo(); + + m_analyser->fileClosed(); + + while (m_paneStack->getPaneCount() > 0) { + + Pane *pane = m_paneStack->getPane(m_paneStack->getPaneCount() - 1); + + while (pane->getLayerCount() > 0) { + m_document->removeLayerFromView + (pane, pane->getLayer(pane->getLayerCount() - 1)); + } + + m_overview->unregisterView(pane); + m_paneStack->deletePane(pane); + } + + while (m_paneStack->getHiddenPaneCount() > 0) { + + Pane *pane = m_paneStack->getHiddenPane + (m_paneStack->getHiddenPaneCount() - 1); + + while (pane->getLayerCount() > 0) { + m_document->removeLayerFromView + (pane, pane->getLayer(pane->getLayerCount() - 1)); + } + + m_overview->unregisterView(pane); + m_paneStack->deletePane(pane); + } + + // m_pendingExtraPanes holds panes that were moved to m_hiddenPanes via + // hidePane() in record(). The getHiddenPaneCount() loop above already + // handled them — they are now deleted. Clear our list so the pointers + // are not used again. + m_pendingExtraPanes.clear(); + + delete m_document; + m_document = 0; + m_viewManager->clearSelections(); + m_timeRulerLayer = 0; // document owned this + + // No song and no pitch: nothing of this one left drawn + syncSongScrollBar(); + + m_sessionFile = ""; + + CommandHistory::getInstance()->clear(); + CommandHistory::getInstance()->documentSaved(); + documentRestored(); +} + +#ifdef Q_OS_ANDROID +QString +MainWindow::getOpenFileName(FileFinder::FileType type) +{ + // Layers and other data go through svgui's dialog as before + if (type != FileFinder::AudioFile && + type != FileFinder::SessionOrAudioFile && + type != FileFinder::SessionFile) { + return MainWindowBase::getOpenFileName(type); + } + + QString app = QApplication::applicationName(); + + // Every file is offered: a provider disables the files whose type is + // not among those asked for, and Android knows no type for .ton, nor + // Qt one Drive gives every audio file. What was picked is checked + // below instead + QFileDialog dialog(this, (type == FileFinder::AudioFile ? + tr("Select an audio file") : + tr("Select a session or audio file"))); + dialog.setAcceptMode(QFileDialog::AcceptOpen); + dialog.setFileMode(QFileDialog::ExistingFile); + + if (!dialog.exec()) return ""; + QList urls = dialog.selectedUrls(); + if (urls.empty() || urls[0].isEmpty()) return ""; + if (urls[0].isLocalFile()) return urls[0].toLocalFile(); + + QString uri = AndroidFiles::grantedUri(urls[0]); + + // The name the user knows the file by, which the file's provider + // gives: the URI need not contain it + QString name = AndroidStorage::displayName(uri); + if (name == "") name = urls[0].fileName(); + cerr << "MainWindow::getOpenFileName: picked " << uri << ", \"" + << name << "\"" << endl; + + bool session = (QFileInfo(name).suffix().toLower() == "ton"); + bool audio = AndroidFiles::hasExtensionIn + (name, AudioFileReaderFactory::getKnownExtensions()); + if (!audio && (!session || type == FileFinder::AudioFile)) { + QMessageBox::warning + (this, tr("Cannot open the file"), + (type == FileFinder::AudioFile ? + tr("\"%1\" is not an audio file %2 can open

%2 opens audio files such as WAV and MP3.

") : + tr("\"%1\" is not a file %2 can open

%2 opens sessions (.ton) and audio files such as WAV and MP3.

")) + .arg(name.toHtmlEscaped(), app) + pickDetails(uri, "", "")); + return ""; + } + + // A file in the phone's own storage has a path, and with All files + // access that is what is opened, as on the desktop: a session finds + // its audio and takes folder beside it, and a session saved beside + // audio opened so finds the audio. Asked for with a session, which + // cannot do without it, and the first time with audio, which can + bool hasPath = + (AndroidFiles::pathLookupFor(uri) != AndroidFiles::PathLookup::None); + if (hasPath && !AndroidStorage::hasAllFilesAccess() && + (session || !m_storage->hasAsked())) { + m_storage->ask + (session ? + tr("A session keeps its audio and its takes folder beside " + "it, and %1 opens and saves it there, where it is.") + .arg(app) : + tr("Audio opened where it is can have its session saved " + "beside it. Otherwise %1 copies the audio into its own " + "storage.").arg(app)); + } + + QString local, why; + if (!hasPath) { + why = tr("its provider gives no path for it"); + } else if (!AndroidStorage::hasAllFilesAccess()) { + why = tr("%1 has no All files access").arg(app); + } else { + local = AndroidStorage::pathFor(uri, why); + if (local != "" && QFileInfo(local).isFile()) { + cerr << "MainWindow::getOpenFileName: opening " << uri + << " where it is, " << local << endl; + return local; + } + if (local != "") why = tr("there is no file at that path"); + } + cerr << "MainWindow::getOpenFileName: no path for " << uri << ": " + << why << endl; + + // A session read through the picker comes without the audio and + // takes beside it: the picker lets Tony read the one file only + if (session) { + if (!hasPath) { + QMessageBox::warning + (this, tr("Cannot open the session"), + tr("The session cannot be opened from here

A session needs its audio and its takes folder beside it, and from here (a cloud app such as Drive) %1 is given the one file only.

Keep sessions in a folder of the phone's own storage, such as one a sync app (Syncthing, FolderSync) keeps in step with your computer, and open them by browsing to that folder in the picker.

") + .arg(app) + pickDetails(uri, local, why)); + } else if (!AndroidStorage::hasAllFilesAccess()) { + QMessageBox::warning + (this, tr("Cannot open the session"), + tr("%1 may not open the session where it is

A session needs its audio and its takes folder beside it, and %1 can read those only with All files access. Allow it when %1 asks, or in the phone's Settings, Apps, %1.

") + .arg(app) + pickDetails(uri, local, why)); + } else { + QMessageBox::warning + (this, tr("Cannot open the session"), + tr("The session was not found where it should be") + + pickDetails(uri, local, why)); + } + return ""; + } + + // Audio without a path is read through the picker's grant, from a + // file descriptor the provider opens: Qt's own content file engine + // rebuilds the URI, differently for a name with parentheses, and is + // then refused + QString dir = QStandardPaths::writableLocation + (QStandardPaths::AppDataLocation) + "/imported"; + QString error; + QString copy; + AndroidStorage::Document document; + if (document.open(uri, "r", error)) { + QFile in; + if (in.open(document.fd(), QIODevice::ReadOnly, + QFileDevice::DontCloseHandle)) { + copy = AndroidFiles::copyIn(in, uri, name, dir, error); + in.close(); + } else { + error = in.errorString(); + } + // A provider that streams the file through a pipe says at the + // close whether all of it came: a copy of part of it is no copy + QString closeError; + if (!document.close(closeError)) { + cerr << "MainWindow::getOpenFileName: closing " << uri << ": " + << closeError << endl; + if (copy != "") { + QFile::remove(copy); + copy = ""; + error = closeError; + } + } + } + if (copy == "") { + QMessageBox::critical + (this, tr("Failed to open file"), + tr("File open failed

\"%1\" could not be copied into %2's own storage: %3

") + .arg(name.toHtmlEscaped(), app, error.toHtmlEscaped()) + + pickDetails(uri, local, why)); + } + return copy; +} + +QString +MainWindow::pickDetails(QString uri, QString path, QString why) const +{ + // Small print for the user to pass on: which app's provider the file + // came from, where Tony looked for it, and why that did not do + QString provider = AndroidFiles::providerOf(uri); + QString details = "

"; + details += tr("From: %1").arg((provider != "" ? provider : uri) + .toHtmlEscaped()); + if (path != "") { + details += "
" + tr("Looked for at: %1").arg(path.toHtmlEscaped()); + } + if (why != "") { + details += "
" + tr("Because: %1").arg(why.toHtmlEscaped()); + } + details += "

"; + return details; +} + +void +MainWindow::saveLog() +{ + QString app = QApplication::applicationName(); + + QByteArray log = LogFile::contents(AndroidStorage::logPath()); + if (log.isEmpty()) { + QMessageBox::information + (this, tr("No log"), + tr("There is no log to save

%1 could not keep one on this phone.

").arg(app)); + return; + } + + saveTextThroughPicker(this, log, tr("Save the log"), + QString("tony-log-%1.txt") + .arg(QDateTime::currentDateTime() + .toString("yyyyMMdd-HHmmss")), + tr("log")); +} + +void +MainWindow::saveTextThroughPicker(QWidget *parent, const QByteArray &text, + QString title, QString suggestedName, + QString what) +{ + QFileDialog dialog(parent, title); + dialog.setAcceptMode(QFileDialog::AcceptSave); + dialog.setFileMode(QFileDialog::AnyFile); + dialog.setMimeTypeFilters({ "text/plain" }); + dialog.selectFile(suggestedName); + if (!dialog.exec()) return; + QList urls = dialog.selectedUrls(); + if (urls.empty() || urls[0].isEmpty()) return; + + // A new document the picker has made, through its grant: one file, + // which any provider can take, a cloud app's included + QString target = (urls[0].isLocalFile() ? urls[0].toLocalFile() : + AndroidFiles::grantedUri(urls[0])); + QString error; + QFile out; + bool opened = false; + AndroidStorage::Document document; + if (urls[0].isLocalFile()) { + out.setFileName(target); + opened = out.open(QIODevice::WriteOnly | QIODevice::Truncate); + if (!opened) error = out.errorString(); + } else { + // "wt", so that a document written over holds only what is + // written now; "w" if the provider will not take that, which for + // the new, empty document the picker made comes to the same + if (!document.open(target, "wt", error)) { + cerr << "MainWindow::saveTextThroughPicker: " << target + << " could not be opened with \"wt\": " << error + << "; trying \"w\"" << endl; + QString again; + if (!document.open(target, "w", again)) error = again; + } + if (document.isOpen()) { + opened = out.open(document.fd(), QIODevice::WriteOnly, + QFileDevice::DontCloseHandle); + if (!opened) error = out.errorString(); + } + } + + bool written = opened && out.write(text) == text.size() && out.flush(); + if (opened && !written) error = out.errorString(); + if (opened) out.close(); + + // What the file behind the descriptor holds before it goes back to + // the provider (-1 if the provider gave a pipe), and then the close, + // through the provider, which is when it takes what was written + qint64 inFile = document.fileSize(); + QString closeError; + if (!document.close(closeError) && written) { + written = false; + error = closeError; + } + + if (!written) { + cerr << "MainWindow::saveTextThroughPicker: could not write the " + << what << " to " << target << ": " << error << endl; + QMessageBox::critical + (parent, tr("Failed to save the %1").arg(what), + tr("The %1 was not saved

%2

") + .arg(what, error.toHtmlEscaped()) + pickDetails(target, "", "")); + return; + } + + if (urls[0].isLocalFile()) { + cerr << "MainWindow::saveTextThroughPicker: saved the " << what + << ", " << text.size() << " bytes, to " << target << endl; + return; + } + + // What the document holds now, as its provider tells anyone who asks. + // A provider hears of the close on a thread of its own and may say so + // a little later: asked again, for up to a second, while it gives + // another size than was written + QElapsedTimer waited; + waited.start(); + AndroidFiles::SavedSize saved; + QString sizeError; + for (int attempt = 1; ; ++attempt) { + sizeError = ""; + saved = AndroidFiles::savedSize + (text.size(), AndroidStorage::sizeOf(target, sizeError)); + if (!saved.differs() || attempt == 10) break; + QThread::msleep(100); + } + + cerr << "MainWindow::saveTextThroughPicker: wrote the " << what << ", " + << saved.written << " bytes, to " << target + << "; the file behind the descriptor held " << inFile + << " before the close; "; + if (saved.known()) { + cerr << "its provider says the document holds " << saved.held + << " bytes"; + } else { + cerr << "its provider gives no size for the document"; + if (sizeError != "") cerr << " (" << sizeError << ")"; + } + cerr << ", " << waited.elapsed() << " ms after the close" << endl; + + if (saved.differs()) { + QMessageBox::warning + (parent, tr("The %1 may be incomplete").arg(what), + tr("The %1 may not have been saved whole

%2 bytes were written to it, and the app that keeps it says it holds %3.

") + .arg(what).arg(saved.written).arg(saved.held) + + pickDetails(target, "", "")); + } +} + +bool +MainWindow::recentFileIsThere(QString path) +{ + if (!AndroidFiles::usableRecentFiles({ path }).empty()) return true; + + QString app = QApplication::applicationName(); + QString own = QStandardPaths::writableLocation + (QStandardPaths::AppDataLocation); + if (!path.startsWith(own) && !AndroidStorage::hasAllFilesAccess()) { + QMessageBox::warning + (this, tr("Cannot open the file"), + tr("%1 may not open \"%2\" where it is

That needs All files access, which %1 no longer has. Allow it in the phone's Settings, Apps, %1, or open the file with File, Open.

") + .arg(app, path.toHtmlEscaped())); + } else { + QMessageBox::warning + (this, tr("File not found"), + tr("\"%1\" is no longer there

It has been moved or deleted since it was last opened. Open it with File, Open from where it is now.

") + .arg(path.toHtmlEscaped())); + } + return false; +} + +QString +MainWindow::getSaveFileName(FileFinder::FileType type) +{ + // Exported layers, audio and images go through svgui's dialog as + // before + if (type != FileFinder::SessionFile) { + return MainWindowBase::getSaveFileName(type); + } + + QString app = QApplication::applicationName(); + + // Asked before the picker: without it there is nowhere a session can + // be saved, and the picker makes the document it is asked for + if (!AndroidStorage::hasAllFilesAccess() && + !m_storage->ask(tr("A session keeps its audio and its takes folder " + "beside it, and %1 saves it there, where it is.") + .arg(app))) { + return ""; + } + + QFileDialog dialog(this, tr("Select a session file")); + dialog.setAcceptMode(QFileDialog::AcceptSave); + dialog.setFileMode(QFileDialog::AnyFile); + + // The type of document the picker makes: Android knows nothing of + // .ton, and "*.ton" would come to this anyway + dialog.setMimeTypeFilters({ "application/octet-stream" }); + + // What the picker's name field starts with (EXTRA_TITLE). There is no + // default suffix: Qt adds that to the path of the URI, where it names + // another document + QString suggested = + AndroidFiles::suggestedSessionName(m_sessionFile, m_audioFile); + if (suggested != "") dialog.selectFile(suggested); + + if (!dialog.exec()) return ""; + QList urls = dialog.selectedUrls(); + if (urls.empty() || urls[0].isEmpty()) return ""; + QString picked = (urls[0].isLocalFile() ? urls[0].toLocalFile() : + AndroidFiles::grantedUri(urls[0])); + + // The picker has made an empty document of that name by now + QString local = picked, why; + if (!urls[0].isLocalFile()) { + local = AndroidStorage::pathFor(picked, why); + if (local != "" && !QFileInfo(local).exists()) { + why = tr("there is no file at that path"); + local = ""; + } + } + if (local == "") { + cerr << "MainWindow::getSaveFileName: no path for " << picked + << ": " << why << endl; + AndroidStorage::removeIfEmpty(picked); + QMessageBox::warning + (this, tr("Cannot save the session there"), + tr("The session cannot be saved there

A session keeps its audio and its takes folder beside it, which %1 can write only in a folder of the phone's own storage, not through a cloud app.

Browse to a folder of the phone's own storage in the picker, such as one a sync app (Syncthing, FolderSync) keeps in step with your computer.

") + .arg(app) + pickDetails(picked, "", why)); + return ""; + } + + QString name = AndroidFiles::sessionFileName(QFileInfo(local).fileName()); + if (name == "") { + AndroidFiles::removeIfEmpty(local); + QMessageBox::warning + (this, tr("No file name"), + tr("The session was not saved

It needs a name: type one in the picker's name field.

")); + return ""; + } + + // Named without the extension, the document the picker made is not + // the one saved: the extension is added, as the desktop's dialog adds + // it + QString path = QFileInfo(local).dir().filePath(name); + if (path != local) AndroidFiles::removeIfEmpty(local); + + // The picker makes an empty document, so one with something in it + // was there already, and the picker may not have asked + QFileInfo target(path); + if (target.exists() && target.size() > 0 && + QMessageBox::question + (this, tr("File exists"), + tr("File exists

The file \"%1\" already exists.\nDo you want to overwrite it?").arg(path), + QMessageBox::Ok, QMessageBox::Cancel) != QMessageBox::Ok) { + return ""; + } + + cerr << "MainWindow::getSaveFileName: the session goes to " << path + << " (picked as " << picked << ")" << endl; + return path; +} + +void +MainWindow::applicationStateChanged(Qt::ApplicationState state) +{ + if (state != Qt::ApplicationSuspended) return; + + cerr << "MainWindow::applicationStateChanged: going into the " + << "background" << endl; + + // A take keeps what was sung, through the Stop path of the Record + // button, as when the audio device fails (checkAudioDevice()) + if (m_recordTarget && m_recordTarget->isRecording()) { + record(); + } else if (m_playSource && m_playSource->isPlaying()) { + stop(); + } + + // Kept running between takes, the device would keep the microphone + // open, and Android silences a microphone in the background anyway + suspendIdleAudio(); + + // Only a session that has a file of its own: one never saved stays as + // it is, as there is no one to ask where it should go. Nor one that + // loaded without some of its audio, which its file would lose + if (!maySaveUnasked()) { + if (m_documentModified && sessionIsIncomplete()) { + cerr << "MainWindow::applicationStateChanged: the session " + << "loaded incomplete; it is not saved" << endl; + } + return; + } + + m_suspendSavePath = m_sessionFile; + saveWhenSuspended(); +} + +void +MainWindow::saveWhenSuspended() +{ + m_suspendSaveTimer->stop(); + + // Another session since, or saved since + if (m_suspendSavePath == "" || m_suspendSavePath != m_sessionFile || + !maySaveUnasked()) { + m_suspendSavePath = ""; + return; + } + + // Not in the middle of something that shows a box or the picker (the + // picker and the settings page send Tony into the background too), + // which may be about to save, or to decide not to + if (QThread::currentThread()->loopLevel() > 1) { + cerr << "MainWindow::saveWhenSuspended: a dialog is open; the " + << "session is not saved" << endl; + m_suspendSavePath = ""; + return; + } + + // Saving waits for the analysis of a take to be merged, which needs + // the event loop that Android is about to hold: the save is made + // once it is done, which is when Tony is back + if (m_analyser2 && m_analyser2->isAnalysingRange()) { + cerr << "MainWindow::saveWhenSuspended: the session is saved when " + << "the analysis of the take is done" << endl; + m_suspendSaveTimer->start(); + return; + } + + cerr << "MainWindow::saveWhenSuspended: saving " << m_sessionFile << endl; + m_suspendSavePath = ""; + saveSession(); +} + +void +MainWindow::createAudioIO() +{ + if (m_playTarget || m_audioIO) return; + if (m_audioMode == AUDIO_NONE) return; + + breakfastquay::ApplicationPlaybackSource *source = + m_playSource->getApplicationPlaybackSource(); + std::string error; + + // As MainWindowBase's: input and output once recording has been + // asked for, else the output alone, and the output alone too if + // there is no input to be had. Nor is there before the microphone + // may be used, which record() asks for first + if (m_audioMode == AUDIO_PLAYBACK_AND_RECORD && m_recordTarget && + microphoneAllowed()) { + OboeAudioIO *io = new OboeAudioIO(m_recordTarget, source); + if (io->isOK()) { + m_audioIO = io; + } else { + error = io->getStartupError(); + delete io; + } + } + + if (!m_audioIO) { + OboeAudioIO *io = new OboeAudioIO(nullptr, source); + if (io->isOK()) { + m_playTarget = io; + } else { + error = io->getStartupError(); + delete io; + } + } + + if (m_audioIO) { + m_audioIO->suspend(); + m_playSource->setSystemPlaybackTarget(m_audioIO); + } else if (m_playTarget) { + m_playTarget->suspend(); + m_playSource->setSystemPlaybackTarget(m_playTarget); + } else { + emit hideSplash(); + QMessageBox::warning + (this, tr("Couldn't open audio device"), + tr("No audio available

%1

Audio playback and recording will not be available.

") + .arg(QString::fromStdString(error).toHtmlEscaped())); + } +} + +bool +MainWindow::microphoneAllowed() const +{ + return qApp->checkPermission(QMicrophonePermission()) == + Qt::PermissionStatus::Granted; +} + +void +MainWindow::askForMicrophone(std::function granted) +{ + qApp->requestPermission + (QMicrophonePermission(), this, + [this, granted](const QPermission &permission) { + if (permission.status() == Qt::PermissionStatus::Granted) { + // What asked, answered at last + if (microphoneAllowed() && granted) granted(); + return; + } + QMessageBox::information + (this, tr("Microphone not allowed"), + tr("Recording needs the microphone

%1 may not use the microphone, so it can play but not record.

To allow it, open the phone's Settings, then Apps, %1, Permissions, Microphone.

") + .arg(QApplication::applicationName())); + }); +} + +void +MainWindow::checkAudioDevice() +{ + breakfastquay::SystemPlaybackTarget *device = m_audioIO; + if (!device) device = m_playTarget; + OboeAudioIO *io = dynamic_cast(device); + if (!io || !io->hasFailed()) return; + + // Whatever was going on stops. A take keeps what was sung, through + // the Stop path of the Record button + if (m_recordTarget && m_recordTarget->isRecording()) { + record(); + } else if (m_playSource && m_playSource->isPlaying()) { + stop(); + } + + // Reopened up to three times in ten seconds, as a headset can move + // the output and then the input. More is a device that fails as + // soon as it is open: it is left closed rather than opened over and + // over, and the next Record or file opened tries again + if (!m_audioDeviceReopened.isValid() || + m_audioDeviceReopened.elapsed() > 10000) { + m_audioDeviceReopened.start(); + m_audioDeviceReopens = 0; + } + if (++m_audioDeviceReopens > 3) { + cerr << "MainWindow::checkAudioDevice: the audio device keeps " + << "failing; closing it" << endl; + m_audioDeviceReopened.invalidate(); + deleteAudioIO(); + updateMenuStates(); + QMessageBox::warning + (this, tr("Audio device failed"), + tr("The audio device stopped working

It failed again each time it was opened. Recording, or opening a file, will try to open it again.

")); + return; + } + + cerr << "MainWindow::checkAudioDevice: the audio device failed or " + << "went away; opening it again" << endl; + recreateAudioIO(); + updateMenuStates(); + // Another route, perhaps, with a figure of its own or none + updateLatencyMenuLine(); +} +#endif + +void +MainWindow::openFile() +{ + QString orig = m_audioFile; + if (orig == "") orig = "."; + else orig = QFileInfo(orig).absoluteDir().canonicalPath(); + + QString path = getOpenFileName(FileFinder::SessionOrAudioFile); + + if (path.isEmpty()) return; + + FileOpenStatus status = openPath(path, ReplaceSession); + + if (status == FileOpenFailed) { + QMessageBox::critical(this, tr("Failed to open file"), + tr("File open failed

File \"%1\" could not be opened").arg(path)); + } else if (status == FileOpenWrongMode) { + QMessageBox::critical(this, tr("Failed to open file"), + tr("Audio required

Please load at least one audio file before importing annotation data")); + } +} + +void +MainWindow::openSingingTrack() +{ + QString path = getOpenFileName(FileFinder::AudioFile); + if (path.isEmpty()) return; + + loadSingingTrack(path); +} + +void +MainWindow::loadSingingTrack(QString path) +{ + // Load a second audio file as the "singing" track. + // This opens the file as an additional model alongside the main + // (reference) model, then runs pYIN on it in the secondary colour scheme. + + if (!m_document) { + QMessageBox::warning(this, tr("No session"), + tr("No session open

Please open a reference audio file first.")); + return; + } + + emit activity(tr("Load singing track \"%1\"").arg(path)); + + // A track the user loads is a take of its own, analysed in full, and + // the take that was on show is put away as it is (spec 5.3) + if (m_takes->getActiveIndex() >= 0) { + closeOpenTakeCommand(true); + deactivateTake(); + m_takes->addTake(); + putOtherTakeLayersAway(); + } + + ModelId singingModelId; + std::vector extraPanes; + FileOpenStatus status = + openSingingAudioFile(path, singingModelId, extraPanes); + + if (status == FileOpenFailed) { + QMessageBox::critical(this, tr("Failed to open singing track"), + tr("File open failed

File \"%1\" could not be opened").arg(path)); + return; + } else if (status == FileOpenWrongMode) { + QMessageBox::critical(this, tr("Failed to open singing track"), + tr("Audio required

Could not open \"%1\" as audio").arg(path)); + return; + } + + // Set up the secondary analyser NOW, before pruning the extra pane: + // the imported WaveformLayer in that pane is the only layer + // referencing the singing model, so deleting it first would make + // Document::releaseModel() free the model before it can be analysed. + // Once m_analyser2's own WaveformLayer references the model the orphan + // can go. This also clears m_pendingSingingModelId, so the + // analyseNewSingingModel() call queued by modelAdded() becomes a no-op. + analyseNewSingingModel(); + + // If the analyser setup above failed, nothing else references the + // singing model and pruning releases it, which is what we want. + for (Pane *extra : extraPanes) { + pruneExtraPane(extra, singingModelId); + } + + // The history goes, as it does on any change of take (spec 5.4): its + // take commands are about a take that is not on show any more, and + // openPath() has just pushed an "Import" command of its own whose + // pane was pruned away again above + clearTakeHistory(); + + updateTakeCombo(); +} + +MainWindow::FileOpenStatus +MainWindow::openSingingAudioFile(QString path, sv::ModelId &modelId, + std::vector &extraPanes) +{ + // Record the pane count before opening so we can collect any extra + // panes that openPath(CreateAdditionalModel) creates via + // AddPaneCommand. We want both tracks to share pane 0, not appear in + // separate panes. + int paneCountBefore = m_paneStack ? m_paneStack->getPaneCount() : 0; + + FileOpenStatus status = openPath(path, CreateAdditionalModel); + + // The extra pane holds the singing track's own imported waveform + // layer, which we do not want either. It is not pruned here: that + // layer is the only reference to the new model until the caller has + // one of its own, and pruning releases a model nothing references. + // Collected from the top down, which is the order they are removed in + if (m_paneStack) { + for (int i = m_paneStack->getPaneCount() - 1; + i >= paneCountBefore; --i) { + if (Pane *extra = m_paneStack->getPane(i)) { + extraPanes.push_back(extra); + } + } + } + + // modelAdded() fired synchronously inside openPath() and stored the + // new model's id in m_pendingSingingModelId. It is left there for + // analyseNewSingingModel() to take; a caller that sets its analyser up + // some other way has to clear it itself + modelId = m_pendingSingingModelId; + + return status; +} + +MainWindow::FileOpenStatus +MainWindow::openTakeAudioFile(QString path, sv::ModelId &modelId) +{ + // The audio file of a take, opened as a model of the document and + // nothing else: no pane, no layer, no entry in Recent Files and -- the + // point of it -- no command. + // + // openPath() cannot be used for these files. It makes a pane through + // an AddPaneCommand, and CommandHistory::addCommand() clears the redo + // stack and deletes what is on it: a command pushed while an undo or a + // redo is running destroys the very command that is running. Undo of + // a take swaps its audio, so this is not avoidable any other way. + modelId = {}; + + FileSource source(path); + if (!source.isAvailable()) return FileOpenFailed; + source.waitForData(); + + // The rate the rest of the session is at, as openAudio() works it out + sv_samplerate_t rate = 0; + if (Preferences::getInstance()->getFixedSampleRate() != 0) { + rate = Preferences::getInstance()->getFixedSampleRate(); + } else if (Preferences::getInstance()->getResampleOnLoad() && + getMainModel()) { + rate = getMainModel()->getSampleRate(); + } + + auto model = std::make_shared(source, rate); + if (!model->isOK()) return FileOpenFailed; + + ModelId id = ModelById::add(model); + m_document->addNonDerivedModel(id); + + // modelAdded() has just queued a call to set up a singing track on it, + // as it does for any audio model that is not the main one. The caller + // makes the analyser itself, so that call must find nothing to do + m_pendingSingingModelId = {}; + + modelId = id; + return FileOpenSucceeded; +} + +QString +MainWindow::swapSingingAudio(QString path) +{ + // The audio of a take is written again from scratch whenever a + // recording is spliced into it or a part of it is erased. Analysing + // the whole of it again would take as long as the song, so the pitch + // and notes layers stay as they are and the new file goes underneath + // them. + // + // What makes that possible: an Analyser claims a pitch or notes layer + // whose model has the analyser's own audio model as its source model. + // That is how a restored session's layers find their analyser; here we + // make it true of the new audio by hand and let the same scan do the + // rest. + + if (!m_document) return tr("There is no session to swap the audio of"); + + Pane *pane = m_paneStack ? m_paneStack->getPane(0) : nullptr; + if (!pane) return tr("There is no pane to swap the audio in"); + + if (!m_analyser2) { + return tr("There is no singing track to swap the audio of"); + } + + Layer *pitch = m_analyser2->getLayer(Analyser::PitchTrack); + Layer *notes = m_analyser2->getLayer(Analyser::Notes); + if (!pitch || !notes) { + return tr("The singing track has no pitch and notes layers to keep"); + } + + // What the swap must leave as it was. The analyser of the new audio + // starts from the settings the two analysers share, which know + // nothing of what a take has done to these layers + const Analyser::Component components[] = { + Analyser::Audio, Analyser::PitchTrack, Analyser::Notes + }; + const int componentCount = sizeof(components) / sizeof(components[0]); + bool visible[componentCount], audible[componentCount]; + for (int i = 0; i < componentCount; ++i) { + visible[i] = m_analyser2->isVisible(components[i]); + audible[i] = m_analyser2->isAudible(components[i]); + } + Layer *selected = pane->getSelectedLayer(); + + // 1. The new audio, as a model of the document and nothing more. + // First, so that a file that cannot be read disturbs nothing + ModelId newAudio; + FileOpenStatus status = openTakeAudioFile(path, newAudio); + + if (status != FileOpenSucceeded || newAudio.isNone()) { + return tr("The file \"%1\" could not be opened as audio").arg(path); + } + + // 2. The old audio's waveform layer goes, and with it the old audio; + // the pitch and notes layers stay in the pane with their events. The + // analyser has nothing left to lose by being deleted + m_analyser2->releaseLayers(); + delete m_analyser2; + m_analyser2 = nullptr; + + // 3. Those layers' models come from the new audio now. Nothing reads + // their source model between the release above and here + for (ModelId id : { pitch->getModel(), notes->getModel() }) { + if (auto model = ModelById::get(id)) { + model->setSourceModel(newAudio); + } + } + + // 4. An analyser for the new audio, which claims the two layers. No + // analysis (deferAnalysis): the layers hold the analysis of all of the + // take but the range that has just changed, and the take's coverage is + // not "the whole of this file" either + bool wasRebuilding = m_rebuildingTakeAudio; + m_rebuildingTakeAudio = true; + setupSingingTrackAnalyser(newAudio, true); + m_rebuildingTakeAudio = wasRebuilding; + + // 5. With no analyser there is no waveform layer holding the new + // audio, and the layers are left showing a take whose audio has gone. + // The model stays registered with the document, which releases it when + // it goes; see loadTakeAudio() + if (!m_analyser2) { + return tr("The singing track could not be set up on \"%1\"").arg(path); + } + + // 6. What the swap was not to change. On the layers themselves: + // setVisible() and setAudible() write to the shared settings, and + // neither the muting of a take nor the pane's own stacking is the + // user's wish about the reference + for (int i = 0; i < componentCount; ++i) { + Layer *layer = m_analyser2->getLayer(components[i]); + if (!layer) continue; + layer->setLayerDormant(pane, !visible[i]); + if (auto params = layer->getPlayParameters()) { + params->setPlayAudible(audible[i]); + } + } + pane->layerParametersChanged(); + + // The selected layer is the top layer of the pane as well, which is + // where the editing tools look for the layer they act on + if (selected && selected != pane->getSelectedLayer()) { + for (int i = 0; i < pane->getLayerCount(); ++i) { + if (pane->getLayer(i) == selected) { + m_paneStack->setCurrentLayer(pane, selected); + break; + } + } + } + + updateLayerStatuses(); + updateMenuStates(); + + return ""; +} + +void +MainWindow::analyseNewSingingModel() +{ + // Called when a new audio model has been added (by loadSingingTrack) + // and we want to run the secondary (singing-track) analysis on it. + if (m_pendingSingingModelId.isNone()) return; + + ModelId singingModelId = m_pendingSingingModelId; + m_pendingSingingModelId = {}; + + setupSingingTrackAnalyser(singingModelId); +} + +void +MainWindow::analyseRestoredSingingModel() +{ + // The queued call from modelAdded(): an audio model has been added to + // the document by a route that has not set a singing analyser up for + // it. The routes that do -- loadSingingTrack() and the take's own + // openTakeAudioFile() -- have cleared the pending id by now, and a + // session being restored is dealt with by restoreTakes(), which drops + // the audio model the document carried and opens each take's audio + // itself. So this is the fallback, and what it finds is a take of its + // own, analysed in full + if (m_restoringSession) return; + if (m_pendingSingingModelId.isNone()) return; + + // Which take this is has to be settled first: its layers are found by + // its name, so it must have one before they are looked for + if (m_takes->getActiveIndex() < 0) { + m_takes->addTake(); + } + + adoptTakeLayers(m_pendingSingingModelId); + analyseNewSingingModel(); +} + +void +MainWindow::openBackgroundMusic() +{ + QString path = getOpenFileName(FileFinder::AudioFile); + if (path.isEmpty()) return; + loadBackgroundMusic(path); +} + +void +MainWindow::loadBackgroundMusic(QString path) +{ + if (!m_document) { + QMessageBox::warning(this, tr("No session"), + tr("No session open

Please open a reference audio file first.")); + return; + } + + emit activity(tr("Load background music \"%1\"").arg(path)); + + // Tear down any previously loaded background music track. + teardownBackgroundMusic(); + + // Record the pane count before opening so we can remove the extra pane + // that openPath(CreateAdditionalModel) creates via AddPaneCommand. + // The background music doesn't need its own pane. + int paneCountBefore = m_paneStack ? m_paneStack->getPaneCount() : 0; + + // Set the flag so modelAdded() captures the new model ID and skips + // singing-track analysis for this model. + m_loadingBackgroundMusic = true; + FileOpenStatus status = openPath(path, CreateAdditionalModel); + m_loadingBackgroundMusic = false; + + if (status == FileOpenFailed) { + m_backgroundMusicModelId = {}; + QMessageBox::critical(this, tr("Failed to open background music"), + tr("File open failed

File \"%1\" could not be opened").arg(path)); + return; + } else if (status == FileOpenWrongMode) { + m_backgroundMusicModelId = {}; + QMessageBox::critical(this, tr("Failed to open background music"), + tr("Audio required

Could not open \"%1\" as audio").arg(path)); + return; + } + + if (m_backgroundMusicModelId.isNone()) { + cerr << "loadBackgroundMusic: modelAdded did not capture a model ID — aborting" << endl; + return; + } + + // Create the WaveformLayer for the background music BEFORE removing the + // orphan layers from the extra pane. This ensures the background music + // model has at least one layer referencing it when the orphan is deleted, + // so Document::releaseModel() does not free it prematurely. + // + // Use createLayer() (not createEmptyLayer()) — the same path that + // Analyser::addWaveform() uses for the secondary analyser — then + // immediately rebind it to the background music model with setModel(). + if (m_paneStack && m_paneStack->getPaneCount() > 0) { + Pane *pane = m_paneStack->getPane(0); + if (pane && m_document) { + Layer *rawLayer = m_document->createLayer(LayerFactory::Waveform); + m_backgroundMusicLayer = qobject_cast(rawLayer); + if (m_backgroundMusicLayer) { + m_document->setModel(m_backgroundMusicLayer, m_backgroundMusicModelId); + // The session saves the layer, and its name is what says + // which audio is the background music when it is opened + m_backgroundMusicLayer->setObjectName(backgroundMusicLayerName()); + ColourDatabase *cdb = ColourDatabase::getInstance(); + m_backgroundMusicLayer->setBaseColour( + cdb->getColourIndex(tr("Green"))); + m_document->attachLayerToView(pane, m_backgroundMusicLayer); + + // The waveform is only needed to register the model with + // the play source — we don't want it rendered on screen. + m_backgroundMusicLayer->showLayer(pane, false); + + // Set initial audibility from the toggle state. + auto params = m_backgroundMusicLayer->getPlayParameters(); + if (params) { + bool wantAudible = !m_playBackgroundMusic || + m_playBackgroundMusic->isChecked(); + params->setPlayAudible(wantAudible); + } + + cerr << "loadBackgroundMusic: waveform layer added for model " + << m_backgroundMusicModelId << endl; + } else { + cerr << "loadBackgroundMusic: failed to create WaveformLayer — aborting" << endl; + return; + } + } + } + + // Remove the extra pane that openPath(CreateAdditionalModel) created. + // Now safe to delete the orphan WaveformLayer: our new layer already + // holds a reference to the model so Document::releaseModel() will not + // free it when the orphan is deleted. + if (m_paneStack) { + while (m_paneStack->getPaneCount() > paneCountBefore) { + Pane *extra = m_paneStack->getPane(m_paneStack->getPaneCount() - 1); + if (!extra) break; + pruneExtraPane(extra, m_backgroundMusicModelId); + } + } + + updateLayerStatuses(); + updateMenuStates(); +} + +void +MainWindow::teardownBackgroundMusic() +{ + if (m_backgroundMusicLayer) { + // Explicitly remove from play source before deleting the layer, so + // the model is removed from the mix even if layerInAView(false) is not + // triggered through the normal path. + if (m_playSource && !m_backgroundMusicModelId.isNone()) { + m_playSource->removeModel(m_backgroundMusicModelId); + } + if (m_document) { + m_document->deleteLayer(m_backgroundMusicLayer, true); + } + m_backgroundMusicLayer = nullptr; + } + m_backgroundMusicModelId = {}; +} + +QString +MainWindow::backgroundMusicLayerName() +{ + return "Background Music"; +} + +void +MainWindow::adoptBackgroundMusic() +{ + if (m_backgroundMusicLayer || !m_document || !m_paneStack) return; + + Pane *pane = m_paneStack->getPane(0); + if (!pane) return; + + // The layer and its model are as loadBackgroundMusic() left them, in + // the play source because the layer is in a view. A session saved + // before the layer had its name carries it too, as an audio model + // that restoreTakes() cannot tell from a take's, and drops. + // + // Music the reader could not find (moved, and not located when it + // asked) leaves the layer with no model: a waveform layer may be + // without one. Of no use, and it would go into the next save too + std::vector orphans; + + for (int i = 0; i < pane->getLayerCount(); ++i) { + auto layer = qobject_cast(pane->getLayer(i)); + if (!layer || layer->objectName() != backgroundMusicLayerName()) { + continue; + } + ModelId modelId = layer->getModel(); + if (modelId.isNone()) { + orphans.push_back(layer); + continue; + } + if (m_backgroundMusicLayer || modelId == getMainModelId() || + !ModelById::isa(modelId)) { + continue; + } + m_backgroundMusicLayer = layer; + m_backgroundMusicModelId = modelId; + cerr << "adoptBackgroundMusic: found the background music of the " + << "session, model " << modelId << endl; + } + + for (Layer *layer : orphans) dropLayerSilently(layer); +} + +void +MainWindow::backgroundMusicToggled() +{ + if (!m_backgroundMusicLayer) return; + auto params = m_backgroundMusicLayer->getPlayParameters(); + if (!params) return; + bool wantAudible = m_playBackgroundMusic && m_playBackgroundMusic->isChecked(); + params->setPlayAudible(wantAudible); + if (m_bgMusicLPW) m_bgMusicLPW->setEnabled(wantAudible); + cerr << "backgroundMusicToggled: background music " + << (wantAudible ? "unmuted" : "muted") << endl; +} + +void +MainWindow::backgroundMusicGainChanged(float gain) +{ + if (!m_backgroundMusicLayer) return; + auto params = m_backgroundMusicLayer->getPlayParameters(); + if (!params) return; + if (gain == 0.f) { + params->setPlayAudible(false); + if (m_playBackgroundMusic) m_playBackgroundMusic->setChecked(false); + } else { + params->setPlayAudible(true); + if (m_playBackgroundMusic) m_playBackgroundMusic->setChecked(true); + params->setPlayGain(gain); + } +} + +void +MainWindow::backgroundMusicPanChanged(float pan) +{ + if (!m_backgroundMusicLayer) return; + auto params = m_backgroundMusicLayer->getPlayParameters(); + if (params) params->setPlayPan(pan); +} + +void +MainWindow::alternatePitchToggled() +{ + if (!m_document || !m_paneStack || m_paneStack->getPaneCount() < 1) { + updateLayerStatuses(); + return; + } + + if (m_alternatePitch->isShown()) { + m_alternatePitch->hide(); + // The layer went without a command, as it must (see + // teardownRealtimePitchLayer()), but the session has changed + documentModified(); + } else if (m_alternatePitch->show(m_document, m_paneStack->getPane(0))) { + // The new layer is on top, and is the one the editing tools + // would work on: put the tracks that can be edited back there, + // in the order they were + m_analyser->stackLayers(); + if (m_analyser2) m_analyser2->stackLayers(); + syncAlternatePitchTrack(); + // As above: the layer arrives without a command, so the change to + // the session has to be noted here + documentModified(); + } + + updateLayerStatuses(); +} + +void +MainWindow::alternatePitchUp() +{ + stepAlternatePitch(true); +} + +void +MainWindow::alternatePitchDown() +{ + stepAlternatePitch(false); +} + +void +MainWindow::stepAlternatePitch(bool up) +{ + if (!m_alternatePitch->isShown() || !m_alternatePitch->canStep(up)) return; + m_alternatePitch->step(up); + documentModified(); + updateLayerStatuses(); + getStatusLabel()->setText + (tr("Alternate pitch track: %1") + .arg(AlternatePitchTrack::describe(m_alternatePitch->getOctaves()))); +} + +void +MainWindow::syncAlternatePitchTrack() +{ + // The reference pitch layer, and its model with it, is replaced + // whenever the reference is analysed again + if (!m_alternatePitch->isShown()) return; + Layer *reference = m_analyser->getLayer(Analyser::PitchTrack); + m_alternatePitch->setSource(reference ? reference->getModel() : ModelId()); + updateAlternatePitchForTake(); +} + +void +MainWindow::syncSongScrollBar() +{ + if (!m_songScroll) return; + Layer *reference = m_analyser ? + m_analyser->getLayer(Analyser::PitchTrack) : nullptr; + m_songScroll->setSongModel(getMainModelId()); + m_songScroll->setPitchModel(reference ? reference->getModel() : ModelId()); +} + +void +MainWindow::updateAlternatePitchForTake() +{ + // During a singing take the alternate track is the one to sing to: + // it is shown in full, and the reference pitch track makes way + bool following = (m_alternatePitch->isShown() && + m_recordingAsSingingTrack && + m_recordTarget && m_recordTarget->isRecording()); + + m_alternatePitch->setFollowed(following); + + Layer *reference = m_analyser->getLayer(Analyser::PitchTrack); + Pane *pane = m_analyser->getPane(); + + if (following) { + if (!m_referencePitchHiddenForTake && reference && pane && + !reference->isLayerDormant(pane)) { + reference->showLayer(pane, false); + m_referencePitchHiddenForTake = true; + } + } else if (m_referencePitchHiddenForTake) { + m_referencePitchHiddenForTake = false; + if (reference && pane) reference->showLayer(pane, true); + } +} + +void +MainWindow::updateSingingTrackForTake() +{ + // The singing that is already there is drawn over the same part of + // the pane as what is being sung now, and its pitch in the same + // orange as the live dots: during a take the singer cannot tell the + // one from the other, and neither helps them follow the track they + // are singing to. So the stored pitch and notes make way, and come + // back when the take stops. Not with Analyser::setVisible(), which + // would write the state to the settings the reference shares. + bool inTake = (m_recordingAsSingingTrack && + m_recordTarget && m_recordTarget->isRecording()); + + Pane *pane = m_analyser2 ? m_analyser2->getPane() : nullptr; + + // Only what was on show is hidden, and only what we hid is shown + // again: the user may have had either of them off already + auto update = [&](Analyser::Component c, bool &hidden) { + Layer *layer = m_analyser2 ? m_analyser2->getLayer(c) : nullptr; + if (inTake) { + if (!hidden && layer && pane && !layer->isLayerDormant(pane)) { + layer->showLayer(pane, false); + hidden = true; + } + } else if (hidden) { + hidden = false; + if (layer && pane) layer->showLayer(pane, true); + } + }; + + update(Analyser::PitchTrack, m_singingPitchHiddenForTake); + update(Analyser::Notes, m_singingNotesHiddenForTake); +} + +void +MainWindow::syncCoverageStrip() +{ + // The strip is the store of the take's coverage as well as the + // picture of it, so it follows every change to the take: a recording + // spliced in, a file loaded, the session closed + Pane *pane = m_paneStack ? m_paneStack->getPane(0) : nullptr; + if (!m_document || !pane) return; + + if (!m_takes->haveTake() || m_takes->getCoverage().isEmpty()) { + m_coverageStrip->hide(); + return; + } + + // The strip of the take that is active now. The strips of the other + // takes stay in the pane, hidden: each is the stored coverage of its + // own take, and release() is how this object lets go of one + QString name = m_takes->getActiveName(); + if (m_coverageStrip->isShown() && m_coverageStrip->getTakeName() != name) { + m_coverageStrip->release(); + } + + bool wasShown = m_coverageStrip->isShown(); + if (!m_coverageStrip->isShown()) { + m_coverageStrip->adopt(m_document, pane, name); + } + if (!m_coverageStrip->show(m_document, pane, name)) return; + + // The play source takes in the model of every layer that is in a + // view, whether the model can be played or not, and the models it + // holds are what say where playback ends. The strip is a picture, + // not sound: one left over from a longer singing file would hold + // playback open past the end of what there is to hear + if (m_playSource && !m_coverageStrip->getModelId().isNone()) { + m_playSource->removeModel(m_coverageStrip->getModelId()); + } + + m_coverageStrip->setCoverage(m_takes->getCoverage()); + + // The band runs along the bottom of the pane, over the take's + // waveform, so it has to be above that layer. The swap makes the + // waveform layer again, on top of everything (as does activating a + // take), so this is looked at on every call and not only when the + // strip is first shown + Layer *strip = m_coverageStrip->getLayer(); + int layers = pane->getLayerCount(); + if (strip && layers > 0 && pane->getLayer(layers - 1) != strip) { + TakeLayers::raise(pane, strip); + } + + if (!wasShown) { + // The new layer is on top, where a tool would look for the layer + // to act on, so the tracks that can be edited go back there, as + // setupRecordingLayer() does. That call does nothing in Tony, + // though: it goes through PaneStack::setCurrentLayer(), which + // needs a PropertyStack, and this pane stack has none. What + // really keeps the strip out of reach is that no tool Tony sets + // for pane 0 acts on a layer of this kind -- NoteEditMode takes + // the pane's top FlexiNoteLayer, and SelectMode, which snaps to + // the top layer, is set only for the ruler pane + m_analyser->stackLayers(); + if (m_analyser2) m_analyser2->stackLayers(); + } +} + +bool +MainWindow::lyricsImportAllowed() const +{ + // The words are put on the reference's timeline, in pane 0. Not + // while a take is being recorded: the singer is reading the words + // that are there + if (!m_document || !getMainModel()) return false; + if (!m_paneStack || m_paneStack->getPaneCount() < 1) return false; + if (m_recordTarget && m_recordTarget->isRecording()) return false; + return true; +} + +QString +MainWindow::askForLyricsFile() +{ + // Lyrics are for the reference, and likely to be kept next to it + QString dir; + if (auto reference = getMainModel()) { + QFileInfo info(reference->getLocation()); + if (info.exists()) dir = info.absolutePath(); + } + + return QFileDialog::getOpenFileName + (this, tr("Import Lyrics"), dir, + tr("Lyrics (*.ttml *.lrc)") + ";;" + tr("TTML lyrics (*.ttml)") + + ";;" + tr("LRC lyrics (*.lrc)") + ";;" + tr("All files (*)")); +} + +void +MainWindow::importLyrics() +{ + if (!lyricsImportAllowed()) return; + QString path = askForLyricsFile(); + if (path.isEmpty()) return; + importLyricsFrom(path); +} + +bool +MainWindow::importLyricsFrom(QString path) +{ + if (!lyricsImportAllowed()) return false; + + Pane *pane = m_paneStack->getPane(0); + auto reference = getMainModel(); + + emit activity(tr("Import lyrics \"%1\"").arg(path)); + + // A lyrics file is a few kB. One chosen by mistake, a recording say, + // is not read at all; and the read stops just past the limit, which + // the parsers enforce too, in case the file grows in the meantime + QString error; + QByteArray bytes; + QFileInfo info(path); + if (!info.isFile()) { + error = tr("File \"%1\" could not be found.").arg(path); + } else if (info.size() > Lyrics::maxFileBytes) { + error = tr("The file is over 1 MB, too big to be a lyrics file."); + } else { + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + error = tr("File \"%1\" could not be opened: %2") + .arg(path).arg(file.errorString()); + } else { + bytes = file.read(Lyrics::maxFileBytes + 1); + } + } + + LyricsParseResult parsed; + if (error == "") { + parsed = parseLyrics(bytes); + error = parsed.error; + } + + // Nothing has changed yet, and nothing does: the lyrics there are + // stay + if (error != "") { + QMessageBox::warning(this, tr("Could not import lyrics"), error); + return false; + } + + const Lyrics &lyrics = parsed.lyrics; + EventVector events = lyricsToEvents(lyrics, reference->getSampleRate()); + + // Words after the end of the reference are shown where there is + // nothing to hear: the lyrics may be of another recording of the song + sv_frame_t end = reference->getEndFrame(); + int pastEnd = int(std::count_if(events.begin(), events.end(), + [end](const Event &e) { + return e.getFrame() >= end; + })); + + // New words are not what edit mode was switched on for, and the ones + // there are go now: a drag of one of them ends first + setLyricsEditing(false); + + QString name = (lyrics.title != "" ? lyrics.title : tr("Lyrics")); + if (!m_lyrics->show(m_document, pane, events, name)) { + // Only if the layer could not be made + updateMenuStates(); + updateLayerStatuses(); + return false; + } + + // The play source takes in the model of every layer that is in a + // view, whether the model can be played or not, and the models it + // holds are what say where playback ends. Words past the end of the + // reference would hold playback open, with nothing to hear + if (m_playSource && !m_lyrics->getModelId().isNone()) { + m_playSource->removeModel(m_lyrics->getModelId()); + } + + // The word at the cursor, without waiting for playback to move it; + // and the waveforms fade under the words + m_lyrics->setPlaybackFrame(m_viewManager->getPlaybackFrame()); + updateWaveformFade(); + + // The layer arrived without a command, as it must, and an import is + // not undoable; but the session has changed + documentModified(); + updateMenuStates(); + updateLayerStatuses(); + + // Kept as the status message, so that the pane's context help, when + // it has nothing to say, gives this back rather than clearing it + int wordCount = int(lyrics.words.size()); + int lineCount = lyrics.lineCount(); + QStringList messages; + messages << tr("Imported %1 in %2.") + .arg(wordCount == 1 ? tr("1 word") : tr("%1 words").arg(wordCount), + lineCount == 1 ? tr("1 line") : tr("%1 lines").arg(lineCount)); + messages << parsed.warnings; + if (pastEnd == 1) { + messages << tr("1 word starts after the end of the reference."); + } else if (pastEnd > 1) { + messages << tr("%1 words start after the end of the reference.") + .arg(pastEnd); + } + m_myStatusMessage = messages.join(" "); + getStatusLabel()->setText(m_myStatusMessage); + + return true; +} + +QString +MainWindow::askForLyricsExportFile(QString suggested) +{ + return QFileDialog::getSaveFileName + (this, tr("Export Lyrics"), suggested, + tr("TTML lyrics (*.ttml)") + ";;" + tr("All files (*)")); +} + +bool +MainWindow::askForLyricsWordText(QString &text, bool isNew) +{ + bool ok = false; + QString typed = QInputDialog::getText + (this, isNew ? tr("Add Word") : tr("Edit Word Text"), + isNew ? tr("Text of the new word:") : tr("Text of the word:"), + QLineEdit::Normal, text, &ok); + if (!ok) return false; + text = typed; + return true; +} + +bool +MainWindow::askForLyricsShift(double &seconds) +{ + // Milliseconds are as fine as anyone can hear, and an hour is longer + // than any song + bool ok = false; + double typed = QInputDialog::getDouble + (this, tr("Shift Lyrics"), + tr("Move all the words by this many seconds\n" + "(negative: earlier, positive: later):"), + seconds, -3600.0, 3600.0, 3, &ok); + if (!ok) return false; + seconds = typed; + return true; +} + +void +MainWindow::exportLyrics() +{ + if (!m_lyrics || !m_lyrics->isShown()) return; + + // Named after the reference and beside it, as the lyrics to import + // were looked for there + QString suggested; + if (auto reference = getMainModel()) { + QFileInfo info(reference->getLocation()); + if (info.exists()) { + suggested = QDir(info.absolutePath()) + .filePath(info.completeBaseName() + ".ttml"); + } + } + + QString path = askForLyricsExportFile(suggested); + if (path.isEmpty()) return; + exportLyricsTo(path); +} + +bool +MainWindow::exportLyricsTo(QString path) +{ + if (!m_lyrics || !m_lyrics->isShown()) return false; + auto model = ModelById::getAs(m_lyrics->getModelId()); + if (!model) return false; + + // The words as the model has them now, not as the file they came + // from had them. The title is not in the model: an import named the + // layer after it + Lyrics lyrics = lyricsFromEvents(model->getAllEvents(), + model->getSampleRate()); + if (RegionLayer *layer = m_lyrics->getLayer()) { + QString name = layer->getLayerPresentationName(); + if (name != tr("Lyrics")) lyrics.title = name; + } + QByteArray bytes = writeTtml(lyrics); + + // A file that is there already is replaced only once the new one is + // written in full + QSaveFile file(path); + bool written = file.open(QIODevice::WriteOnly) && + file.write(bytes) == bytes.size() && + file.commit(); + if (!written) { + QMessageBox::warning + (this, tr("Could not export lyrics"), + tr("File \"%1\" could not be written: %2") + .arg(path).arg(file.errorString())); + return false; + } + + emit activity(tr("Export lyrics to \"%1\"").arg(path)); + + // Not a change to the session, so nothing is marked modified. Kept + // as the status message, as an import's is + int wordCount = int(lyrics.words.size()); + QSet lines; + for (const LyricWord &w : lyrics.words) lines.insert(w.line); + int lineCount = int(lines.size()); + m_myStatusMessage = tr("Exported %1 in %2.") + .arg(wordCount == 1 ? tr("1 word") : tr("%1 words").arg(wordCount), + lineCount == 1 ? tr("1 line") : tr("%1 lines").arg(lineCount)); + getStatusLabel()->setText(m_myStatusMessage); + + return true; +} + +void +MainWindow::removeLyrics() +{ + if (!m_lyrics->isShown()) return; + m_lyrics->hide(); + updateWaveformFade(); + // As for the import: no command, but the session has changed + documentModified(); + updateMenuStates(); + updateLayerStatuses(); +} + +void +MainWindow::showLyricsToggled() +{ + // Straight on the layer, with no command and nothing in the settings: + // the pane writes the layer's visibility into the session, which is + // where it belongs + if (m_lyrics->isShown()) { + m_lyrics->setVisible(!m_lyrics->isVisible()); + documentModified(); + } + updateWaveformFade(); + updateLayerStatuses(); + + // Edit Lyrics goes with the words out of sight + updateMenuStates(); +} + +bool +MainWindow::lyricsEditAllowed() const +{ + if (!m_lyrics || !m_lyrics->isShown() || !m_lyrics->isVisible()) { + return false; + } + if (m_recordTarget && m_recordTarget->isRecording()) return false; + return true; +} + +void +MainWindow::setLyricsEditing(bool on) +{ + if (!m_lyricsEditor) return; + m_lyricsEditor->setEnabled(on && lyricsEditAllowed()); + if (m_editLyricsAction) { + m_editLyricsAction->setChecked(m_lyricsEditor->isEnabled()); + } +} + +void +MainWindow::editLyricsToggled() +{ + if (!m_editLyricsAction) return; + setLyricsEditing(m_editLyricsAction->isChecked()); +} + +void +MainWindow::shiftLyrics() +{ + if (!m_lyricsEditor || !lyricsEditAllowed()) return; + ModelId lyricsModel = m_lyrics->getModelId(); + + double seconds = 0.0; + if (!askForLyricsShift(seconds)) return; + + // The dialog ran an event loop of its own, in which the lyrics may + // have gone, been hidden or replaced, or a take begun: then the + // offset typed is not for what is there now + if (!lyricsEditAllowed() || m_lyrics->getModelId() != lyricsModel) { + return; + } + + // One command; a shift of 0, or one the start of the song leaves no + // room for, is none + double shifted = m_lyricsEditor->shiftLyrics(lyricsModel, seconds); + if (shifted == 0.0) return; + + // Kept as the status message, as an import's is. The amount is the + // one the words moved by, which is less than asked if the first word + // reached the start + QString amount = QString::number(std::abs(shifted), 'f', 3); + m_myStatusMessage = (shifted < 0.0 ? + tr("Shifted the lyrics %1 s earlier.").arg(amount) : + tr("Shifted the lyrics %1 s later.").arg(amount)); + getStatusLabel()->setText(m_myStatusMessage); +} + +void +MainWindow::updateWaveformFade() +{ + // The words are drawn over the bottom of the pane, where the + // waveforms are, and have to be read over them. Not with + // Analyser::setVisible() or anything else that writes a setting: + // this is the state of the session, which saves the colour + bool faded = m_lyrics && m_lyrics->isShown() && m_lyrics->isVisible(); + if (m_analyser) m_analyser->setWaveformFaded(faded); + if (m_analyser2) m_analyser2->setWaveformFaded(faded); +} + +void +MainWindow::setupSingingTrackAnalyser(sv::ModelId singingModelId, bool deferAnalysis) +{ + if (!m_document) return; + if (!m_paneStack || m_paneStack->getPaneCount() < 1) return; + + // Reuse the main pane (pane 0) so both pitch tracks overlay each other. + // NOTE: when called from the recording flow, m_pendingExtraPanes may hold + // extra panes that were hidden but not yet deleted (see record()). We must + // check getPaneCount() AFTER accounting for those hidden panes. Pane 0 + // is always the main analysis pane created by analyseNewMainModel(). + Pane *pane = m_paneStack->getPane(0); + if (!pane) return; + + // Tear down any previous secondary analyser + teardownSingingTrackAnalyser(); + + // Create the secondary analyser with the singing-track colour scheme + m_analyser2 = new Analyser(Analyser::SecondaryColors); + + connect(m_analyser2, SIGNAL(layersChanged()), + this, SLOT(updateLayerStatuses())); + connect(m_analyser2, SIGNAL(layersChanged()), + this, SLOT(updateMenuStates())); + + // Erase Singing is switched off while a recorded range is being + // analysed, so the menus have to hear when one is done with + connect(m_analyser2, SIGNAL(initialAnalysisCompleted()), + this, SLOT(updateMenuStates())); + + // The result of a ranged analysis completes the undo command of the + // recording that asked for it (spec 5.4) + connect(m_analyser2, &Analyser::rangedAnalysisMerged, + this, &MainWindow::takeAnalysisMerged); + + // deferAnalysis=true: only set up waveform/visualisation layers now; + // pYIN will be run later by analyseNow() once recording is complete. + QString error = m_analyser2->newFileLoaded( + m_document, singingModelId, m_paneStack, pane, deferAnalysis); + + if (error != "") { + QMessageBox::warning(this, tr("Failed to analyse singing track"), + tr("Analysis failed

%1

").arg(error)); + delete m_analyser2; + m_analyser2 = nullptr; + // Do NOT drain m_pendingExtraPanes here — nothing holds a reference + // to the recording model at this point, so deleteLayer(orphan, true) + // would free the live WritableWaveFileModel mid-capture → crash. + // The hidden extra pane will be cleaned up by closeSession()'s + // getHiddenPaneCount() loop, or on the next successful recording. + return; + } + + // A take's audio is named in the session's element and opened + // from there, so neither the waveform layer nor the audio model is to + // be written to the session as well: a second copy, with an absolute + // path, that the session reader would ask the user to locate if the + // file had gone + if (Layer *audio = m_analyser2->getLayer(Analyser::Audio)) { + audio->setSavedInSession(false); + } + + // A new analyser, with a new waveform, under the lyrics as much as + // the one it replaces: a take switch and every recording come here + updateWaveformFade(); + + // m_analyser2->newFileLoaded() has now created its own WaveformLayer + // referencing singingModelId. This means it is safe to delete the orphan + // WaveformLayer that MainWindowBase::record() put in the extra pane: + // Document::releaseModel() will not free singingModelId because + // m_analyser2's layer still holds a reference to it. + // + // We must do this BEFORE calling m_paneStack->deletePane(), because + // deleteLayer(force=true) iterates Document::m_layerViewMap to remove the + // layer from any views — and that map still contains the live pane pointer. + // After deletePane() the pointer would be dangling → crash. + drainPendingExtraPanes(singingModelId); + + // The take's audio is the file behind this model. All of a file the + // user loaded holds recorded singing; a file we have just spliced + // ourselves has the coverage the splice worked out, which must not be + // thrown away here. A session that was saved with a coverage strip + // says for itself which parts of its take hold singing: that layer is + // in the pane already, waiting to be taken over. + if (!m_rebuildingTakeAudio) { + if (auto wfm = ModelById::getAs(singingModelId)) { + // The take first, because its name is what the strip of a + // session that has one is stored under + m_takes->setWholeFileTake(wfm->getLocation(), + wfm->getFrameCount()); + Coverage stored; + if (!m_coverageStrip->isShown() && + m_coverageStrip->adopt(m_document, pane, + m_takes->getActiveName())) { + stored = m_coverageStrip->getCoverage(); + } + if (!stored.isEmpty()) { + cerr << "MainWindow::setupSingingTrackAnalyser: the session's " + << "coverage strip has " << stored.getRanges().size() + << " range(s) of recorded singing in it" << endl; + m_takes->setTake(wfm->getLocation(), stored); + } + } + syncCoverageStrip(); + } + + // These layers are the active take's, and everything in the pane that + // is some other take's is put away: the takes that are not on show + // must not be claimed by an analyser, heard, or painted + nameActiveTakeLayers(); + putOtherTakeLayersAway(); + + // Re-stack layers so the primary pitch track stays on top + m_analyser->getLayer(Analyser::PitchTrack); // ensure primary is on top + updateTakeCombo(); + updateLayerStatuses(); + updateMenuStates(); + + if (deferAnalysis) { + emit activity(tr("Singing track set up, with no analysis of its own")); + } else { + emit activity(tr("Singing track loaded and analysis started")); + } +} + +void +MainWindow::pruneExtraPane(Pane *extra, sv::ModelId ownedModelId) +{ + // The rules for what may be deleted and what only detached live + // with the helper: see PaneUtils.cpp. + ::pruneExtraPane(m_document, m_paneStack, extra, ownedModelId, m_overview); +} + +void +MainWindow::drainPendingExtraPanes(sv::ModelId singingModelId) +{ + // Called from setupSingingTrackAnalyser() after m_analyser2 has been + // initialised with its own WaveformLayer referencing the recording + // model, which is what makes pruneExtraPane() safe for the panes that + // record() hid rather than deleted. + if (m_pendingExtraPanes.empty()) return; + + cerr << "MainWindow::drainPendingExtraPanes: draining " + << m_pendingExtraPanes.size() << " pending extra pane(s)" << endl; + + for (Pane *extra : m_pendingExtraPanes) { + pruneExtraPane(extra, singingModelId); + } + + m_pendingExtraPanes.clear(); +} + +void +MainWindow::muteSingingAudioForTake() +{ + // The singing that is there is not heard while it is being recorded + // into: the singer would hear themselves along with the reference, + // and on speakers that goes back into the microphone. Not with + // setAudible(), which would write the state to the settings the + // reference shares; the button goes on saying what the user asked + // for, and restoreSingingAudioAfterTake() applies it afterwards. + m_singingAudioAfterTake = + (m_analyser2 ? m_analyser2->isAudible(Analyser::Audio) : true); + + if (!m_analyser2) return; + if (Layer *audio = m_analyser2->getLayer(Analyser::Audio)) { + if (auto params = audio->getPlayParameters()) { + params->setPlayAudible(false); + m_singingAudioMutedForTake = true; + } + } +} + +void +MainWindow::restoreSingingAudioAfterTake() +{ + if (!m_singingAudioMutedForTake) return; + m_singingAudioMutedForTake = false; + if (!m_analyser2) return; + // By now m_analyser2 is usually the one made for the take's new audio + // file, not the one that was muted: what the user asked for is what + // matters, not which layer it is applied to + if (Layer *audio = m_analyser2->getLayer(Analyser::Audio)) { + if (auto params = audio->getPlayParameters()) { + params->setPlayAudible(m_singingAudioAfterTake); + } + } + updateLayerStatuses(); +} + +void +MainWindow::liftPlaySelectionForTake() +{ + // Playback constrained to the selection starts in the selection, not + // at the lead-in of a pre-roll or at a playhead outside it, and stops + // or loops at its end while the recording runs on. What was sung would + // then not be where the take puts it: the take counts the reference + // as playing on from playbackStart() without a break. Through the + // view manager, which writes no settings; the button follows it, and + // is greyed out meanwhile (updateMenuStates()) + if (!m_viewManager->getPlaySelectionMode()) return; + m_viewManager->setPlaySelectionMode(false); + m_playSelectionLiftedForTake = true; +} + +void +MainWindow::restorePlaySelectionAfterTake() +{ + if (!m_playSelectionLiftedForTake) return; + m_playSelectionLiftedForTake = false; + m_viewManager->setPlaySelectionMode(true); +} + +void +MainWindow::teardownSingingTrackAnalyser() +{ + // m_singingAudioMutedForTake is deliberately not cleared here: the + // singing track is torn down and built again in the middle of + // finishing a take, and what the user asked Play Singing Audio for + // has to survive that. restoreSingingAudioAfterTake() clears it when + // the take is over, and closeSession() when the session goes. + if (!m_analyser2) return; + + // removeAllLayers() removes each layer from the pane and deletes it from + // the document (releasing the model if unreferenced), then calls + // fileClosed() to clear the analyser's internal state. This is the + // correct teardown when the document is still alive (e.g. when replacing + // a previous singing-track recording with a new one). + // + // NOTE: we do NOT call drainPendingExtraPanes() here. The pending extra + // panes always belong to the most-recently-started recording (the one + // about to begin, not the one being torn down). Draining them here would + // call deleteLayer(orphan) while m_analyser2 for the NEW recording doesn't + // exist yet, so nothing would hold the new model reference → crash. + // drainPendingExtraPanes() is called from setupSingingTrackAnalyser() once + // m_analyser2 is set up and its WaveformLayer holds the model reference. + // closeSession() handles any residual hidden panes via its own + // getHiddenPaneCount() loop using removeLayerFromView + deletePane. + // + // The forced deletes do not fire layerInAView(false), which is what + // usually takes a model out of the play source. The models go from + // there when the document releases them (modelAboutToBeReleased). + if (m_document) { + m_analyser2->removeAllLayers(); + } else { + // Document is already gone (e.g. closeSession destroyed it); just + // clear the in-memory state without touching the document. + m_analyser2->fileClosed(); + } + delete m_analyser2; + m_analyser2 = nullptr; +} + +void +MainWindow::setupRealtimePitchLayer() +{ + // Create a SparseTimeValueModel and TimeValueLayer to display a + // live pitch estimate during microphone recording. + // + // At the time this is called (triggered by recordStatusChanged(true)), + // MainWindowBase::record() has already: + // 1. Created a WritableWaveFileModel for the recording. + // 2. Set it as the document main model. + // 3. Emitted audioFileLoaded() -> analyseNewMainModel() which added panes. + // + // So getMainModelId() returns the WritableWaveFileModel being filled. + // RealtimePitchTracker polls that model via getData() on a QTimer. + + if (!m_document) return; + if (!m_paneStack || m_paneStack->getPaneCount() < 1) return; + + Pane *pane = m_paneStack->getPane(0); + if (!pane) return; + + // Remove any stale realtime layer from a previous recording session. + teardownRealtimePitchLayer(); + + // The audio source is the WritableWaveFileModel being recorded into. + // In RecordReplaceSession mode it is the document's main model. + // In RecordCreateAdditionalModel mode (recording as singing track) the + // main model is still the reference track, so we must search for the + // WritableWaveFileModel among all document models instead. + ModelId audioSourceId; + + if (m_recordingAsSingingTrack && m_document) { + // Use the model ID captured in modelAdded() when the recording + // WritableWaveFileModel was first registered. Do NOT scan all + // document models here: a previous recording's WritableWaveFileModel + // may still be registered (because its orphan waveform layer, which + // was view-detached but not deleted from m_document->m_layers, holds + // a reference that prevents releaseModel() from freeing it). A scan + // would find that stale model first and point the tracker at the + // completed old recording, replaying its entire pitch content as dots. + if (!m_currentRecordingModelId.isNone()) { + audioSourceId = m_currentRecordingModelId; + cerr << "setupRealtimePitchLayer: using captured recording model " + << audioSourceId << endl; + } else { + // Fallback: m_currentRecordingModelId not yet set (modelAdded + // deferred lambda hasn't fired). Scan as last resort but prefer + // the model with the fewest frames (most recently started). + ModelId mainId = getMainModelId(); + sv_frame_t fewestFrames = -1; + for (ModelId mid : m_document->getModels()) { + if (mid == mainId) continue; + if (ModelById::isa(mid)) { + auto wfm = ModelById::getAs(mid); + sv_frame_t frames = wfm ? wfm->getFrameCount() : 0; + if (audioSourceId.isNone() || frames < fewestFrames) { + audioSourceId = mid; + fewestFrames = frames; + } + } + } + if (audioSourceId.isNone()) { + audioSourceId = mainId; + cerr << "setupRealtimePitchLayer: could not find singing-track " + "recording model, falling back to main model" << endl; + } else { + cerr << "setupRealtimePitchLayer: fallback scan found recording " + << "model " << audioSourceId + << " (fewest frames=" << fewestFrames << ")" << endl; + } + } + } else { + audioSourceId = getMainModelId(); + } + + if (audioSourceId.isNone()) { + cerr << "setupRealtimePitchLayer: no audio source model found, cannot set up realtime tracker" << endl; + return; + } + + // The dots go on the main model's timeline, so their model is at its + // rate. The device may record at another (a phone runs at 48 kHz): + // the tracker works in the recording's own frames and rate, and + // onRealtimePitchDetected() converts where the dot goes + sv_samplerate_t sr = 44100; + if (auto wfm = getMainModel()) { + sr = wfm->getSampleRate(); + } else if (auto audioModel = + ModelById::getAs(audioSourceId)) { + sr = audioModel->getSampleRate(); + } + + // Create a SparseTimeValueModel to receive pitch estimates. + // Its resolution is the YIN hop size: one estimate per hop. + // Unit "Hz" is required so TimeValueLayer::shouldAutoAlign() defers to + // the pane's log-frequency coordinate system (same as the pYIN pitch track). + // + // notifyOnAdd false: a notice for each of the ~170 dots a second + // would be a redraw for each, and a notice has the pane draw all of + // itself. The model then tells nobody of a dot, though, so + // onRealtimePitchDetected() has the pane draw the part of it where + // each batch of dots goes, 25 times a second + auto pitchModel = std::make_shared + (sr, RealtimePitchTracker::kHopSize, false); + pitchModel->setObjectName(tr("Realtime Pitch (Live)")); + pitchModel->setScaleUnits("Hz"); + m_realtimePitchModelId = ModelById::add(pitchModel); + m_document->addNonDerivedModel(m_realtimePitchModelId); + + // Create a TimeValueLayer to display the pitch estimates. Not with + // createEmptyLayer(): that gives the layer an empty model of its own, + // which goes into the play source only to be released again by the + // setModel() below. + Layer *rawLayer = m_document->createLayer(LayerFactory::TimeValues); + m_realtimePitchLayer = qobject_cast(rawLayer); + + if (!m_realtimePitchLayer) { + cerr << "setupRealtimePitchLayer: failed to create TimeValueLayer" << endl; + // Release the pitch model we just added; the Document will not hold + // a reference to it since we never called setModel yet. + ModelById::release(m_realtimePitchModelId); + m_realtimePitchModelId = {}; + return; + } + + // Associate our pre-filled SparseTimeValueModel with the layer. + // The model was already registered via addNonDerivedModel above. + m_document->setModel(m_realtimePitchLayer, m_realtimePitchModelId); + m_realtimePitchLayer->setVerticalScale(TimeValueLayer::AutoAlignScale); + m_realtimePitchLayer->setPlotStyle(TimeValueLayer::PlotPoints); + + // Out of the pane's cache: told of a change to the model of a layer + // in it, the pane draws every layer in it again -- the reference's + // pitch track, notes and waveform, 25 times a second. Out of it, a + // part of the pane can be drawn with the dots new there + m_realtimePitchLayer->setCachedInView(false); + + // Singing/recording track uses the "Orange" colour so it is visually + // distinct from the reference track (black) and notes (blue). + ColourDatabase *cdb = ColourDatabase::getInstance(); + m_realtimePitchLayer->setBaseColour(cdb->getColourIndex(tr("Orange"))); + + m_document->attachLayerToView(pane, m_realtimePitchLayer); + + // Create and start the pitch tracker. Its thread reads new frames + // from audioSourceId (the WritableWaveFileModel) and keeps what it + // finds; m_liveDotsFeed takes it from there to + // onRealtimePitchDetected() in batches, which writes the estimates + // into m_realtimePitchModelId on this thread. The feed is stopped + // before the tracker goes (stopRealtimePitchTracker()) + m_realtimePitchTracker = new RealtimePitchTracker( + audioSourceId, this); + m_realtimePitchTracker->start(); + + RealtimePitchTracker *tracker = m_realtimePitchTracker; + m_liveDotsFeed.setReporter([this](const LiveDotsFeed::Report &report) { + logLiveDots(report); + }); + m_liveDotsFeed.start + ([tracker]() { return tracker->takeEstimates(); }, + [this](const RealtimePitchTracker::Estimates &estimates) { + onRealtimePitchDetected(estimates); + }); + m_liveDotsFeed.timePaintsOf(pane); + + cerr << "setupRealtimePitchLayer: realtime pitch tracking started " + << "(audio source model " << audioSourceId << ", sr=" << sr << ")" << endl; +} + +void +MainWindow::stopRealtimePitchTracker() +{ + // First: the feed takes from the tracker + m_liveDotsFeed.stop(); + if (m_realtimePitchTracker) { + m_realtimePitchTracker->stop(); + delete m_realtimePitchTracker; + m_realtimePitchTracker = nullptr; + } +} + +void +MainWindow::teardownRealtimePitchLayer() +{ + stopRealtimePitchTracker(); + + if (m_realtimeLayerTeardownConnection) { + disconnect(m_realtimeLayerTeardownConnection); + m_realtimeLayerTeardownConnection = {}; + } + + if (m_realtimePitchLayer) { + // Use deleteLayer(force=true) directly — do NOT call + // removeLayerFromView first. + // + // removeLayerFromView creates a RemoveLayerCommand in the undo + // history with m_added=false. If deleteLayer then destroys the + // layer object, that command holds a dangling pointer. When + // CommandHistory is later cleared (e.g. on the next closeSession) + // the RemoveLayerCommand destructor checks !m_added and calls + // m_d->deleteLayer(m_layer) on the already-deleted layer — + // use-after-free / crash, and the old SparseTimeValueModel can + // stay alive inside the undo entry long enough that its orange + // dots reappear during the next recording. + // + // deleteLayer(force=true) removes the layer from all views + // internally (without generating any undo command), then + // releases the model if unreferenced and deletes the layer. + // This is the correct path for a silent, non-undoable teardown. + if (m_document) { + m_document->deleteLayer(m_realtimePitchLayer, true); + } + m_realtimePitchLayer = nullptr; + } + + // Note: we do NOT call ModelById::release(m_realtimePitchModelId) here. + // The Document owns the SparseTimeValueModel we added via addNonDerivedModel, + // and deleteLayer() above will have already released the model if no other + // layer is referencing it. Calling release() a second time would be a + // double-free. + m_realtimePitchModelId = {}; +} + +void +MainWindow::setupRecordingLayer() +{ + // A layer of our own on the recording, so that the document holds the + // WritableWaveFileModel while the device writes to it. The singing + // analyser cannot do that job any more: it is showing the take's own + // audio, pitch and notes, which stay as they are for the duration. + // + // The layer is never shown. The recording starts at frame 0 of its + // own file, which is not where its sound belongs on the reference's + // timeline, so drawing it would put the waveform in the wrong place; + // the live dots are what the singer watches. It is muted for the + // same reason the live pitch model is (review finding 3). + // + // With this layer in place the extra pane that AddPaneCommand made is + // no longer the only thing holding the model, so record() can prune + // it at once, as loadSingingTrack() does. + if (m_recordingLayer) teardownRecordingLayer(); + + if (!m_document || m_currentRecordingModelId.isNone()) return; + if (!m_paneStack || m_paneStack->getPaneCount() < 1) return; + Pane *pane = m_paneStack->getPane(0); + if (!pane) return; + + Layer *rawLayer = m_document->createLayer(LayerFactory::Waveform); + m_recordingLayer = qobject_cast(rawLayer); + if (!m_recordingLayer) { + cerr << "MainWindow::setupRecordingLayer: failed to create the layer " + << "for the recording" << endl; + return; + } + + m_document->setModel(m_recordingLayer, m_currentRecordingModelId); + m_document->attachLayerToView(pane, m_recordingLayer); + // Raw material of a take in progress: no part of a session + m_recordingLayer->setSavedInSession(false); + m_recordingLayer->showLayer(pane, false); + if (auto params = m_recordingLayer->getPlayParameters()) { + params->setPlayAudible(false); + } + + // The new layer is on top, where the editing tools look for the layer + // to act on: put the tracks that can be edited back there, as + // alternatePitchToggled() does + m_analyser->stackLayers(); + if (m_analyser2) m_analyser2->stackLayers(); +} + +void +MainWindow::teardownRecordingLayer() +{ + // The recording has been spliced into the take (or the take came to + // nothing): the model is not needed any more, and releasing it closes + // the file handles the recording still has open. The file itself + // stays on disk; Tony never deletes a recording. + ModelId recordingModelId = m_currentRecordingModelId; + + if (m_recordingLayer) { + if (m_document) m_document->deleteLayer(m_recordingLayer, true); + m_recordingLayer = nullptr; + } + + // The fallback in record(): with no layer of ours, the pane that + // AddPaneCommand made was kept, hidden, because its waveform layer + // was all that held the model + drainPendingExtraPanes(recordingModelId); + + // deleteLayer(force) does not fire layerInAView(false). The model + // leaves the play source when it is released, which is what has just + // happened; this makes sure of it even if something else holds it + if (m_playSource && !recordingModelId.isNone()) { + m_playSource->removeModel(recordingModelId); + } + + m_currentRecordingModelId = {}; +} + +void +MainWindow::record() +{ + // If recording is already in progress this click is a STOP request, not a + // start request. Delegate straight to the base class (which calls stop()) + // without doing any pre-flight teardown. The teardown would destroy + // m_analyser2 and remove the live recording model from m_playSource while + // audio is still being captured — causing a crash or a null m_analyser2 + // when recordCompleted() fires analyseNow() moments later. + if (m_recordTarget && m_recordTarget->isRecording()) { + // Nothing left for the timer to watch, whether this Stop came + // from the button or from the timer itself + stopTakePolling(); + MainWindowBase::record(); + return; + } + +#ifdef Q_OS_ANDROID + // The microphone is asked for when it is first needed, and the + // answer comes later: the take is started then, from the top + if (!microphoneAllowed()) { + if (m_recordAction) m_recordAction->setChecked(false); + askForMicrophone([this]() { record(); }); + return; + } +#endif + + // If a reference track is already loaded, record the microphone input + // into the singing track rather than replacing the whole session. We + // do that by switching to RecordCreateAdditionalModel for the call, so + // that MainWindowBase::record() adds the WritableWaveFileModel as an + // additional (non-main) model; modelAdded() takes note of it, and + // finishSingingTake() splices it into the take's audio at the end. + // + // If there is no main model yet (first-time record), fall through with + // the default RecordReplaceSession behaviour. + + bool haveReference = (getMainModel() != nullptr); + + // A new take starts with no latency compensation, whichever kind of take + // it is: the live dots are drawn with these, and a standalone take must + // not inherit the shift of a singing take made before it. The figures + // for a singing take are computed in recordingStarted(). This is below + // the early return above on purpose: a Stop must leave them alone, the + // splice on Stop needs them. + m_recordingLatencyFrames = 0; + m_recordingStartGapEstimate = 0; + m_awaitingReferenceStart = false; + m_recordingStartGapMeasured = -1; + m_takeLatency = TakeLatency(); + + if (haveReference) { + + // The recording goes into the take at the playback position. It + // has to be read before the base class call, which centres the view + // on frame 0, and while we are not recording yet: once we are, + // ViewManager reports the duration of the take instead. + sv_frame_t position = m_viewManager ? m_viewManager->getPlaybackFrame() : 0; + if (position < 0) position = 0; + + // Record into Selection: the selection is what is recorded, so it + // says where the take starts and where it stops, and the playhead + // only picks which selection that is. With none selected this is + // an ordinary recording from the playhead. The audio check's + // takes always record into the selection it makes. + sv_frame_t end = -1; + bool intoSelection = m_audioCheckTakes || + (m_recordIntoSelection && m_recordIntoSelection->isChecked()); + if (intoSelection && m_viewManager) { + Coverage::Ranges selected; + for (const Selection &s : m_viewManager->getSelections()) { + if (!s.isEmpty()) { + selected.push_back(Coverage::Range(s.getStartFrame(), + s.getEndFrame())); + } + } + Coverage::Range chosen; + if (TakeTiming::chooseRange(selected, position, chosen)) { + position = chosen.start; + end = chosen.end; + cerr << "MainWindow::record: recording into the selection [" + << position << "," << end << ")" << endl; + } + } + + // Recording from inside singing that is already there replaces it + // from that point on. The user is asked first, unless they have + // said not to be: undo can bring it back. Not asked when + // recording into a selection: making the selection was the answer, + // and the end of the recording is known there (spec 5.1). + if (end < 0 && m_takes->shouldConfirmRecordingAt(position) && + !confirmRecordingOverTake()) { + cerr << "MainWindow::record: recording over the existing singing " + << "was declined" << endl; + return; + } + + m_takePosition = position; + m_takeEnd = end; + m_takePreRoll = TakeTiming::preRollBefore(position, + wantedPreRollFrames()); + + cerr << "MainWindow::record: recording into the singing track from " + << "frame " << position << ", with a lead-in of " + << m_takePreRoll << " frames" << endl; + + // Dots and a tracker of a take whose analysis never finished + if (m_realtimePitchTracker || m_realtimePitchLayer) { + cerr << "MainWindow::record: tearing down leftover realtime pitch layer" << endl; + teardownRealtimePitchLayer(); + } + + // Likewise a recording that was never spliced into the take + if (m_recordingLayer || !m_currentRecordingModelId.isNone()) { + cerr << "MainWindow::record: releasing a recording left over from " + << "a take that did not finish" << endl; + teardownRecordingLayer(); + } + + // The singing track itself stays as it is: its pitch and notes + // are what the singer is adding to. Its audio is kept out of the + // mix (spec 5.1), and its pitch and notes out of sight -- see + // updateSingingTrackForTake(), called below once the device has + // either started or failed to. + muteSingingAudioForTake(); + + m_pendingSingingModelId = {}; + m_recordingInProgress = false; + + m_recordingAsSingingTrack = true; + // The recording is only to be added to the document as a model: + // the pane, the layer and the "Import Recorded Audio" undo entry + // that RecordCreateAdditionalModel makes for it are all things we + // would delete at once, leaving a command on the undo stack whose + // pane has gone. The pane count is still remembered, so that the + // pruning code finds nothing to do rather than being taken out + m_paneCountBeforeRecording = m_paneStack ? m_paneStack->getPaneCount() : 0; + setAudioRecordMode(RecordCreateUnshownModel); + } else { + m_recordingAsSingingTrack = false; + m_takePosition = 0; + m_takePreRoll = 0; + m_takeEnd = -1; + m_paneCountBeforeRecording = 0; + setAudioRecordMode(RecordReplaceSession); + } + + // While recording, the playback cursor is where the take's playback + // started plus what has been recorded: the views follow the take from + // where it is being made, not from frame 0. With a pre-roll that is + // the start of the lead-in, so the cursor runs through the lead-in in + // step with the reference. + sv_frame_t playbackStart = currentTakeTiming().playbackStart(); + if (m_viewManager) { + m_viewManager->setRecordStartFrame(playbackStart); + // What has been recorded is counted in the device's frames, and + // the device's rate is known only once it records: until then, + // and for a recording that becomes the session, one for one + m_viewManager->setRecordFrameRatio(1.0); + } + + MainWindowBase::record(); + + // The base class gives up without a signal when the device cannot be + // opened or the recording cannot be started. No take is coming then, + // so nothing must be left waiting for one: with the flag still set, + // Analyse Now would be routed to a singing track that is not there, + // and the reference would not be re-analysed. (Opening a file is not + // affected: that closes the session, which clears the flag.) + if (!m_recordTarget || !m_recordTarget->isRecording()) { + cerr << "MainWindow::record: recording did not start" << endl; + m_recordingAsSingingTrack = false; + m_recordingInProgress = false; + restoreSingingAudioAfterTake(); + } + + // The base class centres the view on frame 0. The take is being + // recorded from where playback was — the start of the lead-in when + // there is one — and that is where the singer is watching (spec + // section 4). + if (m_recordingAsSingingTrack && m_viewManager) { + m_viewManager->setPlaybackFrame(playbackStart); + m_viewManager->setGlobalCentreFrame(playbackStart); + // A device at 48 kHz against the reference at 44.1 would + // otherwise run the cursor 8.8% ahead of the reference + m_viewManager->setRecordFrameRatio + (currentTakeTiming().referenceFramesPerRecordedFrame()); + } + + updateAlternatePitchForTake(); + updateSingingTrackForTake(); + updateLayerStatuses(); + + // Restore the default mode so that a subsequent "standalone" recording + // (after the singing track session is closed) behaves correctly. + setAudioRecordMode(RecordReplaceSession); + + if (m_recordingAsSingingTrack) { + + // Give the recording a layer of our own to hold it in the document, + // and then remove the extra pane that AddPaneCommand made for it. + // The order matters: that pane's imported waveform layer is the + // only thing referencing the live WritableWaveFileModel until our + // layer is there, and deleting it first would have + // Document::releaseModel() destroy the model mid-capture. + setupRecordingLayer(); + + if (m_paneStack) { + while (m_paneStack->getPaneCount() > m_paneCountBeforeRecording) { + Pane *extra = m_paneStack->getPane(m_paneStack->getPaneCount() - 1); + if (!extra) break; + + if (m_recordingLayer) { + pruneExtraPane(extra, m_currentRecordingModelId); + continue; + } + + // Nothing of ours holds the model, so the pane's own layer + // has to keep it alive until the take is over. Hiding the + // pane takes it out of the visible list while leaving the + // widget alive with valid Document::m_layerViewMap entries: + // a pane must not be deleted while a layer of its own is + // still in that map (see "Extra-pane Pruning" in the dev + // doc). teardownRecordingLayer() prunes it at the end. + if (m_overview) m_overview->unregisterView(extra); + m_paneStack->hidePane(extra); + m_pendingExtraPanes.push_back(extra); + + cerr << "MainWindow::record: no layer for the recording; " + << "keeping its pane " << extra << " hidden for the take" + << endl; + } + } + + // A take with an end of its own to reach is watched until it + // gets there + startTakePolling(); + } +} + +void +MainWindow::recordPressed() +{ + // The check starts and stops its takes through record() itself, and + // a press would stop its take early, or start one of the user's in + // the check's session. The button is shut while it runs; a press + // that arrives all the same (queued before it was shut, say) leaves + // the button showing what is really happening + if (audioCheckRunning()) { + cerr << "MainWindow::recordPressed: the audio check is running; " + << "Record is ignored" << endl; + if (m_recordAction) { + m_recordAction->setChecked + (m_recordTarget && m_recordTarget->isRecording()); + } + return; + } + record(); +} + +bool +MainWindow::audioCheckRunning() const +{ + if (m_audioCheck && m_audioCheck->isRunning()) return true; +#ifdef TONY_DEV_CHECKS + if (m_devChecks && m_devChecks->isRunning()) return true; +#endif + return false; +} + +void +MainWindow::startTakePolling() +{ + // Only a take that is to stop by itself needs watching: nothing else + // about a take is decided while it runs + if (!m_takeTimer) return; + if (!m_recordTarget || !m_recordTarget->isRecording()) return; + if (!currentTakeTiming().havePunchOut()) return; + + m_takeTimer->start(); +} + +void +MainWindow::stopTakePolling() +{ + if (m_takeTimer) m_takeTimer->stop(); +} + +void +MainWindow::pollTakeProgress() +{ + // The take may have ended, or the session gone, between one poll and + // the next: there is nothing to watch then + if (!m_recordTarget || !m_recordTarget->isRecording() || + !m_recordingAsSingingTrack) { + stopTakePolling(); + return; + } + + // The latency may have been refined since the last poll, which moves + // the end of the take with it: currentTakeTiming() has the figure as + // it stands now + TakeTiming timing = currentTakeTiming(); + sv_frame_t received = m_recordTarget->getFramesReceived(); + + // Everything up to the end of the selection has been sung and + // recorded; what comes after it would not be used anyway + if (timing.shouldStopAt(received)) { + cerr << "MainWindow::pollTakeProgress: the singing up to frame " + << timing.end << " has arrived (" << received + << " frames recorded): stopping the take" << endl; + // The same path as the Stop button, which stops the timer + record(); + } +} + +bool +MainWindow::showTakeCountdown() const +{ + // While the lead-in of a pre-roll runs, the status bar counts it down + // instead of saying where playback is or how much has been recorded: + // what is coming in does not count yet. + // + // Everything that writes the status bar during a take has to come + // through here, because they all write often — the recorded duration + // every 10 ms, the playback position every 20 ms, the visible range + // whenever the view scrolls after the cursor — and anything written + // between two of those would be gone before it could be read. (The + // live dots write it too, and need no help: none is drawn during the + // lead-in.) + if (!m_recordingAsSingingTrack || m_takePreRoll <= 0 || !m_recordTarget) { + return false; + } + + QString countdown = currentTakeTiming().countdownText + (m_recordTarget->getFramesReceived()); + if (countdown == "") return false; + + m_myStatusMessage = countdown; + getStatusLabel()->setText(countdown); + return true; +} + +void +MainWindow::recordDurationChanged(sv_frame_t frame, sv_samplerate_t rate) +{ + if (showTakeCountdown()) return; + MainWindowBase::recordDurationChanged(frame, rate); +} + +void +MainWindow::playbackFrameChanged(sv_frame_t frame) +{ + // The word being sung, before the countdown can return: the reference + // plays during a lead-in, and the words go with it. This comes while + // playing, while recording, and for a seek with playback stopped + if (m_lyrics) m_lyrics->setPlaybackFrame(frame); + + if (showTakeCountdown()) return; + MainWindowBase::playbackFrameChanged(frame); +} + +void +MainWindow::recordingStarted() +{ + // recordStatusChanged(bool) is emitted both when recording starts + // (true) and stops (false). We only want to act when it starts. + if (!m_recordTarget) return; + if (!m_recordTarget->isRecording()) { + // Recording stopped - recordingFinishedFull() is called from + // analyseNow() once pYIN completes. The reference pitch track + // comes back now, though: the singer has stopped following, and + // so do the take's own pitch and notes. + updateAlternatePitchForTake(); + updateSingingTrackForTake(); + updateLayerStatuses(); + return; + } + + cerr << "MainWindow::recordingStarted: scheduling realtime pitch layer setup" << endl; + m_recordingInProgress = true; + + // TIMING: recordStatusChanged(true) is emitted from within + // AudioCallbackRecordTarget::startRecording(), which is called by + // MainWindowBase::record() BEFORE setMainModel() and BEFORE + // emit audioFileLoaded() -> analyseNewMainModel() creates the panes. + // If we call setupRealtimePitchLayer() directly here, m_paneStack will + // have zero panes and the setup will silently bail out. + // + // Fix: defer via QTimer::singleShot(0) so the slot runs on the next + // event-loop iteration, by which time record() has finished completely + // (including emit audioFileLoaded() -> panes created). + QTimer::singleShot(0, this, [this]() { + if (!m_recordingInProgress) { + return; + } + cerr << "MainWindow::recordingStarted (deferred): setting up realtime pitch layer" << endl; + setupRealtimePitchLayer(); + + // If the "play reference while recording" toggle is on, start + // playback from where the take is being recorded, so the singer + // hears the reference from there. The audio IO was already + // resumed by record() so m_playSource can be started directly + // without calling MainWindowBase::play() (which would stop + // recording if isRecording() is true). The audio check's takes + // always play it: they measure where it arrives. + bool playReference = m_audioCheckTakes || + (m_playRefWhileRecording && m_playRefWhileRecording->isChecked()); + if (m_recordingAsSingingTrack && playReference && + m_playSource && !m_playSource->isPlaying()) { + cerr << "MainWindow::recordingStarted: starting reference playback" << endl; + + // Measure round-trip hardware latency so we can compensate the + // singing recording's timeline after the take. + // output latency = time from play() call until audio exits the speaker + // input latency = time from sound entering the mic until it arrives here + // The singer's response to the reference where playback starts + // arrives in the recording at approximately frame + // (outputLatency + inputLatency), so that is the frame the splice + // reads the recording from. Drivers often report those two + // wrong; a round trip the audio check measured on this device + // is used instead, while there is one (roundTripAt()). + // With a pre-roll, playback starts at the beginning of the + // lead-in rather than at the take's position, and the splice + // skips the lead-in as well (TakeTiming::spliceOffset()). + TakeTiming timing = currentTakeTiming(); + sv_frame_t playbackStart = timing.playbackStart(); + + // All of it in frames of the recording, at the device's rate. + // The play source counts at the reference's rate: bqaudioio's + // ResamplerWrapper converts the device's output latency to + // that rate when it hands it on + sv_frame_t outputLatency = timing.referenceToRecorded + (m_playSource->getTargetPlayLatency()); + sv_frame_t inputLatency = m_recordTarget ? m_recordTarget->getSystemRecordLatency() : 0; + // + // The take is already running by now: record() started it, and + // this lambda runs an event-loop turn (and a layer setup) later. + // Whatever the device delivers before the reference starts sits + // at the front of the take, ahead of reference frame 0, so it is + // part of the shift as well. What it has delivered so far is + // only an estimate of that, because the play source takes a + // little while to start; the audio callback reports the real + // figure, and refineRecordingLatency() picks it up. + m_recordingStartGapEstimate = + m_recordTarget ? m_recordTarget->getFramesReceived() : 0; + + // The round trip is taken off the front of the recording, so it + // is counted in frames of the recording, at the device's rate. + // The reported latencies are not both counted so: the play + // source usually has the output latency in frames of the + // session. They differ when the device is not at 44.1 kHz, so + // the round trip goes through seconds + sv_samplerate_t recordingRate = 0; + if (auto wfm = ModelById::getAs + (m_currentRecordingModelId)) { + recordingRate = wfm->getSampleRate(); + } + if (recordingRate <= 0) { + recordingRate = sessionRate(); + cerr << "MainWindow::recordingStarted: the recording's rate " + << "is not known; taking the session's, " + << recordingRate << " Hz" << endl; + } + m_lastRecordingRate = recordingRate; + + LatencyCalibration::InUse inUse = roundTripAt(recordingRate); + + // A check that brings a round trip of its own places its takes + // with that, for the run only (the dev checks, with the figure + // the calibration before them measured): nothing is stored, + // and latencyInUse() goes on describing roundTripAt()'s. The + // reported pair stays the device's + const bool checkOwn = + m_audioCheckTakes && m_audioCheckRoundTrip >= 0.0; + if (checkOwn) inUse.roundTrip = m_audioCheckRoundTrip; + + sv_frame_t roundTrip = + LatencyCalibration::toFrames(inUse.roundTrip, recordingRate); + m_recordingLatencyFrames = roundTrip + m_recordingStartGapEstimate; + + m_takeLatency.roundTrip = roundTrip; + m_takeLatency.reportedOutput = inUse.reportedOutput; + m_takeLatency.reportedInput = inUse.reportedInput; + m_takeLatency.measured = checkOwn || + (inUse.source == LatencyCalibration::Source::Measured); + m_takeLatency.startGap = m_recordingStartGapEstimate; + m_takeLatency.startGapMeasured = false; + deviceRoute(m_takeLatency.route); + cerr << "MainWindow::recordingStarted: round trip " << roundTrip + << " frames at " << recordingRate << " Hz (" + << inUse.roundTrip * 1000.0 << " ms), "; + if (checkOwn) { + cerr << "the audio check's own, for its run only"; + } else { + cerr << LatencyCalibration::sourceName(inUse.source); + if (inUse.source == LatencyCalibration::Source::Measured) { + cerr << " on " + << inUse.date.toString(Qt::ISODate).toStdString(); + } else if (inUse.stale && m_takeLatency.route.driver != "") { + cerr << ": the measured one is stale, the streams were " + << "opened otherwise"; + } else if (inUse.stale) { + cerr << ": the measured one is stale, the device reports " + << "other latencies now"; + } + } + cerr << "; output latency=" << outputLatency + << " input latency=" << inputLatency + << " estimated start gap=" << m_recordingStartGapEstimate + << " total compensation=" << m_recordingLatencyFrames << " frames" << endl; + + m_recordingStartGapMeasured = -1; + m_recordFramesPerPlayFrame = + (timing.rate > 0 && timing.recordRate > 0 ? + double(timing.recordRate) / double(timing.rate) : 1.0); + m_awaitingReferenceStart = true; + + liftPlaySelectionForTake(); + m_viewManager->setPlaybackFrame(playbackStart); + m_playSource->play(playbackStart); + } + + updateLayerStatuses(); + updateMenuStates(); + }); +} + +void +MainWindow::refineRecordingLatency() +{ + sv_frame_t measured = m_recordingStartGapMeasured; + if (measured < 0) return; + + // Measured, whether or not the estimate was right: the audio check + // reports for each take which of the two it was placed with. Kept + // here, on the GUI thread, and not by the audio callback that + // measures it + m_takeLatency.startGap = measured; + m_takeLatency.startGapMeasured = true; + + if (measured == m_recordingStartGapEstimate) return; + cerr << "MainWindow::refineRecordingLatency: start gap was " << measured + << " frames, not the estimated " << m_recordingStartGapEstimate << endl; + m_recordingLatencyFrames = currentRecordingLatency(); + m_recordingStartGapEstimate = measured; +} + +sv_frame_t +MainWindow::currentRecordingLatency() const +{ + // Reading the measurement without taking it: pollTakeProgress() wants + // the latest figure every time it looks, but making it the stored one + // is onRealtimePitchDetected()'s business — it throws the dots placed + // with the estimate away when the figure changes. + sv_frame_t measured = m_recordingStartGapMeasured; + if (measured < 0) return m_recordingLatencyFrames; + return m_recordingLatencyFrames + (measured - m_recordingStartGapEstimate); +} + +sv_samplerate_t +MainWindow::sessionRate() const +{ + sv_samplerate_t rate = m_playSource ? m_playSource->getSourceSampleRate() : 0; + if (rate <= 0) rate = Preferences::getInstance()->getFixedSampleRate(); + return rate; +} + +LatencyCalibration::InUse +MainWindow::roundTripAt(sv_samplerate_t recordingRate) const +{ + // The play source counts its output latency in frames at the rate it + // was told the device runs at, as its own getCurrentPlayingFrame() + // does. bqaudioio's ResamplerWrapper tells it the session's rate and + // converts the device's figure to it, when the session had a rate by + // the time the device was opened. If it had none yet (a device chosen + // before any file was opened), the wrapper passed the figure on as the + // device counts it and told the play source 0: the recording's frames + sv_samplerate_t outputRate = + m_playSource ? m_playSource->getDeviceSampleRate() : 0; + if (outputRate <= 0) outputRate = recordingRate; + + double output = m_playSource ? + LatencyCalibration::reportedSeconds + (m_playSource->getTargetPlayLatency(), outputRate) : 0.0; + double input = m_recordTarget ? + LatencyCalibration::reportedSeconds + (m_recordTarget->getSystemRecordLatency(), recordingRate) : 0.0; + + // A device that knows its route is judged by how it opened its + // streams, not by the latencies it reports (LatencyCalibration) + AudioRoute::Route route; + deviceRoute(route); + + QSettings settings; + LatencyCalibration::Figure figure; + bool stored = LatencyCalibration::load + (settings, latencyKey(recordingRate), figure); + return LatencyCalibration::roundTripInUse + (stored ? &figure : nullptr, output, input, + route.outputStreams, route.inputStreams); +} + +bool +MainWindow::deviceRoute(AudioRoute::Route &route) const +{ + route = AudioRoute::Route(); + breakfastquay::SystemPlaybackTarget *device = m_audioIO; + if (!device) device = m_playTarget; + auto reporter = dynamic_cast(device); + if (!reporter) return false; + route = reporter->getAudioRoute(); + return route.driver != ""; +} + +AudioRoute::Route +MainWindow::audioRoute() const +{ + AudioRoute::Route route; + deviceRoute(route); + return route; +} + +LatencyCalibration::Key +MainWindow::latencyKey(sv_samplerate_t rate) const +{ + QSettings settings; + AudioRoute::Route route; + if (!deviceRoute(route)) { + return LatencyCalibration::currentKey(settings, rate); + } + + // Opened for playback only, as the device is on a phone until the + // first take, it cannot say which input it will record from: the one + // a figure is kept with for this output, if only one is. How the + // input will open is not known either, and is not compared + LatencyCalibration::Key key = LatencyCalibration::routeKey(route, rate); + if (!route.hasInput) { + LatencyCalibration::onlyRecordDevice(settings, key, key.recordDevice); + } + return key; +} + +sv_samplerate_t +MainWindow::expectedRecordingRate() const +{ + // A device that knows its route knows its rate as soon as it is open, + // and records at it (OboeAudioIO opens its input at its output's) + AudioRoute::Route route; + if (deviceRoute(route) && route.rate > 0) return route.rate; + return m_lastRecordingRate > 0 ? m_lastRecordingRate : sessionRate(); +} + +LatencyCalibration::InUse +MainWindow::latencyInUse() const +{ + return roundTripAt(expectedRecordingRate()); +} + +bool +MainWindow::storeMeasuredLatency(const AudioCheckResult &result) +{ + if (!result.calibrationUsable()) return false; + + LatencyCalibration::Figure figure; + figure.roundTrip = result.calibratedRoundTrip; + figure.spread = result.summary.spread; + figure.date = QDateTime::currentDateTimeUtc(); + figure.reportedOutput = result.reportedOutputLatency; + figure.reportedInput = result.reportedInputLatency; + figure.outputStreams = result.route.outputStreams; + figure.inputStreams = result.route.inputStreams; + + // Under the devices the check ran on, which the Preferences may no + // longer name: the result can be on show long after the run + LatencyCalibration::Key key = result.key; + key.rate = result.recordingRate; + + QSettings settings; + LatencyCalibration::store(settings, key, figure); + cerr << "MainWindow::storeMeasuredLatency: round trip " + << figure.roundTrip * 1000.0 << " ms at " << key.rate + << " Hz, the device reporting " << figure.reportedOutput * 1000.0 + << " ms out and " << figure.reportedInput * 1000.0 << " ms in"; + if (result.route.driver != "") { + cerr << "; for the route " << key.playbackDevice << " | " + << key.recordDevice << ", its streams: output " + << figure.outputStreams << "; input " << figure.inputStreams; + } + cerr << endl; + updateLatencyMenuLine(); + return true; +} + +void +MainWindow::forgetMeasuredLatency() +{ + QSettings settings; + LatencyCalibration::forget + (settings, latencyKey(expectedRecordingRate())); + cerr << "MainWindow::forgetMeasuredLatency: at " + << expectedRecordingRate() << " Hz" << endl; + updateLatencyMenuLine(); +} + +void +MainWindow::updateLatencyMenuLine() +{ + if (!m_latencyLineAction || !m_forgetLatencyAction) return; + LatencyCalibration::InUse inUse = latencyInUse(); + m_latencyLineAction->setText + (tr("Latency: %1").arg(CalibrateAudioDialog::describeLatency(inUse))); + + // A stale figure is kept too (it applies again if the device goes + // back to its old buffers), and can be forgotten like any other + m_forgetLatencyAction->setEnabled + (inUse.source == LatencyCalibration::Source::Measured || inUse.stale); +} + +void +MainWindow::calibrateAudio() +{ + if (!m_calibrateAudioDialog) { + m_calibrateAudioDialog = new CalibrateAudioDialog(this, m_audioCheck); +#ifdef TONY_DEV_CHECKS + m_calibrateAudioDialog->setDevChecks(m_devChecks); +#endif + // Where the dialog goes while its check runs: the right end of + // the status bar, a corner the panes never reach, clear of the + // status line at its left + statusBar()->addPermanentWidget(m_calibrateAudioDialog->indicator()); + } + m_calibrateAudioDialog->present(); +} + +TakeTiming +MainWindow::currentTakeTiming() const +{ + TakeTiming timing; + auto model = getMainModel(); + timing.rate = model ? model->getSampleRate() : 0; + // The device's rate, which the recording is made at; not known until + // the recording has been started, and not needed before + if (auto recording = ModelById::getAs + (m_currentRecordingModelId)) { + timing.recordRate = recording->getSampleRate(); + } + timing.position = m_takePosition; + timing.end = m_takeEnd; + timing.preRoll = m_takePreRoll; + timing.latency = currentRecordingLatency(); + return timing; +} + +sv_frame_t +MainWindow::wantedPreRollFrames() const +{ + double seconds = 0.0; + if (m_audioCheckTakes) { + // The audio check's takes have a lead-in of their own + seconds = m_audioCheckPreRoll; + } else { + if (!m_preRoll || !m_preRoll->isChecked()) return 0; + QSettings settings; + settings.beginGroup("MainWindow"); + seconds = settings.value("prerollseconds", 3.0).toDouble(); + settings.endGroup(); + } + if (seconds <= 0.0) return 0; + + auto model = getMainModel(); + sv_samplerate_t rate = model ? model->getSampleRate() : 0; + if (rate <= 0) return 0; + + return sv_frame_t(seconds * rate); +} + +void +MainWindow::onRealtimePitchDetected +(const RealtimePitchTracker::Estimates &estimates) +{ + // Everything the tracker has found since the last batch, handed over + // by m_liveDotsFeed on the GUI thread, so that all model mutations + // stay on this thread. One estimate at a time, as they were found, + // a GUI thread slower than the tracker would fall further behind it + // for as long as the take lasted. + // + // The feed stops with the tracker when the take ends. A batch that + // came even so would find the dots perhaps still on show, waiting for + // pYIN, but the take over: leave them, and the status bar, alone. + if (!m_recordingInProgress || estimates.empty()) return; + + // Draw the dot where the finished pitch track will put this sound: the + // take is spliced into the singing track from m_takePosition on, with + // the recording latency and the lead-in taken off its front. The first + // dots may have been placed using the estimate of the start gap. They + // all belong to sound from before the reference started, which has no + // place on the reference's timeline. + sv_frame_t latencyBefore = m_recordingLatencyFrames; + refineRecordingLatency(); + auto m = ModelById::getAs(m_realtimePitchModelId); + if (m && m_recordingLatencyFrames != latencyBefore) { + for (const Event &e : m->getAllEvents()) m->remove(e); + } + + // The frames are the recording's, at the device's rate, and the + // answers the reference's. A negative answer is sound sung during the + // lead-in of a pre-roll, or before the reference started at all: no + // dot for it + TakeTiming timing = currentTakeTiming(); + sv_frame_t from = 0, to = 0; + const RealtimePitchTracker::Estimate *newest = nullptr; + for (const auto &estimate : estimates) { + sv_frame_t intoTake = timing.liveFrameIntoTake(estimate.frame); + if (intoTake < 0) continue; + sv_frame_t dotFrame = m_takePosition + intoTake; + if (m) m->add(Event(dotFrame, float(estimate.hz), tr(""))); + sv_frame_t dotEnd = dotFrame + RealtimePitchTracker::kHopSize; + if (!newest) { + from = dotFrame; + to = dotEnd; + } else { + from = std::min(from, dotFrame); + to = std::max(to, dotEnd); + } + newest = &estimate; + } + if (!newest) return; + + // The pane draws again only where the new dots are. The model's own + // notice would have it draw all of itself, which at a phone's pixel + // ratio is several times the cost, 25 times a second + if (m && m_paneStack && m_paneStack->getPaneCount() > 0) { + updateViewFrames(m_paneStack->getPane(0), from, to); + } + + // The status bar says what is being sung just now: the newest + // estimate of the batch. Convert Hz to MIDI note number and cents + // deviation. MIDI note 69 = A4 = 440 Hz. + double hz = newest->hz; + double midiNote = 12.0 * std::log2(hz / 440.0) + 69.0; + int nearestNote = int(std::round(midiNote)); + int cents = int(std::round((midiNote - nearestNote) * 100.0)); + + // Note names (no flats — sharps only for display simplicity). + static const char *noteNames[] = { + "C", "C#", "D", "D#", "E", "F", + "F#", "G", "G#", "A", "A#", "B" + }; + int noteIndex = ((nearestNote % 12) + 12) % 12; + int octave = (nearestNote / 12) - 1; + QString noteName = QString("%1%2").arg(noteNames[noteIndex]).arg(octave); + + QString centsStr; + if (cents == 0) { + centsStr = tr("in tune"); + } else if (cents > 0) { + centsStr = tr("+%1 cents").arg(cents); + } else { + centsStr = tr("%1 cents").arg(cents); + } + + getStatusLabel()->setText( + tr("Recording — singing: %1 (%2 Hz, %3)") + .arg(noteName) + .arg(hz, 0, 'f', 1) + .arg(centsStr)); +} + +void +MainWindow::logLiveDots(const LiveDotsFeed::Report &report) +{ + // Once a second during a take, so that the log of a phone says + // whether the dots keep up with the singing there: how much has been + // recorded, how far the tracker has analysed it, and how far the dots + // handed to the pane have got, all in seconds of the recording; then + // what the dots cost the GUI thread + sv_samplerate_t rate = 0; + if (auto recording = ModelById::getAs + (m_currentRecordingModelId)) { + rate = recording->getSampleRate(); + } + if (rate <= 0 || !m_recordTarget || !m_realtimePitchTracker) return; + + auto seconds = [rate](sv_frame_t frame) { + return QString::number(double(frame) / rate, 'f', 2); + }; + // A dot is at the middle of the window it was found in: the end of + // that window is the recording it has caught up with + sv_frame_t dotsTo = report.newestFrame < 0 ? 0 : + report.newestFrame + RealtimePitchTracker::kWindowSize / 2; + + cerr << "MainWindow: live dots: " + << seconds(m_recordTarget->getFramesReceived()) << " s recorded, " + << "tracker at " << seconds(m_realtimePitchTracker->getFramesAnalysed()) + << " s, dots to " << seconds(dotsTo) << " s; " + << LiveDotsFeed::describe(report) << endl; +} + +void +MainWindow::recordingFinishedFull(Analyser *analysing) +{ + // Called from analyseNow() once the pYIN analysis of the newly + // recorded audio has been started. The take is over, so the tracker + // goes now. The coarse realtime pitch layer (the "live" orange dots) + // stays on show until the analyser passed in reports that the full + // pYIN pitch track is there to replace it. With no analyser (the + // analysis could not be started) there is nothing to wait for. + cerr << "MainWindow::recordingFinishedFull: take finished" << endl; + stopTakePolling(); + m_recordingInProgress = false; + m_recordingAsSingingTrack = false; + m_currentRecordingModelId = {}; + restoreSingingAudioAfterTake(); + updateSingingTrackForTake(); + + if (analysing && m_realtimePitchLayer) { + stopRealtimePitchTracker(); + if (m_realtimeLayerTeardownConnection) { + disconnect(m_realtimeLayerTeardownConnection); + } + m_realtimeLayerTeardownConnection = + connect(analysing, &Analyser::initialAnalysisCompleted, + this, [this]() { + cerr << "MainWindow: pYIN done, removing realtime pitch layer" << endl; + teardownRealtimePitchLayer(); + }); + } else { + teardownRealtimePitchLayer(); + } + + // Stop reference playback that was started for the singer's benefit, + // and suspend the audio IO where Stop does (suspendAudioOnStop()): + // the application keeps the stream running for the next take + if (m_playSource && m_playSource->isPlaying()) { + cerr << "MainWindow::recordingFinishedFull: stopping reference playback" << endl; + m_playSource->stop(); + if (suspendAudioOnStop()) { + if (m_audioIO) m_audioIO->suspend(); + else if (m_playTarget) m_playTarget->suspend(); + } + } + restorePlaySelectionAfterTake(); + + updateLayerStatuses(); + updateMenuStates(); +} + +void +MainWindow::finishSingingTake() +{ + // The take has stopped, and what the device recorded is raw material: + // it goes into the take's audio file at the position the take was + // started from, with the latency and the lead-in taken off its front, + // and the singing track is then shown and analysed from the file that + // comes out -- the new audio under the pitch and notes layers that + // are there, with only the range that changed analysed, which is what + // makes this quick on a long song. + + stopTakePolling(); + refineRecordingLatency(); + + TakeTiming timing = currentTakeTiming(); + sv_frame_t offset = timing.spliceOffset(); + sv_frame_t length = timing.spliceLength(); + sv_frame_t position = m_takePosition; + + QString recordingPath; + sv_frame_t recorded = 0; + if (auto wfm = ModelById::getAs + (m_currentRecordingModelId)) { + recordingPath = wfm->getLocation(); + recorded = wfm->getFrameCount(); + m_takeLatency.recordingRate = wfm->getSampleRate(); + } + + // The take is over: the tracker goes first, so that the recording's + // model is never released under it, and then the model itself. + // AudioCallbackRecordTarget::stopRecording() has flushed the buffers + // and called writeComplete(), and releasing the model closes what is + // left, so the file is whole before the splice reads it. + stopRealtimePitchTracker(); + m_recordingInProgress = false; + m_recordingAsSingingTrack = false; + teardownRecordingLayer(); + + // The playhead goes back to where the take started: Play then hears + // what was just sung, and Record again records the same part. (While + // recording, ViewManager keeps the playback frame at the duration of + // the take, so it is somewhere else by now.) + if (m_viewManager) m_viewManager->setPlaybackFrame(position); + + // A take stopped the moment it was started, or one no longer than the + // latency and the lead-in together, has nothing in it to add. Nothing + // has gone wrong; there is simply nothing to do. (The recording is at + // the device's rate, the offset at the reference's.) + if (recordingPath != "" && timing.recordedToReference(recorded) <= offset) { + cerr << "MainWindow::finishSingingTake: nothing to use: " << recorded + << " frames recorded, the first " << offset + << " of which are the latency and the lead-in" << endl; + recordingFinishedFull(nullptr); + return; + } + + QString error; + Coverage::Range placed; + + // What an undo of this recording has to put back + QString pathBefore = m_takes->getAudioPath(); + Coverage coverageBefore = m_takes->getCoverage(); + + if (recordingPath == "") { + error = tr("The recording is no longer there to be used"); + } else { + QString directory = takeAudioDirectory(); + if (directory == "") { + error = tr("Could not find a directory to write the singing " + "track into"); + } else { + // Everything before the offset is sound from before the singer + // could have heard the reference at the take's position: the + // round trip, and the lead-in of a pre-roll before it. The + // length is what a punch-out allows, or all there is. A + // device that does not run at the reference's rate made the + // recording at its own, and it is converted to the + // reference's on the way in: the take's frames are the + // reference's, as all three figures are + error = m_takes->spliceRecording(recordingPath, offset, position, + length, directory, &placed, + timing.rate); + } + } + + if (error != "") { + QMessageBox::warning + (this, + tr("Failed to add the recording to the singing track"), + tr("The recording could not be added to the singing track" + "

%1

").arg(error), + QMessageBox::Ok); + // The singing track is as it was, and the recording is still in + // the record directory; there is nothing for the dots to wait for + recordingFinishedFull(nullptr); + return; + } + + cerr << "MainWindow::finishSingingTake: " << recorded << " frames " + << "recorded, used from frame " << offset << ", placed at [" + << placed.start << "," << placed.end << ") of " + << m_takes->getAudioPath() << endl; + + // The command of this recording, made before the audio is shown and + // added to the history after: a range short enough is analysed and + // merged before the rebuild returns, and the merge has to find the + // command it belongs to. Any command still waiting for an analysis is + // closed first -- the rebuild below folds that range into its own run, + // so the merge it was waiting for is never coming + closeOpenTakeCommand(false); + SingingTakeCommand *command = + new SingingTakeCommand(this, tr("Record Singing"), + pathBefore, coverageBefore, + m_takes->getAudioPath(), m_takes->getCoverage()); + m_openTakeCommand = command; + + bool analysing = rebuildSingingTrackFromTake(placed); + + if (analysing) { + // The events of this run are not in the command yet, and an undo + // pressed before they are abandons the run: a redo has to make it + // again, over the range the run really covers + command->setPendingAnalysis(m_takeAnalysisRange); + } else if (m_openTakeCommand == command) { + // Nothing was analysed, or it failed to start: nothing to wait for + m_openTakeCommand = nullptr; + } + + addTakeCommand(command); + + // The coverage has changed whether or not the new audio could be + // shown, and the strip says what it is now. After the rebuild, not + // before: a swap puts the pane's selected layer back as it found it, + // and the strip is never to be that + syncCoverageStrip(); + + // The dots stay until the analysis that replaces them is done + recordingFinishedFull(analysing ? m_analyser2 : nullptr); +} + +bool +MainWindow::rebuildSingingTrackFromTake(const Coverage::Range &placed) +{ + if (!m_takes->haveTake()) return false; + + // What the analysis that follows has to cover: the range the splice + // has just written, and any range whose analysis is still running. + // That one's result is lost -- the swap below releases the models it + // is being written into, and its analyser with them -- so it is + // analysed again rather than left half done + Coverage::Range analyse = placed; + if (m_analyser2 && m_analyser2->isAnalysingRange() && + m_takeAnalysisRange.length() > 0) { + analyse.start = std::min(analyse.start, m_takeAnalysisRange.start); + analyse.end = std::max(analyse.end, m_takeAnalysisRange.end); + } + m_takeAnalysisRange = Coverage::Range(); + + QString path = m_takes->getAudioPath(); + + // A take with pitch and notes on show keeps them: the new audio goes + // underneath, and only the range that changed is analysed. The + // first recording of a take has neither, so its audio is opened as a + // singing track with no analysis of its own and given empty ones + bool haveLayers = m_analyser2 && + m_analyser2->getLayer(Analyser::PitchTrack) && + m_analyser2->getLayer(Analyser::Notes); + + QString error = (haveLayers ? swapSingingAudio(path) + : loadTakeAudio(path)); + + if (error == "" && m_analyser2) { + error = m_analyser2->addEmptyAnalyses(); + + // The first recording of a take has just been given its layers: + // they are named after it, and they go above the takes that are + // put away, where the note tool looks for the layer to act on + nameActiveTakeLayers(); + raiseActiveTakeLayers(); + } + + if (error != "") { + // The recording is in the take's audio file and the take's + // coverage says so, but the screen does not: say so rather than + // leave the two quietly disagreeing + QMessageBox::warning + (this, + tr("Failed to show the recording"), + tr("The recording was added to the singing track, but it " + "could not be shown

%1

What is on screen is the " + "singing track as it was. The recording is in the take's " + "audio file, \"%2\".

").arg(error, path), + QMessageBox::Ok); + return false; + } + + return startTakeAnalysis(analyse.start, analyse.end); +} + +QString +MainWindow::loadTakeAudio(QString path) +{ + // The take's audio, opened and shown with no analysis of its own: + // what is analysed is the range a recording has just gone into, and + // the pitch and notes of the rest of the take are either in the pane + // already or about to be made empty. swapSingingAudio() is this for + // a take that has its layers; the two are the same but for the + // handing over + ModelId audio; + FileOpenStatus status = openTakeAudioFile(path, audio); + + if (status != FileOpenSucceeded || audio.isNone()) { + return tr("The file \"%1\" could not be opened as audio").arg(path); + } + + // Layers of the take that no analyser owns are handed to the one + // about to be made, as after a session restore + adoptTakeLayers(audio); + + // m_rebuildingTakeAudio: the coverage of this file is the one the + // splice worked out, not "the whole of it" + bool wasRebuilding = m_rebuildingTakeAudio; + m_rebuildingTakeAudio = true; + setupSingingTrackAnalyser(audio, true); + m_rebuildingTakeAudio = wasRebuilding; + + if (!m_analyser2) { + // The audio model is left registered with the document, which + // releases it when it goes: Document::releaseModel() is the + // document's own business, and holding a model nothing shows + // costs only memory + return tr("The singing track could not be set up on \"%1\"").arg(path); + } + + return ""; +} + +bool +MainWindow::adoptTakeLayers(ModelId audio) +{ + // An analyser claims a pitch or notes layer whose model has the + // analyser's own audio model as its source model + // (Analyser::claimExistingAnalyses()). The layers of a take that + // has had audio swapped under it have no such link: the model they + // were derived from is long gone, and a session file keeps them as + // ordinary layers. So the link is made here, for the layers of the + // active take, just before the analyser that is to claim them is made. + Pane *pane = m_paneStack ? m_paneStack->getPane(0) : nullptr; + if (!pane || audio.isNone()) return false; + + QString takeName = m_takes->getActiveName(); + + // By name, which is what says whose a take's layers are (spec 6.4). + // The layers of the takes that are put away have no live source model + // either, so nothing but the name can tell them apart. (Before 7b + // there was a fallback for a session whose takes had no names of their + // own; a session that does not name its takes now opens without them.) + TakeLayers::Found found = TakeLayers::find(pane, takeName); + TimeValueLayer *pitch = found.pitch; + FlexiNoteLayer *notes = found.notes; + + // Half a pair is no use: the analyser claims both or neither + if (!pitch || !notes) return false; + + cerr << "MainWindow::adoptTakeLayers: the take's pitch and notes layers " + << "come from model " << audio << " now" << endl; + + for (ModelId id : { pitch->getModel(), notes->getModel() }) { + if (auto model = ModelById::get(id)) { + model->setSourceModel(audio); + } + } + + return true; +} + +bool +MainWindow::startTakeAnalysis(sv_frame_t start, sv_frame_t end) +{ + if (!m_analyser2 || end <= start) return false; + + // How far the analysis may reach for the context it needs: as far as + // the material the take has, and no further. The margin may run + // into silence that has been recorded over, but silence that was + // never recorded tells pYIN nothing and is not ours to analyse + const Coverage &coverage = m_takes->getCoverage(); + sv_frame_t clipStart = start, clipEnd = end; + Coverage::Range at; + if (coverage.getRangeAt(start, at)) clipStart = at.start; + if (coverage.getRangeAt(end - 1, at)) clipEnd = at.end; + + QString error = m_analyser2->analyseRange(start, end, clipStart, clipEnd); + + if (error != "") { + QMessageBox::warning + (this, + tr("Failed to analyse the recording"), + tr("The singing could not be analysed

%1

").arg(error), + QMessageBox::Ok); + return false; + } + + // The analysis is merged from the event loop, however soon it is + // done (Analyser::analyseRange()); this is for an analyser that + // could not start one after all + if (!m_analyser2->isAnalysingRange()) return false; + + m_takeAnalysisRange = Coverage::Range(start, end); + + // Erasing is not to be had while this runs + updateMenuStates(); + return true; +} + +bool +MainWindow::analyseTakeCoverage() +{ + // Analyse Now takes in the singing of the take as well (spec 7): all + // of its coverage is analysed again and merged into its pitch and + // notes. One run from the first range to the last, not one run per + // range: only one ranged analysis can be running at a time, and pYIN + // over the silence between two ranges costs less than a queue of + // runs would. + if (!m_analyser2 || !m_takes->haveTake()) return false; + + const Coverage::Ranges &ranges = m_takes->getCoverage().getRanges(); + if (ranges.empty()) return false; + + // Analyse Now is not undoable (spec 7), and its merge must not be + // taken for the one a recording's command is waiting for. The run + // that command was waiting for is about to be abandoned by this one, + // so the events it would have added never existed; the command keeps + // the range, and analyses it again if it is ever redone + closeOpenTakeCommand(false); + + return startTakeAnalysis(ranges.front().start, ranges.back().end); +} + +void +MainWindow::eraseSingingInSelection() +{ + // The singing in the selected ranges goes out of the take: silence + // in place of it in a new audio file, the ranges out of the coverage, + // and the pitch and notes that were analysed there deleted. Nothing + // is analysed: there is nothing left there to analyse. + // + // The action is disabled unless all of this holds, but a shortcut or + // a script can still reach it + if (!m_viewManager || !m_takes->haveTake()) return; + if (m_recordTarget && m_recordTarget->isRecording()) return; + + // The analysis of a recorded range is merged into the very models + // the swap below hands over, and the swap would cancel it and lose + // its result. It lasts a fraction of the recording it follows, so + // waiting for it is better than rescuing it + if (m_analyser2 && m_analyser2->isAnalysingRange()) return; + + Coverage::Ranges selected; + for (const Selection &s : m_viewManager->getSelections()) { + selected.push_back(Coverage::Range(s.getStartFrame(), + s.getEndFrame())); + } + if (selected.empty()) return; + + QString directory = takeAudioDirectory(); + QString error; + + if (directory == "") { + error = tr("Could not find a directory to write the singing track " + "into"); + } + + // What an undo of this erase has to put back + QString pathBefore = m_takes->getAudioPath(); + Coverage coverageBefore = m_takes->getCoverage(); + + Coverage::Ranges erased; + if (error == "") { + error = m_takes->eraseRanges(selected, directory, &erased); + } + + if (error != "") { + QMessageBox::warning + (this, + tr("Failed to erase the singing"), + tr("The singing in the selection could not be erased" + "

%1

").arg(error), + QMessageBox::Ok); + return; + } + + if (erased.empty()) { + // Nothing of the selection held recorded singing + emit activity(tr("No recorded singing in the selection to erase")); + return; + } + + QString path = m_takes->getAudioPath(); + + cerr << "MainWindow::eraseSingingInSelection: erased " + << erased.size() << " range(s) into " << path << endl; + + // The new audio goes under the take's pitch and notes layers, which + // are edited below rather than analysed again + bool haveLayers = m_analyser2 && + m_analyser2->getLayer(Analyser::PitchTrack) && + m_analyser2->getLayer(Analyser::Notes); + + QString showError = (haveLayers ? swapSingingAudio(path) + : loadTakeAudio(path)); + + // The coverage has changed whether or not the new audio could be + // shown, and the strip says what it is now. After the swap, which + // puts the pane's selected layer back as it found it, and the strip + // is never to be that + syncCoverageStrip(); + + TakeEvents::Change pitchChange, notesChange; + eraseTakeEvents(erased, &pitchChange, ¬esChange); + + // Undoable as a whole: the audio file, the coverage and the events + // that went with the singing. Nothing was analysed, so the command is + // complete the moment it is made + SingingTakeCommand *command = + new SingingTakeCommand(this, tr("Erase Singing"), + pathBefore, coverageBefore, + m_takes->getAudioPath(), m_takes->getCoverage()); + command->setEventChanges(pitchChange, notesChange); + addTakeCommand(command); + + if (showError != "") { + // As after a splice that could not be shown: the take's audio + // and its coverage have changed, and the screen has not + QMessageBox::warning + (this, + tr("Failed to show the erased singing"), + tr("The singing was erased, but the result could not be " + "shown

%1

What is on screen is the singing track " + "as it was. The erased audio is in the take's audio file, " + "\"%2\".

").arg(showError, path), + QMessageBox::Ok); + } + + updateLayerStatuses(); + updateMenuStates(); + emit activity(tr("Erased the singing in the selection")); +} + +void +MainWindow::eraseTakeEvents(const Coverage::Ranges &erased, + TakeEvents::Change *pitchChange, + TakeEvents::Change *notesChange) +{ + if (!m_analyser2) return; + + Layer *pitchLayer = m_analyser2->getLayer(Analyser::PitchTrack); + Layer *notesLayer = m_analyser2->getLayer(Analyser::Notes); + + auto pitch = pitchLayer ? + ModelById::getAs(pitchLayer->getModel()) : + nullptr; + auto notes = notesLayer ? + ModelById::getAs(notesLayer->getModel()) : nullptr; + + // Straight on the models, the way an analysis result is merged: no + // command of their own. What was changed goes back to the caller, + // which puts the erase as a whole on the undo stack + if (pitch) { + TakeEvents::Change change = + TakeEvents::erasePitch(pitch->getAllEvents(), erased); + for (const Event &e : change.removed) pitch->remove(e); + for (const Event &e : change.added) pitch->add(e); + if (pitchChange) *pitchChange = change; + } + + if (notes) { + TakeEvents::Change change = + TakeEvents::eraseNotes(notes->getAllEvents(), erased); + for (const Event &e : change.removed) notes->remove(e); + for (const Event &e : change.added) notes->add(e); + if (notesChange) *notesChange = change; + } +} + +QString +MainWindow::takeAudioDirectory() +{ + // A take's combined audio belongs to the song, in the session's own + // folder beside the .ton (spec 6.4). Before the first save there is + // no folder to put it in, so it goes to the record directory with the + // raw recordings, and the save copies it across + if (m_sessionFile != "") { + QString folder = ensureTakesFolder(m_sessionFile); + if (folder != "") return folder; + + // Nowhere to write it beside the session: better in the record + // directory than nowhere at all, and the next save says so when it + // cannot copy it either + cerr << "MainWindow::takeAudioDirectory: could not make the takes " + << "folder of \"" << m_sessionFile << "\"; writing to the record " + << "directory instead" << endl; + } + + return RecordDirectory::getRecordDirectory(); +} + +QString +MainWindow::ensureTakesFolder(QString sessionPath) +{ + QString folder = TakesFile::takesFolder(sessionPath); + if (folder == "") return ""; + + if (QFileInfo(folder).isDir()) return folder; + + if (!QDir().mkpath(folder)) return ""; + + // Made here and empty so far: if nothing is left in it when the + // session closes, it goes again + if (!m_takeFoldersMade.contains(folder)) m_takeFoldersMade.push_back(folder); + + return folder; +} + +bool +MainWindow::copyTakeAudioForSave(QString sessionPath) +{ + if (!m_takes || m_takes->getTakeCount() == 0) return true; + + // Nothing to do for a session whose takes are all in its folder + // already, and no folder to make for one that has no audio at all + bool anyOutside = false; + QString folder = TakesFile::takesFolder(sessionPath); + for (const SingingTakes::Take &take : m_takes->getTakes()) { + if (take.audioPath == "") continue; + if (!TakesFile::isInFolder(folder, take.audioPath)) anyOutside = true; + } + if (!anyOutside) return true; + + QString error; + QString made = ensureTakesFolder(sessionPath); + + if (made == "") { + error = tr("The folder \"%1\", where the takes' audio belongs, could " + "not be made").arg(folder); + } else { + error = TakesFile::copyTakeAudioInto(*m_takes, made); + } + + if (error != "") { + QMessageBox::critical + (this, + tr("Failed to save the session"), + tr("The session was not saved

The audio of its takes " + "must be in the folder beside it, and could not be put " + "there.

%1

").arg(error), + QMessageBox::Ok); + return false; + } + + return true; +} + +bool +MainWindow::saveSessionFile(QString path) +{ + // Not while the analysis of a recorded range runs: the session would + // hold neither the state before the merge nor the state after it + if (!waitForRangedAnalysis()) return false; + + // The takes' audio goes into the folder of the session being saved + // before the file is written, so that the file names it where it is. + // A Save As copies into the new session's folder and leaves the old + // one alone: it belongs to the .ton that is still there + if (!copyTakeAudioForSave(path)) return false; + + // What toXml() names the takes' audio relative to + m_savingSessionPath = path; + bool saved = MainWindowBase::saveSessionFile(path); + m_savingSessionPath = ""; + + // The .ton that has just been written names the audio file of every + // take of the session. Recording into a take again writes another file + // and supersedes that one, but the saved session still needs it, so it + // is not ours to delete when the session closes + if (saved && m_takes) { + for (const SingingTakes::Take &take : m_takes->getTakes()) { + m_takes->protectPath(take.audioPath); + } + } + + return saved; +} + +void +MainWindow::toXml(QTextStream &out, bool asTemplate) +{ + // A template holds no session content, so it holds no takes either + if (asTemplate) { + MainWindowBase::toXml(out, asTemplate); + return; + } + + // The takes of the session are one element of Tony's own inside the + // document (spec 6.4), which SVFileReader passes over with a + // warning on the terminal and nothing else. The base class writes the + // document from to in one call and has no hook in the + // middle, so it writes into a string here and the element goes in + // before the closing tag. A hook in the fork would save the copy. + QString document; + { + QTextStream buffer(&document); + MainWindowBase::toXml(buffer, asTemplate); + buffer.flush(); + } + + int closing = document.lastIndexOf(""); + + if (closing < 0) { + // Not a document we know where to write in: better saved without + // its takes than not saved at all + cerr << "MainWindow::toXml: no to write the takes before" << endl; + out << document; + return; + } + + out << document.left(closing) + << TakesFile::toXml(*m_takes, m_savingSessionPath) + << document.mid(closing); +} + +MainWindow::FileOpenStatus +MainWindow::openSession(FileSource source) +{ + // m_restoringSession: modelAdded() queues a call that would make a take + // of the audio model the document carries, and restoreTakes() is what + // deals with that model instead + m_restoringSession = true; + FileOpenStatus status = MainWindowBase::openSession(source); + m_restoringSession = false; + + if (status != FileOpenSucceeded) return status; + + // The document is in the panes now, with every take's layers in pane 0, + // and nothing has been analysed + restoreTakes(source.getLocalFilename()); + + return status; +} + +void +MainWindow::restoreTakes(QString sessionPath) +{ + if (!m_document) return; + + TakesFile::Takes stored = TakesFile::read(sessionPath); + + // Opening a session is not a change to it, whatever is done below + bool wasModified = m_documentModified; + + // The background music first, so that its audio model is not taken + // for a singing track's below + adoptBackgroundMusic(); + + // The audio model of the active take, which the document carried + // because the waveform layer showing it is in pane 0: of no use here, + // and not to be mistaken for a singing track of its own + dropRestoredSingingTrack(!stored.found); + + if (!stored.found) { + // A session saved before the takes were stored (spec 3, "Old + // sessions"): it opens without its singing track, and the layers + // that showed one have gone with it + cerr << "MainWindow::restoreTakes: the session has no takes element: " + << "it opens without a singing track" << endl; + updateTakeCombo(); + updateLayerStatuses(); + updateMenuStates(); + if (!wasModified) documentRestored(); + return; + } + + Pane *pane = m_paneStack ? m_paneStack->getPane(0) : nullptr; + + // Every name the session used is reserved before any take is made, so + // that the numbering carries on from it and no new take can be given + // the name of a take of the session -- or of one whose layers are in + // the pane although no take claims them any more + for (const TakesFile::Take &take : stored.takes) { + m_takes->reserveTakeName(take.name); + } + if (pane) { + for (int i = 0; i < pane->getLayerCount(); ++i) { + QString name; + TakeLayers::Kind kind; + if (TakeLayers::parse(pane->getLayer(i)->objectName(), + name, kind)) { + m_takes->reserveTakeName(name); + } + } + } + + for (const TakesFile::Take &take : stored.takes) { + + QString name = m_takes->addTake(take.name); + + // The coverage of a take is stored in the regions of its own + // coverage strip, which the document has put back into the pane + // (5a); a take with no recording in it yet has neither + Coverage coverage; + if (pane) { + TakeLayers::Found found = TakeLayers::find(pane, name); + if (found.coverage) { + if (auto model = ModelById::getAs + (found.coverage->getModel())) { + coverage = Coverage::fromEvents(model->getAllEvents()); + } + } + } + + // The stored path is relative to the session file, so that a song + // and its takes folder can be moved together (spec 6.4); a session + // saved before the folder names its audio absolutely + QString audioPath = TakesFile::resolveAudioPath(sessionPath, + take.audioPath); + + // restoreTake() and not setTake(): nothing here supersedes a file + m_takes->restoreTake(audioPath, coverage); + } + + int active = m_takes->indexOf(stored.active); + if (active < 0 && m_takes->getTakeCount() > 0) active = 0; + + cerr << "MainWindow::restoreTakes: " << m_takes->getTakeCount() + << " take(s) restored, active is \"" << stored.active << "\"" << endl; + + // The .ton moved without its folder: every take is silent, and the + // user is told once, about the folder, rather than once per take + // (spec 6.4). Only the active take opens its audio, so this is also + // the only place the other takes' files are ever looked for + QStringList missing; + for (const SingingTakes::Take &take : m_takes->getTakes()) { + if (take.audioPath == "" || QFileInfo::exists(take.audioPath)) continue; + missing.push_back(take.audioPath); + } + + if (active >= 0) { + // The same path a switch uses: the take's audio under the take's + // own layers, an analyser that claims them with no analysis, its + // coverage strip, and every other take put away + m_takes->setActiveIndex(active); + activateTake(missing.isEmpty()); + } else { + putOtherTakeLayersAway(); + } + + if (!missing.isEmpty()) { + QMessageBox::warning + (this, + tr("Takes without their audio"), + tr("The takes of this session are shown without their " + "audio

%1 audio file(s) were expected in \"%2\", and " + "are not there. Each take still has its pitch, its notes and " + "its coverage; only the sound is missing.

") + .arg(missing.size()) + .arg(TakesFile::takesFolder(sessionPath)), + QMessageBox::Ok); + } + + updateTakeCombo(); + updateLayerStatuses(); + updateMenuStates(); + + // Whatever was done above, the session is as it was saved + if (!wasModified) documentRestored(); +} + +void +MainWindow::dropRestoredSingingTrack(bool withTakeLayers) +{ + // Nothing is to make a take of the audio model the session carried: + // restoreTakes() opens each take's audio from the path in + m_pendingSingingModelId = {}; + + if (!m_document) return; + + // The reference is the main model and the background music has been + // adopted by now; every other audio model in a restored document + // belongs to a singing track + ModelId mainId = getMainModelId(); + std::vector audio; + for (ModelId id : m_document->getModels()) { + if (id == mainId || id == m_backgroundMusicModelId) continue; + if (ModelById::isa(id)) audio.push_back(id); + } + + // (None, in a session saved since the take's waveform layer was kept + // out of the file -- Layer::setSavedInSession() -- but sessions saved + // before that carry one, and layers named after a take may be there + // to drop either way) + if (audio.empty() && !withTakeLayers) return; + + auto isAudio = [&audio](ModelId id) { + return std::find(audio.begin(), audio.end(), id) != audio.end(); + }; + + // Collected before anything is deleted: releasing a model clears the + // source of what was derived from it + std::vector going; + + for (Layer *layer : m_document->getLayers()) { + + ModelId modelId = layer->getModel(); + + if (isAudio(modelId)) { + going.push_back(layer); + continue; + } + + if (!withTakeLayers) continue; + + // The pitch and notes of that singing track, whether they are + // named after a take or (in a session from before this feature) + // simply derived from its audio + auto model = ModelById::get(modelId); + if (model && isAudio(model->getSourceModel())) { + going.push_back(layer); + continue; + } + + QString takeName; + TakeLayers::Kind kind; + if (TakeLayers::parse(layer->objectName(), takeName, kind)) { + going.push_back(layer); + } + } + + cerr << "MainWindow::dropRestoredSingingTrack: dropping " << going.size() + << " restored layer(s) of the singing track the document carried" + << endl; + + for (Layer *layer : going) dropLayerSilently(layer); +} + +void +MainWindow::dropLayerSilently(Layer *layer) +{ + if (!layer || !m_document) return; + + // The play source takes in the model of every layer that is in a view, + // and a forced delete does not fire layerInAView() to take it out again + if (m_playSource && !layer->getModel().isNone()) { + m_playSource->removeModel(layer->getModel()); + } + + // deleteLayer(force) and nothing else: no undo command, out of every + // view it is in, and the model goes with it if nothing else uses it + m_document->deleteLayer(layer, true); +} + +void +MainWindow::addTakeCommand(SingingTakeCommand *command) +{ + if (!command) return; + + // Undo after a take must undo the take, not take away some layer of + // Tony's own. Everything this application adds to a pane is kept off + // the undo stack: its layers go in by Document::attachLayerToView(), + // a take's audio is opened by openTakeAudioFile(), and the recording + // itself is made in RecordCreateUnshownModel mode, which makes no + // pane and no "Import Recorded Audio" entry. So the history is left + // as it is, and the takes and the edits made before this one can + // still be undone after it. + // + // A command still waiting for a merge is not this one's business any + // more: the callers close it before they get here + if (m_openTakeCommand != command) m_openTakeCommand = nullptr; + + // Already done: the take's audio has been written and is on screen. + // CommandHistory marks the document modified, which is right for both + // of these operations + CommandHistory::getInstance()->addCommand(command, false); +} + +void +MainWindow::closeOpenTakeCommand(bool cancelAnalysis) +{ + if (cancelAnalysis && m_analyser2 && m_analyser2->isAnalysingRange()) { + cerr << "MainWindow::closeOpenTakeCommand: abandoning the analysis of " + << "[" << m_takeAnalysisRange.start << "," + << m_takeAnalysisRange.end << ")" << endl; + m_analyser2->cancelRangedAnalysis(); + m_takeAnalysisRange = Coverage::Range(); + + // The live dots of the take were waiting for that analysis to + // replace them, and it is not coming + teardownRealtimePitchLayer(); + + // Erasing is to be had again now that nothing is running + updateMenuStates(); + } + + m_openTakeCommand = nullptr; +} + +void +MainWindow::takeAnalysisMerged() +{ + if (!m_openTakeCommand || !m_analyser2) return; + + // The recording is on the undo stack already, with the splice in it + // and nothing of the analysis: the events the merge has just changed + // complete it, so that one Undo takes both back + m_openTakeCommand->setEventChanges(m_analyser2->getRangedPitchChange(), + m_analyser2->getRangedNotesChange()); + m_openTakeCommand = nullptr; +} + +void +MainWindow::applyTakeEventChanges(const TakeState &state) +{ + if (!m_analyser2) return; + + Layer *pitchLayer = m_analyser2->getLayer(Analyser::PitchTrack); + Layer *notesLayer = m_analyser2->getLayer(Analyser::Notes); + + auto pitch = pitchLayer ? + ModelById::getAs(pitchLayer->getModel()) : + nullptr; + auto notes = notesLayer ? + ModelById::getAs(notesLayer->getModel()) : nullptr; + + // The removals first, in both directions: where the audio did not + // change, an analysis can put back the very event it took out, and + // that event has to be there once at the end and not twice + if (pitch) { + for (const Event &e : state.pitchRemove) pitch->remove(e); + for (const Event &e : state.pitchAdd) pitch->add(e); + } + if (notes) { + for (const Event &e : state.notesRemove) notes->remove(e); + for (const Event &e : state.notesAdd) notes->add(e); + } +} + +bool +MainWindow::applyTakeState(SingingTakeCommand *command, const TakeState &state) +{ + // An undo or a redo of a recording or an erase. Nothing of the + // command is a layer or a model: what the take is shown with now is + // found here, now, whatever it has been replaced by since + if (!m_document) return false; + + // A recording can be undone while the analysis of it is still + // running. That result belongs to the state being left behind, so the + // run is abandoned rather than allowed to land on the take we are + // putting back; the command remembers the range, and analyses it again + // if it is redone + closeOpenTakeCommand(true); + + m_takes->restoreTake(state.path, state.coverage); + + QString error; + + if (state.path == "") { + // Undo of the first recording of a take: no audio to show, and no + // pitch or notes either, exactly as before that recording. The + // next Record makes them again (rebuildSingingTrackFromTake()) + teardownSingingTrackAnalyser(); + } else { + bool haveLayers = m_analyser2 && + m_analyser2->getLayer(Analyser::PitchTrack) && + m_analyser2->getLayer(Analyser::Notes); + error = (haveLayers ? swapSingingAudio(state.path) + : loadTakeAudio(state.path)); + if (error == "" && m_analyser2) { + error = m_analyser2->addEmptyAnalyses(); + // A redo of the first recording of a take makes its layers + // again, and they are the take's + nameActiveTakeLayers(); + raiseActiveTakeLayers(); + } + } + + // After the swap, which puts the pane's selected layer back as it + // found it, and the strip is never to be that + syncCoverageStrip(); + + if (error == "") applyTakeEventChanges(state); + + if (error != "") { + QMessageBox::warning + (this, + tr("Failed to show the singing track"), + tr("The singing track could not be shown as it was" + "

%1

The take's audio is in the file \"%2\".

") + .arg(error, state.path), + QMessageBox::Ok); + } + + // A range whose analysis never finished: its result is in no event + // list, so it is analysed again rather than restored, and the command + // is open once more until that merge lands + if (error == "" && state.analyse.length() > 0) { + m_openTakeCommand = command; + if (!startTakeAnalysis(state.analyse.start, state.analyse.end) && + m_openTakeCommand == command) { + m_openTakeCommand = nullptr; + } + } + + updateLayerStatuses(); + updateMenuStates(); + + emit activity(tr("The singing track is the take's file \"%1\" again") + .arg(state.path == "" ? tr("(none)") : state.path)); + + return error == ""; +} + +void +MainWindow::selectRecordingAtPlayhead() +{ + // The selection becomes the coverage range the playhead is in, so + // that erasing a whole recording is two commands. In a gap there is + // nothing to select, and the selection is left as it was + if (!m_viewManager || !m_takes->haveTake()) return; + if (m_recordTarget && m_recordTarget->isRecording()) return; + + Coverage::Range range; + if (!m_takes->getCoverage().getRangeAt + (m_viewManager->getPlaybackFrame(), range)) { + emit activity(tr("No recorded singing at the playback position")); + return; + } + + m_viewManager->setSelection(Selection(range.start, range.end)); +} + +bool +MainWindow::confirmRecordingOverTake() +{ + QMessageBox box(this); + box.setIcon(QMessageBox::Question); + box.setWindowTitle(tr("Record over the existing singing?")); + box.setText(tr("Record over the existing singing from here?")); + box.setInformativeText + (tr("There is singing recorded from this point on. Recording from " + "here replaces it.")); + box.setStandardButtons(QMessageBox::Yes | QMessageBox::No); + box.setDefaultButton(QMessageBox::Yes); + + QCheckBox *dontAsk = new QCheckBox(tr("Don't ask again"), &box); + box.setCheckBox(dontAsk); + + bool yes = (box.exec() == QMessageBox::Yes); + + // "Don't ask again" means "always go ahead", so it is only taken as + // an answer when this one was yes + if (yes && dontAsk->isChecked()) { + SingingTakes::setOverwriteConfirmationWanted(false); + } + + return yes; +} + +bool +MainWindow::confirmDeleteTake(QString name) +{ + return QMessageBox::question + (this, tr("Delete this take?"), + tr("Delete the take \"%1\"?

Its pitch track and its notes " + "go with it, and this cannot be undone. Its audio file is not " + "deleted.

").arg(name), + QMessageBox::Yes | QMessageBox::No, + QMessageBox::No) == QMessageBox::Yes; +} + +QString +MainWindow::askForTakeName(QString current) +{ + bool ok = false; + QString name = QInputDialog::getText + (this, tr("Rename take"), tr("Name for this take:"), + QLineEdit::Normal, current, &ok); + return ok ? name : QString(); +} + +bool +MainWindow::takeOperationsAllowed() const +{ + // Nothing about the takes of the session changes while one is being + // recorded, or while the analysis of a recorded range is running: that + // analysis is merged into the models a switch would hand over, and the + // switch would lose its result (as an erase would, see + // eraseSingingInSelection()) + if (!m_document) return false; + if (m_paneStack && m_paneStack->getPaneCount() < 1) return false; + if (!getMainModel()) return false; + if (m_recordTarget && m_recordTarget->isRecording()) return false; + if (m_analyser2 && m_analyser2->isAnalysingRange()) return false; + return true; +} + +void +MainWindow::clearTakeHistory() +{ + // A take command holds the state of one take, and after a switch, a + // new take, a duplicate or a delete it is not the take on show any + // more: undoing it would write one take's singing over another's. + // Spec 5.4: the whole history goes, with no prompt + closeOpenTakeCommand(true); + CommandHistory::getInstance()->clear(); +} + +void +MainWindow::nameActiveTakeLayers() +{ + // The pitch and notes layers the singing analyser holds are the active + // take's, and their object names say so: that is the only link between + // a take and the layers that show it (spec 6.4). A whole-file + // analysis and addEmptyAnalyses() both name them after the transform + // that made them, so this is done every time they change hands + QString name = m_takes->getActiveName(); + if (name == "" || !m_analyser2) return; + + const struct { Analyser::Component component; TakeLayers::Kind kind; } + wanted[] = { + { Analyser::PitchTrack, TakeLayers::Pitch }, + { Analyser::Notes, TakeLayers::Notes } + }; + + for (const auto &w : wanted) { + Layer *layer = m_analyser2->getLayer(w.component); + if (!layer) continue; + QString objectName = TakeLayers::nameFor(name, w.kind); + if (layer->objectName() != objectName) { + layer->setObjectName(objectName); + } + } +} + +void +MainWindow::putOtherTakeLayersAway() +{ + // Everything in pane 0 that belongs to a take other than the active + // one: hidden, silent, out of the play source and with no source + // model. Three things depend on this: + // + // - an Analyser claims a pitch or notes layer whose model's source + // model is its own audio, so a take that is put away must have no + // source model at all (and adoptTakeLayers() goes by name); + // - the play source takes in every model of a layer that is in a + // view, and what it holds says where playback ends, so a take + // longer than the active one would hold playback open past the end + // of what there is to hear (5a's finding, as for the strip); + // - a pitch track and a set of notes can be sonified in Tony, and a + // take that is not on show is not to be heard. + Pane *pane = m_paneStack ? m_paneStack->getPane(0) : nullptr; + if (!pane) return; + + QString active = m_takes->getActiveName(); + + for (int i = 0; i < pane->getLayerCount(); ++i) { + + Layer *layer = pane->getLayer(i); + + QString name; + TakeLayers::Kind kind; + if (!TakeLayers::parse(layer->objectName(), name, kind)) continue; + if (name == active) continue; + + layer->setLayerDormant(pane, true); + + if (auto params = layer->getPlayParameters()) { + params->setPlayAudible(false); + } + + ModelId modelId = layer->getModel(); + if (auto model = ModelById::get(modelId)) { + model->setSourceModel(ModelId()); + } + if (m_playSource && !modelId.isNone()) { + m_playSource->removeModel(modelId); + } + } +} + +void +MainWindow::raiseActiveTakeLayers() +{ + // The note tool acts on the pane's topmost note layer, whether that + // layer is dormant or not, so the active take's notes have to be above + // the ones of the takes that are put away. A take's layers are added + // to the pane when it is first recorded into, so without this the + // newest take would keep the tools to itself + Pane *pane = m_paneStack ? m_paneStack->getPane(0) : nullptr; + if (!pane || !m_analyser2) return; + + for (Analyser::Component c : { Analyser::PitchTrack, Analyser::Notes }) { + if (Layer *layer = m_analyser2->getLayer(c)) { + TakeLayers::raise(pane, layer); + } + } +} + +void +MainWindow::deactivateTake() +{ + // The take being put away keeps its layers, with every event in them: + // they are where it is stored (spec 6.4). What it loses is the + // analyser, the audio model under it, and its place in the mix + if (m_analyser2) { + m_analyser2->releaseLayers(); + delete m_analyser2; + m_analyser2 = nullptr; + } + + // The strip stays in the pane as well, holding this take's coverage + m_coverageStrip->release(); + + // Which leaves nothing belonging to the active take but its name: + // putOtherTakeLayersAway() does the rest once another take is active +} + +bool +MainWindow::activateTake(bool warnIfNoAudio) +{ + Pane *pane = m_paneStack ? m_paneStack->getPane(0) : nullptr; + if (!pane) return false; + + QString name = m_takes->getActiveName(); + if (name == "") return false; + + QString path = m_takes->getAudioPath(); + QString error; + + if (path != "") { + // The take's audio under the take's layers, with no analysis: + // loadTakeAudio() finds them by name (adoptTakeLayers()) and the + // new analyser claims them. m_rebuildingTakeAudio: the coverage + // of this file is the take's own, not "the whole of it" + bool wasRebuilding = m_rebuildingTakeAudio; + m_rebuildingTakeAudio = true; + error = loadTakeAudio(path); + m_rebuildingTakeAudio = wasRebuilding; + + // A take that has audio but no pitch and notes -- one whose + // layers were lost with a session that could not be read back -- + // gets empty ones, so that the next recording has something to + // merge into + if (error == "" && m_analyser2) { + error = m_analyser2->addEmptyAnalyses(); + } + nameActiveTakeLayers(); + } + + // The models of the take that is on show now are in the play source + // again: they came out of it when the take was put away. Whether they + // are seen and heard is the analyser's business -- it has just loaded + // the show and play settings onto them, as it does for any file + if (m_analyser2 && m_playSource) { + for (Analyser::Component c : { Analyser::PitchTrack, Analyser::Notes }) { + Layer *layer = m_analyser2->getLayer(c); + if (!layer || layer->getModel().isNone()) continue; + m_playSource->addModel(layer->getModel()); } - m_analyser->setDisplayFrequencyExtents(min, max); } + + putOtherTakeLayersAway(); + syncCoverageStrip(); + raiseActiveTakeLayers(); + + if (error != "" && warnIfNoAudio) { + // Its pitch and notes are there; only the sound is missing + // (spec 6.4, a missing audio file) + QMessageBox::warning + (this, + tr("Failed to open the take's audio"), + tr("The take \"%1\" is shown without its audio

%2

") + .arg(name, error), + QMessageBox::Ok); + } + + return error == ""; } -void -MainWindow::updateDescriptionLabel() +bool +MainWindow::switchToTake(int index) { - // Nothing, we don't have one + if (!takeOperationsAllowed()) return false; + if (index == m_takes->getActiveIndex()) return true; + if (!m_takes->getTake(index)) return false; + + cerr << "MainWindow::switchToTake: from take " + << m_takes->getActiveIndex() << " to " << index << endl; + + clearTakeHistory(); + + deactivateTake(); + m_takes->setActiveIndex(index); + bool ok = activateTake(); + + updateTakeCombo(); + updateLayerStatuses(); + updateMenuStates(); + + // Which take is the active one is stored in the session (spec 6.4), so + // a switch is a change to it + documentModified(); + + emit activity(tr("Switched to the take \"%1\"") + .arg(m_takes->getActiveName())); + + return ok; } void -MainWindow::documentModified() +MainWindow::takeChosenInCombo(int index) { - MainWindowBase::documentModified(); + if (m_updatingTakeCombo) return; + if (index < 0) return; + if (index == m_takes->getActiveIndex()) return; + + if (!switchToTake(index)) { + // Whatever went wrong, the combo box must go on saying which take + // is the active one + updateTakeCombo(); + } } void -MainWindow::documentRestored() +MainWindow::updateTakeCombo() { - MainWindowBase::documentRestored(); + if (!m_takeCombo) return; + + m_updatingTakeCombo = true; + + QStringList names = m_takes->getTakeNames(); + QStringList shown; + for (int i = 0; i < m_takeCombo->count(); ++i) { + shown.push_back(m_takeCombo->itemText(i)); + } + + if (shown != names) { + m_takeCombo->clear(); + m_takeCombo->addItems(names); + } + m_takeCombo->setCurrentIndex(m_takes->getActiveIndex()); + + m_updatingTakeCombo = false; } void -MainWindow::newSession() +MainWindow::newEmptyTake() { - if (!checkSaveModified()) return; - - closeSession(); - createDocument(); - m_document->setAutoAlignment(true); + if (!takeOperationsAllowed()) return; - Pane *pane = m_paneStack->addPane(); - pane->setPlaybackFollow(PlaybackScrollPage); + clearTakeHistory(); - m_viewManager->setGlobalCentreFrame - (pane->getFrameForX(width() / 2)); - - connect(pane, SIGNAL(contextHelpChanged(const QString &)), - this, SLOT(contextHelpChanged(const QString &))); + // The take on show is put away as it is; the new one has no audio and + // no layers until the first recording goes into it + deactivateTake(); + QString name = m_takes->addTake(); + putOtherTakeLayersAway(); + syncCoverageStrip(); -// Layer *waveform = m_document->createMainModelLayer(LayerFactory::Waveform); -// m_document->addLayerToView(pane, waveform); + updateTakeCombo(); + updateLayerStatuses(); + updateMenuStates(); - m_overview->registerView(pane); + // The session has a take it did not have before + documentModified(); - CommandHistory::getInstance()->clear(); - CommandHistory::getInstance()->documentSaved(); - documentRestored(); - updateMenuStates(); + emit activity(tr("Started the empty take \"%1\"").arg(name)); } void -MainWindow::documentReplaced() +MainWindow::duplicateTake() { - if (m_document) { - connect(m_document, SIGNAL(activity(QString)), - m_activityLog, SLOT(activityHappened(QString))); + if (!takeOperationsAllowed()) return; + if (m_takes->getActiveIndex() < 0) return; + + // The events of the take being copied, taken before anything moves: + // the copy gets models of its own holding the same events, and the + // same audio file, which neither take writes over (spec 5.4) + EventVector pitchEvents, notesEvents; + if (m_analyser2) { + if (Layer *layer = m_analyser2->getLayer(Analyser::PitchTrack)) { + if (auto model = ModelById::getAs + (layer->getModel())) { + pitchEvents = model->getAllEvents(); + } + } + if (Layer *layer = m_analyser2->getLayer(Analyser::Notes)) { + if (auto model = ModelById::getAs(layer->getModel())) { + notesEvents = model->getAllEvents(); + } + } + } + + clearTakeHistory(); + + QString from = m_takes->getActiveName(); + deactivateTake(); + QString name = m_takes->duplicateActiveTake(); + + // The copy has no layers of its own yet: activateTake() opens the + // audio, and addEmptyAnalyses() makes the pitch and notes that the + // events below go into + activateTake(); + + if (m_analyser2) { + if (Layer *layer = m_analyser2->getLayer(Analyser::PitchTrack)) { + if (auto model = ModelById::getAs + (layer->getModel())) { + for (const Event &e : pitchEvents) model->add(e); + } + } + if (Layer *layer = m_analyser2->getLayer(Analyser::Notes)) { + if (auto model = ModelById::getAs(layer->getModel())) { + for (const Event &e : notesEvents) model->add(e); + } + } } + + updateTakeCombo(); + updateLayerStatuses(); + updateMenuStates(); + documentModified(); + + emit activity(tr("Copied the take \"%1\" into \"%2\"") + .arg(from, name)); } void -MainWindow::closeSession() +MainWindow::renameTake() { - if (!checkSaveModified()) return; + // The one take operation that leaves the undo history alone: nothing + // of the singing changes, only what the take is called + int index = m_takes->getActiveIndex(); + if (index < 0) return; - m_analyser->fileClosed(); + QString current = m_takes->getActiveName(); + QString name = askForTakeName(current); + if (name == "" || name == current) return; - while (m_paneStack->getPaneCount() > 0) { + if (!m_takes->renameTake(index, name)) { + QMessageBox::warning + (this, tr("Could not rename the take"), + tr("The take could not be renamed to \"%1\"

Another " + "take of this session has that name.

").arg(name), + QMessageBox::Ok); + return; + } - Pane *pane = m_paneStack->getPane(m_paneStack->getPaneCount() - 1); + name = m_takes->getActiveName(); - while (pane->getLayerCount() > 0) { - m_document->removeLayerFromView - (pane, pane->getLayer(pane->getLayerCount() - 1)); + // The take's layers are named after it, so they are renamed with it + nameActiveTakeLayers(); + + if (Pane *pane = m_paneStack ? m_paneStack->getPane(0) : nullptr) { + TakeLayers::Found found = TakeLayers::find(pane, current); + if (found.coverage) { + found.coverage->setObjectName + (TakeLayers::nameFor(name, TakeLayers::Coverage)); } - - m_overview->unregisterView(pane); - m_paneStack->deletePane(pane); + // The strip remembers the name it was adopted under, so it takes + // its layer up again under the new one + m_coverageStrip->release(); + syncCoverageStrip(); } - while (m_paneStack->getHiddenPaneCount() > 0) { + updateTakeCombo(); + updateMenuStates(); + documentModified(); - Pane *pane = m_paneStack->getHiddenPane - (m_paneStack->getHiddenPaneCount() - 1); - - while (pane->getLayerCount() > 0) { - m_document->removeLayerFromView - (pane, pane->getLayer(pane->getLayerCount() - 1)); - } - - m_overview->unregisterView(pane); - m_paneStack->deletePane(pane); - } + emit activity(tr("The take \"%1\" is called \"%2\" now") + .arg(current, name)); +} - delete m_document; - m_document = 0; - m_viewManager->clearSelections(); - m_timeRulerLayer = 0; // document owned this +void +MainWindow::deleteTake() +{ + if (!takeOperationsAllowed()) return; - m_sessionFile = ""; + int index = m_takes->getActiveIndex(); + const SingingTakes::Take *take = m_takes->getTake(index); + if (!take) return; - CommandHistory::getInstance()->clear(); - CommandHistory::getInstance()->documentSaved(); - documentRestored(); + if (!confirmDeleteTake(take->name)) return; + + deleteTakeAt(index); } -void -MainWindow::openFile() +bool +MainWindow::deleteTakeAt(int index) { - QString orig = m_audioFile; - if (orig == "") orig = "."; - else orig = QFileInfo(orig).absoluteDir().canonicalPath(); + const SingingTakes::Take *take = m_takes->getTake(index); + if (!take) return false; + if (!m_document) return false; - QString path = getOpenFileName(FileFinder::SessionOrAudioFile); + QString name = take->name; + bool wasActive = (index == m_takes->getActiveIndex()); - if (path.isEmpty()) return; + cerr << "MainWindow::deleteTakeAt: deleting take \"" << name + << "\" (" << (wasActive ? "active" : "inactive") << ")" << endl; - FileOpenStatus status = openPath(path, ReplaceSession); + clearTakeHistory(); - if (status == FileOpenFailed) { - QMessageBox::critical(this, tr("Failed to open file"), - tr("File open failed

File \"%1\" could not be opened").arg(path)); - } else if (status == FileOpenWrongMode) { - QMessageBox::critical(this, tr("Failed to open file"), - tr("Audio required

Please load at least one audio file before importing annotation data")); + if (wasActive) { + // Its analyser and its audio go; the layers are deleted below + deactivateTake(); + } + + Pane *pane = m_paneStack ? m_paneStack->getPane(0) : nullptr; + if (pane) { + TakeLayers::Found found = TakeLayers::find(pane, name); + for (Layer *layer : { static_cast(found.pitch), + static_cast(found.notes), + static_cast(found.coverage) }) { + if (!layer) continue; + // As the analyser and the strip take their own layers away: + // no command, and the model goes with the layer + if (m_playSource && !layer->getModel().isNone()) { + m_playSource->removeModel(layer->getModel()); + } + m_document->deleteLayer(layer, true); + } + } + + m_takes->removeTake(index); + + // A neighbour is the active take now, or there is none at all. Its + // audio has to be opened either way: the take that was deleted had it + if (wasActive && m_takes->getActiveIndex() >= 0) { + activateTake(); } + + updateTakeCombo(); + updateLayerStatuses(); + updateMenuStates(); + documentModified(); + + emit activity(tr("Deleted the take \"%1\"").arg(name)); + + return true; } void @@ -1828,6 +7981,15 @@ MainWindow::openRecentFile() QString path = action->objectName(); if (path == "") return; +#ifdef Q_OS_ANDROID + // A file moved or deleted since, said so, rather than "could not be + // opened" + if (!recentFileIsThere(path)) { + setupRecentFilesMenu(); + return; + } +#endif + FileOpenStatus status = openPath(path, ReplaceSession); if (status == FileOpenFailed) { @@ -1843,6 +8005,51 @@ void MainWindow::paneAdded(Pane *pane) { pane->setPlaybackFollow(PlaybackScrollPage); + + // Two fingers zoom and scroll the frequency range as well, in the + // pane the reference's analyser shows it in: every pitch and note + // layer there is drawn on it, aligned to its spectrogram's scale + TouchGestures::VerticalRange range; + range.get = [this, pane](VerticalZoom::Range &shown) { + double min, max; + if (!m_analyser || m_analyser->getPane() != pane || + !m_analyser->getDisplayFrequencyExtents(min, max)) { + return false; + } + // What the pane draws values in Hz on, which is that range + // unless something else has come to set the scale + CoordinateScale scale = pane->getEffectiveVerticalExtents("Hz"); + if (scale.getDisplayMinimum() != min || + scale.getDisplayMaximum() != max || + !(scale.isLogarithmic() || scale.isLinear())) { + return false; + } + shown.min = min; + shown.max = max; + shown.log = scale.isLogarithmic(); + return true; + }; + range.set = [this](const VerticalZoom::Range &wanted) { + m_analyser->setDisplayFrequencyExtents(wanted.min, wanted.max); + }; + range.limits = VerticalZoom::pitchLimits(); + + // A zoom keeps in view the reference's pitch and notes and the + // singing's, those of them on show, in the time the pane shows + range.drawn = [this, pane]() { + std::vector values; + for (Analyser *a : { m_analyser, m_analyser2 }) { + if (a && a->getPane() == pane) { + a->getPitchOnShow(pane->getStartFrame(), pane->getEndFrame(), + values); + } + } + return values; + }; + + TouchGestures *gestures = new TouchGestures(pane); // owned by the pane + gestures->setVerticalRange(range); + m_paneStack->sizePanesEqually(); if (m_overview) m_overview->registerView(pane); } @@ -1951,14 +8158,20 @@ MainWindow::commitData(bool mayAskUser) if (!QFileInfo(svDir).isDir()) return false; } - // This name doesn't have to be unguessable + // This name doesn't have to be unguessable. Its extension is the + // one this application opens as a session -- .ton, not Sonic + // Visualiser's .sv, which Tony would try to open as audio + QString extension = InteractiveFileFinder::getInstance() + ->getApplicationSessionExtension(); #ifndef _WIN32 - QString fname = QString("tmp-%1-%2.sv") + QString fname = QString("tmp-%1-%2.%3") .arg(QDateTime::currentDateTime().toString("yyyyMMddhhmmsszzz")) - .arg(QProcess().processId()); + .arg(QProcess().processId()) + .arg(extension); #else - QString fname = QString("tmp-%1.sv") - .arg(QDateTime::currentDateTime().toString("yyyyMMddhhmmsszzz")); + QString fname = QString("tmp-%1.%2") + .arg(QDateTime::currentDateTime().toString("yyyyMMddhhmmsszzz")) + .arg(extension); #endif QString fpath = QDir(svDir).filePath(fname); if (saveSessionFile(fpath)) { @@ -2002,6 +8215,42 @@ MainWindow::checkSaveModified() return false; } +bool +MainWindow::sessionIsIncomplete() const +{ + // Set by svapp's session reader for audio it could not read, and kept + // by the document until it is replaced (or saved, below) + return m_document && m_document->isIncomplete(); +} + +bool +MainWindow::confirmSaveOfIncompleteSession() +{ + if (!sessionIsIncomplete()) return true; + return askToSaveIncompleteSession(); +} + +bool +MainWindow::askToSaveIncompleteSession() +{ + // svapp's words at the load, asked again when it comes to it + return QMessageBox::warning + (this, tr("Save incomplete session?"), + tr("Save this session without all of its audio?

Some of " + "the audio content referred to by the original session file " + "could not be loaded. If you save this session, it will be " + "saved without any reference to that audio, and information " + "may be lost.

"), + QMessageBox::Save | QMessageBox::Cancel, + QMessageBox::Cancel) == QMessageBox::Save; +} + +bool +MainWindow::maySaveUnasked() const +{ + return m_sessionFile != "" && m_documentModified && !sessionIsIncomplete(); +} + bool MainWindow::waitForInitialAnalysis() { @@ -2039,9 +8288,54 @@ MainWindow::waitForInitialAnalysis() } } +bool +MainWindow::waitForRangedAnalysis() +{ + // A session must not be saved in the middle of the analysis of a + // recorded range (spec 6.3): the take's pitch and notes still hold the + // state before the merge, and the two models the run works in are in + // the document, in no pane, so both would be written. The run takes a + // fraction of the recording it follows -- a second or so -- and its + // merge is driven by the event loop, so it is waited for here rather + // than with the dialog waitForInitialAnalysis() puts up for the + // reference's first analysis, which can take as long as the song. + if (!m_analyser2 || !m_analyser2->isAnalysingRange()) return true; + + cerr << "MainWindow::waitForRangedAnalysis: waiting for the analysis of [" + << m_takeAnalysisRange.start << "," << m_takeAnalysisRange.end + << ") to be merged" << endl; + + QEventLoop loop; + + QTimer poll; + connect(&poll, &QTimer::timeout, &loop, [this, &loop]() { + if (!m_analyser2 || !m_analyser2->isAnalysingRange()) loop.quit(); + }); + poll.start(10); + + // A run that never finishes must not hold the save for ever + QTimer::singleShot(30000, &loop, &QEventLoop::quit); + loop.exec(); + + if (m_analyser2 && m_analyser2->isAnalysingRange()) { + // Abandoning it loses the analysis of that range, which the user + // can ask for again with Analyse Now; writing its temporary models + // into the session would leave the session itself wrong + cerr << "MainWindow::waitForRangedAnalysis: the analysis has not " + << "finished; abandoning it so that the session can be saved" + << endl; + closeOpenTakeCommand(true); + } + + return true; +} + void MainWindow::saveSession() { + // (Save As asks for itself) + if (m_sessionFile != "" && !confirmSaveOfIncompleteSession()) return; + // We do not want to save mid-analysis regions -- that would cause // confusion on reloading m_analyser->clearReAnalysis(); @@ -2055,6 +8349,7 @@ MainWindow::saveSession() } else { CommandHistory::getInstance()->documentSaved(); documentRestored(); + if (m_document) m_document->setIncomplete(false); } } else { saveSessionAs(); @@ -2066,6 +8361,8 @@ MainWindow::saveSessionInAudioPath() { if (m_audioFile == "") return; + if (!confirmSaveOfIncompleteSession()) return; + if (!waitForInitialAnalysis()) return; // We do not want to save mid-analysis regions -- that would cause @@ -2101,23 +8398,46 @@ MainWindow::saveSessionInAudioPath() tr("Wait cancelled: the session has not been saved.")); } + saveSessionToPath(path); +} + +bool +MainWindow::saveSessionToPath(QString path) +{ if (!saveSessionFile(path)) { +#ifdef Q_OS_ANDROID + // Save As picked it, and the picker made it, empty + AndroidFiles::removeIfEmpty(path); +#endif QMessageBox::critical(this, tr("Failed to save file"), tr("Session file \"%1\" could not be saved.").arg(path)); - } else { - setWindowTitle(tr("%1: %2") - .arg(QApplication::applicationName()) - .arg(QFileInfo(path).fileName())); - m_sessionFile = path; - CommandHistory::getInstance()->documentSaved(); - documentRestored(); - m_recentFiles.addFile(path); + return false; } + + setWindowTitle(tr("%1: %2") + .arg(QApplication::applicationName()) + .arg(QFileInfo(path).fileName())); + + // From here on the session has a file, so the audio of a take recorded + // from now on is written into its folder (spec 6.4) + m_sessionFile = path; + + // and the file is what the session is: nothing it names is missing + if (m_document) m_document->setIncomplete(false); + + CommandHistory::getInstance()->documentSaved(); + documentRestored(); + m_recentFiles.addFile(path); + return true; } void MainWindow::saveSessionAs() { + // Before the picker, which on Android has made the file by the time + // it returns + if (!confirmSaveOfIncompleteSession()) return; + // We do not want to save mid-analysis regions -- that would cause // confusion on reloading m_analyser->clearReAnalysis(); @@ -2130,23 +8450,16 @@ MainWindow::saveSessionAs() } if (!waitForInitialAnalysis()) { +#ifdef Q_OS_ANDROID + // The picker made it, empty + AndroidFiles::removeIfEmpty(path); +#endif QMessageBox::warning(this, tr("File not saved"), tr("Wait cancelled: the session has not been saved.")); return; } - if (!saveSessionFile(path)) { - QMessageBox::critical(this, tr("Failed to save file"), - tr("Session file \"%1\" could not be saved.").arg(path)); - } else { - setWindowTitle(tr("%1: %2") - .arg(QApplication::applicationName()) - .arg(QFileInfo(path).fileName())); - m_sessionFile = path; - CommandHistory::getInstance()->documentSaved(); - documentRestored(); - m_recentFiles.addFile(path); - } + saveSessionToPath(path); } QString @@ -2546,12 +8859,8 @@ MainWindow::octaveShift(bool up) void MainWindow::togglePitchCandidates() { - CommandHistory::getInstance()->startCompoundOperation(tr("Toggle Pitch Candidates"), true); - m_analyser->showPitchCandidates(!m_analyser->arePitchCandidatesShown()); - CommandHistory::getInstance()->endCompoundOperation(); - updateMenuStates(); } @@ -2904,6 +9213,9 @@ MainWindow::updateVisibleRangeDisplay(Pane *p) const return; } + // The countdown of a pre-roll's lead-in has the status bar to itself + if (showTakeCountdown()) return; + bool haveSelection = false; sv_frame_t startFrame = 0, endFrame = 0; @@ -2997,6 +9309,52 @@ MainWindow::modelAdded(ModelId model) auto dtvm = ModelById::getAs(model); if (dtvm) { cerr << "A dense time-value model (such as an audio file) has been loaded" << endl; + + // If we're loading background music, capture the model ID and return — + // do NOT treat it as a singing track or queue any secondary analysis. + if (m_loadingBackgroundMusic) { + m_backgroundMusicModelId = model; + return; + } + + // A recording being made into the singing track is raw material, + // not the singing track itself: record() gives it a layer of its + // own and finishSingingTake() splices it in at the end. The + // singing analyser is left with the take's own audio, whose pitch + // and notes stay on show for the duration. + if (m_recordingAsSingingTrack && model != getMainModelId()) { + m_currentRecordingModelId = model; + cerr << "modelAdded: the recording of the take is model " + << model << endl; + return; + } + + // If there is already a main model and this is a new additional + // audio model (not the realtime pitch model), treat it as the + // singing track to be analysed with the secondary colour scheme. + ModelId mainId = getMainModelId(); + if (!mainId.isNone() && model != mainId && + m_realtimePitchModelId != model) { + // Guard against race: if modelAdded() fires twice quickly (e.g. + // for an audio model and its alignment model), only set the + // pending id once. + if (m_pendingSingingModelId.isNone()) { + m_pendingSingingModelId = model; + + // An audio file has been loaded as the singing track: the + // take's own audio, or one the user chose. + // loadSingingTrack() runs the analysis itself as soon as + // openPath() returns (it has to happen before the extra + // pane is pruned); this deferred call is what sets up the + // singing track of a session being restored, and the + // fallback for any other route that adds a model. + QTimer::singleShot + (0, this, SLOT(analyseRestoredSingingModel())); + } else { + cerr << "modelAdded: m_pendingSingingModelId already set, ignoring model " + << model << endl; + } + } } } @@ -3007,6 +9365,8 @@ MainWindow::mainModelChanged(ModelId model) MainWindowBase::mainModelChanged(model); + syncSongScrollBar(); + if (m_playTarget || m_audioIO) { connect(m_fader, SIGNAL(valueChanged(float)), this, SLOT(mainModelGainChanged(float))); @@ -3027,6 +9387,29 @@ void MainWindow::analyseNow() { cerr << "analyseNow called" << endl; + + // Not during a take. The analysis of a take is started from here + // when the take ends (recordCompleted, by which time isRecording() + // is false). Run in the middle of one, it would analyse the part + // recorded so far and end the take's bookkeeping early, so that the + // end of the take would re-analyse the reference instead, discarding + // any edits made to its pitch track. + if (m_recordTarget && m_recordTarget->isRecording()) { + cerr << "analyseNow: recording in progress, ignoring" << endl; + return; + } + + // A take that has just stopped is not analysed where it is: the + // recording is raw material, to be spliced into the take's audio at + // the position it was started from. finishSingingTake() does that, + // and rebuilds the singing track from the file that comes out. + if (m_recordingAsSingingTrack) { + cerr << "analyseNow: the take that has just stopped goes into the " + << "singing track" << endl; + finishSingingTake(); + return; + } + if (!m_analyser) return; CommandHistory::getInstance()->startCompoundOperation @@ -3036,6 +9419,9 @@ MainWindow::analyseNow() CommandHistory::getInstance()->endCompoundOperation(); + // The singing of a take is analysed again too, over its coverage + analyseTakeCoverage(); + if (error != "") { QMessageBox::warning (this, @@ -3043,11 +9429,31 @@ MainWindow::analyseNow() tr("Analysis failed

%1

").arg(error), QMessageBox::Ok); } + + // If this analyseNow was triggered by recording completion, the + // realtime pitch layer goes when the full pYIN analysis is available. + if (m_realtimePitchTracker || m_realtimePitchLayer) { + recordingFinishedFull(error == "" ? m_analyser : nullptr); + } } void MainWindow::analyseNewMainModel() { + // When recording as a singing track alongside an existing reference track + // (RecordCreateAdditionalModel mode), MainWindowBase::record() still emits + // audioFileLoaded() at the end — which triggers this slot. But in that + // mode the main model has NOT changed (it is still the reference track), + // so there is nothing for this slot to do. All secondary-track setup is + // handled by modelAdded() → setupSingingTrackAnalyser(). Proceeding here + // would wrongly call m_analyser->newFileLoaded() on the reference track a + // second time, tearing down its existing pitch/note layers and re-running + // pYIN — causing errors, crashes, and a corrupt UI state. + if (m_recordingAsSingingTrack) { + cerr << "analyseNewMainModel: recording-as-singing-track mode, skipping (main model unchanged)" << endl; + return; + } + auto model = getMainModel(); SVDEBUG << "MainWindow::analyseNewMainModel: main model is " << model << endl; @@ -3064,6 +9470,17 @@ MainWindow::analyseNewMainModel() return; } + // openAudio() emits audioFileLoaded() for CreateAdditionalModel too (a + // singing track, background music), with the main model unchanged. + // Going on would hand the reference to m_analyser a second time, which + // keeps its layers but forgets its pitch candidates, and would connect + // the pane's regionOutlined() to us once more. + if (getMainModelId() == m_analysedMainModelId) { + SVDEBUG << "MainWindow::analyseNewMainModel: main model unchanged, nothing to do" << endl; + return; + } + m_analysedMainModelId = getMainModelId(); + int pc = m_paneStack->getPaneCount(); Pane *pane = 0; Pane *selectionStrip = 0; @@ -3072,7 +9489,7 @@ MainWindow::analyseNewMainModel() SVDEBUG << "MainWindow::analyseNewMainModel: Adding pane and selection strip (ruler)" << endl; pane = m_paneStack->addPane(); selectionStrip = m_paneStack->addPane(); - m_document->addLayerToView + m_document->attachLayerToView (selectionStrip, m_document->createMainModelLayer(LayerFactory::TimeRuler)); } else { @@ -3093,6 +9510,14 @@ MainWindow::analyseNewMainModel() if (pane) { + // The reference is the work of this pane, and its end is the end + // of the song. Without saying so, the pane blocks itself off at + // the end of whichever audio model happens to be topmost: the + // take's file, which stops where the singing did, or the + // recording in progress, whose end crawls along behind the + // playback cursor. Either greys out the pane from there on. + pane->setWorkModel(getMainModelId()); + disconnect(pane, SIGNAL(regionOutlined(QRect)), pane, SLOT(zoomToRegion(QRect))); connect(pane, SIGNAL(regionOutlined(QRect)), @@ -3109,6 +9534,36 @@ MainWindow::analyseNewMainModel() } } + // A session saved with the alternate pitch track has its layer in + // the pane already + if (pane && m_alternatePitch->adopt(m_document, pane)) { + cerr << "analyseNewMainModel: found the alternate pitch track of the session, " + << m_alternatePitch->getOctaves() << " octave(s)" << endl; + syncAlternatePitchTrack(); + } + + // Lyrics saved with the session are there too. The load put their + // model into the play source when it added the layer to the view, as + // it does for every layer, and words past the end of the reference + // would hold playback open: out again, as after an import + if (pane && m_lyrics->adopt(m_document, pane)) { + cerr << "analyseNewMainModel: found the lyrics of the session" << endl; + if (m_playSource && !m_lyrics->getModelId().isNone()) { + m_playSource->removeModel(m_lyrics->getModelId()); + } + // The word at the cursor, without waiting for playback to move it + m_lyrics->setPlaybackFrame(m_viewManager->getPlaybackFrame()); + // Remove Lyrics; Show Lyrics is set by updateLayerStatuses() below + updateMenuStates(); + } + + // Whether there were lyrics or not. The waveform's colour is saved + // with its layer, and the analyser, which took the layer over before + // the lyrics were looked for, gave it the fade it knew of then: + // faded if lyrics were found on show, and otherwise grey, even in a + // session saved faded whose lyrics have gone since + updateWaveformFade(); + if (!m_withSpectrogram) { m_analyser->setVisible(Analyser::Spectrogram, false); } @@ -3117,7 +9572,12 @@ MainWindow::analyseNewMainModel() m_analyser->setAudible(Analyser::PitchTrack, false); m_analyser->setAudible(Analyser::Notes, false); } - + + // A session used to be searched here for a second WaveFileModel, which + // was then set up as the singing track. The takes of a session come + // from its element now (spec 7), and restoreTakes() opens the + // audio of the active take itself, so there is nothing to look for. + updateLayerStatuses(); documentRestored(); } @@ -3161,14 +9621,14 @@ MainWindow::modelRegenerationFailed(QString layerName, (this, tr("Failed to regenerate layer"), tr("Layer generation failed

Failed to regenerate derived layer \"%1\" using new data model as input.

The layer transform \"%2\" failed:

%3") - .arg(layerName).arg(transformName).arg(message), + .arg(layerName, transformName, message), QMessageBox::Ok); } else { QMessageBox::warning (this, tr("Failed to regenerate layer"), tr("Layer generation failed

Failed to regenerate derived layer \"%1\" using new data model as input.

The layer transform \"%2\" failed.

No error information is available.") - .arg(layerName).arg(transformName), + .arg(layerName, transformName), QMessageBox::Ok); } } @@ -3179,7 +9639,7 @@ MainWindow::modelRegenerationWarning(QString layerName, QString message) { QMessageBox::warning - (this, tr("Warning"), tr("Warning when regenerating layer

When regenerating the derived layer \"%1\" using new data model as input:

%2").arg(layerName).arg(message), QMessageBox::Ok); + (this, tr("Warning"), tr("Warning when regenerating layer

When regenerating the derived layer \"%1\" using new data model as input:

%2").arg(layerName, message), QMessageBox::Ok); } void diff --git a/main/MainWindow.h b/main/MainWindow.h index f14d5ca9..ec80ae55 100644 --- a/main/MainWindow.h +++ b/main/MainWindow.h @@ -18,23 +18,156 @@ #include "framework/MainWindowBase.h" #include "Analyser.h" +#include "RealtimePitchTracker.h" +#include "AlternatePitchTrack.h" +#include "CoverageStrip.h" +#include "LyricsTrack.h" +#include "LyricsEditor.h" +#include "SingingTakes.h" +#include "TakeCommands.h" +#include "TakeTiming.h" +#include "LatencyUtils.h" +#include "LatencyCalibration.h" +#include "LiveDotsFeed.h" + +#include +#include +#include +#include + +#include "data/model/SparseTimeValueModel.h" + +#ifdef Q_OS_ANDROID +#include +class AndroidStorage; +#endif + +class QTimer; +class QComboBox; +class QActionGroup; +class QToolBar; +class CompactLayout; +class SongScrollBar; +class PlotSize; +class LyricsSize; + +class AudioCheckRunner; +struct AudioCheckResult; +class AudioDriverMenus; +class CalibrateAudioDialog; +#ifdef TONY_DEV_CHECKS +class DevChecks; +#endif namespace sv { class VersionTester; class ActivityLog; class LevelPanToolButton; +class TimeValueLayer; } class MainWindow : public sv::MainWindowBase { Q_OBJECT + // The audio check drives the take path of the window, and reads what + // each take was placed with; see AudioCheckRunner + friend class AudioCheckRunner; +#ifdef TONY_DEV_CHECKS + // The development checks save and reopen the session, and read the + // take's pitch and notes; see DevChecks + friend class DevChecks; + // What the development checks see of a take, only looking; see + // TakeObserver + friend class TakeObserver; +#endif + public: MainWindow(AudioMode audioMode, bool withSonification = true, bool withSpectrogram = true); virtual ~MainWindow(); + /** + * Load a second audio file as the "singing" track whose pitch + * will be analysed alongside the primary reference track. + */ + void loadSingingTrack(QString path); + + /** + * Put the take into the state a SingingTakeCommand holds: its audio + * file, its coverage, the events of its pitch track and notes, and, if + * the state names a range whose analysis never finished, that + * analysis started again. Called by the command on undo and on redo; + * public only for that. False if the audio could not be shown, in + * which case the user has been told. + */ + bool applyTakeState(SingingTakeCommand *command, const TakeState &state); + + // A session that has been saved names the audio file of every take as + // it was at the time, so those files must outlive every other + // reference to them. This is also where a save waits for the analysis + // of a recorded range, so that no session holds the state half way + // through one (see waitForRangedAnalysis()) + bool saveSessionFile(QString path) override; + + // The takes of the session are written into the document as one + // element of Tony's own (spec 6.4), which the base class knows nothing + // of; and after the base class has read a session back, the same + // element is read from the file and the takes put back from it + void toXml(QTextStream &out, bool asTemplate) override; + FileOpenStatus openSession(sv::FileSource source) override; + + // Switch the layout for a phone on or off (CompactLayout), as View > + // Compact Layout does: main() switches it on at start on Android and + // with --compact + void setCompactLayout(bool on); + // The round trip takes are placed with (see LatencyCalibration). + // Keep the one an audio check measured, for the devices it started + // on and the rate it recorded at (AudioCheckResult::key); false, with + // nothing kept, unless the check's figure is usable. The Playback + // menu's line about the latency follows + bool storeMeasuredLatency(const AudioCheckResult &result); + + // Drop the figure latencyInUse() describes, and say so in the menu + void forgetMeasuredLatency(); + + // What the next take will be placed with, as far as it is known + // before the take starts: the device's rate is known only once it + // has recorded, so until a take has been recorded on these devices + // this assumes the session's rate, the only one a usable check + // stores a figure at (a device that reports its route says its rate + // once it is open). The reported pair is 0 until the device is open + LatencyCalibration::InUse latencyInUse() const; + + // The devices a measured round trip is kept for, at the given rate: + // the route the open device reports, if it reports one (OboeAudioIO + // on a phone), else the devices the Preferences name. A device open + // for playback only names the input a figure is kept with for its + // output, if only one is (LatencyCalibration::onlyRecordDevice()) + LatencyCalibration::Key latencyKey(sv::sv_samplerate_t rate) const; + + // The route the open device reports; its driver is "" if it reports + // none, or there is no device open + AudioRoute::Route audioRoute() const; + +#ifdef Q_OS_ANDROID + // The microphone is asked for when it is first needed: Record starts + // the take once it is given, Calibrate Audio the check. granted is + // called then, and not if it is refused, which is said in a box + bool microphoneAllowed() const; + void askForMicrophone(std::function granted); + + // Text saved through the save picker, as Help > Save Log... and + // Calibrate Audio's Save Report... do: a new document the picker + // makes, written and closed through its provider, with what went + // wrong, and what the provider says it holds, said over parent. + // what names the text in the messages ("log") + void saveTextThroughPicker(QWidget *parent, const QByteArray &text, + QString title, QString suggestedName, + QString what); +#endif + signals: void canExportPitchTrack(bool); void canExportNotes(bool); @@ -42,17 +175,49 @@ class MainWindow : public sv::MainWindowBase void canPlayWaveform(bool); void canPlayPitch(bool); void canPlayNotes(bool); + void canLoadSingingTrack(bool); + void canShowRealtimePitch(bool); + void canEraseSinging(bool); + void canSelectRecording(bool); + // Switching and making takes: not while a take is being recorded or a + // recorded range analysed. The second is the same with a take there + // to be copied, renamed or deleted + void canChangeTakes(bool); + void canActOnTake(bool); public slots: virtual bool commitData(bool mayAskUser); // on session shutdown +protected slots: + // Override record() so that when a reference track is already loaded we + // can switch to RecordCreateAdditionalModel before starting the capture, + // causing the recording to be treated as the singing track. + virtual void record(); + + // The Record button, and its shortcut: record() or Stop, except + // while the audio check runs, which records and stops takes of its + // own through record(); the button is shut then, and a press that + // gets here all the same is ignored + virtual void recordPressed(); + protected slots: virtual void openFile(); + virtual void openSingingTrack(); + virtual void openBackgroundMusic(); + virtual void analyseNewSingingModel(); + // The same, for the singing track of a session being restored: the + // take's layers are handed to the new analyser first + virtual void analyseRestoredSingingModel(); virtual void openLocation(); virtual void openRecentFile(); virtual void saveSession(); virtual void saveSessionInAudioPath(); virtual void saveSessionAs(); + + // Save the session to this file and make it the session's own file: + // what Save As and Save In Audio Path do once they have a path. False + // if it could not be saved, in which case the user has been told + bool saveSessionToPath(QString path); virtual void exportPitchLayer(); virtual void exportNoteLayer(); virtual void importPitchLayer(); @@ -69,6 +234,19 @@ protected slots: virtual void switchPitchUp(); virtual void switchPitchDown(); + // Editing the singing of a take: what is in the selection is erased + // from its audio, and the coverage range at the playhead can be + // selected to erase a whole recording (spec 5.2) + virtual void eraseSingingInSelection(); + virtual void selectRecordingAtPlayhead(); + + // The Takes menu and the "Take:" combo box (spec 5.3) + virtual void takeChosenInCombo(int index); + virtual void newEmptyTake(); + virtual void duplicateTake(); + virtual void renameTake(); + virtual void deleteTake(); + virtual void snapNotesToPitches(); virtual void splitNote(); virtual void mergeNotes(); @@ -83,6 +261,23 @@ protected slots: virtual void playAudioToggled(); virtual void playPitchToggled(); virtual void playNotesToggled(); + virtual void playSingingAudioToggled(); + virtual void backgroundMusicToggled(); + virtual void backgroundMusicGainChanged(float gain); + virtual void backgroundMusicPanChanged(float pan); + + virtual void alternatePitchToggled(); + virtual void alternatePitchUp(); + virtual void alternatePitchDown(); + virtual void syncAlternatePitchTrack(); + void syncSongScrollBar(); + + virtual void importLyrics(); + virtual void exportLyrics(); + virtual void removeLyrics(); + virtual void showLyricsToggled(); + virtual void editLyricsToggled(); + virtual void shiftLyrics(); virtual void editDisplayExtents(); @@ -115,6 +310,11 @@ protected slots: virtual void monitoringLevelsChanged(float, float); + // The status bar's "Recording: " and "Playing: ...", which + // the lead-in of a pre-roll replaces with its countdown + virtual void recordDurationChanged(sv::sv_frame_t, sv::sv_samplerate_t); + virtual void playbackFrameChanged(sv::sv_frame_t); + virtual void audioGainChanged(float); virtual void pitchGainChanged(float); virtual void notesGainChanged(float); @@ -153,6 +353,17 @@ protected slots: virtual void setupRecentFilesMenu(); + virtual void rescanAudioDevices(); + virtual void audioDeviceSelected(QAction *); + + // Playback > Audio Driver or Audio Latency chosen, and written to the + // Preferences: the device is opened again, with it + void audioDriverChosen(QString implementation); + void audioLatencyChosen(double seconds); + + // Playback > Calibrate Audio: the audio check's dialog, not modal + virtual void calibrateAudio(); + virtual void handleOSCMessage(const sv::OSCMessage &); virtual void mouseEnteredWidget(); @@ -172,6 +383,15 @@ protected slots: virtual void analyseNewMainModel(); + // --- Real-time pitch tracking during microphone recording --- + virtual void recordingStarted(); + virtual void recordingFinishedFull(Analyser *analysing = nullptr); + virtual void finishSingingTake(); + + // Watches a take that is to stop at an end of its own; see + // startTakePolling() + virtual void pollTakeProgress(); + void moveOneNoteRight(); void moveOneNoteLeft(); void selectOneNoteRight(); @@ -181,9 +401,440 @@ protected slots: void rewind(); protected: + // Primary analyser: the reference/target track loaded by the user. Analyser *m_analyser; + // Secondary analyser: the singing/recording track. + // Null until a second audio file is loaded or a recording is completed. + Analyser *m_analyser2; + + // Real-time pitch tracker: active only during microphone recording. + RealtimePitchTracker *m_realtimePitchTracker; + + // The transient layer shown during recording (replaced by the full + // pYIN analysis once recording is complete). + sv::TimeValueLayer *m_realtimePitchLayer; + + // Model backing the realtime layer (owned by the document). + sv::ModelId m_realtimePitchModelId; + + // Brings the tracker's estimates to onRealtimePitchDetected() in + // batches, and reports once a second what they cost + LiveDotsFeed m_liveDotsFeed; + sv::Overview *m_overview; + + // The overview's thin stand-in in the compact layout, which shows it + // in the overview's place; hidden otherwise + SongScrollBar *m_songScroll; + + // The layout for a phone: one toolbar of touch-sized buttons in place + // of the menu bar and the other toolbars. MainWindow only hands it + // the parts (setupCompactLayout()): the actions below, which are made + // with the menus and toolbars, and others that have members already + CompactLayout *m_compactLayout; + + // View > Plot Size: how large the panes draw pitch and notes + PlotSize *m_plotSize; + + // View > Lyrics Size: how large the lyrics' words are drawn + LyricsSize *m_lyricsSize; + + QAction *m_playAction; + QAction *m_recordAction; + QAction *m_zoomInAction; + QAction *m_zoomOutAction; + QAction *m_navigateToolAction; + QAction *m_noteEditToolAction; + QToolBar *m_playbackControlsToolBar; + QToolBar *m_showAndPlayToolBar; + void setupCompactLayout(); + + // Actions/toolbar items for the singing track + QAction *m_showSingingPitch; + QAction *m_showSingingNotes; + QAction *m_playSingingAudio; + QAction *m_playRefWhileRecording; + QAction *m_preRoll; + QAction *m_recordIntoSelection; + QAction *m_loadSingingTrackAction; + + // The alternate pitch track: the reference pitch track moved by whole + // octaves, for the singer to follow in place of the reference. While + // a singing take is being recorded it is shown in full and the + // reference pitch track is hidden; m_referencePitchHiddenForTake says + // that we hid it, and must show it again afterwards. Not hidden with + // Analyser::setVisible(), which would write the state to the settings. + AlternatePitchTrack *m_alternatePitch; + QAction *m_showAlternatePitch; + QAction *m_alternatePitchUpAction; + QAction *m_alternatePitchDownAction; + bool m_referencePitchHiddenForTake; + void stepAlternatePitch(bool up); + void updateAlternatePitchForTake(); + + // The take's own pitch and notes are put out of sight while it is + // being recorded into, as the reference is for the alternate above: + // the pitch is the orange of the live dots and the notes are drawn + // over the same part of the pane, and a singer recording over + // singing that is there cannot tell either of them from what they + // are singing now. The two flags say which of them we hid. + bool m_singingPitchHiddenForTake; + bool m_singingNotesHiddenForTake; + void updateSingingTrackForTake(); + + // The singing takes of the session: the audio file of each take and + // the ranges of it that hold recorded singing. MainWindow only + // wires it: it decides where a recording goes and writes the files. + SingingTakes *m_takes; + + // The coverage of the take, drawn in pane 0 and stored in the + // session with the layer that draws it. Display only. + CoverageStrip *m_coverageStrip; + + // Put the strip in step with the take's coverage: make it if there + // is a take and none yet, take it away when the take goes + void syncCoverageStrip(); + + // The timed lyrics of the session, drawn along the bottom of pane 0 + // and stored in the session with the layer that draws them. They + // belong to the song, not to a take. Display only + LyricsTrack *m_lyrics; + QAction *m_importLyricsAction; + QAction *m_exportLyricsAction; + QAction *m_removeLyricsAction; + QAction *m_showLyrics; + + // Edit > Edit Lyrics: the mouse moves the words' starts and ends in + // the lyrics' box row while it is on, and changes, adds and deletes + // words there. Off, and not to be had, without lyrics on show or + // while a take is being recorded, which updateMenuStates() sees to; + // and off after an import, which importLyricsFrom() sees to + LyricsEditor *m_lyricsEditor; + QAction *m_editLyricsAction; + + // Edit > Shift Lyrics...: all the words earlier or later by a number + // of seconds. To be had whenever editing is, edit mode on or not + QAction *m_shiftLyricsAction; + + // The lyrics are there to be edited: shown, visible, and no take + // being recorded (the singer is reading them) + bool lyricsEditAllowed() const; + + // Edit mode on, if lyricsEditAllowed(), or off, and the action to + // match. Off finishes a drag in progress, pushing its command, so + // this must not be reached from an undo or a redo + void setLyricsEditing(bool on); + + // Fade the waveforms of both analysers while the lyrics are on show + // over them, and not otherwise. Called after anything that shows or + // hides the lyrics, and after anything that makes an analyser: a new + // one starts unfaded, and a session's lyrics are found only after + // the reference's analyser has taken its waveform over + void updateWaveformFade(); + + // Put the lyrics of this TTML or LRC file on the reference's + // timeline, in place of any there are. Not undoable, as loading + // background music is not, and nothing goes onto the undo stack. + // False if the file could not be read or holds no timed lyrics, + // which the user is told in a dialog, or if lyricsImportAllowed() + // says no; nothing has changed then + bool importLyricsFrom(QString path); + + // Lyrics can be imported once there is a reference, and not while a + // take is being recorded + bool lyricsImportAllowed() const; + + // Ask for the TTML or LRC file to import, "" if the user cancelled. + // Overridden by the tests, which cannot answer a dialog + virtual QString askForLyricsFile(); + + // Write the lyrics, as the model holds them now, to this TTML file. + // Not a command, and the session is not changed by it. False if + // there are no lyrics, or if the file could not be written, which + // the user is told in a dialog + bool exportLyricsTo(QString path); + + // Ask where to export the lyrics to, offering the suggested path; + // "" if the user cancelled. Overridden by the tests + virtual QString askForLyricsExportFile(QString suggested); + + // Ask for the text of a word of the lyrics, offering the one it has + // ("" for a new word): true with the text typed in its place, false + // if the user cancelled. The lyrics editor asks through this. + // Overridden by the tests + virtual bool askForLyricsWordText(QString &text, bool isNew); + + // Ask how far to shift all the words of the lyrics, in seconds, + // negative for earlier: true with the seconds, false if the user + // cancelled. Overridden by the tests + virtual bool askForLyricsShift(double &seconds); + + // --- The audio folder of the session (spec 6.4) --- + + // Where the next combined audio file of a take is to be written: the + // session's own ".takes" folder once the session has a file, and + // the record directory before its first save, the save copying what is + // there into the folder. "" if neither could be had + QString takeAudioDirectory(); + + // The session's takes folder, made if it is not there yet. "" if it + // could not be made. A folder this run made and left empty is removed + // again when the session closes + QString ensureTakesFolder(QString sessionPath); + + // The takes folders this run made, so that an empty one can be taken + // away again on close. A folder with anything in it is never removed: + // what is in it is not necessarily ours + QStringList m_takeFoldersMade; + + // The session file being written, from the start of saveSessionFile() + // to its end: toXml() names each take's audio relative to it + QString m_savingSessionPath; + + // Copy the audio of every take that is not in the folder of the + // session being saved to into it, and point the takes at the copies + // (TakesFile::copyTakeAudioInto()). Copied, not moved: the active + // take's model has its file open. False if a copy failed, in which + // case the user has been told and nothing has changed: the session is + // not to be saved naming files that are not there + bool copyTakeAudioForSave(QString sessionPath); + + // --- Several takes (spec 5.3) --- + // + // Every take of the session has its three layers in pane 0, named + // after it (TakeLayers); only the active take has an audio model and + // the singing analyser. The take a menu or the combo box asks for is + // made the active one by switchToTake(), which is the swap of 6.2 + // with the layers of another take put in place of the ones on show. + + QMenu *m_takesMenu; + QComboBox *m_takeCombo; + QAction *m_newTakeAction; + QAction *m_duplicateTakeAction; + QAction *m_renameTakeAction; + QAction *m_deleteTakeAction; + + // Set while updateTakeCombo() fills the combo box, so that the + // currentIndexChanged it causes is not taken for the user's choice + bool m_updatingTakeCombo; + + void setupTakesMenu(); + + // The combo box lists the takes with the active one selected + void updateTakeCombo(); + + // Make the take at this index the active one: release the analyser + // keeping the layers, put the take's layers away, then show the other + // take's and open its audio underneath them. Nothing is analysed. + // The undo history goes, as spec 5.4 says it must: its commands hold + // the state of a take that is no longer on show + bool switchToTake(int index); + + // Release the singing analyser and the take's audio with it, and put + // the active take's layers away: still in the pane and in the + // session, hidden, silent and owned by nobody + void deactivateTake(); + + // Show the active take: its audio under its layers, an analyser that + // claims them (no analysis), its coverage strip, and the layers + // themselves visible, audible as the user asked and within reach of + // the editing tools. False if its audio could not be opened, in + // which case the user has been told -- unless warnIfNoAudio is false, + // for a session load that has one warning of its own covering all of + // its takes + bool activateTake(bool warnIfNoAudio = true); + + // The takes of a session that has just been read back: the + // element of the file says which takes there are, what their audio + // files are and which of them was on show, and the layers the document + // restored say what is in them (spec 6.4). Each take's coverage comes + // from its own coverage strip; the active take is shown by the same + // path a switch uses, and nothing is analysed. A session with no + // element opens without a singing track at all (spec 3). + void restoreTakes(QString sessionPath); + + // Drop the audio model of the singing track that a session restored: + // Document::toXml() writes it because the active take's waveform layer + // is in pane 0, and it is of no use here -- the take's audio is opened + // from the path in , as it is for a switch. Silently: no undo + // entry, no pane, nothing left in the play source, and + // m_pendingSingingModelId cleared so that nothing makes a take of it. + // + // withTakeLayers: its pitch, notes and coverage layers as well, for a + // session that has no element -- it opens without its singing + // track, and nothing of one is left in the pane to be mistaken for a + // take. + void dropRestoredSingingTrack(bool withTakeLayers); + + // Take a layer and its model out of the document with no trace: no + // undo entry, and nothing left in the play source + void dropLayerSilently(sv::Layer *layer); + + // Set while a session is being read, so that the queued call which + // makes a take of a newly added audio model (modelAdded()) leaves the + // restored one alone: restoreTakes() deals with it + bool m_restoringSession; + + // Name the layers the singing analyser holds after the active take, + // which is what says whose they are (spec 6.4) + void nameActiveTakeLayers(); + + // Every take layer in pane 0 that is not the active take's: hidden, + // silent, out of the play source and with no source model, so that + // nothing claims it and nothing hears it. The one place that + // enforces it, for a switch and for a session load alike + void putOtherTakeLayersAway(); + + // The active take's pitch and notes to the top of the pane, where the + // note tool looks for the layer to act on + void raiseActiveTakeLayers(); + + // Forget the take at this index, with its layers; no audio file is + // touched. Deleting the active take activates a neighbour + bool deleteTakeAt(int index); + + // A take may be switched, made, copied or deleted only when nothing + // is being recorded or analysed + bool takeOperationsAllowed() const; + + // Every take operation but Rename clears the undo history (spec 5.4) + void clearTakeHistory(); + + // Ask before deleting a take, and for a take's new name. Overridden + // by the tests, which cannot answer a dialog + virtual bool confirmDeleteTake(QString name); + virtual QString askForTakeName(QString current); + + // Erase Singing in Selection, and selecting the recording the + // playhead is in + QAction *m_eraseSingingAction; + QAction *m_selectRecordingAction; + + // Take the pitch events and the notes in these ranges out of the + // take's layers, the erased audio having taken the singing they + // describe with it. Straight on the models, as an analysis result + // is; what was changed comes back in the two Changes, for the undo + // command of the erase as a whole + void eraseTakeEvents(const Coverage::Ranges &erased, + TakeEvents::Change *pitchChange = nullptr, + TakeEvents::Change *notesChange = nullptr); + + // Undo and redo of the singing of a take (spec 5.4). + // + // The command of the operation being made just now, from the splice + // or the erase until the analysis that follows a splice has been + // merged into the take's pitch and notes. That merge arrives seconds + // after the splice, and amends this command, so that one Undo takes + // the recording and its analysis back together. Null when no command + // is waiting for anything; never left pointing at a command the + // history may have deleted + SingingTakeCommand *m_openTakeCommand; + + // Put a finished take operation on the undo stack, its work already + // done (CommandHistory::addCommand(command, false)) + void addTakeCommand(SingingTakeCommand *command); + + // Stop waiting for a merge into the open command. With + // cancelAnalysis, a ranged analysis that is still running is + // abandoned first: its result belongs to a state that is being left + // behind, and must not land on the take afterwards + void closeOpenTakeCommand(bool cancelAnalysis); + + // The ranged analysis has been merged: the events it changed go into + // the command of the recording that asked for it + void takeAnalysisMerged(); + + // Apply one command's event changes to the take's pitch and notes + void applyTakeEventChanges(const TakeState &state); + + // Where on the reference's timeline the take being recorded, or the + // one most recently recorded, starts: the playback position when + // Record was pressed, or the start of the selection recorded into. + // The live dots are drawn from here, and this is where the recording + // is spliced into the take's audio. + sv::sv_frame_t m_takePosition; + + // The lead-in played before m_takePosition (R, "Pre-roll"), and the + // frame the take stops itself at (E, "Record into Selection"), or -1 + // when it runs until Stop is pressed. Both are worked out in + // record() and used until the take has been spliced in; TakeTiming + // does the arithmetic. + sv::sv_frame_t m_takePreRoll; + sv::sv_frame_t m_takeEnd; + + // Polls the record target while a take that has an end to reach + // runs, and stops the take once the singing for that end has + // arrived. Not running for a take that goes on until Stop. + QTimer *m_takeTimer; + + void startTakePolling(); + void stopTakePolling(); + + // The take being recorded, or the one just recorded, as TakeTiming + // sees it: everything the splice, the dots and the automatic stop + // are worked out from. + TakeTiming currentTakeTiming() const; + + // The pre-roll asked for, in frames of the reference: the QSettings + // value MainWindow/prerollseconds (3 s), or 0 with the toggle off; + // for a take of the audio check, the check's own + sv::sv_frame_t wantedPreRollFrames() const; + + // Put the countdown of a pre-roll's lead-in in the status bar, and + // say so, if that is what belongs there just now. Everything that + // writes the status bar while a take runs asks this first. + bool showTakeCountdown() const; + + // Ask before recording over singing that is already there, unless + // the user has said not to. Overridden by the tests, which cannot + // answer a dialog. Returns true to go ahead with the recording. + virtual bool confirmRecordingOverTake(); + + // Show and analyse the take's audio file after a recording has been + // spliced into it over the range "placed": the new file goes under + // the take's pitch and notes layers (swapSingingAudio(), or + // loadTakeAudio() and empty layers for the first recording of a + // take) and only "placed" is analysed, over the take's coverage. + // Returns true if an analysis is running that will say when it is + // done; a failure is reported to the user from here. + bool rebuildSingingTrackFromTake(const Coverage::Range &placed); + + // Open the take's audio as the singing track with no analysis of its + // own, for a take that has no pitch and notes layers to keep. + // "" on success, else a message for the user + QString loadTakeAudio(QString path); + + // Hand the take's pitch and notes layers, if they are in pane 0 and + // belong to no analyser, to the audio model about to be analysed: + // that link is what lets an Analyser claim them. True if both were + // found and linked + bool adoptTakeLayers(sv::ModelId audio); + + // Analyse [start, end) of the take's audio and merge the result into + // its pitch and notes, with the context limited to the coverage + // range the material sits in. True if a run was started + bool startTakeAnalysis(sv::sv_frame_t start, sv::sv_frame_t end); + + // Analyse all of the take's coverage again (Analyse Now, spec 7) + bool analyseTakeCoverage(); + + // Keep the take's existing audio out of the mix while it is being + // recorded into, and put it back afterwards + void muteSingingAudioForTake(); + + // Background music track: an additional audio file that plays alongside + // the reference track but is never analysed. The toggle enables/disables + // mixing during both normal playback and recording. + sv::ModelId m_backgroundMusicModelId; + sv::WaveformLayer *m_backgroundMusicLayer; + QAction *m_loadBackgroundMusicAction; + QAction *m_playBackgroundMusic; + sv::LevelPanToolButton *m_bgMusicLPW; + // True while loadBackgroundMusic() is calling openPath() so that + // modelAdded() can capture the model ID without treating it as a singing + // track or queuing a secondary analysis. + bool m_loadingBackgroundMusic; sv::Fader *m_fader; sv::AudioDial *m_playSpeed; QPushButton *m_playSharpen; @@ -195,6 +846,13 @@ protected slots: QMenu *m_recentFilesMenu; QMenu *m_rightButtonMenu; QMenu *m_rightButtonPlaybackMenu; + QMenu *m_audioDeviceMenu; + QActionGroup *m_audioDeviceGroup; + QMenu *m_audioInputDeviceMenu; + QActionGroup *m_audioInputDeviceGroup; + + // Playback > Audio Driver and Audio Latency, before the device menus + AudioDriverMenus *m_audioDriverMenus; QAction *m_deleteSelectedAction; QAction *m_ffwdAction; @@ -245,6 +903,307 @@ protected slots: virtual void setupAnalysisMenu(); virtual void setupHelpMenu(); virtual void setupToolbars(); + virtual void buildAudioDeviceMenu(QMenu *menu, + QActionGroup *group, + const std::vector &names, + QString settingKey); + + // The implementations bqaudioio has, of which the drivers are offered + // in Playback > Audio Driver. Virtual so that the tests can give + // drivers the platform they run on does not have + virtual QStringList audioImplementationNames() const; + + // Where no driver is named and MME is built in, name it (and carry + // the devices chosen before over to it): before a device is opened, + // and before the Playback menu shows the device menus + void nameDefaultAudioDriver(); + + // Helpers for the singing / second-track workflow. + // deferAnalysis=true skips pYIN: swapSingingAudio() uses it, as the + // pitch and notes layers it hands the new analyser are analysed + // already. The scan for existing layers still runs. + virtual void setupSingingTrackAnalyser(sv::ModelId singingModelId, + bool deferAnalysis = false); + virtual void teardownSingingTrackAnalyser(); + virtual void setupRealtimePitchLayer(); + virtual void teardownRealtimePitchLayer(); + virtual void stopRealtimePitchTracker(); + + // A batch of the live tracker's estimates, everything it has found + // since the last: dots for them, the pane told of them, the status + // bar set from the newest. From m_liveDotsFeed + virtual void onRealtimePitchDetected + (const RealtimePitchTracker::Estimates &estimates); + + // The once-a-second log line of a take: how far the recording, the + // tracker and the dots have got, and what the dots cost + void logLiveDots(const LiveDotsFeed::Report &report); + + // The raw recording of a take needs a layer of its own to hold it in + // the document: the singing analyser is busy with the take's audio, + // which stays on show while the recording is made. The layer is + // never shown and never heard; it goes when the recording has been + // spliced into the take, which releases the model and with it the + // file handles of the recording. + void setupRecordingLayer(); + void teardownRecordingLayer(); + + // Background music helpers: load/tear-down a non-analysed audio track + // that plays alongside the reference track. + void loadBackgroundMusic(QString path); + void teardownBackgroundMusic(); + + // The background music of a session that has just been read back: its + // hidden waveform layer is in pane 0, found by its name, heard or muted + // and at the gain and pan the session saved. restoreTakes() calls it + // before it drops every other audio model but the reference. The + // layer of music that could not be read is dropped + void adoptBackgroundMusic(); + + // The object name that marks the background music's layer, in the + // session file as well. Not translated + static QString backgroundMusicLayerName(); + + // Put another audio file under the take's pitch and notes layers, + // keeping those layers and everything in them. The new audio is not + // analysed: what the layers hold is the analysis of all of the take + // but the range that has just changed, which + // rebuildSingingTrackFromTake() analyses on its own. Returns "" on + // success, or a message for the user. + QString swapSingingAudio(QString path); + + // Open path as an additional audio model beside the reference, the + // way Load Singing Track does. The new model's id comes back in + // modelId and the extra panes openPath() made in extraPanes; those + // are the caller's to prune, once a layer of its own holds the model + FileOpenStatus openSingingAudioFile(QString path, sv::ModelId &modelId, + std::vector &extraPanes); + + // Open a take's own audio file as a model of the document and nothing + // else: no pane, no layer and, above all, no undo command. Every + // change to a take swaps its audio, including an undo, and a command + // pushed while an undo is running destroys the command that is running + // (see the comment on the definition) + FileOpenStatus openTakeAudioFile(QString path, sv::ModelId &modelId); + + // Remove an extra pane created by openAudio()/record() in + // CreateAdditionalModel mode: delete the orphan layer(s) showing + // ownedModelId, detach shared layers (the time ruler) without deleting + // them, then delete the pane widget. Another layer must already + // reference ownedModelId, or the model is released with the orphan. + void pruneExtraPane(sv::Pane *extra, sv::ModelId ownedModelId); + + // Drain m_pendingExtraPanes: prune the panes that were deferred from + // record()'s pane-cleanup step. Must be called after m_analyser2 has + // created its WaveformLayer for singingModelId (so deleteLayer won't + // free the recording model), and while the pane widgets are still alive + // (so m_layerViewMap iteration in deleteLayer(force=true) is valid). + void drainPendingExtraPanes(sv::ModelId singingModelId); + + // When loadSingingTrack opens an additional audio file, modelAdded() + // stores the resulting ModelId here so analyseNewSingingModel() can + // pick it up on the next event-loop iteration. + sv::ModelId m_pendingSingingModelId; + + // The singing track is kept out of the playback mix while a take is + // being recorded into it: with the reference playing, the singer + // would otherwise hear their earlier singing along with it, and on + // speakers that goes back into the microphone. The recording itself + // is silent for the same reason (setupRecordingLayer()). + // m_singingAudioAfterTake is what Play Singing Audio asks for, and + // what the rebuilt singing track is given when the take is over. + bool m_singingAudioMutedForTake; + bool m_singingAudioAfterTake; + void restoreSingingAudioAfterTake(); + + // Playback constrained to the selection is lifted while the reference + // plays for a take, and put back when the take is over: true while it + // is lifted + bool m_playSelectionLiftedForTake; + void liftPlaySelectionForTake(); + void restorePlaySelectionAfterTake(); + + // The main model last handed to m_analyser by analyseNewMainModel(). + // audioFileLoaded() is emitted for additional models too (a singing + // track, background music), and the reference must not be set up again + // for those. Cleared in closeSession(). + sv::ModelId m_analysedMainModelId; + + // True while a microphone recording is in progress (set in + // recordingStarted(), cleared in recordingFinishedFull()). + bool m_recordingInProgress; + + // True when the current/most-recent recording was captured as a singing + // track alongside an existing reference track (RecordCreateAdditionalModel + // mode). Set in record(), cleared in recordingFinishedFull() and + // closeSession(). When true, analyseNow() routes analysis through + // m_analyser2 rather than re-analysing the primary reference track. + bool m_recordingAsSingingTrack; + + // Pane count saved just before MainWindowBase::record() is called in + // singing-track mode. After record() returns, any panes above this + // count are extra panes created by AddPaneCommand for the recording's + // waveform layer; we remove them so both tracks share pane 0. + int m_paneCountBeforeRecording; + + // The WritableWaveFileModel being recorded into in the current (or most + // recent) singing-track recording. Set in modelAdded() when + // m_recordingAsSingingTrack is true, cleared by teardownRecordingLayer() + // when the recording has been spliced into the take, and in + // closeSession(). Used by setupRealtimePitchLayer() to identify the + // correct audio source model without scanning all document models — a + // scan would incorrectly pick up a previous recording's + // WritableWaveFileModel that is still registered in the document. + sv::ModelId m_currentRecordingModelId; + + // The layer that holds the raw recording in the document while it is + // being recorded into; see setupRecordingLayer(). + sv::WaveformLayer *m_recordingLayer; + + // True while the singing track is being rebuilt from an audio file + // that we have just spliced ourselves, so that the take's coverage — + // which the splice worked out — is not replaced by "the whole file", + // as it is for a file the user loads or a session restores. + bool m_rebuildingTakeAudio; + + // The range of the take that the ranged analysis now running was + // asked for; empty when none is running. It is remembered here and + // not in the Analyser because the next recording replaces the + // analyser along with the audio, and the analysis it was running + // goes with it: the next one has to cover this range as well, or + // what the singer sang would be left unanalysed. + Coverage::Range m_takeAnalysisRange; + + // Round-trip hardware latency (the figure the audio check measured, + // or else output + input as the device reports them, in frames of the + // recording, at the device's rate; see roundTripAt()) stored when a + // singing-track recording is made with the "play reference while + // recording" toggle on. The recording is read from this frame on when + // it is spliced into the take's audio, so that what the singer sang in + // answer to the reference at m_takePosition lands there; and the live + // dots are placed with it during the take. TakeTiming converts it to + // the reference's frames. + // Reset to 0 in record() at the start of every take, standalone ones + // included, but not by a Stop: the splice needs it after that. + // + // It also includes the start gap: the part of the take recorded before + // the reference began to play. That starts out as an estimate made just + // before play() is called, and refineRecordingLatency() replaces the + // estimate with the measured figure once the audio callback has it. + sv::sv_frame_t m_recordingLatencyFrames; + sv::sv_frame_t m_recordingStartGapEstimate; + + // Written by the audio callback when the first block of the reference is + // handed to the device during a take: the number of frames of the take + // that came before that block. -1 until then. + std::atomic m_recordingStartGapMeasured; + std::atomic m_awaitingReferenceStart; + + // The play source counts at the reference's rate, and the device may + // run at another: frames of the recording per frame of the play + // source, set before the reference is started, for the audio + // callback that measures the start gap + std::atomic m_recordFramesPerPlayFrame; + // What the take being recorded, or the last one, was placed with: + // cleared when a take starts, the round trip and the latencies the + // device reported (in seconds, as roundTripAt() has them) filled in + // when the reference starts to play, and the recording's rate when + // the take is spliced in + TakeLatency m_takeLatency; + + // The audio check, and the override it sets for each take of its + // own: Record into Selection, Play Reference While Recording and a + // pre-roll of its own, whatever the toolbar says. Not by setting the + // toggles, which write the user's settings. record(), + // recordingStarted() and wantedPreRollFrames() consult it + AudioCheckRunner *m_audioCheck; + bool m_audioCheckTakes; + + // The round trip the check's takes are placed with in place of + // roundTripAt()'s, in seconds, for a run that brings one of its own + // (AudioCheckRunner::Plan::roundTrip); negative when it brings none. + // Read with m_audioCheckTakes, and never by latencyInUse() + double m_audioCheckRoundTrip; + + // The pre-roll the check's takes ask for, in seconds + // (AudioCheckRunner::Plan::preRoll). Read with m_audioCheckTakes + double m_audioCheckPreRoll; + +#ifdef TONY_DEV_CHECKS + // The development checks, which drive the audio check and the + // session; made with the window, deleted in its destructor after the + // dialog and before the runner, and told when the session closes + DevChecks *m_devChecks; +#endif + + // The audio check, or the development checks, are running: the take + // path and the devices are theirs until they end + bool audioCheckRunning() const; + + // The Record button (m_recordAction, with the compact layout's parts + // above) is shut while audioCheckRunning() + + // Playback > Calibrate Audio, made the first time it is chosen, and + // the lines under it: the latency takes are placed with, and Forget + // Measured Latency. Calibrate Audio is shut while a take or a check + // is being recorded, and so are the device menus while a check runs: + // the figure it measures is kept for the devices it started on + CalibrateAudioDialog *m_calibrateAudioDialog; + QAction *m_calibrateAudioAction; + QAction *m_latencyLineAction; + QAction *m_forgetLatencyAction; + + // Say which latency is in use, and let it be forgotten if it is one + // the check measured + void updateLatencyMenuLine(); + + // The rate the device recorded at, the last time a take was placed + // with a round trip; 0 until then, and again once another device is + // chosen + sv::sv_samplerate_t m_lastRecordingRate; + + // The session's rate, from the play source, or the fixed rate every + // file is read at before there is one + sv::sv_samplerate_t sessionRate() const; + + // The rate the next take is expected to record at: the rate of a + // device that reports its route; else the last take's, or before + // there is one, the session's + sv::sv_samplerate_t expectedRecordingRate() const; + + // The route of the open device, if it is an AudioRouteReporter and + // reports one; false, with route cleared, if not + bool deviceRoute(AudioRoute::Route &route) const; + + // The round trip for a take recorded at the given rate, in seconds, + // and where it came from: a stored figure for these devices (or this + // route: latencyKey()) and this rate, unless the latencies the device + // reports, or for a route how it opened its streams, have changed + // since it was measured; otherwise the reported pair, each converted + // from the frames it counts + LatencyCalibration::InUse roundTripAt(sv::sv_samplerate_t recordingRate) const; + + void refineRecordingLatency(); + + // The best figure for the latency as things stand, measurement + // included if it has arrived: refineRecordingLatency() is what makes + // it the stored one, and only it, because the live dots placed with + // the estimate are thrown away when the figure changes. + sv::sv_frame_t currentRecordingLatency() const; + + // Set while the live dots of a finished take wait for pYIN to + // produce the pitch track that replaces them. + QMetaObject::Connection m_realtimeLayerTeardownConnection; + + // Extra panes created by MainWindowBase::record() via AddPaneCommand + // that we want to hide immediately but cannot delete yet because + // m_analyser2 hasn't been set up yet (it is deferred via QTimer::singleShot). + // Stored here so that setupSingingTrackAnalyser() can properly delete + // them — AFTER m_analyser2 has created its own WaveformLayer referencing + // the recording model, making it safe to call deleteLayer(orphan, true) + // on the extra pane's waveform layer without releasing the recording model. + // closeSession() deletes any leftovers via its hidden-pane loop. + std::vector m_pendingExtraPanes; virtual void octaveShift(bool up); @@ -254,6 +1213,110 @@ protected slots: bool checkSaveModified(); bool waitForInitialAnalysis(); + // A session that loaded without some of the audio it names (svapp's + // "Incomplete session loaded") is saved without any mention of that + // audio, so the file it came from, saved over, would lose it. It is + // saved only when the user asks and then says yes: Save, Save As and + // Save Session in Audio Path ask first, and a save no one asked for + // (Android's on suspend) passes it by. Once saved it is what its new + // file says, and is not asked about again + bool sessionIsIncomplete() const; + bool confirmSaveOfIncompleteSession(); + // The question, which the tests answer: they cannot answer a dialog + virtual bool askToSaveIncompleteSession(); + + // Whether the session may be saved with no one asked: it has a file + // of its own, has been changed, and is not incomplete + bool maySaveUnasked() const; + + // Whether Stop, and the end of a take, suspend the audio device. No: + // each start of a stream can move its input against its output by + // several ms, which no one measured figure can place every take with, + // so the stream is kept running from the first take on + bool suspendAudioOnStop() const override; + + // How long the device kept running may sit idle, neither playing nor + // recording, before it is suspended anyway, in ms; 0 for never. On + // Android, where the microphone stays open and the battery drains + // while it runs, a couple of minutes; on desktop never + virtual int audioIdleSuspendMillis() const; + void audioActivityChanged(); + void suspendIdleAudio(); + QTimer *m_audioIdleTimer; + +#ifdef Q_OS_ANDROID + // Android's file picker gives content:// URIs, which svcore's readers + // cannot open. A file in the phone's own storage is opened where it + // is, by its path, once Tony has All files access (AndroidStorage), + // so that a session finds its audio and takes beside it. Other audio + // is copied into the app's own storage and the copy's path returned; + // other sessions are refused. Tony's own picker, not svgui's dialog: + // that asks Qt whether the URI's file exists, and Qt's content file + // engine says no for a name with parentheses (AndroidFiles:: + // grantedUri()); and it offers only the types Qt names for its + // filters, which are not always Android's + QString getOpenFileName(sv::FileFinder::FileType type) override; + + // What to tell the user of a picked file that could not be opened or + // saved, to pass on: its provider, the path looked for, and why + QString pickDetails(QString uri, QString path, QString why) const; + + // Help > Save Log...: Tony's log (main.cpp) through the save picker, + // for a user who cannot read the system log to send + void saveLog(); + + // Save Session As: Tony's own picker, which suggests a name (svgui's + // suggests none), and then the path of the file picked in the phone's + // own storage, or nothing. The picker has made an empty document by + // then, which is removed if it is not to be the session. Other files + // are saved as before + QString getSaveFileName(sv::FileFinder::FileType type) override; + AndroidStorage *m_storage; + + // A recent file that has been moved or deleted is said to be so, and + // is no longer offered (RecentFiles keeps it, having no way to drop + // one) + bool recentFileIsThere(QString path); + + // Android sends Tony to the background: playback stops, a take being + // recorded is finished as Stop finishes it, the audio device is + // suspended, and the session is saved as Save saves it, if it has a + // file of its own. Android holds Tony's event loop from the moment + // this returns until Tony is back, so + // nothing here may wait on it: a save that has to wait for the + // analysis of a take is made when that is done + void applicationStateChanged(Qt::ApplicationState state); + void saveWhenSuspended(); + QTimer *m_suspendSaveTimer; + QString m_suspendSavePath; + + // The audio device is Oboe's (OboeAudioIO): bqaudioio has no + // Android backend. Its input only once the microphone may be used + void createAudioIO() override; + + + // A device that goes away (headphones in or out) leaves the device + // failed: looked for on a timer, and the device opened afresh + void checkAudioDevice(); + QTimer *m_audioDeviceCheck; + QElapsedTimer m_audioDeviceReopened; + int m_audioDeviceReopens; +#else + // Before each device is opened: a driver named where none is, and + // the latency chosen for it handed to bqaudioio. Then openAudioIO() + void createAudioIO() override; + + // Opens the device the Preferences name, as MainWindowBase does + virtual void openAudioIO(); +#endif + + // A session must not be saved in the middle of the analysis of a + // recorded range: the take's pitch and notes still hold the state + // before the merge, and the two models the run works in are in the + // document. Waits for the merge, as waitForInitialAnalysis() waits + // for the reference's first analysis + bool waitForRangedAnalysis(); + virtual void updateVisibleRangeDisplay(sv::Pane *p) const; virtual void updatePositionStatusDisplays() const; diff --git a/main/OboeAudioIO.cpp b/main/OboeAudioIO.cpp new file mode 100644 index 00000000..0a0078fe --- /dev/null +++ b/main/OboeAudioIO.cpp @@ -0,0 +1,966 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#include "OboeAudioIO.h" + +#include +#include + +#include +#include + +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include + +using namespace breakfastquay; +using std::cerr; +using std::endl; + +namespace { + +// Callbacks that must have reached the application since a start +// before the streams count as running as they will go on: by then +// FullDuplexStream has finished draining the input +const int steadyCallbacks = 8; + +// How long the constructor runs the streams, at most, for them to +// have timestamps +const int openWaitMillis = 1000; + +// Readings taken for one measurement of the latency +const int readingsWanted = 9; + +int64_t +monotonicNanos() +{ + // The clock of Oboe's timestamps + timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return int64_t(ts.tv_sec) * 1000000000 + ts.tv_nsec; +} + +// How a stream was opened, as the log has it and as a measured round +// trip's fingerprint keeps it: everything its latency depends on +std::string +describeStream(oboe::AudioStream *stream) +{ + // Only an AAudio stream can be asked about MMAP + bool mmap = (stream->getAudioApi() == oboe::AudioApi::AAudio && + oboe::OboeExtensions::isMMapUsed(stream)); + std::ostringstream os; + os << oboe::convertToText(stream->getAudioApi()) + << (mmap ? " (MMAP)" : "") + << ", " << stream->getSampleRate() << " Hz, " + << stream->getChannelCount() << " channel(s), " + << oboe::convertToText(stream->getFormat()) << ", " + << oboe::convertToText(stream->getPerformanceMode()) << ", " + << oboe::convertToText(stream->getSharingMode()) + << ", burst " << stream->getFramesPerBurst() + << ", buffer " << stream->getBufferSizeInFrames() + << " of " << stream->getBufferCapacityInFrames() << " frames"; + if (stream->getDirection() == oboe::Direction::Input) { + os << ", preset " << oboe::convertToText(stream->getInputPreset()); + } + return os.str(); +} + +// The device AAudio opened a stream on, as Android's AudioManager lists +// it. Only the id if it is not listed, or the stream cannot say +// (OpenSL ES: 0). On the GUI thread, as QJniObject clears and logs any +// exception a call throws +AudioRoute::Device +lookUpDevice(int id, bool input) +{ + AudioRoute::Device device; + device.id = id; + if (id <= 0) return device; + + QJniObject context = QNativeInterface::QAndroidApplication::context(); + if (!context.isValid()) return device; + QJniObject manager = context.callObjectMethod + ("getSystemService", "(Ljava/lang/String;)Ljava/lang/Object;", + QJniObject::fromString("audio").object()); + if (!manager.isValid()) return device; + + // AudioManager.GET_DEVICES_INPUTS and GET_DEVICES_OUTPUTS + QJniObject devices = manager.callObjectMethod + ("getDevices", "(I)[Landroid/media/AudioDeviceInfo;", + jint(input ? 1 : 2)); + if (!devices.isValid()) return device; + + QJniEnvironment env; + jobjectArray array = devices.object(); + const jsize count = env->GetArrayLength(array); + for (jsize i = 0; i < count; ++i) { + QJniObject info = QJniObject::fromLocalRef + (env->GetObjectArrayElement(array, i)); + if (!info.isValid()) continue; + if (info.callMethod("getId", "()I") != id) continue; + device.type = info.callMethod("getType", "()I"); + QJniObject name = info.callObjectMethod + ("getProductName", "()Ljava/lang/CharSequence;"); + if (name.isValid()) { + device.productName = name.callObjectMethod + ("toString", "()Ljava/lang/String;").toString(); + } + break; + } + return device; +} + +std::string +describeDevice(const AudioRoute::Device &device) +{ + std::ostringstream os; + os << AudioRoute::deviceName(device).toStdString() + << ", device " << device.id << ", type " << device.type; + return os.str(); +} + +std::string +describeMs(int frames, int rate) +{ + std::ostringstream os; + os.setf(std::ios::fixed); + os.precision(1); + os << (rate > 0 ? double(frames) * 1000.0 / rate : 0.0) << " ms"; + return os.str(); +} + +std::string +describe(StreamLatency::Estimate latency, bool withInput, int rate) +{ + auto ms = [rate](int frames) { return double(frames) * 1000.0 / rate; }; + std::ostringstream os; + os.setf(std::ios::fixed); + os.precision(1); + os << "output " << latency.output << " frames (" + << ms(latency.output) << " ms)"; + if (withInput) { + os << ", input " << latency.input << " frames (" + << ms(latency.input) << " ms), round trip " + << latency.roundTrip() << " frames (" + << ms(latency.roundTrip()) << " ms)"; + } + return os.str(); +} + +} + +// The output stream's data callback: input and output together once +// FullDuplexStream is done draining, or output alone +class OboeAudioIO::Engine : public oboe::FullDuplexStream +{ +public: + explicit Engine(OboeAudioIO *io) : m_io(io) { } + + // Whether the input is read. Changed only while the streams stop + std::atomic duplex { false }; + + // Odd while a callback runs, so that a latency reading can tell + // whether one ran while it was taken + std::atomic callbackSeq { 0 }; + + // Callbacks that have reached the application since the last start + std::atomic processed { 0 }; + + // The input could not be read, and FullDuplexStream has stopped + // both streams. Oboe reports no error for a stream that has no + // callback of its own, as the input has not + std::atomic inputFailed { false }; + + // The most input frames one callback has read since the last start, + // once FullDuplexStream was done draining: more than a callback + // leaves between reads is input that piled up while the callbacks + // were held up, and was read back up at once + std::atomic largestRead { 0 }; + + // Where the input is read to: room for all the input stream holds. + // Allocated before the streams start, never in the callback + void setInputRoom(int frames, int channels) { + m_inputRoom = std::max(0, frames); + m_inputChannels = std::max(1, channels); + m_inputBuffer.assign(size_t(m_inputRoom) * m_inputChannels, 0.f); + } + + // FullDuplexStream reads only as many input frames as the output + // asks for, so input that piles up while the callbacks are held up + // (the phone busy, as after a take) stays piled up for as long as + // the streams run, the input that much later all the while, until + // the input buffer is full. Everything there is is read instead, + // into a buffer of this class's, since FullDuplexStream's has room + // for one output buffer only + oboe::ResultWithValue readInput(int32_t numFrames) override { + oboe::AudioStream *input = getInputStream(); + oboe::ResultWithValue available = input->getAvailableFrames(); + int32_t wanted = StreamLatency::inputFramesToRead + (numFrames, available ? available.value() : 0, m_inputRoom); + oboe::ResultWithValue read = + input->read(m_inputBuffer.data(), wanted, 0 /* no wait */); + m_inputRead = read ? read.value() : 0; + return read; + } + + oboe::DataCallbackResult onAudioReady(oboe::AudioStream *stream, + void *audioData, + int32_t numFrames) override { + callbackSeq.fetch_add(1, std::memory_order_acq_rel); + oboe::DataCallbackResult result = + oboe::DataCallbackResult::Continue; + if (duplex.load(std::memory_order_relaxed)) { + result = FullDuplexStream::onAudioReady + (stream, audioData, numFrames); + if (result != oboe::DataCallbackResult::Continue) { + inputFailed.store(true); + } + } else { + m_io->process(nullptr, 0, + static_cast(audioData), numFrames); + processed.fetch_add(1, std::memory_order_relaxed); + } + callbackSeq.fetch_add(1, std::memory_order_acq_rel); + return result; + } + + // The input FullDuplexStream hands over is in its own buffer, which + // readInput() above does not fill: what it read, and how much, is + // in this class's + oboe::DataCallbackResult onBothStreamsReady(const void *, + int numInputFrames, + void *outputData, + int numOutputFrames) + override { + int frames = std::min(numInputFrames, m_inputRead); + if (frames > largestRead.load(std::memory_order_relaxed)) { + largestRead.store(frames, std::memory_order_relaxed); + } + m_io->process(m_inputBuffer.data(), frames, + static_cast(outputData), numOutputFrames); + processed.fetch_add(1, std::memory_order_relaxed); + return oboe::DataCallbackResult::Continue; + } + +private: + OboeAudioIO *m_io; + std::vector m_inputBuffer; + int m_inputRoom = 0; + int m_inputChannels = 1; + int m_inputRead = 0; +}; + +// The output stream's error callback. Oboe calls it on a thread of its +// own, which holds the stream, and the stream holds this: so it may +// run after the OboeAudioIO has gone, and touches nothing of it +class OboeAudioIO::ErrorFlag : public oboe::AudioStreamErrorCallback +{ +public: + std::atomic failed { false }; + + void onErrorAfterClose(oboe::AudioStream *, oboe::Result) override { + failed.store(true); + } +}; + +OboeAudioIO::OboeAudioIO(ApplicationRecordTarget *target, + ApplicationPlaybackSource *source) : + SystemAudioIO(target, source), + m_epoch(std::chrono::steady_clock::now()), + m_rate(0), + m_sourceChannels(2), + m_outputChannels(0), + m_inputChannels(0), + m_maxFrames(0), + m_outBufferChannels(0), + m_outBuffers(nullptr), + m_inBuffers(nullptr), + m_gain(1.f), + m_balance(0.f), + m_suspended(true), + m_inputRunning(false), + m_recordSuppressed(false), + m_startFailed(false), + m_reopening(false), + m_outputXRuns(0), + m_inputXRunsAtStart(-1) +{ + if (m_source && m_source->getApplicationChannelCount() > 0) { + m_sourceChannels = m_source->getApplicationChannelCount(); + } + + if (!openStreams()) return; + + if (!measureOnceOpen()) { + // Without the input, the caller can still have playback + m_startupError = "Failed to start the audio streams"; + cerr << "OboeAudioIO: " << m_startupError << endl; + if (m_input) { + m_input->close(); + m_input.reset(); + } else { + m_output->close(); + m_output.reset(); + } + } +} + +OboeAudioIO::~OboeAudioIO() +{ + closeStreams(); + m_engine.reset(); + freeBuffers(); +} + +bool +OboeAudioIO::openStreams() +{ + // A fresh engine and error flag for each pair of streams: an old + // stream's error callback may yet come, and must not mark these + m_engine.reset(new Engine(this)); + m_errors = std::make_shared(); + m_outputXRuns = 0; + + // The output first, at the device's own rate; the input is opened + // at the same rate, the lowest latency path for both. Exclusive + // where the device allows it: AAudio opens a shared stream if not + oboe::AudioStreamBuilder outBuilder; + outBuilder.setDirection(oboe::Direction::Output) + ->setPerformanceMode(oboe::PerformanceMode::LowLatency) + ->setSharingMode(oboe::SharingMode::Exclusive) + ->setFormat(oboe::AudioFormat::Float) + ->setFormatConversionAllowed(true) + ->setChannelCount(2) + ->setChannelConversionAllowed(true) + ->setUsage(oboe::Usage::Media) + ->setContentType(oboe::ContentType::Music) + ->setDataCallback(m_engine.get()) + ->setErrorCallback(m_errors); + + oboe::Result result = outBuilder.openStream(m_output); + if (result == oboe::Result::OK && + m_output->getFormat() != oboe::AudioFormat::Float) { + result = oboe::Result::ErrorInvalidFormat; + m_output->close(); + } + if (result != oboe::Result::OK) { + m_startupError = std::string("Failed to open the audio output: ") + + oboe::convertToText(result); + cerr << "OboeAudioIO: " << m_startupError << endl; + m_output.reset(); + return false; + } + + m_rate = m_output->getSampleRate(); + m_outputChannels = m_output->getChannelCount(); + + if (m_target) { + oboe::AudioStreamBuilder inBuilder; + inBuilder.setDirection(oboe::Direction::Input) + ->setPerformanceMode(oboe::PerformanceMode::LowLatency) + ->setSharingMode(oboe::SharingMode::Exclusive) + ->setFormat(oboe::AudioFormat::Float) + ->setFormatConversionAllowed(true) + ->setChannelCount(1) + ->setChannelConversionAllowed(true) + ->setSampleRate(m_rate) + // Low latency and no automatic gain or noise suppression. + // Android 10 and later; before that Oboe asks for + // VoiceRecognition, the nearest + ->setInputPreset(oboe::InputPreset::VoicePerformance) + // FullDuplexStream's advice + ->setBufferCapacityInFrames + (m_output->getBufferCapacityInFrames() * 2); + + result = inBuilder.openStream(m_input); + // At the output's rate, in float, and with no more channels than + // the output has, which FullDuplexStream's own input buffer had + // room for: Engine::readInput() reads into one of its own now, + // but no more has been tried + if (result == oboe::Result::OK && + (m_input->getSampleRate() != m_rate || + m_input->getFormat() != oboe::AudioFormat::Float || + m_input->getChannelCount() > m_outputChannels)) { + result = oboe::Result::ErrorInvalidFormat; + m_input->close(); + } + if (result != oboe::Result::OK) { + m_startupError = std::string("Failed to open the audio input: ") + + oboe::convertToText(result); + cerr << "OboeAudioIO: " << m_startupError << endl; + m_input.reset(); + return false; + } + } + + freeBuffers(); + m_inputChannels = (m_input ? m_input->getChannelCount() : 0); + int burst = m_output->getFramesPerBurst(); + m_maxFrames = std::max({ m_output->getBufferCapacityInFrames(), + burst, 1024 }); + m_outBufferChannels = std::max(m_sourceChannels, m_outputChannels); + m_outBuffers = allocate_and_zero_channels + (m_outBufferChannels, m_maxFrames); + if (m_input) { + m_inBuffers = allocate_and_zero_channels + (m_inputChannels, m_maxFrames); + m_engine->setInputRoom(m_input->getBufferCapacityInFrames(), + m_inputChannels); + m_engine->setSharedInputStream(m_input); + } + m_engine->setSharedOutputStream(m_output); + + // All of this before the first callback, as PortAudioIO does. The + // latency is a guess until it is measured + m_latency = StreamLatency::guess + (m_output->getBufferSizeInFrames(), + m_input ? m_input->getFramesPerBurst() : 0); + + if (m_source) { + m_source->setSystemPlaybackBlockSize(burst); + m_source->setSystemPlaybackSampleRate(m_rate); + m_source->setSystemPlaybackLatency(m_latency.output); + // Not the stream's channel count: the wrappers svapp puts round + // its play source take this for the number of channels they + // will be asked for, and refuse any other. The mix to the + // stream's channels is done here + m_source->setSystemPlaybackChannelCount(m_sourceChannels); + } + if (m_target) { + m_target->setSystemRecordBlockSize(m_input->getFramesPerBurst()); + m_target->setSystemRecordSampleRate(m_rate); + m_target->setSystemRecordLatency(m_latency.input); + m_target->setSystemRecordChannelCount(m_inputChannels); + } + + logStream("output", m_output.get()); + if (m_input) logStream("input", m_input.get()); + findRoute(); + return true; +} + +void +OboeAudioIO::closeStreams() +{ + if (!m_suspended) stopStreams(); + m_suspended = true; + + // Closing waits for a callback that is running; none comes after + if (m_output) m_output->close(); + if (m_input) m_input->close(); + m_output.reset(); + m_input.reset(); +} + +void +OboeAudioIO::freeBuffers() +{ + if (m_outBuffers) deallocate_channels(m_outBuffers, m_outBufferChannels); + if (m_inBuffers) deallocate_channels(m_inBuffers, m_inputChannels); + m_outBuffers = nullptr; + m_inBuffers = nullptr; +} + +bool +OboeAudioIO::measureOnceOpen() +{ + // Run the streams until they can say what their latency is, so that + // the first take is compensated by a measurement too, and leave + // them suspended: the application suspends a new device at once + // anyway (MainWindowBase::createAudioIO() does) + auto started = std::chrono::steady_clock::now(); + oboe::Result result = startStreams(); + if (result != oboe::Result::OK) { + cerr << "OboeAudioIO: failed to start: " + << oboe::convertToText(result) << endl; + m_startFailed = true; + return false; + } + m_suspended = false; + + bool measurable = waitUntilMeasurable(openWaitMillis); + int waited = int(std::chrono::duration_cast + (std::chrono::steady_clock::now() - started).count()); + StreamLatency::Estimate latency; + int backlog = 0, lost = 0; + bool withInput = m_inputRunning; + bool measured = measurable && measureLatency(latency, backlog, lost); + stopStreams(); + m_suspended = true; + + if (measured) { + report(latency, withInput); + cerr << "OboeAudioIO: latency measured " << waited + << " ms after starting: " + << describe(m_latency, m_input != nullptr, m_rate) << endl; + } else { + cerr << "OboeAudioIO: no timestamps " << waited + << " ms after starting; latency guessed: " + << describe(m_latency, m_input != nullptr, m_rate) << endl; + } + return true; +} + +bool +OboeAudioIO::reopen() +{ + // Whatever route Android has now: the one that went may have gone + // for good (headphones out), or come back with another id + m_reopening = true; + closeStreams(); + bool ok = openStreams() && measureOnceOpen(); + m_reopening = false; + if (!ok) { + cerr << "OboeAudioIO: the streams could not be opened again" << endl; + } + return ok; +} + +bool +OboeAudioIO::isSourceOK() const +{ + // Without a record target the input is not wanted + return !m_target || m_input != nullptr; +} + +bool +OboeAudioIO::isTargetOK() const +{ + return m_output != nullptr; +} + +double +OboeAudioIO::getCurrentTime() const +{ + // The play source asks in the callback too: this reads a clock and + // nothing else + return std::chrono::duration + (std::chrono::steady_clock::now() - m_epoch).count(); +} + +bool +OboeAudioIO::hasFailed() const +{ + return m_errors->failed.load() || m_engine->inputFailed.load() || + m_startFailed; +} + +void +OboeAudioIO::resume() +{ + if (!m_suspended || !m_output || hasFailed()) return; + + oboe::Result result = startStreams(); + + // Streams that Android disconnected while they were stopped (after + // an idle spell, or a route that went away) are opened afresh and + // started at once, so that what was asked for goes ahead rather than + // recording nothing until the window reopens the device + if (result == oboe::Result::ErrorDisconnected && !m_reopening) { + cerr << "OboeAudioIO: failed to start: " + << oboe::convertToText(result) + << "; the streams were disconnected while stopped, so they " + << "are opened again" << endl; + if (!reopen()) { + m_startFailed = true; + return; + } + result = startStreams(); + } + + if (result != oboe::Result::OK) { + cerr << "OboeAudioIO: failed to start: " + << oboe::convertToText(result) << endl; + m_startFailed = true; + return; + } + + m_suspended = false; +} + +oboe::Result +OboeAudioIO::startStreams() +{ + bool duplex = (m_input && !m_recordSuppressed); + m_engine->duplex.store(duplex); + m_engine->processed.store(0); + m_engine->largestRead.store(0); + + // FullDuplexStream starts the input, then the output, and starts + // draining the input again + oboe::Result result = + (duplex ? m_engine->start() : m_output->requestStart()); + m_inputRunning = duplex; + + m_inputXRunsAtStart = -1; + if (duplex && result == oboe::Result::OK) { + oboe::ResultWithValue xruns = m_input->getXRunCount(); + if (xruns) m_inputXRunsAtStart = xruns.value(); + } + + if (result != oboe::Result::OK) stopStreams(); + return result; +} + +void +OboeAudioIO::suspend() +{ + if (m_suspended || !m_output) return; + + // Measured while the streams still run, reported once they have + // stopped, so that nothing the audio thread reads changes under it. + // The application reads the figures when a take starts, so the next + // take is compensated by what the device did now + StreamLatency::Estimate latency; + int backlog = 0; + bool withInput = m_inputRunning; + bool steady = (m_engine->processed.load() >= steadyCallbacks); + + // Input lost to an overrun: the device's position ran on while the + // frames read did not, and the timestamps would give the loss as + // latency (the phone's logs had 244 and 484 ms, one and two buffers) + int overruns = 0; + if (withInput && m_inputXRunsAtStart >= 0) { + oboe::ResultWithValue xruns = m_input->getXRunCount(); + if (xruns) overruns = xruns.value() - m_inputXRunsAtStart; + } + + int lost = 0; + bool measured = steady && overruns == 0 && + measureLatency(latency, backlog, lost); + int largestRead = m_engine->largestRead.load(); + + stopStreams(); + m_suspended = true; + + if (measured) { + StreamLatency::Estimate before = m_latency; + report(latency, withInput); + if (m_latency.output != before.output || + m_latency.input != before.input) { + cerr << "OboeAudioIO: latency now " + << describe(m_latency, m_input != nullptr, m_rate) << endl; + } + } else if (overruns > 0) { + cerr << "OboeAudioIO: the input overran " << overruns + << " time(s) while running, and what was lost would read as " + << "latency, so the latency was not measured: kept at " + << describe(m_latency, m_input != nullptr, m_rate) << endl; + } else if (steady && lost > 0) { + cerr << "OboeAudioIO: the input read " << lost << " frames (" + << describeMs(lost, m_rate) << ") late, more than its buffer " + << "holds: input was lost, so the latency was not measured: " + << "kept at " << describe(m_latency, m_input != nullptr, m_rate) + << endl; + } else if (steady && backlog > 0) { + cerr << "OboeAudioIO: the input was " << backlog << " frames (" + << describeMs(backlog, m_rate) << ") behind as the device " + << "stopped, so the latency was not measured: kept at " + << describe(m_latency, m_input != nullptr, m_rate) << endl; + } + + // Input that piled up while the callbacks were held up, or that + // comes in bursts, and was read at once: FullDuplexStream would have + // left it waiting, the input that much later for the rest of the run + if (withInput && !keptUp(largestRead)) { + cerr << "OboeAudioIO: one callback read " << largestRead + << " frames (" << describeMs(largestRead, m_rate) + << ") of input at once while running, more than a callback " + << "leaves: the callbacks were held up, or come in bursts" + << endl; + } + + oboe::ResultWithValue xruns = m_output->getXRunCount(); + if (xruns && xruns.value() != m_outputXRuns) { + m_outputXRuns = xruns.value(); + cerr << "OboeAudioIO: " << m_outputXRuns + << " output underrun(s) since the device was opened" << endl; + } +} + +void +OboeAudioIO::suppressRecordSide(bool suppress) +{ + if (suppress == m_recordSuppressed) return; + bool wasRunning = !m_suspended; + if (wasRunning) suspend(); + m_recordSuppressed = suppress; + if (wasRunning) resume(); +} + +void +OboeAudioIO::setOutputGain(float gain) +{ + SystemAudioIO::setOutputGain(gain); + m_gain.store(gain); +} + +void +OboeAudioIO::setOutputBalance(float balance) +{ + SystemAudioIO::setOutputBalance(balance); + m_balance.store(balance); +} + +void +OboeAudioIO::stopStreams() +{ + // The output first, as its callback reads the input. stop() waits + // until the stream has stopped, so no callback runs after this. + // A stream Oboe has closed after an error just says so + if (m_output) m_output->stop(); + if (m_input && m_inputRunning) m_input->stop(); + m_inputRunning = false; +} + +bool +OboeAudioIO::waitUntilMeasurable(int maxMillis) const +{ + auto deadline = std::chrono::steady_clock::now() + + std::chrono::milliseconds(maxMillis); + while (!m_suspended && !hasFailed()) { + if (m_engine->processed.load() >= steadyCallbacks) { + bool stamped = bool(m_output->getTimestamp(CLOCK_MONOTONIC)); + if (stamped && m_inputRunning) { + stamped = bool(m_input->getTimestamp(CLOCK_MONOTONIC)); + } + if (stamped) return true; + } + if (std::chrono::steady_clock::now() >= deadline) break; + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + } + return false; +} + +bool +OboeAudioIO::measureLatency(StreamLatency::Estimate &latency, + int &backlog, int &lost) const +{ + // Readings are taken here, not in the callback: Oboe advises + // against timestamps there before Android 11. A reading that a + // callback ran through is thrown away, as it would pair one + // callback's output count with another's input count; so is one + // taken just after a callback returned and before Oboe counted what + // it wrote, by the median. So is one taken while the input was not + // being read as it came in, the callbacks held up: its input latency + // is how far behind they were, not the device's (backlog says the + // most that was waiting then). And so is one whose input latency is + // more than the input's buffer holds: that is input lost, which + // stays in the timestamps until the streams stop (lost says the + // most read so) + bool withInput = m_inputRunning; + backlog = 0; + lost = 0; + const int capacity = withInput ? m_input->getBufferCapacityInFrames() : 0; + std::vector readings; + for (int attempt = 0; + attempt < readingsWanted * 5 && int(readings.size()) < readingsWanted; + ++attempt) { + + if (attempt > 0) { + std::this_thread::sleep_for(std::chrono::microseconds(700)); + } + + uint32_t before = m_engine->callbackSeq.load(std::memory_order_acquire); + if (before & 1) continue; + + StreamLatency::Position output, input; + output.appFrames = m_output->getFramesWritten(); + int waiting = 0; + if (withInput) { + input.appFrames = m_input->getFramesRead(); + auto available = m_input->getAvailableFrames(); + if (!available) continue; + waiting = available.value(); + } + + auto outputStamp = m_output->getTimestamp(CLOCK_MONOTONIC); + if (!outputStamp) continue; + output.hardwareFrame = outputStamp.value().position; + output.hardwareNanos = outputStamp.value().timestamp; + + if (withInput) { + auto inputStamp = m_input->getTimestamp(CLOCK_MONOTONIC); + if (!inputStamp) continue; + input.hardwareFrame = inputStamp.value().position; + input.hardwareNanos = inputStamp.value().timestamp; + } + + int64_t now = monotonicNanos(); + if (m_engine->callbackSeq.load(std::memory_order_acquire) != before) { + continue; + } + + if (withInput && !keptUp(waiting)) { + backlog = std::max(backlog, waiting); + continue; + } + + const double inputFrames = withInput ? + StreamLatency::inputLatency(input, now, m_rate) : 0.0; + if (!StreamLatency::inputLatencyPossible(inputFrames, capacity)) { + lost = std::max(lost, int(std::lround(inputFrames))); + continue; + } + + readings.push_back(StreamLatency::fromReading + (StreamLatency::outputLatency(output, now, m_rate), + inputFrames)); + } + + return StreamLatency::median(readings, m_rate, latency); +} + +bool +OboeAudioIO::keptUp(int waiting) const +{ + return StreamLatency::inputKeptUp + (waiting, m_output ? m_output->getBufferSizeInFrames() : 0, + m_input ? m_input->getFramesPerBurst() : 0); +} + +void +OboeAudioIO::report(StreamLatency::Estimate latency, bool withInput) +{ + // Only while the streams are stopped: the play source's wrappers + // are not safe to change under the callback + m_latency.output = latency.output; + if (m_source) m_source->setSystemPlaybackLatency(m_latency.output); + if (withInput) { + m_latency.input = latency.input; + if (m_target) m_target->setSystemRecordLatency(m_latency.input); + } +} + +void +OboeAudioIO::logStream(std::string name, oboe::AudioStream *stream) const +{ + cerr << "OboeAudioIO: " << name << ": " << describeStream(stream) << endl; +} + +void +OboeAudioIO::findRoute() +{ + // The devices AAudio chose: for an unspecified device, those of the + // route Android has now, which is what a measured round trip belongs + // to. Their ids change when a device is plugged in again, so the + // route is named by their types and product names + m_route = AudioRoute::Route(); + m_route.driver = "oboe"; + m_route.rate = m_rate; + m_route.output = lookUpDevice(m_output->getDeviceId(), false); + m_route.outputStreams = QString::fromStdString + (describeStream(m_output.get())); + if (m_input) { + m_route.hasInput = true; + m_route.input = lookUpDevice(m_input->getDeviceId(), true); + m_route.inputStreams = QString::fromStdString + (describeStream(m_input.get())); + } + cerr << "OboeAudioIO: route: output " << describeDevice(m_route.output); + if (m_input) { + cerr << "; input " << describeDevice(m_route.input); + } else { + cerr << "; no input"; + } + cerr << endl; +} + +void +OboeAudioIO::process(const float *input, int inputFrames, + float *output, int outputFrames) +{ + // On the audio thread: no allocation, no lock, no logging. + // + // The input first. The application counts on having a block's + // input before it is asked for the block's output (the start gap, + // recording.md "Latency") + if (m_target && input && inputFrames > 0) { + float peakLeft = 0.f, peakRight = 0.f; + for (int done = 0; done < inputFrames; ) { + int n = std::min(inputFrames - done, m_maxFrames); + v_deinterleave(m_inBuffers, + input + size_t(done) * m_inputChannels, + m_inputChannels, n); + for (int c = 0; c < m_inputChannels && c < 2; ++c) { + float peak = 0.f; + for (int i = 0; i < n; ++i) { + peak = std::max(peak, std::fabs(m_inBuffers[c][i])); + } + if (c == 0) peakLeft = std::max(peakLeft, peak); + if (c == 1 || m_inputChannels == 1) { + peakRight = std::max(peakRight, peak); + } + } + m_target->putSamples(m_inBuffers, m_inputChannels, n); + done += n; + } + m_target->setInputLevels(peakLeft, peakRight); + } + + if (!output || outputFrames <= 0) return; + + if (!m_source) { + v_zero(output, outputFrames * m_outputChannels); + return; + } + + // As bqaudioio's Gains: the balance turns down the other side + float gain = m_gain.load(std::memory_order_relaxed); + float balance = m_balance.load(std::memory_order_relaxed); + float leftGain = (balance > 0.f ? gain * (1.f - balance) : gain); + float rightGain = (balance < 0.f ? gain * (1.f + balance) : gain); + + float peakLeft = 0.f, peakRight = 0.f; + for (int done = 0; done < outputFrames; ) { + int n = std::min(outputFrames - done, m_maxFrames); + int got = m_source->getSourceSamples + (m_outBuffers, m_sourceChannels, n); + got = std::max(0, std::min(got, n)); + if (got < n) { + for (int c = 0; c < m_sourceChannels; ++c) { + v_zero(m_outBuffers[c] + got, n - got); + } + } + v_reconfigure_channels_inplace + (m_outBuffers, m_outputChannels, m_sourceChannels, n); + for (int c = 0; c < m_outputChannels; ++c) { + float g = (c == 0 ? leftGain : c == 1 ? rightGain : gain); + v_scale(m_outBuffers[c], g, n); + if (c < 2) { + float peak = 0.f; + for (int i = 0; i < n; ++i) { + peak = std::max(peak, std::fabs(m_outBuffers[c][i])); + } + if (c == 0) peakLeft = std::max(peakLeft, peak); + if (c == 1 || m_outputChannels == 1) { + peakRight = std::max(peakRight, peak); + } + } + } + v_interleave(output + size_t(done) * m_outputChannels, + m_outBuffers, m_outputChannels, n); + done += n; + } + m_source->setOutputLevels(peakLeft, peakRight); +} diff --git a/main/OboeAudioIO.h b/main/OboeAudioIO.h new file mode 100644 index 00000000..09934d5b --- /dev/null +++ b/main/OboeAudioIO.h @@ -0,0 +1,179 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TONY_OBOE_AUDIO_IO_H +#define TONY_OBOE_AUDIO_IO_H + +#include "AudioRoute.h" +#include "StreamLatency.h" + +#include + +#include +#include +#include +#include +#include + +namespace oboe { +class AudioStream; +enum class Result : int32_t; +} + +/** + * The audio device on Android, through Google's Oboe (AAudio): a + * bqaudioio SystemAudioIO, as PortAudioIO is on the desktop, which + * MainWindow::createAudioIO() installs; bqaudioio has no Android + * backend. Built for Android only. + * + * A stereo output stream at the device's own rate and, given a record + * target, a mono input stream at the same rate, read in the output + * stream's callback (oboe::FullDuplexStream). Every callback hands the + * target its input before it asks the source for output, as the + * start-gap measurement assumes (recording.md, "Latency"). Without a + * record target, or with the record side suppressed, the output runs + * alone. + * + * FullDuplexStream spends its first 50 or so callbacks after each + * start draining and discarding input, with the output silent, so that + * input is read as soon as it comes in: output starts some 100 ms + * after resume(), and the first input the target gets is from then. + * After that each callback reads all the input there is, not only as + * much as the output asks for, as FullDuplexStream would: input that + * piled up while a callback was held up would otherwise stay piled up, + * and run late, for as long as the streams run. + * + * Latency, in frames at the device's rate like PortAudioIO's, is + * worked out from the streams' timestamps (StreamLatency): when the + * device is opened, for which the constructor runs the streams until + * they have timestamps and leaves them suspended, and again each time + * it is suspended after running (the application keeps it running + * between takes, and suspends it once it idles or Tony goes to the + * background), so that the takes after that are compensated by what + * the device did last. Timestamps are read on the calling + * thread, never in the callback. A reading taken while the input was + * not being read as it came in (more waiting than a callback leaves) is + * of how far behind the reading was, not of the device, and is not + * used: the figures stay as they were. + * + * The route, the devices Android opened (the speaker and the phone's + * microphone, a headset, Bluetooth) and how their streams were opened, + * is looked up once, when they are, and logged: a round trip measured + * through them is kept for that route (LatencyCalibration). + * + * A stream that fails (a device disconnected: headphones plugged in + * or out) is stopped by Oboe; hasFailed() then says so, and the owner + * must delete this and open another. Streams that Android disconnected + * while they were stopped say so only when started again: resume() + * then opens them afresh, on whatever route there is now, measures + * them as the constructor does, and starts those, so that the take or + * the playback asked for goes ahead. Every method but the callback's + * is for the GUI thread, which is the only one that logs, to stderr. + */ +class OboeAudioIO : public breakfastquay::SystemAudioIO, + public AudioRouteReporter +{ +public: + /** + * Open the output and, if target is not null, the input. Check + * isOK() afterwards: without the input it is false, and the caller + * is expected to try again without a target, for playback only. + */ + OboeAudioIO(breakfastquay::ApplicationRecordTarget *target, + breakfastquay::ApplicationPlaybackSource *source); + ~OboeAudioIO() override; + + bool isSourceOK() const override; + bool isTargetOK() const override; + double getCurrentTime() const override; + + void suspend() override; + void resume() override; + void suppressRecordSide(bool suppress) override; + + void setOutputGain(float gain) override; + void setOutputBalance(float balance) override; + + /// Why a stream could not be opened, or "" if both were + std::string getStartupError() const { return m_startupError; } + + /// Whether a stream has failed, so that this must be replaced + bool hasFailed() const; + + /// The devices the streams were opened on, as Android's AudioManager + /// names them, and how the streams were opened (the audio API, MMAP, + /// sharing and performance mode, burst, buffer, input preset) + AudioRoute::Route getAudioRoute() const override { return m_route; } + +private: + class Engine; + class ErrorFlag; + + std::shared_ptr m_output; + std::shared_ptr m_input; + std::unique_ptr m_engine; + std::shared_ptr m_errors; + std::string m_startupError; + + std::chrono::steady_clock::time_point m_epoch; + int m_rate; + int m_sourceChannels; + int m_outputChannels; + int m_inputChannels; + int m_maxFrames; + int m_outBufferChannels; + float **m_outBuffers; + float **m_inBuffers; + + std::atomic m_gain; + std::atomic m_balance; + + bool m_suspended; + bool m_inputRunning; + bool m_recordSuppressed; + bool m_startFailed; + bool m_reopening; + StreamLatency::Estimate m_latency; + int m_outputXRuns; + + // The input's overrun count as the streams last started, -1 if the + // input does not say: input lost to an overrun stays in the latency + // the timestamps give until the streams stop, a buffer's worth each + int m_inputXRunsAtStart; + AudioRoute::Route m_route; + + // The callback: the input first, then the output + friend class Engine; + void process(const float *input, int inputFrames, + float *output, int outputFrames); + + bool openStreams(); + void closeStreams(); + void freeBuffers(); + bool measureOnceOpen(); + bool reopen(); + oboe::Result startStreams(); + void stopStreams(); + bool waitUntilMeasurable(int maxMillis) const; + bool measureLatency(StreamLatency::Estimate &latency, + int &backlog, int &lost) const; + bool keptUp(int waiting) const; + void report(StreamLatency::Estimate latency, bool withInput); + void logStream(std::string name, oboe::AudioStream *stream) const; + void findRoute(); + + OboeAudioIO(const OboeAudioIO &) = delete; + OboeAudioIO &operator=(const OboeAudioIO &) = delete; +}; + +#endif diff --git a/main/OctaveSlips.cpp b/main/OctaveSlips.cpp new file mode 100644 index 00000000..ab76602d --- /dev/null +++ b/main/OctaveSlips.cpp @@ -0,0 +1,105 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#include "OctaveSlips.h" + +#include + +using sv::sv_frame_t; + +namespace { + +double +centsBetween(double fromHz, double hz) +{ + return 1200.0 * std::log2(hz / fromHz); +} + +} // namespace + +int +OctaveSlips::octaveFrom(double fromHz, double hz) +{ + double cents = centsBetween(fromHz, hz); + if (std::fabs(cents - 1200.0) <= kToleranceCents) return 1; + if (std::fabs(cents + 1200.0) <= kToleranceCents) return -1; + return 0; +} + +void +OctaveSlips::letThrough(const Dot &dot, std::vector &out) +{ + // Unless it is an octave from the dot before it, which may be a slip + if (m_haveLast) { + int direction = octaveFrom(m_last.hz, dot.hz); + if (direction != 0) { + m_held.push_back(dot); + m_heldDirection = direction; + return; + } + } + out.push_back(dot); + m_last = dot; + m_haveLast = true; +} + +void +OctaveSlips::releaseHeld(std::vector &out) +{ + if (m_held.empty()) return; + out.insert(out.end(), m_held.begin(), m_held.end()); + m_last = m_held.back(); + m_held.clear(); + m_heldDirection = 0; +} + +void +OctaveSlips::push(sv_frame_t frame, double hz, std::vector &out) +{ + // A run that ends in silence cannot be told from a phrase that ends + // an octave up or down: it is let through + if (!(hz > 0.0)) { + releaseHeld(out); + m_haveLast = false; + return; + } + + Dot dot { frame, hz }; + + if (m_held.empty()) { + letThrough(dot, out); + return; + } + + // Back at the pitch before the run: the run was a slip + if (std::fabs(centsBetween(m_last.hz, hz)) <= kToleranceCents) { + m_held.clear(); + m_heldDirection = 0; + out.push_back(dot); + m_last = dot; + return; + } + + // Still an octave away, and not for longer than a slip lasts + if (octaveFrom(m_last.hz, hz) == m_heldDirection && + int(m_held.size()) < kMaxRunHops) { + m_held.push_back(dot); + return; + } + + // The singing went there: the run is let through, and this dot + // follows it as any dot follows the one before + releaseHeld(out); + letThrough(dot, out); +} diff --git a/main/OctaveSlips.h b/main/OctaveSlips.h new file mode 100644 index 00000000..1e304b7c --- /dev/null +++ b/main/OctaveSlips.h @@ -0,0 +1,86 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TONY_OCTAVE_SLIPS_H +#define TONY_OCTAVE_SLIPS_H + +#include "base/BaseTypes.h" + +#include + +/** + * Drops the live tracker's octave slips: a short run of dots an octave + * from the dots on both sides of it. + * + * YIN takes the first lag whose normalised difference is under its + * threshold. Where noise lifts the dip at the period just over it, the + * dip at twice the period, normalised by a larger mean, can still be + * under: the dot comes out an octave low (and, the other way, an octave + * high). On the user's phone, with the input at -28 dBFS, 4 of about + * 280 dots a punch-in slipped so, which the dev checks' item 3 failed + * on. pYIN, whose pitch replaces the dots once a take is analysed, does + * not slip so. + * + * Fed every hop the tracker analyses, in order, voiced or not. A dot + * about an octave from the dot on the hop before it is held back, with + * those after it that are too, until one comes back to the pitch before + * the run: then the run is dropped. A run that goes on longer than one + * window's hops, or ends in an unvoiced hop or at another pitch, is let + * through, late by as long as it was held: a leap of an octave in the + * singing. A dot with no dot on the hop before it (the start of a + * phrase) is let through as it is. So only dots after an octave's jump + * are ever late, by one window at the most. + * + * Plain arithmetic, for the tracker's thread; tested in TestOctaveSlips. + */ +class OctaveSlips +{ +public: + struct Dot { + sv::sv_frame_t frame; + double hz; + }; + + /// The longest run dropped, in hops: one window of the tracker's + /// (2048 frames in hops of 256), as a slip comes of what is in one + /// window, and every window that holds it may slip + static constexpr int kMaxRunHops = 8; + + /// How near an octave a dot has to be to the one before the run, + /// and how near its pitch the dot after the run, in cents + static constexpr double kToleranceCents = 100.0; + + /** + * The next hop, at frame: its pitch, or 0 for an unvoiced hop. + * Appends to out, oldest first, the dots now let through. + */ + void push(sv::sv_frame_t frame, double hz, std::vector &out); + + /// How many dots are held back now + int held() const { return int(m_held.size()); } + +private: + /// +1 for about an octave above hz, -1 below, else 0 + static int octaveFrom(double fromHz, double hz); + void letThrough(const Dot &dot, std::vector &out); + void releaseHeld(std::vector &out); + + bool m_haveLast = false; + Dot m_last { 0, 0.0 }; ///< the dot let through on the hop + ///< before the held run, if voiced + std::vector m_held; + int m_heldDirection = 0; +}; + +#endif diff --git a/main/PaneUtils.cpp b/main/PaneUtils.cpp new file mode 100644 index 00000000..0c57b5ad --- /dev/null +++ b/main/PaneUtils.cpp @@ -0,0 +1,105 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#include "PaneUtils.h" + +#include "framework/Document.h" +#include "view/Pane.h" +#include "view/PaneStack.h" +#include "view/Overview.h" +#include "layer/Layer.h" + +#include + +using namespace sv; +using std::cerr; +using std::endl; + +void +pruneExtraPane(Document *document, PaneStack *paneStack, Pane *extra, + ModelId ownedModelId, Overview *overview) +{ + // PRECONDITIONS: some other layer (the secondary analyser's + // WaveformLayer, or the background music layer) already references + // ownedModelId, otherwise deleting the orphan below makes + // Document::releaseModel() free the model. The pane widget must + // still be alive, so that the Document::m_layerViewMap iteration in + // deleteLayer(force=true) is valid. + // + // The extra pane contains two kinds of layer: + // + // 1. The orphan WaveformLayer from createImportedLayer() — unique to + // this pane, with ownedModelId as its model. deleteLayer(force=true) + // removes it from the view without creating an undo command and + // deletes it from the document. We must NOT use removeLayerFromView + // for it, as that would push a RemoveLayerCommand onto the undo + // stack holding a pointer to a layer we are about to delete. + // + // 2. Shared layers, i.e. the time ruler, which openAudio()/record() + // add to the new pane when MainWindowBase::m_timeRulerLayer is set + // (e.g. after a session load) and which also lives in pane 1. We + // must NOT call deleteLayer() on it: that would remove the ruler + // from every view, free it, and leave m_timeRulerLayer dangling. + // removeLayerFromView is no good either: it pushes a + // RemoveLayerCommand with a raw pointer to the extra pane, which + // is about to be destroyed → crash on undo. Use + // detachLayerFromView (no undo command): it removes the layer + // from the pane's display list AND updates m_layerViewMap so + // deletePane() leaves no dangling pointer. + // + // We distinguish them by whether the layer's model is ownedModelId. + // The ruler's model is the main model (createMainModelLayer() gives + // it that id), so it never matches the id of an additional model. + // A layer that has no model at all has the "none" id, though, so an + // empty ownedModelId must match nothing: without the isNone() guard + // such a layer would be deleted rather than detached. + if (!extra) return; + + if (document) { + int layerCount = extra->getLayerCount(); + for (int i = layerCount - 1; i >= 0; --i) { + Layer *lay = extra->getLayer(i); + if (!lay) continue; + + if (!ownedModelId.isNone() && lay->getModel() == ownedModelId) { + cerr << "pruneExtraPane: deleteLayer orphan " + << lay << " [" << lay->objectName().toStdString() + << "]" << endl; + document->deleteLayer(lay, true); + } else { + cerr << "pruneExtraPane: detachLayerFromView " + << lay << " [" << lay->objectName().toStdString() + << "] (shared layer, keeping in other views)" << endl; + document->detachLayerFromView(extra, lay); + } + } + } + + // Now destroy the pane widget (works for hidden panes too). By this + // point m_layerViewMap no longer references this pane. + if (overview) overview->unregisterView(extra); + if (paneStack) paneStack->deletePane(extra); +} + +void +updateViewFrames(View *view, sv_frame_t from, sv_frame_t to) +{ + if (!view || to <= from) return; + + // A pixel either side: a layer draws at the view's x for a frame, + // scaled to the pixel ratio, and at least one physical pixel wide + int x0 = view->getXForFrame(from) - 1; + int x1 = view->getXForFrame(to) + 1; + if (x1 < 0 || x0 >= view->width()) return; + view->update(x0, 0, x1 - x0 + 1, view->height()); +} diff --git a/main/PaneUtils.h b/main/PaneUtils.h new file mode 100644 index 00000000..f134a6c7 --- /dev/null +++ b/main/PaneUtils.h @@ -0,0 +1,60 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TONY_PANE_UTILS_H +#define TONY_PANE_UTILS_H + +#include "base/ById.h" +#include "data/model/Model.h" + +namespace sv { +class Document; +class PaneStack; +class Pane; +class Overview; +class View; +} + +/** + * Remove an unwanted extra pane that openAudio()/record() created via + * AddPaneCommand in CreateAdditionalModel mode. Shared by the record, + * load-singing-track and load-background-music flows, all of which + * want the new audio overlaid on pane 0 rather than in its own pane. + * + * Layers in the pane whose model is ownedModelId (the imported + * waveform) are deleted from the document; any other layer (the + * shared time ruler) is only detached from this pane. The pane is + * then unregistered from the overview, if one is given, and deleted. + * The pane may be visible or hidden. + * + * Some other layer must already reference ownedModelId, otherwise + * Document::releaseModel() frees the model along with the orphan. + */ +void pruneExtraPane(sv::Document *document, + sv::PaneStack *paneStack, + sv::Pane *extra, + sv::ModelId ownedModelId, + sv::Overview *overview = nullptr); + +/** + * Have the view draw again only the part of itself over frames [from, + * to), for a layer that draws there alone what changed there, and + * that is out of the view's cache, such as the live dots. A model's + * own change notice has the view draw all of itself: at a pixel ratio + * of 3 that is nine times the pixels, of every layer, the cached ones + * copied from the cache. + */ +void updateViewFrames(sv::View *view, + sv::sv_frame_t from, sv::sv_frame_t to); + +#endif diff --git a/main/PinchZoom.cpp b/main/PinchZoom.cpp new file mode 100644 index 00000000..de84046c --- /dev/null +++ b/main/PinchZoom.cpp @@ -0,0 +1,131 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#include "PinchZoom.h" + +#include "data/model/RelativelyFineZoomConstraint.h" + +#include + +using namespace sv; + +namespace PinchZoom +{ + +double +framesPerPixel(ZoomLevel z) +{ + if (z.zone == ZoomLevel::PixelsPerFrame) { + return 1.0 / double(z.level); + } + return double(z.level); +} + +ZoomLevel +nearestLevel(double fpp) +{ + // The wheel steps through View::getZoomConstraintLevel(), which is + // this constraint alone: the layers' own constraints count only for + // a layer that does not supportsOtherZoomLevels(), and none in svgui + // says that. Its limits are the wheel's limits too + RelativelyFineZoomConstraint constraint; + + double minFpp = framesPerPixel(constraint.getMinZoomLevel()); + double maxFpp = framesPerPixel(constraint.getMaxZoomLevel()); + if (!(fpp > minFpp)) fpp = minFpp; // NaN as well + if (fpp > maxFpp) fpp = maxFpp; + + ZoomLevel requested; + if (fpp >= 1.0) { + requested = ZoomLevel(ZoomLevel::FramesPerPixel, + int(std::lround(fpp))); + } else { + requested = ZoomLevel(ZoomLevel::PixelsPerFrame, + int(std::lround(1.0 / fpp))); + } + + return constraint.getNearestZoomLevel(requested, + ZoomConstraint::RoundNearest); +} + +ZoomLevel +pinchedLevel(ZoomLevel current, double targetFpp) +{ + if (!(targetFpp > 0.0)) return current; + + ZoomLevel candidate = nearestLevel(targetFpp); + if (candidate == current) return current; + + // Nearer by 2% at least. Adjacent levels are 10% or more apart, and a + // finger resting on a screen wanders by well under 1% of a pinch + const double margin = std::log(1.02); + + double candidateError = + std::fabs(std::log(framesPerPixel(candidate) / targetFpp)); + double currentError = + std::fabs(std::log(framesPerPixel(current) / targetFpp)); + + if (candidateError + margin < currentError) return candidate; + return current; +} + +double +frameAtX(sv_frame_t centre, ZoomLevel z, int width, double x) +{ + double dx = x - double(width / 2); + + if (z.zone == ZoomLevel::FramesPerPixel) { + // View maps x from the centre rounded down to a whole pixel + sv_frame_t rounded = (centre / z.level) * z.level; + return double(rounded) + dx * double(z.level); + } + + return double(centre) + dx / double(z.level); +} + +sv_frame_t +centreFor(double frame, ZoomLevel z, int width, double x) +{ + double dx = x - double(width / 2); + return sv_frame_t(std::llround(frame - dx * framesPerPixel(z))); +} + +AxisMovement::AxisMovement(double deadZone) : + m_deadZone(deadZone), + m_counting(false), + m_offset(0.0) +{ +} + +double +AxisMovement::update(double along, double across) +{ + if (!m_counting && + std::fabs(along) > m_deadZone && + 2.0 * std::fabs(along) >= std::fabs(across)) { + m_counting = true; + m_offset = (along > 0.0 ? m_deadZone : -m_deadZone); + } + return m_counting ? along - m_offset : 0.0; +} + +void +AxisMovement::start(double along) +{ + if (m_counting) return; + m_counting = true; + m_offset = along; +} + +} diff --git a/main/PinchZoom.h b/main/PinchZoom.h new file mode 100644 index 00000000..571ecd53 --- /dev/null +++ b/main/PinchZoom.h @@ -0,0 +1,98 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TONY_PINCH_ZOOM_H +#define TONY_PINCH_ZOOM_H + +#include "base/BaseTypes.h" +#include "base/ZoomLevel.h" + +/** + * The arithmetic of a two-finger gesture on a pane's time axis: which + * zoom level a pinch asks for, and where the pane must be centred for + * a frame to stay under the fingers; and, for either axis, when the + * fingers' movement along it counts. TouchGestures does as the + * answers say (VerticalZoom has the vertical axis). + * + * The x mapping is svgui's View::getFrameForX() made continuous: the + * same centre, rounded the same way, so that a frame placed at x by + * centreFor() is the one the pane shows there, to within a pixel. + * + * Nothing here touches a view, so all of it is tested without a + * window (TestPinchZoom). + */ +namespace PinchZoom +{ + /// Frames per pixel at z: below 1 when zoomed in past one to one + double framesPerPixel(sv::ZoomLevel z); + + /** + * The zoom level nearest to fpp frames per pixel among those the + * mouse wheel steps through, and within the same limits. + */ + sv::ZoomLevel nearestLevel(double fpp); + + /** + * The level for a pinch that asks for targetFpp, with the view at + * current: the nearest level, but current until another is clearly + * nearer, so that fingers held still half way between two levels + * do not make the view flicker between them. + */ + sv::ZoomLevel pinchedLevel(sv::ZoomLevel current, double targetFpp); + + /** + * The frame at x (pixels, may be fractional) in a view of the given + * width centred on centre at zoom z. + */ + double frameAtX(sv::sv_frame_t centre, sv::ZoomLevel z, int width, + double x); + + /** + * The centre that puts frame at x in a view of the given width at + * zoom z. + */ + sv::sv_frame_t centreFor(double frame, sv::ZoomLevel z, int width, + double x); + + /** + * How much of the fingers' movement along one axis counts: the + * change in their spread, or the travel of the point between them, + * since they came down. None until the movement is plainly meant: + * more than the dead zone, and at least half as much as the same + * movement across the axis, so that a pinch or a drag along one + * axis leaves the other alone. From then on all of it, less what + * was taken for the dead zone, so that nothing jumps when it + * starts to count. + */ + class AxisMovement + { + public: + explicit AxisMovement(double deadZone = 0.0); + + /// What counts of the movement along, with across the other's + double update(double along, double across); + + /// Count from now on, from along as it is now + void start(double along); + + bool isCounting() const { return m_counting; } + + private: + double m_deadZone; + bool m_counting; + double m_offset; + }; +} + +#endif diff --git a/main/PlotSize.cpp b/main/PlotSize.cpp new file mode 100644 index 00000000..d207b08e --- /dev/null +++ b/main/PlotSize.cpp @@ -0,0 +1,97 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#include "PlotSize.h" + +#include "view/ViewManager.h" + +#include +#include +#include + +static const char *settingsGroup = "MainWindow"; +static const char *settingsKey = "plotsize"; + +PlotSize::PlotSize(sv::ViewManager *viewManager, QObject *parent) : + QObject(parent), + m_viewManager(viewManager), + m_group(new QActionGroup(this)), + m_percent(0) +{ + m_group->setExclusive(true); + + for (int percent : getSteps()) { + QAction *action = new QAction(tr("%1%").arg(percent), m_group); + action->setCheckable(true); + action->setData(percent); + action->setStatusTip + (tr("Draw the pitch tracks and the notes at %1% of their " + "normal size").arg(percent)); + connect(action, &QAction::triggered, + this, [this, percent]() { setPercent(percent); }); + } + + QSettings settings; + settings.beginGroup(settingsGroup); + int percent = settings.value(settingsKey, getDefaultPercent()).toInt(); + settings.endGroup(); + + if (!getSteps().contains(percent)) { + percent = getDefaultPercent(); + } + apply(percent); +} + +QList +PlotSize::getActions() const +{ + return m_group->actions(); +} + +int +PlotSize::getDefaultPercent() +{ +#ifdef Q_OS_ANDROID + return 150; +#else + return 100; +#endif +} + +void +PlotSize::setPercent(int percent) +{ + if (!getSteps().contains(percent)) return; + + apply(percent); + + QSettings settings; + settings.beginGroup(settingsGroup); + settings.setValue(settingsKey, percent); + settings.endGroup(); +} + +void +PlotSize::apply(int percent) +{ + m_percent = percent; + + for (QAction *action : m_group->actions()) { + if (action->data().toInt() == percent) action->setChecked(true); + } + + if (m_viewManager) { + m_viewManager->setPlotScale(percent / 100.0); + } +} diff --git a/main/PlotSize.h b/main/PlotSize.h new file mode 100644 index 00000000..763dc93f --- /dev/null +++ b/main/PlotSize.h @@ -0,0 +1,70 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TONY_PLOT_SIZE_H +#define TONY_PLOT_SIZE_H + +#include +#include + +class QAction; +class QActionGroup; + +namespace sv { +class ViewManager; +} + +/** + * How large the panes draw the pitch tracks, the live dots and the + * notes, as View > Plot Size sets it: 100%, 150% or 200% of their + * normal size in logical pixels. It is the view manager's plot scale + * (svgui fork), which the panes apply together with the screen's pixel + * ratio, so the steps look alike on a phone and on a desktop. Chosen, + * a step takes effect at once and is remembered in the settings. + * + * The default is 150% on Android, where a thin line on a small screen + * held at arm's length is hard to follow, and 100% elsewhere, where the + * panes then draw exactly as they always have. + */ +class PlotSize : public QObject +{ + Q_OBJECT + +public: + /// Reads the setting and applies it to the view manager + PlotSize(sv::ViewManager *viewManager, QObject *parent); + + /// The checkable steps, one of them checked, for a submenu + QList getActions() const; + + int getPercent() const { return m_percent; } + + static QList getSteps() { return { 100, 150, 200 }; } + + /// 150 on Android, 100 elsewhere + static int getDefaultPercent(); + +public slots: + /// Applies and remembers a step; anything else is ignored + void setPercent(int percent); + +private: + void apply(int percent); + + sv::ViewManager *m_viewManager; + QActionGroup *m_group; + int m_percent; +}; + +#endif diff --git a/main/PopupArea.cpp b/main/PopupArea.cpp new file mode 100644 index 00000000..fd9d26db --- /dev/null +++ b/main/PopupArea.cpp @@ -0,0 +1,90 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#include "PopupArea.h" + +#include +#include + +QRect +PopupArea::usable(QRect available, QRect window, QMargins safeArea, + int edgeMargin) +{ + if (!available.isValid()) return available; + + QRect area = available; + if (!window.isNull()) { + area &= window.marginsRemoved(safeArea); + } + + edgeMargin = std::max(0, edgeMargin); + area.setTop(std::max(area.top(), available.top() + edgeMargin)); + area.setBottom(std::min(area.bottom(), available.bottom() - edgeMargin)); + + if (!area.isValid()) return available; + return area; +} + +int +PopupArea::menuFrame(QRect available, QRect usable, int base) +{ + int frame = std::max(0, base); + if (!available.isValid() || !usable.isValid()) return frame; + + frame = std::max(frame, usable.left() - available.left()); + frame = std::max(frame, usable.top() - available.top()); + frame = std::max(frame, available.right() - usable.right()); + frame = std::max(frame, available.bottom() - usable.bottom()); + return frame; +} + +QRect +PopupArea::fit(QRect rect, QRect usable) +{ + if (!usable.isValid()) return rect; + + rect.setWidth(std::min(rect.width(), usable.width())); + rect.setHeight(std::min(rect.height(), usable.height())); + + if (rect.right() > usable.right()) rect.moveRight(usable.right()); + if (rect.left() < usable.left()) rect.moveLeft(usable.left()); + if (rect.bottom() > usable.bottom()) rect.moveBottom(usable.bottom()); + if (rect.top() < usable.top()) rect.moveTop(usable.top()); + return rect; +} + +QRect +PopupArea::place(QSize size, QRect usable, QPoint topLeft, bool centre) +{ + QRect rect(topLeft, size); + if (centre && usable.isValid()) { + rect.moveTopLeft(QPoint(usable.left() + (usable.width() - size.width()) / 2, + usable.top() + (usable.height() - size.height()) / 2)); + } + return fit(rect, usable); +} + +int +PopupArea::pixels(double mm, double dotsPerInch) +{ + if (mm <= 0 || dotsPerInch <= 0) return 0; + return int(std::lround(mm * dotsPerInch / 25.4)); +} + +int +PopupArea::fingerWidth(double dotsPerInch) +{ + if (dotsPerInch < 100 || dotsPerInch > 300) dotsPerInch = 160; + return pixels(8, dotsPerInch); +} diff --git a/main/PopupArea.h b/main/PopupArea.h new file mode 100644 index 00000000..28c6f11c --- /dev/null +++ b/main/PopupArea.h @@ -0,0 +1,88 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TONY_POPUP_AREA_H +#define TONY_POPUP_AREA_H + +#include +#include + +/** + * Where on a phone's screen menus and other popups may go. + * + * Qt places a popup inside the screen's available geometry, and on + * Android that is the whole of the activity, system bars included: from + * Android 15 on an app draws edge to edge, under the status bar, the + * navigation bar and the camera's cutout, and is told of them only as + * its windows' safe area margins (QWindow::safeAreaMargins()), which + * the main window's layout keeps clear of and a popup's placement does + * not. A tap on a menu item under the status bar goes to the status + * bar. Plain arithmetic, for TouchMenuStyle, which applies it. + */ +class PopupArea +{ +public: + /** + * The part of available (the screen's available geometry) a popup + * may cover: inside window (the application's main window, in the + * same coordinates) less safeArea, its safe area margins, and at + * least edgeMargin from the top and bottom of available, where a + * finger does not reach well. A null window stands for all of + * available. Never empty: if the margins leave nothing, available. + */ + static QRect usable(QRect available, QRect window, QMargins safeArea, + int edgeMargin); + + /** + * The margin QMenu keeps between a menu and every side of the screen + * (the style's PM_MenuDesktopFrameWidth, one number for all four + * sides) for its menus to lie inside usable: the widest of the four + * gaps between available and usable, and at least base, the style's + * own. QMenu places, sizes and scrolls a menu by that margin alone, + * so a menu is kept inside by it rather than moved after it is + * placed, which would leave QMenu's scrolling out of step. + */ + static int menuFrame(QRect available, QRect usable, int base); + + /** + * rect moved, and shortened or narrowed where it has to be, to lie + * inside usable: for a combo box's list, which scrolls whatever its + * height. + */ + static QRect fit(QRect rect, QRect usable); + + /** + * Where a dialog of size goes inside usable: centred in it when it + * is being shown afresh (centre), else where topLeft puts it, as the + * user may have moved it; and then fit() into usable. Qt centres a + * dialog over its parent inside the screen's available geometry, + * which on a phone takes in the system bars. + */ + static QRect place(QSize size, QRect usable, QPoint topLeft, bool centre); + + /** + * A length in pixels: mm millimetres at dotsPerInch. + */ + static int pixels(double mm, double dotsPerInch); + + /** + * About a finger's width, 8 mm, in Qt's pixels on a phone whose + * screen has dotsPerInch of them to the inch. That is near 160 + * (Android's dp, which Qt's pixels are there); a figure far from it, + * as some phones report, is taken to be 160. + */ + static int fingerWidth(double dotsPerInch); +}; + +#endif diff --git a/main/RealtimePitchTracker.cpp b/main/RealtimePitchTracker.cpp new file mode 100644 index 00000000..c4100f67 --- /dev/null +++ b/main/RealtimePitchTracker.cpp @@ -0,0 +1,284 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#include "RealtimePitchTracker.h" +#include "OctaveSlips.h" + +#include "data/model/WritableWaveFileModel.h" +#include "data/model/Model.h" + +#include "bqfft/FFT.h" + +#include +#include +#include + +using namespace sv; +using namespace breakfastquay; +using std::cerr; +using std::endl; +using std::vector; + +RealtimePitchTracker::RealtimePitchTracker(ModelId audioSourceId, + QObject *parent) + : QThread(parent), + m_audioSourceId(audioSourceId), + m_minFreq(60.0), + m_maxFreq(1000.0), + m_threshold(0.15), + m_minLevel(kMinLevel), + m_framesAnalysed(0) +{ +} + +RealtimePitchTracker::~RealtimePitchTracker() +{ + stop(); +} + +void +RealtimePitchTracker::start() +{ + QThread::start(); +} + +void +RealtimePitchTracker::stop() +{ + requestInterruption(); + wait(); +} + +RealtimePitchTracker::Estimates +RealtimePitchTracker::takeEstimates() +{ + Estimates taken; + std::lock_guard guard(m_estimatesMutex); + taken.swap(m_estimates); + return taken; +} + +void +RealtimePitchTracker::run() +{ + FFT *fft = nullptr; + sv_frame_t nextFrameToProcess = 0; + OctaveSlips slips; + vector passed; + + cerr << "RealtimePitchTracker: background thread started" << endl; + + while (!isInterruptionRequested()) { + + auto audioModel = ModelById::getAs(m_audioSourceId); + if (!audioModel) { + msleep(5); + continue; + } + + sv_frame_t totalFrames = audioModel->getFrameCount(); + if (totalFrames < kWindowSize) { + msleep(5); + continue; + } + + double sr = audioModel->getSampleRate(); + int channels = audioModel->getChannelCount(); + + if (!fft) { + fft = new FFT(kWindowSize); + } + + int minLag = std::max(1, (int)std::floor(sr / m_maxFreq)); + int maxLag = std::min(kWindowSize / 2 - 2, (int)std::ceil(sr / m_minFreq)); + + bool processedAny = false; + + while (nextFrameToProcess + kWindowSize <= totalFrames) { + + // Channel -1 is the mixdown of all channels, which is also + // what the pYIN transform is given: the microphone need not + // be on the first input of the interface. + floatvec_t rawFv = audioModel->getData(-1, nextFrameToProcess, kWindowSize); + + if ((int)rawFv.size() < kWindowSize) break; + + vector raw(rawFv.begin(), rawFv.end()); + + double lagSamples = -1.0; + if (level(raw.data(), kWindowSize / 2, channels) >= m_minLevel) { + vector diff; + yinDifferenceFFT(raw, diff, fft); + yinCMND(diff); + lagSamples = yinFindPitch(diff, minLag, maxLag, m_threshold); + } + + double hz = 0.0; + if (lagSamples > 0.0) { + hz = sr / lagSamples; + if (hz < m_minFreq || hz > m_maxFreq) hz = 0.0; + } + + // Every hop, voiced or not: a run an octave off is held back + // until what follows it says whether it slipped + sv_frame_t centreFrame = nextFrameToProcess + kWindowSize / 2; + passed.clear(); + slips.push(centreFrame, hz, passed); + if (!passed.empty()) { + std::lock_guard guard(m_estimatesMutex); + for (const OctaveSlips::Dot &dot : passed) { + m_estimates.push_back({ dot.frame, dot.hz }); + } + processedAny = true; + } + + m_framesAnalysed = nextFrameToProcess + kWindowSize; + nextFrameToProcess += kHopSize; + } + + // Sleep only when no new hops were available; otherwise spin + // immediately to drain any remaining frames. + if (!processedAny) { + msleep(5); + } + } + + delete fft; + cerr << "RealtimePitchTracker: background thread stopped" << endl; +} + +double +RealtimePitchTracker::level(const float *mixdown, int count, int channels) +{ + if (!mixdown || count < 1 || channels < 1) return -200.0; + double sum = 0.0; + for (int i = 0; i < count; ++i) { + double x = double(mixdown[i]) / channels; + sum += x * x; + } + double rms = std::sqrt(sum / double(count)); + if (rms <= 0.0) return -200.0; + return std::max(-200.0, 20.0 * std::log10(rms)); +} + +// --------------------------------------------------------------------------- +// YIN algorithm — FFT-accelerated difference function +// Reference: de Cheveigné & Kawahara, "YIN, a fundamental frequency +// estimator for speech and music", JASA 111(4), 2002. +// --------------------------------------------------------------------------- + +void +RealtimePitchTracker::yinDifferenceFFT(const vector &buf, + vector &diff, + FFT *fft) +{ + int frameSize = (int)buf.size(); + int halfSize = frameSize / 2; + int fftBins = halfSize + 1; + + // --- Power terms (iterative, O(n)) --- + vector powerTerms(halfSize); + powerTerms[0] = 0.0; + for (int j = 0; j < halfSize; ++j) + powerTerms[0] += double(buf[j]) * double(buf[j]); + for (int tau = 1; tau < halfSize; ++tau) { + powerTerms[tau] = powerTerms[tau-1] + - double(buf[tau-1]) * double(buf[tau-1]) + + double(buf[tau + halfSize]) * double(buf[tau + halfSize]); + } + + // --- Forward FFT of the full input --- + vector audioReal(fftBins), audioImag(fftBins); + fft->forward(buf.data(), audioReal.data(), audioImag.data()); + + // --- Kernel: reversed first half, zero-padded to frameSize --- + vector kernel(frameSize, 0.0f); + for (int j = 0; j < halfSize; ++j) + kernel[j] = buf[halfSize - 1 - j]; + vector kernelReal(fftBins), kernelImag(fftBins); + fft->forward(kernel.data(), kernelReal.data(), kernelImag.data()); + + // --- Complex multiply --- + vector acfReal(fftBins), acfImag(fftBins); + for (int j = 0; j < fftBins; ++j) { + acfReal[j] = audioReal[j]*kernelReal[j] - audioImag[j]*kernelImag[j]; + acfImag[j] = audioReal[j]*kernelImag[j] + audioImag[j]*kernelReal[j]; + } + + // --- Inverse FFT --- + vector acfOut(frameSize); + fft->inverse(acfReal.data(), acfImag.data(), acfOut.data()); + + // bqfft inverse is unnormalized — divide by frameSize. + const double scale = 1.0 / frameSize; + + diff.assign(halfSize, 0.0); + for (int tau = 1; tau < halfSize; ++tau) { + diff[tau] = powerTerms[0] + powerTerms[tau] + - 2.0 * double(acfOut[tau + halfSize - 1]) * scale; + } +} + +void +RealtimePitchTracker::yinCMND(vector &diff) +{ + int halfSize = (int)diff.size(); + if (halfSize == 0) return; + + diff[0] = 1.0; + + double runningSum = 0.0; + for (int tau = 1; tau < halfSize; ++tau) { + runningSum += diff[tau]; + if (runningSum > 0.0) { + diff[tau] = diff[tau] * double(tau) / runningSum; + } else { + diff[tau] = 1.0; + } + } +} + +double +RealtimePitchTracker::yinFindPitch(const vector &cmnd, + int minLag, int maxLag, + double threshold) +{ + int halfSize = (int)cmnd.size(); + if (maxLag >= halfSize - 1) maxLag = halfSize - 2; + if (minLag < 1) minLag = 1; + if (minLag >= maxLag) return -1.0; + + int bestTau = -1; + for (int tau = minLag; tau <= maxLag; ++tau) { + if (cmnd[tau] < threshold) { + while (tau + 1 <= maxLag && cmnd[tau + 1] < cmnd[tau]) { + ++tau; + } + bestTau = tau; + break; + } + } + + if (bestTau < 1 || bestTau >= halfSize - 1) { + return -1.0; + } + + double s0 = cmnd[bestTau - 1]; + double s1 = cmnd[bestTau]; + double s2 = cmnd[bestTau + 1]; + + double denom = 2.0 * (2.0 * s1 - s2 - s0); + if (std::abs(denom) < 1e-12) return double(bestTau); + return double(bestTau) + (s2 - s0) / denom; +} diff --git a/main/RealtimePitchTracker.h b/main/RealtimePitchTracker.h new file mode 100644 index 00000000..cb2d8335 --- /dev/null +++ b/main/RealtimePitchTracker.h @@ -0,0 +1,179 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef REALTIME_PITCH_TRACKER_H +#define REALTIME_PITCH_TRACKER_H + +#include + +#include +#include +#include + +#include "base/BaseTypes.h" +#include "data/model/Model.h" + +namespace sv { +class WritableWaveFileModel; +} + +namespace breakfastquay { +class FFT; +} + +/** + * RealtimePitchTracker runs on a dedicated background QThread and + * continuously polls a WritableWaveFileModel for new audio samples, + * estimating pitch in real time using FFT-accelerated YIN. + * + * An estimate an octave from the ones either side of it is taken for + * YIN's octave slip and dropped (OctaveSlips): a run an octave off is + * held back until the hops after it say which it was. + * + * The estimates are kept until the GUI thread takes them, all at once + * (takeEstimates()): there is one for each hop, about 170 a second, and + * a signal for each would queue a call on the GUI thread that a slow + * GUI thread falls behind with for good (LiveDotsFeed). + * + * Usage: + * 1. Create a RealtimePitchTracker with the ModelId of the + * WritableWaveFileModel being recorded into. + * 2. Call start() — the background thread starts immediately. + * 3. Take what it has found with takeEstimates(), from any thread. + * 4. Call stop() when recording ends — blocks until the thread exits. + */ +class RealtimePitchTracker : public QThread +{ + Q_OBJECT + +public: + // Window size: 2048 samples @ 44100 Hz ≈ 46 ms. + // Hop size: 256 samples ≈ 5.8 ms. One estimate per hop, so this is + // also the resolution of the model the estimates go into. + static constexpr int kWindowSize = 2048; + static constexpr int kHopSize = 256; + + /** + * @param audioSourceId ModelId of the WritableWaveFileModel being + * recorded into. Polled from the background thread. + * @param parent Optional Qt parent (must live on GUI thread). + */ + RealtimePitchTracker(sv::ModelId audioSourceId, + QObject *parent = nullptr); + + virtual ~RealtimePitchTracker(); + + /** + * Start the background polling thread. + * Must be called from the GUI thread. + */ + void start(); + + /** + * Request the background thread to stop and block until it exits. + * Must be called from the GUI thread. + */ + void stop(); + + /** Minimum frequency (Hz) reported. Default: 60 Hz. */ + void setMinFrequency(double hz) { m_minFreq = hz; } + double getMinFrequency() const { return m_minFreq; } + + /** Maximum frequency (Hz) reported. Default: 1000 Hz. */ + void setMaxFrequency(double hz) { m_maxFreq = hz; } + double getMaxFrequency() const { return m_maxFreq; } + + /** YIN threshold (0–1). Default: 0.15. */ + void setThreshold(double t) { m_threshold = t; } + double getThreshold() const { return m_threshold; } + + /** + * The level a window must reach to be given a pitch, in dBFS: the + * level() of its first half, which YIN's difference function compares + * with the window further on, so that the pitch it finds is the first + * half's. The whole window's would let a window through whose second + * half reaches into a sound, with the pitch of the quiet before it. + * + * YIN is blind to level: it finds a pitch now and then in a quiet + * steady sound, as it did in the fans a user's microphone heard at + * -66.5 dBFS between the sounds, 25 dB below the quietest window of + * a tone it heard (docs/audio-drivers.md, §7). pYIN, as Tony runs + * it, penalises such soft pitches too. + */ + static constexpr double kMinLevel = -60.0; + + /** The level floor in dBFS. Default: kMinLevel. */ + void setMinLevel(double dbfs) { m_minLevel = dbfs; } + double getMinLevel() const { return m_minLevel; } + + /** + * The level of \a count frames of the mixdown of \a channels + * channels, in dBFS: the RMS of their average. The mixdown is their + * sum, so a microphone on two inputs would read 6 dB louder than on + * one; the average reads what each input has (and a microphone on + * one input of two 6 dB below it). Silence reads -200. + */ + static double level(const float *mixdown, int count, int channels); + + /** A voiced pitch estimate. */ + struct Estimate { + sv::sv_frame_t frame; ///< centre frame of the analysis window + double hz; ///< always > 0 + }; + typedef std::vector Estimates; + + /** + * The estimates found since the last call, oldest first; they are + * not kept any longer. Any thread. + */ + Estimates takeEstimates(); + + /** + * The frame of the recording the tracker has analysed up to: the + * end of its latest window, voiced or not. Any thread. + */ + sv::sv_frame_t getFramesAnalysed() const { return m_framesAnalysed; } + +protected: + /** The background polling loop — do not call directly. */ + void run() override; + +private: + friend class TestRealtimeYin; + + sv::ModelId m_audioSourceId; + + double m_minFreq; + double m_maxFreq; + double m_threshold; + double m_minLevel; + + std::mutex m_estimatesMutex; + Estimates m_estimates; + std::atomic m_framesAnalysed; + + // --- YIN helpers (all called only from run()) --- + + static void yinDifferenceFFT(const std::vector &buf, + std::vector &diff, + breakfastquay::FFT *fft); + + static void yinCMND(std::vector &diff); + + static double yinFindPitch(const std::vector &cmnd, + int minLag, int maxLag, + double threshold); +}; + +#endif // REALTIME_PITCH_TRACKER_H diff --git a/main/SingingTakes.cpp b/main/SingingTakes.cpp new file mode 100644 index 00000000..beb764cc --- /dev/null +++ b/main/SingingTakes.cpp @@ -0,0 +1,448 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#include "SingingTakes.h" + +#include "TakeAudio.h" + +#include +#include +#include +#include +#include +#include + +#include +#include + +using namespace sv; + +SingingTakes::SingingTakes(QObject *parent) : + QObject(parent), + m_active(-1), + m_named(0) +{ +} + +SingingTakes::~SingingTakes() +{ +} + +void +SingingTakes::clear() +{ + m_takes.clear(); + m_active = -1; + m_named = 0; + m_reserved.clear(); + m_superseded.clear(); + m_written.clear(); + m_protected.clear(); +} + +const SingingTakes::Take * +SingingTakes::activeTake() const +{ + if (m_active < 0 || m_active >= int(m_takes.size())) return nullptr; + return &m_takes[m_active]; +} + +SingingTakes::Take & +SingingTakes::takeForRecording() +{ + if (m_active < 0 || m_active >= int(m_takes.size())) { + // The first recording of a session records into a take of its own + // (spec 5.3), and so does one made after every take was deleted + addTake(); + } + return m_takes[m_active]; +} + +bool +SingingTakes::haveTake() const +{ + const Take *take = activeTake(); + return take && take->audioPath != ""; +} + +QString +SingingTakes::getAudioPath() const +{ + const Take *take = activeTake(); + return take ? take->audioPath : QString(); +} + +const Coverage & +SingingTakes::getCoverage() const +{ + static const Coverage empty; + const Take *take = activeTake(); + return take ? take->coverage : empty; +} + +QString +SingingTakes::getActiveName() const +{ + const Take *take = activeTake(); + return take ? take->name : QString(); +} + +QStringList +SingingTakes::getTakeNames() const +{ + QStringList names; + for (const Take &take : m_takes) names.push_back(take.name); + return names; +} + +int +SingingTakes::indexOf(QString name) const +{ + for (int i = 0; i < int(m_takes.size()); ++i) { + if (m_takes[i].name == name) return i; + } + return -1; +} + +const SingingTakes::Take * +SingingTakes::getTake(int index) const +{ + if (index < 0 || index >= int(m_takes.size())) return nullptr; + return &m_takes[index]; +} + +bool +SingingTakes::setActiveIndex(int index) +{ + if (index < 0 || index >= int(m_takes.size())) return false; + m_active = index; + return true; +} + +QString +SingingTakes::addTake(QString name) +{ + Take take; + + if (name != "" && indexOf(name) < 0) { + take.name = name; + } else { + // "Take N" with an N this session has not used, however many takes + // have been deleted since: the name is the take's identity, and + // the layers of a deleted take may still be in the document. Not + // translated: the names of the take's layers are built from it and + // stored in the session file + do { + take.name = QString("Take %1").arg(++m_named); + } while (indexOf(take.name) >= 0 || m_reserved.contains(take.name)); + } + + m_takes.push_back(take); + m_active = int(m_takes.size()) - 1; + return take.name; +} + +QString +SingingTakes::duplicateActiveTake(QString name) +{ + const Take *from = activeTake(); + if (!from) return ""; + + // Copied before addTake(), which may make the vector move + QString path = from->audioPath; + Coverage coverage = from->coverage; + + QString made = addTake(name); + m_takes[m_active].audioPath = path; + m_takes[m_active].coverage = coverage; + + // The audio file is not copied and not superseded: the two takes read + // the same one until one of them is recorded into or erased from, + // which writes a new file anyway (spec 5.4) + return made; +} + +bool +SingingTakes::renameTake(int index, QString name) +{ + if (index < 0 || index >= int(m_takes.size())) return false; + + name = name.trimmed(); + if (name == "") return false; + + int existing = indexOf(name); + if (existing >= 0 && existing != index) return false; + + m_takes[index].name = name; + return true; +} + +void +SingingTakes::reserveTakeName(QString name) +{ + if (name == "") return; + + if (!m_reserved.contains(name)) m_reserved.push_back(name); + + // A session that had a "Take 7" in it goes on at "Take 8", however + // many of the takes before it have been deleted since: the default + // names carry on where the session left off rather than starting again + // at the first number no take happens to be using + static const QRegularExpression pattern("^Take (\\d+)$"); + QRegularExpressionMatch match = pattern.match(name); + if (match.hasMatch()) { + int number = match.captured(1).toInt(); + if (number > m_named) m_named = number; + } +} + +bool +SingingTakes::removeTake(int index) +{ + if (index < 0 || index >= int(m_takes.size())) return false; + + m_takes.erase(m_takes.begin() + index); + + if (m_takes.empty()) { + m_active = -1; + } else if (index < m_active) { + --m_active; + } else if (index == m_active) { + // A neighbour takes over: the one before, or the first if this + // was it + m_active = (index > 0 ? index - 1 : 0); + } + + return true; +} + +void +SingingTakes::setTake(QString path, const Coverage &coverage) +{ + Take &take = takeForRecording(); + if (take.audioPath != "" && take.audioPath != path) { + m_superseded.push_back(take.audioPath); + } + take.audioPath = path; + take.coverage = coverage; +} + +void +SingingTakes::restoreTake(QString path, const Coverage &coverage) +{ + Take &take = takeForRecording(); + take.audioPath = path; + take.coverage = coverage; +} + +void +SingingTakes::relocateTake(int index, QString path) +{ + if (index < 0 || index >= int(m_takes.size())) return; + if (path == "" || m_takes[index].audioPath == path) return; + + m_takes[index].audioPath = path; + if (!m_written.contains(path)) m_written.push_back(path); +} + +void +SingingTakes::protectPath(QString path) +{ + if (path != "" && !m_protected.contains(path)) m_protected.push_back(path); +} + +QStringList +SingingTakes::unusedWrittenFiles() const +{ + QStringList inUse; + for (const Take &take : m_takes) { + if (take.audioPath != "") inUse.push_back(take.audioPath); + } + + QStringList unused; + for (const QString &path : m_written) { + if (inUse.contains(path)) continue; + if (m_protected.contains(path)) continue; + if (unused.contains(path)) continue; + unused.push_back(path); + } + return unused; +} + +QStringList +SingingTakes::removeUnusedFiles() +{ + QStringList gone; + for (const QString &path : unusedWrittenFiles()) { + if (QFile::remove(path)) { + gone.push_back(path); + m_written.removeAll(path); + } else if (!QFile::exists(path)) { + // Something else has taken it away; it is not ours any more + m_written.removeAll(path); + } + } + return gone; +} + +void +SingingTakes::setWholeFileTake(QString path, sv_frame_t frames) +{ + Coverage whole; + whole.add(0, frames); + setTake(path, whole); +} + +QString +SingingTakes::spliceRecording(QString recordingPath, + sv_frame_t recordingOffset, + sv_frame_t position, + sv_frame_t length, + QString directory, + Coverage::Range *placed, + sv_samplerate_t rate) +{ + QString outPath = nextAudioPath(directory); + if (outPath == "") { + return tr("Could not find a name to write the singing track under, " + "in \"%1\"").arg(directory); + } + + // A recording at another rate than the take's is converted first, + // into a folder of its own beside the take's files that goes again, + // with what is in it, as soon as the splice has read it + QString source = recordingPath; + std::unique_ptr scratch; + if (rate > 0 && TakeAudio::sampleRate(recordingPath) != rate) { + scratch.reset(new QTemporaryDir + (QDir(directory).filePath("resampling-XXXXXX"))); + if (!scratch->isValid()) { + return tr("Could not make a folder to convert the recording in, " + "in \"%1\"").arg(directory); + } + source = scratch->filePath("recording.wav"); + QString error = TakeAudio::resample(recordingPath, rate, source); + if (error != "") return error; + } + + Take &take = takeForRecording(); + + Coverage::Range range; + QString error = TakeAudio::splice(take.audioPath, source, + recordingOffset, position, length, + outPath, &range); + if (error != "") return error; + + if (take.audioPath != "") m_superseded.push_back(take.audioPath); + take.audioPath = outPath; + m_written.push_back(outPath); + take.coverage.add(range.start, range.end); + + if (placed) *placed = range; + return ""; +} + +QString +SingingTakes::eraseRanges(const Coverage::Ranges &ranges, QString directory, + Coverage::Ranges *erased) +{ + if (erased) erased->clear(); + + // Only what holds singing can be erased. Silence that was never + // recorded is not ours to rewrite, and a selection that runs past + // the end of the singing must not make the file any longer + Coverage wanted; + for (const Coverage::Range &r : ranges) { + for (const Coverage::Range &covered : getCoverage().getRanges()) { + wanted.add(std::max(r.start, covered.start), + std::min(r.end, covered.end)); + } + } + // Nothing in the selection holds recorded singing, and there may be no + // take at all + if (wanted.isEmpty()) return ""; + + QString outPath = nextAudioPath(directory); + if (outPath == "") { + return tr("Could not find a name to write the singing track under, " + "in \"%1\"").arg(directory); + } + + Take &take = takeForRecording(); + + QString error = TakeAudio::erase(take.audioPath, wanted.getRanges(), + outPath); + if (error != "") return error; + + m_superseded.push_back(take.audioPath); + take.audioPath = outPath; + m_written.push_back(outPath); + for (const Coverage::Range &r : wanted.getRanges()) { + take.coverage.remove(r.start, r.end); + } + + if (erased) *erased = wanted.getRanges(); + return ""; +} + +bool +SingingTakes::coversPosition(sv_frame_t position) const +{ + return getCoverage().contains(position); +} + +bool +SingingTakes::shouldConfirmRecordingAt(sv_frame_t position) const +{ + return coversPosition(position) && isOverwriteConfirmationWanted(); +} + +bool +SingingTakes::isOverwriteConfirmationWanted() +{ + QSettings settings; + settings.beginGroup("MainWindow"); + bool wanted = settings.value("confirmrecordover", true).toBool(); + settings.endGroup(); + return wanted; +} + +void +SingingTakes::setOverwriteConfirmationWanted(bool wanted) +{ + QSettings settings; + settings.beginGroup("MainWindow"); + settings.setValue("confirmrecordover", wanted); + settings.endGroup(); +} + +QString +SingingTakes::nextAudioPath(QString directory) +{ + QDir dir(directory); + + // The time of day is enough to tell one from the next within a + // session; the counter is there because it need only be enough. + // ":" is not allowed in a file name on Windows + QString stamp = QDateTime::currentDateTime().toString("yyyyMMdd-HHmmss-zzz"); + + for (int i = 0; i < 1000; ++i) { + QString name = (i == 0 ? + QString("take-%1.wav").arg(stamp) : + QString("take-%1-%2.wav").arg(stamp).arg(i)); + if (!dir.exists(name)) return dir.filePath(name); + } + + return ""; +} diff --git a/main/SingingTakes.h b/main/SingingTakes.h new file mode 100644 index 00000000..09586557 --- /dev/null +++ b/main/SingingTakes.h @@ -0,0 +1,295 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TONY_SINGING_TAKES_H +#define TONY_SINGING_TAKES_H + +#include "Coverage.h" + +#include "base/BaseTypes.h" + +#include +#include +#include + +#include + +/** + * The state of the singing takes of a session: for each take, the audio + * file its singing lives in and the ranges of that file that hold + * recorded material. One take is the active one, and the single-take + * calls below -- getAudioPath(), spliceRecording() and the rest -- are + * all about that one. + * + * It knows nothing of layers, models or windows: MainWindow asks it + * where a recording is to go and what the take is afterwards, and puts + * the answer on the screen itself. A take's identity on the screen is + * its name, which is what the names of its layers are built from; no + * layer or model is named here. The audio files are written by + * TakeAudio. + */ +class SingingTakes : public QObject +{ + Q_OBJECT + +public: + SingingTakes(QObject *parent = nullptr); + virtual ~SingingTakes(); + + /** + * One take: its name, the audio file that holds its singing (empty + * before its first recording) and the ranges of that file that hold + * recorded material. + */ + struct Take { + QString name; + QString audioPath; + Coverage coverage; + }; + + typedef std::vector Takes; + + /// There is an active take, with an audio file to play and analyse + bool haveTake() const; + + QString getAudioPath() const; + const Coverage &getCoverage() const; + + /// Nothing recorded and nothing to go back to: a new session + void clear(); + + // --- The takes of the session (spec 5.3) --- + + const Takes &getTakes() const { return m_takes; } + int getTakeCount() const { return int(m_takes.size()); } + + /// Which take is the active one, or -1 when there is no take at all + int getActiveIndex() const { return m_active; } + + /// The name of the active take, or "" when there is none + QString getActiveName() const; + + QStringList getTakeNames() const; + + /// The take of this name, or -1 + int indexOf(QString name) const; + + /// The take at this index, or null + const Take *getTake(int index) const; + + /// Make the take at this index the active one. False if there is none + bool setActiveIndex(int index); + + /** + * Add a take with no audio yet and make it the active one; the + * return is its name. With no name given it is called "Take N", + * with an N that no take of this session has had. + */ + QString addTake(QString name = ""); + + /** + * Add a take that holds the same audio file and coverage as the + * active one, and make it the active one; the return is its name, or + * "" if there was no take to copy. The audio file is shared: both + * takes write a new one before they change anything in it. + */ + QString duplicateActiveTake(QString name = ""); + + /** + * Rename the take at this index. False if there is no such take, if + * the name is empty, or if another take has it already. + */ + bool renameTake(int index, QString name); + + /** + * Do not give this name to a take that is named by default, although + * no take of the session has it: the layers of a take that a session + * held and that this session has not taken up are in the document + * under it. A name asked for by name is still given. + * + * A name of the "Take N" form also carries the numbering on: after + * "Take 7" has been reserved the next take named by default is "Take + * 8", whatever has happened to the takes before it. + */ + void reserveTakeName(QString name); + + /** + * Forget the take at this index; no audio file is touched. If it was + * the active one, the take before it becomes active, or the one after + * it if it was the first, or there is no active take left. False if + * there is no such take. + */ + bool removeTake(int index); + + /** + * The active take is this file, with the coverage a session kept for + * it in its coverage strip. With no take at all, one is added. + */ + void setTake(QString path, const Coverage &coverage); + + /** + * The take is this file with this coverage again, because an undo or + * a redo has said so. Unlike setTake(), nothing is added to the + * superseded list: both files were already known when the operation + * being undone was done, and undo and redo may swap between them any + * number of times. An empty path is the state before the first + * recording of a take: no take at all. + */ + void restoreTake(QString path, const Coverage &coverage); + + /** + * The audio of the take at this index is this file instead: a copy of + * the same sound somewhere else, made because the session was saved + * and its takes' audio belongs beside it (spec 6.4, TakesFile). + * + * The file the take had is not superseded -- nothing about the take + * has changed, and the audio model that is showing it goes on + * reading it -- but it is no longer the take's, so the cleanup on + * close may take it away if this run wrote it and no saved session + * names it. The copy counts as a file this run wrote, for the same + * reason. + */ + void relocateTake(int index, QString path); + + /** + * The take is the whole of this file: a singing track the user + * loaded, or one restored from a session saved before coverage was + * stored with it. + */ + void setWholeFileTake(QString path, sv::sv_frame_t frames); + + /** + * Write the next audio file of the take, with the recording in + * recordingPath spliced into it at "position" on the reference's + * timeline. The recording is used from its frame recordingOffset + * on (the latency: what was recorded before the singer could have + * heard the reference at "position"), for "length" frames, or to + * its end if length is negative. The file is written into + * directory, under a name that nothing else is using. + * + * The take's audio is at the given rate, the reference's, so that + * its frames are the reference's frames. A recording at any other + * rate -- from a device that runs at 48 kHz, as phones do -- is + * converted to it before it goes in, and recordingOffset and length + * count frames of it as converted. With a rate of 0 the recording + * goes in at its own rate. + * + * On success returns "" and the take's audio is the new file, its + * coverage takes in what was recorded, and the file before is + * remembered as superseded. Otherwise the take is as it was and + * the return is a message for the user. + */ + QString spliceRecording(QString recordingPath, + sv::sv_frame_t recordingOffset, + sv::sv_frame_t position, + sv::sv_frame_t length, + QString directory, + Coverage::Range *placed = nullptr, + sv::sv_samplerate_t rate = 0); + + /** + * Write the next audio file of the take with the given ranges made + * silent. The ranges are clipped to the take's coverage first: + * silence that was never recorded holds nothing to erase. The file + * is written into directory, under a name that nothing else is + * using; it is as long as the file it came from. + * + * On success returns "" and the take's audio is the new file, with + * the erased ranges gone from its coverage and the file before + * remembered as superseded. "erased", if it is not null, receives + * the ranges that were taken out: empty means that nothing of what + * was asked for held recorded singing, and nothing was done at all. + * On failure the take is exactly as it was and the return is a + * message for the user. + */ + QString eraseRanges(const Coverage::Ranges &ranges, + QString directory, + Coverage::Ranges *erased = nullptr); + + /** + * The audio files of takes that later files have replaced during + * this run. They are kept until the session closes, for undo. + */ + const QStringList &getSupersededPaths() const { return m_superseded; } + + /** + * The audio files this run wrote itself, oldest first. A file the + * user loaded as a singing track is not one of them, however + * thoroughly it has since been superseded, so this is the list Tony + * may delete from (see removeUnusedFiles()). + */ + const QStringList &getWrittenPaths() const { return m_written; } + + /** + * Keep this file whatever happens, because something outside this + * object refers to it: a session file that has been saved names the + * take's audio as it was at the time, and that file must still be + * there when the session is opened again. + */ + void protectPath(QString path); + + /** + * The files this run wrote that nothing refers to any more: every + * one but the audio of a take -- any take, since a duplicate shares + * its file with the take it was made from -- and the protected ones. + * Files the user brought are never in the list. + */ + QStringList unusedWrittenFiles() const; + + /** + * Delete the files unusedWrittenFiles() names and return those that + * really went. For the close of a session: until then a superseded + * file may be wanted again by undo. + */ + QStringList removeUnusedFiles(); + + /// Recording from this frame on would record over material that is there + bool coversPosition(sv::sv_frame_t position) const; + + /// ... and the user has not asked to stop being asked about it + bool shouldConfirmRecordingAt(sv::sv_frame_t position) const; + + /// The state of "Don't ask again" in the overwrite question + static bool isOverwriteConfirmationWanted(); + static void setOverwriteConfirmationWanted(bool wanted); + + /** + * A path in directory for the next audio file of a take, under a + * name that no file there has. Empty if every name tried was + * taken. + */ + static QString nextAudioPath(QString directory); + +private: + Takes m_takes; + int m_active; + + // Counts the takes this session has named, so that a name is never + // used twice even after the take that had it has gone + int m_named; + + // Names no take has, that are not to be given to one all the same + QStringList m_reserved; + + QStringList m_superseded; + QStringList m_written; + QStringList m_protected; + + // The active take, or null. The non-const one adds a take if there + // is none: the first recording of a session makes "Take 1" + const Take *activeTake() const; + Take &takeForRecording(); +}; + +#endif diff --git a/main/SongScroll.cpp b/main/SongScroll.cpp new file mode 100644 index 00000000..6cf16051 --- /dev/null +++ b/main/SongScroll.cpp @@ -0,0 +1,164 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#include "SongScroll.h" + +#include +#include + +using namespace sv; + +namespace SongScroll +{ + +double +xForFrame(sv_frame_t frame, sv_frame_t songFrames, double width) +{ + if (songFrames <= 0 || width <= 0.0) return 0.0; + return double(frame) * width / double(songFrames); +} + +sv_frame_t +frameForX(double x, sv_frame_t songFrames, double width) +{ + if (songFrames <= 0 || width <= 0.0) return 0; + return sv_frame_t(std::llround(x * double(songFrames) / width)); +} + +sv_frame_t +clampToSong(sv_frame_t frame, sv_frame_t songFrames) +{ + if (songFrames <= 0 || frame < 0) return 0; + if (frame >= songFrames) return songFrames - 1; + return frame; +} + +Thumb +thumb(sv_frame_t start, sv_frame_t end, sv_frame_t songFrames, + double width, double minWidth) +{ + Thumb t; + if (songFrames <= 0 || width <= 0.0) return t; + + // What of the strip the panes cover: nothing of what they show + // before the song or after it + t.x0 = std::clamp(xForFrame(start, songFrames, width), 0.0, width); + t.x1 = std::clamp(xForFrame(end, songFrames, width), 0.0, width); + if (t.x1 < t.x0) t.x1 = t.x0; + + // Wide enough to see and to catch, about the same middle, and + // within the strip + minWidth = std::min(minWidth, width); + if (t.width() < minWidth) { + double middle = (t.x0 + t.x1) / 2.0; + t.x0 = middle - minWidth / 2.0; + t.x1 = middle + minWidth / 2.0; + if (t.x0 < 0.0) { + t.x1 -= t.x0; + t.x0 = 0.0; + } + if (t.x1 > width) { + t.x0 -= t.x1 - width; + t.x1 = width; + } + } + return t; +} + +bool +hitsThumb(const Thumb &thumb, double x) +{ + return x >= thumb.x0 && x <= thumb.x1; +} + +sv_frame_t +dragCentre(sv_frame_t grabbedCentre, double grabX, double x, + sv_frame_t songFrames, double width) +{ + if (songFrames <= 0 || width <= 0.0) return 0; + double moved = (x - grabX) * double(songFrames) / width; + return clampToSong(grabbedCentre + sv_frame_t(std::llround(moved)), + songFrames); +} + +sv_frame_t +jumpCentre(double x, sv_frame_t songFrames, double width) +{ + return clampToSong(frameForX(x, songFrames, width), songFrames); +} + +std::vector +pitchColumns(const std::vector> &events, + sv_frame_t resolution, sv_frame_t songFrames, int columns) +{ + std::vector result(std::max(columns, 0)); + if (columns <= 0 || songFrames <= 0) return result; + if (resolution < 1) resolution = 1; + + // The column that shows frame, which is within the song + auto columnOf = [&](sv_frame_t frame) { + return int(std::min(frame * sv_frame_t(columns) / songFrames, + sv_frame_t(columns - 1))); + }; + + for (const auto &event : events) { + double value = event.second; + if (!(value > 0.0)) continue; + + sv_frame_t first = event.first; + sv_frame_t last = first + resolution - 1; + if (last < 0 || first >= songFrames) continue; + first = std::max(first, sv_frame_t(0)); + last = std::min(last, songFrames - 1); + + for (int c = columnOf(first); c <= columnOf(last); ++c) { + Column &column = result[c]; + if (column.isEmpty()) { + column.low = column.high = value; + } else { + column.low = std::min(column.low, value); + column.high = std::max(column.high, value); + } + } + } + return result; +} + +bool +pitchRange(const std::vector &columns, double &low, double &high) +{ + bool found = false; + for (const Column &column : columns) { + if (column.isEmpty()) continue; + if (!found) { + low = column.low; + high = column.high; + found = true; + } else { + low = std::min(low, column.low); + high = std::max(high, column.high); + } + } + return found; +} + +double +yForPitch(double hz, double low, double high, double height) +{ + if (!(low > 0.0) || !(high > low) || !(hz > 0.0)) return height / 2.0; + double along = std::log(hz / low) / std::log(high / low); + return (1.0 - std::clamp(along, 0.0, 1.0)) * height; +} + +} diff --git a/main/SongScroll.h b/main/SongScroll.h new file mode 100644 index 00000000..d6738cd9 --- /dev/null +++ b/main/SongScroll.h @@ -0,0 +1,113 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TONY_SONG_SCROLL_H +#define TONY_SONG_SCROLL_H + +#include "base/BaseTypes.h" + +#include +#include + +/** + * The arithmetic of the song scroll bar (SongScrollBar): a strip that + * shows the whole song, frame 0 at its left edge and the song's end at + * its right, with a thumb for what the panes show. Where a frame is on + * the strip and back, the thumb's geometry, where a drag or a press + * puts the panes' centre, and the contour of the reference's pitch + * drawn along it. + * + * Positions are in pixels from the strip's left edge, and may be + * fractional. Nothing here touches a widget, so all of it is tested + * without a window (TestSongScroll). + */ +namespace SongScroll +{ + /// The x of frame on a strip width pixels wide showing songFrames + double xForFrame(sv::sv_frame_t frame, sv::sv_frame_t songFrames, + double width); + + /// The frame at x: xForFrame()'s inverse, rounded to a frame + sv::sv_frame_t frameForX(double x, sv::sv_frame_t songFrames, + double width); + + /// frame, or the song's first or last frame if it is outside it + sv::sv_frame_t clampToSong(sv::sv_frame_t frame, + sv::sv_frame_t songFrames); + + struct Thumb { + double x0 = 0.0; + double x1 = 0.0; + double width() const { return x1 - x0; } + }; + + /** + * The thumb for panes that show the frames start to end: the part of + * the strip they cover, cut to the strip, then at least minWidth + * wide (or the strip's width if that is less) and moved back within + * the strip if that took it over an edge. The panes may show more + * than the song, and then the thumb is the whole strip. + */ + Thumb thumb(sv::sv_frame_t start, sv::sv_frame_t end, + sv::sv_frame_t songFrames, double width, double minWidth); + + /// Whether a press at x is on the thumb, its edges included + bool hitsThumb(const Thumb &thumb, double x); + + /** + * The centre a drag asks for: the panes' centre when the strip was + * grabbed at grabX, moved by as many frames as the strip shows in + * the distance from grabX to x, and kept within the song. + */ + sv::sv_frame_t dragCentre(sv::sv_frame_t grabbedCentre, double grabX, + double x, sv::sv_frame_t songFrames, + double width); + + /// The centre a press away from the thumb asks for: the frame there + sv::sv_frame_t jumpCentre(double x, sv::sv_frame_t songFrames, + double width); + + /// The pitch in one column of the strip, in Hz: none if high is 0 + struct Column { + double low = 0.0; + double high = 0.0; + bool isEmpty() const { return !(high > 0.0); } + }; + + /** + * For each of the given number of columns across the strip, the + * lowest and highest of the pitches in the frames it shows. An event + * (frame, value) stands for the resolution frames from its own, as + * a pitch track's does, so that a column narrower than that is not + * left empty between two events. A value of 0 or less is no pitch. + * A column with no pitch in it, as where the pitch track has no + * events, is empty. + */ + std::vector pitchColumns + (const std::vector> &events, + sv::sv_frame_t resolution, sv::sv_frame_t songFrames, int columns); + + /// The lowest and highest pitch in the columns; false if all empty + bool pitchRange(const std::vector &columns, + double &low, double &high); + + /** + * Where hz goes on a log scale from low at height down to high at + * 0, for a band height pixels high: the middle if the range is a + * single pitch. + */ + double yForPitch(double hz, double low, double high, double height); +} + +#endif diff --git a/main/SongScrollBar.cpp b/main/SongScrollBar.cpp new file mode 100644 index 00000000..4866cc56 --- /dev/null +++ b/main/SongScrollBar.cpp @@ -0,0 +1,353 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#include "SongScrollBar.h" + +#include "view/Pane.h" +#include "view/PaneStack.h" +#include "view/ViewManager.h" +#include "data/model/SparseTimeValueModel.h" + +#include +#include +#include + +#include +#include +#include + +using namespace sv; + +SongScrollBar::SongScrollBar(ViewManager *viewManager, PaneStack *paneStack, + QWidget *parent) : + QWidget(parent), + m_viewManager(viewManager), + m_paneStack(paneStack) +{ + setObjectName("Song Scroll Bar"); + setFixedHeight(stripHeight); + setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + + // Long enough that pYIN's steps make a few contours a second, not + // one for each block it writes + m_contourTimer.setSingleShot(true); + m_contourTimer.setInterval(250); + connect(&m_contourTimer, &QTimer::timeout, + this, &SongScrollBar::contourTimedOut); + + m_checkTimer.setSingleShot(true); + m_checkTimer.setInterval(0); + connect(&m_checkTimer, &QTimer::timeout, + this, &SongScrollBar::checkPositions); + + if (viewManager) { + connect(viewManager, &ViewManager::globalCentreFrameChanged, + this, [this]() { scheduleCheck(); }); + connect(viewManager, qOverload + (&ViewManager::viewCentreFrameChanged), + this, [this]() { scheduleCheck(); }); + connect(viewManager, qOverload + (&ViewManager::viewZoomLevelChanged), + this, [this]() { scheduleCheck(); }); + // Every 20 ms while playing or recording, which is also how a + // page turned by playback is seen + connect(viewManager, &ViewManager::playbackFrameChanged, + this, [this]() { scheduleCheck(); }); + } +} + +SongScrollBar::~SongScrollBar() +{ +} + +QSize +SongScrollBar::sizeHint() const +{ + return QSize(200, stripHeight); +} + +void +SongScrollBar::setSongModel(ModelId model) +{ + if (model == m_songModel) return; + m_songModel = model; + m_contourStale = true; + update(); +} + +void +SongScrollBar::setPitchModel(ModelId model) +{ + if (!ModelById::isa(model)) model = {}; + if (model == m_pitchModel) return; + + if (auto previous = ModelById::get(m_pitchModel)) { + disconnect(previous.get(), nullptr, this, nullptr); + } + + m_pitchModel = model; + + // pYIN writes to the model from its own thread: these are queued. + // A model released without anyone saying so leaves no contour behind + if (auto pitch = ModelById::get(m_pitchModel)) { + connect(pitch.get(), &Model::modelChanged, + this, &SongScrollBar::pitchModelChanged); + connect(pitch.get(), &Model::modelChangedWithin, + this, &SongScrollBar::pitchModelChanged); + connect(pitch.get(), &QObject::destroyed, + this, &SongScrollBar::pitchModelChanged); + } + + m_contourTimer.stop(); + m_contourStale = true; + update(); +} + +sv_frame_t +SongScrollBar::getSongFrames() const +{ + auto model = ModelById::get(m_songModel); + if (!model) return 0; + return std::max(model->getEndFrame(), sv_frame_t(0)); +} + +Pane * +SongScrollBar::pane() const +{ + if (!m_paneStack || m_paneStack->getPaneCount() == 0) return nullptr; + return m_paneStack->getPane(0); +} + +SongScroll::Thumb +SongScrollBar::currentThumb() const +{ + Pane *p = pane(); + sv_frame_t song = getSongFrames(); + if (!p || song <= 0) return {}; + return SongScroll::thumb(p->getStartFrame(), p->getEndFrame(), + song, width(), minThumbWidth); +} + +int +SongScrollBar::playheadX() const +{ + sv_frame_t song = getSongFrames(); + if (!m_viewManager || song <= 0 || width() <= 0) return -1; + double x = SongScroll::xForFrame(m_viewManager->getPlaybackFrame(), + song, width()); + return std::clamp(int(std::lround(x)), 0, width() - 1); +} + +void +SongScrollBar::scheduleCheck() +{ + // Hidden, as on the desktop, it has nothing to follow + if (isVisible() && !m_checkTimer.isActive()) m_checkTimer.start(); +} + +void +SongScrollBar::checkPositions() +{ + SongScroll::Thumb thumb = currentThumb(); + bool withThumb = (thumb.width() > 0.0); + if (withThumb != m_paintedWithThumb || + std::lround(thumb.x0) != std::lround(m_paintedThumb.x0) || + std::lround(thumb.x1) != std::lround(m_paintedThumb.x1) || + playheadX() != m_paintedPlayheadX) { + update(); + } +} + +void +SongScrollBar::pitchModelChanged() +{ + if (!m_contourTimer.isActive()) m_contourTimer.start(); +} + +void +SongScrollBar::contourTimedOut() +{ + m_contourStale = true; + update(); +} + +void +SongScrollBar::changeEvent(QEvent *e) +{ + // The contour is drawn in the palette's colours: dark mode + if (e->type() == QEvent::PaletteChange) { + m_contourStale = true; + update(); + } + QWidget::changeEvent(e); +} + +void +SongScrollBar::rebuildContour(sv_frame_t songFrames) +{ + m_contourStale = false; + m_contourSongFrames = songFrames; + m_columns.clear(); + + qreal ratio = devicePixelRatioF(); + int w = int(std::lround(width() * ratio)); + int h = int(std::lround(height() * ratio)); + if (w <= 0 || h <= 0) { + m_contour = QImage(); + return; + } + + m_contour = QImage(w, h, QImage::Format_ARGB32_Premultiplied); + m_contour.fill(Qt::transparent); + + auto pitch = ModelById::getAs(m_pitchModel); + if (pitch && songFrames > 0) { + std::vector> events; + EventVector all = pitch->getAllEvents(); + events.reserve(all.size()); + for (const Event &e : all) { + events.push_back({ e.getFrame(), double(e.getValue()) }); + } + m_columns = SongScroll::pitchColumns + (events, pitch->getResolution(), songFrames, w); + } + + double low = 0.0, high = 0.0; + if (SongScroll::pitchRange(m_columns, low, high)) { + + // Faint, and clear of the thumb's outline at the top and bottom + QColor colour = palette().color(QPalette::WindowText); + colour.setAlpha(130); + double margin = 4.0 * ratio; + double band = h - 2.0 * margin; + int thinnest = std::max(1, int(std::lround(ratio))); + + QPainter paint(&m_contour); + for (int c = 0; c < int(m_columns.size()); ++c) { + const SongScroll::Column &column = m_columns[c]; + if (column.isEmpty()) continue; + int top = int(std::floor + (margin + SongScroll::yForPitch + (column.high, low, high, band))); + int bottom = int(std::ceil + (margin + SongScroll::yForPitch + (column.low, low, high, band))); + bottom = std::max(bottom, top + thinnest); + paint.fillRect(c, top, 1, bottom - top, colour); + } + } + + m_contour.setDevicePixelRatio(ratio); +} + +void +SongScrollBar::paintEvent(QPaintEvent *) +{ + QPainter paint(this); + paint.fillRect(rect(), palette().color(QPalette::Base)); + + sv_frame_t song = getSongFrames(); + qreal ratio = devicePixelRatioF(); + QSize size(int(std::lround(width() * ratio)), + int(std::lround(height() * ratio))); + if (m_contourStale || song != m_contourSongFrames || + m_contour.size() != size || m_contour.devicePixelRatio() != ratio) { + rebuildContour(song); + } + if (!m_contour.isNull()) { + paint.drawImage(QPointF(0.0, 0.0), m_contour); + } + + // What the panes do not show dimmed, as the Overview has it + SongScroll::Thumb thumb = currentThumb(); + bool withThumb = (thumb.width() > 0.0); + if (withThumb) { + QColor dim = palette().color(QPalette::Window); + dim.setAlpha(160); + paint.fillRect(QRectF(0.0, 0.0, thumb.x0, height()), dim); + paint.fillRect(QRectF(thumb.x1, 0.0, width() - thumb.x1, height()), + dim); + + paint.setRenderHint(QPainter::Antialiasing, true); + paint.setPen(QPen(palette().color(QPalette::Highlight), 2.0)); + paint.setBrush(Qt::NoBrush); + paint.drawRoundedRect(QRectF(thumb.x0, 0.0, thumb.width(), height()) + .adjusted(1.0, 1.0, -1.0, -1.0), 3.0, 3.0); + paint.setRenderHint(QPainter::Antialiasing, false); + } + + int playhead = playheadX(); + if (playhead >= 0) { + paint.setPen(QPen(palette().color(QPalette::WindowText), 1.0)); + paint.drawLine(playhead, 0, playhead, height()); + } + + m_paintedThumb = thumb; + m_paintedWithThumb = withThumb; + m_paintedPlayheadX = playhead; +} + +void +SongScrollBar::moveCentreTo(sv_frame_t centre) +{ + // As the Overview does: every pane that follows the global centre + // (all of Tony's) goes with it + if (m_viewManager) m_viewManager->setGlobalCentreFrame(centre); +} + +void +SongScrollBar::mousePressEvent(QMouseEvent *e) +{ + if (e->button() != Qt::LeftButton) return; + + Pane *p = pane(); + sv_frame_t song = getSongFrames(); + if (!p || song <= 0) return; + + m_dragging = true; + m_grabX = e->position().x(); + + // On the thumb, a drag moves the panes from where they are. Anywhere + // else they go there first, and a drag moves them on from there + if (SongScroll::hitsThumb(currentThumb(), m_grabX)) { + m_grabbedCentre = p->getCentreFrame(); + } else { + m_grabbedCentre = SongScroll::jumpCentre(m_grabX, song, width()); + moveCentreTo(m_grabbedCentre); + } +} + +void +SongScrollBar::mouseMoveEvent(QMouseEvent *e) +{ + if (!m_dragging) return; + + // The session may have gone from under the finger + sv_frame_t song = getSongFrames(); + if (!pane() || song <= 0) { + m_dragging = false; + return; + } + + moveCentreTo(SongScroll::dragCentre(m_grabbedCentre, m_grabX, + e->position().x(), song, width())); +} + +void +SongScrollBar::mouseReleaseEvent(QMouseEvent *e) +{ + if (e->button() != Qt::LeftButton) return; + if (m_dragging) mouseMoveEvent(e); + m_dragging = false; +} diff --git a/main/SongScrollBar.h b/main/SongScrollBar.h new file mode 100644 index 00000000..3999c3e8 --- /dev/null +++ b/main/SongScrollBar.h @@ -0,0 +1,155 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TONY_SONG_SCROLL_BAR_H +#define TONY_SONG_SCROLL_BAR_H + +#include "SongScroll.h" + +#include "base/BaseTypes.h" +#include "data/model/Model.h" + +#include +#include +#include +#include + +#include + +namespace sv { +class Pane; +class PaneStack; +class ViewManager; +} + +/** + * A thin strip that stands in for svgui's Overview in the compact + * layout, where the Overview takes too much of a phone's height: the + * whole song across its width, a faint contour of the reference's pitch + * along it (so that the parts that are sung can be told from the rests), + * a thumb for what the panes show, the rest dimmed, and the playhead. + * + * Pressing on the thumb and dragging moves the panes by as much as the + * drag; pressing elsewhere centres them there, and a drag carries on + * from that point. The panes are moved through the ViewManager, as the + * Overview does, so that every pane follows. The playhead is left where + * it is. A finger arrives as Qt's mouse events. + * + * The contour is drawn into an image at the screen's pixel ratio, made + * again only when the pitch model has changed (pYIN fills it in steps: + * the changes are gathered for a while first), when the strip's size or + * the ratio changes, or the song's length. A paint is the image, the + * thumb and the playhead. + * + * The thumb follows the panes' scrolling and zoom, and playback's + * paging, which moves the panes without telling anyone: the first pane's + * start and end frames are read afresh each time the playhead moves, and + * the strip repainted if the thumb or the playhead moved by a pixel. + * + * MainWindow creates it hidden, hands it the song's audio model and the + * reference's pitch model whenever they change, and CompactLayout shows + * it (CompactLayout::Parts::shownWidgets). + */ +class SongScrollBar : public QWidget +{ + Q_OBJECT + +public: + SongScrollBar(sv::ViewManager *viewManager, sv::PaneStack *paneStack, + QWidget *parent = nullptr); + virtual ~SongScrollBar(); + + /// The strip's height, in logical pixels, which are Android's dp + static const int stripHeight = 24; + + /// The narrowest the thumb is drawn, in logical pixels + static const int minThumbWidth = 16; + + /// The song's audio: what its width stands for. None for no song + void setSongModel(sv::ModelId model); + + /// The reference's pitch track, a SparseTimeValueModel, or none + void setPitchModel(sv::ModelId model); + + sv::ModelId getSongModel() const { return m_songModel; } + sv::ModelId getPitchModel() const { return m_pitchModel; } + + /// The song's length in frames, 0 if there is none + sv::sv_frame_t getSongFrames() const; + + /// The thumb for what the first pane shows now, if there is one + SongScroll::Thumb currentThumb() const; + + /// The thumb and the playhead's x as last painted (-1 for none) + SongScroll::Thumb getPaintedThumb() const { return m_paintedThumb; } + int getPaintedPlayheadX() const { return m_paintedPlayheadX; } + + /// Whether the pitch has changed since the contour was last drawn + bool isContourPending() const { + return m_contourStale || m_contourTimer.isActive(); + } + + /// The contour's columns, in device pixels, as last drawn + const std::vector &getContour() const { + return m_columns; + } + + QSize sizeHint() const override; + +protected: + void paintEvent(QPaintEvent *) override; + void mousePressEvent(QMouseEvent *) override; + void mouseMoveEvent(QMouseEvent *) override; + void mouseReleaseEvent(QMouseEvent *) override; + void changeEvent(QEvent *) override; + +private slots: + void pitchModelChanged(); + void contourTimedOut(); + void checkPositions(); + +private: + sv::Pane *pane() const; + int playheadX() const; + void scheduleCheck(); + void moveCentreTo(sv::sv_frame_t centre); + void rebuildContour(sv::sv_frame_t songFrames); + + QPointer m_viewManager; + QPointer m_paneStack; + sv::ModelId m_songModel; + sv::ModelId m_pitchModel; + + // The pitch model's changes, gathered before the contour is made + QTimer m_contourTimer; + bool m_contourStale = true; + + // The geometry is checked once the panes have taken a change in + // themselves: the strip may hear of it before they do + QTimer m_checkTimer; + + QImage m_contour; + std::vector m_columns; + sv::sv_frame_t m_contourSongFrames = 0; + + SongScroll::Thumb m_paintedThumb; + bool m_paintedWithThumb = false; + int m_paintedPlayheadX = -1; + + bool m_dragging = false; + double m_grabX = 0.0; + sv::sv_frame_t m_grabbedCentre = 0; +}; + +#endif diff --git a/main/StreamLatency.cpp b/main/StreamLatency.cpp new file mode 100644 index 00000000..6c32abac --- /dev/null +++ b/main/StreamLatency.cpp @@ -0,0 +1,122 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#include "StreamLatency.h" + +#include +#include + +namespace StreamLatency +{ + +static double +framesSince(int64_t thenNanos, int64_t nowNanos, double rate) +{ + return double(nowNanos - thenNanos) * rate / 1.0e9; +} + +double +outputLatency(Position output, int64_t nowNanos, double rate) +{ + // The hardware has played up to hardwareFrame plus whatever has + // gone out since its timestamp; the rest of what was written is + // still to be heard + return double(output.appFrames - output.hardwareFrame) - + framesSince(output.hardwareNanos, nowNanos, rate); +} + +double +inputLatency(Position input, int64_t nowNanos, double rate) +{ + // The next frame to read came in (appFrames - hardwareFrame) frames + // after the timestamp's + return framesSince(input.hardwareNanos, nowNanos, rate) - + double(input.appFrames - input.hardwareFrame); +} + +Estimate +fromReading(double output, double input) +{ + // The sum is rounded once, so that it is the nearest to the exact + // round trip whichever way the parts round + Estimate e; + int roundTrip = int(std::lround(output + input)); + e.output = int(std::lround(output)); + e.input = roundTrip - e.output; + if (e.input < 0) { + e.output = roundTrip; + e.input = 0; + } + if (e.output < 0) { + e.input = roundTrip; + e.output = 0; + } + if (e.input < 0) { + e.input = 0; // nothing to keep: the round trip is negative + } + return e; +} + +bool +isPlausible(Estimate estimate, double rate) +{ + int roundTrip = estimate.roundTrip(); + return roundTrip > 0 && roundTrip <= rate; +} + +bool +median(std::vector readings, double rate, Estimate &chosen) +{ + readings.erase(std::remove_if(readings.begin(), readings.end(), + [rate](const Estimate &e) { + return !isPlausible(e, rate); + }), + readings.end()); + if (readings.empty()) return false; + std::sort(readings.begin(), readings.end(), + [](const Estimate &a, const Estimate &b) { + return a.roundTrip() < b.roundTrip(); + }); + chosen = readings[(readings.size() - 1) / 2]; + return true; +} + +Estimate +guess(int outputBufferFrames, int inputBurstFrames) +{ + Estimate e; + e.output = std::max(0, outputBufferFrames); + e.input = std::max(0, inputBurstFrames); + return e; +} + +int +inputFramesToRead(int asked, int available, int room) +{ + return std::max(0, std::min(room, std::max(asked, available))); +} + +bool +inputKeptUp(int backlogFrames, int outputBufferFrames, int inputBurstFrames) +{ + return backlogFrames <= std::max(0, outputBufferFrames) + + 2 * std::max(0, inputBurstFrames); +} + +bool +inputLatencyPossible(double inputFrames, int capacityFrames) +{ + return capacityFrames <= 0 || inputFrames < double(capacityFrames); +} + +} diff --git a/main/StreamLatency.h b/main/StreamLatency.h new file mode 100644 index 00000000..81aca836 --- /dev/null +++ b/main/StreamLatency.h @@ -0,0 +1,133 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TONY_STREAM_LATENCY_H +#define TONY_STREAM_LATENCY_H + +#include +#include + +/** + * The arithmetic of a device's latency from its streams' timestamps, + * for OboeAudioIO: Android has no call that says what the latency is, + * only timestamps that say when a frame met the hardware. + * + * Every figure is in frames at the device's rate, the unit bqaudioio's + * backends report latency in (setSystemPlaybackLatency() and + * setSystemRecordLatency()). + * + * Nothing here touches a device, so all of it is tested without one + * (TestStreamLatency). + */ +namespace StreamLatency +{ + /** + * Where a stream is: the frames the application has written to an + * output stream, or read from an input stream, so far, and a + * timestamp: the frame at hardwareFrame left for the speaker, or + * came in from the microphone, at hardwareNanos (CLOCK_MONOTONIC). + */ + struct Position { + int64_t appFrames = 0; + int64_t hardwareFrame = 0; + int64_t hardwareNanos = 0; + }; + + /** + * An output stream's latency at nowNanos: how long the next frame + * the application writes will take to be heard. + */ + double outputLatency(Position output, int64_t nowNanos, double rate); + + /** + * An input stream's latency at nowNanos: how long ago the next + * frame the application reads came in from the microphone. + * Negative if it has not come in yet. + */ + double inputLatency(Position input, int64_t nowNanos, double rate); + + /// Output and input latency, in whole frames + struct Estimate { + int output = 0; + int input = 0; + int roundTrip() const { return output + input; } + }; + + /** + * One reading of the two latencies, taken at the same moment, as + * an estimate: rounded, and neither of them negative. + * + * Taken between two callbacks, the output latency comes out high + * and the input latency low by the same amount, the time to the + * next callback, so only their sum is exact. The sum is what a + * recording is compensated by (LatencyUtils.h) and is kept: a + * negative part is taken off the other. + */ + Estimate fromReading(double output, double input); + + /** + * Whether an estimate can be believed: a round trip longer than + * nothing and no longer than a second at the given rate. + */ + bool isPlausible(Estimate estimate, double rate); + + /** + * Of several readings, the plausible one with the median round + * trip: a reading spoilt by a callback that ran while it was taken + * is off by a callback's worth of frames, and is left out that + * way. False if none is plausible. + */ + bool median(std::vector readings, double rate, + Estimate &chosen); + + /** + * A guess for streams with no timestamps: what the output buffer + * holds, and one burst of input. + */ + Estimate guess(int outputBufferFrames, int inputBurstFrames); + + /** + * How many input frames a full-duplex callback reads, when the + * output asks for "asked" and the input has "available" waiting: + * all of them, as far as "room" goes, and never fewer than asked + * (a read takes no more than there is). Oboe's FullDuplexStream + * reads only as many as the output asks for, so input that piles up + * while the callbacks are held up stays piled up for as long as the + * streams run, the input that much later all the while. + */ + int inputFramesToRead(int asked, int available, int room); + + /** + * Whether an input with backlogFrames waiting to be read was being + * read as it came in: a callback that keeps up leaves no more than + * the output's buffer and a couple of input bursts between one read + * and the next. A latency read with more waiting is how far behind + * the reading was, not the device's, and is not to be used: on a + * phone the input stood at its whole buffer (11424 frames, 244 ms) + * once a take had stopped, against 100 to 190 frames during one. + */ + bool inputKeptUp(int backlogFrames, int outputBufferFrames, + int inputBurstFrames); + + /** + * Whether an input latency read from the timestamps, in frames, can + * be the device's at all: less than the input's buffer holds. Input + * lost to an overrun runs the device's position on past the frames + * read, and reads as latency, a buffer's worth at a time. The phone + * gave 11691 and 23219 frames against a buffer of 11520 while its + * input kept up, and an MMAP stream's overrun count said nothing. + */ + bool inputLatencyPossible(double inputFrames, int capacityFrames); +} + +#endif diff --git a/main/TakeAudio.cpp b/main/TakeAudio.cpp new file mode 100644 index 00000000..8b97aa85 --- /dev/null +++ b/main/TakeAudio.cpp @@ -0,0 +1,393 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#include "TakeAudio.h" + +#include "data/fileio/FileSource.h" +#include "data/fileio/WavFileReader.h" +#include "data/fileio/WavFileWriter.h" + +#include + +#include +#include +#include + +#include +#include +#include +#include + +using namespace sv; + +namespace { + +const sv_frame_t blockFrames = 16384; + +QString tr(const char *text) +{ + return QCoreApplication::translate("TakeAudio", text); +} + +// A stretch [start, end) of the take to be filled from a file, or +// with silence if there is no source +struct Patch { + sv_frame_t start; + sv_frame_t end; + WavFileReader *source; + sv_frame_t sourceOffset; // source frame that lands on "start" +}; + +std::unique_ptr openWav(QString path, QString &error) +{ + if (!QFileInfo(path).isFile()) { + error = tr("Audio file \"%1\" does not exist").arg(path); + return {}; + } + std::unique_ptr reader(new WavFileReader(FileSource(path))); + if (!reader->isOK()) { + error = tr("Failed to read audio file \"%1\": %2") + .arg(path, reader->getError()); + return {}; + } + return reader; +} + +// count frames from "start" on, interleaved in "channels" channels, +// silent where the file has nothing (start may be past its end) +floatvec_t readFrames(WavFileReader *reader, sv_frame_t start, + sv_frame_t count, int channels) +{ + floatvec_t result(count * channels, 0.f); + if (!reader || start >= reader->getFrameCount()) return result; + + int have = reader->getChannelCount(); + floatvec_t data = reader->getInterleavedFrames(start, count); + sv_frame_t got = sv_frame_t(data.size()) / have; + + for (sv_frame_t i = 0; i < got; ++i) { + for (int c = 0; c < channels; ++c) { + if (have == channels) { + result[i * channels + c] = data[i * have + c]; + } else if (channels == 1) { + float sum = 0.f; + for (int k = 0; k < have; ++k) sum += data[i * have + k]; + result[i] = sum / float(have); + } else { + result[i * channels + c] = data[i * have + (c % have)]; + } + } + } + + return result; +} + +// How much of the patch, rather than of what it replaces, is heard at +// a frame: 1 in the middle, less within "fade" frames of either end +float weightAt(sv_frame_t frame, const Patch &patch, sv_frame_t fade) +{ + sv_frame_t fromEdge = std::min(frame - patch.start + 1, patch.end - frame); + if (fromEdge > fade) return 1.f; + return float(fromEdge) / float(fade + 1); +} + +// Patches must be in order and must not overlap +QString write(WavFileReader *old, sv_samplerate_t rate, int channels, + sv_frame_t total, const std::vector &patches, + sv_frame_t fade, QString outPath) +{ + if (fade < 0) fade = TakeAudio::defaultFadeFrames(rate); + + QString error; + + { + // To a temporary file that is moved into place on close + WavFileWriter writer(outPath, rate, channels, + WavFileWriter::WriteToTemporary); + if (!writer.isOK()) { + error = writer.getError(); + } + + for (sv_frame_t b = 0; b < total && error == ""; b += blockFrames) { + + sv_frame_t count = std::min(blockFrames, total - b); + floatvec_t block = readFrames(old, b, count, channels); + + for (const Patch &patch : patches) { + + sv_frame_t from = std::max(b, patch.start); + sv_frame_t to = std::min(b + count, patch.end); + if (from >= to) continue; + + // Never more than half the patch, or the ends would meet + sv_frame_t patchFade = std::min(fade, (patch.end - patch.start) / 2); + + floatvec_t replacement = readFrames + (patch.source, patch.sourceOffset + (from - patch.start), + to - from, channels); + + for (sv_frame_t i = from; i < to; ++i) { + float w = weightAt(i, patch, patchFade); + for (int c = 0; c < channels; ++c) { + float &sample = block[(i - b) * channels + c]; + sample = sample * (1.f - w) + + replacement[(i - from) * channels + c] * w; + } + } + } + + if (!writer.putInterleavedFrames(block)) { + error = writer.getError(); + if (error == "") error = tr("Failed to write audio data"); + } + } + + if (error == "" && !writer.close()) { + error = writer.getError(); + if (error == "") error = tr("Failed to finish writing audio file"); + } + } + + // The writer puts its file in place even when it is abandoned + if (error != "") { + QFile::remove(outPath); + return tr("Failed to write \"%1\": %2").arg(outPath, error); + } + + return ""; +} + +QString checkOutPath(QString outPath, QString oldPath) +{ + if (outPath == "") return tr("No file name given to write to"); + + // Not QFileInfo == QFileInfo: that compares canonical paths, which are + // both empty for two files that do not exist, so a take whose audio + // file has gone missing was refused with this message instead of the + // one that says what is really wrong + if (oldPath != "" && + QFileInfo(outPath).absoluteFilePath() == + QFileInfo(oldPath).absoluteFilePath()) { + return tr("File \"%1\" is the one being read from, and is not to be " + "overwritten").arg(outPath); + } + + if (QFileInfo::exists(outPath)) { + return tr("File \"%1\" exists already, and is not to be overwritten") + .arg(outPath); + } + + return ""; +} + +} // namespace + +sv_frame_t +TakeAudio::defaultFadeFrames(sv_samplerate_t rate) +{ + // 5 ms: too short to hear as a fade, long enough not to click + return sv_frame_t(rate * 0.005); +} + +QString +TakeAudio::splice(QString oldPath, QString recordingPath, + sv_frame_t recordingOffset, sv_frame_t position, + sv_frame_t length, QString outPath, + Coverage::Range *placed, sv_frame_t fadeFrames) +{ + QString error = checkOutPath(outPath, oldPath); + if (error != "") return error; + + auto recording = openWav(recordingPath, error); + if (!recording) return error; + + std::unique_ptr old; + if (oldPath != "") { + old = openWav(oldPath, error); + if (!old) return error; + if (old->getSampleRate() != recording->getSampleRate()) { + return tr("The recording's sample rate (%1) is not that of the take so far (%2)") + .arg(recording->getSampleRate()).arg(old->getSampleRate()); + } + } + + if (recordingOffset < 0) recordingOffset = 0; + + sv_frame_t available = recording->getFrameCount() - recordingOffset; + if (length < 0 || length > available) length = available; + + if (position < 0) { + recordingOffset -= position; + length += position; + position = 0; + } + + if (length <= 0) { + return tr("The recording is too short to use"); + } + + Patch patch { position, position + length, + recording.get(), recordingOffset }; + + sv_frame_t total = patch.end; + if (old) total = std::max(total, old->getFrameCount()); + + int channels = old ? old->getChannelCount() : recording->getChannelCount(); + + error = write(old.get(), recording->getSampleRate(), channels, total, + { patch }, fadeFrames, outPath); + + if (error == "" && placed) { + *placed = Coverage::Range(patch.start, patch.end); + } + + return error; +} + +QString +TakeAudio::erase(QString oldPath, const Coverage::Ranges &ranges, + QString outPath, sv_frame_t fadeFrames) +{ + QString error = checkOutPath(outPath, oldPath); + if (error != "") return error; + + auto old = openWav(oldPath, error); + if (!old) return error; + + sv_frame_t total = old->getFrameCount(); + + // In order, apart, and within the file + Coverage tidy; + for (const Coverage::Range &r : ranges) { + tidy.add(std::max(r.start, sv_frame_t(0)), std::min(r.end, total)); + } + + std::vector patches; + for (const Coverage::Range &r : tidy.getRanges()) { + patches.push_back({ r.start, r.end, nullptr, 0 }); + } + + return write(old.get(), old->getSampleRate(), old->getChannelCount(), + total, patches, fadeFrames, outPath); +} + +QString +TakeAudio::resample(QString inPath, sv_samplerate_t rate, QString outPath) +{ + QString error = checkOutPath(outPath, inPath); + if (error != "") return error; + + if (rate <= 0) { + return tr("No sample rate given to convert \"%1\" to").arg(inPath); + } + + auto in = openWav(inPath, error); + if (!in) return error; + + int channels = in->getChannelCount(); + sv_frame_t inFrames = in->getFrameCount(); + double ratio = rate / in->getSampleRate(); + + // As long as the original in seconds, to the frame + sv_frame_t outFrames = sv_frame_t(std::llround(double(inFrames) * ratio)); + + // The quality svcore uses when it opens a file at another rate than + // its own, as it does a reference: libsamplerate's medium sinc + breakfastquay::Resampler::Parameters params; + params.quality = breakfastquay::Resampler::FastestTolerable; + params.initialSampleRate = in->getSampleRate(); + params.maxBufferSize = int(blockFrames); + + // The resampler holds back the last few frames it is given until it + // has seen what follows them, so the end of the file comes out only + // once some silence has gone in after it. One block of silence is + // far more than it holds back; this many is a resampler that is not + // working + const int maxSilentBlocks = 4; + int silentBlocks = 0; + + int outSpace = int(std::ceil(double(blockFrames) * ratio)) + 16; + floatvec_t out(size_t(outSpace) * channels, 0.f); + + { + // To a temporary file that is moved into place on close + WavFileWriter writer(outPath, rate, channels, + WavFileWriter::WriteToTemporary); + if (!writer.isOK()) { + error = writer.getError(); + } + + try { + breakfastquay::Resampler resampler(params, channels); + + sv_frame_t readFrom = 0; + sv_frame_t written = 0; + + while (written < outFrames && error == "") { + + floatvec_t block; + if (readFrom < inFrames) { + sv_frame_t count = std::min(blockFrames, inFrames - readFrom); + block = in->getInterleavedFrames(readFrom, count); + block.resize(size_t(count) * channels, 0.f); + readFrom += count; + } else if (++silentBlocks <= maxSilentBlocks) { + block.assign(size_t(blockFrames) * channels, 0.f); + } else { + error = tr("The resampler stopped short of the end"); + break; + } + + int got = resampler.resampleInterleaved + (out.data(), outSpace, block.data(), + int(block.size() / channels), ratio, false); + + sv_frame_t keep = std::min(sv_frame_t(got), outFrames - written); + if (keep <= 0) continue; + + floatvec_t part(out.begin(), out.begin() + keep * channels); + if (!writer.putInterleavedFrames(part)) { + error = writer.getError(); + if (error == "") error = tr("Failed to write audio data"); + } + written += keep; + } + } catch (const breakfastquay::Resampler::Exception &) { + error = tr("The resampler failed"); + } + + if (error == "" && !writer.close()) { + error = writer.getError(); + if (error == "") error = tr("Failed to finish writing audio file"); + } + } + + // The writer puts its file in place even when it is abandoned + if (error != "") { + QFile::remove(outPath); + return tr("Failed to convert \"%1\" to %2 Hz: %3") + .arg(inPath, QString::number(rate), error); + } + + return ""; +} + +sv_samplerate_t +TakeAudio::sampleRate(QString path) +{ + QString error; + auto reader = openWav(path, error); + if (!reader) return 0; + return reader->getSampleRate(); +} diff --git a/main/TakeAudio.h b/main/TakeAudio.h new file mode 100644 index 00000000..fd853774 --- /dev/null +++ b/main/TakeAudio.h @@ -0,0 +1,98 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TONY_TAKE_AUDIO_H +#define TONY_TAKE_AUDIO_H + +#include "Coverage.h" + +#include "base/BaseTypes.h" + +#include + +/** + * The audio of a singing take is one WAV file that starts at frame 0 + * of the reference's timeline, silent wherever nothing was recorded. + * These functions make the next such file from the last: nothing is + * ever changed in place, so the file before is there to go back to. + * + * All of them work through the files a block at a time, and all + * return an empty string on success and a message for the user + * otherwise. If they fail, there is no file at outPath. + */ +namespace TakeAudio +{ + /** + * The length of the fades that join new material to old, for + * audio at the given rate. + */ + sv::sv_frame_t defaultFadeFrames(sv::sv_samplerate_t rate); + + /** + * Write to outPath the take in oldPath with a recording put into + * it at the frame "position". + * + * oldPath may be empty: there is no take yet, and the result is + * silence up to the position. The recording is used from its + * frame recordingOffset on (the latency: what was recorded before + * the singer could have heard frame "position" of the reference), + * for "length" frames, or to its end if length is negative. Frames + * that would land before frame 0 of the take are left out. + * + * The result is as long as it needs to be to hold both. It has + * the channels of the old take if there was one (a recording with + * a different number is mixed down, or copied across, to suit) and + * of the recording otherwise. The ends of the new material are + * crossfaded with what was there over fadeFrames frames; negative + * means defaultFadeFrames(). + * + * If placed is not null, it receives the range of the take that + * the recording now fills. + */ + QString splice(QString oldPath, + QString recordingPath, + sv::sv_frame_t recordingOffset, + sv::sv_frame_t position, + sv::sv_frame_t length, + QString outPath, + Coverage::Range *placed = nullptr, + sv::sv_frame_t fadeFrames = -1); + + /** + * Write to outPath the take in oldPath with the given ranges made + * silent, fading out into each and in again after it. The result + * is as long as the original. + */ + QString erase(QString oldPath, + const Coverage::Ranges &ranges, + QString outPath, + sv::sv_frame_t fadeFrames = -1); + + /** + * Write to outPath the audio in inPath at another sample rate: as + * long as it was in seconds, with the same channels, and what was + * at a time in the one at the same time in the other. For a + * recording from a device that does not run at the reference's + * rate, whose frames have to be the reference's before it can go + * into a take. + */ + QString resample(QString inPath, + sv::sv_samplerate_t rate, + QString outPath); + + /// The sample rate of the audio file at path, or 0 if it cannot be read + sv::sv_samplerate_t sampleRate(QString path); +} + +#endif diff --git a/main/TakeCommands.cpp b/main/TakeCommands.cpp new file mode 100644 index 00000000..a73bc638 --- /dev/null +++ b/main/TakeCommands.cpp @@ -0,0 +1,80 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#include "TakeCommands.h" + +#include "MainWindow.h" + +SingingTakeCommand::SingingTakeCommand(MainWindow *window, QString name, + QString pathBefore, + const Coverage &coverageBefore, + QString pathAfter, + const Coverage &coverageAfter) : + m_window(window), + m_name(name), + m_pathBefore(pathBefore), + m_pathAfter(pathAfter), + m_coverageBefore(coverageBefore), + m_coverageAfter(coverageAfter) +{ +} + +SingingTakeCommand::~SingingTakeCommand() +{ +} + +void +SingingTakeCommand::setEventChanges(const TakeEvents::Change &pitch, + const TakeEvents::Change ¬es) +{ + m_pitch = pitch; + m_notes = notes; + + // The analysis has landed, so its result is in the lists now and a + // redo has no reason to run it again + m_pendingAnalysis = Coverage::Range(); +} + +void +SingingTakeCommand::execute() +{ + TakeState state; + state.path = m_pathAfter; + state.coverage = m_coverageAfter; + state.pitchRemove = m_pitch.removed; + state.pitchAdd = m_pitch.added; + state.notesRemove = m_notes.removed; + state.notesAdd = m_notes.added; + state.analyse = m_pendingAnalysis; + + if (m_window) m_window->applyTakeState(this, state); +} + +void +SingingTakeCommand::unexecute() +{ + // The other way round: what the change added goes, and what it + // removed comes back. Nothing is analysed -- a range whose analysis + // is still running belongs to the state being left behind, and + // MainWindow abandons that run + TakeState state; + state.path = m_pathBefore; + state.coverage = m_coverageBefore; + state.pitchRemove = m_pitch.added; + state.pitchAdd = m_pitch.removed; + state.notesRemove = m_notes.added; + state.notesAdd = m_notes.removed; + + if (m_window) m_window->applyTakeState(this, state); +} diff --git a/main/TakeCommands.h b/main/TakeCommands.h new file mode 100644 index 00000000..407f5d25 --- /dev/null +++ b/main/TakeCommands.h @@ -0,0 +1,114 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TONY_TAKE_COMMANDS_H +#define TONY_TAKE_COMMANDS_H + +#include "Coverage.h" +#include "TakeEvents.h" + +#include "base/Command.h" + +#include + +class MainWindow; + +/** + * What the take is to be made into: its audio file, its coverage, and + * the events to take out of and put into its pitch track and its notes. + * An empty path is the state before the first recording of a take: no + * audio, and nothing to show it with. + * + * "analyse" is a range whose analysis has to be run again rather than + * restored from events, because its result never existed -- the run was + * still going when the command was undone. An empty range means there + * is nothing to analyse. + */ +struct TakeState +{ + QString path; + Coverage coverage; + sv::EventVector pitchRemove; + sv::EventVector pitchAdd; + sv::EventVector notesRemove; + sv::EventVector notesAdd; + Coverage::Range analyse; +}; + +/** + * One undoable change to the singing of a take: a recording spliced into + * it ("Record Singing", together with the analysis of the range that was + * recorded) or a part of it erased ("Erase Singing"). + * + * The command holds values only: the audio file before and after, the + * coverage before and after, and the events the change took out of and + * put into the pitch track and the notes. No layer and no model + * pointer -- by the time an undo runs, the take's audio model and its + * analyser have been made again several times over, and MainWindow finds + * the current ones. The audio files both stay on disk until the session + * closes, which is what makes the swap back possible. + * + * A recording is on the undo stack from the moment its audio is spliced, + * which is seconds before the analysis of the recorded range is merged + * into the take's pitch and notes. The command is "open" until then: + * MainWindow amends it with the events of the merge when it lands, and + * an undo pressed before that abandons the run and leaves the range in + * setPendingAnalysis(), for a redo to analyse again. + */ +class SingingTakeCommand : public sv::Command +{ +public: + SingingTakeCommand(MainWindow *window, QString name, + QString pathBefore, const Coverage &coverageBefore, + QString pathAfter, const Coverage &coverageAfter); + virtual ~SingingTakeCommand(); + + QString getName() const override { return m_name; } + + /// Do it again: the take as it was after the change + void execute() override; + + /// Undo: the take as it was before the change + void unexecute() override; + + /** + * The events the change took out of and put into the pitch track and + * the notes. For a recording this arrives with the merge of the + * ranged analysis, which is also the moment the range below stops + * needing to be analysed again. + */ + void setEventChanges(const TakeEvents::Change &pitch, + const TakeEvents::Change ¬es); + + /** + * This range was being analysed when the command was made, so its + * result is in no event list: a redo has to analyse it again. + */ + void setPendingAnalysis(const Coverage::Range &range) { + m_pendingAnalysis = range; + } + +private: + MainWindow *m_window; + QString m_name; + QString m_pathBefore; + QString m_pathAfter; + Coverage m_coverageBefore; + Coverage m_coverageAfter; + TakeEvents::Change m_pitch; + TakeEvents::Change m_notes; + Coverage::Range m_pendingAnalysis; +}; + +#endif diff --git a/main/TakeDiff.cpp b/main/TakeDiff.cpp new file mode 100644 index 00000000..e6b0e6ac --- /dev/null +++ b/main/TakeDiff.cpp @@ -0,0 +1,401 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#include "TakeDiff.h" + +#include "RealtimePitchTracker.h" +#include "TakeEvents.h" + +#include +#include +#include +#include +#include +#include + +using namespace sv; + +namespace { + +sv_frame_t +framesOf(double seconds, sv_samplerate_t rate) +{ + return sv_frame_t(std::llround(seconds * rate)); +} + +bool +sameBits(float a, float b) +{ + uint32_t x, y; + std::memcpy(&x, &a, sizeof x); + std::memcpy(&y, &b, sizeof y); + return x == y; +} + +// A ratio in dB that stays finite, as LatencyCheck has it: silence +// against silence is 0 dB, and anything against silence is 200 +double +decibels(double value, double against) +{ + const double limit = 200.0; + if (value <= 0.0 && against <= 0.0) return 0.0; + if (value <= 0.0) return -limit; + if (against <= 0.0) return limit; + double db = 20.0 * std::log10(value / against); + return std::max(-limit, std::min(limit, db)); +} + +// The events no part of which lies in the window, sorted. What +// erasing the window would touch is what lies in it: a note that +// crosses its edge goes with it, and an event with no duration, a +// pitch event, goes by its frame alone +EventVector +outside(const EventVector &events, const Coverage::Range &window) +{ + EventVector all(events); + EventVector in = TakeEvents::eraseNotes + (events, Coverage::Ranges { window }).removed; + std::sort(all.begin(), all.end()); + std::sort(in.begin(), in.end()); + + EventVector result; + std::set_difference(all.begin(), all.end(), in.begin(), in.end(), + std::back_inserter(result)); + return result; +} + +} // namespace + +TakeDiff::AudioDiff +TakeDiff::audioOutside(const float *before, sv_frame_t beforeFrames, + const float *after, sv_frame_t afterFrames, + int channels, const Coverage::Range &range) +{ + AudioDiff result; + if (channels < 1) return result; + + auto compare = [&](sv_frame_t from, sv_frame_t to) { + for (sv_frame_t i = from; i < to; ++i) { + bool differs = false; + for (int c = 0; c < channels; ++c) { + float a = (i < beforeFrames) ? before[i * channels + c] : 0.f; + float b = (i < afterFrames) ? after[i * channels + c] : 0.f; + if (sameBits(a, b)) continue; + differs = true; + result.largestDifference = std::max + (result.largestDifference, + std::fabs(double(a) - double(b))); + } + if (differs) { + if (result.firstDifference < 0) result.firstDifference = i; + ++result.differences; + } + } + }; + + // An empty or backward range leaves out nothing + sv_frame_t total = std::max(beforeFrames, afterFrames); + sv_frame_t skipFrom = std::max(sv_frame_t(0), std::min(range.start, total)); + sv_frame_t skipTo = std::max(skipFrom, std::min(range.end, total)); + compare(0, skipFrom); + compare(skipTo, total); + + result.pass = (result.differences == 0); + return result; +} + +TakeDiff::EventDiff +TakeDiff::eventsOutside(const EventVector &before, const EventVector &after, + const Coverage::Range &range, sv_samplerate_t rate, + double marginSeconds) +{ + EventDiff result; + + sv_frame_t margin = framesOf(marginSeconds, rate); + result.window = Coverage::Range(range.start - margin, range.end + margin); + + EventVector was = outside(before, result.window); + EventVector is = outside(after, result.window); + + // What only one side has, event for event: the very same event on + // both sides is no change, however many of it there are + EventVector onlyWas, onlyIs; + std::set_difference(was.begin(), was.end(), is.begin(), is.end(), + std::back_inserter(onlyWas)); + std::set_difference(is.begin(), is.end(), was.begin(), was.end(), + std::back_inserter(onlyIs)); + + // Both sorted by frame first: pair what is left at one frame as + // changed, in order, and the rest was removed or added + size_t i = 0, j = 0; + while (i < onlyWas.size() || j < onlyIs.size()) { + if (j == onlyIs.size() || + (i < onlyWas.size() && + onlyWas[i].getFrame() < onlyIs[j].getFrame())) { + result.removed.push_back(onlyWas[i++]); + } else if (i == onlyWas.size() || + onlyIs[j].getFrame() < onlyWas[i].getFrame()) { + result.added.push_back(onlyIs[j++]); + } else { + result.changed.push_back({ onlyWas[i++], onlyIs[j++] }); + } + } + + auto first = [&](sv_frame_t frame) { + if (result.firstDifference < 0 || frame < result.firstDifference) { + result.firstDifference = frame; + } + }; + for (const Event &e : result.removed) first(e.getFrame()); + for (const Event &e : result.added) first(e.getFrame()); + for (const auto &p : result.changed) first(p.first.getFrame()); + + result.pass = result.added.empty() && result.removed.empty() && + result.changed.empty(); + return result; +} + +TakeDiff::PitchJoin +TakeDiff::pitchAcross(const EventVector &pitch, sv_frame_t join, + sv_samplerate_t rate, double windowSeconds, + int maxGapHops) +{ + PitchJoin result; + + sv_frame_t reach = framesOf(windowSeconds, rate); + result.window = Coverage::Range(join - reach, join + reach); + const sv_frame_t from = result.window.start, to = result.window.end; + + // Order, in the order given + std::vector inside; + for (const Event &e : pitch) { + sv_frame_t f = e.getFrame(); + if (f < from || f >= to) continue; + if (!inside.empty() && f < inside.back()) { + if (result.firstOutOfOrder < 0) result.firstOutOfOrder = f; + ++result.outOfOrder; + } + inside.push_back(f); + } + result.events = int(inside.size()); + + // Doubles, whatever the order: each frame counted once however + // many events it holds + std::sort(inside.begin(), inside.end()); + for (size_t k = 1; k < inside.size(); ++k) { + if (inside[k] != inside[k-1]) continue; + if (k >= 2 && inside[k-1] == inside[k-2]) continue; + if (result.firstDoubled < 0) result.firstDoubled = inside[k]; + ++result.doubled; + } + inside.erase(std::unique(inside.begin(), inside.end()), inside.end()); + + // Gaps: from the last event before the window, through those in + // it, to the first after it. With no event beyond an edge, the + // edge stands in for one + sv_frame_t previous = from, next = to; + bool havePrevious = false, haveNext = false; + for (const Event &e : pitch) { + sv_frame_t f = e.getFrame(); + if (f < from && (!havePrevious || f > previous)) { + previous = f; + havePrevious = true; + } + if (f >= to && (!haveNext || f < next)) { + next = f; + haveNext = true; + } + } + + std::vector points; + points.push_back(previous); + points.insert(points.end(), inside.begin(), inside.end()); + points.push_back(next); + + for (size_t k = 1; k < points.size(); ++k) { + sv_frame_t gap = points[k] - points[k-1]; + if (gap > result.largestGap) { + result.largestGap = gap; + result.largestGapFrom = points[k-1]; + } + } + + result.pass = result.largestGap <= maxGapHops * kHopFrames && + result.doubled == 0 && result.outOfOrder == 0; + return result; +} + +TakeDiff::NoteJoin +TakeDiff::notesAcross(const EventVector ¬es, sv_frame_t join, + sv_samplerate_t rate, double clearanceSeconds) +{ + NoteJoin result; + + sv_frame_t clearance = framesOf(clearanceSeconds, rate); + bool haveEdge = false; + + for (const Event ¬e : notes) { + sv_frame_t start = note.getFrame(); + sv_frame_t end = start + note.getDuration(); + + if (start <= join && join < end) result.spanning.push_back(note); + + bool edgeNear = false; + for (sv_frame_t edge : { start, end }) { + sv_frame_t offset = edge - join; + if (std::llabs(offset) <= clearance) edgeNear = true; + if (!haveEdge || std::llabs(offset) < std::llabs(result.nearestEdge)) { + result.nearestEdge = offset; + haveEdge = true; + } + } + if (edgeNear) result.edgesNear.push_back(note); + } + + result.pass = result.spanning.size() == 1 && result.edgesNear.empty(); + return result; +} + +TakeDiff::SampleStep +TakeDiff::stepAt(const float *samples, sv_frame_t frames, int channels, + sv_samplerate_t rate, sv_frame_t join) +{ + SampleStep result; + if (channels < 1) return result; + + // First difference i is x[i] - x[i - 1], so i runs from 1 + auto clamp = [&](sv_frame_t i) { + return std::max(sv_frame_t(1), std::min(i, frames)); + }; + + sv_frame_t reach = framesOf(kStepSeconds, rate); + sv_frame_t half = framesOf(kStepContextSeconds / 2.0, rate); + sv_frame_t stepFrom = clamp(join - reach), stepTo = clamp(join + reach + 1); + sv_frame_t contextFrom = clamp(join - half), contextTo = clamp(join + half); + + // Nothing to read at the join: it is not in the audio + if (stepFrom >= stepTo) return result; + + auto difference = [&](sv_frame_t i, int c) { + return std::fabs(double(samples[i * channels + c]) - + double(samples[(i - 1) * channels + c])); + }; + + bool first = true; + for (int c = 0; c < channels; ++c) { + + double largest = 0.0; + sv_frame_t largestAt = join; + for (sv_frame_t i = stepFrom; i < stepTo; ++i) { + double d = difference(i, c); + if (d > largest) { + largest = d; + largestAt = i; + } + } + + double typical = 0.0; + std::vector context; + for (sv_frame_t i = contextFrom; i < contextTo; ++i) { + context.push_back(difference(i, c)); + } + if (!context.empty()) { + size_t k = size_t(std::llround + (kTypicalQuantile * double(context.size() - 1))); + std::nth_element(context.begin(), context.begin() + k, + context.end()); + typical = context[k]; + } + + double db = decibels(largest, typical); + if (first || db > result.stepDb) { + result.stepDb = db; + result.largest = largest; + result.typical = typical; + result.largestAt = largestAt; + result.channel = c; + first = false; + } + } + + result.pass = result.stepDb <= kMaxStepDb; + return result; +} + +TakeDiff::DotReach +TakeDiff::dotReach(sv_samplerate_t rate) +{ + DotReach reach; + if (rate <= 0) return reach; + reach.before = double(kDotHops * RealtimePitchTracker::kHopSize) / rate; + reach.after = reach.before + + double(RealtimePitchTracker::kWindowSize / 2) / rate; + reach.onset = double(RealtimePitchTracker::kWindowSize) / rate; + reach.end = double(RealtimePitchTracker::kWindowSize / 2) / rate; + return reach; +} + +TakeDiff::LiveDot +TakeDiff::placeLiveDot(const LatencyCheck::Layout &layout, + double punchInStart, double seconds, double hz) +{ + LiveDot dot; + const DotReach reach = dotReach(layout.rate); + if (layout.rate <= 0) return dot; + + auto atOnset = [&](double start) { + return seconds >= start - reach.before && + seconds < start + reach.onset; + }; + + // A dot is at the middle of its window: within half a window of a + // tone's end, the window holds both the tone and what follows it + auto atEnd = [&](double end) { + return std::fabs(seconds - end) < reach.end; + }; + + for (const LatencyCheck::Event &e : layout.events) { + const double sweepAt = double(e.sweepStart) / layout.rate; + if (seconds >= sweepAt - reach.before && + seconds <= sweepAt + LatencyCheck::kSweepSeconds + reach.after) { + dot.place = DotPlace::OnSweep; + return dot; + } + } + + // The first windows of a punch-in straddle the start of what is + // kept: before it, what the mic heard during the lead-in + if (atOnset(punchInStart)) { + dot.place = DotPlace::AtEdge; + } + + for (const LatencyCheck::Event &e : layout.events) { + const double from = double(e.toneStart) / layout.rate; + const double to = double(e.toneStart + e.toneLength) / layout.rate; + if (seconds < from - reach.before || seconds > to + reach.after) { + continue; + } + const bool voiced = (hz > 0.0); + dot.toneHz = e.toneHz; + dot.cents = (voiced ? 1200.0 * std::log2(hz / e.toneHz) : 0.0); + if (dot.place == DotPlace::AtEdge || atOnset(from) || atEnd(to)) { + dot.place = DotPlace::AtEdge; + } else { + dot.place = (voiced && std::fabs(dot.cents) <= kDotCents ? + DotPlace::OnPitch : DotPlace::OffPitch); + } + return dot; + } + return dot; +} diff --git a/main/TakeDiff.h b/main/TakeDiff.h new file mode 100644 index 00000000..60b04f5b --- /dev/null +++ b/main/TakeDiff.h @@ -0,0 +1,378 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TONY_TAKE_DIFF_H +#define TONY_TAKE_DIFF_H + +#include "Coverage.h" +#include "LatencyCheck.h" + +#include "base/BaseTypes.h" +#include "base/Event.h" + +#include +#include + +/** + * What a change did to a take, and what the joins it left look like: + * the comparisons the development checks make on a real take (the + * audio and events outside a punch-in, the join of two punch-ins + * inside a held tone, and where its live dots lie among the + * reference's sounds). Each returns whether it passed and the numbers + * behind the answer, so that a check can report them. + * + * Pure functions over sample buffers, event vectors and frames: no + * model, no window and no device, so all of it is tested without them + * (TestTakeDiff). Samples are interleaved, as WavFileReader gives + * them. Frames are on the take's timeline, which is the reference's, + * and ranges are [start, end) as everywhere else. + */ +namespace TakeDiff +{ + /** + * Whether the audio of a take outside a range is what it was. + */ + struct AudioDiff { + /// Every sample outside the range is bit for bit what it was + bool pass; + + /// The first frame outside the range where any channel + /// differs, or -1 if none does + sv::sv_frame_t firstDifference; + + /// How many frames outside the range differ, and the largest + /// difference of a sample there, full scale being 1 + sv::sv_frame_t differences; + double largestDifference; + + AudioDiff() : pass(false), firstDifference(-1), differences(0), + largestDifference(0) { } + }; + + /** + * Compare a take's audio before and after a change that is to + * touch only the range: a punch-in's placed range, or an erased + * one. Both buffers have the given number of channels. A frame + * past the end of either is silence, as a take is wherever nothing + * was recorded: a splice past the end makes the file longer. + * + * Nothing outside the range is excused. TakeAudio::splice() and + * erase() crossfade over the first and last fade frames *inside* + * the range, the range's own first and last frame included, and + * copy every other frame from the old file (weightAt() in + * TakeAudio.cpp). Take files are 32-bit float, so the copy is + * exact. + */ + AudioDiff audioOutside(const float *before, sv::sv_frame_t beforeFrames, + const float *after, sv::sv_frame_t afterFrames, + int channels, const Coverage::Range &range); + + /** + * How far either side of a changed range its pitch and notes may + * change: a ranged analysis replaces what lies within 0.25 s of + * the range it was asked for (docs/takes.md, "W"), and + * manual-checklist item 10 allows the same. + */ + constexpr double kEventMarginSeconds = 0.25; + + /** + * What changed outside a window. An event is outside when no + * part of it lies in the window; a note that crosses the window's + * edge counts as inside. + */ + struct EventDiff { + /// Nothing outside the window was added, removed or changed + bool pass; + + /// Outside the window: events only the vector after the change + /// has, events only the one before it had, and events at one + /// frame that differ, as (before, after). An event that moved + /// is removed at one frame and added at the other; a note that + /// grew into the window is removed + sv::EventVector added; + sv::EventVector removed; + std::vector> changed; + + /// The first frame of any of those, or -1 + sv::sv_frame_t firstDifference; + + /// The window left out: the range widened by the margin + Coverage::Range window; + + EventDiff() : pass(false), firstDifference(-1) { } + }; + + /** + * Compare pitch events, or notes, before and after a change to + * the range, outside the range widened by the margin either side. + * Events are compared in full (frame, value, duration, level and + * label): outside the window the merge leaves the very same + * events. The vectors need not be sorted. + */ + EventDiff eventsOutside(const sv::EventVector &before, + const sv::EventVector &after, + const Coverage::Range &range, + sv::sv_samplerate_t rate, + double marginSeconds = kEventMarginSeconds); + + /// pYIN's step, in frames of the audio it analyses (Analyser.cpp): + /// over a voiced stretch the pitch track has an event every hop + constexpr sv::sv_frame_t kHopFrames = 256; + + /** + * How far either side of a join the pitch track is looked at. A + * ranged analysis runs over its range widened by 0.5 s and merges + * what lies within 0.25 s of it (docs/takes.md), so a join made + * by a punch-in has the merge's seam 0.25 s before it; this takes + * it in with room, and all that the run could reach. + */ + constexpr double kPitchWindowSeconds = 0.5; + + /// The longest step between neighbouring pitch events that is not + /// a hole, in hops. Over a held tone pYIN stamps every hop, and + /// the hole a bad merge once left was two hops + constexpr int kMaxGapHops = 1; + + /** + * Whether the pitch track runs through a join: no hole, no frame + * twice, frames in order. + */ + struct PitchJoin { + /// No gap longer than the largest allowed, nothing doubled and + /// nothing out of order, within the window + bool pass; + + /// The pitch events within the window + int events; + + /// The longest step within the window from one event to the + /// next, in frames, and the frame it starts from. A step that + /// begins before the window or ends after it counts. Where the + /// track has no event beyond the window's edge, the edge + /// itself counts as one, so a track that stops inside the + /// window, or has nothing in it, shows a long gap + sv::sv_frame_t largestGap; + sv::sv_frame_t largestGapFrom; + + /// Frames within the window that hold more than one event, and + /// the first of them, or -1 + int doubled; + sv::sv_frame_t firstDoubled; + + /// Events within the window that come, in the order given, + /// after one at a later frame; and the first of them, or -1 + int outOfOrder; + sv::sv_frame_t firstOutOfOrder; + + /// The window looked at: [join - reach, join + reach) + Coverage::Range window; + + PitchJoin() : pass(false), events(0), largestGap(0), + largestGapFrom(0), doubled(0), firstDoubled(-1), + outOfOrder(0), firstOutOfOrder(-1) { } + }; + + /** + * Look at the pitch events around a join, in the order given (a + * model gives them sorted). Only the window is looked at: pYIN + * itself stamps one frame twice 100 hops before the end of every + * run, which a whole-file track keeps and a ranged merge drops. + */ + PitchJoin pitchAcross(const sv::EventVector &pitch, sv::sv_frame_t join, + sv::sv_samplerate_t rate, + double windowSeconds = kPitchWindowSeconds, + int maxGapHops = kMaxGapHops); + + /** + * How close to a join no note may begin or end: as far as the + * pitch window, so that a note split at the merge's seam 0.25 s + * from the join is caught. The held tone a join is made in has to + * reach further than this on both sides (the dev layout's reach + * 1.5 s from their middle). + */ + constexpr double kNoteClearanceSeconds = 0.5; + + /** + * Whether one note runs through a join. + */ + struct NoteJoin { + /// Exactly one note holds the join frame, and no note begins + /// or ends within the clearance of it + bool pass; + + /// The notes that hold the join frame + sv::EventVector spanning; + + /// The notes that begin or end within the clearance of the join + sv::EventVector edgesNear; + + /// The onset or end of any note nearest the join, in frames + /// from it (negative before it); 0 if there are no notes + sv::sv_frame_t nearestEdge; + + NoteJoin() : pass(false), nearestEdge(0) { } + }; + + /** + * Look at the notes around a join. A note is [frame, frame + + * duration): one that ends at the join does not hold it, and its + * end is 0 frames from it. + */ + NoteJoin notesAcross(const sv::EventVector ¬es, sv::sv_frame_t join, + sv::sv_samplerate_t rate, + double clearanceSeconds = kNoteClearanceSeconds); + + /// Where a step at a join is looked for: first differences within + /// this of the join ... + constexpr double kStepSeconds = 0.002; + + /// ... against those of this much audio around it, centred on it + constexpr double kStepContextSeconds = 0.05; + + /// The context's typical first difference is this quantile of + /// their sizes. For a steady tone that is within 0.03 dB of its + /// largest, so a clean join in a tone reads about 0 dB; unlike a + /// mean, a click or two in the context does not raise it + constexpr double kTypicalQuantile = 0.95; + + /** + * A join is a step when its largest first difference is this far + * above the typical one. Measured with this code's arithmetic: a + * steady tone of 196 to 294 Hz reads 0.03 dB, and so does the + * splice's crossfade into it at the opposite phase. White noise reads + * 3.6 dB typically and at most 6.7 dB in 200 trials (the largest + * of 177 differences against the 95th percentile of 2205). A hard + * cut in a 220 Hz tone at a random phase reads the jump against + * the tone's steepest step: 27 dB typically, up to 36 dB. About + * one such cut in ten reads under 10 dB: the two sides happened + * to meet within three of the tone's own steps (a tenth of its + * amplitude), which is hardly a click. 10 dB keeps noise well + * clear and misses only those. + */ + constexpr double kMaxStepDb = 10.0; + + /** + * Whether the samples step at a join. + */ + struct SampleStep { + /// stepDb is within kMaxStepDb + bool pass; + + /// The largest first difference within kStepSeconds of the + /// join against the typical one over kStepContextSeconds, in + /// dB, in the channel where that is largest. 0 dB when both + /// are silent, 200 when only the typical one is + double stepDb; + + /// The two first differences, full scale being 1, and the + /// frame the largest leads into (it is x[f] - x[f - 1]) + double largest; + double typical; + sv::sv_frame_t largestAt; + + /// The channel they were read from + int channel; + + SampleStep() : pass(false), stepDb(0), largest(0), typical(0), + largestAt(0), channel(0) { } + }; + + /** + * Look for a step in the samples at a join. Both stretches are + * cut short at the ends of the samples; with no first difference + * to read within kStepSeconds of the join (a join outside the + * audio), it does not pass. + * The context is read as it is: a join between two stretches of + * very different level is read against the whole of it. + */ + SampleStep stepAt(const float *samples, sv::sv_frame_t frames, + int channels, sv::sv_samplerate_t rate, + sv::sv_frame_t join); + + /** + * Live dots against the reference they were sung to, on a loopback, + * where what is sung is the reference itself (the dev checks' item + * 3). + * + * A dot is drawn at the middle of the live tracker's window, but YIN + * hears mostly the window's first half, so a dot comes up to half a + * window after the sound that made it, and not before it: on the + * loopback fake a tone's dots begin about 540 frames into it and end + * up to 440 past it. So a sound's dots lie from its start to half a + * window past its end, give or take kDotHops of the tracker's hops; + * and those on a tone are within kDotCents of its pitch. + * + * Two kinds of dot are counted apart, their pitch not judged. The + * end of each sweep, near 8 kHz, makes a dot or two at a subharmonic + * just under the tracker's 1 kHz ceiling. And a dot at an edge + * comes of a window that straddles it: within one window after a + * tone's start or the punch-in's, or within half a window either + * side of a tone's end, where the window holds the tone's decay + * through the room and then silence. On the fake such a dot is on + * pitch, but through a real speaker, room and microphone it wanders, + * 50 to 75 cents on the user's runs, at the same places on every + * driver. + */ + constexpr int kDotHops = 1; + constexpr double kDotCents = 50.0; + + /// Where a live dot lies among the reference's sounds + enum class DotPlace { + OnPitch, ///< on a tone, within kDotCents of its pitch + OffPitch, ///< on a tone, further from its pitch + AtEdge, ///< within a window after a tone's or the punch-in's + ///< start, or half a window either side of a tone's end + OnSweep, ///< on a sweep + OnNothing ///< on none of the reference's sounds + }; + + struct LiveDot { + DotPlace place; + + /// The tone it lies on, or at an edge of, and how far the dot + /// is from its pitch in cents; 0 for a dot on no tone + double toneHz; + double cents; + + LiveDot() : place(DotPlace::OnNothing), toneHz(0), cents(0) { } + }; + + /** + * How far a sound's dots reach, in seconds: before its start, and + * past its end; how long after a start they are not judged, and how + * far either side of a tone's end. The tracker's frames, counted + * at the rate given: the reference's, as the dots are placed on its + * timeline. + */ + struct DotReach { + double before; + double after; + double onset; + double end; + DotReach() : before(0), after(0), onset(0), end(0) { } + }; + DotReach dotReach(sv::sv_samplerate_t rate); + + /** + * Where a dot, at the given seconds and pitch on the reference's + * timeline, lies among the sounds of the layout, for a punch-in + * starting at punchInStart seconds. The reach of one sound never + * meets the next's; a dot on a sweep near the punch-in's start is + * on the sweep. + */ + LiveDot placeLiveDot(const LatencyCheck::Layout &layout, + double punchInStart, double seconds, double hz); +} + +#endif diff --git a/main/TakeEvents.cpp b/main/TakeEvents.cpp new file mode 100644 index 00000000..3fc09099 --- /dev/null +++ b/main/TakeEvents.cpp @@ -0,0 +1,103 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#include "TakeEvents.h" + +using namespace sv; + +namespace { + +// In order and apart, so that one pass over them is enough +Coverage::Ranges tidied(const Coverage::Ranges &ranges) +{ + Coverage coverage; + for (const Coverage::Range &r : ranges) coverage.add(r.start, r.end); + return coverage.getRanges(); +} + +bool covers(const Coverage::Ranges &ranges, sv_frame_t frame) +{ + for (const Coverage::Range &r : ranges) { + if (frame >= r.start && frame < r.end) return true; + } + return false; +} + +} // namespace + +TakeEvents::Change +TakeEvents::erasePitch(const EventVector &events, + const Coverage::Ranges &erased) +{ + Coverage::Ranges ranges = tidied(erased); + + Change change; + for (const Event &e : events) { + if (covers(ranges, e.getFrame())) change.removed.push_back(e); + } + return change; +} + +TakeEvents::Change +TakeEvents::eraseNotes(const EventVector &events, + const Coverage::Ranges &erased) +{ + Coverage::Ranges ranges = tidied(erased); + + Change change; + + for (const Event &e : events) { + + sv_frame_t start = e.getFrame(); + sv_frame_t end = start + e.getDuration(); + + // A note with no length is either in an erased range or not + if (end <= start) { + if (covers(ranges, start)) change.removed.push_back(e); + continue; + } + + // What is left of the note: the erased ranges taken out of it. + // Two stretches can be left, when a range was erased from the + // middle of the note + Coverage::Ranges left; + sv_frame_t from = start; + + for (const Coverage::Range &r : ranges) { + if (r.end <= from) continue; + if (r.start >= end) break; + if (r.start > from) { + left.push_back(Coverage::Range(from, r.start)); + } + from = r.end; + if (from >= end) break; + } + if (from < end) left.push_back(Coverage::Range(from, end)); + + // Nothing of this one was erased + if (left.size() == 1 && + left[0].start == start && left[0].end == end) { + continue; + } + + change.removed.push_back(e); + + for (const Coverage::Range &r : left) { + change.added.push_back + (e.withFrame(r.start).withDuration(r.length())); + } + } + + return change; +} diff --git a/main/TakeEvents.h b/main/TakeEvents.h new file mode 100644 index 00000000..882b3f7b --- /dev/null +++ b/main/TakeEvents.h @@ -0,0 +1,62 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TONY_TAKE_EVENTS_H +#define TONY_TAKE_EVENTS_H + +#include "Coverage.h" + +#include "base/Event.h" + +/** + * The pitch events and the notes of a take, edited to follow a change + * to its audio. Erasing part of a take takes the singing out of its + * audio file, and what was analysed there has to go with it. + * + * These are pure functions over event lists: they touch no model, so + * they can be tested without a window, and what they return is the + * change itself, which is what an undo command is made of. + */ +namespace TakeEvents +{ + /** + * What a model has to lose and then gain for the change to be made. + */ + struct Change { + sv::EventVector removed; + sv::EventVector added; + + bool isEmpty() const { return removed.empty() && added.empty(); } + }; + + /** + * The pitch events in the erased ranges go; nothing else changes. + */ + Change erasePitch(const sv::EventVector &events, + const Coverage::Ranges &erased); + + /** + * The notes lose what the erased ranges take from them. A note + * wholly inside a range goes. A note that a range starts inside is + * cut back to the start of the range. A note whose own start was + * erased begins again at the end of the range, shorter by what was + * taken: the singing its onset was is not there any more. A note + * with an erased range in the middle of it becomes two notes, as the + * audio did. + */ + Change eraseNotes(const sv::EventVector &events, + const Coverage::Ranges &erased); +} + +#endif diff --git a/main/TakeLayers.cpp b/main/TakeLayers.cpp new file mode 100644 index 00000000..bb40b129 --- /dev/null +++ b/main/TakeLayers.cpp @@ -0,0 +1,108 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#include "TakeLayers.h" + +#include "view/Pane.h" +#include "layer/TimeValueLayer.h" +#include "layer/FlexiNoteLayer.h" +#include "layer/RegionLayer.h" + +using namespace sv; + +// The word each kind of layer is named after its take +static QString +suffixFor(TakeLayers::Kind kind) +{ + switch (kind) { + case TakeLayers::Pitch: return "Pitch"; + case TakeLayers::Notes: return "Notes"; + case TakeLayers::Coverage: default: return "Coverage"; + } +} + +QString +TakeLayers::nameFor(QString takeName, Kind kind) +{ + if (takeName == "") return ""; + return takeName + " " + suffixFor(kind); +} + +bool +TakeLayers::parse(QString layerName, QString &takeName, Kind &kind) +{ + for (Kind k : { Pitch, Notes, Coverage }) { + QString suffix = " " + suffixFor(k); + if (!layerName.endsWith(suffix)) continue; + QString name = layerName.left(layerName.length() - suffix.length()); + if (name == "") continue; + takeName = name; + kind = k; + return true; + } + return false; +} + +TakeLayers::Found +TakeLayers::find(Pane *pane, QString takeName) +{ + Found found; + if (!pane || takeName == "") return found; + + for (int i = 0; i < pane->getLayerCount(); ++i) { + + Layer *layer = pane->getLayer(i); + QString name = layer->objectName(); + + if (!found.pitch && name == nameFor(takeName, Pitch)) { + found.pitch = qobject_cast(layer); + } + if (!found.notes && name == nameFor(takeName, Notes)) { + found.notes = qobject_cast(layer); + } + if (!found.coverage && name == nameFor(takeName, Coverage)) { + found.coverage = qobject_cast(layer); + } + } + + return found; +} + +void +TakeLayers::raise(Pane *pane, Layer *layer) +{ + if (!pane || !layer) return; + + // The stacking order of a pane is the order its layers were added in: + // Analyser::stackLayers() does nothing in Tony, because it goes + // through PaneStack::setCurrentLayer(), which needs a PropertyStack + // that this pane stack has none of. So the only way to put a layer on + // top is to add it to the view again. + // + // View::removeLayer() and addLayer() are the view's own bookkeeping: + // Document::m_layerViewMap is untouched, so the layer stays a layer of + // this view as far as the document and the session file are concerned, + // and no command is made. If Pane::getTopFlexiNoteLayer() ever skips + // dormant layers (a fork change), the active take's notes would be on + // top without this. + // + // removeLayer() does not disconnect layerMeasurementRectsChanged, + // which addLayer() connects, so that one is undone here: without it + // the connection is made again on every switch. + QObject::disconnect(layer, SIGNAL(layerMeasurementRectsChanged()), + pane, nullptr); + + pane->removeLayer(layer); + pane->addLayer(layer); +} diff --git a/main/TakeLayers.h b/main/TakeLayers.h new file mode 100644 index 00000000..48885a2a --- /dev/null +++ b/main/TakeLayers.h @@ -0,0 +1,78 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TONY_TAKE_LAYERS_H +#define TONY_TAKE_LAYERS_H + +#include + +namespace sv { +class Layer; +class Pane; +class TimeValueLayer; +class FlexiNoteLayer; +class RegionLayer; +} + +/** + * The layers of a take, found by name. + * + * Every take of a session has three layers in pane 0 -- pitch, notes and + * coverage -- and their object names carry the identity of the take they + * belong to: "Take 2 Pitch", "Take 2 Notes", "Take 2 Coverage" (spec + * 6.4). That is the only link between a take and what shows it: no + * pointer to a layer is kept anywhere but in the analyser of the take + * that is active, so a take whose layers were put back by a session load + * is found the same way as one that has been switched away from. + * + * The names are not translated: they are stored in the session file. + */ +class TakeLayers +{ +public: + enum Kind { Pitch, Notes, Coverage }; + + /// The object name of that layer of the take of this name + static QString nameFor(QString takeName, Kind kind); + + /** + * Take a layer's object name apart again. False if it is not the + * name of a take's layer at all. + */ + static bool parse(QString layerName, QString &takeName, Kind &kind); + + /// The layers of the take of this name that are in the pane + struct Found { + sv::TimeValueLayer *pitch = nullptr; + sv::FlexiNoteLayer *notes = nullptr; + sv::RegionLayer *coverage = nullptr; + }; + + static Found find(sv::Pane *pane, QString takeName); + + /** + * Move a layer to the top of the pane's layer stack, which is where + * the editing tools look for the layer to act on: the note tool takes + * the pane's topmost note layer, dormant or not, so the active take's + * notes have to be above the takes that are put away. + * + * Done on the view alone -- the layer is not added to or removed from + * the document, so nothing is created, deleted or made undoable, and + * the layer goes on being saved with the session. See the note in + * the implementation about the fork change that would retire this. + */ + static void raise(sv::Pane *pane, sv::Layer *layer); +}; + +#endif diff --git a/main/TakeTiming.cpp b/main/TakeTiming.cpp new file mode 100644 index 00000000..c5e77c99 --- /dev/null +++ b/main/TakeTiming.cpp @@ -0,0 +1,165 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#include "TakeTiming.h" + +#include "LatencyUtils.h" + +#include + +#include +#include + +using namespace sv; + +namespace { + +QString tr(const char *text) +{ + return QCoreApplication::translate("TakeTiming", text); +} + +} // namespace + +sv_frame_t +TakeTiming::recordedToReference(sv_frame_t recordedFrames) const +{ + if (rate <= 0 || recordRate <= 0 || recordRate == rate) { + return recordedFrames; + } + return sv_frame_t(std::llround(double(recordedFrames) * rate / recordRate)); +} + +sv_frame_t +TakeTiming::referenceToRecorded(sv_frame_t referenceFrames) const +{ + if (rate <= 0 || recordRate <= 0 || recordRate == rate) { + return referenceFrames; + } + return sv_frame_t(std::llround(double(referenceFrames) * recordRate / rate)); +} + +double +TakeTiming::referenceFramesPerRecordedFrame() const +{ + if (rate <= 0 || recordRate <= 0 || recordRate == rate) { + return 1.0; + } + return double(rate) / double(recordRate); +} + +sv_frame_t +TakeTiming::preRollBefore(sv_frame_t position, sv_frame_t wanted) +{ + if (wanted <= 0 || position <= 0) return 0; + return std::min(wanted, position); +} + +sv_frame_t +TakeTiming::playbackStart() const +{ + sv_frame_t start = position - preRoll; + return start > 0 ? start : 0; +} + +bool +TakeTiming::havePunchOut() const +{ + return end > position; +} + +sv_frame_t +TakeTiming::spliceOffset() const +{ + // Everything before this was recorded before the singer could have + // heard the reference at P: the round trip, and the lead-in they + // were listening to before it + sv_frame_t offset = recordedToReference(latency) + preRoll; + return offset > 0 ? offset : 0; +} + +sv_frame_t +TakeTiming::spliceLength() const +{ + if (!havePunchOut()) return -1; + return end - position; +} + +sv_frame_t +TakeTiming::autoStopFrames() const +{ + if (!havePunchOut()) return 0; + // Counted as the record target counts, in frames of the recording + sv_samplerate_t deviceRate = (recordRate > 0 ? recordRate : rate); + sv_frame_t margin = sv_frame_t(deviceRate * autoStopMarginSeconds()); + return referenceToRecorded(spliceOffset() + (end - position)) + margin; +} + +bool +TakeTiming::shouldStopAt(sv_frame_t framesReceived) const +{ + if (!havePunchOut()) return false; + return framesReceived >= autoStopFrames(); +} + +sv_frame_t +TakeTiming::liveFrameIntoTake(sv_frame_t recordedFrame) const +{ + // The same shift the splice applies to the audio, so that a dot sits + // where the finished pitch track will put the sound it stands for + return compensatedLiveFrame(recordedToReference(recordedFrame), + spliceOffset()); +} + +bool +TakeTiming::isInLeadIn(sv_frame_t framesReceived) const +{ + // Without a pre-roll there is no lead-in to wait through: what + // little comes before the latency is not worth counting down + return preRoll > 0 && recordedToReference(framesReceived) < spliceOffset(); +} + +int +TakeTiming::countdownSeconds(sv_frame_t framesReceived) const +{ + if (rate <= 0 || !isInLeadIn(framesReceived)) return 0; + double seconds = + double(spliceOffset() - recordedToReference(framesReceived)) / rate; + return int(std::ceil(seconds)); +} + +QString +TakeTiming::countdownText(sv_frame_t framesReceived) const +{ + int seconds = countdownSeconds(framesReceived); + if (seconds <= 0) return ""; + return tr("Recording in %1…").arg(seconds); +} + +bool +TakeTiming::chooseRange(const Coverage::Ranges &ranges, sv_frame_t playhead, + Coverage::Range &chosen) +{ + if (ranges.empty()) return false; + + for (const Coverage::Range &range : ranges) { + if (playhead >= range.start && playhead < range.end) { + chosen = range; + return true; + } + } + + chosen = ranges[0]; + return true; +} diff --git a/main/TakeTiming.h b/main/TakeTiming.h new file mode 100644 index 00000000..1f427ff1 --- /dev/null +++ b/main/TakeTiming.h @@ -0,0 +1,153 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TONY_TAKE_TIMING_H +#define TONY_TAKE_TIMING_H + +#include "Coverage.h" + +#include "base/BaseTypes.h" + +#include + +/** + * The frame arithmetic of one singing take: where the reference is + * played from, which part of the recording is kept, when the take + * stops by itself, and where a live dot belongs. + * + * P (position) is where the take's new material goes on the + * reference's timeline; E (end) is where it stops if the singer is + * recording into a selection; R (preRoll) is the lead-in that is + * heard but not recorded over; L (latency) is the round trip, as + * LatencyUtils.h describes it. The device records from the press of + * Record whatever else happens: pre-roll and punch-out only change + * which part of the recording is used and when it stops. + * + * The device need not run at the reference's rate (phones run at + * 48 kHz, the reference is always a 44.1 kHz model). P, E and R, and + * anything placed on the reference's timeline, are frames at rate; L + * and anything counted off the record target are frames of the + * recording, at recordRate. The recording is converted to the + * reference's rate before it is spliced, so the splice counts in the + * reference's frames. + * + * Nothing here touches a model, a window or the settings, so all of + * it is tested without either (TestTakeTiming): MainWindow fills the + * fields in and does as the answers say. + */ +struct TakeTiming +{ + /// Of the reference, and of the take's audio + sv::sv_samplerate_t rate; + + /// Of the recording: the device's. 0 means the same as rate + sv::sv_samplerate_t recordRate; + + /// P: where the material recorded from now on is to land + sv::sv_frame_t position; + + /// E: where the take stops by itself, or -1 for "when Stop is pressed" + sv::sv_frame_t end; + + /// R: the lead-in played before P, never taking S below frame 0 + sv::sv_frame_t preRoll; + + /// L: what was recorded before the singer could hear the reference + /// at S, in frames of the recording + sv::sv_frame_t latency; + + TakeTiming() : + rate(0), recordRate(0), position(0), end(-1), preRoll(0), + latency(0) { } + + /// Frames of the recording as frames of the reference's timeline + sv::sv_frame_t recordedToReference(sv::sv_frame_t recordedFrames) const; + + /// Frames of the reference's timeline as frames of the recording + sv::sv_frame_t referenceToRecorded(sv::sv_frame_t referenceFrames) const; + + /// How many of the reference's frames one frame of the recording + /// is: 1 unless both rates are known and differ + double referenceFramesPerRecordedFrame() const; + + /** + * The lead-in there is room for before position: the pre-roll + * asked for, shortened near the start of the song and nothing at + * frame 0, since a take cannot start before the song does. + */ + static sv::sv_frame_t preRollBefore(sv::sv_frame_t position, + sv::sv_frame_t wanted); + + /** + * How much singing past E is recorded before the take stops + * itself. The latency can still be refined while the take runs, + * and the timer that watches for the end only polls now and then, + * so the margin is what makes sure everything up to E is there. + * What is recorded past E is never used. + */ + static double autoStopMarginSeconds() { return 0.25; } + + /// S: where playback starts, so the reference reaches P after R frames + sv::sv_frame_t playbackStart() const; + + /// The take has an end to stop itself at + bool havePunchOut() const; + + /** + * The frame of the recording that the take's new material starts + * at, once the recording is at the reference's rate + */ + sv::sv_frame_t spliceOffset() const; + + /// How much of the recording to use, or -1 for all there is of it + sv::sv_frame_t spliceLength() const; + + /** + * How many frames the record target must have received for the + * take to hold everything up to E, margin included. 0 when there + * is no punch-out to stop at. + */ + sv::sv_frame_t autoStopFrames() const; + + /// The take has everything it was asked for and can stop now + bool shouldStopAt(sv::sv_frame_t framesReceived) const; + + /** + * Where sound found at this frame of the recording belongs, + * counted in the reference's frames from P. Negative means it was + * sung during the lead-in, before the take's own material begins, + * and has no place on the reference's timeline. + */ + sv::sv_frame_t liveFrameIntoTake(sv::sv_frame_t recordedFrame) const; + + /// The lead-in is still running: nothing recorded so far counts + bool isInLeadIn(sv::sv_frame_t framesReceived) const; + + /// Seconds of the lead-in left, rounded up; 0 once it is over + int countdownSeconds(sv::sv_frame_t framesReceived) const; + + /// What to show while the lead-in runs; empty once it is over + QString countdownText(sv::sv_frame_t framesReceived) const; + + /** + * The range to record into out of those selected: the one that + * holds the playhead, else the first. The ranges must be in + * order. False, leaving chosen alone, if there are none. + */ + static bool chooseRange(const Coverage::Ranges &ranges, + sv::sv_frame_t playhead, + Coverage::Range &chosen); +}; + +#endif diff --git a/main/TakesFile.cpp b/main/TakesFile.cpp new file mode 100644 index 00000000..f2590950 --- /dev/null +++ b/main/TakesFile.cpp @@ -0,0 +1,328 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#include "TakesFile.h" + +#include "SingingTakes.h" + +#include "base/XmlExportable.h" +#include "data/fileio/BZipFileDevice.h" + +#include +#include +#include +#include +#include + +#include + +using namespace sv; + +namespace { + +QString tr(const char *text) +{ + return QCoreApplication::translate("TakesFile", text); +} + +// Windows tells no two file names apart by their case, and a path +// written with back slashes names the same file as one with forward +// slashes. The session file stores forward slashes throughout +#ifdef Q_OS_WIN +const Qt::CaseSensitivity nameCase = Qt::CaseInsensitive; +#else +const Qt::CaseSensitivity nameCase = Qt::CaseSensitive; +#endif + +QString cleaned(QString path) +{ + if (path == "") return path; + return QDir::cleanPath(QDir::fromNativeSeparators(path)); +} + +// The directory part of a cleaned path, without its trailing slash +// ("C:/songs/a.ton" gives "C:", "/songs/a.ton" gives "/songs") +QString directoryOf(QString cleanedPath) +{ + int slash = cleanedPath.lastIndexOf('/'); + if (slash < 0) return ""; + if (slash == 0) return "/"; + return cleanedPath.left(slash); +} + +// One file, under one name: for telling whether two takes are sharing +// their audio +QString fileKey(QString path) +{ + QString key = cleaned(path); + return nameCase == Qt::CaseInsensitive ? key.toLower() : key; +} + +} + +QString +TakesFile::toXml(const SingingTakes &takes, QString sessionPath, QString indent) +{ + QString xml; + + xml += QString("%1\n") + .arg(indent) + .arg(XmlExportable::encodeEntities(takes.getActiveName())); + + for (const SingingTakes::Take &take : takes.getTakes()) { + // One arg() for all three: with one each, a "%1" or "%3" in the + // take's name would be filled in by the next + xml += QString("%1 \n") + .arg(indent, + XmlExportable::encodeEntities(take.name), + XmlExportable::encodeEntities + (relativeAudioPath(sessionPath, take.audioPath))); + } + + xml += QString("%1\n").arg(indent); + + return xml; +} + +TakesFile::Takes +TakesFile::read(QString sessionPath) +{ + Takes takes; + if (sessionPath == "") return takes; + + // A session file is bzip2, but the session reader takes a plain XML + // one as well, so the first bytes say which this is rather than the + // extension + bool compressed = false; + { + QFile probe(sessionPath); + if (!probe.open(QIODevice::ReadOnly)) return takes; + compressed = (probe.read(3) == QByteArray("BZh")); + } + + if (compressed) { + BZipFileDevice file(sessionPath); + if (!file.open(QIODevice::ReadOnly)) return takes; + QXmlStreamReader reader(&file); + takes = readFrom(reader); + file.close(); + } else { + QFile file(sessionPath); + if (!file.open(QIODevice::ReadOnly)) return takes; + QXmlStreamReader reader(&file); + takes = readFrom(reader); + } + + return takes; +} + +TakesFile::Takes +TakesFile::readFrom(QXmlStreamReader &reader) +{ + Takes takes; + bool inTakes = false; + + // Everything of the document but our own element is passed over: it + // has been read once already, by the session reader + while (!reader.atEnd()) { + + switch (reader.readNext()) { + + case QXmlStreamReader::StartElement: + { + QString name = reader.name().toString().toLower(); + + if (name == "takes") { + inTakes = true; + takes.found = true; + takes.active = reader.attributes().value("active").toString(); + } else if (inTakes && name == "take") { + Take take; + take.name = reader.attributes().value("name").toString(); + take.audioPath = reader.attributes().value("audio").toString(); + // A take is its name: one without a name is no take + if (take.name != "") takes.takes.push_back(take); + } + break; + } + + case QXmlStreamReader::EndElement: + if (reader.name().toString().toLower() == "takes") inTakes = false; + break; + + default: + break; + } + } + + return takes; +} + +QString +TakesFile::takesFolder(QString sessionPath) +{ + if (sessionPath == "") return ""; + + QString path = cleaned(sessionPath); + QString directory = directoryOf(path); + QString name = path.mid(path.lastIndexOf('/') + 1); + + // The base name of the session file: everything but the extension, + // so that "My Song.ton" gives "My Song.takes" and a name with dots + // of its own keeps them + int dot = name.lastIndexOf('.'); + if (dot > 0) name = name.left(dot); + if (name == "") return ""; + + name += ".takes"; + + if (directory == "") return name; + if (directory == "/") return "/" + name; + return directory + "/" + name; +} + +QString +TakesFile::relativeAudioPath(QString sessionPath, QString audioPath) +{ + if (audioPath == "") return ""; + + QString audio = cleaned(audioPath); + if (sessionPath == "") return audio; + + QString directory = directoryOf(cleaned(sessionPath)); + if (directory == "") return audio; + + // Anything outside the session's own directory is named where it is: + // a relative path there would be a trail of "../.." that says nothing + if (!isInFolder(directory, audio)) return audio; + + int from = (directory == "/" ? 1 : directory.length() + 1); + return audio.mid(from); +} + +QString +TakesFile::resolveAudioPath(QString sessionPath, QString storedPath) +{ + if (storedPath == "") return ""; + + QString stored = cleaned(storedPath); + + // A session saved before the takes folder names its audio absolutely + // (phase 7b), and that is where the file is + if (QFileInfo(stored).isAbsolute()) return stored; + + QString directory = directoryOf(cleaned(sessionPath)); + if (directory == "") return stored; + if (directory == "/") return cleaned("/" + stored); + return cleaned(directory + "/" + stored); +} + +bool +TakesFile::isInFolder(QString folder, QString path) +{ + if (folder == "" || path == "") return false; + + QString within = cleaned(folder); + if (!within.endsWith('/')) within += '/'; + + QString file = cleaned(path); + return file.length() > within.length() && + file.startsWith(within, nameCase); +} + +QString +TakesFile::freeCopyPath(QString folder, QString fileName) +{ + if (folder == "" || fileName == "") return ""; + + QDir dir(folder); + if (!dir.exists(fileName)) return cleaned(dir.filePath(fileName)); + + // A file of that name is there already. Whatever it is, it is not + // ours to write over: the copy gets a name of its own + QString base = fileName, suffix; + int dot = fileName.lastIndexOf('.'); + if (dot > 0) { + base = fileName.left(dot); + suffix = fileName.mid(dot); + } + + for (int i = 2; i < 1000; ++i) { + QString name = QString("%1-%2%3") + .arg(base, QString::number(i), suffix); + if (!dir.exists(name)) return cleaned(dir.filePath(name)); + } + + return ""; +} + +QString +TakesFile::copyTakeAudioInto(SingingTakes &takes, QString folder) +{ + if (folder == "") return tr("No folder to copy the takes' audio into"); + + QString error; + + // The copies made here, to be undone if one of them fails, and which + // source file each of them came from: two takes sharing a file share + // the copy as well + QStringList made; + std::map copyOf; + std::vector> relocations; + + for (int i = 0; i < takes.getTakeCount(); ++i) { + + const SingingTakes::Take *take = takes.getTake(i); + if (!take || take->audioPath == "") continue; + if (isInFolder(folder, take->audioPath)) continue; + + QString key = fileKey(take->audioPath); + auto known = copyOf.find(key); + if (known != copyOf.end()) { + relocations.push_back({ i, known->second }); + continue; + } + + QString target = + freeCopyPath(folder, QFileInfo(take->audioPath).fileName()); + if (target == "") { + error = tr("Could not find a name to copy the audio of the take " + "\"%1\" under, in \"%2\"").arg(take->name, folder); + break; + } + + if (!QFile::copy(take->audioPath, target)) { + error = tr("Could not copy the audio of the take \"%1\" to " + "\"%2\"").arg(take->name, target); + break; + } + + made.push_back(target); + copyOf[key] = target; + relocations.push_back({ i, target }); + } + + if (error != "") { + // Nothing has been changed and nothing is left behind: a session + // that cannot have all of its audio beside it is not saved at all + for (const QString &path : made) QFile::remove(path); + return error; + } + + for (const auto &relocation : relocations) { + takes.relocateTake(relocation.first, relocation.second); + } + + return ""; +} diff --git a/main/TakesFile.h b/main/TakesFile.h new file mode 100644 index 00000000..716df2cb --- /dev/null +++ b/main/TakesFile.h @@ -0,0 +1,146 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TONY_TAKES_FILE_H +#define TONY_TAKES_FILE_H + +#include + +#include + +class SingingTakes; +class QXmlStreamReader; + +/** + * The takes of a session in the session file: the one element of Tony's + * own that a `.ton` carries (spec 6.4). + * + * + * + * + * + * + * Everything else about a take -- its pitch, its notes and the coverage + * strip that holds its coverage -- is in the document as ordinary layers, + * named after the take (TakeLayers), and comes back with it. This element + * says which takes there are, which of them was on show, and where each + * one's audio is; a take with no recording in it yet has an empty audio + * path. + * + * `SVFileReader` does not know the element and only warns about it, so it + * is written inside the `` document and read by a pass of Tony's own + * over the same file afterwards. The element being absent is what tells a + * session saved before this feature from one saved with no takes in it. + */ +class TakesFile +{ +public: + struct Take { + QString name; + QString audioPath; // "" for a take with no recording in it yet + }; + + struct Takes { + std::vector takes; + + /// The name of the take that was on show + QString active; + + /// The file had a `` element at all + bool found = false; + }; + + /** + * The element for the takes of this session, indented with indent and + * ending in a newline. Names and paths are escaped. + * + * sessionPath is the file being written: a take's audio inside that + * session's own directory is named relative to it (spec 6.4), which + * is what lets a song and its takes folder be moved together. With + * no session path the paths are written as they are. + */ + static QString toXml(const SingingTakes &takes, + QString sessionPath = "", + QString indent = " "); + + // --- Where a session's take audio lives (spec 6.4) --- + + /** + * The folder the combined audio files of a session's takes belong + * in: the session file's name without its extension, plus + * ".takes", beside the session file. "C:/songs/My Song.ton" gives + * "C:/songs/My Song.takes". Forward slashes, no trailing one; "" + * for an empty path. + */ + static QString takesFolder(QString sessionPath); + + /** + * The path to store in the session file for this audio file: the + * path relative to the session file's own directory when the audio + * is inside it, and the absolute path otherwise -- an audio file + * somewhere else is named where it is. Forward slashes. + */ + static QString relativeAudioPath(QString sessionPath, QString audioPath); + + /** + * The audio file a stored path means, for a session read from + * sessionPath: a relative path is taken against the session file's + * own directory, so that the song and its folder may be moved + * together; an absolute one -- as a session saved before the folder + * has -- is where it says it is. + */ + static QString resolveAudioPath(QString sessionPath, QString storedPath); + + /// path is inside folder, at any depth + static bool isInFolder(QString folder, QString path); + + /** + * A path in folder for a copy of a file of this name, under a name + * no file there has: the name itself if it is free, and otherwise + * the name with "-2", "-3" and so on before the extension. "" + * if folder or fileName is empty, or every name tried was taken. + */ + static QString freeCopyPath(QString folder, QString fileName); + + /** + * Copy the audio of every take that is not in folder already into + * it, and point the take at the copy: what a session save does + * before it writes the file, so that the paths it writes are inside + * the session's own folder (spec 6.4). + * + * Copied and not moved: the active take's audio model has its file + * open, which Windows will not let us move, and it can go on + * reading the old, identical copy until the next swap. A file two + * takes share -- a duplicated take -- is copied once and both take + * the copy. No file in folder is ever written over. + * + * "" on success. Otherwise the return is a message for the user, + * every take still points where it did, and the copies this call + * made have been removed again: a session must not be saved naming + * files that are not there. + */ + static QString copyTakeAudioInto(SingingTakes &takes, QString folder); + + /** + * Read the element from a session file: bzip2, as a `.ton` is, or + * plain XML, which the session reader also accepts. A file that + * cannot be read, or that has no element, gives found == false. + */ + static Takes read(QString sessionPath); + + /// The pass itself, over a document that is open already + static Takes readFrom(QXmlStreamReader &reader); +}; + +#endif diff --git a/main/TouchGestures.cpp b/main/TouchGestures.cpp new file mode 100644 index 00000000..88ed8fc9 --- /dev/null +++ b/main/TouchGestures.cpp @@ -0,0 +1,638 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#include "TouchGestures.h" +#include "PinchZoom.h" + +#include "view/Pane.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +using namespace sv; + +// How Qt 6 turns touch into mouse events, as far as this class depends +// on it (qguiapplication.cpp, processTouchEvent(); qapplication.cpp, +// notify() and translateRawTouchEvent()): +// +// - Each touch event is offered to the widgets first. Only if none +// accepts it does Qt make a mouse event of it, from the point that +// came down first, and it goes on doing so for as long as the events +// are not accepted. Accepting the event that lifts that point would +// leave Qt's mouse button pressed for good. +// +// - The points of a TouchBegin that no widget accepts go to the first +// widget, from the one touched up to the window, that is subscribed +// to a gesture. Otherwise that is QScrollArea's viewport, which +// subscribes to PanGesture, and it would take the pane's second +// finger too. Subscribed to a gesture, the pane gets its points +// itself. The gesture is one that recognises nothing: it is there +// only to be subscribed to. +// +// - Updates to those points are not delivered to the pane after an +// unaccepted TouchBegin, except from the event in which another +// finger comes down: from that one to the TouchEnd, the pane gets +// every point on it. +// +// So a lone finger is seen only as Qt's mouse events. The pane's first +// touch event with two points in it is where the fingers take over: +// from there each touch event is accepted, so that Qt makes no mouse +// event of it, except the one that lifts the first finger, so that Qt +// makes the release that matches its press. A TouchBegin with two points +// in it is accepted at once, and then Qt makes no mouse events at all. + +namespace { + +class SubscriptionOnly : public QGestureRecognizer +{ +public: + Result recognize(QGesture *, QObject *, QEvent *) override { + return Ignore; + } +}; + +Qt::GestureType +subscriptionOnlyGesture() +{ + // The gesture manager owns the recognizer + static Qt::GestureType type = + QGestureRecognizer::registerRecognizer(new SubscriptionOnly); + return type; +} + +bool +isTouchScreen(const QPointingDevice *device) +{ + return device && + device->type() == QInputDevice::DeviceType::TouchScreen; +} + +} + +TouchGestures::TouchGestures(Pane *pane) : + QObject(pane), + m_pane(pane) +{ + m_longPressTimer.setSingleShot(true); + m_longPressTimer.setInterval(longPressMs); + connect(&m_longPressTimer, &QTimer::timeout, + this, &TouchGestures::longPressed); + + pane->setAttribute(Qt::WA_AcceptTouchEvents); + pane->grabGesture(subscriptionOnlyGesture()); + pane->installEventFilter(this); +} + +TouchGestures::~TouchGestures() +{ + stopWatchingMenu(); +} + +void +TouchGestures::setVerticalRange(const VerticalRange &range) +{ + m_verticalRange = range; +} + +bool +TouchGestures::eventFilter(QObject *watched, QEvent *event) +{ + if (m_menu && watched == m_menu) return menuEvent(event); + + if (watched != m_pane || m_replaying) return false; + + switch (event->type()) { + + case QEvent::TouchBegin: + case QEvent::TouchUpdate: + case QEvent::TouchEnd: + case QEvent::TouchCancel: { + // A touch pad is a mouse here + auto te = static_cast(event); + if (!isTouchScreen(te->pointingDevice())) return false; + return touchEvent(te); + } + + case QEvent::MouseButtonPress: + case QEvent::MouseButtonDblClick: + case QEvent::MouseMove: + case QEvent::MouseButtonRelease: { + // Qt's from a touch, and a platform's own (Windows), carry the + // touch screen + auto me = static_cast(event); + if (!isTouchScreen(me->pointingDevice())) return false; + return mouseEvent(me); + } + + default: + return false; + } +} + +bool +TouchGestures::touchEvent(QTouchEvent *e) +{ + // Returns true for all of them: the pane has no use for touch + // events. Accepted or not says whether Qt makes mouse events of it + + if (e->type() == QEvent::TouchCancel) { + // Qt sends a release for the press it made. In Passing it is the + // pane's; otherwise it is to be eaten + dropHeld(); + if (m_state != State::Passing && m_state != State::Idle) { + m_state = m_sourcePressSeen ? State::Ending : State::Idle; + } + m_points.clear(); + m_order.clear(); + m_sourceId = -1; + return true; + } + + if (e->type() == QEvent::TouchBegin) { + + restart(); + for (const QEventPoint &p : e->points()) { + setPoint(p.id(), p.position()); + } + + if (m_order.size() > 1) { + e->accept(); + startTwoFingers(); + } else { + m_sourceId = m_order.value(0, -1); + e->ignore(); + } + return true; + } + + bool sourceLifted = false; + + for (const QEventPoint &p : e->points()) { + if (p.state() == QEventPoint::State::Released) { + removePoint(p.id()); + if (p.id() == m_sourceId) { + sourceLifted = true; + m_sourceId = -1; + } + } else { + setPoint(p.id(), p.position()); + } + } + + if (m_state != State::TwoFingers && m_order.size() > 1) { + startTwoFingers(); + } + + if (m_state != State::TwoFingers) { + e->ignore(); + return true; + } + + if (m_order.size() > 1) { + updatePinch(); + } + + if (sourceLifted) { + e->ignore(); + } else { + e->accept(); + } + + if (e->type() == QEvent::TouchEnd) { + m_points.clear(); + m_order.clear(); + m_sourceId = -1; + m_state = (sourceLifted && m_sourcePressSeen) ? + State::Ending : State::Idle; + } + + return true; +} + +bool +TouchGestures::mouseEvent(QMouseEvent *e) +{ + QEvent::Type type = e->type(); + bool press = (type == QEvent::MouseButtonPress || + type == QEvent::MouseButtonDblClick); + bool release = (type == QEvent::MouseButtonRelease); + bool drag = (type == QEvent::MouseMove && + (e->buttons() & Qt::LeftButton)); + + // Hovering, and any button but the one a finger is, are not ours + if (!press && !release && !drag) return false; + if ((press || release) && e->button() != Qt::LeftButton) return false; + + // A lone finger's touch updates do not come here, its mouse events do + if (m_state == State::Idle || m_state == State::Holding || + m_state == State::Passing) { + m_lastMousePosition = e->position(); + if (m_points.contains(m_sourceId)) { + m_points[m_sourceId] = e->position(); + } + } + + switch (m_state) { + + case State::Idle: + if (!press) return false; // Qt's, from a touch that ended elsewhere + m_sourcePressSeen = true; + m_pressPosition = e->position(); + hold(e); + m_state = State::Holding; + m_longPressTimer.start(); + return true; + + case State::Holding: + if (drag && + (e->position() - m_pressPosition).manhattanLength() <= slop()) { + hold(e); + return true; + } + // Moved or lifted: the pane has all of it, this one last + replayHeld(); + m_state = release ? State::Idle : State::Passing; + return false; + + case State::Passing: + if (release) m_state = State::Idle; + return false; + + case State::LongPressed: + case State::Ending: + if (release) m_state = State::Idle; + return true; + + case State::TwoFingers: + return true; + } + + return false; +} + +bool +TouchGestures::menuEvent(QEvent *event) +{ + switch (event->type()) { + + case QEvent::MouseButtonPress: + case QEvent::MouseButtonDblClick: + case QEvent::MouseMove: + case QEvent::MouseButtonRelease: { + auto me = static_cast(event); + if (!isTouchScreen(me->pointingDevice())) return false; + if (event->type() == QEvent::MouseButtonPress || + event->type() == QEvent::MouseButtonDblClick) { + // Another touch, so that one is over: this is a tap + stopWatchingMenu(); + return false; + } + if (event->type() == QEvent::MouseButtonRelease) { + // Lifted: the menu is the user's now + stopWatchingMenu(); + if (m_state == State::LongPressed) m_state = State::Idle; + } + me->accept(); + return true; + } + + default: + return false; + } +} + +void +TouchGestures::longPressed() +{ + if (m_state != State::Holding) return; + + QPointF position = m_pressPosition; + dropHeld(); + m_state = State::LongPressed; + + // A right press is how the pane is asked for its menu + // (Pane::mousePressEvent). It is the mouse's, not the touch screen's, + // so this filter lets it through + QMouseEvent press(QEvent::MouseButtonPress, position, + m_pane->mapToGlobal(position), + Qt::RightButton, Qt::RightButton, + QGuiApplication::keyboardModifiers()); + QPointer self(this); + sendToPane(&press); + if (!self) return; + + // While the finger is down, Qt sends what it makes of it to the menu + // that has just opened (QWindowPrivate::forwardToPopup()) + QWidget *menu = QApplication::activePopupWidget(); + if (menu) { + m_menu = menu; + menu->installEventFilter(this); + } +} + +void +TouchGestures::stopWatchingMenu() +{ + if (m_menu) m_menu->removeEventFilter(this); + m_menu = nullptr; +} + +void +TouchGestures::restart() +{ + // What was left of the last touch, if its end did not come here (a + // menu took it, say). The pane must not be left pressed + if (m_state == State::Holding) { + dropHeld(); + } else if (m_state == State::Passing) { + endPaneDrag(); + } + + stopWatchingMenu(); + + m_state = State::Idle; + m_points.clear(); + m_order.clear(); + m_sourceId = -1; + m_sourcePressSeen = false; +} + +void +TouchGestures::startTwoFingers() +{ + if (m_state == State::Holding) { + dropHeld(); + } else if (m_state == State::Passing) { + endPaneDrag(); + } + + m_state = State::TwoFingers; + beginPinch(); +} + +void +TouchGestures::beginPinch() +{ + if (m_order.size() < 2) return; + + m_pinchA = m_order[0]; + m_pinchB = m_order[1]; + + QPointF a = m_points.value(m_pinchA); + QPointF b = m_points.value(m_pinchB); + + m_startCentre = (a + b) / 2.0; + m_startSpreadX = spread(a.x() - b.x()); + m_startSpreadY = spread(a.y() - b.y()); + + // Two fingers dragged together change their spread a little: that + // is not yet a pinch. Twice the slop, as Android's own detector has. + // Up the pane the same goes for their travel: the range stays put + // while the fingers scroll or zoom in time + double deadZone = 2 * slop(); + m_spreadX = PinchZoom::AxisMovement(deadZone); + m_spreadY = PinchZoom::AxisMovement(deadZone); + m_travelY = PinchZoom::AxisMovement(deadZone); + + m_startFramesPerPixel = + PinchZoom::framesPerPixel(m_pane->getZoomLevel()); + + m_anchorFrame = PinchZoom::frameAtX + (m_pane->getCentreFrame(), m_pane->getZoomLevel(), + m_pane->width(), m_startCentre.x()); + + // The range the fingers start from, within its limits (one typed + // in may not be), and the value under the point between them + m_haveRange = false; + double height = m_pane->height(); + VerticalZoom::Range shown; + if (height > 0 && m_verticalRange.get && m_verticalRange.set && + m_verticalRange.get(shown)) { + m_haveRange = true; + m_shownRange = shown; + m_startRange = VerticalZoom::limited + (shown, m_verticalRange.limits, height, m_startCentre.y()); + m_anchorY = m_startCentre.y(); + m_anchorValue = VerticalZoom::valueAtY + (m_startRange, height, m_anchorY); + m_anchorToMiddle = false; + + // Or rather the middle of the pitch on show, which a zoom about + // the fingers pushes out of the pane when it is far from them (a + // low voice). Asked for once a pinch: it is the same pitch + // however the pinch goes on + double middle = 0.0; + if (m_verticalRange.drawn && + VerticalZoom::middleShown(m_verticalRange.drawn(), m_startRange, + middle)) { + m_anchorValue = middle; + m_anchorY = VerticalZoom::yForValue(m_startRange, height, middle); + m_anchorToMiddle = true; + } + } +} + +void +TouchGestures::updatePinch() +{ + if (!m_points.contains(m_pinchA) || !m_points.contains(m_pinchB)) { + // One of the two was lifted and another is down: a new pinch + // from here + beginPinch(); + return; + } + + QPointF a = m_points.value(m_pinchA); + QPointF b = m_points.value(m_pinchB); + QPointF travel = (a + b) / 2.0 - m_startCentre; + double spreadX = spread(a.x() - b.x()); + double spreadY = spread(a.y() - b.y()); + double x = (a.x() + b.x()) / 2.0; + + // Each axis zooms by the spread along it, once that counts + m_spreadX.update(spreadX - m_startSpreadX, spreadY - m_startSpreadY); + double pinchY = m_spreadY.update(spreadY - m_startSpreadY, + spreadX - m_startSpreadX); + + // A range zoomed about the fingers goes with them up and down, as the + // time axis goes with them across + if (m_spreadY.isCounting()) m_travelY.start(travel.y()); + double travelY = m_travelY.update(travel.y(), travel.x()); + + if (m_spreadX.isCounting()) { + ZoomLevel current = m_pane->getZoomLevel(); + ZoomLevel level = PinchZoom::pinchedLevel + (current, m_startFramesPerPixel * m_startSpreadX / spreadX); + if (!(level == current)) { + m_pane->setZoomLevel(level); + } + } + + // The frame that was under the middle of the fingers stays there + ZoomLevel level = m_pane->getZoomLevel(); + int width = m_pane->width(); + sv_frame_t wanted = PinchZoom::centreFor(m_anchorFrame, level, width, x); + + // Within the audio, as a one-finger drag keeps it + // (Pane::dragTopLayer). Held at an end, the view stays while the + // fingers go on: what is under them then is what they hold + sv_frame_t centre = wanted; + sv_frame_t end = m_pane->getModelsEndFrame(); + if (centre >= end) centre = end - 1; + if (centre < 0) centre = 0; + if (centre != wanted) { + m_anchorFrame = PinchZoom::frameAtX(centre, level, width, x); + } + + if (centre != m_pane->getCentreFrame()) { + m_pane->setCentreFrame(centre); + } + + updateVerticalRange((m_startSpreadY + pinchY) / m_startSpreadY, + travelY); +} + +void +TouchGestures::updateVerticalRange(double factor, double travel) +{ + // Left as it is until the fingers plainly move up or down + if (!m_haveRange) return; + if (!m_spreadY.isCounting() && !m_travelY.isCounting()) return; + + double height = m_pane->height(); + if (!(height > 0)) return; + + // The value zoomed about goes as far up or down as the fingers have + // gone, with the range narrowed by as much as they have spread; the + // middle of the pitch towards the middle of the pane as it narrows, + // what was between the fingers with them + double y = m_anchorY; + if (m_anchorToMiddle) { + y = VerticalZoom::towardsMiddle(y, height, factor); + } + y += travel; + VerticalZoom::Range wanted = VerticalZoom::zoomedAbout + (m_startRange, factor, m_anchorValue, height, y); + VerticalZoom::Range range = VerticalZoom::limited + (wanted, m_verticalRange.limits, height, y); + + // Held at a limit, the range stays while the fingers go on: what is + // at y then is what is held, as at the ends of the audio + if (range != wanted) { + m_anchorValue = VerticalZoom::valueAtY(range, height, y); + } + + if (range != m_shownRange) { + m_shownRange = range; + m_verticalRange.set(range); + } +} + +void +TouchGestures::hold(QMouseEvent *e) +{ + m_held.push_back(std::unique_ptr(e->clone())); +} + +void +TouchGestures::replayHeld() +{ + m_longPressTimer.stop(); + + std::vector> held; + held.swap(m_held); + + QPointer self(this); + for (auto &e : held) { + sendToPane(e.get()); + if (!self) return; + } +} + +void +TouchGestures::dropHeld() +{ + m_longPressTimer.stop(); + m_held.clear(); +} + +void +TouchGestures::endPaneDrag() +{ + // A release where the finger is, as a mouse released there would + // end it + QPointF position = sourcePosition(); + QMouseEvent release(QEvent::MouseButtonRelease, position, + m_pane->mapToGlobal(position), + Qt::LeftButton, Qt::NoButton, + QGuiApplication::keyboardModifiers()); + sendToPane(&release); +} + +void +TouchGestures::sendToPane(QMouseEvent *e) +{ + bool wasReplaying = m_replaying; + m_replaying = true; + QPointer self(this); + QCoreApplication::sendEvent(m_pane, e); + if (self) m_replaying = wasReplaying; +} + +void +TouchGestures::setPoint(int id, QPointF position) +{ + if (!m_points.contains(id)) m_order.push_back(id); + m_points[id] = position; +} + +void +TouchGestures::removePoint(int id) +{ + m_points.remove(id); + m_order.removeAll(id); +} + +QPointF +TouchGestures::sourcePosition() const +{ + return m_points.value(m_sourceId, m_lastMousePosition); +} + +int +TouchGestures::slop() const +{ + // Qt's distance for a drag to start: in logical pixels, which on a + // phone are the platform's density-independent ones + return QGuiApplication::styleHints()->startDragDistance(); +} + +double +TouchGestures::spread(double distance) const +{ + // The fingers' spread along one axis, but never less than four + // times the slop: two fingers side by side are hardly apart up the + // pane, and a ratio of two such spreads could be anything + return std::max(std::fabs(distance), 4.0 * std::max(slop(), 1)); +} diff --git a/main/TouchGestures.h b/main/TouchGestures.h new file mode 100644 index 00000000..af4e256f --- /dev/null +++ b/main/TouchGestures.h @@ -0,0 +1,198 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TONY_TOUCH_GESTURES_H +#define TONY_TOUCH_GESTURES_H + +#include "PinchZoom.h" +#include "VerticalZoom.h" + +#include "base/BaseTypes.h" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +class QMouseEvent; +class QTouchEvent; + +namespace sv { +class Pane; +} + +/** + * Touch on one pane: pinch to zoom about the fingers, two fingers + * dragged to scroll, and a long press for the pane's right-button + * menu. MainWindow gives every pane one; the pane owns it. + * + * The time axis zooms by the fingers' spread across the pane and + * follows them across it. Given a vertical range (setVerticalRange()), + * the range zooms by their spread up the pane, about the pitch on show + * if there is any, and follows them up and down, each axis only once + * the fingers plainly move along it + * (PinchZoom::AxisMovement): a pinch across the pane leaves the range + * alone, and a pinch up the pane the zoom level. + * + * One finger is left to Qt, which makes mouse events of a touch that + * nothing accepts: tapping, dragging in Navigate mode and selecting in + * the selection strip go through the pane's mouse handling as they + * always have. What this adds works on that stream of mouse events: + * + * - The press is held back until the finger moves, lifts, or has been + * down long enough to be a long press. Held back, it cannot start a + * selection or a drag, or schedule a move of the playhead, that a + * long press or a second finger would leave behind. When the finger + * moves or lifts, the pane gets everything held, in order. + * + * - When a second finger comes down, a drag the first had started is + * ended where it is, and the fingers drive the view until they are + * all up. The first finger's mouse events are eaten meanwhile. + * + * - The menu a long press opens comes up under the finger, and QMenu + * takes the release of a button that has moved over an item for a + * choice of it. A resting finger moves, and the first item is Undo: + * so the rest of that finger's events are kept from the menu, which + * then waits for a tap, as a phone's own menus do. + * + * How Qt decides what becomes a mouse event, and why the pane + * subscribes to a gesture that never happens, is in TouchGestures.cpp. + * Input from a mouse is not touched. + */ +class TouchGestures : public QObject +{ + Q_OBJECT + +public: + explicit TouchGestures(sv::Pane *pane); + virtual ~TouchGestures(); + + /** + * The range of values the pane's layers are drawn over, bottom to + * top, for two fingers to zoom and scroll. Changing it makes no + * undo step. A pane without one moves in time only. + */ + struct VerticalRange { + /// The range shown now, on the pane's scale; false if none + std::function get; + /// Show another + std::function set; + VerticalZoom::Limits limits; + /// The values drawn on it in the pane's time range now (its + /// pitch), for a zoom to keep in view: it zooms about their + /// middle (VerticalZoom::middleShown()), which comes towards + /// the middle of the pane as it zooms in. Unset, or none on + /// the range: about what is between the fingers + std::function()> drawn; + }; + + void setVerticalRange(const VerticalRange &range); + + /// How long one finger must rest for a long press, in ms + static const int longPressMs = 500; + +protected: + bool eventFilter(QObject *watched, QEvent *event) override; + +private: + enum class State { + Idle, // no touch, or one whose press has not come + Holding, // one finger resting: its press held back + Passing, // one finger moving: its events go to the pane + LongPressed, // the menu asked for: the finger's events eaten + TwoFingers, // the fingers drive the view: mouse events eaten + Ending // all up: the release Qt makes is still to eat + }; + + bool touchEvent(QTouchEvent *); + bool mouseEvent(QMouseEvent *); + bool menuEvent(QEvent *); + void longPressed(); + void stopWatchingMenu(); + + void restart(); + void startTwoFingers(); + void beginPinch(); + void updatePinch(); + void updateVerticalRange(double factor, double travel); + + void hold(QMouseEvent *); + void replayHeld(); + void dropHeld(); + void endPaneDrag(); + void sendToPane(QMouseEvent *); + + void setPoint(int id, QPointF position); + void removePoint(int id); + QPointF sourcePosition() const; + int slop() const; + double spread(double distance) const; + + sv::Pane *m_pane; + State m_state = State::Idle; + + QTimer m_longPressTimer; + QPointer m_menu; // the one a long press opened, until lifted + std::vector> m_held; + QPointF m_pressPosition; + QPointF m_lastMousePosition; + bool m_replaying = false; + + // The touch points down on the pane, in pane coordinates, and the + // order they came down in + QHash m_points; + QList m_order; + + // The point Qt makes the mouse events from, while it is down, and + // whether its press came to this pane + int m_sourceId = -1; + bool m_sourcePressSeen = false; + + // The two points of the pinch, and the view when they came down + int m_pinchA = -1; + int m_pinchB = -1; + QPointF m_startCentre; + double m_startSpreadX = 0.0; + double m_startSpreadY = 0.0; + double m_startFramesPerPixel = 1.0; + double m_anchorFrame = 0.0; + + // How much of the fingers' movement since then counts: the change + // in their spread across the pane and up it, and the travel of + // the point between them up it + PinchZoom::AxisMovement m_spreadX; + PinchZoom::AxisMovement m_spreadY; + PinchZoom::AxisMovement m_travelY; + + // The vertical range, as it was when they came down and as last + // shown, and the value it zooms about: the middle of what was drawn + // on it, which goes towards the middle of the pane, or what was + // under the point between the fingers; and where that was + VerticalRange m_verticalRange; + bool m_haveRange = false; + VerticalZoom::Range m_startRange; + VerticalZoom::Range m_shownRange; + double m_anchorValue = 0.0; + double m_anchorY = 0.0; + bool m_anchorToMiddle = false; +}; + +#endif diff --git a/main/TouchMenuStyle.cpp b/main/TouchMenuStyle.cpp new file mode 100644 index 00000000..67640902 --- /dev/null +++ b/main/TouchMenuStyle.cpp @@ -0,0 +1,255 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#include "TouchMenuStyle.h" +#include "PopupArea.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +TouchMenuStyle::TouchMenuStyle(QStyle *base) : + QProxyStyle(base), + m_pressY(0), + m_scrolledToY(0), + m_dragging(false), + m_edgeMargin(0) +{ +} + +void +TouchMenuStyle::setSafeAreaWindow(QWidget *window) +{ + m_safeAreaWindow = window; +} + +void +TouchMenuStyle::setEdgeMargin(int pixels) +{ + m_edgeMargin = pixels; +} + +QRect +TouchMenuStyle::usableArea(const QWidget *popup) const +{ + QScreen *screen = (popup ? popup->screen() : nullptr); + if (!screen) screen = QGuiApplication::primaryScreen(); + if (!screen) return QRect(); + + // Android's available geometry is the whole of the activity, bars + // and all (qandroidplatformscreen.cpp, handleLayoutSizeChanged() in + // androidjnimain.cpp); the bars are the window's safe area margins + QRect window; + QMargins safeArea; + if (QWidget *w = m_safeAreaWindow.data()) { + if (w->isVisible()) { + window = QRect(w->mapToGlobal(QPoint(0, 0)), w->size()); +#if QT_VERSION >= QT_VERSION_CHECK(6, 9, 0) + if (QWindow *handle = w->windowHandle()) { + safeArea = handle->safeAreaMargins(); + } +#endif + } + } + + QRect available = screen->availableGeometry(); + QRect usable = PopupArea::usable(available, window, safeArea, + m_edgeMargin); + + // Said when it changes, for a report from a phone + if (usable != m_lastUsable) { + m_lastUsable = usable; + std::cerr << "TouchMenuStyle: popups within " << usable.x() << "," + << usable.y() << " " << usable.width() << "x" + << usable.height() << " of " << available.width() << "x" + << available.height() << ", safe area margins " + << safeArea.left() << "," << safeArea.top() << "," + << safeArea.right() << "," << safeArea.bottom() + << std::endl; + } + return usable; +} + +bool +TouchMenuStyle::isComboList(const QWidget *widget) +{ + return widget && widget->windowType() == Qt::Popup && + qobject_cast(widget->parentWidget()); +} + +int +TouchMenuStyle::styleHint(StyleHint hint, + const QStyleOption *option, + const QWidget *widget, + QStyleHintReturn *returnData) const +{ + if (hint == SH_Menu_Scrollable) return 1; + return QProxyStyle::styleHint(hint, option, widget, returnData); +} + +int +TouchMenuStyle::pixelMetric(PixelMetric metric, + const QStyleOption *option, + const QWidget *widget) const +{ + int value = QProxyStyle::pixelMetric(metric, option, widget); + + // A strip ten pixels high at the menu's edge is where a finger has to + // press for the arrows to scroll it; missing it chooses the item + // beside it + if (metric == PM_MenuScrollerHeight) return 3 * value; + + // QMenu keeps this far from every side of the screen's available + // geometry, when it places a menu, sizes one too tall to fit and + // scrolls it (QMenuPrivate::popup(), scrollMenu() in qmenu.cpp): so + // the whole of what it does stays inside the usable area + if (metric == PM_MenuDesktopFrameWidth && + qobject_cast(widget)) { + QScreen *screen = widget->screen(); + if (!screen) screen = QGuiApplication::primaryScreen(); + if (screen) { + return PopupArea::menuFrame(screen->availableGeometry(), + usableArea(widget), value); + } + } + + return value; +} + +void +TouchMenuStyle::polish(QWidget *widget) +{ + QProxyStyle::polish(widget); + if (qobject_cast(widget) || isComboList(widget)) { + widget->installEventFilter(this); + } +} + +void +TouchMenuStyle::unpolish(QWidget *widget) +{ + if (qobject_cast(widget) || isComboList(widget)) { + widget->removeEventFilter(this); + } + QProxyStyle::unpolish(widget); +} + +int +TouchMenuStyle::rowHeight(QMenu *menu) +{ + // The first item that is not a separator, which are thinner + for (QAction *action : menu->actions()) { + if (!action->isVisible() || action->isSeparator()) continue; + int height = menu->actionGeometry(action).height(); + if (height > 0) return height; + } + return std::max(8, menu->fontMetrics().height()); +} + +bool +TouchMenuStyle::eventFilter(QObject *object, QEvent *event) +{ + // A combo box's list is placed by QComboBox::showPopup() inside the + // available geometry and then shown: moved into the usable area + // before it is, as its list scrolls whatever its height + if (event->type() == QEvent::Show && object->isWidgetType() && + isComboList(static_cast(object))) { + QWidget *list = static_cast(object); + QRect fitted = PopupArea::fit(list->geometry(), usableArea(list)); + if (fitted != list->geometry()) list->setGeometry(fitted); + return false; + } + + QMenu *menu = qobject_cast(object); + if (!menu) return false; + + switch (event->type()) { + + case QEvent::MouseButtonPress: { + // Passed on: the press may be a tap, and QMenu takes one on its + // scroll arrows as its own + QMouseEvent *e = static_cast(event); + if (e->button() != Qt::LeftButton) break; + m_menu = menu; + m_pressY = m_scrolledToY = e->globalPosition().y(); + m_dragging = false; + break; + } + + case QEvent::MouseMove: { + QMouseEvent *e = static_cast(event); + if (m_menu != menu || !(e->buttons() & Qt::LeftButton)) break; + + double y = e->globalPosition().y(); + + if (!m_dragging) { + // Until the finger has gone this far, QMenu follows it + if (std::abs(y - m_pressY) < QApplication::startDragDistance()) { + break; + } + m_dragging = true; + // Nothing is chosen by a drag, and a submenu the press opened + // is closed + menu->setActiveAction(nullptr); + } + + // A row for each row's height the finger has moved: the finger + // moving down pulls the items above into view, as a wheel turned + // up does. The wheel event goes where QMenu takes it, inside it + int row = rowHeight(menu); + QPointF centre(menu->rect().center()); + while (std::abs(y - m_scrolledToY) >= row) { + int direction = (y > m_scrolledToY ? 1 : -1); + QWheelEvent wheel(centre, menu->mapToGlobal(centre), QPoint(), + QPoint(0, 120 * direction), Qt::NoButton, + Qt::NoModifier, Qt::NoScrollPhase, false); + QCoreApplication::sendEvent(menu, &wheel); + m_scrolledToY += direction * row; + } + + // Not QMenu's: it would pick out the item under the finger + return true; + } + + case QEvent::MouseButtonRelease: { + if (m_menu != menu) break; + bool dragged = m_dragging; + m_menu = nullptr; + m_dragging = false; + // The end of a drag is not a choice + if (dragged) return true; + break; + } + + case QEvent::Hide: + if (m_menu == menu) { + m_menu = nullptr; + m_dragging = false; + } + break; + + default: + break; + } + + return false; +} diff --git a/main/TouchMenuStyle.h b/main/TouchMenuStyle.h new file mode 100644 index 00000000..4e144ba3 --- /dev/null +++ b/main/TouchMenuStyle.h @@ -0,0 +1,100 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TONY_TOUCH_MENU_STYLE_H +#define TONY_TOUCH_MENU_STYLE_H + +#include +#include + +class QMenu; + +/** + * Menus for a touch screen, as the application's style on Android (the + * style it wraps does everything else). + * + * A menu taller than the screen scrolls, one column high, where Qt's + * styles would lay it out in columns and let the ones that do not fit + * run off the screen: QMenu scrolls if its style answers + * SH_Menu_Scrollable. The scroll arrows it then shows are made tall + * enough for a finger. A finger dragged up or down a menu scrolls it as + * a phone's lists scroll, a row at a time, through the wheel events + * QMenu scrolls by; the lift that ends a drag chooses nothing. A tap + * chooses an item as before. + * + * Menus and combo box lists stay inside the part of the screen a popup + * may use (PopupArea): clear of the system bars an app drawn edge to + * edge lies under, which the main window's safe area margins give, and + * of the top and bottom edges of the screen by edgeMargin. + * + * Built everywhere and tested on the desktop, whose style is left as it + * is: only main() on Android installs it. + */ +class TouchMenuStyle : public QProxyStyle +{ + Q_OBJECT + +public: + // The application's style, or base if given, with menus for touch + TouchMenuStyle(QStyle *base = nullptr); + + // The window whose safe area popups keep inside: the main window, + // which covers the screen on a phone. None by default + void setSafeAreaWindow(QWidget *window); + + // How far popups keep from the top and bottom of the screen, in + // pixels: 0 by default + void setEdgeMargin(int pixels); + + // Where popup may be, in global coordinates + QRect usableArea(const QWidget *popup) const; + + int styleHint(StyleHint hint, + const QStyleOption *option = nullptr, + const QWidget *widget = nullptr, + QStyleHintReturn *returnData = nullptr) const override; + + int pixelMetric(PixelMetric metric, + const QStyleOption *option = nullptr, + const QWidget *widget = nullptr) const override; + + // Each menu the style is given watches for a finger dragged over it + void polish(QWidget *widget) override; + void unpolish(QWidget *widget) override; + using QProxyStyle::polish; + using QProxyStyle::unpolish; + +protected: + bool eventFilter(QObject *object, QEvent *event) override; + +private: + // The height a finger moves to scroll the menu by one row + static int rowHeight(QMenu *menu); + + // Whether widget is a combo box's list, a popup window of its own + static bool isComboList(const QWidget *widget); + + // The menu pressed on, where, how far the scrolling has followed the + // finger, and whether the press has become a drag + QPointer m_menu; + double m_pressY; + double m_scrolledToY; + bool m_dragging; + + QPointer m_safeAreaWindow; + int m_edgeMargin; + mutable QRect m_lastUsable; +}; + +#endif diff --git a/main/VerticalZoom.cpp b/main/VerticalZoom.cpp new file mode 100644 index 00000000..86abfc21 --- /dev/null +++ b/main/VerticalZoom.cpp @@ -0,0 +1,185 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#include "VerticalZoom.h" + +#include +#include + +namespace VerticalZoom +{ + +namespace { + +// Onto the axis the pane spaces evenly: log10, as svgui's LogRange +double map(bool log, double value) +{ + return log ? std::log10(value) : value; +} + +double unmap(bool log, double point) +{ + return log ? std::pow(10.0, point) : point; +} + +// False for NaN as well +bool shows(const Range &range) +{ + return range.max > range.min && (!range.log || range.min > 0.0); +} + +} + +Limits +pitchLimits() +{ + Limits limits; + limits.lowest = 27.5; // A0 + limits.highest = 4186.009; // C8 + limits.narrowest = std::pow(2.0, 4.0 / 12.0); // a major third + return limits; +} + +double +valueAtY(const Range &range, double height, double y) +{ + double bottom = map(range.log, range.min); + double top = map(range.log, range.max); + return unmap(range.log, bottom + (top - bottom) * (height - y) / height); +} + +double +yForValue(const Range &range, double height, double value) +{ + double bottom = map(range.log, range.min); + double top = map(range.log, range.max); + return height - height * (map(range.log, value) - bottom) / (top - bottom); +} + +Range +zoomedAbout(const Range &range, double factor, double value, + double height, double y) +{ + double span = (map(range.log, range.max) - map(range.log, range.min)) + / factor; + double bottom = map(range.log, value) - span * (height - y) / height; + + Range zoomed; + zoomed.min = unmap(range.log, bottom); + zoomed.max = unmap(range.log, bottom + span); + zoomed.log = range.log; + return zoomed; +} + +Range +limited(const Range &range, const Limits &limits, double height, double y) +{ + bool log = range.log; + + Range all; + all.min = limits.lowest; + all.max = limits.highest; + all.log = log; + + if (!shows(range)) return all; + + Range result = range; + + if (result.max < result.min * limits.narrowest) { + + // Widened about what is at y, or at the edge the fingers are + // beyond, as far as the limits have it + double at = std::min(std::max(y, 0.0), height); + double value = std::min(std::max(valueAtY(result, height, at), + limits.lowest), + limits.highest); + + if (log) { + double span = std::log10(result.max / result.min); + result = zoomedAbout(result, + span / std::log10(limits.narrowest), + value, height, at); + } else { + // min + (max - min) * above = value, with max = narrowest * min + double above = (height - at) / height; + result.min = value / (1.0 + above * (limits.narrowest - 1.0)); + result.max = result.min * limits.narrowest; + } + } + + double span = map(log, result.max) - map(log, result.min); + double lowest = map(log, limits.lowest); + double highest = map(log, limits.highest); + + if (span >= highest - lowest) return all; + + // Moved along the mapped axis, so that it keeps its size on screen + double shift = 0.0; + if (map(log, result.min) < lowest) { + shift = lowest - map(log, result.min); + } else if (map(log, result.max) > highest) { + shift = highest - map(log, result.max); + } + + if (shift != 0.0) { + double bottom = map(log, result.min) + shift; + result.min = unmap(log, bottom); + result.max = unmap(log, bottom + span); + // Exactly on the limit, not a rounding error either side of it + if (shift > 0.0) result.min = limits.lowest; + else result.max = limits.highest; + } + + return result; +} + +bool +middleShown(const std::vector &values, const Range &range, + double &middle) +{ + if (!shows(range)) return false; + + std::vector shown; + shown.reserve(values.size()); + for (double value : values) { + // False for NaN as well + if (value >= range.min && value <= range.max) { + shown.push_back(map(range.log, value)); + } + } + if (shown.empty()) return false; + + // An octave jump at the start of a note, or a breath taken for a + // pitch, is not what is sung: a few points either way are left out + size_t stray = shown.size() / 20; + auto lowest = shown.begin() + stray; + auto highest = shown.end() - 1 - stray; + std::nth_element(shown.begin(), lowest, shown.end()); + double low = *lowest; + std::nth_element(shown.begin(), highest, shown.end()); + double high = *highest; + + middle = unmap(range.log, (low + high) / 2.0); + return true; +} + +double +towardsMiddle(double y, double height, double factor) +{ + if (!(factor > 1.0)) return y; + double middle = height / 2.0; + return middle + (y - middle) / factor; +} + +} diff --git a/main/VerticalZoom.h b/main/VerticalZoom.h new file mode 100644 index 00000000..9b3ac32e --- /dev/null +++ b/main/VerticalZoom.h @@ -0,0 +1,111 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TONY_VERTICAL_ZOOM_H +#define TONY_VERTICAL_ZOOM_H + +#include + +/** + * The arithmetic of two fingers on a pane's vertical axis: the value + * at a height in the pane, the range that zooms about a value held at + * a height, the limits a range is kept within, and the value a zoom is + * about when the pane has pitch to keep in view. TouchGestures does as + * the answers say. + * + * The mapping is svgui's CoordinateScale's for a vertical scale: y + * from the top of the pane, the range's minimum at the bottom edge (y + * is the height) and its maximum at the top (y is 0), the values + * between spaced evenly on a linear scale and by ratio on a + * logarithmic one. Zooming and scrolling happen on that mapped axis, + * so that on a log scale an octave keeps its height wherever it is. + * + * Nothing here touches a view, so all of it is tested without a + * window (TestVerticalZoom). + */ +namespace VerticalZoom +{ + /// The values at the bottom and the top of a pane, and the scale + struct Range { + double min = 0.0; + double max = 0.0; + bool log = false; + }; + + inline bool operator==(const Range &a, const Range &b) { + return a.min == b.min && a.max == b.max && a.log == b.log; + } + inline bool operator!=(const Range &a, const Range &b) { + return !(a == b); + } + + /// How far a range may go + struct Limits { + double lowest = 0.0; // its min no lower than this + double highest = 0.0; // its max no higher + double narrowest = 1.0; // its max at least this many times its min + }; + + /** + * For a pitch track: from A0 to C8, the piano's range, which holds + * any note that is sung and the default range with room to spare; + * and four semitones at the narrowest. + */ + Limits pitchLimits(); + + /// The value at y in a pane of the given height showing range + double valueAtY(const Range &range, double height, double y); + + /// Where value is in a pane of the given height showing range + double yForValue(const Range &range, double height, double value); + + /** + * The range factor times narrower than range, on its own scale, + * that shows value at y: above 1 zooms in, below 1 out. With a + * factor of 1 it is range scrolled, whatever it had at y. + */ + Range zoomedAbout(const Range &range, double factor, double value, + double height, double y); + + /** + * range within limits: widened about y, if narrower than they + * allow; then moved inside them, or made all of them if wider. + * A range within the limits comes back as it is. One that shows + * nothing (no width, or a log scale from zero) becomes all of them. + */ + Range limited(const Range &range, const Limits &limits, + double height, double y); + + /** + * What a zoom of range is anchored at to keep values in view (the + * pitch a pane draws, say): the middle, on range's scale, of those + * of them that range shows, half way between the lowest and the + * highest, less a twentieth of them at each end, so that a few + * that stray from the rest do not move it. False if range shows + * none of them, or shows nothing. + */ + bool middleShown(const std::vector &values, const Range &range, + double &middle); + + /** + * Where a value that was at y is held as a range zooms by factor + * about it: nearer the middle of the pane as it zooms in, its + * distance from there divided by the factor, so that what is about + * it comes into the middle as it grows; where it was as it zooms + * out. + */ + double towardsMiddle(double y, double height, double factor); +} + +#endif diff --git a/main/dev/DevChecks.cpp b/main/dev/DevChecks.cpp new file mode 100644 index 00000000..6dce211e --- /dev/null +++ b/main/dev/DevChecks.cpp @@ -0,0 +1,2718 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifdef TONY_DEV_CHECKS + +#include "DevChecks.h" + +#include "../AudioDriverMenus.h" +#include "../MainWindow.h" +#include "../RealtimePitchTracker.h" +#include "../SingingTakes.h" +#include "../TakeDiff.h" +#include "../TakeTiming.h" + +#include "audio/AudioCallbackPlaySource.h" +#include "audio/AudioCallbackRecordTarget.h" +#include "data/fileio/FileSource.h" +#include "data/fileio/WavFileReader.h" +#include "data/model/NoteModel.h" +#include "data/model/SparseTimeValueModel.h" +#include "layer/Layer.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +using std::cerr; +using std::endl; +using std::vector; + +using namespace sv; + +namespace { + +// Signed, to a tenth of a millisecond: the tolerance is 6 ms +QString +signedMs(double seconds) +{ + const double ms = std::round(seconds * 10000.0) / 10.0; + return QString("%1%2 ms").arg(ms > 0.0 ? "+" : "") + .arg(ms == 0.0 ? 0.0 : ms, 0, 'f', 1); +} + +QString +unsignedMs(double seconds) +{ + return QString("%1 ms").arg(seconds * 1000.0, 0, 'f', 1); +} + +QString +secondsText(double seconds) +{ + return QString("%1").arg(seconds, 0, 'f', 2); +} + +// A value as the session file holds it: Event::toXml() writes it with +// QString::arg(), which keeps six significant figures +float +asSaved(float value) +{ + return QString("%1").arg(value).toFloat(); +} + +// The events as a session saved and read back has them, in order, so +// that they can be compared with those of the session read back +EventVector +asSaved(const EventVector &events) +{ + EventVector saved; + saved.reserve(events.size()); + for (const Event &e : events) { + Event s = e; + if (s.hasValue()) s = s.withValue(asSaved(s.getValue())); + if (s.hasLevel()) s = s.withLevel(asSaved(s.getLevel())); + saved.push_back(s); + } + std::sort(saved.begin(), saved.end()); + return saved; +} + +// Whether the events after the reopen are those before the save, and +// in words how many there are, or the first that differs +bool +sameEvents(const EventVector &before, const EventVector &after, + sv_samplerate_t rate, QString what, QString &description) +{ + const EventVector b = asSaved(before); + const EventVector a = asSaved(after); + if (a == b) { + description = QString("%1 %2, the same").arg(a.size()).arg(what); + return true; + } + size_t i = 0; + while (i < a.size() && i < b.size() && a[i] == b[i]) ++i; + sv_frame_t frame = (i < b.size() ? b[i].getFrame() : + i < a.size() ? a[i].getFrame() : 0); + description = QString("%1 %2, %3 before the save; the first difference " + "at %4 s") + .arg(a.size()).arg(what).arg(b.size()) + .arg(rate > 0 ? secondsText(double(frame) / rate) : QString("?")); + return false; +} + +double +median(vector values) +{ + if (values.empty()) return 0.0; + std::sort(values.begin(), values.end()); + const size_t n = values.size(); + return n % 2 ? values[n / 2] : 0.5 * (values[n / 2 - 1] + values[n / 2]); +} + +// A level, full scale 1, in dBFS; exact silence in words +QString +levelText(double level) +{ + if (level <= 0.0) return "silence"; + return QString("%1 dBFS").arg(20.0 * std::log10(level), 0, 'f', 1); +} + +// The peak of each channel of an audio file, full scale 1: the file as +// it is, not normalised as the session's models are read +vector +channelPeaks(QString path, QString &error) +{ + error = ""; + if (path == "") { + error = DevChecks::tr("its raw recording was not seen"); + return {}; + } + FileSource source(path); + WavFileReader reader(source); + const int channels = reader.getChannelCount(); + if (!reader.isOK() || channels < 1) { + error = DevChecks::tr("its raw recording \"%1\" could not be read: %2") + .arg(path).arg(reader.getError()); + return {}; + } + const floatvec_t data = reader.getInterleavedFrames + (0, reader.getFrameCount()); + vector peaks(channels, 0.f); + for (size_t i = 0; i < data.size(); ++i) { + float &p = peaks[i % size_t(channels)]; + p = std::max(p, std::fabs(data[i])); + } + return peaks; +} + +// The inputs, counting from 1, in words: "input 2", "inputs 1 and 2" +QString +inputsText(const vector &inputs) +{ + if (inputs.empty()) return DevChecks::tr("no input"); + QStringList names; + for (int i : inputs) names << QString::number(i + 1); + if (names.size() == 1) return DevChecks::tr("input %1").arg(names[0]); + const QString last = names.takeLast(); + return DevChecks::tr("inputs %1 and %2").arg(names.join(", ")).arg(last); +} + +// How many frames an audio file holds, or -1 and why not +sv_frame_t +frameCount(QString path, QString &error) +{ + error = ""; + if (path == "") { + error = DevChecks::tr("its raw recording was not seen"); + return -1; + } + FileSource source(path); + WavFileReader reader(source); + if (!reader.isOK()) { + error = DevChecks::tr("its raw recording \"%1\" could not be read: %2") + .arg(path).arg(reader.getError()); + return -1; + } + return reader.getFrameCount(); +} + +// A time on the reference's timeline in whole frames, as the runner +// selected its punch-ins +sv_frame_t +frameAt(double seconds, sv_samplerate_t rate) +{ + return sv_frame_t(std::llround(seconds * rate)); +} + +QString +rangeText(double from, double to) +{ + return DevChecks::tr("%1 to %2 s").arg(secondsText(from)) + .arg(secondsText(to)); +} + +QString +coverageText(const Coverage &coverage, sv_samplerate_t rate) +{ + QStringList ranges; + for (const Coverage::Range &r : coverage.getRanges()) { + ranges << rangeText(double(r.start) / rate, double(r.end) / rate); + } + return ranges.isEmpty() ? DevChecks::tr("nothing") : ranges.join(", "); +} + +// Where a run's judged sweeps landed, in words, and a problem for each +// one not found or further off than items 1 and 2 allow +QString +offsetsOf(const LatencyCheck::TakeSummary &s, QStringList &problems) +{ + QStringList offsets; + for (const LatencyCheck::EventResult &e : s.events) { + if (!e.arrival.found) { + offsets << DevChecks::tr("not found"); + problems << DevChecks::tr("the sweep at %1 s was not found") + .arg(secondsText(e.expectedSeconds)); + continue; + } + offsets << signedMs(e.arrival.errorSeconds); + if (std::fabs(e.arrival.errorSeconds) > DevChecks::kPlacementSeconds) { + problems << DevChecks::tr("the sweep at %1 s landed %2 off") + .arg(secondsText(e.expectedSeconds)) + .arg(signedMs(e.arrival.errorSeconds)); + } + } + if (s.events.empty()) problems << DevChecks::tr("no sweep was judged"); + return offsets.isEmpty() ? DevChecks::tr("none judged") : offsets.join(", "); +} + +// What TakeDiff::audioOutside() found, in words +QString +audioText(const TakeDiff::AudioDiff &d, sv_samplerate_t rate) +{ + if (d.pass) return DevChecks::tr("the same, bit for bit"); + return DevChecks::tr("%1 frames differ, the first at %2 s, by up to %3") + .arg(d.differences) + .arg(double(d.firstDifference) / rate, 0, 'f', 3) + .arg(levelText(d.largestDifference)); +} + +// How many of the events lie wholly outside the window, as +// TakeDiff::eventsOutside() counts them: what it compared +int +countOutside(const EventVector &events, const Coverage::Range &window) +{ + int n = 0; + for (const Event &e : events) { + const sv_frame_t end = e.getFrame() + + std::max(e.getDuration(), sv_frame_t(1)); + if (end <= window.start || e.getFrame() >= window.end) ++n; + } + return n; +} + +// What TakeDiff::eventsOutside() found, in words: where it looked is +// "where", and the events before the change that lie there +QString +eventsText(const TakeDiff::EventDiff &d, const EventVector &before, + QString what, QString where, sv_samplerate_t rate) +{ + const int compared = countOutside(before, d.window); + if (d.pass) { + return DevChecks::tr("%1 %2 %3, unchanged").arg(compared).arg(what) + .arg(where); + } + return DevChecks::tr("%1 %2 %3: %4 added, %5 removed, %6 changed, the " + "first at %7 s") + .arg(compared).arg(what).arg(where).arg(d.added.size()) + .arg(d.removed.size()).arg(d.changed.size()) + .arg(double(d.firstDifference) / rate, 0, 'f', 3); +} + +// The number of seconds a status text counts down, if it is the lead-in's +// countdown as TakeTiming words it; 0 if it is anything else +int +countdownOf(QString status) +{ + const QRegularExpressionMatch m = + QRegularExpression("[0-9]+").match(status); + if (!m.hasMatch()) return 0; + const int n = m.captured(0).toInt(); + TakeTiming timing; + timing.rate = 1; + timing.position = n; + timing.preRoll = n; + return timing.countdownText(0) == status ? n : 0; +} + +} // namespace + +QString +CheckResult::verdictName(Verdict verdict) +{ + switch (verdict) { + case Verdict::Pass: return "Pass"; + case Verdict::Fail: return "Fail"; + case Verdict::Measured: return "Measured"; + case Verdict::Skipped: return "Skipped"; + } + return ""; +} + +int +DevReport::count(CheckResult::Verdict verdict) const +{ + int n = 0; + for (const CheckResult &c : checks) { + if (c.verdict == verdict) ++n; + } + return n; +} + +DevChecks::DevChecks(MainWindow *window, AudioCheckRunner *runner) : + QObject(window), + m_window(window), + m_runner(runner), + m_timer(new QTimer(this)), + m_stage(-1), + m_begun(false), + m_inPoll(false), + m_runnerRunning(false), + m_reopening(false), + m_saved(false), + m_observer(new TakeObserver(window, this)), + m_observedPunchIn(0), + m_haveFresh(false), + m_reopened(false) +{ + m_timer->setInterval(kPollMs); + connect(m_timer, &QTimer::timeout, this, &DevChecks::poll); + + // Direct: the result has no metatype, and the stage waiting for it + // is looked at on the next poll + connect(runner, &AudioCheckRunner::finished, + this, &DevChecks::runnerFinished); + + // Direct as well: a take is watched from the poll of the runner's + // that started it + connect(runner, &AudioCheckRunner::progress, + this, &DevChecks::runnerProgress); +} + +DevChecks::~DevChecks() +{ + // Deleted by the window in its destructor, before the runner. The + // run ends without a word, as the runner's does, and so does the + // runner's run it started: without the Stop path, which would splice + // a take and start its analysis in the middle of the window's + // teardown. The take in progress is left to the window + m_timer->stop(); + m_observer->stop(); + if (m_stage >= 0) { + cerr << "DevChecks: the window is going; the run ends" << endl; + if (m_runnerRunning && m_runner) m_runner->abandon(); + m_runnerRunning = false; + m_stage = -1; + } +} + +vector +DevChecks::longPunchIns(const LatencyCheck::Layout &layout) +{ + // All that judgeTake() reads for a sweep, and the little more that + // LatencyCheck::punchInsFor() leaves + const double before = LatencyCheck::kSearchSeconds + + LatencyCheck::kJudgeMarginSeconds + LatencyCheck::kPunchInSlackSeconds; + const double after = LatencyCheck::kSearchSeconds + + LatencyCheck::kSweepSeconds + LatencyCheck::kJudgeMarginSeconds + + LatencyCheck::kPunchInSlackSeconds; + if (layout.rate <= 0) return {}; + const double length = double(layout.length) / layout.rate; + + vector punchIns; + for (double share : { 0.25, 0.625 }) { + for (const LatencyCheck::Event &e : layout.events) { + const double at = double(e.sweepStart) / layout.rate; + if (at < share * length) continue; + punchIns.push_back(LatencyCheck::PunchIn(at - before, at + after)); + break; + } + } + if (punchIns.size() != 2 || punchIns[0].end > punchIns[1].start || + punchIns[1].end > length) { + return {}; + } + return punchIns; +} + +vector +DevChecks::freshPunchIns() +{ + return { LatencyCheck::PunchIn(6.3, 10.2), + LatencyCheck::PunchIn(16.8, 21.2) }; +} + +vector +DevChecks::joinPunchIns() +{ + return { LatencyCheck::PunchIn(26.0, 28.7), + LatencyCheck::PunchIn(28.7, 32.0) }; +} + +LatencyCheck::PunchIn +DevChecks::reRecording() +{ + return LatencyCheck::PunchIn(19.2, 21.2); +} + +LatencyCheck::PunchIn +DevChecks::nearTheStart() +{ + return LatencyCheck::PunchIn(1.0, 4.2); +} + +QString +DevChecks::nextScratchFolder(QString directory, QString inUse) +{ + const QDir dir(directory); + const QRegularExpression name("^dev-checks-[0-9]+$"); + + // Only folders by our own name: the directory may hold anything else + const QStringList folders = + dir.entryList(QDir::Dirs | QDir::NoDotAndDotDot); + for (const QString &folder : folders) { + if (!name.match(folder).hasMatch()) continue; + const QString path = dir.absoluteFilePath(folder); + if (inUse != "" && QFileInfo(inUse).absoluteDir() == QDir(path)) { + continue; + } + if (!QDir(path).removeRecursively()) { + cerr << "DevChecks: an earlier scratch folder could not be " + << "removed: " << path << endl; + } + } + + for (int n = 1; ; ++n) { + const QString path = + dir.absoluteFilePath(QString("dev-checks-%1").arg(n)); + if (QFileInfo::exists(path)) continue; + if (!QDir().mkpath(path)) return ""; + return path; + } +} + +bool +DevChecks::start(const Options &options) +{ + if (isRunning()) return false; + if (!m_runner || m_runner->isRunning()) { + cerr << "DevChecks::start: the audio check is running" << endl; + return false; + } + if (m_window->m_recordTarget && m_window->m_recordTarget->isRecording()) { + cerr << "DevChecks::start: a take is being recorded" << endl; + return false; + } + + // The folder the session open now lives in stays, as it does + const QString scratch = nextScratchFolder + (scratchDirectory(options.scratchDirectory), m_window->m_sessionFile); + if (scratch == "") { + cerr << "DevChecks::start: no scratch folder could be made in " + << scratchDirectory(options.scratchDirectory) << endl; + return false; + } + + m_options = options; + m_scratchFolder = scratch; + m_sessionPath = QDir(scratch).filePath(kSessionFileName); + m_saved = false; + m_startedAt = QDateTime::currentDateTime(); + // As the Preferences name them, or the route a phone has open + m_devices = m_window->latencyKey(0); + + m_layout = LatencyCheck::devLayout(); + m_observedPunchIn = 0; + m_watched.clear(); + m_long = LongSong(); + m_long.layout = LatencyCheck::longLayout(m_layout.rate, + m_options.longSeconds); + m_haveFresh = false; + m_fresh = AudioCheckResult(); + m_coverageAfterFresh = Coverage(); + m_freshWatched.clear(); + m_reRecord = PunchInStage(); + m_nearStart = PunchInStage(); + m_joins = PunchInStage(); + m_pitchBefore.clear(); + m_notesBefore.clear(); + m_pitchAfter.clear(); + m_notesAfter.clear(); + m_reopened = false; + m_beforeSave = LatencyCheck::TakeSummary(); + m_rejudged = LatencyCheck::TakeSummary(); + m_runnerRunning = false; + m_runnerResult = AudioCheckResult(); + + m_stages.clear(); + if (m_options.longSeconds > 0.0) { + m_stages.push_back({ tr("Long song"), + [this]() { beginLongSong(); }, + [this]() { return longSongDone(); }, + kCheckStageTimeoutMs }); + } + m_stages.push_back({ tr("Fresh punch-ins"), + [this]() { beginFreshPunchIns(); }, + [this]() { return freshPunchInsDone(); }, + kCheckStageTimeoutMs }); + m_stages.push_back({ tr("Re-record"), + [this]() { + beginPunchInStage + (m_reRecord, { reRecording() }, + kReRecordPreRollSeconds); + }, + [this]() { return punchInStageDone(m_reRecord); }, + kCheckStageTimeoutMs }); + m_stages.push_back({ tr("Pre-roll near the start"), + [this]() { + beginPunchInStage + (m_nearStart, { nearTheStart() }, + kNearStartPreRollSeconds); + }, + [this]() { return punchInStageDone(m_nearStart); }, + kCheckStageTimeoutMs }); + m_stages.push_back({ tr("Joins"), + [this]() { + beginPunchInStage + (m_joins, joinPunchIns(), + AudioCheckRunner::kPreRollSeconds); + }, + [this]() { return punchInStageDone(m_joins); }, + kCheckStageTimeoutMs }); + m_stages.push_back({ tr("Save and reopen"), + [this]() { beginReopen(); }, + [this]() { return reopenDone(); }, + kReopenTimeoutMs }); + + cerr << "DevChecks::start: " << m_stages.size() << " stages, scratch " + << "folder " << m_scratchFolder << ", round trip "; + if (m_options.roundTrip >= 0.0) { + cerr << m_options.roundTrip * 1000.0 << " ms" << endl; + } else { + cerr << "the window's own" << endl; + } + + // Nothing is done before the first poll, so that however the run + // ends, the caller hears of it through finished() and never from + // inside this call + m_stage = 0; + m_begun = false; + m_timer->start(); + return true; +} + +void +DevChecks::cancel() +{ + if (!isRunning()) return; + end(tr("The dev checks were cancelled.")); +} + +bool +DevChecks::isRunning() const +{ + return m_stage >= 0; +} + +void +DevChecks::sessionClosing() +{ + // Its own reopen replaces the session, and so does its runner as it + // opens the dev reference. The window tells the runner first: a run + // of the runner's still going after that is one replacing the session + if (!isRunning() || m_reopening) return; + if (m_runnerRunning && m_runner && m_runner->isRunning()) return; + end(tr("The session was closed during the dev checks.")); +} + +void +DevChecks::poll() +{ + if (m_inPoll) return; + if (!isRunning()) { + m_timer->stop(); + return; + } + m_inPoll = true; + + // A copy: whatever a stage calls may end the run + const int index = m_stage; + const Stage stage = m_stages[index]; + + if (!m_begun) { + m_begun = true; + m_stageClock.start(); + cerr << "DevChecks: stage " << (index + 1) << " of " + << m_stages.size() << ": " << stage.name << endl; + emit progress(stage.name, index + 1, int(m_stages.size())); + if (m_stage == index) stage.begin(); + } else if (stage.done()) { + if (m_stage == index) { + m_begun = false; + if (++m_stage >= int(m_stages.size())) { + m_stage = index; + end(""); + } + } + } else if (m_stage == index && + m_stageClock.elapsed() > qint64(stage.limitMs)) { + end(tr("Stage %1 of %2, \"%3\", did not finish within %4 s.") + .arg(index + 1).arg(m_stages.size()).arg(stage.name) + .arg(stage.limitMs / 1000)); + } + + m_inPoll = false; +} + +void +DevChecks::runnerFinished(const AudioCheckResult &result) +{ + // A run the dialog started, or anyone else, is not ours + if (!m_runnerRunning) return; + if (m_long.timing) longSongStep(AudioCheckRunner::Step::Idle, 0); + if (m_observer->isObserving()) finishObservation(); + m_runnerRunning = false; + m_runnerResult = result; +} + +void +DevChecks::runnerProgress(const AudioCheckRunner::Progress &state) +{ + if (!m_runnerRunning) return; + if (m_long.timing) longSongStep(state.step, state.punchIn); + + // Reported once the take has started, from the same poll of the + // runner's. A punch-in's analysis is done when the next one starts + // recording, or when the runner finishes + using Step = AudioCheckRunner::Step; + const bool sameTake = + (state.step == Step::Recording || state.step == Step::AnalysingTake) && + state.punchIn == m_observedPunchIn; + if (m_observer->isObserving() && !sameTake) finishObservation(); + + if (state.step == Step::Recording && !m_observer->isObserving()) { + m_observedPunchIn = state.punchIn; + m_observer->start(); + } +} + +void +DevChecks::finishObservation() +{ + m_observer->stop(); + Watched w; + w.punchIn = m_observedPunchIn - 1; + w.seen = m_observer->observation(); + m_watched.push_back(w); + m_observedPunchIn = 0; +} + +void +DevChecks::longSongStep(AudioCheckRunner::Step step, int punchIn) +{ + // Reported again as the seconds left go down + LongSong &s = m_long; + if (step == s.step && punchIn == s.punchIn) return; + + // Each step ends as the next begins: the whole song's analysis as + // the first punch-in starts to record, and a punch-in's analysis as + // the next starts or the runner ends. The whole song's is timed from + // its session open, as a song opened by the user is analysed, and a + // punch-in's from the take stopped: the steps the audio check waits + using Step = AudioCheckRunner::Step; + const qint64 now = s.clock.elapsed(); + const double seconds = double(now - s.stepFrom) / 1000.0; + if (s.step == Step::AnalysingReference) s.wholeSongSeconds = seconds; + if (s.step == Step::AnalysingTake) s.stopSeconds.push_back(seconds); + + // The take's pitch before each punch-in, which is the pitch after the + // one before it. Starting a take hides the pitch but leaves its model + if (step == Step::Recording) { + s.pitch.push_back(takeEvents(Analyser::PitchTrack)); + } + + if (s.step == Step::OpeningReference) { + cerr << "DevChecks: the long song was written and opened in " + << seconds << " s" << endl; + } else if (s.step == Step::AnalysingReference) { + cerr << "DevChecks: the long song was analysed in " << seconds + << " s" << endl; + } else if (s.step == Step::AnalysingTake) { + cerr << "DevChecks: punch-in " << s.punchIn << " into the long song " + << "was analysed in " << seconds << " s" << endl; + } + + s.step = step; + s.punchIn = punchIn; + s.stepFrom = now; +} + +const DevChecks::Watched * +DevChecks::freshWatched(int i) const +{ + for (const Watched &w : m_freshWatched) { + if (w.punchIn == i) return &w; + } + return nullptr; +} + +void +DevChecks::beginLongSong() +{ + AudioCheckRunner::Plan plan; + plan.layout = m_long.layout; + plan.ranges = longPunchIns(m_long.layout); + plan.roundTrip = m_options.roundTrip; + + m_watched.clear(); + m_observedPunchIn = 0; + m_runnerResult = AudioCheckResult(); + m_long.timing = true; + m_long.clock.start(); + m_runnerRunning = m_runner && m_runner->start(plan); + if (!m_runnerRunning) { + m_long.timing = false; + end(tr("The audio check could not start on a long song of %1 s.") + .arg(m_options.longSeconds)); + } +} + +bool +DevChecks::longSongDone() +{ + if (m_runnerRunning) return false; + m_long.timing = false; + + if (m_runnerResult.failure != "") { + end(tr("The punch-ins into the long song could not be recorded: %1") + .arg(m_runnerResult.failure)); + return false; + } + + // The runner has waited for the last punch-in's analysis + m_long.result = m_runnerResult; + m_long.coverage = m_window->m_takes->getCoverage(); + m_long.watched = m_watched; + m_long.pitch.push_back(takeEvents(Analyser::PitchTrack)); + m_long.done = true; + return true; +} + +void +DevChecks::beginFreshPunchIns() +{ + AudioCheckRunner::Plan plan; + plan.layout = m_layout; + plan.ranges = freshPunchIns(); + plan.roundTrip = m_options.roundTrip; + + m_watched.clear(); + m_observedPunchIn = 0; + m_runnerResult = AudioCheckResult(); + m_runnerRunning = m_runner && m_runner->start(plan); + if (!m_runnerRunning) { + end(tr("The audio check could not start.")); + } +} + +bool +DevChecks::freshPunchInsDone() +{ + if (m_runnerRunning) return false; + + if (m_runnerResult.failure != "") { + end(tr("The punch-ins could not be recorded: %1") + .arg(m_runnerResult.failure)); + return false; + } + + m_fresh = m_runnerResult; + m_haveFresh = true; + m_coverageAfterFresh = m_window->m_takes->getCoverage(); + + // Each channel of what the device delivered, before anything was + // mixed or spliced, to see which input the mic is on + m_freshWatched = m_watched; + for (Watched &w : m_freshWatched) { + w.channelPeaks = channelPeaks(w.seen.recordingPath, w.channelError); + } + return true; +} + +void +DevChecks::beginPunchInStage(PunchInStage &stage, + vector ranges, + double preRoll) +{ + // The take as the stages before left it, to compare with what these + // punch-ins leave + stage.before = snapshot(); + + AudioCheckRunner::Plan plan; + plan.layout = m_layout; + plan.ranges = ranges; + plan.keepSession = true; + plan.roundTrip = m_options.roundTrip; + plan.preRoll = preRoll; + + m_watched.clear(); + m_observedPunchIn = 0; + m_runnerResult = AudioCheckResult(); + m_runnerRunning = m_runner && m_runner->start(plan); + if (!m_runnerRunning) { + end(tr("The audio check could not start.")); + } +} + +bool +DevChecks::punchInStageDone(PunchInStage &stage) +{ + if (m_runnerRunning) return false; + + if (m_runnerResult.failure != "") { + end(tr("The punch-in could not be recorded: %1") + .arg(m_runnerResult.failure)); + return false; + } + + // The runner has waited for the take's analysis: its pitch and notes + // are those this punch-in leaves + stage.result = m_runnerResult; + stage.after = snapshot(); + stage.watched = m_watched; + + // The lead-in the window gave the take, which is the last so far, + // and all that the device delivered until the take stopped + stage.preRoll = m_window->m_takePreRoll; + stage.recorded = frameCount + (stage.watched.empty() ? QString() : + stage.watched.front().seen.recordingPath, stage.recordedError); + + stage.done = true; + return true; +} + +DevChecks::Snapshot +DevChecks::snapshot() const +{ + Snapshot s; + sv_samplerate_t rate = 0; + s.audioError = AudioCheckRunner::readTakeFile + (m_window->m_takes->getAudioPath(), s.audio, rate); + s.pitch = takeEvents(Analyser::PitchTrack); + s.notes = takeEvents(Analyser::Notes); + s.coverage = m_window->m_takes->getCoverage(); + return s; +} + +vector +DevChecks::runs() const +{ + vector all; + if (m_long.done) { + all.push_back({ &m_long.layout, &m_long.result, &m_long.coverage, + &m_long.watched }); + } + if (m_haveFresh) { + all.push_back({ &m_layout, &m_fresh, &m_coverageAfterFresh, + &m_freshWatched }); + } + for (const PunchInStage *stage : { &m_reRecord, &m_nearStart, &m_joins }) { + if (stage->done) { + all.push_back({ &m_layout, &stage->result, &stage->after.coverage, + &stage->watched }); + } + } + return all; +} + +const DevChecks::Watched * +DevChecks::watchedOf(const Run &run, int i) +{ + for (const Watched &w : *run.watched) { + if (w.punchIn == i) return &w; + } + return nullptr; +} + +vector +DevChecks::punchInsSoFar() const +{ + // Not the long song's: that was another session, and another take + vector ranges; + for (const Run &run : runs()) { + if (run.layout != &m_layout) continue; + for (const LatencyCheck::PunchInResult &p : + run.result->summary.punchIns) { + ranges.push_back(p.range); + } + } + return ranges; +} + +void +DevChecks::beginReopen() +{ + // Read from the models before the save, and again from the models + // there are after the reopen + m_pitchBefore = takeEvents(Analyser::PitchTrack); + m_notesBefore = takeEvents(Analyser::Notes); + + // And the take's file judged over every punch-in so far, as it will + // be again after the reopen. Not the runs' own judgements: a later + // punch-in over an earlier one's sweep has replaced it + { + vector mono; + sv_samplerate_t rate = 0; + const QString error = AudioCheckRunner::readTakeFile + (m_window->m_takes->getAudioPath(), mono, rate); + if (error != "") { + end(error); + return; + } + m_beforeSave = LatencyCheck::judgeTake + (m_layout, mono.data(), sv_frame_t(mono.size()), rate, + punchInsSoFar()); + } + + // The way Save As saves once it has a name, with no dialog but for a + // failure: the take's audio is copied into the session's folder + if (!m_window->saveSessionToPath(m_sessionPath)) { + if (isRunning()) { + end(tr("The session could not be saved to \"%1\".") + .arg(m_sessionPath)); + } + return; + } + m_saved = true; + if (!isRunning()) return; + + m_reopening = true; + const MainWindowBase::FileOpenStatus status = + m_window->openPath(m_sessionPath, MainWindowBase::ReplaceSession); + m_reopening = false; + if (!isRunning()) return; + + if (status != MainWindowBase::FileOpenSucceeded) { + end(tr("The session saved in \"%1\" could not be opened again.") + .arg(m_sessionPath)); + } +} + +bool +DevChecks::reopenDone() +{ + // The session restores its analyses rather than running them, but + // waits as the runner does, in case something runs all the same + if (AudioCheckRunner::analysing(m_window->m_analyser)) return false; + if (!m_window->m_takes->haveTake()) { + end(tr("The session opened again has no take.")); + return false; + } + if (!m_window->m_analyser2 || + AudioCheckRunner::analysing(m_window->m_analyser2)) { + return false; + } + + m_pitchAfter = takeEvents(Analyser::PitchTrack); + m_notesAfter = takeEvents(Analyser::Notes); + + // The take's file as the session names it now: the copy in its + // folder. Judged over the same ranges as before the save + vector mono; + sv_samplerate_t rate = 0; + const QString error = AudioCheckRunner::readTakeFile + (m_window->m_takes->getAudioPath(), mono, rate); + if (error != "") { + end(error); + return false; + } + m_rejudged = LatencyCheck::judgeTake + (m_layout, mono.data(), sv_frame_t(mono.size()), rate, + punchInsSoFar()); + m_reopened = true; + return true; +} + +EventVector +DevChecks::takeEvents(Analyser::Component component) const +{ + Analyser *analyser = m_window->m_analyser2; + Layer *layer = analyser ? analyser->getLayer(component) : nullptr; + if (!layer) return {}; + if (auto model = ModelById::getAs + (layer->getModel())) { + return model->getAllEvents(); + } + if (auto model = ModelById::getAs(layer->getModel())) { + return model->getAllEvents(); + } + return {}; +} + +void +DevChecks::end(QString failure) +{ + if (!isRunning()) return; + + m_timer->stop(); + m_observer->stop(); + m_observedPunchIn = 0; + m_long.timing = false; + m_stage = -1; + m_begun = false; + + // A take of its own is stopped through the Stop path, as the check's + // Cancel does; what the runner says then is not waited for + if (m_runnerRunning) { + m_runnerRunning = false; + if (m_runner) m_runner->cancel(); + } + + DevReport report; + report.failure = failure; + report.checks = evaluate(failure); + if (m_saved) report.sessionPath = m_sessionPath; + report.reportPath = writeReport(report); + + cerr << "DevChecks: the run ended" + << (failure != "" ? ": " + failure.toStdString() : std::string()) + << "; " << report.count(CheckResult::Verdict::Pass) << " passed, " + << report.count(CheckResult::Verdict::Fail) << " failed, " + << report.count(CheckResult::Verdict::Measured) << " measured, " + << report.count(CheckResult::Verdict::Skipped) << " skipped; " + << "report in " << report.reportPath << endl; + + emit finished(report); +} + +vector +DevChecks::evaluate(QString reason) const +{ + return { latencyCheck(reason), phrasesCheck(reason), + liveDotsCheck(reason), speakersCheck(reason), + micChannelCheck(reason), positionCheck(reason), + longSongCheck(reason), joinsCheck(reason), + leadInCheck(reason), nearStartCheck(reason), + stopsItselfCheck(reason) }; +} + +CheckResult +DevChecks::latencyCheck(QString reason) const +{ + CheckResult c; + c.item = 1; + c.name = "latency_on_this_machine"; + + const vector all = runs(); + if (all.empty()) { + c.verdict = CheckResult::Verdict::Skipped; + c.message = reason; + return c; + } + + // Every judged sweep of every punch-in of every run, found and within + // the tolerance; the punch-ins numbered along the run + QStringList problems; + double largest = 0.0; + int n = 0; + for (const Run &run : all) { + const LatencyCheck::TakeSummary &s = run.result->summary; + for (int i = 0; i < int(s.punchIns.size()); ++i) { + const LatencyCheck::PunchInResult &p = s.punchIns[i]; + ++n; + QStringList offsets; + for (const LatencyCheck::EventResult &e : s.events) { + if (e.punchIn != i) continue; + if (!e.arrival.found) { + offsets << tr("not found"); + problems << tr("the sweep at %1 s was not found") + .arg(secondsText(e.expectedSeconds)); + continue; + } + const double offset = e.arrival.errorSeconds; + offsets << signedMs(offset); + if (std::fabs(offset) > std::fabs(largest)) largest = offset; + if (std::fabs(offset) > kPlacementSeconds) { + problems << tr("the sweep at %1 s landed %2 off") + .arg(secondsText(e.expectedSeconds)) + .arg(signedMs(offset)); + } + } + if (p.judged == 0) { + problems << tr("punch-in %1 judged no sweep").arg(n); + } + c.numbers.push_back + ({ tr("offsets, punch-in %1 (%2)").arg(n) + .arg(rangeText(p.range.start, p.range.end)), + offsets.isEmpty() ? tr("none judged") : + offsets.join(", ") }); + } + } + if (n == 0) problems << tr("no punch-in was judged"); + c.numbers.push_back({ tr("largest offset"), signedMs(largest) }); + c.numbers.push_back({ tr("round trip used"), + unsignedMs(all.front().result->usedRoundTrip) }); + + if (!m_reopened) { + c.verdict = CheckResult::Verdict::Skipped; + c.message = reason; + if (!problems.isEmpty()) { + c.message += " " + tr("Before that: %1.").arg(problems.join("; ")); + } + return c; + } + + // The same file, copied into the session's folder by the save: every + // sweep where it was just before the save, to the frame + const LatencyCheck::TakeSummary &s = m_beforeSave; + const LatencyCheck::TakeSummary &r = m_rejudged; + QString offsetsAfter = tr("the same"); + bool same = (r.events.size() == s.events.size()); + for (size_t i = 0; same && i < s.events.size(); ++i) { + const LatencyCheck::EventResult &a = s.events[i]; + const LatencyCheck::EventResult &b = r.events[i]; + if (a.event != b.event || a.arrival.found != b.arrival.found || + a.arrival.errorFrames != b.arrival.errorFrames) { + same = false; + offsetsAfter = tr("the sweep at %1 s: %2, %3 before the save") + .arg(secondsText(a.expectedSeconds)) + .arg(b.arrival.found ? signedMs(b.arrival.errorSeconds) + : tr("not found")) + .arg(a.arrival.found ? signedMs(a.arrival.errorSeconds) + : tr("not found")); + } + } + if (r.events.size() != s.events.size()) { + offsetsAfter = tr("%1 sweeps judged, %2 before the save") + .arg(r.events.size()).arg(s.events.size()); + } + if (!same) problems << tr("the take judged again after reopening differs"); + c.numbers.push_back({ tr("offsets after reopening"), offsetsAfter }); + + const sv_samplerate_t rate = m_fresh.referenceRate; + QString pitch, notes; + if (!sameEvents(m_pitchBefore, m_pitchAfter, rate, tr("pitch events"), + pitch)) { + problems << tr("the take's pitch changed after reopening"); + } + if (!sameEvents(m_notesBefore, m_notesAfter, rate, tr("notes"), notes)) { + problems << tr("the take's notes changed after reopening"); + } + // With no pitch in the take, as when a phone's speaker plays none of + // the tones, there is nothing to compare, and the part is not judged + // rather than failed + QString notJudged; + if (m_pitchBefore.empty() && m_pitchAfter.empty()) { + notJudged = tr("Its pitch and notes after reopening were not judged: " + "the take had no pitch to compare."); + } + c.numbers.push_back({ tr("pitch after reopening"), pitch }); + c.numbers.push_back({ tr("notes after reopening"), notes }); + + if (problems.isEmpty() && notJudged == "") { + c.verdict = CheckResult::Verdict::Pass; + c.message = tr("Every sweep landed within %1 of where the reference " + "has it, and the session saved and opened again " + "gives the same offsets, pitch and notes.") + .arg(unsignedMs(kPlacementSeconds)); + } else if (problems.isEmpty()) { + c.verdict = CheckResult::Verdict::Pass; + c.message = tr("Every sweep landed within %1 of where the reference " + "has it, and the session saved and opened again " + "gives the same offsets. %2") + .arg(unsignedMs(kPlacementSeconds)).arg(notJudged); + } else { + c.verdict = CheckResult::Verdict::Fail; + c.message = problems.join("; ") + "."; + if (notJudged != "") c.message += " " + notJudged; + } + return c; +} + +CheckResult +DevChecks::phrasesCheck(QString reason) const +{ + CheckResult c; + c.item = 2; + c.name = "several_phrases_in_one_take"; + + if (runs().empty()) { + c.verdict = CheckResult::Verdict::Skipped; + c.message = reason; + return c; + } + + // Each punch-in of every run in the take where it was recorded, as + // the take was straight after its run, placed right, and with the + // start gap of its own stream measured + QStringList problems; + int n = 0; + for (const Run &run : runs()) { + const LatencyCheck::TakeSummary &s = run.result->summary; + const sv_samplerate_t rate = run.result->referenceRate; + for (int i = 0; i < int(s.punchIns.size()); ++i) { + const LatencyCheck::PunchInResult &p = s.punchIns[i]; + ++n; + + // As the runner selected it, in whole frames of the session + const sv_frame_t start = frameAt(p.range.start, rate); + const sv_frame_t end = frameAt(p.range.end, rate); + Coverage::Range held; + if (!run.coverage->getRangeAt(start, held) || + held.start > start || held.end < end) { + problems << tr("punch-in %1 is not all in the take").arg(n); + } + + if (p.found == 0) { + problems << tr("punch-in %1: no sweep found").arg(n); + } else if (std::fabs(p.medianOffset) > kPlacementSeconds) { + problems << tr("punch-in %1 landed %2 off").arg(n) + .arg(signedMs(p.medianOffset)); + } + + const TakeLatency t = (i < int(run.result->takes.size()) ? + run.result->takes[i] : TakeLatency()); + if (!t.startGapMeasured) { + problems << tr("punch-in %1's start gap was not measured") + .arg(n); + } + + c.numbers.push_back + ({ tr("punch-in %1, median offset").arg(n), + p.found > 0 ? signedMs(p.medianOffset) : + tr("nothing found") }); + c.numbers.push_back + ({ tr("punch-in %1, start gap").arg(n), + tr("%1 (%2 frames), %3") + .arg(unsignedMs(t.recordingSeconds(t.startGap))) + .arg(t.startGap) + .arg(t.startGapMeasured ? tr("measured") : + tr("estimated only")) }); + } + } + if (n < 2) problems << tr("%1 punch-ins, not several").arg(n); + + if (problems.isEmpty()) { + c.verdict = CheckResult::Verdict::Pass; + c.message = tr("Every punch-in is in the take, placed within %1, " + "with a start gap measured for its own stream.") + .arg(unsignedMs(kPlacementSeconds)); + } else { + c.verdict = CheckResult::Verdict::Fail; + c.message = problems.join("; ") + "."; + } + return c; +} + +CheckResult +DevChecks::liveDotsCheck(QString reason) const +{ + CheckResult c; + c.item = 3; + c.name = "live_dots"; + + if (!m_haveFresh) { + c.verdict = CheckResult::Verdict::Skipped; + c.message = reason; + return c; + } + + // Every dot where the take's pitch track will have the sound it + // came from: on one of the reference's tones, and at its pitch. The + // loopback records the reference, so that is where the singing is. + // Where a sound's dots may lie, and which of them are counted apart + // and not judged (on the sweeps, and at the start of a tone or of + // the punch-in or at a tone's end), TakeDiff::placeLiveDot() says + const sv_samplerate_t rate = m_fresh.referenceRate; + const LatencyCheck::Layout &layout = m_layout; + const TakeDiff::DotReach reach = TakeDiff::dotReach(layout.rate); + const LatencyCheck::TakeSummary &s = m_fresh.summary; + + QStringList problems; + vector behind; + int atEdges = 0; + for (int i = 0; i < int(s.punchIns.size()); ++i) { + const LatencyCheck::PunchInResult &p = s.punchIns[i]; + const Watched *w = freshWatched(i); + if (!w) { + problems << tr("punch-in %1 was not watched").arg(i + 1); + continue; + } + + const vector &dots = w->seen.dots; + int onSweeps = 0; + int edges = 0; + int off = 0; + QString firstOff; + for (const TakeObserver::Dot &d : dots) { + // Behind the cursor: the cursor had got this far past the + // dot's place when the dot was first there + behind.push_back(double(d.playbackFrame - d.frame) / rate); + + const double t = double(d.frame) / rate; + const TakeDiff::LiveDot dot = + TakeDiff::placeLiveDot(layout, p.range.start, t, d.hz); + if (dot.place == TakeDiff::DotPlace::OnPitch) continue; + if (dot.place == TakeDiff::DotPlace::OnSweep) { + ++onSweeps; + continue; + } + if (dot.place == TakeDiff::DotPlace::AtEdge) { + ++edges; + continue; + } + if (off++ == 0) { + const QString why = + dot.place == TakeDiff::DotPlace::OffPitch ? + tr("%1 cents from the tone of %2 Hz") + .arg(dot.cents, 0, 'f', 0).arg(dot.toneHz) : + tr("on no tone"); + firstOff = tr("the first at %1 s, %2 Hz, %3") + .arg(t, 0, 'f', 3).arg(d.hz, 0, 'f', 1).arg(why); + } + } + atEdges += edges; + + if (int(dots.size()) <= kMinDots) { + problems << tr("punch-in %1 drew %2 live dots").arg(i + 1) + .arg(dots.size()); + } + if (off > 0) { + problems << tr("punch-in %1: %2 of its %3 dots are off the " + "reference's sounds, %4") + .arg(i + 1).arg(off).arg(dots.size()).arg(firstOff); + } + c.numbers.push_back + ({ tr("dots, punch-in %1 (%2 to %3 s)").arg(i + 1) + .arg(secondsText(p.range.start)) + .arg(secondsText(p.range.end)), + tr("%1: %2 on the tones, %3 at edges, %4 on the sweeps, %5 " + "elsewhere") + .arg(dots.size()) + .arg(int(dots.size()) - onSweeps - edges - off) + .arg(edges).arg(onSweeps).arg(off) }); + } + if (s.punchIns.empty()) problems << tr("no punch-in was judged"); + + // Checklist item 8, and the "cursor versus dots" risk: the cursor + // runs with what has been recorded, the dots with the round trip + if (!behind.empty()) { + const auto range = std::minmax_element(behind.begin(), behind.end()); + c.numbers.push_back({ tr("dots behind the cursor, median"), + signedMs(median(behind)) }); + c.numbers.push_back({ tr("dots behind the cursor, spread"), + unsignedMs(*range.second - *range.first) }); + } + + if (problems.isEmpty()) { + c.verdict = CheckResult::Verdict::Pass; + c.message = tr("Every punch-in drew more than %1 live dots, each on " + "one of the reference's sounds (from %2 before it " + "to %3 after it), and those on its tones within %4 " + "cents of their pitch.") + .arg(kMinDots).arg(unsignedMs(reach.before)) + .arg(unsignedMs(reach.after)).arg(TakeDiff::kDotCents); + } else { + c.verdict = CheckResult::Verdict::Fail; + c.message = problems.join("; ") + "."; + } + // A window that straddles a start or a tone's end wanders off pitch + // through a real speaker and mic, so what the dots there did is only + // told + if (atEdges > 0) { + c.message += tr(" %1 dots within %2 after the start of a tone or " + "of a punch-in, or within %3 either side of a " + "tone's end, were not judged.") + .arg(atEdges).arg(unsignedMs(reach.onset)) + .arg(unsignedMs(reach.end)); + } + return c; +} + +DevChecks::GapLooks +DevChecks::gapLooks(const LatencyCheck::Layout &layout, + const TakeObserver::Observation &o, + const TakeLatency &t, sv_samplerate_t rate, + double until) const +{ + GapLooks g; + + // An audio callback takes in a block of input, then hands out a + // block of output. The reference's first block went out from + // where playback started, after the start gap's input, so the + // frames received say which frames of the reference went out + if (!t.startGapMeasured || t.recordingRate <= 0 || rate <= 0) return g; + g.placed = true; + + // The reference's sounds, in seconds + const double sweepSeconds = + double(LatencyCheck::sweep(layout.rate).size()) / layout.rate; + vector> sounds; + for (const LatencyCheck::Event &e : layout.events) { + const double sweepAt = double(e.sweepStart) / layout.rate; + sounds.push_back({ sweepAt, sweepAt + sweepSeconds }); + sounds.push_back({ double(e.toneStart) / layout.rate, + double(e.toneStart + e.toneLength) / layout.rate }); + } + auto silent = [&sounds](double from, double to) { + for (const auto &sound : sounds) { + if (from < sound.second && to > sound.first) return false; + } + return true; + }; + + const double start = double(o.playbackStart) / rate; + auto played = [&](sv_frame_t received) { + return start + double(received - t.startGap) / t.recordingRate; + }; + + // The levels read at a poll are of the blocks handed out since + // the read before. Those were taken in after the frames counted + // just before that read, but for the one block being handled + // then, which went out after it: one block early. And one late, + // for a resampler between the play source and a device at + // another rate. The output latency plays no part: the levels + // are of what was handed to the device, not of what was heard. + // How long a block is, nothing in the window says (the play + // source is never told the device's), and PortAudio may vary + // it; but each block's input is counted at once, so no block is + // longer than the most frames that came in between two looks. + // Not counting looks held up: the first ones of a take wait for + // the window to set it up, and would count several blocks + sv_frame_t blockFrames = 0; + sv_frame_t heldUp = 0; + const TakeObserver::Sample *previous = nullptr; + for (const TakeObserver::Sample &sample : o.samples) { + if (!sample.recording) continue; + blockFrames = std::max(blockFrames, sample.framesAfter - + sample.framesBefore); + if (previous) { + const sv_frame_t since = + sample.framesBefore - previous->framesAfter; + if (sample.ms - previous->ms <= 2 * TakeObserver::kPollMs) { + blockFrames = std::max(blockFrames, since); + } else { + heldUp = std::max(heldUp, since); + } + } + previous = &sample; + } + if (blockFrames <= 0) blockFrames = heldUp; + const double block = double(blockFrames) / t.recordingRate; + g.margin = block; + + for (size_t k = 1; k < o.samples.size(); ++k) { + const TakeObserver::Sample &a = o.samples[k - 1]; + const TakeObserver::Sample &b = o.samples[k]; + if (!a.outputRead || !b.outputRead) continue; + const double level = std::max(b.outputLeft, b.outputRight); + g.loudest = std::max(g.loudest, level); + const double from = played(a.framesBefore) - block; + const double to = played(b.framesAfter) + block; + if (from < until) { + g.longestWait = std::max(g.longestWait, (b.ms - a.ms) / 1000.0); + } + if (from < start || to > until || !silent(from, to)) continue; + ++g.looks; + g.loudestInGaps = std::max(g.loudestInGaps, level); + if (level > 0.0 && g.heard <= 0.0) { + g.heard = level; + g.heardFrom = from; + g.heardTo = to; + } + } + return g; +} + +CheckResult +DevChecks::speakersCheck(QString reason) const +{ + CheckResult c; + c.item = 4; + c.name = "nothing_of_the_take_in_the_speakers"; + + const vector all = runs(); + if (all.empty()) { + c.verdict = CheckResult::Verdict::Skipped; + c.message = reason; + return c; + } + + QStringList problems; + + // The input played back out, by Tony or by the system, reaches the + // mic again a little later: every sweep arrives twice. In any run + const LatencyCheck::Echo *echo = &all.front().result->summary.echo; + for (const Run &run : all) { + if (run.result->summary.echo.heard) { + echo = &run.result->summary.echo; + break; + } + } + if (echo->heard) { + problems << tr("every sweep arrived a second time, %1 later at " + "%2 dB: the input is being played back out, by " + "Tony or by the system (\"Listen to this device\")") + .arg(unsignedMs(echo->delaySeconds)) + .arg(echo->levelDb, 0, 'f', 1); + } + c.numbers.push_back + ({ tr("second arrival"), + echo->heard ? tr("%1 after the sweep, %2 dB, in %3 sweeps") + .arg(unsignedMs(echo->delaySeconds)).arg(echo->levelDb, 0, 'f', 1) + .arg(echo->events) : tr("none heard") }); + + // What Tony played, at every punch-in of every run: exactly nothing + // where the reference is silent, so no take and no synth + double loudest = 0.0; + double loudestInGaps = 0.0; + double margin = 0.0; + double longestWait = 0.0; + int gapPolls = 0; + QString firstHeard; + int n = 0; + for (const Run &run : all) { + const LatencyCheck::TakeSummary &s = run.result->summary; + for (int i = 0; i < int(s.punchIns.size()); ++i) { + ++n; + const Watched *w = watchedOf(run, i); + if (!w) { + problems << tr("punch-in %1 was not watched").arg(n); + continue; + } + const TakeObserver::Observation &o = w->seen; + + // Play Singing Audio: what it said before the take, it says + // after, and the take is heard or not as it says + if (!o.sawAfter) { + problems << tr("punch-in %1 was not seen after it stopped") + .arg(n); + } else { + auto onOff = [](bool on) { return on ? tr("on") : tr("off"); }; + if (o.singingAudioAfter != o.singingAudioBefore) { + problems << tr("Play Singing Audio was %1 before " + "punch-in %2 and %3 after it") + .arg(onOff(o.singingAudioBefore)).arg(n) + .arg(onOff(o.singingAudioAfter)); + } + if (o.takeAudibleAfter != o.singingAudioAfter) { + problems << tr("after punch-in %1 Play Singing Audio is " + "%2, but the take's audio is %3") + .arg(n).arg(onOff(o.singingAudioAfter)) + .arg(o.takeAudibleAfter ? tr("heard") : tr("silent")); + } + c.numbers.push_back + ({ tr("Play Singing Audio, punch-in %1").arg(n), + tr("%1 before, %2 after, the take %3") + .arg(onOff(o.singingAudioBefore)) + .arg(onOff(o.singingAudioAfter)) + .arg(o.takeAudibleAfter ? tr("heard") : + tr("silent")) }); + } + + const TakeLatency t = (i < int(run.result->takes.size()) ? + run.result->takes[i] : TakeLatency()); + const GapLooks g = gapLooks(*run.layout, o, t, + run.result->referenceRate, + std::numeric_limits::max()); + if (!g.placed) { + problems << tr("punch-in %1's start gap was not measured, " + "so what it played could not be placed") + .arg(n); + continue; + } + loudest = std::max(loudest, g.loudest); + loudestInGaps = std::max(loudestInGaps, g.loudestInGaps); + margin = std::max(margin, g.margin); + longestWait = std::max(longestWait, g.longestWait); + gapPolls += g.looks; + if (g.heard > 0.0 && firstHeard == "") { + firstHeard = tr("%1 from %2 to %3 s, in punch-in %4") + .arg(levelText(g.heard)).arg(g.heardFrom, 0, 'f', 3) + .arg(g.heardTo, 0, 'f', 3).arg(n); + } + } + } + if (n == 0) problems << tr("no punch-in was judged"); + + // With no look in any gap, the take played out would not have shown: + // that part is not judged, rather than failed + QString notJudged; + if (loudest <= 0.0) { + problems << tr("no output level was reported while the reference " + "played, so the silent gaps say nothing"); + } else if (gapPolls == 0) { + notJudged = tr("What Tony played was not judged: no look at the " + "output lay wholly in one of the reference's silent " + "gaps, where the take played out would show (the " + "longest wait between two looks was %1, and a look " + "reaches %2 either side).") + .arg(unsignedMs(longestWait)).arg(unsignedMs(margin)); + } + if (firstHeard != "") { + problems << tr("Tony played something where the reference is " + "silent: %1").arg(firstHeard); + } + c.numbers.push_back({ tr("largest output level in the silent gaps"), + tr("%1, over %2 looks").arg(levelText(loudestInGaps)) + .arg(gapPolls) }); + c.numbers.push_back({ tr("largest output level"), levelText(loudest) }); + c.numbers.push_back({ tr("margin either side of a look"), + unsignedMs(margin) }); + + if (problems.isEmpty() && notJudged == "") { + c.verdict = CheckResult::Verdict::Pass; + c.message = tr("No sweep arrived twice, Tony played nothing where the " + "reference is silent, and Play Singing Audio was as " + "it had been after each take."); + } else if (problems.isEmpty()) { + c.verdict = CheckResult::Verdict::Pass; + c.message = tr("No sweep arrived twice, and Play Singing Audio was as " + "it had been after each take. %1").arg(notJudged); + } else { + c.verdict = CheckResult::Verdict::Fail; + c.message = problems.join("; ") + "."; + if (notJudged != "") c.message += " " + notJudged; + } + return c; +} + +CheckResult +DevChecks::micChannelCheck(QString reason) const +{ + CheckResult c; + c.item = 5; + c.name = "mic_on_input_2"; + + if (!m_haveFresh) { + c.verdict = CheckResult::Verdict::Skipped; + c.message = reason; + return c; + } + + // Each channel the device delivered, as it delivered it + const LatencyCheck::TakeSummary &s = m_fresh.summary; + QStringList problems; + vector loudest; + for (int i = 0; i < int(s.punchIns.size()); ++i) { + const Watched *w = freshWatched(i); + if (!w) { + problems << tr("punch-in %1 was not watched").arg(i + 1); + continue; + } + if (w->channelError != "") { + problems << tr("punch-in %1: %2").arg(i + 1).arg(w->channelError); + continue; + } + QStringList peaks; + for (size_t ch = 0; ch < w->channelPeaks.size(); ++ch) { + if (loudest.size() <= ch) loudest.resize(ch + 1, 0.f); + loudest[ch] = std::max(loudest[ch], w->channelPeaks[ch]); + peaks << tr("input %1 %2").arg(ch + 1) + .arg(levelText(w->channelPeaks[ch])); + } + c.numbers.push_back({ tr("input peaks, punch-in %1").arg(i + 1), + peaks.join(", ") }); + } + + float top = 0.f; + for (float peak : loudest) top = std::max(top, peak); + vector carrying; + for (size_t ch = 0; ch < loudest.size(); ++ch) { + if (top > 0.f && loudest[ch] > 0.f && + 20.0 * std::log10(loudest[ch] / top) >= -kMicChannelDb) { + carrying.push_back(int(ch)); + } + } + c.numbers.push_back({ tr("the mic is on"), + tr("%1, peak %2").arg(inputsText(carrying)) + .arg(levelText(top)) }); + + if (problems.isEmpty() && carrying.empty()) { + problems << tr("nothing was recorded on any input"); + } + if (!problems.isEmpty()) { + c.verdict = CheckResult::Verdict::Fail; + c.message = problems.join("; ") + "."; + return c; + } + + // A phone's microphone, which OboeAudioIO opens as the one channel + // it records: there is no other input it could be on + if (loudest.size() == 1) { + c.verdict = CheckResult::Verdict::Measured; + c.message = tr("Not applicable here: the device records one input " + "channel."); + return c; + } + + if (carrying != vector{ 1 }) { + c.verdict = CheckResult::Verdict::Measured; + c.message = tr("Not applicable here: the mic is on %1, not on " + "input 2 alone.").arg(inputsText(carrying)); + return c; + } + + // On input 2 alone: the live tracker hears the mixdown, so the dots + // are drawn all the same + for (int i = 0; i < int(s.punchIns.size()); ++i) { + const Watched *w = freshWatched(i); + const int dots = w ? int(w->seen.dots.size()) : 0; + if (dots <= kMinDots) { + problems << tr("punch-in %1 drew %2 live dots").arg(i + 1) + .arg(dots); + } + } + if (problems.isEmpty()) { + c.verdict = CheckResult::Verdict::Pass; + c.message = tr("The mic is on input 2, and every punch-in drew more " + "than %1 live dots.").arg(kMinDots); + } else { + c.verdict = CheckResult::Verdict::Fail; + c.message = tr("The mic is on input 2, and %1.") + .arg(problems.join("; ")); + } + return c; +} + +CheckResult +DevChecks::positionCheck(QString reason) const +{ + CheckResult c; + c.item = 7; + c.name = "record_from_a_position"; + + const PunchInStage &stage = m_reRecord; + const LatencyCheck::TakeSummary &s = stage.result.summary; + if (!stage.done || s.punchIns.empty()) { + c.verdict = CheckResult::Verdict::Skipped; + c.message = reason; + return c; + } + + // Recorded over the end of an earlier punch-in: placed as items 1 and + // 2 ask, and outside the range selected the take's audio is what it + // was, to the bit, and its pitch and notes are, beyond the margin a + // ranged analysis may change. The range is the selection: what the + // splice placed, if the take ran to its end (item 14), and nothing + // the splice wrote past it is excused + const sv_samplerate_t rate = stage.result.referenceRate; + const LatencyCheck::PunchIn &p = s.punchIns[0].range; + const Coverage::Range range(frameAt(p.start, rate), frameAt(p.end, rate)); + QStringList problems; + c.numbers.push_back({ tr("range"), rangeText(p.start, p.end) }); + c.numbers.push_back({ tr("offsets"), offsetsOf(s, problems) }); + + const Snapshot &before = stage.before; + const Snapshot &after = stage.after; + if (before.audioError != "" || after.audioError != "") { + problems << (before.audioError != "" ? before.audioError : + after.audioError); + } else { + const TakeDiff::AudioDiff audio = TakeDiff::audioOutside + (before.audio.data(), sv_frame_t(before.audio.size()), + after.audio.data(), sv_frame_t(after.audio.size()), 1, range); + if (!audio.pass) { + problems << tr("the take's audio outside the range changed"); + } + c.numbers.push_back({ tr("audio outside the range"), + audioText(audio, rate) }); + } + + const TakeDiff::EventDiff pitch = + TakeDiff::eventsOutside(before.pitch, after.pitch, range, rate); + const TakeDiff::EventDiff notes = + TakeDiff::eventsOutside(before.notes, after.notes, range, rate); + const QString where = tr("outside %1") + .arg(rangeText(double(pitch.window.start) / rate, + double(pitch.window.end) / rate)); + if (!pitch.pass) problems << tr("the take's pitch outside the range " + "changed"); + if (!notes.pass) problems << tr("the take's notes outside the range " + "changed"); + // With no pitch there, as when a phone's speaker plays none of the + // tones, there is nothing to compare: not judged, rather than failed + QString notJudged; + if (countOutside(before.pitch, pitch.window) == 0 && pitch.pass) { + notJudged = tr("Its pitch and notes outside the range were not " + "judged: the take had no pitch there to compare."); + } + c.numbers.push_back({ tr("pitch"), eventsText(pitch, before.pitch, + tr("pitch events"), where, + rate) }); + c.numbers.push_back({ tr("notes"), eventsText(notes, before.notes, + tr("notes"), where, rate) }); + + if (problems.isEmpty() && notJudged == "") { + c.verdict = CheckResult::Verdict::Pass; + c.message = tr("Recorded over part of an earlier punch-in, the take " + "was placed within %1, and outside the range its " + "audio is the same bit for bit, and its pitch and " + "notes beyond %2 s of it.") + .arg(unsignedMs(kPlacementSeconds)) + .arg(TakeDiff::kEventMarginSeconds); + } else if (problems.isEmpty()) { + c.verdict = CheckResult::Verdict::Pass; + c.message = tr("Recorded over part of an earlier punch-in, the take " + "was placed within %1, and outside the range its " + "audio is the same bit for bit. %2") + .arg(unsignedMs(kPlacementSeconds)).arg(notJudged); + } else { + c.verdict = CheckResult::Verdict::Fail; + c.message = problems.join("; ") + "."; + if (notJudged != "") c.message += " " + notJudged; + } + return c; +} + +CheckResult +DevChecks::longSongCheck(QString reason) const +{ + CheckResult c; + c.item = 9; + c.name = "stop_on_a_long_song"; + + const LongSong &s = m_long; + const LatencyCheck::TakeSummary &summary = s.result.summary; + if (!s.done || summary.punchIns.empty()) { + c.verdict = CheckResult::Verdict::Skipped; + c.message = (m_options.longSeconds > 0.0 ? reason : + tr("The long song was left out of this run.")); + return c; + } + + // Stop analyses only the range recorded, and merges it in: far + // quicker than the whole song's analysis, and nothing of the take's + // pitch outside the range changes, beyond the margin the merge may + // touch. The second punch-in has the first's pitch to leave alone, + // which a whole-take analysis would make again + const sv_samplerate_t rate = s.result.referenceRate; + const double whole = s.wholeSongSeconds; + QStringList problems; + c.numbers.push_back + ({ tr("whole-song analysis"), + whole < 0.0 ? tr("not timed") : + tr("%1 s, of a song of %2 s, from its session open") + .arg(secondsText(whole)) + .arg(secondsText(double(s.layout.length) / s.layout.rate)) }); + if (whole < 0.0) problems << tr("the whole song's analysis was not timed"); + + int compared = 0; + bool allSeenAlike = true; + for (int i = 0; i < int(summary.punchIns.size()); ++i) { + const LatencyCheck::PunchIn &p = summary.punchIns[i].range; + const QString range = rangeText(p.start, p.end); + if (i >= int(s.stopSeconds.size())) { + problems << tr("punch-in %1's analysis was not timed").arg(i + 1); + } else { + const double stop = s.stopSeconds[i]; + c.numbers.push_back + ({ tr("Stop to pitch merged, punch-in %1 (%2)").arg(i + 1) + .arg(range), + whole > 0.0 ? + tr("%1 s, %2 per cent of the whole song's") + .arg(secondsText(stop)).arg(100.0 * stop / whole, 0, 'f', 0) + : tr("%1 s").arg(secondsText(stop)) }); + if (whole >= 0.0 && !(stop < kStopShare * whole)) { + problems << tr("punch-in %1 took %2 s from Stop to its pitch " + "merged, not under %3 of the whole song's " + "analysis, %4 s") + .arg(i + 1).arg(secondsText(stop)).arg(kStopShare) + .arg(secondsText(whole)); + } + } + + if (i + 1 >= int(s.pitch.size())) { + problems << tr("the take's pitch around punch-in %1 was not seen") + .arg(i + 1); + allSeenAlike = false; + continue; + } + const EventVector &before = s.pitch[i]; + const EventVector &after = s.pitch[i + 1]; + const Coverage::Range r(frameAt(p.start, rate), frameAt(p.end, rate)); + const TakeDiff::EventDiff pitch = + TakeDiff::eventsOutside(before, after, r, rate); + compared += countOutside(before, pitch.window); + if (!pitch.pass) { + problems << tr("punch-in %1 changed the take's pitch outside its " + "range").arg(i + 1); + allSeenAlike = false; + } + c.numbers.push_back + ({ tr("pitch, punch-in %1").arg(i + 1), + eventsText(pitch, before, tr("pitch events"), + tr("outside %1") + .arg(rangeText(double(pitch.window.start) / rate, + double(pitch.window.end) / rate)), + rate) }); + } + // With no pitch there, as when a phone's speaker plays none of the + // tones, there is nothing to compare: not judged, rather than failed. + // A comparison that failed, or could not be made, is a problem above + QString notJudged; + if (compared == 0 && allSeenAlike) { + notJudged = tr("The take's pitch outside the punch-ins was not " + "judged: it had none there to compare."); + } + + if (problems.isEmpty() && notJudged == "") { + c.verdict = CheckResult::Verdict::Pass; + c.message = tr("On a song of %1 s, each punch-in had its pitch merged " + "in under %2 of the time the whole song's analysis " + "took, and left the take's pitch beyond %3 s of its " + "range as it was: only the range was analysed.") + .arg(secondsText(double(s.layout.length) / s.layout.rate)) + .arg(kStopShare).arg(TakeDiff::kEventMarginSeconds); + } else if (problems.isEmpty()) { + c.verdict = CheckResult::Verdict::Pass; + c.message = tr("On a song of %1 s, each punch-in had its analysis " + "merged in under %2 of the time the whole song's " + "analysis took. %3") + .arg(secondsText(double(s.layout.length) / s.layout.rate)) + .arg(kStopShare).arg(notJudged); + } else { + c.verdict = CheckResult::Verdict::Fail; + c.message = problems.join("; ") + "."; + if (notJudged != "") c.message += " " + notJudged; + } + return c; +} + +CheckResult +DevChecks::joinsCheck(QString reason) const +{ + CheckResult c; + c.item = 10; + c.name = "the_joins"; + + const PunchInStage &stage = m_joins; + const LatencyCheck::TakeSummary &s = stage.result.summary; + if (!stage.done || s.punchIns.size() < 2) { + c.verdict = CheckResult::Verdict::Skipped; + c.message = reason; + return c; + } + + // Two punch-ins that meet in the middle of a held tone, as a singer + // punching in twice within one note: at the join the samples run on + // without a step, the pitch track without a hole or a frame twice, + // one note runs through it, and outside the two ranges the take's + // pitch and notes are as they were. Each part is named when it + // fails. Two punch-ins placed differently, as on a device whose + // offset moves when its stream restarts, do not show in the step: + // each splice fades against the silence the file held there, so the + // join is a dip of a few ms that hides a jump of phase. Hence the + // number "second punch-in against the first", which does + const sv_samplerate_t rate = stage.result.referenceRate; + const LatencyCheck::PunchIn &first = s.punchIns[0].range; + const LatencyCheck::PunchIn &second = s.punchIns[1].range; + const sv_frame_t join = frameAt(first.end, rate); + auto at = [rate](sv_frame_t frame) { + return QString("%1 s").arg(double(frame) / rate, 0, 'f', 3); + }; + QStringList problems; + c.numbers.push_back({ tr("join"), + tr("%1 s, between %2 and %3") + .arg(secondsText(first.end)) + .arg(rangeText(first.start, first.end)) + .arg(rangeText(second.start, second.end)) }); + + // Where each landed, for reading the rest: items 1 and 2 judge it + QStringList placing; + c.numbers.push_back({ tr("offsets"), offsetsOf(s, placing) }); + if (s.punchIns[0].found > 0 && s.punchIns[1].found > 0) { + c.numbers.push_back({ tr("second punch-in against the first"), + signedMs(s.punchIns[1].medianOffset - + s.punchIns[0].medianOffset) }); + } + + const Snapshot &before = stage.before; + const Snapshot &after = stage.after; + if (after.audioError != "") { + problems << tr("step: %1").arg(after.audioError); + } else { + const TakeDiff::SampleStep step = TakeDiff::stepAt + (after.audio.data(), sv_frame_t(after.audio.size()), 1, rate, + join); + c.numbers.push_back + ({ tr("step at the join"), + tr("%1 dB, at most %2: the largest first difference %3 at %4, " + "the typical one %5") + .arg(step.stepDb, 0, 'f', 1).arg(TakeDiff::kMaxStepDb) + .arg(levelText(step.largest)).arg(at(step.largestAt)) + .arg(levelText(step.typical)) }); + if (!step.pass) { + problems << tr("step: the samples jump at the join, %1 dB over " + "their typical step, more than %2 dB") + .arg(step.stepDb, 0, 'f', 1).arg(TakeDiff::kMaxStepDb); + } + } + + const TakeDiff::PitchJoin pitch = + TakeDiff::pitchAcross(after.pitch, join, rate); + c.numbers.push_back + ({ tr("pitch across the join"), + tr("%1 events within %2 s of it; the largest gap %3 (%4 hops) " + "from %5; %6 frames twice, %7 out of order") + .arg(pitch.events).arg(TakeDiff::kPitchWindowSeconds) + .arg(unsignedMs(double(pitch.largestGap) / rate)) + .arg(double(pitch.largestGap) / double(TakeDiff::kHopFrames), 0, + 'f', 1) + .arg(at(pitch.largestGapFrom)).arg(pitch.doubled) + .arg(pitch.outOfOrder) }); + // With no pitch near the join, as when a phone's speaker plays none + // of the tones, the pitch and note parts have nothing to judge: not + // judged, rather than failed. A track with a gap is judged + QStringList notJudged; + const bool pitchNear = (pitch.events > 0); + if (!pitchNear) { + notJudged << tr("pitch: not judged, there was no pitch within %1 s " + "of the join") + .arg(TakeDiff::kPitchWindowSeconds); + } else if (!pitch.pass) { + QStringList why; + if (pitch.largestGap > TakeDiff::kMaxGapHops * TakeDiff::kHopFrames) { + why << tr("a gap of %1 from %2") + .arg(unsignedMs(double(pitch.largestGap) / rate)) + .arg(at(pitch.largestGapFrom)); + } + if (pitch.doubled > 0) { + why << tr("%1 frames twice, the first at %2").arg(pitch.doubled) + .arg(at(pitch.firstDoubled)); + } + if (pitch.outOfOrder > 0) { + why << tr("%1 events out of order, the first at %2") + .arg(pitch.outOfOrder).arg(at(pitch.firstOutOfOrder)); + } + problems << tr("pitch: the track does not run through the join: %1") + .arg(why.join(", ")); + } + + // And every note within a second of it, to show how a note that does + // not run through came apart + const TakeDiff::NoteJoin notes = + TakeDiff::notesAcross(after.notes, join, rate); + auto noteText = [rate](const Event &e) { + return rangeText(double(e.getFrame()) / rate, + double(e.getFrame() + e.getDuration()) / rate); + }; + QStringList spanning, close, around; + for (const Event &e : notes.spanning) spanning << noteText(e); + for (const Event &e : notes.edgesNear) close << noteText(e); + const sv_frame_t oneSecond = frameAt(1.0, rate); + for (const Event &e : after.notes) { + if (e.getFrame() < join + oneSecond && + e.getFrame() + e.getDuration() > join - oneSecond) { + around << noteText(e); + } + } + c.numbers.push_back({ tr("notes at the join"), + spanning.isEmpty() ? tr("none") : + spanning.join(", ") }); + c.numbers.push_back({ tr("nearest note edge"), + after.notes.empty() ? tr("no notes") : + signedMs(double(notes.nearestEdge) / rate) }); + c.numbers.push_back({ tr("notes within 1 s of the join"), + around.isEmpty() ? tr("none") : around.join(", ") }); + const bool notesNear = pitchNear || !around.isEmpty(); + if (!notesNear) { + notJudged << tr("note: not judged, there was no note within 1 s of " + "the join, nor any pitch"); + } else if (!notes.pass) { + QStringList why; + if (notes.spanning.size() != 1) { + why << tr("%1 notes hold the join, not one") + .arg(notes.spanning.size()); + } + if (!close.isEmpty()) { + why << tr("a note begins or ends within %1 s of it: %2") + .arg(TakeDiff::kNoteClearanceSeconds).arg(close.join(", ")); + } + problems << tr("note: one note does not run through the join: %1") + .arg(why.join(", and ")); + } + + // Nothing moved outside the two ranges together, beyond the margin + const Coverage::Range both(frameAt(first.start, rate), + frameAt(second.end, rate)); + const TakeDiff::EventDiff pitchOutside = + TakeDiff::eventsOutside(before.pitch, after.pitch, both, rate); + const TakeDiff::EventDiff notesOutside = + TakeDiff::eventsOutside(before.notes, after.notes, both, rate); + const QString where = tr("outside %1") + .arg(rangeText(double(pitchOutside.window.start) / rate, + double(pitchOutside.window.end) / rate)); + if (!pitchOutside.pass) { + problems << tr("outside: the take's pitch outside the ranges changed"); + } + if (!notesOutside.pass) { + problems << tr("outside: the take's notes outside the ranges changed"); + } + const bool pitchOutsideJudged = + (countOutside(before.pitch, pitchOutside.window) > 0 || + !pitchOutside.pass); + if (!pitchOutsideJudged) { + notJudged << tr("outside: the take's pitch and notes not judged, it " + "had no pitch outside the ranges to compare"); + } + c.numbers.push_back({ tr("pitch outside"), + eventsText(pitchOutside, before.pitch, + tr("pitch events"), where, rate) }); + c.numbers.push_back({ tr("notes outside"), + eventsText(notesOutside, before.notes, tr("notes"), + where, rate) }); + + // What was not judged is said after the rest, in the order of the + // parts, as the failures are + const QString unjudged = + notJudged.isEmpty() ? QString() : notJudged.join("; ") + "."; + if (problems.isEmpty() && notJudged.isEmpty()) { + c.verdict = CheckResult::Verdict::Pass; + c.message = tr("Two punch-ins meeting at %1 s, in the middle of a " + "held tone, left no step in the samples there, the " + "pitch track running through it, one note across it " + "and no note beginning or ending within %2 s of it, " + "and the take's pitch and notes beyond %3 s of the " + "two ranges as they were.") + .arg(secondsText(first.end)) + .arg(TakeDiff::kNoteClearanceSeconds) + .arg(TakeDiff::kEventMarginSeconds); + } else if (problems.isEmpty()) { + c.verdict = CheckResult::Verdict::Pass; + c.message = tr("Two punch-ins meeting at %1 s, in the middle of a " + "held tone, left no step in the samples there, and " + "passed every part that could be judged. %2") + .arg(secondsText(first.end)).arg(unjudged); + } else { + c.verdict = CheckResult::Verdict::Fail; + c.message = problems.join("; ") + "."; + if (unjudged != "") c.message += " " + unjudged; + } + return c; +} + +CheckResult +DevChecks::leadInCheck(QString reason) const +{ + CheckResult c; + c.item = 12; + c.name = "nothing_heard_or_changed_in_the_lead_in"; + + const PunchInStage &stage = m_reRecord; + const LatencyCheck::TakeSummary &s = stage.result.summary; + if (!stage.done || s.punchIns.empty()) { + c.verdict = CheckResult::Verdict::Skipped; + c.message = reason; + return c; + } + + // The re-recording's lead-in played over what an earlier punch-in + // recorded before P. Nothing of that may change: item 7's + // comparisons, their part before P alone, so that a change there is + // told apart from one after the range + const sv_samplerate_t rate = stage.result.referenceRate; + const LatencyCheck::PunchIn &p = s.punchIns[0].range; + const Snapshot &before = stage.before; + const Snapshot &after = stage.after; + const sv_frame_t from = frameAt(p.start, rate); + const sv_frame_t beyond = std::max + (from + 1, sv_frame_t(std::max(before.audio.size(), + after.audio.size())) + + frameAt(1.0, rate)); + const Coverage::Range rest(from, beyond); + QStringList problems; + + if (before.audioError != "" || after.audioError != "") { + problems << (before.audioError != "" ? before.audioError : + after.audioError); + } else { + const TakeDiff::AudioDiff audio = TakeDiff::audioOutside + (before.audio.data(), sv_frame_t(before.audio.size()), + after.audio.data(), sv_frame_t(after.audio.size()), 1, rest); + if (!audio.pass) { + problems << tr("the take's audio before the punch-in changed"); + } + c.numbers.push_back({ tr("audio before %1 s").arg(secondsText(p.start)), + audioText(audio, rate) }); + } + + const TakeDiff::EventDiff pitch = + TakeDiff::eventsOutside(before.pitch, after.pitch, rest, rate); + const TakeDiff::EventDiff notes = + TakeDiff::eventsOutside(before.notes, after.notes, rest, rate); + const QString where = tr("before %1 s") + .arg(secondsText(double(pitch.window.start) / rate)); + if (!pitch.pass) problems << tr("the take's pitch before the punch-in " + "changed"); + if (!notes.pass) problems << tr("the take's notes before the punch-in " + "changed"); + // With no pitch there, as when a phone's speaker plays none of the + // tones, there is nothing to compare: not judged, rather than failed + QString pitchNotJudged; + if (countOutside(before.pitch, pitch.window) == 0 && pitch.pass) { + pitchNotJudged = tr("Its pitch and notes before the punch-in were " + "not judged: the take had no pitch there to " + "compare."); + } + c.numbers.push_back({ tr("pitch"), eventsText(pitch, before.pitch, + tr("pitch events"), where, + rate) }); + c.numbers.push_back({ tr("notes"), eventsText(notes, before.notes, + tr("notes"), where, rate) }); + + // And while it played, Tony played the reference and nothing else: + // item 4's looks at the output, those that lie wholly before P. The + // take's audio is under them now, and kept silent as in any take. + // With no look in any gap, the take played out would not have shown: + // that part is not judged, rather than failed + const Watched *w = stage.watched.empty() ? nullptr : &stage.watched[0]; + const TakeLatency t = stage.result.takes.empty() ? TakeLatency() : + stage.result.takes[0]; + QString notJudged; + if (!w) { + problems << tr("the punch-in was not watched"); + } else { + const GapLooks g = gapLooks(m_layout, w->seen, t, rate, p.start); + if (!g.placed) { + problems << tr("the start gap was not measured, so what the " + "lead-in played could not be placed"); + } else { + if (g.looks == 0) { + notJudged = tr("What Tony played during the lead-in was " + "not judged: no look at the output lay " + "wholly in one of the reference's silent " + "gaps, where the take played out would " + "show (the longest wait between two looks " + "was %1, and a look reaches %2 either " + "side).") + .arg(unsignedMs(g.longestWait)).arg(unsignedMs(g.margin)); + } + if (g.heard > 0.0) { + problems << tr("during the lead-in Tony played something " + "where the reference is silent: %1 from %2 " + "to %3 s") + .arg(levelText(g.heard)).arg(g.heardFrom, 0, 'f', 3) + .arg(g.heardTo, 0, 'f', 3); + } + c.numbers.push_back + ({ tr("output in the lead-in's silent gaps"), + tr("%1, over %2 looks").arg(levelText(g.loudestInGaps)) + .arg(g.looks) }); + c.numbers.push_back + ({ tr("longest wait between two looks in the lead-in"), + unsignedMs(g.longestWait) }); + } + } + + // Either part may have nothing to judge: what was judged is said + // first, then what was not + QStringList unjudged; + if (pitchNotJudged != "") unjudged << pitchNotJudged; + if (notJudged != "") unjudged << notJudged; + if (problems.isEmpty()) { + c.verdict = CheckResult::Verdict::Pass; + if (pitchNotJudged == "" && notJudged == "") { + c.message = tr("Before the punch-in the take's audio is the same " + "bit for bit, and its pitch and notes beyond %1 s " + "of it; and while the lead-in played over the " + "take, Tony played nothing where the reference is " + "silent.") + .arg(TakeDiff::kEventMarginSeconds); + } else if (pitchNotJudged == "") { + c.message = tr("Before the punch-in the take's audio is the same " + "bit for bit, and its pitch and notes beyond %1 s " + "of it.").arg(TakeDiff::kEventMarginSeconds); + } else if (notJudged == "") { + c.message = tr("Before the punch-in the take's audio is the same " + "bit for bit; and while the lead-in played over " + "the take, Tony played nothing where the " + "reference is silent."); + } else { + c.message = tr("Before the punch-in the take's audio is the same " + "bit for bit."); + } + c.message += " " + unjudged.join(" "); + c.message = c.message.trimmed(); + } else { + c.verdict = CheckResult::Verdict::Fail; + c.message = problems.join("; ") + "."; + if (!unjudged.isEmpty()) c.message += " " + unjudged.join(" "); + } + return c; +} + +CheckResult +DevChecks::nearStartCheck(QString reason) const +{ + CheckResult c; + c.item = 13; + c.name = "pre_roll_near_the_start"; + + const PunchInStage &stage = m_nearStart; + const LatencyCheck::TakeSummary &s = stage.result.summary; + if (!stage.done || s.punchIns.empty()) { + c.verdict = CheckResult::Verdict::Skipped; + c.message = reason; + return c; + } + + // Less song before the punch-in than the pre-roll asked for: the + // lead-in is all there is of it, and no more + const sv_samplerate_t rate = stage.result.referenceRate; + const LatencyCheck::PunchIn &p = s.punchIns[0].range; + const double room = std::min(kNearStartPreRollSeconds, p.start); + QStringList problems; + c.numbers.push_back({ tr("lead-in"), + tr("%1 s, of the %2 s asked for") + .arg(secondsText(double(stage.preRoll) / rate)) + .arg(secondsText(kNearStartPreRollSeconds)) }); + if (stage.preRoll > frameAt(room, rate)) { + problems << tr("the lead-in was %1 s, longer than the %2 s of song " + "before the punch-in") + .arg(secondsText(double(stage.preRoll) / rate)) + .arg(secondsText(room)); + } + + const Watched *w = stage.watched.empty() ? nullptr : &stage.watched[0]; + const TakeLatency t = stage.result.takes.empty() ? TakeLatency() : + stage.result.takes[0]; + if (!w) { + problems << tr("the punch-in was not watched"); + } else { + const TakeObserver::Observation &o = w->seen; + + // Playback from the start of the song, and the cursor never + // before it. The cursor is where playback started plus what has + // been recorded, so it is read while recording only + bool seen = false; + sv_frame_t lowest = 0; + for (const TakeObserver::Sample &sample : o.samples) { + if (!sample.recording) continue; + lowest = seen ? std::min(lowest, sample.playbackFrame) : + sample.playbackFrame; + seen = true; + } + c.numbers.push_back({ tr("playback from"), + tr("%1 s").arg(secondsText + (double(o.playbackStart) / + rate)) }); + c.numbers.push_back({ tr("cursor, lowest"), + seen ? tr("%1 s").arg(secondsText + (double(lowest) / rate)) + : tr("not seen") }); + if (o.playbackStart != 0) { + problems << tr("playback started at %1 s, not at the start of " + "the song") + .arg(secondsText(double(o.playbackStart) / rate)); + } + if (!seen) { + problems << tr("the take was not seen recording"); + } else if (lowest < 0) { + problems << tr("the cursor was at %1 s, before the start of the " + "song").arg(secondsText(double(lowest) / rate)); + } + + // The countdown as the status bar showed it: in whole seconds, + // the lead-in and the round trip still to come (the singing that + // answers the reference at P arrives a round trip later), down + // to 1. Before the start gap is measured the window counts with + // its estimate, which may differ by a block or two: hence the + // 50 ms + vector counted; + for (const TakeObserver::Sample &sample : o.samples) { + if (!sample.recording) continue; + const int n = countdownOf(sample.status); + if (n > 0 && (counted.empty() || counted.back() != n)) { + counted.push_back(n); + } + } + const double wait = room + (t.recordingRate > 0 ? + double(t.roundTrip + t.startGap) / + t.recordingRate : 0.0); + const int most = int(std::ceil(wait + 0.05)); + QStringList words; + for (int n : counted) words << QString::number(n); + c.numbers.push_back + ({ tr("countdown"), + counted.empty() ? tr("none shown") : + tr("%1; at most %2, for %3 s of lead-in and round trip") + .arg(words.join(", ")).arg(most).arg(secondsText(wait)) }); + if (counted.empty()) { + problems << tr("no countdown was shown"); + } else { + const int highest = + *std::max_element(counted.begin(), counted.end()); + if (highest > most) { + problems << tr("the countdown began at %1, not %2: it " + "counted more lead-in than there is room for") + .arg(highest).arg(most); + } + if (counted.back() != 1) { + problems << tr("the countdown ended at %1, not 1") + .arg(counted.back()); + } + } + } + + c.numbers.push_back({ tr("offsets"), offsetsOf(s, problems) }); + + if (problems.isEmpty()) { + c.verdict = CheckResult::Verdict::Pass; + c.message = tr("With a pre-roll of %1 s asked for at %2 s, playback " + "ran from the start of the song and not before it, " + "the countdown counted only the lead-in there is " + "room for, and the take was placed within %3.") + .arg(secondsText(kNearStartPreRollSeconds)) + .arg(secondsText(p.start)).arg(unsignedMs(kPlacementSeconds)); + } else { + c.verdict = CheckResult::Verdict::Fail; + c.message = problems.join("; ") + "."; + } + return c; +} + +CheckResult +DevChecks::stopsItselfCheck(QString reason) const +{ + CheckResult c; + c.item = 14; + c.name = "record_into_selection_stops_by_itself"; + + vector stages; + for (const PunchInStage *stage : { &m_reRecord, &m_nearStart }) { + if (stage->done && !stage->result.summary.punchIns.empty()) { + stages.push_back(stage); + } + } + if (stages.empty()) { + c.verdict = CheckResult::Verdict::Skipped; + c.message = reason; + return c; + } + + // The window looks this often whether a take into a selection has + // all it needs + const double poll = (m_window->m_takeTimer ? + m_window->m_takeTimer->interval() / 1000.0 : 0.0); + auto seconds = [](double s) { return QString("%1 s").arg(s, 0, 'f', 3); }; + + QStringList problems; + for (const PunchInStage *stage : stages) { + const LatencyCheck::TakeSummary &s = stage->result.summary; + const sv_samplerate_t rate = stage->result.referenceRate; + const LatencyCheck::PunchIn &p = s.punchIns[0].range; + const sv_frame_t start = frameAt(p.start, rate); + const sv_frame_t end = frameAt(p.end, rate); + const QString range = rangeText(p.start, p.end); + const Watched *w = stage->watched.empty() ? nullptr : + &stage->watched[0]; + const TakeLatency t = stage->result.takes.empty() ? TakeLatency() : + stage->result.takes[0]; + + // How far past the selection's end the take recorded: its raw + // recording against what reaches the end, the round trip and the + // lead-in before the selection. The take waits for a margin past + // the end (TakeTiming::shouldStopAt()), and is then stopped by + // the take timer's next look, with the block coming in as it + // looks. Not worked out with TakeTiming, whose margin is part of + // what is checked. In seconds: the raw recording, the round trip + // and the start gap count frames of the recording, at the + // device's rate; the lead-in and the selection count the + // reference's + if (!w) { + problems << tr("the take at %1 was not watched").arg(range); + } else if (stage->recorded < 0) { + problems << tr("the take at %1: %2").arg(range) + .arg(stage->recordedError); + } else if (t.recordingRate <= 0 || rate <= 0) { + problems << tr("the take at %1: its rate is not known") + .arg(range); + } else { + const double needed = + t.recordingSeconds(t.roundTrip + t.startGap) + + double(stage->preRoll + (end - start)) / rate; + const double past = t.recordingSeconds(stage->recorded) - needed; + const double allowed = kStopMarginSeconds + poll + gapLooks + (m_layout, w->seen, t, rate, + std::numeric_limits::max()).margin; + c.numbers.push_back + ({ tr("stopped, %1").arg(range), + tr("%1 past the end of the selection, at most %2 allowed") + .arg(seconds(past)).arg(seconds(allowed)) }); + if (past < 0.0) { + problems << tr("the take at %1 stopped %2 before the end of " + "its selection had been recorded") + .arg(range).arg(seconds(-past)); + } else if (past > allowed) { + problems << tr("the take at %1 went on %2 past the end of its " + "selection, more than %3 s, a look of the " + "take timer and a block").arg(range) + .arg(seconds(past)).arg(kStopMarginSeconds); + } + } + + // The take's coverage as it was, and the selection: no more, and + // no less + Coverage expected = stage->before.coverage; + expected.add(start, end); + c.numbers.push_back({ tr("coverage after, %1").arg(range), + coverageText(stage->after.coverage, rate) }); + if (stage->after.coverage != expected) { + problems << tr("after the take at %1 the take covers %2, not %3") + .arg(range).arg(coverageText(stage->after.coverage, rate)) + .arg(coverageText(expected, rate)); + } + + // No question asked, and no dialog of any kind, from the take's + // start to its analysis done + if (w) { + const TakeObserver::Sample *modal = nullptr; + for (const TakeObserver::Sample &sample : w->seen.samples) { + if (sample.modal) { + modal = &sample; + break; + } + } + c.numbers.push_back + ({ tr("dialogs, %1").arg(range), + modal ? tr("one up %1 into the take") + .arg(seconds(modal->ms / 1000.0)) : + tr("none, over %1 looks").arg(w->seen.samples.size()) }); + if (modal) { + problems << tr("a dialog was up %1 into the take at %2") + .arg(seconds(modal->ms / 1000.0)).arg(range); + } + } + } + + if (problems.isEmpty()) { + c.verdict = CheckResult::Verdict::Pass; + c.message = tr("Each take into a selection stopped by itself within " + "%1 s of the selection's end, a look of the take " + "timer and a block, added the selection to the take " + "and nothing else, and no dialog came up.") + .arg(kStopMarginSeconds); + } else { + c.verdict = CheckResult::Verdict::Fail; + c.message = problems.join("; ") + "."; + } + return c; +} + +QString +DevChecks::writeReport(const DevReport &report) const +{ + const QString directory = reportDirectory(m_options.reportDirectory); + if (!QDir().mkpath(directory)) { + cerr << "DevChecks: the report directory " << directory + << " could not be made" << endl; + return ""; + } + const QString path = QDir(directory).filePath(kReportFileName); + QFile file(path); + if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate | + QIODevice::Text)) { + cerr << "DevChecks: the report " << path << " could not be written" + << endl; + return ""; + } + + auto device = [](QString name) { + return name == "" ? QString("(System Default)") : name; + }; + + QTextStream out(&file); + out << "Tony development checks\n"; + out << "Run: " << m_startedAt.toString(Qt::ISODate) << "\n"; + out << "Output device: " << device(m_devices.playbackDevice) << "\n"; + out << "Input device: " << device(m_devices.recordDevice) << "\n"; + + // So that runs on two drivers, or at two latencies, can be told apart + out << "Audio driver: " + << AudioDriverMenus::driverName(m_devices.implementation) << "\n"; + const double latencyAsked = m_window->m_audioDriverMenus ? + m_window->m_audioDriverMenus->appliedLatency() : 0.0; + out << "Latency asked for: " + << (latencyAsked > 0.0 ? unsignedMs(latencyAsked) + : QString("none")) << "\n"; + + // What the device says of itself, as the window has it now. The + // output latency counts frames at the rate the play source was told + // the device runs at, else the recording's (MainWindow::roundTripAt()) + QStringList drivers; + for (const std::string &name : + breakfastquay::AudioFactory::getImplementationNames()) { + drivers << QString::fromStdString(name); + } +#ifdef Q_OS_ANDROID + // Tony's own, which MainWindow::createAudioIO() opens: bqaudioio's + // factory has none for Android + drivers << "oboe"; +#endif + out << "Audio drivers built in: " << drivers.join(", ") << "\n"; + const vector all = runs(); + + // A device that reports the route it opened, as OboeAudioIO does on + // a phone, is none of those: which it is, and how its streams opened + // for the first punch-in, on which the latencies below depend + const AudioRoute::Route route = + all.empty() ? AudioRoute::Route() : all.front().result->route; + if (route.driver != "") { + out << "Audio driver in use: " << route.driver << "\n"; + out << "Output streams: " << route.outputStreams << "\n"; + out << "Input streams: " << (route.inputStreams != "" ? + route.inputStreams : + QString("none open")) << "\n"; + } + const sv_samplerate_t recordingRate = + all.empty() ? 0 : all.front().result->recordingRate; + sv_samplerate_t outputRate = m_window->m_playSource ? + m_window->m_playSource->getDeviceSampleRate() : 0; + if (outputRate <= 0) outputRate = recordingRate; + auto latency = [](qint64 frames, sv_samplerate_t rate) { + if (rate <= 0) return QString("%1 frames").arg(frames); + return QString("%1 frames (%2)").arg(frames) + .arg(unsignedMs(double(frames) / rate)); + }; + out << "Playback latency reported: " + << (m_window->m_playSource ? + latency(m_window->m_playSource->getTargetPlayLatency(), + outputRate) : QString("no device")) << "\n"; + out << "Record latency reported: " + << (m_window->m_recordTarget ? + latency(m_window->m_recordTarget->getSystemRecordLatency(), + recordingRate > 0 ? recordingRate : outputRate) + : QString("no device")) << "\n"; + out << "Round trip for the run: " + << (m_options.roundTrip >= 0.0 ? unsignedMs(m_options.roundTrip) + : QString("the one takes are placed with")) << "\n"; + out << "Scratch folder: " << m_scratchFolder << "\n"; + out << "Session: " << (report.sessionPath != "" ? report.sessionPath + : QString("not saved")) << "\n"; + if (report.failure != "") { + out << "The run ended early: " << report.failure << "\n"; + } + + // Grouped by checklist item, in the order of the checklist + vector checks = report.checks; + std::stable_sort(checks.begin(), checks.end(), + [](const CheckResult &a, const CheckResult &b) { + return a.item < b.item; + }); + int item = -1; + for (const CheckResult &c : checks) { + if (c.item != item) { + item = c.item; + out << "\nItem " << item << "\n"; + } + out << " " << c.name << ": " + << CheckResult::verdictName(c.verdict).toUpper() << "\n"; + if (c.message != "") out << " " << c.message << "\n"; + for (const auto &n : c.numbers) { + out << " " << n.first << ": " << n.second << "\n"; + } + } + + out << "\nTotals: " << report.count(CheckResult::Verdict::Pass) + << " passed, " << report.count(CheckResult::Verdict::Fail) + << " failed, " << report.count(CheckResult::Verdict::Measured) + << " measured, " << report.count(CheckResult::Verdict::Skipped) + << " skipped\n"; + out.flush(); + file.close(); + + return QFileInfo(path).absoluteFilePath(); +} + +QString +DevChecks::reportDirectory(QString given) +{ + if (given != "") return given; + const QString logs = qEnvironmentVariable("TONY_TEST_LOG_DIR"); + if (logs != "") return logs; + return QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); +} + +QString +DevChecks::scratchDirectory(QString given) +{ + if (given != "") return given; + return QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); +} + +#endif diff --git a/main/dev/DevChecks.h b/main/dev/DevChecks.h new file mode 100644 index 00000000..b5692425 --- /dev/null +++ b/main/dev/DevChecks.h @@ -0,0 +1,581 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TONY_DEV_CHECKS_H +#define TONY_DEV_CHECKS_H + +#ifdef TONY_DEV_CHECKS + +#include "../Analyser.h" +#include "../AudioCheckRunner.h" +#include "../Coverage.h" +#include "../LatencyCalibration.h" +#include "../LatencyCheck.h" +#include "TakeObserver.h" + +#include "base/Event.h" + +#include +#include +#include +#include +#include + +#include +#include +#include + +class MainWindow; +class QTimer; + +/** + * What one development check found. Not a QtTest function: the app + * suite has to show that each check can fail, which a QVERIFY inside + * another test cannot. + */ +struct CheckResult +{ + enum class Verdict { Pass, Fail, Measured, Skipped }; + + /// The item it settles, in the manual checklist's numbering from + /// before default's rewrite of it (docs/calibrate-audio.md explains), + /// and its name + int item; + QString name; + + Verdict verdict; + + /// The figures behind the verdict, as label and value, in words + std::vector> numbers; + + /// What the verdict means, or why there is none + QString message; + + /// "Pass", "Fail", "Measured" or "Skipped" + static QString verdictName(Verdict verdict); + + CheckResult() : item(0), verdict(Verdict::Skipped) { } +}; + +/** + * What a run of the development checks found, however it ended. + */ +struct DevReport +{ + /// Every check, in the order of the checklist's items. A run that + /// ended early has those it did not reach Skipped, with the reason + std::vector checks; + + /// Why the run ended before its last stage was done; empty if not + QString failure; + + /// The report file, and the session the run saved in its scratch + /// folder; empty if they were not written + QString reportPath; + QString sessionPath; + + int count(CheckResult::Verdict verdict) const; +}; + +/** + * The development checks (development builds only): every item of the + * manual checklist that a speaker-to-microphone loopback can settle, + * run on the live window through the audio check's ordinary takes, + * after a calibration, with the round trip it measured. + * + * A run is a list of stages, each of which starts something and then + * says, when polled, whether it is done. Driven by a polling timer + * and the runner's finished(), as the runner itself is, and never by a + * nested event loop: the window can be closed, and the session + * replaced, at any moment, and a run ends cleanly then. + * + * The stages: + * 1. Long song: a run of the audio check on a long reference of its + * own (Options::longSeconds; replacing the calibration's session + * without asking), with the two punch-ins far apart of + * longPunchIns(). First, so that the next stage replaces this + * session as a check's own, unsaved, and no saved one is ever + * replaced. + * 2. Fresh punch-ins: a run on the dev layout, in a reference of its + * own, with the two punch-ins of freshPunchIns() and the round trip + * given. + * 3. Re-record: a run into the same session and take, with the one + * punch-in of reRecording(), over part of stage 2's second. + * 4. Pre-roll near the start: the same with nearTheStart(), and a + * pre-roll longer than the song before it. + * 5. Joins: the same with the two punch-ins of joinPunchIns(), which + * meet in the middle of a held tone. + * 6. Save and reopen: the session saved into a scratch folder of this + * run, the way Save As saves once it has a name, then opened again + * and its take's file judged again. + * + * The checks are worked out when the run ends, from what the stages + * kept: items 1 (latency, also after save and reopen) and 2 (several + * phrases in one take) over every punch-in of the run; from what a + * TakeObserver saw of each of stage 2's punch-ins, items 3 (live dots, + * and how far behind the cursor they appear) and 5 (the mic on input + * 2); from what it saw of every punch-in, item 4 (nothing of the take + * in the speakers); from stage 1, item 9 (Stop on a long song); from + * the take before and after stage 3, items 7 (record from a position) + * and 12 (the lead-in); from stage 4, item 13 (pre-roll near the + * start); from stages 3 and 4, item 14 (Record into Selection stops by + * itself); and from stage 5, item 10 (the joins). A run that ends + * early works out each check whose stages it got through, and has the + * rest Skipped. + * + * The session saved stays open afterwards, so that the takes can be + * looked at; its scratch folder stays with it, and the next run + * removes every such folder the session open then does not use. The + * report goes to a text file, DevChecks.txt, ending with a Totals: + * line like the suites'. + * + * MainWindow owns it, and deletes it after the Calibrate Audio dialog + * and before the runner. A friend of MainWindow: it saves and opens + * sessions, and reads the take's coverage, file and events. + */ +class DevChecks : public QObject +{ + Q_OBJECT + +public: + /// Items 1, 2, 7 and 13: how far from where the reference has it a + /// sweep may land, either way. Kept running, a stream on two sound + /// cards drifts, and PortAudio slips a period (10 ms on WASAPI) to + /// match, so a session's takes lie within one period of each other: + /// the user's run lay within -5.3 and +4.0 ms of its calibration. + /// The user accepted that (2026-09-26), far under what a singer + /// hears; 2 ms before + static constexpr double kPlacementSeconds = 0.006; + + /// Item 3: more live dots than this in every punch-in, each where + /// TakeDiff::placeLiveDot() allows + static constexpr int kMinDots = 10; + + /// Item 5: an input carries the mic when its peak is no more than + /// this far below the loudest input's + static constexpr double kMicChannelDb = 20.0; + + /// Stage 3's pre-roll, in seconds: a lead-in from 16.8 s, where + /// stage 2's second punch-in begins, so that it plays over two of + /// the reference's silent gaps in what that punch-in recorded (16.8 + /// to 17.7 s and 18.8 to 19.2 s). Item 12 looks at the output in + /// both: a window held up over one still has the other to be judged + /// in + static constexpr double kReRecordPreRollSeconds = 2.4; + + /// Stage 4's pre-roll, in seconds: more than there is room for + /// before nearTheStart() + static constexpr double kNearStartPreRollSeconds = 3.0; + + /// Item 14: how far past the end of its selection a take may record + /// before it stops itself, besides a look of the window's take timer + /// and a block of the device, as the checklist asks + static constexpr double kStopMarginSeconds = 0.25; + + /// Item 9: each punch-in into the long song has its pitch merged + /// within this share of the time the whole song's analysis took + static constexpr double kStopShare = 0.5; + + /// How often a stage is looked at + static constexpr int kPollMs = 50; + + /// How long a stage may take. A stage that runs the audio check + /// waits for it, and the runner has limits of its own for each + /// step, which end its run with a reason: this is a backstop behind + /// them. The runner's for the reference's analysis and for those of + /// two punch-ins, the most a stage records, and two minutes for the + /// rest (writing and opening the reference, recording, stopping): + /// 4 minutes on a desktop, 10 on a phone. Save and reopen is quick + /// on a desktop; on a phone it may take as many times longer as an + /// analysis may + static constexpr int kCheckStageTimeoutMs = + AudioCheckRunner::kReferenceTimeoutMs + + 2 * AudioCheckRunner::kTakeAnalysisTimeoutMs + 120000; + static constexpr int kReopenTimeoutMs = + 60000 * AudioCheckRunner::kAnalysisTimeFactor; + + /// The report's file name, in the report directory + static constexpr const char *kReportFileName = "DevChecks.txt"; + + /// The name of the session saved in the scratch folder + static constexpr const char *kSessionFileName = "dev-checks.ton"; + + struct Options { + /// The round trip the run's takes are placed with, in seconds, + /// for the run only (AudioCheckRunner::Plan::roundTrip); + /// negative for the one the window uses + double roundTrip; + + /// Where the report goes: "" for $TONY_TEST_LOG_DIR if that is + /// set, else the application's data directory + QString reportDirectory; + + /// Where the scratch folders go: "" for the application's data + /// directory + QString scratchDirectory; + + /// How long the long song of stage 1 is, in seconds; 0 leaves + /// the stage out, and item 9 is Skipped (for tests that look at + /// the other stages, which it would only make longer) + double longSeconds; + + Options() : roundTrip(-1.0), + longSeconds(LatencyCheck::kLongSeconds) { } + }; + + DevChecks(MainWindow *window, AudioCheckRunner *runner); + virtual ~DevChecks(); + + /** + * Stage 1's punch-ins into a long layout, in seconds: each the + * shortest range that judges one sweep, the first sweep from a + * quarter of the way into the song, and the first from five eighths + * (near 60 and 150 s of 4 minutes), so that each is far from the + * start and from the other. Empty if the layout is too short. + */ + static std::vector + longPunchIns(const LatencyCheck::Layout &layout); + + /** + * Stage 2's punch-ins on the dev layout, in seconds: [6.3, 10.2] + * and [16.8, 21.2], each judging two sweeps (7.2 and 9.1 s; 17.7 + * and 20.1 s) with 50 ms to spare. In two separate regions of the + * calibration part, clear of what later stages need: the start + * (before 4.3 s) for a punch-in at 1 s whose range judges the sweep + * at 3.1 s; the held tones from 26.9 s on; 10.2 to 16.8 s and 21.2 + * to 25 s for fresh punch-ins; and each range holds two sweeps, so + * that a re-recording can start inside it, past its first sweep, + * with its lead-in over what was recorded there, and still judge + * the second. + */ + static std::vector freshPunchIns(); + + /** + * Stage 5's punch-ins, in seconds: [26.0, 28.7] and [28.7, 32.0], + * meeting at 28.7 s, the middle of the held tone from 27.2 to 30.2 + * s, which reaches 1.5 s either side of the join: further than + * TakeDiff looks at the pitch and notes around it. The first + * judges that tone's sweep at 26.9 s, and the second the next + * sweep, at 30.9 s; the second's 1 s lead-in plays over what the + * first recorded. The shortest pair that does that, and clear of + * every other stage's range. + */ + static std::vector joinPunchIns(); + + /** + * Stage 3's punch-in, in seconds: [19.2, 21.2], over the end of + * stage 2's second and past its first sweep, judging the sweep at + * 20.1 s. Its lead-in (kReRecordPreRollSeconds) plays over what + * stage 2 recorded from its start: a gap where the reference is + * silent and the take holds only what the mic heard besides (16.8 + * to 17.7 s), the sweep at 17.7 s, the tone from 18 s, and another + * such gap from 18.8 s. Starting this late keeps the range short + * and leaves a whole note of the take (18 to 18.8 s) before it for + * the ranged analysis to leave alone. + */ + static LatencyCheck::PunchIn reRecording(); + + /** + * Stage 4's punch-in, in seconds: [1.0, 4.2], judging the sweep at + * 3.1 s, recorded with a pre-roll of kNearStartPreRollSeconds. + */ + static LatencyCheck::PunchIn nearTheStart(); + + /** + * A new scratch folder in the directory, made: dev-checks-1, + * dev-checks-2 and so on, the lowest number free. Every such + * folder in the directory is removed first, with all it holds, + * except the one the session file inUse is in: that session, open + * now, was saved there by an earlier run. "" if none could be + * made. + */ + static QString nextScratchFolder(QString directory, QString inUse); + + /** + * Begin a run. False, with nothing started, if one is running + * already, if the audio check is, if a take is being recorded, or + * if no scratch folder could be made. Otherwise finished() comes + * once, at the end, however the run ends. + */ + bool start(const Options &options); + + /// End the run: its take is stopped through the Stop path, as the + /// audio check's Cancel does, and finished() says why + void cancel(); + + bool isRunning() const; + + /// Called by MainWindow::closeSession(), after the runner has been + /// told: a run ends then, unless it is replacing the session itself + /// (its reopen, or its runner opening the dev reference) + void sessionClosing(); + +signals: + /// When a stage begins: its name, and which of how many. Never + /// from inside start() or cancel() + void progress(QString stage, int stageNumber, int stages); + + void finished(const DevReport &report); + +private: + struct Stage { + QString name; + std::function begin; + std::function done; + int limitMs; + }; + + MainWindow *m_window; + QPointer m_runner; + QTimer *m_timer; + + Options m_options; + std::vector m_stages; + + /// The stage going on, or -1; whether it has begun, and since when + int m_stage; + bool m_begun; + QElapsedTimer m_stageClock; + + /// Set while poll() runs: a dialog shown from inside a stage runs + /// an event loop of its own, in which the timer goes on firing + bool m_inPoll; + + /// A run of the runner's that this run started is going on; and + /// what it said when it ended + bool m_runnerRunning; + AudioCheckResult m_runnerResult; + + /// Set while the run opens the session it saved + bool m_reopening; + + /// What the run was started with, for the report + QDateTime m_startedAt; + LatencyCalibration::Key m_devices; + QString m_scratchFolder; + QString m_sessionPath; + bool m_saved; + + /// What the observer saw of one of the runner's punch-ins, and the + /// peak of each channel of its raw recording, full scale 1, or why + /// that could not be read + struct Watched { + int punchIn; ///< counting from 0 + TakeObserver::Observation seen; + std::vector channelPeaks; + QString channelError; + Watched() : punchIn(0) { } + }; + + /// Watches each punch-in of a run of the runner's that this run + /// started, from its Recording step until its analysis is done; + /// which punch-in, counting from 1, or 0; and what it saw + TakeObserver *m_observer; + int m_observedPunchIn; + std::vector m_watched; + + /// Stage 1, on a long song of its own: its layout, whether it got + /// through, what the runner found, the take's coverage straight + /// after, and what was seen of each punch-in. The runner's steps + /// timed: the whole song's analysis, from the session open to the + /// analysis done (-1 if it was not seen through), and each + /// punch-in's, from the take stopped to its pitch merged, in the + /// order recorded; and the take's pitch as each punch-in began to + /// record, and after the last. While the stage runs, the step and + /// punch-in the runner last reported, and when that began + struct LongSong { + LatencyCheck::Layout layout; + bool done; + AudioCheckResult result; + Coverage coverage; + std::vector watched; + double wholeSongSeconds; + std::vector stopSeconds; + std::vector pitch; + bool timing; + QElapsedTimer clock; + AudioCheckRunner::Step step; + int punchIn; + qint64 stepFrom; + LongSong() : done(false), wholeSongSeconds(-1.0), timing(false), + step(AudioCheckRunner::Step::Idle), punchIn(0), + stepFrom(0) { } + }; + LongSong m_long; + + /// Stage 2: the dev layout, which later stages record into too, what + /// the runner found, the take's coverage straight after, and what was + /// seen of each punch-in + LatencyCheck::Layout m_layout; + bool m_haveFresh; + AudioCheckResult m_fresh; + Coverage m_coverageAfterFresh; + std::vector m_freshWatched; + + /// The take as it was at one moment: its audio as its file holds it, + /// mixed to one channel (AudioCheckRunner::readTakeFile()), or why + /// it could not be read; its pitch and notes; and its coverage + struct Snapshot { + std::vector audio; + QString audioError; + sv::EventVector pitch; + sv::EventVector notes; + Coverage coverage; + }; + + /// Stages 3, 4 and 5, each a run of the runner's into the take there + /// is: whether it got through, what the runner found, the take + /// before and after, what was seen of the punch-ins, the lead-in the + /// window gave the last, and how many frames the first one's raw + /// recording holds (-1, and why, if that could not be read) + struct PunchInStage { + bool done; + AudioCheckResult result; + Snapshot before; + Snapshot after; + std::vector watched; + sv::sv_frame_t preRoll; + sv::sv_frame_t recorded; + QString recordedError; + PunchInStage() : done(false), preRoll(0), recorded(-1) { } + }; + PunchInStage m_reRecord; + PunchInStage m_nearStart; + PunchInStage m_joins; + + /// Stage 6: the take's pitch and notes before the save and after + /// the reopen, and its file judged, over every punch-in into it, + /// just before the save and again after the reopen + sv::EventVector m_pitchBefore; + sv::EventVector m_notesBefore; + sv::EventVector m_pitchAfter; + sv::EventVector m_notesAfter; + bool m_reopened; + LatencyCheck::TakeSummary m_beforeSave; + LatencyCheck::TakeSummary m_rejudged; + + /// One of the runs of the runner's that the run got through, in + /// order: the layout of the reference it recorded against, what it + /// found, the take's coverage after it, and what was seen of its + /// punch-ins + struct Run { + const LatencyCheck::Layout *layout; + const AudioCheckResult *result; + const Coverage *coverage; + const std::vector *watched; + }; + std::vector runs() const; + + /// What was seen of a run's punch-in i, counting from 0, or null + static const Watched *watchedOf(const Run &run, int i); + + /// What the output levels read at a punch-in's looks say of the + /// reference's silent gaps (speakersCheck()). Looks that reach past + /// "until", in seconds on the reference's timeline, are left out + struct GapLooks { + /// The start gap was measured, so that the looks could be placed + bool placed; + + /// How far either side of a look what it read may lie, seconds + double margin; + + /// The loudest level read at any look, and at any look lying + /// wholly in a gap, full scale 1; and how many looks did + double loudest; + double loudestInGaps; + int looks; + + /// The first look in a gap that read anything: its level (0 if + /// none did) and where it lay, in seconds + double heard; + double heardFrom; + double heardTo; + + /// The longest wait between two looks while recording, in + /// seconds, of those beginning before "until": the window held + /// up. The frames received jump meanwhile, so the look across + /// the wait reaches over a sound, and is left out + double longestWait; + + GapLooks() : placed(false), margin(0), loudest(0), loudestInGaps(0), + looks(0), heard(0), heardFrom(0), heardTo(0), + longestWait(0) { } + }; + GapLooks gapLooks(const LatencyCheck::Layout &layout, + const TakeObserver::Observation &seen, + const TakeLatency &latency, sv::sv_samplerate_t rate, + double until) const; + + void poll(); + void runnerFinished(const AudioCheckResult &result); + void runnerProgress(const AudioCheckRunner::Progress &state); + + /// Stop the observer and keep what it saw + void finishObservation(); + + /// Stage 1: the runner has reported a step and punch-in (Idle and 0 + /// when it finished); a step it had reported before ends + void longSongStep(AudioCheckRunner::Step step, int punchIn); + + /// What was seen of stage 2's punch-in i, counting from 0, or null + const Watched *freshWatched(int i) const; + + void beginLongSong(); + bool longSongDone(); + void beginFreshPunchIns(); + bool freshPunchInsDone(); + void beginPunchInStage(PunchInStage &stage, + std::vector ranges, + double preRoll); + bool punchInStageDone(PunchInStage &stage); + void beginReopen(); + bool reopenDone(); + + /// The take as it is now, its models looked up afresh + Snapshot snapshot() const; + + /// Every punch-in into the dev reference's take so far, in the order + /// recorded + std::vector punchInsSoFar() const; + + /// The events of the take's pitch track or notes, from its model + /// as it is now + sv::EventVector takeEvents(Analyser::Component component) const; + + /// End the run, work the checks out, write the report and say so; + /// failure is empty for a run that got through every stage + void end(QString failure); + + std::vector evaluate(QString reason) const; + CheckResult latencyCheck(QString reason) const; + CheckResult phrasesCheck(QString reason) const; + CheckResult liveDotsCheck(QString reason) const; + CheckResult speakersCheck(QString reason) const; + CheckResult micChannelCheck(QString reason) const; + CheckResult positionCheck(QString reason) const; + CheckResult longSongCheck(QString reason) const; + CheckResult joinsCheck(QString reason) const; + CheckResult leadInCheck(QString reason) const; + CheckResult nearStartCheck(QString reason) const; + CheckResult stopsItselfCheck(QString reason) const; + + /// The report file written, or "" if it could not be + QString writeReport(const DevReport &report) const; + + static QString reportDirectory(QString given); + static QString scratchDirectory(QString given); +}; + +#endif +#endif diff --git a/main/dev/TakeObserver.cpp b/main/dev/TakeObserver.cpp new file mode 100644 index 00000000..334fd4c7 --- /dev/null +++ b/main/dev/TakeObserver.cpp @@ -0,0 +1,216 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifdef TONY_DEV_CHECKS + +#include "TakeObserver.h" + +#include "../Analyser.h" +#include "../MainWindow.h" + +#include "audio/AudioCallbackPlaySource.h" +#include "audio/AudioCallbackRecordTarget.h" +#include "data/model/SparseTimeValueModel.h" +#include "data/model/WritableWaveFileModel.h" +#include "view/ViewManager.h" + +#include +#include +#include +#include +#include + +#include + +using namespace sv; + +TakeObserver::TakeObserver(MainWindow *window, QObject *parent) : + QObject(parent), + m_window(window), + m_timer(new QTimer(this)), + m_playbackFrame(0), + m_loopLevel(0), + m_inputSince(false), + m_inputLeftSince(0.f), + m_inputRightSince(0.f), + m_inputLeft(0.f), + m_inputRight(0.f) +{ + m_timer->setInterval(kPollMs); + m_timer->setTimerType(Qt::PreciseTimer); + connect(m_timer, &QTimer::timeout, this, &TakeObserver::poll); + + // While recording, the meter is told the input levels the view + // manager reads; the observer must not read them itself + if (m_window->m_viewManager) { + connect(m_window->m_viewManager, &ViewManager::monitoringLevelsChanged, + this, &TakeObserver::inputLevels); + } +} + +TakeObserver::~TakeObserver() +{ +} + +void +TakeObserver::start() +{ + m_observation = Observation(); + m_seen.clear(); + m_inputSince = false; + m_inputLeftSince = m_inputRightSince = 0.f; + m_inputLeft = m_inputRight = 0.f; + m_playbackFrame = 0; + + // Started from a poll of the runner's, in the window's own loop + m_loopLevel = QThread::currentThread()->loopLevel(); + + // This take's dots come in a model made once the take is under way + m_earlierDots = m_window->m_realtimePitchModelId; + + if (m_window->m_viewManager) { + m_observation.playbackStart = + m_window->m_viewManager->getRecordStartFrame(); + } + if (m_window->m_playSingingAudio) { + m_observation.singingAudioBefore = + m_window->m_playSingingAudio->isChecked(); + } + + m_clock.start(); + poll(); + m_timer->start(); +} + +void +TakeObserver::stop() +{ + // No last look: the next take may have begun by now + m_timer->stop(); +} + +bool +TakeObserver::isObserving() const +{ + return m_timer->isActive(); +} + +void +TakeObserver::inputLevels(float left, float right) +{ + // While the take only plays, the view manager tells the meter the + // output levels instead + if (!isObserving() || !m_window->m_recordTarget || + !m_window->m_recordTarget->isRecording()) { + return; + } + if (!m_inputSince) { + m_inputSince = true; + m_inputLeftSince = left; + m_inputRightSince = right; + } else { + m_inputLeftSince = std::max(m_inputLeftSince, left); + m_inputRightSince = std::max(m_inputRightSince, right); + } +} + +void +TakeObserver::poll() +{ + AudioCallbackRecordTarget *target = m_window->m_recordTarget; + AudioCallbackPlaySource *source = m_window->m_playSource; + + Sample s; + s.ms = m_clock.elapsed(); + s.recording = target && target->isRecording(); + + // Only while recording: then getPlaybackFrame() works out what the + // view manager's own poll does. While the reference plays on after + // the take, it would take the play source's frame, and could do so + // between the take's putting the playhead back and stopping playback + if (s.recording && m_window->m_viewManager) { + m_playbackFrame = m_window->m_viewManager->getPlaybackFrame(); + } + s.playbackFrame = m_playbackFrame; + + if (target) s.framesBefore = target->getFramesReceived(); + if (s.recording && source) { + s.outputRead = true; + float left = 0.f, right = 0.f; + if (source->getOutputLevels(left, right)) { + s.outputLeft = left; + s.outputRight = right; + } + } + if (target) s.framesAfter = target->getFramesReceived(); + + if (s.recording) { + if (m_inputSince) { + m_inputLeft = m_inputLeftSince; + m_inputRight = m_inputRightSince; + m_inputSince = false; + } + s.inputLeft = m_inputLeft; + s.inputRight = m_inputRight; + } + + s.status = m_window->getStatusLabel()->text(); + s.modal = (QApplication::activeModalWidget() != nullptr || + QThread::currentThread()->loopLevel() > m_loopLevel); + + if (s.recording && m_observation.recordingPath == "") { + if (auto recording = ModelById::getAs + (m_window->m_currentRecordingModelId)) { + m_observation.recordingPath = recording->getLocation(); + } + } + + // Each dot the first time it is there. The window may throw the + // dots placed so far away once, when it learns the start gap; any + // placed again are new dots + const ModelId dotsId = m_window->m_realtimePitchModelId; + if (!dotsId.isNone() && dotsId != m_earlierDots) { + if (auto dots = ModelById::getAs(dotsId)) { + for (const Event &e : dots->getAllEvents()) { + const auto key = std::make_pair(e.getFrame(), e.getValue()); + if (!m_seen.insert(key).second) continue; + Dot d; + d.ms = s.ms; + d.frame = e.getFrame(); + d.hz = e.getValue(); + d.playbackFrame = s.playbackFrame; + m_observation.dots.push_back(d); + } + } + } + + if (!s.recording && m_observation.stoppedMs < 0 && + !m_observation.samples.empty() && + m_observation.samples.back().recording) { + m_observation.stoppedMs = s.ms; + } + if (!s.recording && m_observation.stoppedMs >= 0) { + m_observation.sawAfter = true; + m_observation.singingAudioAfter = + m_window->m_playSingingAudio && + m_window->m_playSingingAudio->isChecked(); + Analyser *take = m_window->m_analyser2; + m_observation.takeAudibleAfter = + take && take->isAudible(Analyser::Audio); + } + + m_observation.samples.push_back(s); +} + +#endif diff --git a/main/dev/TakeObserver.h b/main/dev/TakeObserver.h new file mode 100644 index 00000000..c11b08b7 --- /dev/null +++ b/main/dev/TakeObserver.h @@ -0,0 +1,201 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TONY_TAKE_OBSERVER_H +#define TONY_TAKE_OBSERVER_H + +#ifdef TONY_DEV_CHECKS + +#include "base/BaseTypes.h" +#include "data/model/Model.h" + +#include +#include +#include + +#include +#include +#include + +class MainWindow; +class QTimer; + +/** + * Watches one take of the window's (development builds only), from + * just after it starts until it is let go, and keeps what it saw for + * the development checks to judge afterwards: DevChecks starts one for + * each punch-in of the audio check when it starts recording, and stops + * it when that punch-in's analysis is done. + * + * It only looks. It writes no model and no setting, and reads nothing + * that someone else would then miss, with one exception made on + * purpose: the play source's output levels. getOutputLevels() and + * getInputLevels() each give the peak since the previous call and + * reset it, so each can have one reader only. While a take is being + * recorded, lead-in included, ViewManager::checkPlayStatus() reads the + * input levels for the meter and never the output levels, so the + * observer reads the output levels then, and only then, and takes the + * input levels from the meter's own signal. + * + * A friend of MainWindow, as DevChecks is: it reads the take's state. + */ +class TakeObserver : public QObject +{ + Q_OBJECT + +public: + /// How often it looks: as often as ViewManager moves the cursor + static constexpr int kPollMs = 20; + + /// What one poll saw + struct Sample { + /// Since the observer started + qint64 ms; + + /// The record target was recording + bool recording; + + /// The ViewManager's playback frame, where the cursor is drawn: + /// where the take's playback started plus what has been + /// recorded (ViewManager::getPlaybackFrame()). Read only while + /// recording; after that, the last read + sv::sv_frame_t playbackFrame; + + /// Frames the record target had received, read just before and + /// just after the output levels. An audio callback takes in a + /// block of input and then hands out a block of output, so these + /// place the output blocks the levels were taken from + sv::sv_frame_t framesBefore; + sv::sv_frame_t framesAfter; + + /// The output levels were read at this poll: only while + /// recording. They are the loudest sample handed to the device + /// since the previous read, left and right, full scale 1; 0 if + /// the device reported none + bool outputRead; + float outputLeft; + float outputRight; + + /// The loudest input the level meter was told of since the + /// previous poll, or what it was last told if nothing since; + /// while recording only + float inputLeft; + float inputRight; + + /// The status bar's text, and whether a modal dialog was up: a + /// modal widget, or an event loop run deeper than the one the + /// observer started in, which is how a dialog Android draws + /// itself (a message box, the file picker) shows: Qt's widget for + /// it is never on screen, and is no active modal widget + QString status; + bool modal; + + Sample() : ms(0), recording(false), playbackFrame(0), + framesBefore(0), framesAfter(0), outputRead(false), + outputLeft(0), outputRight(0), inputLeft(0), + inputRight(0), modal(false) { } + }; + + /// A live dot, as it first appeared + struct Dot { + qint64 ms; + + /// Where it was drawn, on the reference's timeline, and its + /// pitch + sv::sv_frame_t frame; + float hz; + + /// The playback frame at the poll that first saw it + sv::sv_frame_t playbackFrame; + + Dot() : ms(0), frame(0), hz(0), playbackFrame(0) { } + }; + + /// All it saw of one take + struct Observation { + std::vector samples; + std::vector dots; + + /// Where the take's playback started (the pre-roll's lead-in + /// included): the ViewManager's record start frame + sv::sv_frame_t playbackStart; + + /// The raw recording, with every channel the device delivered; + /// "" if none was seen + QString recordingPath; + + /// When a poll first found the take no longer recording, since + /// the observer started; -1 if none did + qint64 stoppedMs; + + /// Play Singing Audio as the button showed it when the take had + /// begun (it goes on showing what was asked for before the take, + /// while the take's audio is kept silent), and as it showed after + /// the take, with whether the take's audio was then audible; the + /// last two from the last poll after the take stopped + bool singingAudioBefore; + bool singingAudioAfter; + bool takeAudibleAfter; + bool sawAfter; + + Observation() : playbackStart(0), stoppedMs(-1), + singingAudioBefore(false), singingAudioAfter(false), + takeAudibleAfter(false), sawAfter(false) { } + }; + + explicit TakeObserver(MainWindow *window, QObject *parent = nullptr); + virtual ~TakeObserver(); + + /// Start watching the take just started, afresh; it is looked at at + /// once, and then every kPollMs + void start(); + + /// Stop watching. What was seen stays until the next start() + void stop(); + + bool isObserving() const; + + const Observation &observation() const { return m_observation; } + +private: + MainWindow *m_window; + QTimer *m_timer; + QElapsedTimer m_clock; + Observation m_observation; + sv::sv_frame_t m_playbackFrame; + + /// The dot model there was when the take began: the last take's, + /// if its dots were still on show. This take's is made later + sv::ModelId m_earlierDots; + + /// The dots seen so far, by frame and pitch + std::set> m_seen; + + /// How deep in event loops the observer was started: a poll deeper + /// than that runs inside a dialog's + int m_loopLevel; + + /// What the level meter was told since the last poll, and last + bool m_inputSince; + float m_inputLeftSince; + float m_inputRightSince; + float m_inputLeft; + float m_inputRight; + + void poll(); + void inputLevels(float left, float right); +}; + +#endif +#endif diff --git a/main/main.cpp b/main/main.cpp index ebb20b5f..cadd18bb 100644 --- a/main/main.cpp +++ b/main/main.cpp @@ -14,6 +14,7 @@ */ #include "MainWindow.h" +#include "CompactLayout.h" #include "system/System.h" #include "system/Init.h" @@ -36,11 +37,29 @@ #include #include #include +#include #include #include #include +#ifdef Q_OS_ANDROID +#include "AndroidFiles.h" +#include "AndroidStorage.h" +#include "LogFile.h" +#include "PopupArea.h" +#include "TouchMenuStyle.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#endif + #include "../version.h" #include @@ -130,6 +149,85 @@ class TonyApplication : public QApplication } }; +#ifdef Q_OS_ANDROID +// Each line that goes to the system log goes to a file in the app's own +// storage as well, from when keepSystemLogInFile() is called (it needs +// the application): the user, who has no adb to read the system log +// with, can save a copy of it with Help > Save Log... and send it. The +// lines before that are held until then +static std::mutex logFileMutex; +static LogFile *logFile = nullptr; +static std::vector linesBeforeLogFile; + +static void +writeToLogFile(const std::string &line) +{ + std::lock_guard lock(logFileMutex); + if (logFile) { + logFile->write(QByteArray::fromStdString(line)); + } else if (linesBeforeLogFile.size() < 1000) { + linesBeforeLogFile.push_back(line); + } +} + +static void +keepSystemLogInFile(QString path) +{ + std::lock_guard lock(logFileMutex); + LogFile *file = new LogFile(path, 512 * 1024); + if (!file->open()) { + delete file; + linesBeforeLogFile.clear(); + __android_log_write(ANDROID_LOG_WARN, "Tony", + "Cannot write the log file"); + return; + } + for (const std::string &line : linesBeforeLogFile) { + file->write(QByteArray::fromStdString(line)); + } + linesBeforeLogFile.clear(); + logFile = file; +} + +// An Android app's stdout and stderr lead nowhere, and Tony and svcore +// report through cerr. Send both into a pipe, and have a thread pass what +// comes out of it on to the system log (logcat), a line at a time, under +// the tag "Tony", and to the log file. Qt's own messages go to the system +// log only. +static void +sendOutputToSystemLog() +{ + int fds[2]; + if (pipe(fds) != 0) return; + + setvbuf(stdout, nullptr, _IOLBF, 0); + setvbuf(stderr, nullptr, _IONBF, 0); + dup2(fds[1], STDOUT_FILENO); + dup2(fds[1], STDERR_FILENO); + close(fds[1]); + + int readEnd = fds[0]; + std::thread([readEnd]() { + char buffer[1024]; + std::string line; + while (true) { + ssize_t n = read(readEnd, buffer, sizeof(buffer)); + if (n < 0 && errno == EINTR) continue; + if (n <= 0) break; + for (ssize_t i = 0; i < n; ++i) { + // The system log cuts longer lines short + if (buffer[i] == '\n' || line.size() >= 1000) { + __android_log_write(ANDROID_LOG_INFO, "Tony", line.c_str()); + writeToLogFile(line); + line.clear(); + } + if (buffer[i] != '\n') line += buffer[i]; + } + } + }).detach(); +} +#endif + static QString getEnvQStr(QString variable) { @@ -155,9 +253,13 @@ putEnvQStr(QString assignment) #endif } -static void +// Returns what went wrong in setting up the plugins, for the user to be +// told; only on Android can anything go wrong here +static QStringList setupTonyVampPath() { + QStringList problems; + QString myVampPath = getEnvQStr("TONY_VAMP_PATH"); #ifdef Q_OS_WIN32 @@ -182,6 +284,21 @@ setupTonyVampPath() #ifdef Q_OS_MAC myVampPath = myDir + "/../Resources"; (void)sep; // unused +#elif defined(Q_OS_ANDROID) + // myDir is the folder Android installed the application's own + // library in, and the plugins beside it as libpyin.so and + // libchp.so. Vamp looks in a folder of links to those two + // under their own names instead (see AndroidFiles) + QString linkDir = QStandardPaths::writableLocation + (QStandardPaths::AppDataLocation) + "/vamp"; + QStringList links = AndroidFiles::linkVampPlugins + (myDir, linkDir, { "pyin", "chp" }, problems); + for (QString link : links) { + QString problem = AndroidFiles::checkVampPlugin(link); + if (problem != "") problems << problem; + } + myVampPath = linkDir; + (void)sep; // unused #else if (binaryName != "") { myVampPath = @@ -201,11 +318,17 @@ setupTonyVampPath() // Windows lacks setenv, must use putenv (different arg convention) putEnvQStr(env); + + return problems; } int main(int argc, char **argv) { +#ifdef Q_OS_ANDROID + sendOutputToSystemLog(); +#endif + if (argc == 2 && (QString(argv[1]) == "--version" || QString(argv[1]) == "-v")) { cerr << TONY_VERSION << endl; @@ -216,11 +339,38 @@ main(int argc, char **argv) TonyApplication application(argc, argv); +#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) + // Qt follows the system's dark app mode, but Tony's icons and + // several of its widgets are drawn in black for a light + // background and vanish on a dark one. Stay light whatever the + // system says. + QGuiApplication::styleHints()->setColorScheme(Qt::ColorScheme::Light); +#endif + +#ifdef Q_OS_ANDROID + // Menus that scroll, by a finger dragged over them, rather than run + // off the screen. Before any widget is made, so that every menu has it + QApplication::setStyle(new TouchMenuStyle); +#endif + QApplication::setOrganizationName("sonic-visualiser"); QApplication::setOrganizationDomain("sonicvisualiser.org"); QApplication::setApplicationName("Tony"); - setupTonyVampPath(); +#ifdef Q_OS_ANDROID + keepSystemLogInFile(AndroidStorage::logPath()); + { + // The package's version name carries the commit (build-apk.sh) + QString installed = AndroidStorage::versionName(); + cerr << "Tony " << (installed != "" ? installed.toStdString() : + std::string(TONY_VERSION)) + << " on Android API " + << QNativeInterface::QAndroidApplication::sdkVersion() + << ", Qt " << qVersion() << endl; + } +#endif + + QStringList pluginProblems = setupTonyVampPath(); QStringList args = application.arguments(); @@ -238,7 +388,7 @@ main(int argc, char **argv) if (args.contains("--help") || args.contains("-h") || args.contains("-?")) { std::cerr << QApplication::tr( - "\nTony is a program for interactive note and pitch analysis and annotation.\n\nUsage:\n\n %1 [--no-audio] [--no-sonification] [--no-spectrogram] [ ...]\n\n --no-audio: Do not attempt to open an audio output device\n --no-sonification: Disable sonification of pitch tracks and notes and hide their toggles.\n --no-spectrogram: Disable spectrogram.\n : One or more Tony (.ton) and audio files may be provided.").arg(argv[0]).toStdString() << std::endl; + "\nTony is a program for interactive note and pitch analysis and annotation.\n\nUsage:\n\n %1 [--no-audio] [--no-sonification] [--no-spectrogram] [--compact] [ ...]\n\n --no-audio: Do not attempt to open an audio output device\n --no-sonification: Disable sonification of pitch tracks and notes and hide their toggles.\n --no-spectrogram: Disable spectrogram.\n --compact: Start with the layout for a phone: one toolbar of large buttons in place of the menus and toolbars.\n : One or more Tony (.ton) and audio files may be provided.").arg(argv[0]).toStdString() << std::endl; exit(2); } @@ -302,9 +452,27 @@ main(int argc, char **argv) QObject::connect(gui, SIGNAL(hideSplash()), splash, SLOT(hide())); } + // Before the window is shown, so that a phone never shows the + // desktop layout + gui->setCompactLayout(CompactLayout::isWantedAtStart(args)); + QScreen *screen = QApplication::primaryScreen(); QRect available = screen->availableGeometry(); +#ifdef Q_OS_ANDROID + // Menus clear of the system bars, which the main window's safe area + // margins give, and a finger's width from the screen's top and bottom + if (TouchMenuStyle *style = + qobject_cast(QApplication::style())) { + int margin = PopupArea::fingerWidth(screen->physicalDotsPerInchY()); + style->setSafeAreaWindow(gui); + style->setEdgeMargin(margin); + cerr << "Menus keep " << margin << " px from the screen's top and " + << "bottom (" << screen->physicalDotsPerInchY() << " px per " + << "inch)" << endl; + } +#endif + int width = (available.width() * 2) / 3; int height = available.height() / 2; if (height < 450) height = (available.height() * 2) / 3; @@ -324,6 +492,23 @@ main(int argc, char **argv) gui->show(); +#ifdef Q_OS_ANDROID + if (!pluginProblems.empty()) { + // Without the plugins no audio file can be analysed, and on a + // phone the log that says why is out of the user's sight + QStringList lines; + for (QString problem : pluginProblems) { + lines << problem.toHtmlEscaped(); + } + QMessageBox::warning + (gui, QMessageBox::tr("Plugins not found"), + QMessageBox::tr("The pitch analysis plugins could not be set up

%1

") + .arg(lines.join("
"))); + } +#else + (void)pluginProblems; // none but Android's +#endif + application.readyForFiles(); for (QStringList::iterator i = args.begin(); i != args.end(); ++i) { diff --git a/main/mingw_byte_fix.h b/main/mingw_byte_fix.h new file mode 100644 index 00000000..2984db3a --- /dev/null +++ b/main/mingw_byte_fix.h @@ -0,0 +1,101 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +/* + mingw_byte_fix.h — force-include header for MinGW + C++17 builds + ----------------------------------------------------------------- + Problem + ~~~~~~~ + rpcndr.h (pulled in by unless WIN32_LEAN_AND_MEAN is + set) contains the unconditional declaration: + + typedef unsigned char byte; + + C++17's introduces std::byte as an enum class. When + both end up in scope in the same translation unit, GCC 14 raises a + hard error: + + error: reference to 'byte' is ambiguous + candidates are: 'enum class std::byte' + 'typedef unsigned char byte' + + This fires on virtually every TU in this project because: + • svcore/system/System.h includes (which drags in + rpcndr.h via winscard.h), AND + • Qt6 headers and/or the C++ standard library bring in . + + Fix + ~~~ + This header is force-included before every TU (via -include on + the compiler command line — see meson.build). + + We include here with WIN32_LEAN_AND_MEAN pre-defined. + That causes windows.h to set its own include guard (__WINDOWS_H__) + while skipping the RPC/COM sub-headers (including rpcndr.h). + + When any later code does #include the include guard + fires and the header is a no-op — rpcndr.h is therefore never + processed and the conflicting 'byte' typedef never appears. + + None of the code in this project uses RPC/DCOM types directly, so + omitting those sub-headers has no practical effect. + + After including windows.h we include so that std::byte + is already fully defined before any other header has a chance to + race with it. + + This header is a no-op on non-MinGW / non-C++17 builds. +*/ + +#ifndef MINGW_BYTE_FIX_H +#define MINGW_BYTE_FIX_H + +#if defined(__GNUC__) && defined(_WIN32) && defined(__cplusplus) && __cplusplus >= 201703L + +/* Only act if windows.h hasn't been included yet — if it has, we + are too late to block rpcndr.h, but hopefully the conflict isn't + present in that translation unit. */ +#ifndef _WINDOWS_ + +# ifndef WIN32_LEAN_AND_MEAN +# define WIN32_LEAN_AND_MEAN +# define MINGW_BYTE_FIX_DEFINED_LEAN_AND_MEAN +# endif + +# include /* sets _WINDOWS_; skips rpcndr.h */ + +# ifdef MINGW_BYTE_FIX_DEFINED_LEAN_AND_MEAN +# undef WIN32_LEAN_AND_MEAN +# undef MINGW_BYTE_FIX_DEFINED_LEAN_AND_MEAN +# endif + +/* Restore NT types (NTSTATUS etc.) that WIN32_LEAN_AND_MEAN excluded. + These are used directly by svcore/data/fileio/test/UnsupportedFormat.cpp + and indirectly by other Windows API consumers in the tree. + must be included before ; both are safe to + include after a lean because they do not re-include + rpcndr.h. */ +# ifndef NTSTATUS +# include +# endif +# include + +#endif /* !_WINDOWS_ */ + +/* Ensure std::byte is now defined cleanly. */ +#include + +#endif /* MinGW C++17 */ + +#endif /* MINGW_BYTE_FIX_H */ \ No newline at end of file diff --git a/main/test/FakeAudioIO.h b/main/test/FakeAudioIO.h new file mode 100644 index 00000000..5525628c --- /dev/null +++ b/main/test/FakeAudioIO.h @@ -0,0 +1,370 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TEST_FAKE_AUDIO_IO_H +#define TEST_FAKE_AUDIO_IO_H + +// A duplex audio device with no hardware behind it. A worker thread +// runs the callback in real time, as a driver would: each block it +// pushes a programmed input and then pulls the application's output, +// in that order, like PortAudioIO and JACKAudioIO. +// +// The device reports whatever latencies the test asks for, so the +// application computes a known compensation, and the input can be +// made to arrive late by exactly that much. + +#include "../AudioRoute.h" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +class FakeAudioIO : public breakfastquay::SystemAudioIO, + public AudioRouteReporter +{ +public: + struct Config { + int sampleRate = 44100; + int blockSize = 512; + int channels = 2; + + // The input's channels, where they are not as many as the + // output's: a phone records one (OboeAudioIO) and plays two. -1 + // for as many as channels + int inputChannels = -1; + + // Reported to the application, and nothing else: the delay the + // input really has is inputDelay + int recordLatency = 0; + int playbackLatency = 0; + + // Added to the reported record latency at every resume after the + // first: a device that measures its latencies at each start, as + // Oboe does from its timestamps, reports others every time. The + // input's real delay stays inputDelay + int recordLatencyStep = 0; + + // The route reported to the application, as OboeAudioIO reports + // the one Android opened; with no driver, none, as PortAudioIO + AudioRoute::Route route; + + // Mono input, delivered once and followed by silence. The + // input clock restarts whenever the device is resumed + std::vector input; + + // Frames of silence before the input + int inputDelay = 0; + + // The one channel the input arrives on, the others silent, as a + // microphone on input 2 of an interface is channel 1; -1 for + // every channel + int inputChannel = -1; + + // Start the input clock at the first audible output sample + // instead of at resume. With inputDelay equal to the reported + // round trip, this is a singer who is exactly on time. + // inputDelay must be at least a block, because the input of + // the block in which playback starts has already gone + bool inputFollowsPlayback = false; + + // Add the output to the input, inputDelay frames late: + // speakers bleeding into the microphone. Again inputDelay + // must be at least a block + bool loopback = false; + + // A second arrival of the loopback, echoDelay frames after the + // first, at echoGain times its level: the input played back out + // somewhere (Windows' "Listen to this device") and heard again. + // No second arrival while echoGain is 0 + int echoDelay = 0; + float echoGain = 0.f; + + // How far the loopback (and its echo) moves at each resume after + // the first, in frames: restartShift early, then late, then on + // time, and so on, as a real stream's input moves against its + // output by several ms each time it starts. inputDelay less this + // must still be at least a block. 0 for none + int restartShift = 0; + + // A steady sine added to the input all along, humGain its peak, + // on every channel the input arrives on: a room's fans or a + // mains hum, which the microphone hears between the sounds as + // well. None while humGain is 0 + double humHz = 0.0; + float humGain = 0.f; + + // Tell the application the peak of each block's input and output, + // left and right, as PortAudioIO does for its level meters + bool reportLevels = false; + + // The device opens, and suspends and resumes as asked, but never + // calls back: no input comes in and no output is asked for, as + // with a driver whose stream starts and then delivers nothing + bool neverCallsBack = false; + + // Whether the application keeps the input it is given just + // now. It discards input until its recording file is open, + // which is some time after it resumes the device. If unset, + // all input counts as kept + std::function inputIsKept; + }; + + FakeAudioIO(breakfastquay::ApplicationRecordTarget *target, + breakfastquay::ApplicationPlaybackSource *source, + Config config) : + SystemAudioIO(target, source), + m_config(config), + m_suspended(true), + m_stop(false), + m_resumeFrame(0), + m_frames(0), + m_playStartFrame(-1), + m_sinceResume(0), + m_framesBeforePlayStart(-1), + m_resumeCount(0), + m_loopbackDelay(config.inputDelay) + { + m_source->setSystemPlaybackBlockSize(m_config.blockSize); + m_source->setSystemPlaybackSampleRate(m_config.sampleRate); + m_source->setSystemPlaybackChannelCount(m_config.channels); + m_source->setSystemPlaybackLatency(m_config.playbackLatency); + + m_target->setSystemRecordBlockSize(m_config.blockSize); + m_target->setSystemRecordSampleRate(m_config.sampleRate); + m_target->setSystemRecordChannelCount(inputChannelCount()); + m_target->setSystemRecordLatency(m_config.recordLatency); + m_reportedRecordLatency = m_config.recordLatency; + + m_thread = std::thread([this]() { run(); }); + } + + ~FakeAudioIO() override { + m_stop = true; + m_thread.join(); + } + + bool isSourceOK() const override { return true; } + bool isTargetOK() const override { return true; } + + double getCurrentTime() const override { + return double(m_frames.load()) / m_config.sampleRate; + } + + void suppressRecordSide(bool) override { } + + AudioRoute::Route getAudioRoute() const override { return m_config.route; } + + // No callback is running, or will start, once this returns + void suspend() override { + std::lock_guard guard(m_mutex); + m_suspended = true; + } + + void resume() override { + std::lock_guard guard(m_mutex); + if (!m_suspended) return; + m_suspended = false; + m_resumeFrame = long(m_captured.size()); + m_playStartFrame = -1; + m_sinceResume = 0; + m_framesBeforePlayStart = -1; + ++m_resumeCount; + // No callback runs while suspended, and none has started yet + if (m_config.recordLatencyStep != 0 && m_resumeCount > 1) { + m_reportedRecordLatency += m_config.recordLatencyStep; + m_target->setSystemRecordLatency(m_reportedRecordLatency); + } + m_loopbackDelay = m_config.inputDelay + restartOffset(m_resumeCount); + } + + bool isSuspended() const { + std::lock_guard guard(m_mutex); + return m_suspended; + } + + int getResumeCount() const { + std::lock_guard guard(m_mutex); + return m_resumeCount; + } + + /** Everything the application has played, mixed to mono. */ + std::vector getCapturedOutput() const { + std::lock_guard guard(m_mutex); + return m_captured; + } + + /** + * Index into the captured output of the first audible sample + * since the last resume, or -1 if there has been none. + */ + long getPlayStartFrame() const { + std::lock_guard guard(m_mutex); + return m_playStartFrame; + } + + /** + * Number of input frames the application kept between the last + * resume and the first audible output sample, or -1: how far into + * the take the reference started. + */ + long getFramesBeforePlayStart() const { + std::lock_guard guard(m_mutex); + return m_framesBeforePlayStart; + } + +private: + Config m_config; + mutable std::mutex m_mutex; + std::thread m_thread; + bool m_suspended; + std::atomic m_stop; + long m_resumeFrame; + std::atomic m_frames; + long m_playStartFrame; + long m_sinceResume; + long m_framesBeforePlayStart; + int m_resumeCount; + int m_reportedRecordLatency = 0; + int m_loopbackDelay; + std::vector m_captured; + + // Where the loopback lands after the given number of starts, against + // inputDelay: on time at the first, then -, +, 0 times the shift + int restartOffset(int starts) const { + if (starts < 2) return 0; + static const int cycle[] = { -1, 1, 0 }; + return cycle[(starts - 2) % 3] * m_config.restartShift; + } + + void run() { + using namespace std::chrono; + auto period = duration_cast + (duration(double(m_config.blockSize) / + m_config.sampleRate)); + auto next = steady_clock::now(); + while (!m_stop) { + next += period; + std::this_thread::sleep_until(next); + std::lock_guard guard(m_mutex); + if (m_suspended || m_config.neverCallsBack) { + // don't try to catch up on the time spent suspended + next = steady_clock::now(); + continue; + } + process(); + } + } + + int inputChannelCount() const { + return m_config.inputChannels > 0 ? m_config.inputChannels + : m_config.channels; + } + + static float peak(const float *samples, int count) { + float p = 0.f; + for (int i = 0; i < count; ++i) p = std::max(p, std::fabs(samples[i])); + return p; + } + + // The input at a position in the captured output's timeline + float inputAt(long frame) const { + long origin = m_resumeFrame; + if (m_config.inputFollowsPlayback) { + if (m_playStartFrame < 0) return 0.f; + origin = m_playStartFrame; + } + long i = frame - origin - m_config.inputDelay; + if (i < 0 || i >= long(m_config.input.size())) return 0.f; + return m_config.input[size_t(i)]; + } + + void process() { + const int n = m_config.blockSize; + const int ch = m_config.channels; + const long base = long(m_captured.size()); + + std::vector in(n, 0.f); + for (int i = 0; i < n; ++i) { + in[i] = inputAt(base + i); + if (m_config.humGain != 0.f) { + const double pi = 3.14159265358979323846; + in[i] += m_config.humGain * float(std::sin + (2.0 * pi * m_config.humHz * double(base + i) / + m_config.sampleRate)); + } + if (m_config.loopback) { + long j = base + i - m_loopbackDelay; + if (j >= 0 && j < base) in[i] += m_captured[size_t(j)]; + if (m_config.echoGain != 0.f) { + j -= m_config.echoDelay; + if (j >= 0 && j < base) { + in[i] += m_config.echoGain * m_captured[size_t(j)]; + } + } + } + } + + bool kept = !m_config.inputIsKept || m_config.inputIsKept(); + long keptBefore = m_sinceResume; + + const int inCh = inputChannelCount(); + std::vector silence(n, 0.f); + std::vector inPtrs(inCh, in.data()); + if (m_config.inputChannel >= 0) { + for (int c = 0; c < inCh; ++c) { + if (c != m_config.inputChannel) inPtrs[c] = silence.data(); + } + } + m_target->putSamples(inPtrs.data(), inCh, n); + if (kept) m_sinceResume += n; + if (m_config.reportLevels) { + m_target->setInputLevels(peak(inPtrs[0], n), + peak(inPtrs[inCh > 1 ? 1 : 0], n)); + } + + std::vector> out(ch, std::vector(n, 0.f)); + std::vector outPtrs; + for (auto &v : out) outPtrs.push_back(v.data()); + int got = m_source->getSourceSamples(outPtrs.data(), ch, n); + if (m_config.reportLevels) { + m_source->setOutputLevels(peak(outPtrs[0], got), + peak(outPtrs[ch > 1 ? 1 : 0], got)); + } + + for (int i = 0; i < n; ++i) { + float mix = 0.f; + if (i < got) { + for (int c = 0; c < ch; ++c) mix += out[c][i]; + mix /= float(ch); + } + m_captured.push_back(mix); + if (m_playStartFrame < 0 && std::fabs(mix) > 1e-4f) { + m_playStartFrame = base + i; + m_framesBeforePlayStart = keptBefore + (kept ? i : 0); + } + } + + m_frames += n; + } +}; + +#endif diff --git a/main/test/PyinReference.cpp b/main/test/PyinReference.cpp new file mode 100644 index 00000000..e5ecf86c --- /dev/null +++ b/main/test/PyinReference.cpp @@ -0,0 +1,26 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#include "PyinReference.h" + +#include "../../pyin/YinUtil.h" + +std::vector +pyinFastDifference(const std::vector &in) +{ + int halfSize = int(in.size() / 2); + std::vector out(halfSize, 0.0); + YinUtil util(halfSize); + util.fastDifference(in.data(), out.data()); + return out; +} diff --git a/main/test/PyinReference.h b/main/test/PyinReference.h new file mode 100644 index 00000000..ad376789 --- /dev/null +++ b/main/test/PyinReference.h @@ -0,0 +1,27 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TEST_PYIN_REFERENCE_H +#define TEST_PYIN_REFERENCE_H + +#include + +/** + * pYIN's own YinUtil::fastDifference, for a frame of in.size() samples; + * returns in.size()/2 values. Wrapped in its own translation unit + * because YinUtil.h pulls in the Vamp plugin SDK headers, which must + * not be mixed with the host SDK headers that svcore uses. + */ +std::vector pyinFastDifference(const std::vector &in); + +#endif diff --git a/main/test/RunSuite.h b/main/test/RunSuite.h new file mode 100644 index 00000000..ea794c7b --- /dev/null +++ b/main/test/RunSuite.h @@ -0,0 +1,150 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TEST_RUN_SUITE_H +#define TEST_RUN_SUITE_H + +#include +#include +#include + +/** + * The test functions of a suite that shard \a shard of \a count runs: + * every count-th one in declaration order, starting at the shard's + * number. The test functions are what QTest takes them to be, the + * private slots without arguments, less initTestCase, cleanupTestCase, + * init, cleanup and the _data functions. + */ +inline QStringList +shardFunctions(const QObject *suite, int shard, int count) +{ + QStringList names; + const QMetaObject *mo = suite->metaObject(); + int index = 0; + for (int i = 0; i < mo->methodCount(); ++i) { + QMetaMethod m = mo->method(i); + if (m.methodType() != QMetaMethod::Slot || + m.access() != QMetaMethod::Private || + m.parameterCount() != 0) { + continue; + } + QString name = QString::fromLatin1(m.name()); + if (name == "initTestCase" || name == "cleanupTestCase" || + name == "init" || name == "cleanup" || name.endsWith("_data")) { + continue; + } + if (index++ % count == shard) { + names << name; + } + } + return names; +} + +/** + * Read a value of TONY_TEST_SHARD, "i/n". True only for two whole + * numbers with 0 <= i < n, which are then set in \a shard and \a count. + * runSuite() and shardApplicationName() both read the value through + * this, so that they cannot disagree on what is a shard. + */ +inline bool +parseShard(const QString &value, int &shard, int &count) +{ + QStringList parts = value.split('/'); + if (parts.size() != 2) { + return false; + } + bool iok = false, nok = false; + int i = parts[0].toInt(&iok); + int n = parts[1].toInt(&nok); + if (!iok || !nok || n < 1 || i < 0 || i >= n) { + return false; + } + shard = i; + count = n; + return true; +} + +/** + * The application name for a process of the test executable \a base + * that runs the shard \a shard (the value of TONY_TEST_SHARD): + * "-shardof", or \a base itself for no value or one that + * is not a shard. Settings, QStandardPaths' data location and svcore's + * temp directory and log are all keyed by the application name, and on + * Windows, where they are the registry and known folders, no + * environment variable moves them: so the name is all that keeps shards + * running at once from clearing each other's settings and files. + */ +inline QString +shardApplicationName(const QString &base, const QString &shard) +{ + int i = 0, n = 0; + if (!parseShard(shard, i, n)) { + return base; + } + return QString("%1-shard%2of%3").arg(base).arg(i).arg(n); +} + +/** + * Run one suite with the command-line arguments given. If the + * environment variable TONY_TEST_LOG_DIR is set, the suite's results + * are also written to /.txt. A single "-o file" + * argument cannot do that, as each suite would overwrite the last. + * + * If TONY_TEST_SHARD is set to "i/n", only the suite's shard i of n + * runs (shardFunctions()), so that n processes started at once run the + * whole suite between them in about 1/n of the time: most tests wait + * on a fake device playing in real time. A suite with nothing in the + * shard is not run. Not for use with test names on the command line. + * The mains give each shard an application name of its own: + * shardApplicationName(). + */ +inline bool +runSuite(QObject *suite, int argc, char *argv[]) +{ + QStringList args; + for (int i = 0; i < argc; ++i) { + args << QString::fromLocal8Bit(argv[i]); + } + QString shard = qEnvironmentVariable("TONY_TEST_SHARD"); + if (shard != "") { + int i = 0, n = 0; + if (!parseShard(shard, i, n)) { + qWarning("TONY_TEST_SHARD must be i/n with 0 <= i < n, not \"%s\"", + qPrintable(shard)); + return false; + } + QStringList names = shardFunctions(suite, i, n); + if (names.isEmpty()) { + return true; + } + args << names; + } + QString logDir = qEnvironmentVariable("TONY_TEST_LOG_DIR"); + if (logDir != "") { + QString file = QDir(logDir).filePath + (QString("%1.txt").arg(suite->metaObject()->className())); + args << "-o" << (file + ",txt") << "-o" << "-,txt"; + } +#if QT_VERSION < QT_VERSION_CHECK(6, 5, 0) + // Qt 6.4's watchdog, meant to end a test function that runs for five + // minutes, can time the whole suite instead: it ended TestRecordWorkflow + // 300 s after the suite began, one second into a test that passes. + // That suite runs for longer, so on that Qt the limit goes beyond it + if (qEnvironmentVariableIsEmpty("QTEST_FUNCTION_TIMEOUT")) { + qputenv("QTEST_FUNCTION_TIMEOUT", "1800000"); + } +#endif + return QTest::qExec(suite, args) == 0; +} + +#endif diff --git a/main/test/TestAndroidFiles.h b/main/test/TestAndroidFiles.h new file mode 100644 index 00000000..ceb12ba6 --- /dev/null +++ b/main/test/TestAndroidFiles.h @@ -0,0 +1,731 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TEST_ANDROID_FILES_H +#define TEST_ANDROID_FILES_H + +// Tier 1: the file work the Android build does at start (the Vamp plugin +// links) and when a file is picked (the copy into app storage, the path +// of a picked content:// URI or where to look it up, the URI string +// Android granted, the files Tony opens, the names Save Session As +// suggests and accepts, the recent files that are still there, what Save +// Log makes of the size a provider gives for its document), done +// here on plain files in a temporary directory and on URIs written as +// Android writes them. What only a phone has -- a provider behind the +// URI, MediaStore, the installed library directory -- is not here. + +#include "../AndroidFiles.h" + +#include "plugin/PluginIdentifier.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class TestAndroidFiles : public QObject +{ + Q_OBJECT + + QTemporaryDir m_dir; + int m_counter = 0; + + // A path of its own for each use, created or not + QString newPath(QString name) { + return m_dir.filePath(QString("%1-%2").arg(name).arg(++m_counter)); + } + + QString newDir(QString name) { + QString path = newPath(name); + QDir().mkpath(path); + return path; + } + + static bool writeFile(QString path, QByteArray content) { + QFile file(path); + if (!file.open(QIODevice::WriteOnly)) return false; + bool ok = (file.write(content) == content.size()); + file.close(); + return ok; + } + + static QByteArray readFile(QString path) { + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) return QByteArray("(unreadable)"); + return file.readAll(); + } + + // What svcore's scan of a VAMP_PATH directory finds there + // (NativeVampPluginFactory's getCandidateLibraries()) + static QStringList scanned(QString dir) { + return QDir(dir, "*.so", QDir::Name | QDir::IgnoreCase, + QDir::Files | QDir::Readable).entryList(); + } + + // The folder as Android installs an application's libraries: the + // plugins among them, renamed lib*.so + QString installedLibraries(QByteArray pyin, QByteArray chp) { + QString dir = newDir("lib-arm64"); + writeFile(dir + "/libTony_arm64-v8a.so", "Tony"); + writeFile(dir + "/libQt6Core_arm64-v8a.so", "Qt"); + writeFile(dir + "/libplugins_platforms_qtforandroid_arm64-v8a.so", "Qt"); + writeFile(dir + "/libc++_shared.so", "C++"); + if (pyin != "") writeFile(dir + "/libpyin.so", pyin); + if (chp != "") writeFile(dir + "/libchp.so", chp); + return dir; + } + +private slots: + void initTestCase() { + QVERIFY(m_dir.isValid()); + } + + // --- The copy of a picked file --- + + void a_picked_file_is_copied_under_its_own_name() { + // On Android the source is a content:// URI, named like nothing + // the user knows + QString source = newDir("provider") + "/document-1234"; + QVERIFY(writeFile(source, "RIFF and the rest")); + + QString into = newPath("imported"); // not there yet + QString error; + QString copy = AndroidFiles::copyIn(source, "My Song.wav", into, error); + + QCOMPARE(error, QString()); + QCOMPARE(copy, QDir(into).filePath("My Song.wav")); + QCOMPARE(readFile(copy), QByteArray("RIFF and the rest")); + QCOMPARE(readFile(source), QByteArray("RIFF and the rest")); + } + + void picking_a_file_of_the_same_name_again_replaces_the_copy() { + QString from = newDir("provider"); + QVERIFY(writeFile(from + "/a", "first")); + QVERIFY(writeFile(from + "/b", "second, and longer")); + + QString into = newPath("imported"); + QString error; + QString first = AndroidFiles::copyIn(from + "/a", "take.wav", into, error); + QString second = AndroidFiles::copyIn(from + "/b", "take.wav", into, error); + + QCOMPARE(error, QString()); + QCOMPARE(second, first); + QCOMPARE(readFile(second), QByteArray("second, and longer")); + QCOMPARE(QDir(into).entryList(QDir::Files), QStringList({ "take.wav" })); + } + + void a_failed_copy_says_why_and_keeps_the_earlier_one() { + QString from = newDir("provider"); + QVERIFY(writeFile(from + "/a", "the good one")); + + QString into = newPath("imported"); + QString error; + QString copy = AndroidFiles::copyIn(from + "/a", "take.wav", into, error); + QCOMPARE(error, QString()); + + QString failed = AndroidFiles::copyIn(from + "/gone", "take.wav", + into, error); + QCOMPARE(failed, QString()); + QVERIFY(error != ""); + QCOMPARE(readFile(copy), QByteArray("the good one")); + QCOMPARE(QDir(into).entryList(QDir::Files), QStringList({ "take.wav" })); + } + + void a_file_is_copied_from_what_its_provider_hands_over() { + // On Android: a file descriptor from the provider, through the URI + // as Android wrote it, which may be a pipe with no size + QByteArray content("ID3 and the rest of an MP3"); + QBuffer pipe(&content); + QVERIFY(pipe.open(QIODevice::ReadOnly)); + + QString into = newPath("imported"); + QString error; + QString copy = AndroidFiles::copyIn + (pipe, "content://com.google.android.apps.docs.storage/document/" + "acc%3D1%3Bdoc%3Dencoded%3Dabc", + "(vocals) Avi Kaplan - Peace Somehow.mp3", into, error); + + QCOMPARE(error, QString()); + QCOMPARE(copy, QDir(into).filePath + ("(vocals) Avi Kaplan - Peace Somehow.mp3")); + QCOMPARE(readFile(copy), content); + } + + void a_name_cannot_put_the_copy_elsewhere() { + QString source = newDir("provider") + "/document"; + QVERIFY(writeFile(source, "x")); + + QString into = newPath("imported"); + QString error; + QString copy = AndroidFiles::copyIn(source, "../../escape.wav", + into, error); + + QCOMPARE(error, QString()); + QCOMPARE(QFileInfo(copy).absolutePath(), QFileInfo(into).absoluteFilePath()); + QCOMPARE(QFileInfo(copy).fileName(), QString(".._.._escape.wav")); + } + + void names_are_made_safe_to_use() { + QCOMPARE(AndroidFiles::safeFileName("Song 1.wav"), QString("Song 1.wav")); + QCOMPARE(AndroidFiles::safeFileName("Ääni – live.mp3"), + QString("Ääni – live.mp3")); + QCOMPARE(AndroidFiles::safeFileName("a/b\\c.wav"), QString("a_b_c.wav")); + QCOMPARE(AndroidFiles::safeFileName("What? : \"x\"|*.ogg"), + QString("What_ _Live__ _x___.ogg")); + QCOMPARE(AndroidFiles::safeFileName("line\nbreak.wav"), + QString("line_break.wav")); + QCOMPARE(AndroidFiles::safeFileName(" padded.wav "), QString("padded.wav")); + QCOMPARE(AndroidFiles::safeFileName(""), QString("imported")); + QCOMPARE(AndroidFiles::safeFileName("."), QString("imported")); + QCOMPARE(AndroidFiles::safeFileName(".."), QString("imported")); + } + + // --- A picked document's path --- + + // As Android's Uri.toString() gives it, which QFileDialog then passes + // through QUrl: the string Tony gets from the picker. Tested in both + // forms, since QUrl decodes some of the id and not the rest + static QString pickedAsQtGivesIt(QString uri) { + return QUrl(uri).toString(QUrl::PreferLocalFile); + } + + void a_document_in_the_phones_storage_has_its_path() { + QString root = "/storage/emulated/0"; + QString uri = "content://com.android.externalstorage.documents/" + "document/primary%3AMusic%2Ftest.ton"; + + QCOMPARE(AndroidFiles::pathFromContentUri(uri, root), + QString("/storage/emulated/0/Music/test.ton")); + QCOMPARE(AndroidFiles::pathFromContentUri(pickedAsQtGivesIt(uri), root), + QString("/storage/emulated/0/Music/test.ton")); + + // The root is the phone's, whatever it is + QCOMPARE(AndroidFiles::pathFromContentUri(uri, "/storage/emulated/10"), + QString("/storage/emulated/10/Music/test.ton")); + } + + void names_with_spaces_and_finnish_letters_are_decoded() { + QString root = "/storage/emulated/0"; + // "Music/Laulut/Sävel äänessä 100%.ton", as Android encodes it + QString uri = "content://com.android.externalstorage.documents/" + "document/primary%3AMusic%2FLaulut%2FS%C3%A4vel%20%C3%A4%C3%A4ness" + "%C3%A4%20100%25.ton"; + QString expected("/storage/emulated/0/Music/Laulut/" + "Sävel äänessä 100%.ton"); + + QCOMPARE(AndroidFiles::pathFromContentUri(uri, root), expected); + QString given = pickedAsQtGivesIt(uri); + QCOMPARE(AndroidFiles::pathFromContentUri(given, root), expected); + } + + void a_document_under_a_folder_grant_has_its_path() { + QString uri = "content://com.android.externalstorage.documents/" + "tree/primary%3ASync%2FSongs/document/" + "primary%3ASync%2FSongs%2FMy%20Song.ton"; + QCOMPARE(AndroidFiles::pathFromContentUri(uri, "/storage/emulated/0"), + QString("/storage/emulated/0/Sync/Songs/My Song.ton")); + } + + void the_top_of_the_phones_storage_is_its_root() { + QString uri = "content://com.android.externalstorage.documents/" + "document/primary%3A"; + QCOMPARE(AndroidFiles::pathFromContentUri(uri, "/storage/emulated/0/"), + QString("/storage/emulated/0")); + } + + void a_document_on_a_card_is_under_its_volume() { + QString uri = "content://com.android.externalstorage.documents/" + "document/1A2B-3C4D%3AMusic%2FSong.mp3"; + QCOMPARE(AndroidFiles::pathFromContentUri(uri, "/storage/emulated/0"), + QString("/storage/1A2B-3C4D/Music/Song.mp3")); + } + + void a_download_with_a_raw_id_has_that_path() { + QString uri = "content://com.android.providers.downloads.documents/" + "document/raw%3A%2Fstorage%2Femulated%2F0%2FDownload%2FSong.mp3"; + QCOMPARE(AndroidFiles::pathFromContentUri(uri, "/storage/emulated/0"), + QString("/storage/emulated/0/Download/Song.mp3")); + QCOMPARE(AndroidFiles::pathFromContentUri(pickedAsQtGivesIt(uri), + "/storage/emulated/0"), + QString("/storage/emulated/0/Download/Song.mp3")); + } + + void documents_elsewhere_have_no_path() { + QString root = "/storage/emulated/0"; + QStringList none = { + // Google Drive, Dropbox: a cloud provider's own ids + "content://com.google.android.apps.docs.storage/document/" + "acc%3D1%3Bdoc%3Dencoded%3DabcDEF", + "content://com.dropbox.android.document/document/" + "%2FSongs%2Ftest.ton", + // The media provider and a download, by number: looked up in + // MediaStore instead (pathLookupFor()) + "content://com.android.providers.media.documents/document/audio%3A42", + "content://com.android.providers.downloads.documents/document/msf%3A1234", + "content://com.android.providers.downloads.documents/document/1234", + // A raw id that is not a path + "content://com.android.providers.downloads.documents/document/raw%3ASong.mp3", + // The external storage provider, but no volume Tony knows + "content://com.android.externalstorage.documents/document/home%3ADocuments", + "content://com.android.externalstorage.documents/document/Music%2Ftest.ton", + "content://com.android.externalstorage.documents/document/%3AMusic", + // Not a document + "content://com.android.externalstorage.documents/tree/primary%3AMusic", + "content://com.android.externalstorage.documents/root/primary", + // Not content:// at all + "/storage/emulated/0/Music/test.ton", + "file:///storage/emulated/0/Music/test.ton", + "", + }; + for (QString uri : none) { + QCOMPARE(AndroidFiles::pathFromContentUri(uri, root), QString()); + } + + // Without the root there is nowhere to put primary's documents + QCOMPARE(AndroidFiles::pathFromContentUri + ("content://com.android.externalstorage.documents/" + "document/primary%3AMusic%2Ftest.ton", ""), + QString()); + } + + void an_id_cannot_lead_out_of_its_volume() { + QString root = "/storage/emulated/0"; + QCOMPARE(AndroidFiles::pathFromContentUri + ("content://com.android.externalstorage.documents/" + "document/primary%3A..%2F..%2F..%2Fdata%2Fx", root), + QString()); + QCOMPARE(AndroidFiles::pathFromContentUri + ("content://com.android.externalstorage.documents/" + "document/primary%3AMusic%2F..%2F..%2Fx", root), + QString()); + QCOMPARE(AndroidFiles::pathFromContentUri + ("content://com.android.providers.downloads.documents/" + "document/raw%3A%2Fstorage%2F..%2Fdata%2Fx", root), + QString()); + } + + // The user's session, whose name has spaces and parentheses, in each + // form its URI can come in: as Android writes it; as + // QFileDialog::selectedFiles() gives it (what the "File does not + // exist" box showed); and as Qt's content file engine rebuilds it, + // with the parentheses encoded as well + void the_users_session_has_its_path_in_every_form_of_its_uri() { + QString root = "/storage/emulated/0"; + QString provider = + "content://com.android.externalstorage.documents/document/"; + QString name = "(vocals) Avi Kaplan - Peace Somehow.ton"; + QString inMusic = "/storage/emulated/0/Music/" + name; + QString inDownload = "/storage/emulated/0/Download/" + name; + + QString android = provider + "primary%3AMusic%2F" + "(vocals)%20Avi%20Kaplan%20-%20Peace%20Somehow.ton"; + QString shown = provider + "primary%3AMusic%2F" + "(vocals) Avi Kaplan - Peace Somehow.ton"; + QString rebuilt = provider + "primary%3AMusic%2F" + "%28vocals%29%20Avi%20Kaplan%20-%20Peace%20Somehow.ton"; + + QCOMPARE(pickedAsQtGivesIt(android), shown); + QCOMPARE(AndroidFiles::pathFromContentUri(android, root), inMusic); + QCOMPARE(AndroidFiles::pathFromContentUri(shown, root), inMusic); + QCOMPARE(AndroidFiles::pathFromContentUri(rebuilt, root), inMusic); + QCOMPARE(AndroidFiles::pathLookupFor(shown), + AndroidFiles::PathLookup::InUri); + + // In Download, by the phone's storage or by the Downloads root + QString download = provider + "primary%3ADownload%2F" + "(vocals)%20Avi%20Kaplan%20-%20Peace%20Somehow.ton"; + QCOMPARE(AndroidFiles::pathFromContentUri(download, root), inDownload); + QCOMPARE(AndroidFiles::pathFromContentUri + (pickedAsQtGivesIt(download), root), inDownload); + QString raw = "content://com.android.providers.downloads.documents/" + "document/raw%3A%2Fstorage%2Femulated%2F0%2FDownload%2F" + "(vocals)%20Avi%20Kaplan%20-%20Peace%20Somehow.ton"; + QCOMPARE(AndroidFiles::pathFromContentUri(raw, root), inDownload); + QCOMPARE(AndroidFiles::pathFromContentUri + (pickedAsQtGivesIt(raw), root), inDownload); + + // Under a grant of the folder + QString tree = "content://com.android.externalstorage.documents/" + "tree/primary%3AMusic/document/primary%3AMusic%2F" + "(vocals)%20Avi%20Kaplan%20-%20Peace%20Somehow.ton"; + QCOMPARE(AndroidFiles::pathFromContentUri(tree, root), inMusic); + QCOMPARE(AndroidFiles::pathFromContentUri + (pickedAsQtGivesIt(tree), root), inMusic); + } + + // What Tony opens a picked document by, when it has no path: the URI + // exactly as Android wrote it, which is what Android granted + void the_uri_is_opened_as_android_wrote_it() { + QStringList uris = { + "content://com.android.externalstorage.documents/document/" + "primary%3AMusic%2F(vocals)%20Avi%20Kaplan%20-%20Peace%20" + "Somehow.ton", + // Uri.encode() leaves "_-!.~'()*" as they are, and encodes + // the rest as UTF-8, in capitals + "content://com.android.externalstorage.documents/document/" + "primary%3ADownload%2FIt's%20a%20*star*!%20%C3%84%C3%A4ni" + "%20%2B%26%3D%3B%2C%24%23%3F%25%5B%5D.wav", + "content://com.android.externalstorage.documents/document/" + "primary%3ADownload%2F%7B%7D%7C%5C%5E%60%22%3C%3E%40.wav", + "content://com.android.externalstorage.documents/tree/" + "primary%3AMusic/document/primary%3AMusic%2F(a)%20b.ton", + "content://com.google.android.apps.docs.storage/document/" + "acc%3D1%3Bdoc%3Dencoded%3DAbC-_x%2Fy%2Bz%3D%3D", + "content://com.android.providers.downloads.documents/document/" + "msf%3A1000001234", + "content://com.android.providers.downloads.documents/document/" + "raw%3A%2Fstorage%2Femulated%2F0%2FDownload%2Fx%20(1).ton", + "content://com.android.providers.downloads.documents/document/1234", + "content://com.android.providers.media.documents/document/" + "audio%3A42", + }; + for (QString uri : uris) { + QCOMPARE(AndroidFiles::grantedUri(QUrl(uri)), uri); + } + + // Not the string the picker's selectedFiles() gives: that has the + // spaces decoded, and is another URI to Android + QVERIFY(pickedAsQtGivesIt(uris[0]) != uris[0]); + } + + void the_provider_is_named() { + QCOMPARE(AndroidFiles::providerOf + ("content://com.google.android.apps.docs.storage/document/" + "acc%3D1%3Bdoc%3Dencoded%3Dabc"), + QString("com.google.android.apps.docs.storage")); + QCOMPARE(AndroidFiles::providerOf + ("content://com.android.externalstorage.documents/root/primary"), + QString("com.android.externalstorage.documents")); + QCOMPARE(AndroidFiles::providerOf("/storage/emulated/0/x.ton"), + QString()); + QCOMPARE(AndroidFiles::providerOf(""), QString()); + } + + void downloads_and_media_are_looked_up_in_mediastore() { + QString downloads = + "content://com.android.providers.downloads.documents/document/"; + QString media = + "content://com.android.providers.media.documents/document/"; + using L = AndroidFiles::PathLookup; + + QCOMPARE(AndroidFiles::pathLookupFor(downloads + "msf%3A1000001234"), + L::MediaStore); + QCOMPARE(AndroidFiles::mediaStoreUriFor(downloads + "msf%3A1000001234"), + QString("content://media/external/downloads/1000001234")); + // As the picker's selectedFiles() gives it + QCOMPARE(AndroidFiles::mediaStoreUriFor(downloads + "msf:77"), + QString("content://media/external/downloads/77")); + + QCOMPARE(AndroidFiles::pathLookupFor(media + "audio%3A42"), + L::MediaStore); + QCOMPARE(AndroidFiles::mediaStoreUriFor(media + "audio%3A42"), + QString("content://media/external/audio/media/42")); + QCOMPARE(AndroidFiles::mediaStoreUriFor(media + "image%3A7"), + QString("content://media/external/images/media/7")); + QCOMPARE(AndroidFiles::mediaStoreUriFor(media + "video%3A8"), + QString("content://media/external/video/media/8")); + QCOMPARE(AndroidFiles::mediaStoreUriFor(media + "document%3A9"), + QString("content://media/external/file/9")); + + // A download known by its number in the download manager + QCOMPARE(AndroidFiles::pathLookupFor(downloads + "1234"), + L::ByNameAndSize); + QCOMPARE(AndroidFiles::mediaStoreUriFor(downloads + "1234"), QString()); + + // The path is in the URI + QCOMPARE(AndroidFiles::pathLookupFor + (downloads + "raw%3A%2Fstorage%2Femulated%2F0%2FDownload%2Fa.ton"), + L::InUri); + QCOMPARE(AndroidFiles::pathLookupFor + ("content://com.android.externalstorage.documents/document/" + "primary%3AMusic%2Fa.ton"), + L::InUri); + + // Nothing to look up: folders, the providers' roots and groups, a + // cloud provider's documents, ids that are not numbers + QStringList none = { + downloads + "msd%3A55", + downloads + "downloads", + downloads + "msf%3A12a", + downloads + "raw%3ASong.mp3", + media + "audio_root", + media + "album%3A3", + media + "artist%3A4", + media + "images_bucket%3A5", + media + "audio%3A", + "content://com.android.providers.media.documents/tree/audio_root", + "content://com.google.android.apps.docs.storage/document/" + "acc%3D1%3Bdoc%3Dencoded%3Dabc", + "content://com.android.externalstorage.documents/document/Music", + "content://media/external/audio/media/42", + "/storage/emulated/0/Download/a.ton", + "", + }; + for (QString uri : none) { + QCOMPARE(AndroidFiles::pathLookupFor(uri), L::None); + QCOMPARE(AndroidFiles::mediaStoreUriFor(uri), QString()); + } + } + + void a_numbered_download_is_found_by_name_and_size() { + QString root = "/storage/emulated/0"; + QString download = "/storage/emulated/0/Download/Song (1).mp3"; + QString copy = "/storage/emulated/0/Music/Song (1).mp3"; + QString deeper = "/storage/emulated/0/Download/Old/Song (1).mp3"; + + QCOMPARE(AndroidFiles::chooseDownload({ download }, root), download); + QCOMPARE(AndroidFiles::chooseDownload({ copy }, root), copy); + + // Several of that name and size: the one in Download + QCOMPARE(AndroidFiles::chooseDownload({ copy, download, deeper }, root), + download); + QCOMPARE(AndroidFiles::chooseDownload({ download, copy }, root + "/"), + download); + QCOMPARE(AndroidFiles::chooseDownload({ download, download }, root), + download); + + // Not settled + QCOMPARE(AndroidFiles::chooseDownload({ copy, deeper }, root), QString()); + QCOMPARE(AndroidFiles::chooseDownload({ download, copy }, ""), QString()); + QCOMPARE(AndroidFiles::chooseDownload({}, root), QString()); + QCOMPARE(AndroidFiles::chooseDownload({ "" }, root), QString()); + } + + void only_tonys_own_files_are_opened() { + QString session = "*.ton"; + QString audio = "*.aiff *.flac *.mp3 *.ogg *.opus *.wav"; + + QVERIFY(AndroidFiles::hasExtensionIn + ("(vocals) Avi Kaplan - Peace Somehow.ton", session)); + QVERIFY(AndroidFiles::hasExtensionIn("Song.MP3", audio)); + QVERIFY(AndroidFiles::hasExtensionIn("Song v1.2.wav", audio)); + QVERIFY(AndroidFiles::hasExtensionIn("a.WaV", "*.WAV")); + + QVERIFY(!AndroidFiles::hasExtensionIn("Song.ton", audio)); + QVERIFY(!AndroidFiles::hasExtensionIn("Notes.pdf", session + " " + audio)); + QVERIFY(!AndroidFiles::hasExtensionIn("Song", audio)); + QVERIFY(!AndroidFiles::hasExtensionIn("Song.mp3.part", audio)); + QVERIFY(!AndroidFiles::hasExtensionIn("mp3", audio)); + QVERIFY(!AndroidFiles::hasExtensionIn("", audio)); + QVERIFY(!AndroidFiles::hasExtensionIn("Song.mp3", "")); + } + + void recent_files_that_are_gone_are_left_out() { + QString dir = newDir("recent"); + QString here = dir + "/Here.ton"; + QString moved = dir + "/Moved.ton"; + QVERIFY(writeFile(here, "BZh91AY&SY")); + + QStringList recent = { + moved, + here, + "content://com.android.externalstorage.documents/document/" + "primary%3AMusic%2FSong.mp3", + newDir("a-folder"), + "", + }; + QCOMPARE(AndroidFiles::usableRecentFiles(recent), QStringList({ here })); + } + + // --- The name Save Session As suggests and accepts --- + + void the_suggested_name_is_the_sessions_or_the_references() { + QCOMPARE(AndroidFiles::suggestedSessionName + ("/storage/emulated/0/Music/My Song.ton", + "/storage/emulated/0/Music/Other.mp3"), + QString("My Song.ton")); + QCOMPARE(AndroidFiles::suggestedSessionName + ("", "/data/user/0/io.github.jhhr.tony/files/imported/" + "Ääni v1.2.mp3"), + QString("Ääni v1.2.ton")); + QCOMPARE(AndroidFiles::suggestedSessionName("", ""), QString()); + } + + void a_picked_name_without_an_extension_gets_one() { + QCOMPARE(AndroidFiles::sessionFileName("test.ton"), QString("test.ton")); + QCOMPARE(AndroidFiles::sessionFileName("test"), QString("test.ton")); + QCOMPARE(AndroidFiles::sessionFileName("My Song v1.2"), + QString("My Song v1.2")); + QCOMPARE(AndroidFiles::sessionFileName("test (1).ton"), + QString("test (1).ton")); + } + + void a_picked_name_that_names_nothing_is_refused() { + QCOMPARE(AndroidFiles::sessionFileName(""), QString()); + QCOMPARE(AndroidFiles::sessionFileName(" "), QString()); + QCOMPARE(AndroidFiles::sessionFileName(".ton"), QString()); + QCOMPARE(AndroidFiles::sessionFileName(" .ton"), QString()); + QCOMPARE(AndroidFiles::sessionFileName("."), QString()); + QCOMPARE(AndroidFiles::sessionFileName("..ton"), QString()); + QCOMPARE(AndroidFiles::sessionFileName("(invalid)"), QString()); + } + + void only_an_empty_document_is_removed() { + QString dir = newDir("picked"); + + // What the picker leaves for a save that is not made there + QString empty = dir + "/test.ton"; + QVERIFY(writeFile(empty, "")); + QVERIFY(AndroidFiles::removeIfEmpty(empty)); + QVERIFY(!QFileInfo::exists(empty)); + + // A session that was there before is not to be touched + QString session = dir + "/Song.ton"; + QVERIFY(writeFile(session, "BZh91AY&SY")); + QVERIFY(!AndroidFiles::removeIfEmpty(session)); + QCOMPARE(readFile(session), QByteArray("BZh91AY&SY")); + + QVERIFY(!AndroidFiles::removeIfEmpty(dir + "/not-there.ton")); + QVERIFY(!AndroidFiles::removeIfEmpty(newDir("folder"))); + QVERIFY(!AndroidFiles::removeIfEmpty("")); + } + + // Save Log compares what went into the document with what its + // provider says it holds afterwards, from the _size column + void a_saved_document_is_compared_with_what_was_written() { + auto whole = AndroidFiles::savedSize(51234, "51234"); + QVERIFY(whole.known()); + QVERIFY(!whole.differs()); + QCOMPARE(whole.written, qint64(51234)); + QCOMPARE(whole.held, qint64(51234)); + + // The fifth phone test: all of it written, nothing there + auto empty = AndroidFiles::savedSize(51234, "0"); + QVERIFY(empty.known()); + QVERIFY(empty.differs()); + QCOMPARE(empty.held, qint64(0)); + + QVERIFY(AndroidFiles::savedSize(51234, "4096").differs()); + QVERIFY(AndroidFiles::savedSize(0, "0").known()); + QVERIFY(!AndroidFiles::savedSize(0, "0").differs()); + + // A null column, as a provider that does not know the size gives + // it, is not a difference; nor is anything that is not a count + for (QString none : { "", " ", "-1", "big", "12 bytes" }) { + auto unknown = AndroidFiles::savedSize(51234, none); + QVERIFY2(!unknown.known(), qPrintable(none)); + QVERIFY2(!unknown.differs(), qPrintable(none)); + QCOMPARE(unknown.held, qint64(-1)); + } + } + + // --- The Vamp plugin links --- + + void the_plugins_are_linked_under_their_own_names() { +#ifdef Q_OS_WIN + QSKIP("Symbolic links: the Android build's, not Windows'"); +#endif + QString libraries = installedLibraries("pyin", "chp"); + QString vamp = newPath("vamp"); // not there yet + + QStringList problems; + QStringList links = AndroidFiles::linkVampPlugins + (libraries, vamp, { "pyin", "chp" }, problems); + + QCOMPARE(problems, QStringList()); + QCOMPARE(links, QStringList({ vamp + "/pyin.so", vamp + "/chp.so" })); + + // The scan opens every .so it finds: the plugins, and nothing else + QCOMPARE(scanned(vamp), QStringList({ "chp.so", "pyin.so" })); + QVERIFY(QFileInfo(links[0]).isSymLink()); + QCOMPARE(readFile(links[0]), QByteArray("pyin")); + QCOMPARE(readFile(links[1]), QByteArray("chp")); + + // svcore names a plugin after its library's file name, and Tony + // asks for pYIN as vamp:pyin:pyin:... (Analyser) + QCOMPARE(sv::PluginIdentifier::createIdentifier("vamp", links[0], "pyin"), + QString("vamp:pyin:pyin")); + } + + void links_left_by_an_earlier_install_are_replaced() { +#ifdef Q_OS_WIN + QSKIP("Symbolic links: the Android build's, not Windows'"); +#endif + QString vamp = newPath("vamp"); + QStringList problems; + + QString before = installedLibraries("old pyin", "old chp"); + AndroidFiles::linkVampPlugins(before, vamp, { "pyin", "chp" }, problems); + QCOMPARE(problems, QStringList()); + + // An update installs the libraries in a new folder and removes + // the old one, leaving the links pointing nowhere + QVERIFY(QDir(before).removeRecursively()); + QString after = installedLibraries("new pyin", "new chp"); + + QStringList links = AndroidFiles::linkVampPlugins + (after, vamp, { "pyin", "chp" }, problems); + + QCOMPARE(problems, QStringList()); + QCOMPARE(links.size(), 2); + QCOMPARE(readFile(links[0]), QByteArray("new pyin")); + QCOMPARE(readFile(links[1]), QByteArray("new chp")); + QCOMPARE(QFileInfo(links[0]).symLinkTarget(), + QFileInfo(after + "/libpyin.so").absoluteFilePath()); + QCOMPARE(scanned(vamp), QStringList({ "chp.so", "pyin.so" })); + } + + void a_missing_plugin_is_reported_and_the_rest_linked() { +#ifdef Q_OS_WIN + QSKIP("Symbolic links: the Android build's, not Windows'"); +#endif + QString vamp = newPath("vamp"); + QStringList problems; + AndroidFiles::linkVampPlugins(installedLibraries("pyin", "chp"), vamp, + { "pyin", "chp" }, problems); + QCOMPARE(problems, QStringList()); + + QStringList links = AndroidFiles::linkVampPlugins + (installedLibraries("pyin", ""), vamp, { "pyin", "chp" }, problems); + + QCOMPARE(links, QStringList({ vamp + "/pyin.so" })); + QCOMPARE(problems.size(), 1); + QVERIFY(problems[0].contains("libchp.so")); + // Not the link from before, to a library that is not there now + QCOMPARE(scanned(vamp), QStringList({ "pyin.so" })); + } + + void a_linked_plugin_is_checked_as_the_scan_would_load_it() { +#ifdef Q_OS_WIN + QSKIP("Symbolic links: the Android build's, not Windows'"); +#endif + // The real pYIN, which the build leaves beside this executable, + // installed as Android installs it; and a library that is not one + QString built = QCoreApplication::applicationDirPath() + "/pyin.so"; + if (!QFileInfo::exists(built)) { + QSKIP("No pyin.so beside the test executable: build it first"); + } + QString libraries = installedLibraries("", "not a library"); + QVERIFY(QFile::copy(built, libraries + "/libpyin.so")); + + QString vamp = newPath("vamp"); + QStringList problems; + QStringList links = AndroidFiles::linkVampPlugins + (libraries, vamp, { "pyin", "chp" }, problems); + QCOMPARE(problems, QStringList()); + QCOMPARE(links.size(), 2); + + QCOMPARE(AndroidFiles::checkVampPlugin(links[0]), QString()); + + QString problem = AndroidFiles::checkVampPlugin(links[1]); + QVERIFY(problem.startsWith("Cannot load " + links[1] + ": ")); + QVERIFY(problem.size() > QString("Cannot load " + links[1] + ": ").size()); + } +}; + +#endif diff --git a/main/test/TestAudioCheck.h b/main/test/TestAudioCheck.h new file mode 100644 index 00000000..c26334a4 --- /dev/null +++ b/main/test/TestAudioCheck.h @@ -0,0 +1,2508 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TEST_AUDIO_CHECK_H +#define TEST_AUDIO_CHECK_H + +// Tier 5, as TestRecordWorkflow: the audio check (AudioCheckRunner) on +// the real MainWindow, recording from the fake device with its output +// looped back into its input, as an earcup held to the mic. A run is +// two punch-ins of two sweeps each on the first 11 s of the calibration +// reference, about 13 s of real time. The windows have no development +// checks, even in a development build: TestDevChecks has those. +// +// The same dialog watchdog as TestRecordWorkflow's: a dialog would +// block the test for ever, so it is dismissed, and the test fails in +// cleanup(). + +#include "TestSignals.h" +#include "TestMainWindow.h" + +#include "../AudioCheckRunner.h" +#include "../CalibrateAudioDialog.h" +#include "../LatencyCheck.h" + +#include "version.h" + +#include "layer/Layer.h" +#include "data/fileio/WavFileWriter.h" +#include "base/PlayParameters.h" +#include "base/PlayParameterRepository.h" +#include "base/RecordDirectory.h" +#include "transform/ModelTransformerFactory.h" +#include "widgets/InteractiveFileFinder.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +class TestAudioCheck : public QObject +{ + Q_OBJECT + + static constexpr double rate = 44100.0; + + // What Windows' mixer and phones run at. The reference is made at + // rate whatever the device runs at, and a take recorded at this one + // is converted to it as it is spliced + static constexpr double otherRate = 48000.0; + + // What the device reports, and what the round trip really is + static constexpr int reportedOut = 2 * 4096; + static constexpr int reportedIn = 4096; + static constexpr int roundTrip = 3 * 4096 + 123; + + // How far the fake's input moves against its output each time its + // stream starts again: 10 ms, as a real driver's did by up to 8 ms + // either way (FakeAudioIO::Config::restartShift) + static constexpr int restartShift = 441; + + QTemporaryDir m_dir; + TestMainWindow *m_window = nullptr; + QTimer m_watchdog; + QStringList m_dialogs; + + // The application's font, which a test may make a phone's + QFont m_font; + + // What the runner said when the run ended, and how often it said it; + // and every progress it reported + AudioCheckResult m_result; + int m_finished = 0; + std::vector m_progress; + + void makeWindow(FakeAudioIO::Config config, bool installDevice = true) { + delete m_window; + m_window = new TestMainWindow(config, installDevice); +#ifdef TONY_DEV_CHECKS + // The calibration alone, as a release build has it: with the + // dev checks there, the dialog carries on into them by default + // (TestDevChecks) + m_window->doDeleteDevChecks(); +#endif + m_result = AudioCheckResult(); + m_finished = 0; + m_progress.clear(); + connect(m_window->audioCheck(), &AudioCheckRunner::finished, + this, [this](const AudioCheckResult &result) { + m_result = result; + ++m_finished; + }); + connect(m_window->audioCheck(), &AudioCheckRunner::progress, + this, [this](const AudioCheckRunner::Progress &progress) { + m_progress.push_back(progress); + }); + } + + // Speakers into the microphone: the output comes back as input, + // roundTrip frames late, while the device reports latencies that + // add up to less + static FakeAudioIO::Config loopback() { + FakeAudioIO::Config config; + config.playbackLatency = reportedOut; + config.recordLatency = reportedIn; + config.inputDelay = roundTrip; + config.loopback = true; + return config; + } + + // Two punch-ins of two events each, on the calibration reference cut + // short after its fifth event. The event at 4.7 s is left out: it is + // too close to the one before for a punch-in to end between them + AudioCheckRunner::Plan shortPlan() { + AudioCheckRunner::Plan plan; + plan.layout = LatencyCheck::calibrationLayout(); + plan.layout.length = plan.layout.events[5].sweepStart; + plan.layout.events.resize(5); + plan.punchIns = 2; + plan.eventsEach = 2; + plan.referencePath = m_dir.filePath("reference.wav"); + return plan; + } + + void startCheck() { startCheck(shortPlan()); } + void runCheck() { runCheck(shortPlan()); } + + void startCheck(const AudioCheckRunner::Plan &plan, + bool discard = true) { + // As answering "No" to "do you want to save?", which the check + // asks before it replaces the session + if (discard) m_window->discardModifications(); + QVERIFY(m_window->audioCheck()->start(plan)); + QVERIFY(m_window->audioCheck()->isRunning()); + } + + void runCheck(const AudioCheckRunner::Plan &plan, bool discard = true) { + m_result = AudioCheckResult(); + m_finished = 0; + startCheck(plan, discard); + if (QTest::currentTestFailed()) return; + QTRY_VERIFY_WITH_TIMEOUT(m_finished > 0, 60000); + QCOMPARE(m_finished, 1); + QVERIFY(!m_window->audioCheck()->isRunning()); + } + + // The short plan with one punch-in of its own, which judges the sweep + // at 7.2 s; its lead-in starts in the tone of the sweep before, so + // that the fake device's first audible sample is the first played + AudioCheckRunner::Plan onePunchIn() { + AudioCheckRunner::Plan plan = shortPlan(); + plan.ranges = { LatencyCheck::PunchIn(6.3, 8.3) }; + return plan; + } + + // Every measured round trip kept, as the settings hold it + static QStringList storedLatency() { + QSettings settings; + settings.beginGroup("LatencyCalibration"); + QStringList all; + for (const QString &key : settings.allKeys()) { + all << key + "=" + settings.value(key).toString(); + } + settings.endGroup(); + all.sort(); + return all; + } + + // A reference in the directory the check writes its own to, as a + // check before this one left it, opened and analysed; its path + QString openEarlierCheckReference() { + QDir().mkpath(AudioCheckRunner::referenceDirectory()); + const QString path = AudioCheckRunner::nextReferencePath + (AudioCheckRunner::referenceDirectory(), ""); + const AudioCheckRunner::Plan plan = shortPlan(); + const std::vector samples = LatencyCheck::generate(plan.layout); + sv::WavFileWriter writer(path, plan.layout.rate, 1, + sv::WavFileWriter::WriteToTarget); + const float *data = samples.data(); + if (!writer.isOK() || + !writer.writeSamples(&data, sv::sv_frame_t(samples.size())) || + !writer.close()) { + return {}; + } + m_window->discardModifications(); + if (m_window->openPath(path, MainWindow::ReplaceSession) != + MainWindow::FileOpenSucceeded) { + return {}; + } + Analyser *analyser = m_window->analyser(); + QTest::qWaitFor([analyser]() { + return analyser->getLayer(Analyser::PitchTrack) && + analyser->getInitialAnalysisCompletion() >= 100 && + !sv::ModelTransformerFactory::getInstance() + ->haveRunningTransformers(); + }, 30000); + return path; + } + + // The application's data directory, where a check writes its + // references unless told otherwise, is Qt's test location while one + // of these lives + struct TestDataLocation { + TestDataLocation() { QStandardPaths::setTestModeEnabled(true); } + ~TestDataLocation() { QStandardPaths::setTestModeEnabled(false); } + }; + + // The devices the Preferences name, as the device menus write them + // when the driver is left alone. The fake device takes no notice + static void setDevices(QString output, QString input) { + QSettings settings; + settings.beginGroup("Preferences"); + settings.setValue("audio-playback-device", output); + settings.setValue("audio-record-device", input); + settings.endGroup(); + } + + // The devices chosen under a driver, as its device menus write them + static void setDevices(QString output, QString input, QString driver) { + QSettings settings; + settings.beginGroup("Preferences"); + settings.setValue("audio-playback-device-" + driver, output); + settings.setValue("audio-record-device-" + driver, input); + settings.endGroup(); + } + + // The implementations bqaudioio has on Windows, whatever it has here + static QStringList windowsImplementations() { + return { "port", "mme", "directsound", "wasapi" }; + } + + // A Preference, as the driver and device menus write it + static void setPreference(QString name, QString value) { + QSettings settings; + settings.setValue("Preferences/" + name, value); + } + + static QString preference(QString name) { + QSettings settings; + return settings.value("Preferences/" + name).toString(); + } + + // The driver, the devices kept for each driver and the latency asked + // of each, as a test may have left them + static void forgetDriverPreferences() { + QSettings settings; + settings.beginGroup("Preferences"); + for (const QString &name : settings.childKeys()) { + if (name == "audio-target" || + name.startsWith("audio-latency-") || + name.startsWith("audio-playback-device-") || + name.startsWith("audio-record-device-")) { + settings.remove(name); + } + } + settings.endGroup(); + } + + // A menu's entries, the one ticked, and one by its text + static QStringList entries(QMenu *menu) { + QStringList texts; + for (QAction *a : menu->actions()) { + if (!a->isSeparator()) texts << a->text(); + } + return texts; + } + + static QString ticked(QMenu *menu) { + for (QAction *a : menu->actions()) { + if (a->isChecked()) return a->text(); + } + return {}; + } + + static QAction *entry(QMenu *menu, QString text) { + for (QAction *a : menu->actions()) { + if (a->text() == text) return a; + } + return nullptr; + } + + // Playback > Audio Driver or Audio Latency opened, and an entry of it + // chosen + void chooseDriver(QString text) { + QMenu *menu = m_window->audioDriverMenus()->driverMenu(); + emit menu->aboutToShow(); + QAction *action = entry(menu, text); + QVERIFY2(action, qPrintable(text + " not in: " + + entries(menu).join(", "))); + action->trigger(); + } + + void chooseLatency(QString text) { + QMenu *menu = m_window->audioDriverMenus()->latencyMenu(); + emit menu->aboutToShow(); + QAction *action = entry(menu, text); + QVERIFY2(action, qPrintable(text + " not in: " + + entries(menu).join(", "))); + action->trigger(); + } + + // Audio Driver and Audio Latency, which are greyed out together: + // both enabled, or both not + bool driverMenusEnabled() { + AudioDriverMenus *menus = m_window->audioDriverMenus(); + return menus->driverMenu()->menuAction()->isEnabled() && + menus->latencyMenu()->menuAction()->isEnabled(); + } + + bool driverMenusDisabled() { + AudioDriverMenus *menus = m_window->audioDriverMenus(); + return !menus->driverMenu()->menuAction()->isEnabled() && + !menus->latencyMenu()->menuAction()->isEnabled(); + } + + // Shown, as they are where there is more than one driver: a hidden + // action is disabled as well, whatever it was set to + void showDriverMenus(QStringList implementations) { + m_window->setAudioImplementations(implementations); + m_window->doRebuildAudioDriverMenus(); + } + + double latencyApplied() { + return m_window->audioDriverMenus()->appliedLatency(); + } + + static LatencyCalibration::Key key(QString output, QString input, + QString driver = QString()) { + LatencyCalibration::Key key; + key.implementation = driver; + key.playbackDevice = output; + key.recordDevice = input; + key.rate = rate; + return key; + } + + // A phone's route as OboeAudioIO reports it: the speaker and the + // phone's own microphone, or a Bluetooth headset's output and the + // same microphone, each opened as AAudio opens such a device + static AudioRoute::Route phoneRoute(bool bluetooth = false) { + AudioRoute::Route route; + route.driver = "oboe"; + route.output.id = bluetooth ? 41 : 3; + route.output.type = bluetooth ? 8 : 2; + route.output.productName = bluetooth ? "Headset X" : "Pixel 7"; + route.hasInput = true; + route.input.id = 7; + route.input.type = 15; + route.input.productName = "Pixel 7"; + route.rate = rate; + route.outputStreams = bluetooth ? + "AAudio, 44100 Hz, Shared, burst 240, buffer 480 of 3840" : + "AAudio (MMAP), 44100 Hz, Exclusive, burst 96, buffer 192 of 1920"; + route.inputStreams = "AAudio (MMAP), 44100 Hz, Exclusive, burst 96, " + "buffer 11424 of 11520, preset VoicePerformance"; + return route; + } + + // The Playback menu's line about the latency, as it reads when the + // menu is opened + QString latencyLine() { + emit m_window->playbackMenu()->aboutToShow(); + return m_window->latencyLineAction()->text(); + } + + // Playback > Calibrate Audio chosen, and the dialog it shows, Start + // pressed on it with the short plan + CalibrateAudioDialog *startCheckFromMenu() { + m_window->calibrateAudioAction()->trigger(); + CalibrateAudioDialog *dialog = m_window->calibrateAudioDialog(); + if (!dialog) return nullptr; + dialog->setPlan(shortPlan()); + m_window->discardModifications(); + dialog->startCheck(); + return dialog; + } + + // A run judged at the reference's rate, by the fake device's + // figures, to be given a verdict + static AudioCheckResult judgedResult(LatencyCheck::Verdict verdict) { + AudioCheckResult r; + r.recordingRate = rate; + r.referenceRate = rate; + r.reportedOutputLatency = reportedOut / rate; + r.reportedInputLatency = reportedIn / rate; + r.usedRoundTrip = (reportedOut + reportedIn) / rate; + r.takes.resize(4); + r.summary.verdict = verdict; + if (verdict != LatencyCheck::Verdict::Ok) { + r.summary.flags.push_back(verdict); + } + r.summary.judged = 12; + r.summary.found = 12; + r.summary.punchIns.resize(4); + for (LatencyCheck::PunchInResult &p : r.summary.punchIns) { + p.judged = 3; + p.found = 3; + p.medianOffset = 0.003; + p.spread = 0.001; + } + r.summary.medianOffset = 0.003; + r.summary.spread = 0.001; + r.summary.inputPeak = 0.25; + r.calibratedRoundTrip = r.usedRoundTrip + 0.003; + return r; + } + + // The three toggles of the take path, and the settings they and the + // pre-roll's length are kept in + QStringList toggles() { + QSettings settings; + settings.beginGroup("MainWindow"); + QStringList state; + state << QString("play reference %1, setting %2") + .arg(m_window->playReferenceWhileRecordingAction()->isChecked()) + .arg(settings.value("playrefwhilerecording").toString()); + state << QString("pre-roll %1, setting %2, %3 s") + .arg(m_window->preRollAction()->isChecked()) + .arg(settings.value("preroll").toString()) + .arg(settings.value("prerollseconds").toString()); + state << QString("record into selection %1, setting %2") + .arg(m_window->recordIntoSelectionAction()->isChecked()) + .arg(settings.value("recordintoselection").toString()); + settings.endGroup(); + return state; + } + + static QByteArray describe(const AudioCheckResult &r) { + const LatencyCheck::TakeSummary &s = r.summary; + QStringList flags; + for (LatencyCheck::Verdict v : s.flags) { + flags << LatencyCheck::verdictName(v); + } + return QString("%1 %2 [%3], found %4 of %5, median offset %6 " + "frames, spread %7 ms, input peak %8; %9 takes, " + "round trip used %10 frames, measured %11 frames; " + "recorded at %12 Hz, reference at %13 Hz") + .arg(r.failure == "" ? "judged:" : "failed: " + r.failure) + .arg(LatencyCheck::verdictName(s.verdict)) + .arg(flags.join(" ")) + .arg(s.found) + .arg(s.judged) + .arg(s.medianOffset * rate) + .arg(s.spread * 1000.0) + .arg(s.inputPeak) + .arg(r.takes.size()) + .arg(r.usedRoundTrip * rate) + .arg(r.calibratedRoundTrip * rate) + .arg(r.recordingRate) + .arg(r.referenceRate) + .toUtf8(); + } + + // What differs from the check's playback, or "": the reference + // audible, centred, and brought back from full scale to the level it + // was made at; its pitch and notes silent + QString checkPlaybackProblem() { + const float gain = + float(std::pow(10.0, LatencyCheck::kPeakDbfs / 20.0)); + QStringList problems; + auto reference = sv::PlayParameterRepository::getInstance() + ->getPlayParameters(m_window->mainModelId().untyped); + if (!reference) { + problems << "the reference has no play parameters"; + } else { + if (!reference->isPlayAudible()) { + problems << "the reference is muted"; + } + if (reference->getPlayPan() != 0.f) { + problems << QString("the reference is panned %1") + .arg(reference->getPlayPan()); + } + if (std::fabs(reference->getPlayGain() - gain) > 1e-6f) { + problems << QString("the reference plays at %1 dB") + .arg(20.0 * std::log10(reference->getPlayGain())); + } + } + for (Analyser::Component c : { Analyser::PitchTrack, + Analyser::Notes }) { + sv::Layer *layer = m_window->analyser()->getLayer(c); + auto params = layer ? layer->getPlayParameters() : nullptr; + if (!params) { + problems << QString("no layer %1").arg(int(c)); + } else if (params->isPlayAudible()) { + problems << QString("layer %1 is heard").arg(int(c)); + } + } + return problems.join(", "); + } + + // The settings the analysers' show and play toggles are kept in, as + // Analyser::loadState() reads them + static QStringList analyserSettings() { + QSettings settings; + settings.beginGroup("Analyser"); + QStringList state; + for (int c = Analyser::Audio; c <= Analyser::Spectrogram; ++c) { + state << QString("component %1 visible %2 audible %3").arg(c) + .arg(settings.value(QString("visible-%1").arg(c), + c != Analyser::Spectrogram).toBool()) + .arg(settings.value(QString("audible-%1").arg(c), true) + .toBool()); + } + settings.endGroup(); + return state; + } + + // The reference muted in the user's own sessions. Its spectrogram's + // setting as well: both are read for the reference's model, the + // spectrogram's last + static void muteReferenceInSettings() { + QSettings settings; + settings.beginGroup("Analyser"); + settings.setValue(QString("audible-%1").arg(Analyser::Audio), false); + settings.setValue(QString("audible-%1").arg(Analyser::Spectrogram), + false); + settings.endGroup(); + } + + // How the session open now plays its reference, and its pitch and + // notes. Not the gain of those two: the toolbar moves it to a notch + // of its own level control in the first session of a window only + QStringList sessionPlayback() { + QStringList state; + auto reference = sv::PlayParameterRepository::getInstance() + ->getPlayParameters(m_window->mainModelId().untyped); + if (reference) { + state << QString("reference audible %1 pan %2 gain %3") + .arg(reference->isPlayAudible()) + .arg(reference->getPlayPan()) + .arg(reference->getPlayGain()); + } else { + state << "no reference"; + } + for (Analyser::Component c : { Analyser::PitchTrack, + Analyser::Notes }) { + sv::Layer *layer = m_window->analyser()->getLayer(c); + auto params = layer ? layer->getPlayParameters() : nullptr; + if (params) { + state << QString("layer %1 audible %2 pan %3").arg(int(c)) + .arg(params->isPlayAudible()) + .arg(params->getPlayPan()); + } else { + state << QString("no layer %1").arg(int(c)); + } + } + return state; + } + + // An ordinary file, opened as the user opens one, and analysed + void openSong() { + const QString path = m_dir.filePath("song.wav"); + if (!QFileInfo::exists(path)) { + const std::vector samples = TestSignals::sawtooth + (220.5, rate, int(1.5 * rate), 0.5); + sv::WavFileWriter writer(path, rate, 1, + sv::WavFileWriter::WriteToTarget); + const float *data = samples.data(); + QVERIFY(writer.isOK()); + QVERIFY(writer.writeSamples(&data, + sv::sv_frame_t(samples.size()))); + QVERIFY(writer.close()); + } + m_window->discardModifications(); + QCOMPARE(m_window->openPath(path, MainWindow::ReplaceSession), + MainWindow::FileOpenSucceeded); + Analyser *analyser = m_window->analyser(); + QTRY_VERIFY_WITH_TIMEOUT + (analyser->getLayer(Analyser::PitchTrack) && + analyser->getLayer(Analyser::Notes) && + analyser->getInitialAnalysisCompletion() >= 100 && + !sv::ModelTransformerFactory::getInstance() + ->haveRunningTransformers(), 30000); + } + + static QString stepName(AudioCheckRunner::Step step) { + switch (step) { + case AudioCheckRunner::Step::Idle: return "idle"; + case AudioCheckRunner::Step::OpeningReference: return "opening"; + case AudioCheckRunner::Step::AnalysingReference: + return "analysing the reference"; + case AudioCheckRunner::Step::Recording: return "recording"; + case AudioCheckRunner::Step::AnalysingTake: return "analysing the take"; + } + return ""; + } + + // The window as a phone shows it: the compact layout, in the part of + // the phone's window clear of its bars (817 by 387 of 923 by 411, as + // the log of the user's phone has it; this platform has no bars) + void showAsAPhone() { + m_window->setCompactLayout(true); + m_window->resize(817, 387); + m_window->show(); + QVERIFY(QTest::qWaitForWindowExposed(m_window)); + settle(); + } + + // Let the windows lay themselves out again + void settle() { + QCoreApplication::sendPostedEvents(); + QTest::qWait(20); + } + + static QString describe(QRect r) { + return QString("%1x%2 at %3,%4").arg(r.width()).arg(r.height()) + .arg(r.x()).arg(r.y()); + } + + static QRect onScreen(QWidget *widget) { + return QRect(widget->mapToGlobal(QPoint(0, 0)), widget->size()); + } + + // The dialog inside the window, with every button it shows inside + // both, and its text, if it has more than there is room for, + // scrolling to its end; scrolls says whether it had to + void verifyFits(CalibrateAudioDialog *dialog, QString page, + bool *scrolls = nullptr) { + settle(); + const QRect window = onScreen(m_window); + QVERIFY2(dialog->isVisible(), qPrintable(page)); + QVERIFY2(window.contains(dialog->frameGeometry()), + qPrintable(QString("%1 page %2 is not inside the window, %3") + .arg(page).arg(describe(dialog->frameGeometry())) + .arg(describe(window)))); + int buttons = 0; + for (QPushButton *button : dialog->findChildren()) { + if (!button->isVisible()) continue; + ++buttons; + const QRect r = onScreen(button); + QVERIFY2(window.contains(r) && onScreen(dialog).contains(r), + qPrintable(QString("%1 page: %2 at %3 is off the dialog, " + "%4, or the window") + .arg(page).arg(button->text()).arg(describe(r)) + .arg(describe(onScreen(dialog))))); + } + QVERIFY2(buttons > 0, qPrintable(page)); + + if (scrolls) *scrolls = false; + for (QScrollArea *area : dialog->findChildren()) { + if (!area->isVisible()) continue; + QWidget *text = area->widget(); + const int beyond = + std::max(0, text->heightForWidth(text->width()) - + area->viewport()->height()); + QVERIFY2(text->height() >= text->heightForWidth(text->width()), + qPrintable(page + ": the text is cut short")); + QCOMPARE(area->verticalScrollBar()->maximum(), beyond); + if (beyond > 0 && scrolls) *scrolls = true; + } + } + + // A finger's tap, which Qt makes into a mouse press and release when + // the widget under it takes no touch, as on a phone + void tap(QWidget *widget) { + QPointingDevice *finger = QTest::createTouchDevice(); + const QPoint centre = widget->rect().center(); + QTest::touchEvent(widget->window(), finger).press(0, centre, widget); + QTest::touchEvent(widget->window(), finger).release(0, centre, widget); + settle(); + } + + static QPushButton *button(QWidget *dialog, QString text) { + for (QPushButton *b : dialog->findChildren()) { + if (b->text() == text) return b; + } + return nullptr; + } + + // Not a slot: QtTest would run it as a test. As TestRecordWorkflow's + void dismissDialog() { + QWidget *modal = QApplication::activeModalWidget(); + if (!modal) return; + QString description = modal->windowTitle(); + if (auto box = qobject_cast(modal)) { + description += ": " + box->text(); + m_dialogs.push_back(description); + QList buttons = box->buttons(); + if (!buttons.isEmpty()) { + buttons.last()->click(); + return; + } + } else { + m_dialogs.push_back(description); + } + if (auto dialog = qobject_cast(modal)) { + dialog->reject(); + } else { + modal->close(); + } + } + +private slots: + void initTestCase() { + QVERIFY(m_dir.isValid()); + m_font = QApplication::font(); + + QSettings().clear(); + + // Otherwise the MainWindow constructor asks, in a dialog + QSettings settings; + settings.beginGroup("Preferences"); + settings.setValue(QString("network-permission-%1").arg(TONY_VERSION), + false); + settings.endGroup(); + + sv::InteractiveFileFinder::getInstance() + ->setApplicationSessionExtension("ton"); + + // Recordings and takes go here and not among the user's + sv::RecordDirectory::setRecordContainerDirectory + (m_dir.filePath("recorded")); + + connect(&m_watchdog, &QTimer::timeout, + this, [this]() { dismissDialog(); }); + m_watchdog.start(50); + } + + void init() { + m_dialogs.clear(); + QSettings settings; + settings.beginGroup("MainWindow"); + settings.setValue("playrefwhilerecording", false); + settings.setValue("preroll", false); + settings.setValue("recordintoselection", false); + settings.remove("prerollseconds"); + settings.endGroup(); + settings.beginGroup("Analyser"); + settings.remove(""); + settings.endGroup(); + settings.beginGroup("Preferences"); + settings.remove("audio-playback-device"); + settings.remove("audio-record-device"); + settings.endGroup(); + SingingTakes::setOverwriteConfirmationWanted(true); + } + + void cleanup() { + // A round trip a test stored would place the next test's takes, + // and a driver it named open the next test's device. First, as + // the waits below return early when they fail + QSettings().remove("LatencyCalibration"); + forgetDriverPreferences(); + + if (m_window) { + if (m_window->recordTarget()->isRecording()) { + m_window->doRecord(); + } + QTRY_VERIFY_WITH_TIMEOUT + (!sv::ModelTransformerFactory::getInstance() + ->haveRunningTransformers(), 30000); + m_window->doCloseSession(); + delete m_window; + m_window = nullptr; + } + QApplication::setFont(m_font); + QVERIFY2(m_dialogs.isEmpty(), + qPrintable("unexpected dialog: " + m_dialogs.join(" | "))); + } + + void cleanupTestCase() { + m_watchdog.stop(); + sv::RecordDirectory::setRecordContainerDirectory(""); + } + + // The device reports 2 x 4096 frames out and 4096 in, and the round + // trip is 123 frames longer. Every take lands 123 frames late, the + // check finds them there, and the round trip it works out is the + // real one. Its takes play the reference, record into their ranges + // and have its own lead-in, and the user's three toggles, set + // otherwise, are as they were, and so are their settings + void check_measures_the_round_trip() { + makeWindow(loopback()); + { + QSettings settings; + settings.setValue("MainWindow/prerollseconds", 3.0); + } + m_window->setPlayReferenceWhileRecording(false); + m_window->setPreRoll(true); + m_window->setRecordIntoSelection(false); + const QStringList before = toggles(); + + runCheck(); + if (QTest::currentTestFailed()) return; + + const AudioCheckResult &r = m_result; + QVERIFY2(r.failure == "", describe(r).constData()); + QVERIFY2(r.summary.verdict == LatencyCheck::Verdict::Ok, + describe(r).constData()); + QCOMPARE(r.summary.judged, 4); + QCOMPARE(r.summary.found, 4); + QVERIFY2(std::fabs(r.summary.medianOffset * rate - + (roundTrip - reportedOut - reportedIn)) <= 4.0, + describe(r).constData()); + + QCOMPARE(int(r.takes.size()), 2); + for (const TakeLatency &t : r.takes) { + QCOMPARE(t.roundTrip, sv::sv_frame_t(reportedOut + reportedIn)); + QCOMPARE(t.reportedOutput, reportedOut / rate); + QCOMPARE(t.reportedInput, reportedIn / rate); + QCOMPARE(t.recordingRate, rate); + } + QCOMPARE(r.usedRoundTrip, (reportedOut + reportedIn) / rate); + QCOMPARE(r.reportedOutputLatency, reportedOut / rate); + QCOMPARE(r.reportedInputLatency, reportedIn / rate); + QCOMPARE(r.recordingRate, rate); + QCOMPARE(r.referenceRate, rate); + QVERIFY(r.calibrationUsable()); + QVERIFY2(std::fabs(r.calibratedRoundTrip * rate - roundTrip) <= 4.0, + describe(r).constData()); + + // The second punch-in starts 6.3 s in, with room for all of the + // check's lead-in + QCOMPARE(m_window->takePreRoll(), + sv::sv_frame_t(AudioCheckRunner::kPreRollSeconds * rate)); + + QCOMPARE(toggles(), before); + QVERIFY(!m_window->audioCheckTakes()); + } + + // The round trip the check measured, kept: a second check places its + // takes with it, and finds them where they belong. Forgotten, the + // reported pair is in use again + void check_stored_round_trip_is_used() { + makeWindow(loopback()); + + runCheck(); + if (QTest::currentTestFailed()) return; + QVERIFY2(m_result.calibrationUsable(), describe(m_result).constData()); + const double measured = m_result.calibratedRoundTrip; + + QVERIFY(m_window->storeMeasuredLatency(m_result)); + LatencyCalibration::InUse inUse = m_window->latencyInUse(); + QVERIFY(inUse.source == LatencyCalibration::Source::Measured); + QCOMPARE(inUse.roundTrip, measured); + QVERIFY(inUse.date.isValid()); + + m_result = AudioCheckResult(); + m_finished = 0; + runCheck(); + if (QTest::currentTestFailed()) return; + + const AudioCheckResult &r = m_result; + QVERIFY2(r.summary.verdict == LatencyCheck::Verdict::Ok, + describe(r).constData()); + QCOMPARE(r.summary.found, 4); + QVERIFY2(std::fabs(r.summary.medianOffset * rate) <= 4.0, + describe(r).constData()); + QCOMPARE(int(r.takes.size()), 2); + for (const TakeLatency &t : r.takes) { + QVERIFY(t.measured); + QCOMPARE(t.roundTrip, sv::sv_frame_t(std::llround(measured * rate))); + } + QVERIFY2(std::fabs(r.calibratedRoundTrip * rate - roundTrip) <= 4.0, + describe(r).constData()); + + m_window->forgetMeasuredLatency(); + inUse = m_window->latencyInUse(); + QVERIFY(inUse.source == LatencyCalibration::Source::Reported); + QVERIFY(!inUse.stale); + QCOMPARE(inUse.roundTrip, (reportedOut + reportedIn) / rate); + } + + // A device at 48 kHz against the reference at 44.1: the takes are + // recorded at the device's rate and converted as they are spliced, so + // the check is judged, and its figure kept, like any other. The fake's + // delay and the latencies it reports count its own frames: the round + // trip it really has is roundTrip frames at 48 kHz, worked out here in + // seconds. The figure is kept under the device's rate, not the + // reference's, and the Playback menu's line then shows it + void check_measures_the_round_trip_at_48000() { + FakeAudioIO::Config config = loopback(); + config.sampleRate = int(otherRate); + makeWindow(config); + + runCheck(); + if (QTest::currentTestFailed()) return; + + const AudioCheckResult &r = m_result; + QVERIFY2(r.failure == "", describe(r).constData()); + QVERIFY2(r.summary.verdict == LatencyCheck::Verdict::Ok, + describe(r).constData()); + QCOMPARE(r.summary.judged, 4); + QCOMPARE(r.summary.found, 4); + QCOMPARE(r.recordingRate, otherRate); + QCOMPARE(r.referenceRate, rate); + QCOMPARE(r.key.rate, otherRate); + + // Placed with the reported pair, in frames of the recording + QCOMPARE(int(r.takes.size()), 2); + for (const TakeLatency &t : r.takes) { + QCOMPARE(t.recordingRate, otherRate); + QCOMPARE(t.roundTrip, sv::sv_frame_t(reportedOut + reportedIn)); + } + QCOMPARE(r.usedRoundTrip, (reportedOut + reportedIn) / otherRate); + QCOMPARE(r.reportedInputLatency, reportedIn / otherRate); + // The play source has it at the reference's rate, to a frame + QVERIFY(std::fabs(r.reportedOutputLatency - reportedOut / otherRate) + < 1.0 / rate); + + // bqaudioio's ResamplerWrapper, which brings the reference to the + // device's rate, holds it back by about a millisecond that nothing + // reports (TestRecordWorkflow's latency_with_a_device_at_48000): + // part of the round trip the takes need, as on a real device at + // another rate, and never early. Counted at the wrong rate, the + // figure would be 8 % off, some 20 ms + QVERIFY(r.calibrationUsable()); + const double late = r.calibratedRoundTrip - roundTrip / otherRate; + QVERIFY2(late >= -0.0001 && late <= 0.0015, + qPrintable(QString("measured %1 ms, the fake's delay is " + "%2 ms: %3") + .arg(r.calibratedRoundTrip * 1000.0) + .arg(roundTrip / otherRate * 1000.0) + .arg(describe(r).constData()))); + QVERIFY(!m_window->audioCheckTakes()); + + QVERIFY(m_window->storeMeasuredLatency(r)); + LatencyCalibration::Key at48 = key("", ""); + at48.rate = otherRate; + LatencyCalibration::Figure figure; + { + QSettings settings; + QVERIFY(LatencyCalibration::load(settings, at48, figure)); + QCOMPARE(figure.roundTrip, r.calibratedRoundTrip); + QVERIFY(!LatencyCalibration::load(settings, key("", ""), figure)); + } + const LatencyCalibration::InUse inUse = m_window->latencyInUse(); + QVERIFY(inUse.source == LatencyCalibration::Source::Measured); + QCOMPARE(inUse.roundTrip, r.calibratedRoundTrip); + const QString line = latencyLine(); + QVERIFY2(line.startsWith("Latency: measured"), qPrintable(line)); + } + + // The round trip measured at 48 kHz, kept: a second check places its + // takes with it, turned into frames at the device's rate, and finds + // every one where it belongs. The first check is one punch-in, which + // is enough to measure with and keeps this short + void check_stored_round_trip_is_used_at_48000() { + FakeAudioIO::Config config = loopback(); + config.sampleRate = int(otherRate); + makeWindow(config); + + runCheck(onePunchIn()); + if (QTest::currentTestFailed()) return; + QVERIFY2(m_result.calibrationUsable(), describe(m_result).constData()); + QVERIFY(m_window->storeMeasuredLatency(m_result)); + const double measured = m_result.calibratedRoundTrip; + + runCheck(); + if (QTest::currentTestFailed()) return; + + const AudioCheckResult &r = m_result; + QVERIFY2(r.failure == "", describe(r).constData()); + QVERIFY2(r.summary.verdict == LatencyCheck::Verdict::Ok, + describe(r).constData()); + QCOMPARE(r.summary.found, 4); + + // Near 0: the wrapper's hold-back moves by a frame or two from one + // stream start to the next, so not to 4 frames as at 44.1 kHz. A + // figure turned into frames at the wrong rate lands some 20 ms off, + // and one without the hold-back 1 ms + const double allowed = 0.0002; + QCOMPARE(int(r.summary.punchIns.size()), 2); + for (const LatencyCheck::PunchInResult &p : r.summary.punchIns) { + QVERIFY2(std::fabs(p.medianOffset) <= allowed, + describe(r).constData()); + } + QCOMPARE(int(r.takes.size()), 2); + for (const TakeLatency &t : r.takes) { + QVERIFY(t.measured); + QCOMPARE(t.roundTrip, + sv::sv_frame_t(std::llround(measured * otherRate))); + } + QVERIFY2(std::fabs(r.calibratedRoundTrip - measured) <= allowed, + describe(r).constData()); + } + + // A device whose reported latencies move from one start to the next, + // as Oboe's do on a phone: the second punch-in is placed with 10 ms + // more than the first, and lands 10 ms earlier, while the path's + // round trip has not moved. The check measures that round trip, and + // finds it steady + void check_measures_the_round_trip_when_reports_move() { + FakeAudioIO::Config config = loopback(); + config.recordLatencyStep = 441; + makeWindow(config); + + runCheck(); + if (QTest::currentTestFailed()) return; + + const AudioCheckResult &r = m_result; + QVERIFY2(r.failure == "", describe(r).constData()); + QCOMPARE(int(r.takes.size()), 2); + QVERIFY2(r.takes[1].roundTrip - r.takes[0].roundTrip >= 441, + qPrintable(QString("placed with %1 and %2 frames") + .arg(r.takes[0].roundTrip) + .arg(r.takes[1].roundTrip))); + QCOMPARE(int(r.summary.punchIns.size()), 2); + QVERIFY2(r.summary.punchIns[0].medianOffset - + r.summary.punchIns[1].medianOffset > 0.009, + describe(r).constData()); + + QVERIFY2(r.summary.verdict == LatencyCheck::Verdict::Ok, + describe(r).constData()); + QVERIFY(r.calibrationUsable()); + QVERIFY2(std::fabs(r.calibratedRoundTrip * rate - roundTrip) <= 4.0, + describe(r).constData()); + } + + // A phone: the device reports the route it opened. The figure is + // kept for that route, whatever the Preferences name, with how its + // streams were opened; its takes are placed with it although the + // latencies the device reports move by more than a millisecond from + // take to take. Another route, a Bluetooth headset, has a figure of + // its own or none, and the menu's line follows the device as it is + // opened again for each; the same route opened otherwise has its + // figure out of date + void check_keeps_the_figure_for_the_route() { + setDevices("Speakers A", "Microphone A"); + FakeAudioIO::Config config = loopback(); + config.route = phoneRoute(); + config.recordLatencyStep = 441; + makeWindow(config); + + runCheck(onePunchIn()); + if (QTest::currentTestFailed()) return; + QVERIFY2(m_result.calibrationUsable(), describe(m_result).constData()); + + const LatencyCalibration::Key speaker = + LatencyCalibration::routeKey(phoneRoute(), rate); + QCOMPARE(m_result.key.implementation, QString("oboe")); + QCOMPARE(m_result.key.playbackDevice, + QString("Built-in speaker (Pixel 7)")); + QCOMPARE(m_result.key.recordDevice, + QString("Built-in microphone (Pixel 7)")); + QCOMPARE(m_result.key.rate, rate); + QCOMPARE(m_result.route.outputStreams, phoneRoute().outputStreams); + QCOMPARE(m_result.route.inputStreams, phoneRoute().inputStreams); + + QVERIFY(m_window->storeMeasuredLatency(m_result)); + { + QSettings settings; + LatencyCalibration::Figure figure; + QVERIFY(!LatencyCalibration::load + (settings, key("Speakers A", "Microphone A"), figure)); + QVERIFY(LatencyCalibration::load(settings, speaker, figure)); + QCOMPARE(figure.roundTrip, m_result.calibratedRoundTrip); + QCOMPARE(figure.outputStreams, phoneRoute().outputStreams); + QCOMPARE(figure.inputStreams, phoneRoute().inputStreams); + } + QString line = latencyLine(); + QVERIFY2(line.startsWith("Latency: measured"), qPrintable(line)); + + // The next check's takes are placed with it, the reported input + // latency having moved by 10 ms since + const int reportedBefore = + int(m_window->recordTarget()->getSystemRecordLatency()); + runCheck(onePunchIn()); + if (QTest::currentTestFailed()) return; + QVERIFY2(m_window->recordTarget()->getSystemRecordLatency() - + reportedBefore >= 441, + qPrintable(QString("reported %1 frames, then %2") + .arg(reportedBefore) + .arg(m_window->recordTarget() + ->getSystemRecordLatency()))); + QCOMPARE(int(m_result.takes.size()), 1); + QVERIFY(m_result.takes[0].measured); + QVERIFY2(std::fabs(m_result.summary.medianOffset * rate) <= 4.0, + describe(m_result).constData()); + + // The headset: nothing kept for it + m_window->setFakeRoute(phoneRoute(true)); + m_window->doRecreateAudioIO(); + QCOMPARE(m_window->audioRoute().output.productName, + QString("Headset X")); + line = latencyLine(); + QVERIFY2(line.startsWith("Latency: driver's figure"), qPrintable(line)); + QVERIFY2(!line.contains("out of date"), qPrintable(line)); + QVERIFY(!m_window->forgetLatencyAction()->isEnabled()); + + // The speaker again + m_window->setFakeRoute(phoneRoute()); + m_window->doRecreateAudioIO(); + line = latencyLine(); + QVERIFY2(line.startsWith("Latency: measured"), qPrintable(line)); + QVERIFY(m_window->forgetLatencyAction()->isEnabled()); + + // Opened for playback only, as a phone's device is until its first + // take: the input calibrated with the speaker is taken for it + AudioRoute::Route playbackOnly = phoneRoute(); + playbackOnly.hasInput = false; + playbackOnly.input = AudioRoute::Device(); + playbackOnly.inputStreams = ""; + m_window->setFakeRoute(playbackOnly); + m_window->doRecreateAudioIO(); + QCOMPARE(m_window->latencyKey(rate).recordDevice, + QString("Built-in microphone (Pixel 7)")); + line = latencyLine(); + QVERIFY2(line.startsWith("Latency: measured"), qPrintable(line)); + + // The speaker, shared rather than exclusive: out of date + AudioRoute::Route shared = phoneRoute(); + shared.outputStreams = "AAudio, 44100 Hz, Shared, burst 96"; + m_window->setFakeRoute(shared); + m_window->doRecreateAudioIO(); + line = latencyLine(); + QVERIFY2(line.contains("(the measured one is out of date)"), + qPrintable(line)); + QVERIFY(m_window->forgetLatencyAction()->isEnabled()); + + // Forgotten for the route it is kept for + m_window->forgetLatencyAction()->trigger(); + QSettings settings; + LatencyCalibration::Figure figure; + QVERIFY(!LatencyCalibration::load(settings, speaker, figure)); + } + + // A device whose input moves 10 ms against its output each time its + // stream starts, and a window that suspends it at Stop, as svapp + // does unless told otherwise: each take starts the stream again, and + // the second punch-in lands 10 ms from the first, which is Unsteady. + // The negative of the next test, and the proof that the fake's shift + // works + void check_takes_move_apart_when_the_stream_restarts() { + FakeAudioIO::Config config = loopback(); + config.restartShift = restartShift; + makeWindow(config); + + runCheck(); + if (QTest::currentTestFailed()) return; + + const AudioCheckResult &r = m_result; + QVERIFY2(r.failure == "", describe(r).constData()); + QCOMPARE(r.summary.found, 4); + QCOMPARE(m_window->fake()->getResumeCount(), 2); + QCOMPARE(int(r.summary.punchIns.size()), 2); + const double apart = r.summary.punchIns[0].medianOffset - + r.summary.punchIns[1].medianOffset; + QVERIFY2(std::fabs(apart * rate - restartShift) <= 4.0, + qPrintable(QString("the second punch-in %1 frames before " + "the first: %2") + .arg(apart * rate).arg(describe(r).constData()))); + QVERIFY2(r.summary.verdict == LatencyCheck::Verdict::Unsteady, + describe(r).constData()); + } + + // The same device with the stream kept running between takes, as + // the application keeps it: started once, at the first + // take, and suspended neither by Stop nor by the end of a take, so + // that both punch-ins land alike and the check is Ok + void check_takes_agree_with_the_stream_kept_running() { + FakeAudioIO::Config config = loopback(); + config.restartShift = restartShift; + makeWindow(config); + QVERIFY(!m_window->applicationSuspendsAudioOnStop()); + m_window->keepAudioRunning(true); + + runCheck(); + if (QTest::currentTestFailed()) return; + + const AudioCheckResult &r = m_result; + QVERIFY2(r.failure == "", describe(r).constData()); + QCOMPARE(r.summary.found, 4); + QCOMPARE(int(r.summary.punchIns.size()), 2); + const double apart = r.summary.punchIns[0].medianOffset - + r.summary.punchIns[1].medianOffset; + QVERIFY2(std::fabs(apart) <= 0.0002, + qPrintable(QString("the second punch-in %1 ms before the " + "first: %2") + .arg(apart * 1000.0).arg(describe(r).constData()))); + QVERIFY2(r.summary.verdict == LatencyCheck::Verdict::Ok, + describe(r).constData()); + QCOMPARE(m_window->fake()->getResumeCount(), 1); + QVERIFY(!m_window->fake()->isSuspended()); + } + + // Cancel during a take stops it through the Stop path, clears the + // override, ends the run once and starts nothing more + void check_cancelled_during_a_take() { + makeWindow(loopback()); + const QStringList before = toggles(); + + startCheck(); + if (QTest::currentTestFailed()) return; + QTRY_VERIFY_WITH_TIMEOUT(m_window->recordTarget()->isRecording(), + 30000); + QVERIFY(m_window->audioCheckTakes()); + QTest::qWait(1000); + + m_window->audioCheck()->cancel(); + QVERIFY(!m_window->recordTarget()->isRecording()); + QVERIFY(!m_window->recordingAsSingingTrack()); + QVERIFY(!m_window->audioCheckTakes()); + QVERIFY(!m_window->audioCheck()->isRunning()); + QCOMPARE(m_finished, 1); + QVERIFY(m_result.failure != ""); + + QTest::qWait(500); + QVERIFY(!m_window->recordTarget()->isRecording()); + QCOMPARE(m_finished, 1); + QCOMPARE(toggles(), before); + } + + // No device to record from: the take does not start, and the run + // ends there and says why + void check_fails_without_a_device() { + makeWindow(loopback(), false); + + runCheck(); + if (QTest::currentTestFailed()) return; + + QVERIFY2(m_result.failure != "", describe(m_result).constData()); + QVERIFY(m_result.takes.empty()); + QVERIFY(!m_result.calibrationUsable()); + QVERIFY(!m_window->recordTarget()->isRecording()); + QVERIFY(!m_window->recordingAsSingingTrack()); + QVERIFY(!m_window->audioCheckTakes()); + } + + // A device that opens but never calls back: not one frame comes in. + // Once the take should have been over, and not before, so that a + // device slow to start is not taken for one that delivers nothing, + // the run stops the take through the Stop path and ends saying that + // the device delivered no input, not that the take did not stop. No + // take is left, and no harm: the next file opened is analysed + void check_ends_when_the_device_delivers_nothing() { + FakeAudioIO::Config config = loopback(); + config.neverCallsBack = true; + makeWindow(config); + + // How long after the take began to record the run ended; the + // connections go with the guard when the test returns + QObject guard; + QElapsedTimer recording; + qint64 endedAfterMs = -1; + connect(m_window->audioCheck(), &AudioCheckRunner::progress, &guard, + [&recording](const AudioCheckRunner::Progress &p) { + if (p.step == AudioCheckRunner::Step::Recording && + !recording.isValid()) { + recording.start(); + } + }); + connect(m_window->audioCheck(), &AudioCheckRunner::finished, &guard, + [&](const AudioCheckResult &) { + if (recording.isValid()) { + endedAfterMs = recording.elapsed(); + } + }); + + runCheck(onePunchIn()); + if (QTest::currentTestFailed()) return; + + QVERIFY2(m_result.failure.contains("delivered no input"), + describe(m_result).constData()); + const qint64 takeMs = + qint64((AudioCheckRunner::kPreRollSeconds + 2.0) * 1000.0); + QVERIFY2(endedAfterMs >= takeMs + AudioCheckRunner::kNoInputTimeoutMs - + AudioCheckRunner::kPollMs, + qPrintable(QString("ended %1 ms into a take of %2 ms") + .arg(endedAfterMs).arg(takeMs))); + + QVERIFY(!m_result.calibrationUsable()); + QVERIFY(!m_window->recordTarget()->isRecording()); + QVERIFY(!m_window->recordingInProgress()); + QVERIFY(!m_window->recordingAsSingingTrack()); + QVERIFY(!m_window->audioCheckTakes()); + QVERIFY2(!m_window->takes()->haveTake(), + "a recording of nothing was kept as a take"); + + openSong(); + if (QTest::currentTestFailed()) return; + QCOMPARE(m_finished, 1); + } + + // With automatic analysis switched off the reference is never + // analysed, and the check does not wait for it: it needs none + void check_runs_without_automatic_analysis() { + { + QSettings settings; + settings.setValue("Analyser/auto-analysis", false); + } + makeWindow(loopback()); + + startCheck(); + if (QTest::currentTestFailed()) return; + QTRY_VERIFY_WITH_TIMEOUT(m_window->recordTarget()->isRecording(), + 10000); + QVERIFY(!m_window->analyser()->getLayer(Analyser::PitchTrack)); + + m_window->audioCheck()->cancel(); + QCOMPARE(m_finished, 1); + QVERIFY(!m_window->recordTarget()->isRecording()); + } + + // Closing the session during a take ends the run the same way + void check_ends_when_the_session_closes() { + makeWindow(loopback()); + + startCheck(); + if (QTest::currentTestFailed()) return; + QTRY_VERIFY_WITH_TIMEOUT(m_window->recordTarget()->isRecording(), + 30000); + QTest::qWait(1000); + + m_window->doCloseSession(); + QVERIFY(!m_window->recordTarget()->isRecording()); + QVERIFY(!m_window->audioCheckTakes()); + QVERIFY(!m_window->audioCheck()->isRunning()); + QCOMPARE(m_finished, 1); + QVERIFY(m_result.failure != ""); + + QTest::qWait(500); + QVERIFY(!m_window->recordTarget()->isRecording()); + QCOMPARE(m_finished, 1); + } + + // The check's session plays the reference centred, at the level it + // was made at, and not its pitch and notes: from the first take to + // the end of the run, and after it. The user has muted the reference + // in their own sessions, which the check does not follow; its sweeps + // reach the speakers at -12 dBFS, in both channels, and come back at + // the level they were made at. Its progress names each step and + // punch-in in order, with the recording still to come going down + void check_plays_the_reference_centred_and_quiet() { + muteReferenceInSettings(); + const QStringList settingsBefore = analyserSettings(); + makeWindow(loopback()); + + QStringList problems; + int looks = 0; + bool recorded = false; + QTimer sampler; + connect(&sampler, &QTimer::timeout, &sampler, [&]() { + if (!m_window->audioCheck()->isRunning()) return; + if (m_window->recordTarget()->isRecording()) recorded = true; + if (!recorded) return; + ++looks; + const QString problem = checkPlaybackProblem(); + if (problem != "" && !problems.contains(problem)) { + problems << problem; + } + }); + sampler.start(20); + runCheck(); + sampler.stop(); + if (QTest::currentTestFailed()) return; + + QVERIFY2(m_result.failure == "", describe(m_result).constData()); + + // What reached the speakers, both channels mixed: the reference + // centred has the same level there as in each channel + const std::vector played = + m_window->fake()->getCapturedOutput(); + float peak = 0.f; + for (float s : played) peak = std::max(peak, std::fabs(s)); + const double peakDb = 20.0 * std::log10(peak); + QVERIFY2(std::fabs(peakDb - LatencyCheck::kPeakDbfs) <= 0.5, + qPrintable(QString("played at %1 dBFS").arg(peakDb))); + + // and each sweep, as the finder heard it come back: 0 dB is the + // level it was made at + QVERIFY2(m_result.summary.found == 4, describe(m_result).constData()); + for (const LatencyCheck::EventResult &e : m_result.summary.events) { + if (!e.arrival.found) continue; + QVERIFY2(std::fabs(e.arrival.levelDb) <= 1.0, + qPrintable(QString("the sweep at %1 s came back at " + "%2 dB") + .arg(e.expectedSeconds) + .arg(e.arrival.levelDb))); + } + + // The playback, looked at every 20 ms from the first take on: + // neither the analyses nor the next take put anything back + QVERIFY2(looks >= 200, + qPrintable(QString("looked %1 times").arg(looks))); + QVERIFY2(problems.isEmpty(), + qPrintable("during the check: " + problems.join(" | "))); + const QString after = checkPlaybackProblem(); + QVERIFY2(after == "", qPrintable("after the check: " + after)); + QCOMPARE(analyserSettings(), settingsBefore); + + // Each step once as it begins, and more often while a take is + // recorded, as the seconds of recording to come go down + QVERIFY(!m_progress.empty()); + const AudioCheckRunner::Plan plan = shortPlan(); + double planned = 0.0; + for (const LatencyCheck::PunchIn &p : LatencyCheck::punchInsFor + (plan.layout, plan.punchIns, plan.eventsEach)) { + planned += std::min(AudioCheckRunner::kPreRollSeconds, p.start) + + (p.end - p.start); + } + QVERIFY2(std::fabs(m_progress.front().secondsLeft - planned) < 0.01, + qPrintable(QString("%1 s to record at first, not %2 s") + .arg(m_progress.front().secondsLeft) + .arg(planned))); + QStringList steps; + int reportsWhileRecording[3] = { 0, 0, 0 }; + double left = m_progress.front().secondsLeft; + for (const AudioCheckRunner::Progress &p : m_progress) { + QCOMPARE(p.punchIns, 2); + QVERIFY(p.punchIn >= 0 && p.punchIn <= 2); + QVERIFY2(p.secondsLeft <= left + 0.001, + qPrintable(QString("%1 s left after %2 s") + .arg(p.secondsLeft).arg(left))); + left = p.secondsLeft; + const QString step = + QString("%1 %2").arg(stepName(p.step)).arg(p.punchIn); + if (steps.isEmpty() || steps.back() != step) steps << step; + if (p.step == AudioCheckRunner::Step::Recording) { + ++reportsWhileRecording[p.punchIn]; + } + } + QCOMPARE(steps, QStringList() + << "opening 0" << "analysing the reference 0" + << "recording 1" << "analysing the take 1" + << "recording 2" << "analysing the take 2"); + QVERIFY2(reportsWhileRecording[1] >= 3 && reportsWhileRecording[2] >= 3, + qPrintable(QString("%1 and %2 reports while recording") + .arg(reportsWhileRecording[1]) + .arg(reportsWhileRecording[2]))); + QCOMPARE(m_progress.back().secondsLeft, 0.0); + } + + // A session opened after a check plays as one opened before it did, + // and the settings every session reads are as they were: the + // check's playback was its session's alone. The user has the + // reference muted and the sonification on, both the other way round + // from the check's + void check_leaves_the_next_session_alone() { + muteReferenceInSettings(); + makeWindow(loopback()); + openSong(); + if (QTest::currentTestFailed()) return; + const QStringList before = sessionPlayback(); + const QStringList settingsBefore = analyserSettings(); + QVERIFY2(checkPlaybackProblem() != "", qPrintable(before.join(", "))); + + startCheck(); + if (QTest::currentTestFailed()) return; + QTRY_VERIFY_WITH_TIMEOUT(m_window->recordTarget()->isRecording(), + 30000); + QTest::qWait(500); + QCOMPARE(checkPlaybackProblem(), QString()); + m_window->audioCheck()->cancel(); + QCOMPARE(m_finished, 1); + QTRY_VERIFY_WITH_TIMEOUT + (!sv::ModelTransformerFactory::getInstance() + ->haveRunningTransformers(), 30000); + QCOMPARE(analyserSettings(), settingsBefore); + + openSong(); + if (QTest::currentTestFailed()) return; + QCOMPARE(sessionPlayback(), before); + QCOMPARE(analyserSettings(), settingsBefore); + } + + // Each check writes its reference to a file of its own, never over + // the one the open session plays, which may be the check before's; + // the others are removed, and the lowest free number is taken + void check_reference_gets_a_file_of_its_own() { + QTemporaryDir dir; + QVERIFY(dir.isValid()); + auto touch = [&](QString name) { + QFile file(dir.filePath(name)); + return file.open(QIODevice::WriteOnly); + }; + auto files = [&]() { + return QDir(dir.path()).entryList(QDir::Files, QDir::Name); + }; + const QString one = dir.filePath("calibrate-audio-reference-1.wav"); + const QString two = dir.filePath("calibrate-audio-reference-2.wav"); + + QCOMPARE(AudioCheckRunner::nextReferencePath(dir.path(), ""), one); + QVERIFY(touch("calibrate-audio-reference-1.wav")); + + // Check Again, with the first check's session open + QCOMPARE(AudioCheckRunner::nextReferencePath(dir.path(), one), two); + QVERIFY(touch("calibrate-audio-reference-2.wav")); + QVERIFY(touch("calibrate-audio-reference.wav")); + QVERIFY(touch("song.wav")); + + // and again + QCOMPARE(AudioCheckRunner::nextReferencePath(dir.path(), two), one); + QCOMPARE(files(), QStringList() + << "calibrate-audio-reference-2.wav" << "song.wav"); + + // A check with the user's song open + QCOMPARE(AudioCheckRunner::nextReferencePath + (dir.path(), dir.filePath("song.wav")), one); + QCOMPARE(files(), QStringList() << "song.wav"); + } + + // Punch-ins given in the plan must be recordable one after the other + // within the layout: overlapping, out of order, empty or outside it, + // the run does not start. Ranges that meet are fine. A plan that + // keeps the session needs one + void check_refuses_ranges_that_do_not_fit() { + makeWindow(loopback()); + AudioCheckRunner::Plan plan = shortPlan(); + using P = LatencyCheck::PunchIn; + const std::vector> refused = { + { P(2.2, 4.2), P(4.0, 6.3) }, + { P(6.3, 8.3), P(2.2, 4.2) }, + { P(-0.1, 2.0) }, + { P(9.0, 11.0) }, + { P(3.0, 3.0) }, + { P(4.0, 3.0) }, + }; + for (const std::vector

&ranges : refused) { + plan.ranges = ranges; + QVERIFY(AudioCheckRunner::punchInsOf(plan).empty()); + QVERIFY(!m_window->audioCheck()->start(plan)); + QVERIFY(!m_window->audioCheck()->isRunning()); + } + + plan.ranges = { P(2.2, 4.2), P(4.2, 6.3), P(6.3, 10.8) }; + const std::vector

given = AudioCheckRunner::punchInsOf(plan); + QCOMPARE(int(given.size()), 3); + QCOMPARE(given[1].start, 4.2); + QCOMPARE(given[2].end, 10.8); + + // Without ranges, as many as it asks for from the layout + plan.ranges.clear(); + QCOMPARE(int(AudioCheckRunner::punchInsOf(plan).size()), 2); + + plan = onePunchIn(); + plan.keepSession = true; + QVERIFY(!m_window->audioCheck()->start(plan)); + QVERIFY(!m_window->audioCheck()->isRunning()); + + QTest::qWait(200); + QCOMPARE(m_finished, 0); + } + + // A round trip of the run's own: its takes are placed with it and + // land where they belong, although another is kept for the devices + // and the window places every other take with that. The one kept, + // and the Playback menu's line about it, are as they were. The take + // measured its own start gap, as the device says it was + void check_uses_the_round_trip_it_is_given() { + makeWindow(loopback()); + + // With a file open, the device reports what it will during takes + openSong(); + if (QTest::currentTestFailed()) return; + const LatencyCalibration::InUse reported = m_window->latencyInUse(); + LatencyCalibration::Figure figure; + figure.roundTrip = 0.3; + figure.date = QDateTime::currentDateTimeUtc(); + figure.reportedOutput = reported.reportedOutput; + figure.reportedInput = reported.reportedInput; + { + QSettings settings; + LatencyCalibration::store(settings, key("", ""), figure); + } + const QString lineBefore = latencyLine(); + const QStringList storedBefore = storedLatency(); + QVERIFY2(lineBefore.startsWith("Latency: measured 300 ms"), + qPrintable(lineBefore)); + + AudioCheckRunner::Plan plan = onePunchIn(); + plan.roundTrip = roundTrip / rate; + runCheck(plan); + if (QTest::currentTestFailed()) return; + + const AudioCheckResult &r = m_result; + QVERIFY2(r.failure == "", describe(r).constData()); + QVERIFY2(r.summary.verdict == LatencyCheck::Verdict::Ok, + describe(r).constData()); + QCOMPARE(r.summary.judged, 1); + QCOMPARE(r.summary.found, 1); + QVERIFY2(std::fabs(r.summary.medianOffset * rate) <= 4.0, + describe(r).constData()); + QCOMPARE(int(r.takes.size()), 1); + const TakeLatency &t = r.takes[0]; + QCOMPARE(t.roundTrip, sv::sv_frame_t(roundTrip)); + QVERIFY(t.measured); + QCOMPARE(t.reportedOutput, reportedOut / rate); + QCOMPARE(t.reportedInput, reportedIn / rate); + QCOMPARE(r.usedRoundTrip, roundTrip / rate); + + QVERIFY(t.startGapMeasured); + const long gap = m_window->fake()->getFramesBeforePlayStart(); + QVERIFY2(gap >= 0 && std::labs(long(t.startGap) - gap) <= 16, + qPrintable(QString("start gap %1 frames; the device says %2") + .arg(t.startGap).arg(gap))); + + QCOMPARE(latencyLine(), lineBefore); + QCOMPARE(storedLatency(), storedBefore); + const LatencyCalibration::InUse inUse = m_window->latencyInUse(); + QVERIFY(inUse.source == LatencyCalibration::Source::Measured); + QCOMPARE(inUse.roundTrip, 0.3); + } + + // A plan asks for a pre-roll of its own, the check's unless it says + // otherwise: its take has that lead-in, and is placed right with it. + // A negative one is refused + void check_uses_the_pre_roll_it_is_given() { + makeWindow(loopback()); + QCOMPARE(AudioCheckRunner::Plan().preRoll, + AudioCheckRunner::kPreRollSeconds); + + AudioCheckRunner::Plan refused = onePunchIn(); + refused.preRoll = -0.5; + QVERIFY(!m_window->audioCheck()->start(refused)); + QVERIFY(!m_window->audioCheck()->isRunning()); + + AudioCheckRunner::Plan plan = onePunchIn(); + plan.roundTrip = roundTrip / rate; + plan.preRoll = 0.5; + runCheck(plan); + if (QTest::currentTestFailed()) return; + + const AudioCheckResult &r = m_result; + QVERIFY2(r.failure == "", describe(r).constData()); + QCOMPARE(r.summary.found, 1); + QVERIFY2(std::fabs(r.summary.medianOffset * rate) <= 4.0, + describe(r).constData()); + QCOMPARE(m_window->takePreRoll(), sv::sv_frame_t(0.5 * rate)); + QVERIFY(!m_window->audioCheckTakes()); + } + + // A run that keeps the session records into the one open, the + // reference and take of the run before: nothing is written, opened + // or asked, though the session is modified. The take keeps the + // earlier punch-in, and only the run's own is judged + void check_records_into_the_session_open() { + makeWindow(loopback()); + + AudioCheckRunner::Plan first = shortPlan(); + first.ranges = { LatencyCheck::PunchIn(2.2, 4.2) }; + runCheck(first); + if (QTest::currentTestFailed()) return; + QVERIFY2(m_result.summary.verdict == LatencyCheck::Verdict::Ok, + describe(m_result).constData()); + QCOMPARE(m_result.summary.judged, 1); + const sv::ModelId reference = m_window->mainModelId(); + QVERIFY(m_window->isDocumentModified()); + + AudioCheckRunner::Plan second = onePunchIn(); + second.keepSession = true; + second.referencePath = m_dir.filePath("not-written.wav"); + runCheck(second, false); + if (QTest::currentTestFailed()) return; + + const AudioCheckResult &r = m_result; + QVERIFY2(r.failure == "", describe(r).constData()); + QVERIFY2(r.summary.verdict == LatencyCheck::Verdict::Ok, + describe(r).constData()); + QVERIFY(!QFileInfo::exists(second.referencePath)); + QVERIFY(m_window->mainModelId() == reference); + QCOMPARE(r.referenceRate, rate); + + QCOMPARE(int(r.summary.punchIns.size()), 1); + QVERIFY(std::fabs(r.summary.punchIns[0].range.start - 6.3) < 1e-4); + QCOMPARE(r.summary.judged, 1); + QCOMPARE(int(r.summary.events.size()), 1); + QVERIFY(std::fabs(r.summary.events[0].expectedSeconds - 7.2) < 1e-4); + QCOMPARE(int(r.takes.size()), 1); + + const Coverage &coverage = m_window->takes()->getCoverage(); + QCOMPARE(int(coverage.getRanges().size()), 2); + QVERIFY(coverage.contains(sv::sv_frame_t(2.3 * rate))); + QVERIFY(coverage.contains(sv::sv_frame_t(6.4 * rate))); + } + + // The session of an earlier check, never saved and changed by its + // takes, is replaced without asking; and the new reference goes to a + // file of its own, the earlier one being open + void check_replaces_its_own_session_without_asking() { + TestDataLocation testData; + makeWindow(loopback()); + const QString earlier = openEarlierCheckReference(); + QVERIFY(earlier != ""); + const sv::ModelId reference = m_window->mainModelId(); + m_window->markModified(); + QVERIFY(m_window->isDocumentModified()); + + AudioCheckRunner::Plan plan = onePunchIn(); + plan.referencePath = ""; + startCheck(plan, false); + if (QTest::currentTestFailed()) return; + QTRY_VERIFY_WITH_TIMEOUT(m_window->recordTarget()->isRecording(), + 30000); + QVERIFY2(m_dialogs.isEmpty(), qPrintable(m_dialogs.join(" | "))); + QVERIFY(m_window->mainModelId() != reference); + + // The earlier one was open, so it stayed, and the new one has a + // name of its own + const QStringList references = + QDir(AudioCheckRunner::referenceDirectory()).entryList + (QStringList() << "calibrate-audio-reference*.wav", QDir::Files, + QDir::Name); + QCOMPARE(references, QStringList() + << "calibrate-audio-reference-1.wav" + << "calibrate-audio-reference-2.wav"); + QCOMPARE(QFileInfo(earlier).fileName(), + QString("calibrate-audio-reference-1.wav")); + + m_window->audioCheck()->cancel(); + QCOMPARE(m_finished, 1); + } + + // A session of the user's is asked about before it is replaced: their + // song, and a check's session they saved. The watchdog answers + void check_asks_before_replacing_the_users_session() { + TestDataLocation testData; + makeWindow(loopback()); + + openSong(); + if (QTest::currentTestFailed()) return; + m_window->markModified(); + startCheck(onePunchIn(), false); + if (QTest::currentTestFailed()) return; + QTRY_VERIFY_WITH_TIMEOUT(!m_dialogs.isEmpty(), 10000); + // By its text: macOS shows no title on a message box, and Qt + // keeps none there + QVERIFY2(m_dialogs.first().contains + ("The current session has been modified."), + qPrintable(m_dialogs.join(" | "))); + QTRY_VERIFY_WITH_TIMEOUT(m_finished > 0 || + m_window->recordTarget()->isRecording(), + 30000); + m_window->audioCheck()->cancel(); + QCOMPARE(m_finished, 1); + QTRY_VERIFY_WITH_TIMEOUT + (!sv::ModelTransformerFactory::getInstance() + ->haveRunningTransformers(), 30000); + m_dialogs.clear(); + + const QString earlier = openEarlierCheckReference(); + QVERIFY(earlier != ""); + QVERIFY(m_window->doSaveSessionAs(m_dir.filePath("kept.ton"))); + m_window->markModified(); + m_finished = 0; + startCheck(onePunchIn(), false); + if (QTest::currentTestFailed()) return; + QTRY_VERIFY_WITH_TIMEOUT(!m_dialogs.isEmpty(), 10000); + QVERIFY2(m_dialogs.first().contains + ("The current session has been modified."), + qPrintable(m_dialogs.join(" | "))); + QTRY_VERIFY_WITH_TIMEOUT(m_finished > 0 || + m_window->recordTarget()->isRecording(), + 30000); + m_window->audioCheck()->cancel(); + QCOMPARE(m_finished, 1); + m_dialogs.clear(); + } + + // Record is shut while a check runs. A press that reaches the window + // all the same does not stop the check's take, which records to the + // end of its range as if nothing had happened + void check_ignores_the_record_button() { + makeWindow(loopback()); + QVERIFY(m_window->recordAction()->isEnabled()); + + startCheck(onePunchIn()); + if (QTest::currentTestFailed()) return; + QTRY_VERIFY_WITH_TIMEOUT(m_window->recordTarget()->isRecording(), + 30000); + QVERIFY(!m_window->recordAction()->isEnabled()); + + m_window->recordAction()->trigger(); + emit m_window->recordAction()->triggered(false); + QVERIFY(m_window->recordTarget()->isRecording()); + QVERIFY(m_window->recordAction()->isChecked()); + QTest::qWait(300); + QVERIFY(m_window->recordTarget()->isRecording()); + + QTRY_VERIFY_WITH_TIMEOUT(m_finished > 0, 60000); + QVERIFY2(m_result.summary.verdict == LatencyCheck::Verdict::Ok, + describe(m_result).constData()); + QCOMPARE(m_result.summary.found, 1); + QCOMPARE(int(m_result.takes.size()), 1); + QVERIFY(m_window->recordAction()->isEnabled()); + } + + // Playback > Calibrate Audio: a dialog, not modal, that names the + // devices and the latency in use and starts a check. While the check + // runs, neither it nor the device menus can be chosen. Other devices + // are named in the Preferences meanwhile; Use this latency keeps the + // round trip measured for the devices the check started on, and the + // menu's line says what the devices named now are placed with + void calibrate_audio_from_the_menu() { + // As on Windows: a driver named, and the devices chosen under it + setPreference("audio-target", "wasapi"); + setDevices("Speakers A", "Microphone A", "wasapi"); + makeWindow(loopback()); + showDriverMenus(windowsImplementations()); + + m_window->calibrateAudioAction()->trigger(); + CalibrateAudioDialog *dialog = m_window->calibrateAudioDialog(); + QVERIFY(dialog); + QVERIFY(dialog->isVisible()); + QVERIFY(!dialog->isModal()); + QVERIFY(dialog->page() == CalibrateAudioDialog::Page::Instructions); + const QString instructions = dialog->pageText(); + for (QString words : { "Speakers A", "Microphone A", "off your ears", + "moderate volume", "room quiet", + "replaces the session", "save your work", + "driver's figure" }) { + QVERIFY2(instructions.contains(words), + qPrintable(words + " not in: " + instructions)); + } + + // Four punch-ins of three sweeps, which the calibration layout has + // room for, unless the test says otherwise + const AudioCheckRunner::Plan plan = dialog->plan(); + QCOMPARE(plan.punchIns, 4); + QCOMPARE(plan.eventsEach, 3); + QCOMPARE(int(LatencyCheck::punchInsFor + (plan.layout, plan.punchIns, plan.eventsEach).size()), 4); + + dialog->setPlan(shortPlan()); + m_window->discardModifications(); + dialog->startCheck(); + QVERIFY(m_window->audioCheck()->isRunning()); + QVERIFY(dialog->page() == CalibrateAudioDialog::Page::Progress); + setDevices("Speakers B", "Microphone B", "wasapi"); + + // Small while the check runs: the dialog hidden, and its + // indicator, which the window's status bar holds, on show there + AudioCheckIndicator *indicator = dialog->indicator(); + QVERIFY(dialog->isCollapsed()); + QVERIFY(!dialog->isVisible()); + QCOMPARE(indicator->parentWidget(), + static_cast(m_window->statusBar())); + QVERIFY(!indicator->isHidden()); + + QTRY_VERIFY_WITH_TIMEOUT(m_window->recordTarget()->isRecording(), + 30000); + QVERIFY(!m_window->calibrateAudioAction()->isEnabled()); + QVERIFY(!m_window->audioOutputMenu()->menuAction()->isEnabled()); + QVERIFY(!m_window->audioInputMenu()->menuAction()->isEnabled()); + QVERIFY(driverMenusDisabled()); + QVERIFY2(dialog->pageText().contains("Recording punch-in 1 of 2"), + qPrintable(dialog->pageText())); + // The step, the punch-in and the time left, on one line + QVERIFY2(QRegularExpression("^Recording punch-in 1 of 2, \\d+ s " + "left$").match(indicator->text()) + .hasMatch(), qPrintable(indicator->text())); + QVERIFY(indicator->progress() >= 0); + + // Back by itself at the end, with the result + QTRY_VERIFY_WITH_TIMEOUT + (dialog->page() == CalibrateAudioDialog::Page::Result, 60000); + QVERIFY(dialog->isVisible()); + QVERIFY(!dialog->isCollapsed()); + QVERIFY(indicator->isHidden()); + QCOMPARE(m_finished, 1); + QVERIFY(m_window->calibrateAudioAction()->isEnabled()); + QVERIFY(m_window->audioOutputMenu()->menuAction()->isEnabled()); + QVERIFY(m_window->audioInputMenu()->menuAction()->isEnabled()); + QVERIFY(driverMenusEnabled()); + + // 12411 frames measured, 12288 reported, at 44.1 kHz + QVERIFY2(m_result.calibrationUsable(), describe(m_result).constData()); + QString words = dialog->pageText(); + for (QString w : { "came back steadily", + "281 ms measured; the driver reports 279 ms", + "output Speakers A; input Microphone A" }) { + QVERIFY2(words.contains(w), + qPrintable(w + " not in: " + words)); + } + QVERIFY(dialog->canUseLatency()); + + dialog->useLatency(); + QVERIFY(!dialog->canUseLatency()); + QVERIFY2(dialog->pageText().contains("Kept."), + qPrintable(dialog->pageText())); + + LatencyCalibration::Figure figure; + QSettings settings; + QVERIFY(!LatencyCalibration::load + (settings, key("Speakers B", "Microphone B", "wasapi"), figure)); + QVERIFY(LatencyCalibration::load + (settings, key("Speakers A", "Microphone A", "wasapi"), figure)); + QVERIFY2(std::fabs(figure.roundTrip * rate - roundTrip) <= 4.0, + qPrintable(QString("kept %1 frames") + .arg(figure.roundTrip * rate))); + + QCOMPARE(latencyLine(), QString("Latency: driver's figure, 279 ms")); + QVERIFY(!m_window->forgetLatencyAction()->isEnabled()); + setDevices("Speakers A", "Microphone A", "wasapi"); + const QString line = latencyLine(); + QVERIFY2(line.startsWith("Latency: measured 281 ms, "), + qPrintable(line)); + QVERIFY(m_window->forgetLatencyAction()->isEnabled()); + } + + // Forget Measured Latency: the menu's line goes back to the driver's + // figure, and the figure kept is gone + void calibrate_audio_forget_measured_latency() { + makeWindow(loopback()); + const LatencyCalibration::InUse reported = m_window->latencyInUse(); + QVERIFY(reported.source == LatencyCalibration::Source::Reported); + QVERIFY2(latencyLine().startsWith("Latency: driver's figure"), + qPrintable(latencyLine())); + QVERIFY(!m_window->forgetLatencyAction()->isEnabled()); + + // As the check keeps one, for the devices the device reports now + LatencyCalibration::Figure figure; + figure.roundTrip = 0.3; + figure.date = QDateTime::currentDateTimeUtc(); + figure.reportedOutput = reported.reportedOutput; + figure.reportedInput = reported.reportedInput; + QSettings settings; + LatencyCalibration::store(settings, key("", ""), figure); + + const QString line = latencyLine(); + QCOMPARE(line, QString("Latency: measured 300 ms, %1") + .arg(QLocale().toString(QDate::currentDate(), "d MMM"))); + QVERIFY(m_window->forgetLatencyAction()->isEnabled()); + + m_window->forgetLatencyAction()->trigger(); + QVERIFY(!LatencyCalibration::load(settings, key("", ""), figure)); + QVERIFY(m_window->latencyInUse().source == + LatencyCalibration::Source::Reported); + QVERIFY2(m_window->latencyLineAction()->text() + .startsWith("Latency: driver's figure"), + qPrintable(m_window->latencyLineAction()->text())); + QVERIFY(!m_window->forgetLatencyAction()->isEnabled()); + } + + // What the result page says, for results made here: the verdict in + // plain words with its fix, and Use this latency only when the figure + // can be used + void calibrate_audio_result_words() { + makeWindow(loopback()); + m_window->calibrateAudioAction()->trigger(); + CalibrateAudioDialog *dialog = m_window->calibrateAudioDialog(); + QVERIFY(dialog); + + auto verify = [&](const AudioCheckResult &result, bool usable, + QStringList present, QStringList absent) { + dialog->showResult(result); + QVERIFY(dialog->page() == CalibrateAudioDialog::Page::Result); + const QString words = dialog->pageText(); + for (QString w : present) { + QVERIFY2(words.contains(w), + qPrintable(w + " not in: " + words)); + } + for (QString w : absent) { + QVERIFY2(!words.contains(w), + qPrintable(w + " in: " + words)); + } + QCOMPARE(dialog->canUseLatency(), usable); + }; + + // Found one sweep in twelve + AudioCheckResult silent = + judgedResult(LatencyCheck::Verdict::NoSignal); + silent.summary.found = 1; + verify(silent, false, + { "could not hear the test sounds: it found 1 of 12", + "volume up", "not muted", "Audio enhancements", + "\"Hands-Free\"", "not measured; the driver reports 279 ms" }, + { "Use this latency" }); + if (QTest::currentTestFailed()) return; + + // A device at 48 kHz is judged like any other: its rate is in the + // figures, as a fact + AudioCheckResult fast = judgedResult(LatencyCheck::Verdict::Ok); + fast.recordingRate = otherRate; + fast.key.rate = otherRate; + verify(fast, true, + { "The test sounds came back steadily", + "Press Use this latency", + "282 ms measured; the driver reports", + "recorded at 48000 Hz, converted to the reference's " + "44100 Hz" }, + { "cannot line up", "reference at 44100 Hz" }); + if (QTest::currentTestFailed()) return; + + // Unsteady, but usable; and the microphone monitored + AudioCheckResult unsteady = + judgedResult(LatencyCheck::Verdict::Unsteady); + unsteady.summary.spread = 0.008; + unsteady.summary.echo.heard = true; + unsteady.summary.echo.delaySeconds = 0.045; + unsteady.summary.echo.levelDb = -12.0; + verify(unsteady, true, + { "The driver's timing varies from take to take by 8 ms.", + "Your microphone is being played back somewhere", + "Listen to this device", "45 ms later", + "45 ms after the sound, 12 dB quieter", + "recorded at 44100 Hz, reference at 44100 Hz" }, + { "Kept." }); + if (QTest::currentTestFailed()) return; + + // Copy puts all of it on the clipboard, under when it was made + dialog->copyReport(); + const QString copied = QGuiApplication::clipboard()->text(); + QCOMPARE(copied, dialog->reportText()); + for (QString w : { "Calibrate Audio, ", + "The driver's timing varies from take to take", + "45 ms after the sound, 12 dB quieter", + "output (System Default); input (System Default)" }) { + QVERIFY2(copied.contains(w), qPrintable(w + " not in: " + copied)); + } + } + + // On a phone the instructions name the route the device has open and + // the phone's loopback, and the result's advice is a phone's, with + // the streams the figure is checked against + void calibrate_audio_on_a_phone() { + FakeAudioIO::Config config = loopback(); + config.route = phoneRoute(); + makeWindow(config); + // The device opens with the first file + QCOMPARE(m_window->audioRoute().driver, QString()); + openSong(); + if (QTest::currentTestFailed()) return; + QCOMPARE(m_window->audioRoute().driver, QString("oboe")); + + m_window->calibrateAudioAction()->trigger(); + CalibrateAudioDialog *dialog = m_window->calibrateAudioDialog(); + QVERIFY(dialog); + QVERIFY(dialog->page() == CalibrateAudioDialog::Page::Instructions); + QString words = dialog->pageText(); + for (QString w : { "Built-in speaker (Pixel 7)", + "Built-in microphone (Pixel 7)", + "phone's microphone", "off your ears", + "speaker and microphone make the loop", + "microphone of its own", "calibrated on its own", + "driver's figure" }) { + QVERIFY2(words.contains(w), qPrintable(w + " not in: " + words)); + } + QVERIFY2(!words.contains("System Default"), qPrintable(words)); + + AudioCheckResult silent = judgedResult(LatencyCheck::Verdict::NoSignal); + silent.summary.found = 1; + silent.route = phoneRoute(); + silent.key = LatencyCalibration::routeKey(phoneRoute(), rate); + dialog->showResult(silent); + words = dialog->pageText(); + for (QString w : { "could not hear the test sounds", + "Settings, Apps", "cancelling echo", + "output Built-in speaker (Pixel 7); input " + "Built-in microphone (Pixel 7)", + "Streams:", "Exclusive, burst 96, buffer 192", + "Oboe" }) { + QVERIFY2(words.contains(w), qPrintable(w + " not in: " + words)); + } + for (QString w : { "Windows", "Hands-Free", "(unknown)" }) { + QVERIFY2(!words.contains(w), qPrintable(w + " in: " + words)); + } + + AudioCheckResult fading = judgedResult(LatencyCheck::Verdict::Fading); + fading.summary.fadingDb = 12.0; + fading.route = phoneRoute(); + dialog->showResult(fading); + words = dialog->pageText(); + QVERIFY2(words.contains("echo cancellation or noise suppression"), + qPrintable(words)); + QVERIFY2(!words.contains("Windows"), qPrintable(words)); + } + + // Not while an ordinary take is being recorded: the check records + // takes of its own. Nor can the driver or its latency be chosen, + // which would open the device again under the take + void calibrate_audio_not_during_a_take() { + makeWindow(FakeAudioIO::Config()); + showDriverMenus(windowsImplementations()); + openSong(); + if (QTest::currentTestFailed()) return; + QVERIFY(m_window->calibrateAudioAction()->isEnabled()); + QVERIFY(driverMenusEnabled()); + + m_window->doRecord(); + QVERIFY(m_window->recordTarget()->isRecording()); + QVERIFY(!m_window->calibrateAudioAction()->isEnabled()); + QVERIFY(driverMenusDisabled()); + QTest::qWait(300); + + m_window->doRecord(); + QVERIFY(!m_window->recordTarget()->isRecording()); + QTRY_VERIFY_WITH_TIMEOUT + (m_window->calibrateAudioAction()->isEnabled(), 10000); + QVERIFY(driverMenusEnabled()); + } + + // Closing the dialog while its check runs cancels the check; opened + // again, it starts from the instructions + void calibrate_audio_closed_during_a_check() { + makeWindow(loopback()); + CalibrateAudioDialog *dialog = startCheckFromMenu(); + QVERIFY(dialog); + QVERIFY(m_window->audioCheck()->isRunning()); + QTRY_VERIFY_WITH_TIMEOUT(m_window->recordTarget()->isRecording(), + 30000); + + // Small, it has nothing to close: brought back as a tap on its + // indicator brings it, then closed + QVERIFY(!dialog->isVisible()); + emit dialog->indicator()->clicked(); + QVERIFY(dialog->isVisible()); + QVERIFY(dialog->page() == CalibrateAudioDialog::Page::Progress); + QVERIFY(dialog->close()); + QVERIFY(!dialog->isVisible()); + QVERIFY(dialog->indicator()->isHidden()); + QVERIFY(!m_window->audioCheck()->isRunning()); + QVERIFY(!m_window->recordTarget()->isRecording()); + QVERIFY(!m_window->audioCheckTakes()); + QCOMPARE(m_finished, 1); + QVERIFY(m_result.failure != ""); + QVERIFY(m_window->calibrateAudioAction()->isEnabled()); + + m_window->calibrateAudioAction()->trigger(); + QVERIFY(dialog->isVisible()); + QVERIFY(dialog->page() == CalibrateAudioDialog::Page::Instructions); + } + + // Every page fits a phone held in landscape, all its buttons on + // screen and its text scrolling where it is longer: at this desktop's + // font and at a phone's size of font, which scrolls + void calibrate_audio_fits_a_phone() { + for (int pixels : { 0, 17 }) { + if (pixels > 0) { + QFont font = m_font; + font.setPixelSize(pixels); + QApplication::setFont(font); + } + const QString size = (pixels > 0 ? QString("%1 px").arg(pixels) : + QString("the desktop's font")); + if (m_window) { + QTRY_VERIFY_WITH_TIMEOUT + (!sv::ModelTransformerFactory::getInstance() + ->haveRunningTransformers(), 30000); + m_window->doCloseSession(); + } + // A phone's route, and its instructions, once a file is open + FakeAudioIO::Config config = loopback(); + config.route = phoneRoute(); + makeWindow(config); + showAsAPhone(); + if (QTest::currentTestFailed()) return; + openSong(); + if (QTest::currentTestFailed()) return; + + m_window->calibrateAudioAction()->trigger(); + CalibrateAudioDialog *dialog = m_window->calibrateAudioDialog(); + QVERIFY(dialog); + verifyFits(dialog, "instructions, " + size); + if (QTest::currentTestFailed()) return; + + // Started, and brought back from small + dialog->setPlan(shortPlan()); + m_window->discardModifications(); + dialog->startCheck(); + QVERIFY(dialog->isCollapsed()); + emit dialog->indicator()->clicked(); + QVERIFY(dialog->page() == CalibrateAudioDialog::Page::Progress); + verifyFits(dialog, "progress, " + size); + if (QTest::currentTestFailed()) return; + dialog->cancelCheck(); + QVERIFY(dialog->page() == CalibrateAudioDialog::Page::Result); + verifyFits(dialog, "result of a cancelled check, " + size); + if (QTest::currentTestFailed()) return; + + // As long as a result gets: a phone's, every paragraph there is + AudioCheckResult result = + judgedResult(LatencyCheck::Verdict::Unsteady); + result.summary.spread = 0.008; + result.summary.echo.heard = true; + result.summary.echo.delaySeconds = 0.045; + result.summary.echo.levelDb = -12.0; + result.route = phoneRoute(); + result.key = LatencyCalibration::routeKey(phoneRoute(), rate); + dialog->showResult(result); + dialog->useLatency(); + bool scrolls = false; + verifyFits(dialog, "result, " + size, &scrolls); + if (QTest::currentTestFailed()) return; + if (pixels > 0) { + QVERIFY2(scrolls, "the result fitted without scrolling: the " + "test shows nothing"); + } + + m_window->discardModifications(); + } + } + + // While the check runs the dialog is a bar and a line of text at the + // right end of the status bar, clear of the pane its takes are drawn + // in. A tap brings the dialog back on the progress page, with Cancel + // and Make Small; Make Small hides it again, the check going on; and + // after Cancel it shows the result, the indicator gone + void calibrate_audio_small_during_a_check() { + makeWindow(loopback()); + showAsAPhone(); + if (QTest::currentTestFailed()) return; + CalibrateAudioDialog *dialog = startCheckFromMenu(); + QVERIFY(dialog); + QVERIFY(m_window->audioCheck()->isRunning()); + QVERIFY(dialog->isCollapsed()); + QVERIFY(!dialog->isVisible()); + + AudioCheckIndicator *indicator = dialog->indicator(); + settle(); + QVERIFY(indicator->isVisible()); + QTRY_VERIFY_WITH_TIMEOUT(m_window->recordTarget()->isRecording(), + 30000); + QVERIFY2(indicator->text().startsWith("Recording punch-in 1 of 2"), + qPrintable(indicator->text())); + + // In the window's bottom right corner, in its status bar, clear + // of the pane; and not so wide as to leave the status line no room + const QRect window = onScreen(m_window); + const QRect corner = onScreen(indicator); + QVERIFY2(onScreen(m_window->statusBar()).contains(corner), + qPrintable(describe(corner))); + QVERIFY2(!corner.intersects(onScreen(m_window->paneStack())), + qPrintable(describe(corner) + " and " + + describe(onScreen(m_window->paneStack())))); + QVERIFY2(corner.left() > window.center().x() && + corner.bottom() >= onScreen(m_window->paneStack()).bottom(), + qPrintable(describe(corner) + " in " + describe(window))); + + tap(indicator); + QVERIFY(dialog->isVisible()); + QVERIFY(!dialog->isCollapsed()); + QVERIFY(indicator->isHidden()); + QVERIFY(dialog->page() == CalibrateAudioDialog::Page::Progress); + QPushButton *cancel = button(dialog, "Cancel"); + QPushButton *small = button(dialog, "Make Small"); + QVERIFY(cancel && cancel->isVisible()); + QVERIFY(small && small->isVisible()); + + QTest::mouseClick(small, Qt::LeftButton); + settle(); + QVERIFY(!dialog->isVisible()); + QVERIFY(dialog->isCollapsed()); + QVERIFY(indicator->isVisible()); + QVERIFY(m_window->audioCheck()->isRunning()); + QCOMPARE(m_finished, 0); + + tap(indicator); + QVERIFY(dialog->isVisible()); + QTest::mouseClick(cancel, Qt::LeftButton); + QCOMPARE(m_finished, 1); + QVERIFY(!m_window->audioCheck()->isRunning()); + QVERIFY(!m_window->recordTarget()->isRecording()); + QVERIFY(dialog->isVisible()); + QVERIFY(dialog->page() == CalibrateAudioDialog::Page::Result); + QVERIFY(indicator->isHidden()); + } + + // Playback > Audio Driver: the drivers among the implementations + // bqaudioio has, by the names the user knows, in their order, with + // the one named ticked, and Audio Latency with it, both before the + // device menus. One driver is no choice, and neither is shown + void driver_menu_lists_the_drivers() { + setPreference("audio-target", "wasapi"); + makeWindow(FakeAudioIO::Config()); + showDriverMenus({ "port", "wasapi", "directsound", "mme" }); + + QMenu *drivers = m_window->audioDriverMenus()->driverMenu(); + QMenu *latencies = m_window->audioDriverMenus()->latencyMenu(); + QVERIFY(drivers->menuAction()->isVisible()); + QVERIFY(latencies->menuAction()->isVisible()); + QCOMPARE(drivers->title(), QString("Audio Dri&ver")); + QCOMPARE(latencies->title(), QString("Audio &Latency")); + QCOMPARE(entries(drivers), + QStringList({ "MME", "DirectSound", "WASAPI" })); + QCOMPARE(ticked(drivers), QString("WASAPI")); + QCOMPARE(entries(latencies), + QStringList({ "10 ms", "20 ms", "50 ms", "100 ms", + "200 ms" })); + // WASAPI's own, none having been chosen + QCOMPARE(ticked(latencies), QString("20 ms")); + + const QList playback = m_window->playbackMenu()->actions(); + const qsizetype driverAt = playback.indexOf(drivers->menuAction()); + const qsizetype latencyAt = playback.indexOf(latencies->menuAction()); + QVERIFY(driverAt >= 0); + QCOMPARE(latencyAt, driverAt + 1); + QVERIFY(latencyAt < + playback.indexOf(m_window->audioOutputMenu()->menuAction())); + + // Ticked afresh as the menu opens + setPreference("audio-target", "directsound"); + setPreference("audio-latency-directsound", "0.05"); + emit drivers->aboutToShow(); + QCOMPARE(ticked(drivers), QString("DirectSound")); + QCOMPARE(ticked(latencies), QString("50 ms")); + + m_window->setAudioImplementations({ "port", "mme" }); + m_window->doRebuildAudioDriverMenus(); + QVERIFY(!drivers->menuAction()->isVisible()); + QVERIFY(!latencies->menuAction()->isVisible()); + } + + // Choosing WASAPI names it and opens the device again, WASAPI's + // devices this time; the device menus then write WASAPI's keys, and + // MME's are left as they were + void driver_chosen_from_the_menu() { + setPreference("audio-target", "mme"); + setPreference("audio-playback-device-mme", "Speakers (MME)"); + setPreference("audio-record-device-mme", "Mic (MME)"); + setPreference("audio-playback-device-wasapi", "Speakers (WASAPI)"); + setPreference("audio-record-device-wasapi", "Mic (WASAPI)"); + makeWindow(FakeAudioIO::Config()); + m_window->setAudioImplementations(windowsImplementations()); + m_window->recreateAudioIO(); + QCOMPARE(m_window->audioIOOpened(), 1); + QCOMPARE(m_window->audioIOOpenedFor().implementation, QString("mme")); + QCOMPARE(m_window->audioIOOpenedFor().playbackDevice, + QString("Speakers (MME)")); + + chooseDriver("WASAPI"); + if (QTest::currentTestFailed()) return; + QCOMPARE(preference("audio-target"), QString("wasapi")); + QCOMPARE(m_window->audioIOOpened(), 2); + QVERIFY(m_window->fake()); + const LatencyCalibration::Key opened = m_window->audioIOOpenedFor(); + QCOMPARE(opened.implementation, QString("wasapi")); + QCOMPARE(opened.playbackDevice, QString("Speakers (WASAPI)")); + QCOMPARE(opened.recordDevice, QString("Mic (WASAPI)")); + QCOMPARE(ticked(m_window->audioDriverMenus()->driverMenu()), + QString("WASAPI")); + + // The driver in use chosen again: nothing to open afresh + chooseDriver("WASAPI"); + QCOMPARE(m_window->audioIOOpened(), 2); + + // (System Default), from a menu that lists WASAPI's devices (none + // here, where there is no WASAPI) + m_window->doRescanAudioDevices(); + QAction *systemDefault = + m_window->audioOutputMenu()->actions().value(0); + QVERIFY(systemDefault); + QCOMPARE(systemDefault->text(), QString("(System Default)")); + systemDefault->trigger(); + QCOMPARE(preference("audio-playback-device-wasapi"), QString()); + QCOMPARE(preference("audio-playback-device-mme"), + QString("Speakers (MME)")); + QCOMPARE(m_window->audioIOOpenedFor().playbackDevice, QString()); + QCOMPARE(m_window->audioIOOpenedFor().recordDevice, + QString("Mic (WASAPI)")); + } + + // With no driver named, WASAPI is named before the first device is + // opened, and the devices chosen before are WASAPI's; or before the + // Playback menu shows its device menus; MME where there is no WASAPI. + // A driver named already is left alone + void driver_named_by_default() { + setDevices("Speakers", "Microphone"); + makeWindow(FakeAudioIO::Config()); + m_window->setAudioImplementations(windowsImplementations()); + QVERIFY(!m_window->fake()); + QCOMPARE(preference("audio-target"), QString()); + + m_window->recreateAudioIO(); + QCOMPARE(m_window->audioIOOpened(), 1); + LatencyCalibration::Key opened = m_window->audioIOOpenedFor(); + QCOMPARE(opened.implementation, QString("wasapi")); + QCOMPARE(opened.playbackDevice, QString("Speakers")); + QCOMPARE(opened.recordDevice, QString("Microphone")); + QCOMPARE(preference("audio-target"), QString("wasapi")); + QCOMPARE(preference("audio-playback-device-wasapi"), + QString("Speakers")); + QCOMPARE(preference("audio-record-device-wasapi"), + QString("Microphone")); + QCOMPARE(latencyApplied(), 0.02); + + forgetDriverPreferences(); + setPreference("audio-target", "auto"); + emit m_window->playbackMenu()->aboutToShow(); + QCOMPARE(preference("audio-target"), QString("wasapi")); + + forgetDriverPreferences(); + m_window->setAudioImplementations({ "port", "mme", "directsound" }); + m_window->recreateAudioIO(); + QCOMPARE(m_window->audioIOOpenedFor().implementation, QString("mme")); + QCOMPARE(preference("audio-target"), QString("mme")); + QCOMPARE(latencyApplied(), 0.2); + m_window->setAudioImplementations(windowsImplementations()); + + setPreference("audio-target", "directsound"); + m_window->recreateAudioIO(); + emit m_window->playbackMenu()->aboutToShow(); + QCOMPARE(m_window->audioIOOpenedFor().implementation, + QString("directsound")); + QCOMPARE(preference("audio-target"), QString("directsound")); + + // Nor is anything named where there is neither + forgetDriverPreferences(); + m_window->setAudioImplementations({ "pulse", "port", "jack" }); + m_window->recreateAudioIO(); + emit m_window->playbackMenu()->aboutToShow(); + QCOMPARE(m_window->audioIOOpenedFor().implementation, QString()); + QCOMPARE(preference("audio-target"), QString()); + } + + // The latency is chosen per driver, handed to bqaudioio as the device + // is opened, and choosing one opens it again; where none has been + // chosen, 20 ms on WASAPI and 200 ms on the others + void latency_kept_per_driver() { + setPreference("audio-target", "mme"); + makeWindow(FakeAudioIO::Config()); + m_window->setAudioImplementations(windowsImplementations()); + m_window->recreateAudioIO(); + QCOMPARE(latencyApplied(), 0.2); + + chooseLatency("50 ms"); + if (QTest::currentTestFailed()) return; + QCOMPARE(preference("audio-latency-mme").toDouble(), 0.05); + QCOMPARE(m_window->audioIOOpened(), 2); + QCOMPARE(latencyApplied(), 0.05); + + chooseDriver("WASAPI"); + if (QTest::currentTestFailed()) return; + QCOMPARE(latencyApplied(), 0.02); + emit m_window->audioDriverMenus()->latencyMenu()->aboutToShow(); + QCOMPARE(ticked(m_window->audioDriverMenus()->latencyMenu()), + QString("20 ms")); + + chooseLatency("10 ms"); + if (QTest::currentTestFailed()) return; + QCOMPARE(latencyApplied(), 0.01); + QCOMPARE(preference("audio-latency-mme").toDouble(), 0.05); + + chooseDriver("MME"); + if (QTest::currentTestFailed()) return; + QCOMPARE(latencyApplied(), 0.05); + emit m_window->audioDriverMenus()->latencyMenu()->aboutToShow(); + QCOMPARE(ticked(m_window->audioDriverMenus()->latencyMenu()), + QString("50 ms")); + QCOMPARE(m_window->audioIOOpened(), 5); + } + + // A round trip measured under one driver is not the one takes are + // placed with under another, and is again under the first. The + // check's instructions name the driver + void measured_latency_kept_per_driver() { + setPreference("audio-target", "mme"); + makeWindow(loopback()); + m_window->setAudioImplementations(windowsImplementations()); + m_window->recreateAudioIO(); + const LatencyCalibration::InUse reported = m_window->latencyInUse(); + QVERIFY(reported.source == LatencyCalibration::Source::Reported); + + LatencyCalibration::Figure figure; + figure.roundTrip = 0.3; + figure.date = QDateTime::currentDateTimeUtc(); + figure.reportedOutput = reported.reportedOutput; + figure.reportedInput = reported.reportedInput; + LatencyCalibration::Key mme = key("", ""); + mme.implementation = "mme"; + QSettings settings; + LatencyCalibration::store(settings, mme, figure); + QVERIFY(m_window->latencyInUse().source == + LatencyCalibration::Source::Measured); + + chooseDriver("WASAPI"); + if (QTest::currentTestFailed()) return; + QVERIFY(m_window->latencyInUse().source == + LatencyCalibration::Source::Reported); + QVERIFY2(latencyLine().startsWith("Latency: driver's figure"), + qPrintable(latencyLine())); + + m_window->calibrateAudioAction()->trigger(); + CalibrateAudioDialog *dialog = m_window->calibrateAudioDialog(); + QVERIFY(dialog); + for (QString words : { "Driver:", "WASAPI" }) { + QVERIFY2(dialog->pageText().contains(words), + qPrintable(words + " not in: " + dialog->pageText())); + } + dialog->close(); + + chooseDriver("MME"); + if (QTest::currentTestFailed()) return; + QVERIFY(m_window->latencyInUse().source == + LatencyCalibration::Source::Measured); + QCOMPARE(m_window->latencyInUse().roundTrip, 0.3); + } +}; + +#endif diff --git a/main/test/TestAudioDriverSettings.h b/main/test/TestAudioDriverSettings.h new file mode 100644 index 00000000..6a132073 --- /dev/null +++ b/main/test/TestAudioDriverSettings.h @@ -0,0 +1,168 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TEST_AUDIO_DRIVER_SETTINGS_H +#define TEST_AUDIO_DRIVER_SETTINGS_H + +// Tier 2: the audio driver and the latency asked of it, as the +// Preferences keep them. No window and no device. The settings are an +// INI file of each test's own, as TestLatencyCalibration's are. + +#include "../AudioDriverSettings.h" + +#include +#include +#include +#include +#include + +class TestAudioDriverSettings : public QObject +{ + Q_OBJECT + + QTemporaryDir m_dir; + int m_counter = 0; + QString m_path; + +private slots: + void initTestCase() { + QVERIFY(m_dir.isValid()); + } + + void init() { + m_path = m_dir.filePath(QString("settings-%1.ini").arg(++m_counter)); + } + + void cleanup() { + QFile::remove(m_path); + } + + // The drivers among bqaudioio's implementations, in the order they + // are offered in, whatever order the factory gives them in + void drivers_in_their_order() { + QCOMPARE(AudioDriverSettings::drivers + ({ "pulse", "wasapi", "port", "directsound", "jack", "mme" }), + QStringList({ "mme", "directsound", "wasapi" })); + QCOMPARE(AudioDriverSettings::drivers({ "pulse", "port", "jack" }), + QStringList()); + QCOMPARE(AudioDriverSettings::drivers({ "port", "wasapi" }), + QStringList({ "wasapi" })); + } + + // WASAPI is named where nothing is, or "auto", and the devices + // chosen before there were drivers become WASAPI's, where it has + // none of its own; MME where there is no WASAPI. Then, or where + // anything else is named, nothing happens + void default_driver_named_once() { + QSettings settings(m_path, QSettings::IniFormat); + const QStringList windows = { "port", "mme", "directsound", "wasapi" }; + + QCOMPARE(AudioDriverSettings::defaultDriver(windows), + QString("wasapi")); + QCOMPARE(AudioDriverSettings::defaultDriver + ({ "port", "mme", "directsound" }), QString("mme")); + QCOMPARE(AudioDriverSettings::defaultDriver({ "pulse", "port" }), + QString()); + + settings.setValue("Preferences/audio-playback-device", "Speakers"); + settings.setValue("Preferences/audio-record-device", "Mic"); + settings.setValue("Preferences/audio-record-device-wasapi", + "Mic (WASAPI)"); + QVERIFY(AudioDriverSettings::nameDefaultDriver(settings, windows)); + QCOMPARE(settings.value("Preferences/audio-target").toString(), + QString("wasapi")); + QCOMPARE(AudioDriverSettings::currentImplementation(settings), + QString("wasapi")); + QCOMPARE(settings.value("Preferences/audio-playback-device-wasapi") + .toString(), QString("Speakers")); + QCOMPARE(settings.value("Preferences/audio-record-device-wasapi") + .toString(), QString("Mic (WASAPI)")); + QVERIFY(!settings.contains("Preferences/audio-playback-device-mme")); + + // Named now: another device without a suffix is not carried over + settings.setValue("Preferences/audio-playback-device", "Headphones"); + QVERIFY(!AudioDriverSettings::nameDefaultDriver(settings, windows)); + QCOMPARE(settings.value("Preferences/audio-playback-device-wasapi") + .toString(), QString("Speakers")); + + settings.setValue("Preferences/audio-target", "auto"); + QCOMPARE(AudioDriverSettings::currentImplementation(settings), + QString()); + QVERIFY(AudioDriverSettings::nameDefaultDriver(settings, windows)); + QCOMPARE(AudioDriverSettings::currentImplementation(settings), + QString("wasapi")); + + // One chosen is left alone, MME included + settings.setValue("Preferences/audio-target", "mme"); + QVERIFY(!AudioDriverSettings::nameDefaultDriver(settings, windows)); + QCOMPARE(AudioDriverSettings::currentImplementation(settings), + QString("mme")); + + // Without WASAPI, MME + settings.remove("Preferences"); + settings.setValue("Preferences/audio-playback-device", "Speakers"); + QVERIFY(AudioDriverSettings::nameDefaultDriver + (settings, { "port", "mme", "directsound" })); + QCOMPARE(AudioDriverSettings::currentImplementation(settings), + QString("mme")); + QCOMPARE(settings.value("Preferences/audio-playback-device-mme") + .toString(), QString("Speakers")); + + // Without MME built in, nothing is named, and no device key made + settings.remove("Preferences"); + settings.setValue("Preferences/audio-playback-device", "Speakers"); + QVERIFY(!AudioDriverSettings::nameDefaultDriver + (settings, { "pulse", "port", "jack" })); + QVERIFY(!settings.contains("Preferences/audio-target")); + QVERIFY(!settings.contains("Preferences/audio-playback-device-mme")); + QVERIFY(!settings.contains("Preferences/audio-record-device-mme")); + } + + // Kept per driver, in seconds; 200 ms where none has been chosen, or + // no driver is named + void latency_kept_per_driver() { + std::vector choices = AudioDriverSettings::latencyChoices(); + QCOMPARE(int(choices.size()), 5); + QCOMPARE(choices.front(), 0.010); + QCOMPARE(choices.back(), AudioDriverSettings::kDefaultLatency); + QCOMPARE(AudioDriverSettings::kDefaultLatency, 0.2); + + QSettings settings(m_path, QSettings::IniFormat); + // WASAPI's own where none is chosen; the others ask for 200 ms + QCOMPARE(AudioDriverSettings::latency(settings, "wasapi"), 0.02); + QCOMPARE(AudioDriverSettings::latency(settings, "mme"), 0.2); + AudioDriverSettings::setLatency(settings, "wasapi", 0.05); + AudioDriverSettings::setLatency(settings, "mme", 0.1); + settings.sync(); + + QSettings again(m_path, QSettings::IniFormat); + QCOMPARE(again.value("Preferences/audio-latency-wasapi").toString() + .toDouble(), 0.05); + QCOMPARE(AudioDriverSettings::latency(again, "wasapi"), 0.05); + QCOMPARE(AudioDriverSettings::latency(again, "mme"), 0.1); + QCOMPARE(AudioDriverSettings::latency(again, "directsound"), 0.2); + QCOMPARE(AudioDriverSettings::latency(again, ""), 0.2); + + again.setValue("Preferences/audio-latency-mme", "nonsense"); + QCOMPARE(AudioDriverSettings::latency(again, "mme"), 0.2); + again.setValue("Preferences/audio-latency-mme", "0"); + QCOMPARE(AudioDriverSettings::latency(again, "mme"), 0.2); + again.setValue("Preferences/audio-latency-wasapi", "nonsense"); + QCOMPARE(AudioDriverSettings::latency(again, "wasapi"), 0.02); + + QVERIFY(AudioDriverSettings::sameLatency(0.02, 0.0201)); + QVERIFY(!AudioDriverSettings::sameLatency(0.02, 0.01)); + } +}; + +#endif diff --git a/main/test/TestCompactLayout.h b/main/test/TestCompactLayout.h new file mode 100644 index 00000000..d30ffbf8 --- /dev/null +++ b/main/test/TestCompactLayout.h @@ -0,0 +1,1074 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TEST_COMPACT_LAYOUT_H +#define TEST_COMPACT_LAYOUT_H + +// Tier 5: the compact layout (CompactLayout) of the real MainWindow, +// switched on and off as the View menu does, on a window on show at +// about a phone's size in landscape. Whether a widget is visible is +// only known on a window on show. + +#include "TestSignals.h" + +#include "../MainWindow.h" +#include "../Analyser.h" +#include "../CompactLayout.h" +#include "../LyricsTrack.h" +#include "../SongScroll.h" +#include "../SongScrollBar.h" + +#include "version.h" + +#include "layer/Layer.h" +#include "view/Overview.h" +#include "view/Pane.h" +#include "view/PaneStack.h" +#include "view/ViewManager.h" +#include "data/fileio/WavFileWriter.h" +#include "transform/ModelTransformerFactory.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +/** + * MainWindow without an audio device, with what the tests look at + */ +class CompactTestWindow : public MainWindow +{ +public: + CompactTestWindow() : MainWindow(AUDIO_PLAYBACK_AND_RECORD, true, false) { } + + CompactLayout *compact() { return m_compactLayout; } + QComboBox *takeBox() { return m_takeCombo; } + QWidget *overview() { return m_overview; } + SongScrollBar *songScroll() { return m_songScroll; } + sv::PaneStack *paneStack() { return m_paneStack; } + QAction *zoomInAction() { return m_zoomInAction; } + SingingTakes *takes() { return m_takes; } + sv::ViewManager *viewManager() { return m_viewManager; } + LyricsTrack *lyrics() { return m_lyrics; } + Analyser *analyser() { return m_analyser; } + + // What the compact toolbar should hold, in order, but for the take + // box, the menu button and the panel button, and Undo and Redo + QList ownActions() { + return { m_playAction, m_recordAction, m_recordIntoSelection, + m_eraseSingingAction, m_zoomInAction, m_zoomOutAction }; + } + + // What it hides: the note-editing tools and the audio device menus + QList hiddenActions() { + return { m_navigateToolAction, m_noteEditToolAction, + m_audioDeviceMenu->menuAction(), + m_audioInputDeviceMenu->menuAction() }; + } + + QAction *navigateTool() { return m_navigateToolAction; } + QAction *noteEditTool() { return m_noteEditToolAction; } + QAction *eraseAction() { return m_eraseSingingAction; } + + void discardModifications() { m_documentModified = false; } + void doCloseSession() { discardModifications(); closeSession(); } + void doNewEmptyTake() { newEmptyTake(); } + +protected: + void createAudioIO() override { } +}; + +class TestCompactLayout : public QObject +{ + Q_OBJECT + + static constexpr double rate = 44100.0; + + QTemporaryDir m_dir; + CompactTestWindow *m_window = nullptr; + QTimer m_watchdog; + QStringList m_dialogs; + + // Everything switching off must put back as it was + struct Layout { + QByteArray state; // QMainWindow::saveState() + QSize iconSize; + bool menuBarShown = false; + bool overviewShown = false; + bool songScrollShown = false; + // Of the toolbars in the window's layout, by name + QMap toolBarsShown; + QMap toolBarIconSizes; + QMap> toolBarActions; + // Of the actions compact mode hides + QList actionsVisible; + // Of the menus of the menu bar and their submenus, in order + QList tearOffs; + QWidget *takeBoxParent = nullptr; + bool takeBoxShown = false; + }; + + static void addTearOffs(QMenu *menu, QList &tearOffs) { + tearOffs.push_back(menu->isTearOffEnabled()); + for (QAction *action: menu->actions()) { + if (QMenu *submenu = action->menu()) addTearOffs(submenu, tearOffs); + } + } + + QList layoutToolBars() { + QList toolBars; + for (QToolBar *toolBar: m_window->findChildren + (QString(), Qt::FindDirectChildrenOnly)) { + if (m_window->toolBarArea(toolBar) != Qt::NoToolBarArea) { + toolBars.push_back(toolBar); + } + } + return toolBars; + } + + QToolBar *toolBar(QString name) { + return m_window->findChild + (name, Qt::FindDirectChildrenOnly); + } + + Layout layout() { + Layout layout; + layout.state = m_window->saveState(); + layout.iconSize = m_window->iconSize(); + layout.menuBarShown = m_window->menuBar()->isVisible(); + layout.overviewShown = m_window->overview()->isVisible(); + layout.songScrollShown = m_window->songScroll()->isVisible(); + for (QToolBar *toolBar: layoutToolBars()) { + QString name = toolBar->objectName(); + layout.toolBarsShown[name] = toolBar->isVisible(); + layout.toolBarIconSizes[name] = toolBar->iconSize(); + layout.toolBarActions[name] = toolBar->actions(); + } + for (QAction *action: m_window->hiddenActions()) { + layout.actionsVisible.push_back(action->isVisible()); + } + for (QAction *action: m_window->menuBar()->actions()) { + if (QMenu *menu = action->menu()) addTearOffs(menu, layout.tearOffs); + } + layout.takeBoxParent = m_window->takeBox()->parentWidget(); + layout.takeBoxShown = m_window->takeBox()->isVisible(); + return layout; + } + + void verifySameLayout(const Layout &now, const Layout &before) { + QCOMPARE(now.menuBarShown, before.menuBarShown); + QCOMPARE(now.overviewShown, before.overviewShown); + QCOMPARE(now.songScrollShown, before.songScrollShown); + QCOMPARE(now.toolBarsShown, before.toolBarsShown); + QCOMPARE(now.toolBarIconSizes, before.toolBarIconSizes); + QCOMPARE(now.iconSize, before.iconSize); + QVERIFY2(now.toolBarActions == before.toolBarActions, + "the toolbars do not hold the actions they held"); + QCOMPARE(now.actionsVisible, before.actionsVisible); + QCOMPARE(now.tearOffs, before.tearOffs); + QCOMPARE(now.takeBoxParent, before.takeBoxParent); + QCOMPARE(now.takeBoxShown, before.takeBoxShown); + QCOMPARE(now.state, before.state); + } + + // Let the window lay itself out again + void settle() { + QCoreApplication::sendPostedEvents(); + QTest::qWait(20); + } + + void switchCompact() { + m_window->compact()->getAction()->trigger(); + settle(); + } + + // The compact toolbar's action that holds the take box, if it has it + QWidgetAction *takeBoxAction(QToolBar *toolBar) { + for (QAction *action: toolBar->actions()) { + auto widgetAction = qobject_cast(action); + if (widgetAction && + widgetAction->defaultWidget() == m_window->takeBox()) { + return widgetAction; + } + } + return nullptr; + } + + static QString names(const QList &actions) { + QStringList names; + for (QAction *action: actions) { + if (qobject_cast(action)) names << "(widget)"; + else names << action->iconText(); + } + return names.join(", "); + } + + bool analysed() { + Analyser *a = m_window->analyser(); + return a && a->getLayer(Analyser::PitchTrack) && + a->getLayer(Analyser::Notes) && + a->getInitialAnalysisCompletion() >= 100 && + !sv::ModelTransformerFactory::getInstance() + ->haveRunningTransformers(); + } + + // At about a phone's size in landscape, once Qt has scaled for the + // screen's density + void openWindow() { + m_window = new CompactTestWindow; + m_window->resize(900, 400); + m_window->show(); + QVERIFY(QTest::qWaitForWindowExposed(m_window)); + settle(); + } + + // Two seconds of reference, analysed: takes need a main model + void openReference() { + QString path = m_dir.filePath("reference.wav"); + if (!QFile::exists(path)) { + std::vector data = TestSignals::sine + (220.5, rate, int(2 * rate), 0.5); + sv::WavFileWriter writer(path, rate, 1, + sv::WavFileWriter::WriteToTarget); + const float *ptr = data.data(); + QVERIFY(writer.isOK()); + QVERIFY(writer.writeSamples(&ptr, sv::sv_frame_t(data.size()))); + QVERIFY(writer.close()); + } + m_window->discardModifications(); + QCOMPARE(m_window->openPath(path, MainWindow::ReplaceSession), + MainWindow::FileOpenSucceeded); + QTRY_VERIFY_WITH_TIMEOUT(analysed(), 30000); + } + + SongScrollBar *songScroll() { return m_window->songScroll(); } + sv::Pane *pane() { return m_window->paneStack()->getPane(0); } + + // The panes at level frames per pixel about centre + void setView(int level, sv::sv_frame_t centre) { + pane()->setZoomLevel + (sv::ZoomLevel(sv::ZoomLevel::FramesPerPixel, level)); + pane()->setCentreFrame(centre); + settle(); + } + + // Whether the strip has painted the thumb for what the panes show + bool paintedAsNow() { + SongScroll::Thumb painted = songScroll()->getPaintedThumb(); + SongScroll::Thumb now = songScroll()->currentThumb(); + return now.width() > 0.0 && + std::fabs(painted.x0 - now.x0) < 1.0 && + std::fabs(painted.x1 - now.x1) < 1.0; + } + + static QString text(const SongScroll::Thumb &t) { + return QString("[%1, %2]").arg(t.x0).arg(t.x1); + } + + // The frames a pixel of the strip stands for + double stripFramesPerPixel() { + return double(songScroll()->getSongFrames()) / songScroll()->width(); + } + + int voicedColumns() { + int n = 0; + for (const auto &c : songScroll()->getContour()) { + if (!c.isEmpty()) ++n; + } + return n; + } + + // The reference open and analysed, and the window compact + void openCompactWithReference() { + openWindow(); + if (QTest::currentTestFailed()) return; + openReference(); + if (QTest::currentTestFailed()) return; + switchCompact(); + QVERIFY(songScroll()->isVisible()); + QVERIFY(pane()); + QVERIFY(songScroll()->getSongFrames() > 0); + + // The last of the analysis drawn: nothing else is to repaint the + // strip while a test looks at what does + QTRY_VERIFY(!songScroll()->isContourPending()); + } + + void dismissDialog() { + QWidget *modal = QApplication::activeModalWidget(); + if (!modal) return; + QString description = modal->windowTitle(); + if (auto box = qobject_cast(modal)) { + description += ": " + box->text(); + m_dialogs.push_back(description); + QList buttons = box->buttons(); + if (!buttons.isEmpty()) { + buttons.last()->click(); + return; + } + } else { + m_dialogs.push_back(description); + } + if (auto dialog = qobject_cast(modal)) { + dialog->reject(); + } else { + modal->close(); + } + } + +private slots: + void initTestCase() { + QVERIFY(m_dir.isValid()); + + // Otherwise the MainWindow constructor asks, in a dialog + QSettings settings; + settings.beginGroup("Preferences"); + settings.setValue(QString("network-permission-%1").arg(TONY_VERSION), + false); + settings.endGroup(); + + connect(&m_watchdog, &QTimer::timeout, + this, [this]() { dismissDialog(); }); + m_watchdog.start(50); + } + + void init() { + m_dialogs.clear(); + } + + void cleanup() { + if (m_window) { + // A test that failed with Qt's button pressed must not leave + // it so for the next + if (QGuiApplication::mouseButtons() != Qt::NoButton) { + QTest::mouseRelease(m_window->windowHandle(), Qt::LeftButton); + } + QTRY_VERIFY_WITH_TIMEOUT + (!sv::ModelTransformerFactory::getInstance() + ->haveRunningTransformers(), 30000); + m_window->doCloseSession(); + delete m_window; + m_window = nullptr; + } + QSettings().remove("MainWindow/plotsize"); + QVERIFY2(m_dialogs.isEmpty(), + qPrintable("unexpected dialog: " + m_dialogs.join(" | "))); + } + + void cleanupTestCase() { + m_watchdog.stop(); + } + + // From the View menu: the menu bar, every toolbar and the overview + // go, and one toolbar of large buttons comes, holding the window's + // own actions and its take box + void switching_on_leaves_one_toolbar() { + openWindow(); + if (QTest::currentTestFailed()) return; + + CompactLayout *compact = m_window->compact(); + QVERIFY(!compact->isOn()); + QVERIFY2(!compact->getToolBar(), "made before it was wanted"); + + QMenu *viewMenu = nullptr; + for (QAction *action: m_window->menuBar()->actions()) { + if (action->menu() && + action->menu()->actions().contains(compact->getAction())) { + viewMenu = action->menu(); + } + } + QVERIFY(viewMenu); + QCOMPARE(viewMenu->title(), QString("&View")); + + // The note-editing tool, which compact mode hides, selected + m_window->noteEditTool()->trigger(); + QCOMPARE(m_window->viewManager()->getToolMode(), + sv::ViewManager::NoteEditMode); + + switchCompact(); + QVERIFY(compact->isOn()); + QVERIFY(compact->getAction()->isChecked()); + + QToolBar *bar = compact->getToolBar(); + QVERIFY(bar); + QVERIFY(bar->isVisible()); + QCOMPARE(m_window->toolBarArea(bar), Qt::TopToolBarArea); + + QVERIFY(!m_window->menuBar()->isVisible()); + QVERIFY(!m_window->overview()->isVisible()); + QVERIFY(m_window->songScroll()->isVisible()); + QCOMPARE(int(layoutToolBars().size()), 7); // the window's six and this + for (QToolBar *toolBar: layoutToolBars()) { + if (toolBar == bar) continue; + QVERIFY2(!toolBar->isVisible(), qPrintable(toolBar->objectName())); + } + + // Menu, Play, Record, Record into Selection, the take box, Undo, + // Redo, Erase, Zoom In, Zoom Out, Show and Play. Undo and Redo are + // the ones of the window's own toolbar, with their menus + QToolBar *tools = toolBar("Tools Toolbar"); + QVERIFY(tools); + QVERIFY(compact->getMenuButton()); + QList own = m_window->ownActions(); + QList expected { + compact->getMenuButton()->defaultAction(), + own[0], own[1], own[2], + takeBoxAction(bar), + tools->actions()[0], tools->actions()[1], + own[3], own[4], own[5], + compact->getPanelAction() + }; + QVERIFY(!expected.contains(nullptr)); + QList actions; + for (QAction *action: bar->actions()) { + if (!action->isSeparator()) actions.push_back(action); + } + QVERIFY2(actions == expected, + qPrintable(QString("the toolbar holds %1, not %2") + .arg(names(actions)).arg(names(expected)))); + QVERIFY(tools->actions()[0]->menu()); + QCOMPARE(m_window->eraseAction()->iconText(), QString("Erase")); + + // Every button on show, large; the take box the window's own, on + // show and working + QCOMPARE(bar->iconSize(), QSize(CompactLayout::iconSize, + CompactLayout::iconSize)); + for (QAction *action: actions) { + QWidget *widget = bar->widgetForAction(action); + QVERIFY2(widget && widget->isVisible(), + qPrintable(action->iconText())); + // and as tall as the icons, those that show text too + if (auto button = qobject_cast(widget)) { + QCOMPARE(button->iconSize(), bar->iconSize()); + QVERIFY2(button->height() >= CompactLayout::iconSize, + qPrintable(QString("%1 is %2 high") + .arg(action->iconText()) + .arg(button->height()))); + } + } + QCOMPARE(bar->widgetForAction(takeBoxAction(bar)), + static_cast(m_window->takeBox())); + QCOMPARE(m_window->takeBox()->parentWidget(), + static_cast(bar)); + + // The note-editing tools and the audio device menus hidden, and + // the tool the one that edits nothing + for (QAction *action: m_window->hiddenActions()) { + QVERIFY2(!action->isVisible(), qPrintable(action->text())); + } + QVERIFY(m_window->navigateTool()->isChecked()); + QCOMPARE(m_window->viewManager()->getToolMode(), + sv::ViewManager::NavigateMode); + } + + // Every menu of the menu bar is in the menu button's popup, which a + // tap opens; the switch back is in there, and nothing can be torn off + void menu_button_holds_every_menu() { + openWindow(); + if (QTest::currentTestFailed()) return; + + CompactLayout *compact = m_window->compact(); + switchCompact(); + + QToolButton *button = compact->getMenuButton(); + QVERIFY(button); + QVERIFY(button->isVisible()); + QCOMPARE(button->popupMode(), QToolButton::InstantPopup); + QMenu *popup = button->defaultAction()->menu(); + QVERIFY(popup); + + QList menus = m_window->menuBar()->actions(); + QCOMPARE(int(menus.size()), 7); // File, Edit, View, Analysis, + // Takes, Playback, Help + QVERIFY2(popup->actions() == menus, + qPrintable(QString("the popup holds %1, the menu bar %2") + .arg(names(popup->actions())).arg(names(menus)))); + bool haveSwitch = false; + for (QAction *action: menus) { + QVERIFY(action->menu()); + QVERIFY(action->isVisible()); + QVERIFY2(!action->menu()->isTearOffEnabled(), + qPrintable(action->text())); + if (action->menu()->actions().contains(compact->getAction())) { + haveSwitch = true; + } + } + QVERIFY(haveSwitch); + + // Playback > Calibrate Audio among them, on show: the device menus + // are hidden, not the check + QAction *calibrate = nullptr; + for (QAction *action: menus) { + for (QAction *item: action->menu()->actions()) { + if (item->text() == "&Calibrate Audio...") calibrate = item; + } + } + QVERIFY(calibrate); + QVERIFY(calibrate->isVisible()); + + // A tap opens it (and a timer closes it again) + bool checked = false, shown = false; + QTimer timer; + timer.setSingleShot(true); + connect(&timer, &QTimer::timeout, this, [&]() { + shown = popup->isVisible(); + popup->close(); + checked = true; + }); + timer.start(200); + QTest::mouseClick(button, Qt::LeftButton); + QTRY_VERIFY_WITH_TIMEOUT(checked, 5000); + QVERIFY(shown); + } + + // The shortcuts of the menus work with the menu bar hidden: Select All + // is in the Edit menu and in no toolbar + void menu_shortcuts_work_while_compact() { + openWindow(); + if (QTest::currentTestFailed()) return; + openReference(); + if (QTest::currentTestFailed()) return; + + switchCompact(); + QVERIFY(!m_window->menuBar()->isVisible()); + + m_window->activateWindow(); + QVERIFY(QTest::qWaitForWindowActive(m_window)); + + QVERIFY(m_window->viewManager()->getSelections().empty()); + QTest::keyClick(m_window, Qt::Key_A, Qt::ControlModifier); + QCOMPARE(int(m_window->viewManager()->getSelections().size()), 1); + } + + // The Show and Play button brings the bottom toolbars, the toggles + // and gains and the playback speed, and takes them away again + void show_and_play_button_shows_and_hides_the_panel() { + openWindow(); + if (QTest::currentTestFailed()) return; + + CompactLayout *compact = m_window->compact(); + switchCompact(); + + QAction *panel = compact->getPanelAction(); + QVERIFY(panel); + QVERIFY(panel->isCheckable()); + QVERIFY(!panel->isChecked()); + auto button = qobject_cast + (compact->getToolBar()->widgetForAction(panel)); + QVERIFY(button); + QVERIFY(button->isVisible()); + + QStringList panelNames { "Playback Controls", "Show and Play" }; + QStringList otherNames { "File Toolbar", "Tools Toolbar", + "Playback Toolbar", "Play Mode Toolbar" }; + for (QString name: panelNames + otherNames) { + QVERIFY2(toolBar(name), qPrintable(name)); + QVERIFY2(!toolBar(name)->isVisible(), qPrintable(name)); + } + + QTest::mouseClick(button, Qt::LeftButton); + settle(); + QVERIFY(panel->isChecked()); + for (QString name: panelNames) { + QVERIFY2(toolBar(name)->isVisible(), qPrintable(name)); + } + // and nothing else with it + for (QString name: otherNames) { + QVERIFY2(!toolBar(name)->isVisible(), qPrintable(name)); + } + QVERIFY(!m_window->menuBar()->isVisible()); + QVERIFY(compact->getToolBar()->isVisible()); + + QTest::mouseClick(button, Qt::LeftButton); + settle(); + QVERIFY(!panel->isChecked()); + for (QString name: panelNames) { + QVERIFY2(!toolBar(name)->isVisible(), qPrintable(name)); + } + } + + // Switching off puts back what was there: here a layout of the user's + // own, with one bottom toolbar hidden, which the panel showed while + // compact + void switching_off_restores_the_layout() { + openWindow(); + if (QTest::currentTestFailed()) return; + + toolBar("Playback Controls")->hide(); + settle(); + Layout before = layout(); + QVERIFY(before.menuBarShown); + QVERIFY(before.overviewShown); + QVERIFY(!before.songScrollShown); + QVERIFY(!before.toolBarsShown["Playback Controls"]); + QVERIFY(before.toolBarsShown["Show and Play"]); + QCOMPARE(int(before.toolBarsShown.size()), 6); + + CompactLayout *compact = m_window->compact(); + switchCompact(); + compact->getPanelAction()->trigger(); + settle(); + QVERIFY(toolBar("Playback Controls")->isVisible()); + + switchCompact(); + QVERIFY(!compact->isOn()); + QVERIFY(!compact->getAction()->isChecked()); + QCOMPARE(m_window->toolBarArea(compact->getToolBar()), + Qt::NoToolBarArea); + verifySameLayout(layout(), before); + } + + // On, off, on and off: the same toolbar and popup the second time, + // nothing added twice, and the layout of the start at the end + void switching_twice_leaves_nothing_duplicated() { + openWindow(); + if (QTest::currentTestFailed()) return; + + Layout before = layout(); + int toolBars = int(m_window->findChildren().size()); + int menus = int(m_window->findChildren().size()); + int boxes = int(m_window->findChildren().size()); + + CompactLayout *compact = m_window->compact(); + switchCompact(); + QToolBar *bar = compact->getToolBar(); + QList actions = bar->actions(); + QList popup = compact->getMenuButton()->defaultAction() + ->menu()->actions(); + int buttons = int(bar->findChildren().size()); + + switchCompact(); + switchCompact(); + QCOMPARE(compact->getToolBar(), bar); + QVERIFY2(bar->actions() == actions, + qPrintable(QString("the toolbar holds %1, not %2") + .arg(names(bar->actions())).arg(names(actions)))); + QVERIFY(compact->getMenuButton()->defaultAction()->menu()->actions() + == popup); + QCOMPARE(int(bar->findChildren().size()), buttons); + + switchCompact(); + verifySameLayout(layout(), before); + if (QTest::currentTestFailed()) return; + + // The compact toolbar and its popup are kept for the next time, + // and that is all there is more of + QCOMPARE(int(m_window->findChildren().size()), + toolBars + 1); + QCOMPARE(int(m_window->findChildren().size()), menus + 1); + QCOMPARE(int(m_window->findChildren().size()), boxes); + } + + // The take box in the compact toolbar is the window's own: choosing a + // take there switches to it, as it does in the desktop layout + void take_box_chooses_the_take_while_compact() { + openWindow(); + if (QTest::currentTestFailed()) return; + openReference(); + if (QTest::currentTestFailed()) return; + + m_window->doNewEmptyTake(); + m_window->doNewEmptyTake(); + QCOMPARE(m_window->takes()->getTakeNames(), + QStringList({ "Take 1", "Take 2" })); + QCOMPARE(m_window->takes()->getActiveIndex(), 1); + auto home = qobject_cast + (m_window->takeBox()->parentWidget()); + QVERIFY(home); + + switchCompact(); + QToolBar *bar = m_window->compact()->getToolBar(); + QVERIFY(takeBoxAction(bar)); + QComboBox *box = qobject_cast + (bar->widgetForAction(takeBoxAction(bar))); + QVERIFY(box); + QVERIFY(box->isVisible()); + QVERIFY(box->isEnabled()); + QCOMPARE(box->count(), 2); + QCOMPARE(box->currentText(), QString("Take 2")); + + // As from a keyboard: the take above. Page Up, as Up is Zoom In's + QTest::keyClick(box, Qt::Key_PageUp); + QCOMPARE(m_window->takes()->getActiveIndex(), 0); + QCOMPARE(box->currentText(), QString("Take 1")); + + // and back in its own toolbar the box says so + switchCompact(); + QCOMPARE(box->parentWidget(), static_cast(home)); + QVERIFY(box->isVisible()); + QCOMPARE(box->currentText(), QString("Take 1")); + } + + // main() switches on at start with --compact, and always on Android: + // before the window is first shown + void compact_at_start() { +#ifdef Q_OS_ANDROID + QVERIFY(CompactLayout::isWantedAtStart({ "Tony" })); +#else + QVERIFY(CompactLayout::isWantedAtStart({ "Tony", "--compact" })); + QVERIFY(CompactLayout::isWantedAtStart + ({ "Tony", "--no-audio", "--compact", "song.wav" })); + QVERIFY(!CompactLayout::isWantedAtStart({ "Tony" })); + QVERIFY(!CompactLayout::isWantedAtStart + ({ "Tony", "--no-audio", "song.wav" })); +#endif + + m_window = new CompactTestWindow; + m_window->resize(900, 400); + m_window->setCompactLayout(true); + QVERIFY(m_window->compact()->isOn()); + QVERIFY(m_window->compact()->getAction()->isChecked()); + + m_window->show(); + QVERIFY(QTest::qWaitForWindowExposed(m_window)); + settle(); + + QToolBar *bar = m_window->compact()->getToolBar(); + QVERIFY(bar->isVisible()); + QVERIFY(m_window->takeBox()->isVisible()); + QVERIFY(!m_window->menuBar()->isVisible()); + QVERIFY(!m_window->overview()->isVisible()); + QVERIFY(m_window->songScroll()->isVisible()); + for (QToolBar *toolBar: layoutToolBars()) { + if (toolBar == bar) continue; + QVERIFY2(!toolBar->isVisible(), qPrintable(toolBar->objectName())); + } + + // Off: as a window that was never compact has it + m_window->setCompactLayout(false); + settle(); + QVERIFY(!m_window->compact()->getAction()->isChecked()); + QVERIFY(m_window->menuBar()->isVisible()); + QVERIFY(m_window->overview()->isVisible()); + QVERIFY(!m_window->songScroll()->isVisible()); + QCOMPARE(int(layoutToolBars().size()), 6); + for (QToolBar *toolBar: layoutToolBars()) { + QVERIFY2(toolBar->isVisible(), qPrintable(toolBar->objectName())); + } + QVERIFY(m_window->takeBox()->isVisible()); + } + + // View > Plot Size (PlotSize): the steps with the default checked, a + // step applied to the panes at once and taken up at the next start. + // On a phone it is in the menu button's popup with the rest of the + // View menu + void plot_size_in_the_view_menu() { + QSettings().remove("MainWindow/plotsize"); + openWindow(); + if (QTest::currentTestFailed()) return; + + QMenu *plotSize = nullptr; + for (QAction *menu: m_window->menuBar()->actions()) { + if (!menu->menu() || !menu->menu()->actions() + .contains(m_window->compact()->getAction())) continue; + for (QAction *action: menu->menu()->actions()) { + if (action->menu() && action->text() == "Plot &Size") { + plotSize = action->menu(); + } + } + } + QVERIFY(plotSize); + + QStringList texts, checked; + for (QAction *action: plotSize->actions()) { + texts << action->text(); + if (action->isChecked()) checked << action->text(); + } + QCOMPARE(texts, QStringList({ "100%", "150%", "200%" })); + QCOMPARE(checked, QStringList({ "100%" })); + QCOMPARE(m_window->viewManager()->getPlotScale(), 1.0); + + plotSize->actions().at(2)->trigger(); + QCOMPARE(m_window->viewManager()->getPlotScale(), 2.0); + + m_window->doCloseSession(); + delete m_window; + m_window = nullptr; + openWindow(); + if (QTest::currentTestFailed()) return; + QCOMPARE(m_window->viewManager()->getPlotScale(), 2.0); + } + + // View > Lyrics Size (LyricsSize): the same, for the scale the + // lyrics track gives its layer + void lyrics_size_in_the_view_menu() { + QSettings().remove("MainWindow/lyricssize"); + openWindow(); + if (QTest::currentTestFailed()) return; + + QMenu *lyricsSize = nullptr; + for (QAction *menu: m_window->menuBar()->actions()) { + if (!menu->menu() || !menu->menu()->actions() + .contains(m_window->compact()->getAction())) continue; + for (QAction *action: menu->menu()->actions()) { + if (action->menu() && action->text() == "Lyrics Si&ze") { + lyricsSize = action->menu(); + } + } + } + QVERIFY(lyricsSize); + + QStringList texts, checked; + for (QAction *action: lyricsSize->actions()) { + texts << action->text(); + if (action->isChecked()) checked << action->text(); + } + QCOMPARE(texts, QStringList({ "35%", "50%", "65%", "80%", "100%" })); + QCOMPARE(checked, QStringList({ "100%" })); // 50% on Android + QCOMPARE(m_window->lyrics()->getTextScale(), 1.0); + + lyricsSize->actions().at(1)->trigger(); + QCOMPARE(m_window->lyrics()->getTextScale(), 0.5); + + m_window->doCloseSession(); + delete m_window; + m_window = nullptr; + openWindow(); + if (QTest::currentTestFailed()) return; + QCOMPARE(m_window->lyrics()->getTextScale(), 0.5); + QSettings().remove("MainWindow/lyricssize"); + } + + // The song scroll bar (SongScrollBar) takes the overview's place + // while compact, a thin strip above the panes, and goes with it + void song_scroll_bar_in_place_of_the_overview() { + openWindow(); + if (QTest::currentTestFailed()) return; + + SongScrollBar *strip = songScroll(); + QVERIFY(strip); + QVERIFY(!strip->isVisible()); + QVERIFY(m_window->overview()->isVisible()); + + switchCompact(); + QVERIFY(strip->isVisible()); + QVERIFY(!m_window->overview()->isVisible()); + QCOMPARE(strip->height(), int(SongScrollBar::stripHeight)); + QWidget *stack = m_window->paneStack(); + QVERIFY(strip->mapTo(m_window, QPoint(0, strip->height())).y() <= + stack->mapTo(m_window, QPoint(0, 0)).y()); + QVERIFY2(strip->width() > m_window->width() * 9 / 10, + qPrintable(QString("%1 wide").arg(strip->width()))); + + switchCompact(); + QVERIFY(!strip->isVisible()); + QVERIFY(m_window->overview()->isVisible()); + } + + // Dragging the thumb moves every pane by as much as the drag, and a + // finger does it as the mouse does. The playhead stays + void song_scroll_bar_drag_moves_the_panes() { + openCompactWithReference(); + if (QTest::currentTestFailed()) return; + + SongScrollBar *strip = songScroll(); + sv::Pane *p = pane(); + setView(16, 30000); + QTRY_VERIFY(paintedAsNow()); + + SongScroll::Thumb thumb = strip->currentThumb(); + QVERIFY2(thumb.width() > 50.0 && thumb.x0 > 100.0 && + thumb.x1 < strip->width() - 150.0, qPrintable(text(thumb))); + int y = strip->height() / 2; + // Grabbed off its middle: the panes move from where they were, + // not to where the thumb was grabbed + QPoint from(int(thumb.x0 + thumb.width() / 4), y); + QPoint to = from + QPoint(100, 0); + sv::sv_frame_t start = p->getCentreFrame(); + sv::sv_frame_t playhead = + m_window->viewManager()->getPlaybackFrame(); + + QTest::mousePress(strip, Qt::LeftButton, Qt::NoModifier, from); + for (int i = 1; i <= 10; ++i) { + QTest::mouseMove(strip, from + (to - from) * i / 10); + } + QTest::mouseRelease(strip, Qt::LeftButton, Qt::NoModifier, to); + sv::sv_frame_t byMouse = p->getCentreFrame() - start; + + // to within a pixel of the pane's, to which it rounds its centre + double expected = 100 * stripFramesPerPixel(); + QVERIFY2(std::fabs(double(byMouse) - expected) <= 17.0, + qPrintable(QString("the mouse moved it by %1, not %2") + .arg(byMouse).arg(expected))); + QCOMPARE(m_window->paneStack()->getPane(1)->getCentreFrame(), + p->getCentreFrame()); + QTRY_VERIFY(paintedAsNow()); + QVERIFY2(strip->getPaintedThumb().x0 > thumb.x0 + 90.0, + qPrintable(text(strip->getPaintedThumb()))); + QCOMPARE(m_window->viewManager()->getPlaybackFrame(), playhead); + + p->setCentreFrame(start); + settle(); + QCOMPARE(p->getCentreFrame(), start); + + // Not a double click with the mouse's press + QTest::qWait(QApplication::doubleClickInterval() + 100); + + QPointingDevice *device = QTest::createTouchDevice(); + QTest::QTouchEventWidgetSequence t = + QTest::touchEvent(m_window, device, false); + t.press(0, from, strip).commit(); + for (int i = 1; i <= 10; ++i) { + t.move(0, from + (to - from) * i / 10, strip).commit(); + } + t.release(0, to, strip).commit(); + sv::sv_frame_t byTouch = p->getCentreFrame() - start; + + QCOMPARE(byTouch, byMouse); + QCOMPARE(QGuiApplication::mouseButtons(), Qt::NoButton); + QCOMPARE(m_window->viewManager()->getPlaybackFrame(), playhead); + } + + // A press beside the thumb centres the panes there, and a drag goes + // on from there + void song_scroll_bar_press_beside_the_thumb_centres_there() { + openCompactWithReference(); + if (QTest::currentTestFailed()) return; + + SongScrollBar *strip = songScroll(); + sv::Pane *p = pane(); + setView(16, 20000); + QTRY_VERIFY(paintedAsNow()); + + QPoint at(strip->width() * 3 / 4, strip->height() / 2); + QVERIFY2(!SongScroll::hitsThumb(strip->currentThumb(), at.x()), + qPrintable(text(strip->currentThumb()))); + sv::sv_frame_t target = SongScroll::jumpCentre + (at.x(), strip->getSongFrames(), strip->width()); + sv::sv_frame_t playhead = + m_window->viewManager()->getPlaybackFrame(); + + QTest::mousePress(strip, Qt::LeftButton, Qt::NoModifier, at); + QVERIFY2(std::llabs(p->getCentreFrame() - target) <= 16, + qPrintable(QString("centred on %1, not %2") + .arg(p->getCentreFrame()).arg(target))); + + QPoint to = at - QPoint(40, 0); + for (int i = 1; i <= 4; ++i) { + QTest::mouseMove(strip, at + (to - at) * i / 4); + } + QTest::mouseRelease(strip, Qt::LeftButton, Qt::NoModifier, to); + double expected = double(target) - 40 * stripFramesPerPixel(); + QVERIFY2(std::fabs(double(p->getCentreFrame()) - expected) <= 17.0, + qPrintable(QString("dragged to %1, not %2") + .arg(p->getCentreFrame()).arg(expected))); + + QTRY_VERIFY(paintedAsNow()); + QVERIFY(SongScroll::hitsThumb(strip->getPaintedThumb(), to.x())); + QCOMPARE(m_window->viewManager()->getPlaybackFrame(), playhead); + } + + // The thumb follows a zoom, and playback's turning of the page, + // which moves the panes without a signal of their own + void song_scroll_bar_follows_zoom_and_paging() { + openCompactWithReference(); + if (QTest::currentTestFailed()) return; + + SongScrollBar *strip = songScroll(); + sv::Pane *p = pane(); + setView(32, 20000); + QTRY_VERIFY(paintedAsNow()); + double wide = strip->getPaintedThumb().width(); + + m_window->zoomInAction()->trigger(); + QVERIFY(p->getZoomLevel().level < 32); + QTRY_VERIFY(paintedAsNow()); + QVERIFY2(strip->getPaintedThumb().width() < wide - 10.0, + qPrintable(QString("%1 wide, was %2") + .arg(strip->getPaintedThumb().width()).arg(wide))); + + setView(16, 10000); + QTRY_VERIFY(paintedAsNow()); + SongScroll::Thumb before = strip->getPaintedThumb(); + + sv::sv_frame_t ahead = 60000; + QVERIFY(ahead > p->getEndFrame()); + m_window->viewManager()->setPlaybackFrame(ahead); + QVERIFY2(p->getStartFrame() <= ahead && ahead <= p->getEndFrame(), + "the pane did not turn the page"); + QTRY_VERIFY(paintedAsNow()); + QVERIFY2(strip->getPaintedThumb().x0 > before.x0 + 100.0, + qPrintable(text(strip->getPaintedThumb()))); + int x = int(std::lround(SongScroll::xForFrame + (ahead, strip->getSongFrames(), + strip->width()))); + QCOMPARE(strip->getPaintedPlayheadX(), x); + } + + // The contour is the reference's pitch; closing the session leaves + // none of it, nor a thumb, and the next reference has its own + void song_scroll_bar_contour_and_closing_the_session() { + openCompactWithReference(); + if (QTest::currentTestFailed()) return; + + SongScrollBar *strip = songScroll(); + Analyser *a = m_window->analyser(); + QCOMPARE(strip->getSongModel(), a->getMainModelId()); + QCOMPARE(strip->getPitchModel(), + a->getLayer(Analyser::PitchTrack)->getModel()); + + // The sine is voiced throughout, at the one pitch + int columns = int(std::lround(strip->width() * + strip->devicePixelRatioF())); + QTRY_VERIFY2(voicedColumns() > columns * 8 / 10, + qPrintable(QString("%1 of %2 columns voiced") + .arg(voicedColumns()).arg(columns))); + const auto &contour = strip->getContour(); + for (int c = columns / 4; c < columns * 3 / 4; ++c) { + QVERIFY2(!contour[c].isEmpty() && + std::fabs(contour[c].low - 220.5) < 3.0 && + std::fabs(contour[c].high - 220.5) < 3.0, + qPrintable(QString("column %1: %2 to %3").arg(c) + .arg(contour[c].low).arg(contour[c].high))); + } + + m_window->doCloseSession(); + settle(); + QVERIFY(strip->getSongModel().isNone()); + QVERIFY(strip->getPitchModel().isNone()); + strip->repaint(); + QCOMPARE(voicedColumns(), 0); + QCOMPARE(strip->getPaintedThumb().width(), 0.0); + QCOMPARE(strip->getPaintedPlayheadX(), -1); + + // Nothing to move, and nothing breaks + QTest::mouseClick(strip, Qt::LeftButton, Qt::NoModifier, + QPoint(strip->width() / 2, strip->height() / 2)); + + openReference(); + if (QTest::currentTestFailed()) return; + QCOMPARE(strip->getSongModel(), a->getMainModelId()); + QTRY_VERIFY2(voicedColumns() > columns * 8 / 10, + qPrintable(QString("%1 of %2 columns voiced") + .arg(voicedColumns()).arg(columns))); + } +}; + +#endif diff --git a/main/test/TestCoverage.h b/main/test/TestCoverage.h new file mode 100644 index 00000000..518e6db6 --- /dev/null +++ b/main/test/TestCoverage.h @@ -0,0 +1,216 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TEST_COVERAGE_H +#define TEST_COVERAGE_H + +// Tier 2: the list of recorded ranges of a singing take + +#include "../Coverage.h" + +#include +#include + +class TestCoverage : public QObject +{ + Q_OBJECT + + typedef Coverage::Range Range; + typedef Coverage::Ranges Ranges; + +private slots: + void empty() { + Coverage c; + QVERIFY(c.isEmpty()); + QVERIFY(!c.contains(0)); + QVERIFY(!c.overlaps(0, 100)); + QCOMPARE(c.getEndFrame(), sv::sv_frame_t(0)); + c.remove(0, 100); + QVERIFY(c.isEmpty()); + } + + void add_keeps_order() { + Coverage c; + c.add(500, 600); + c.add(100, 200); + c.add(300, 400); + QCOMPARE(c.getRanges(), + (Ranges { Range(100, 200), Range(300, 400), Range(500, 600) })); + QCOMPARE(c.getEndFrame(), sv::sv_frame_t(600)); + } + + void add_ignores_empty_ranges() { + Coverage c; + c.add(100, 100); + c.add(200, 100); + QVERIFY(c.isEmpty()); + } + + void add_joins_overlapping() { + Coverage c; + c.add(100, 200); + c.add(300, 400); + c.add(500, 600); + c.add(150, 350); + QCOMPARE(c.getRanges(), (Ranges { Range(100, 400), Range(500, 600) })); + c.add(0, 1000); + QCOMPARE(c.getRanges(), (Ranges { Range(0, 1000) })); + } + + // A recording that starts where the last one stopped is one + // stretch of singing, not two + void add_joins_touching() { + Coverage c; + c.add(100, 200); + c.add(200, 300); + QCOMPARE(c.getRanges(), (Ranges { Range(100, 300) })); + c.add(50, 100); + QCOMPARE(c.getRanges(), (Ranges { Range(50, 300) })); + c.add(301, 400); + QCOMPARE(c.getRanges(), (Ranges { Range(50, 300), Range(301, 400) })); + } + + void add_inside_changes_nothing() { + Coverage c; + c.add(100, 400); + c.add(200, 300); + QCOMPARE(c.getRanges(), (Ranges { Range(100, 400) })); + } + + void remove_whole() { + Coverage c; + c.add(100, 200); + c.add(300, 400); + c.remove(300, 400); + QCOMPARE(c.getRanges(), (Ranges { Range(100, 200) })); + c.remove(0, 1000); + QVERIFY(c.isEmpty()); + } + + void remove_trims() { + Coverage c; + c.add(100, 400); + c.remove(50, 150); + QCOMPARE(c.getRanges(), (Ranges { Range(150, 400) })); + c.remove(350, 500); + QCOMPARE(c.getRanges(), (Ranges { Range(150, 350) })); + } + + void remove_splits() { + Coverage c; + c.add(100, 400); + c.remove(200, 300); + QCOMPARE(c.getRanges(), (Ranges { Range(100, 200), Range(300, 400) })); + } + + void remove_across_several() { + Coverage c; + c.add(100, 200); + c.add(300, 400); + c.add(500, 600); + c.remove(150, 550); + QCOMPARE(c.getRanges(), (Ranges { Range(100, 150), Range(550, 600) })); + } + + void remove_outside_changes_nothing() { + Coverage c; + c.add(100, 200); + c.remove(200, 300); + c.remove(0, 100); + c.remove(150, 150); + QCOMPARE(c.getRanges(), (Ranges { Range(100, 200) })); + } + + // The end is not part of the range + void contains_and_range_at() { + Coverage c; + c.add(100, 200); + c.add(300, 400); + QVERIFY(!c.contains(99)); + QVERIFY(c.contains(100)); + QVERIFY(c.contains(199)); + QVERIFY(!c.contains(200)); + QVERIFY(!c.contains(250)); + + Range r(7, 8); + QVERIFY(c.getRangeAt(350, r)); + QCOMPARE(r, Range(300, 400)); + QVERIFY(!c.getRangeAt(250, r)); + QCOMPARE(r, Range(300, 400)); + } + + void overlaps() { + Coverage c; + c.add(100, 200); + QVERIFY(!c.overlaps(0, 100)); + QVERIFY(c.overlaps(0, 101)); + QVERIFY(c.overlaps(150, 160)); + QVERIFY(c.overlaps(199, 300)); + QVERIFY(!c.overlaps(200, 300)); + QVERIFY(!c.overlaps(150, 150)); + } + + void equality() { + Coverage a, b; + a.add(100, 200); + a.add(200, 300); + b.add(100, 300); + QVERIFY(a == b); + b.remove(150, 160); + QVERIFY(a != b); + } + + // Coverage is stored as the regions of the coverage strip's model, + // so it has to go there and come back unchanged + + void events_round_trip() { + Coverage c; + c.add(100, 200); + c.add(4000, 9000); + + sv::EventVector events = c.toEvents(); + QCOMPARE(int(events.size()), 2); + QCOMPARE(events[0].getFrame(), sv::sv_frame_t(100)); + QCOMPARE(events[0].getDuration(), sv::sv_frame_t(100)); + QCOMPARE(events[1].getFrame(), sv::sv_frame_t(4000)); + QCOMPARE(events[1].getDuration(), sv::sv_frame_t(5000)); + + // A stock RegionLayer prints the value of a region that has no + // label of its own, so every region has one + for (const sv::Event &e : events) { + QCOMPARE(e.getValue(), 0.f); + QCOMPARE(e.getLabel(), Coverage::regionLabel()); + } + + QCOMPARE(Coverage::fromEvents(events).getRanges(), c.getRanges()); + } + + void events_of_nothing() { + QVERIFY(Coverage().toEvents().empty()); + QVERIFY(Coverage::fromEvents(sv::EventVector()).isEmpty()); + } + + void events_sorted_and_joined() { + // Whatever order a model hands its events back in, and whether + // or not two of them meet, what comes out is coverage + sv::EventVector events; + events.push_back(sv::Event(500, 0.f, 100, Coverage::regionLabel())); + events.push_back(sv::Event(100, 0.f, 100, Coverage::regionLabel())); + events.push_back(sv::Event(200, 0.f, 100, Coverage::regionLabel())); + + QCOMPARE(Coverage::fromEvents(events).getRanges(), + (Ranges { Range(100, 300), Range(500, 600) })); + } +}; + +#endif diff --git a/main/test/TestDecodedPcm.h b/main/test/TestDecodedPcm.h new file mode 100644 index 00000000..ace10b2c --- /dev/null +++ b/main/test/TestDecodedPcm.h @@ -0,0 +1,300 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TEST_DECODED_PCM_H +#define TEST_DECODED_PCM_H + +// Tier 2: the output of Android's decoders as svcore's readers give +// audio. Bytes in the encodings Android names, float frames out; the +// decoder itself can only be tried on a phone. + +#include "../DecodedPcm.h" + +#include +#include + +#include +#include +#include +#include + +class TestDecodedPcm : public QObject +{ + Q_OBJECT + + // The samples as bytes in an encoding, little-endian as on every + // Android ABI: the inverse of the conversion, for the tests that go + // through every encoding. The byte-level tests below do not use it, + // so that a mistake made the same way both ways is still caught + static std::vector encode(const std::vector &samples, + int encoding) { + std::vector bytes; + for (float v : samples) { + switch (encoding) { + case DecodedPcm::Int16: { + long i = std::lrint(double(v) * 32768.0); + i = std::min(32767L, std::max(-32768L, i)); + bytes.push_back(uint8_t(i & 0xff)); + bytes.push_back(uint8_t((i >> 8) & 0xff)); + break; + } + case DecodedPcm::Int8: { + long i = std::lrint(double(v) * 128.0) + 128; + i = std::min(255L, std::max(0L, i)); + bytes.push_back(uint8_t(i)); + break; + } + case DecodedPcm::Float: { + uint8_t b[4]; + std::memcpy(b, &v, 4); + bytes.insert(bytes.end(), b, b + 4); + break; + } + case DecodedPcm::Int24Packed: { + long i = std::lrint(double(v) * 8388608.0); + i = std::min(8388607L, std::max(-8388608L, i)); + bytes.push_back(uint8_t(i & 0xff)); + bytes.push_back(uint8_t((i >> 8) & 0xff)); + bytes.push_back(uint8_t((i >> 16) & 0xff)); + break; + } + case DecodedPcm::Int32: { + long long i = std::llrint(double(v) * 2147483648.0); + i = std::min(2147483647LL, std::max(-2147483648LL, i)); + for (int k = 0; k < 4; ++k) { + bytes.push_back(uint8_t((i >> (8 * k)) & 0xff)); + } + break; + } + } + } + return bytes; + } + + // The smallest step of an encoding, as a float + static double step(int encoding) { + switch (encoding) { + case DecodedPcm::Int16: return 1.0 / 32768.0; + case DecodedPcm::Int8: return 1.0 / 128.0; + case DecodedPcm::Int24Packed: return 1.0 / 8388608.0; + case DecodedPcm::Int32: return 1.0 / 2147483648.0; + default: return 0.0; + } + } + + // Frame f, channel c, of a signal whose every sample says where it + // came from, within [-1, 1) + static float ramp(size_t f, int c) { + return float((double(f % 200) - 100.0) / 128.0 + 0.001 * c); + } + + static std::vector ramps(size_t frames, int channels, size_t from = 0) { + std::vector v; + for (size_t f = from; f < from + frames; ++f) { + for (int c = 0; c < channels; ++c) v.push_back(ramp(f, c)); + } + return v; + } + + static std::vector convertBytes(std::vector bytes, + int encoding, int channels, + int targetChannels) { + size_t frames = bytes.size() / + size_t(DecodedPcm::bytesPerSample(encoding) * channels); + std::vector out(frames * size_t(targetChannels), -99.f); + DecodedPcm::convert(bytes.data(), encoding, channels, frames, + out.data(), targetChannels); + return out; + } + +private slots: + + // Android's own byte layouts (AudioFormat): signed and little-endian, + // 8-bit unsigned about 128, 24-bit as three bytes + void each_encoding_from_its_bytes() { + QCOMPARE(convertBytes({ 0x00, 0x40, 0x00, 0xc0, 0xff, 0x7f, 0x00, 0x80 }, + DecodedPcm::Int16, 1, 1), + (std::vector { 0.5f, -0.5f, 32767.f / 32768.f, -1.f })); + QCOMPARE(convertBytes({ 0xc0, 0x40, 0x80, 0x00 }, + DecodedPcm::Int8, 1, 1), + (std::vector { 0.5f, -0.5f, 0.f, -1.f })); + QCOMPARE(convertBytes({ 0x00, 0x00, 0x40, 0x00, 0x00, 0xc0, + 0xff, 0xff, 0xff, 0x00, 0x00, 0x80, + 0x01, 0x00, 0x00 }, + DecodedPcm::Int24Packed, 1, 1), + (std::vector { 0.5f, -0.5f, -1.f / 8388608.f, -1.f, + 1.f / 8388608.f })); + QCOMPARE(convertBytes({ 0x00, 0x00, 0x00, 0x40, 0x00, 0x00, 0x00, 0xc0, + 0x00, 0x00, 0x00, 0x80 }, + DecodedPcm::Int32, 1, 1), + (std::vector { 0.5f, -0.5f, -1.f })); + float values[] = { 0.25f, -0.75f }; + std::vector floats(sizeof(values)); + std::memcpy(floats.data(), values, sizeof(values)); + QCOMPARE(convertBytes(floats, DecodedPcm::Float, 1, 1), + (std::vector { 0.25f, -0.75f })); + } + + // The same stereo signal in every encoding comes out the same, to + // within the encoding's step, and in its frames and channels + void every_encoding_gives_the_signal() { + const int encodings[] = { + DecodedPcm::Int16, DecodedPcm::Int8, DecodedPcm::Float, + DecodedPcm::Int24Packed, DecodedPcm::Int32 + }; + std::vector signal = ramps(300, 2); + for (int encoding : encodings) { + std::vector out = + convertBytes(encode(signal, encoding), encoding, 2, 2); + QCOMPARE(out.size(), signal.size()); + for (size_t i = 0; i < signal.size(); ++i) { + QVERIFY2(std::fabs(out[i] - signal[i]) <= step(encoding) * 0.51 + 1e-9, + qPrintable(QString("%1: sample %2 is %3, not %4") + .arg(QString::fromStdString + (DecodedPcm::describe(encoding))) + .arg(i).arg(out[i]).arg(signal[i]))); + } + } + } + + void encodings_known_and_described() { + QVERIFY(DecodedPcm::isKnownEncoding(2)); + QVERIFY(DecodedPcm::isKnownEncoding(4)); + QVERIFY(!DecodedPcm::isKnownEncoding(0)); + QVERIFY(!DecodedPcm::isKnownEncoding(1)); // ENCODING_DEFAULT + QVERIFY(!DecodedPcm::isKnownEncoding(5)); // AC3: not PCM + QCOMPARE(DecodedPcm::bytesPerSample(21), 3); + QCOMPARE(QString::fromStdString(DecodedPcm::describe(4)), + QString("float")); + QVERIFY(QString::fromStdString(DecodedPcm::describe(7)) + .contains("7")); + } + + // Fewer channels than the stream's: each is used in turn, a mono + // buffer in both channels of a stereo stream + void fewer_channels_are_repeated() { + std::vector mono = { 0.5f, -0.25f }; + std::vector out = convertBytes + (encode(mono, DecodedPcm::Float), DecodedPcm::Float, 1, 2); + QCOMPARE(out, (std::vector { 0.5f, 0.5f, -0.25f, -0.25f })); + + std::vector stereo = { 0.5f, -0.25f }; + out = convertBytes(encode(stereo, DecodedPcm::Float), + DecodedPcm::Float, 2, 3); + QCOMPARE(out, (std::vector { 0.5f, -0.25f, 0.5f })); + } + + // More channels than the stream's: folded in, none dropped; 5.1 to + // stereo keeps the centre channel, where a voice usually is + void more_channels_are_folded_in() { + std::vector stereo = { 0.5f, -0.25f }; + std::vector out = convertBytes + (encode(stereo, DecodedPcm::Float), DecodedPcm::Float, 2, 1); + QCOMPARE(out, (std::vector { 0.125f })); + + // FL FR FC LFE BL BR + std::vector six = { 0.1f, 0.2f, 0.4f, 0.8f, 0.3f, 0.6f }; + out = convertBytes(encode(six, DecodedPcm::Float), + DecodedPcm::Float, 6, 2); + QCOMPARE(out.size(), size_t(2)); + QVERIFY(std::fabs(out[0] - (0.1f + 0.4f + 0.3f) / 3.f) < 1e-6); + QVERIFY(std::fabs(out[1] - (0.2f + 0.8f + 0.6f) / 3.f) < 1e-6); + } + + // Buffers of any size in, reads of any size out: every frame once, + // in order, and the counts agree + void read_in_pieces_of_any_size() { + DecodedPcm pcm(2); + QCOMPARE(pcm.getChannelCount(), 2); + QCOMPARE(pcm.getAvailable(), size_t(0)); + + // Reads that leave a little behind, so that what has been read is + // cleared away before more is added, and reads of more than is + // there + const size_t adds[] = { 1024, 1, 333, 2048, 7, 1024, 500 }; + const size_t reads[] = { 1000, 20, 1, 2000, 300, 37, 5000 }; + + size_t added = 0, readFrames = 0; + size_t r = 0; + std::vector got; + + for (size_t a : adds) { + std::vector bytes = + encode(ramps(a, 2, added), DecodedPcm::Int16); + QCOMPARE(pcm.add(bytes.data(), bytes.size(), + DecodedPcm::Int16, 2), a); + added += a; + QCOMPARE(pcm.getAvailable(), added - readFrames); + + std::vector out(reads[r] * 2, -99.f); + size_t n = pcm.read(out.data(), reads[r]); + QCOMPARE(n, std::min(reads[r], added - readFrames)); + ++r; + readFrames += n; + got.insert(got.end(), out.begin(), out.begin() + ptrdiff_t(n * 2)); + } + + // The rest, and then nothing + std::vector out(added * 2); + size_t n = pcm.read(out.data(), added); + QCOMPARE(n, added - readFrames); + readFrames += n; + got.insert(got.end(), out.begin(), out.begin() + ptrdiff_t(n * 2)); + QCOMPARE(pcm.read(out.data(), 10), size_t(0)); + + QCOMPARE(pcm.getFramesAdded(), int64_t(added)); + QCOMPARE(pcm.getFramesRead(), int64_t(added)); + QCOMPARE(pcm.getAvailable(), size_t(0)); + + std::vector expected = ramps(added, 2); + QCOMPARE(got.size(), expected.size()); + for (size_t i = 0; i < got.size(); ++i) { + QVERIFY2(std::fabs(got[i] - expected[i]) <= 1.0 / 65536.0, + qPrintable(QString("sample %1 is %2, not %3") + .arg(i).arg(got[i]).arg(expected[i]))); + } + } + + // A buffer in another channel count is taken at the stream's count: + // the format changed part way through + void a_change_of_channels_keeps_the_stream_s_count() { + DecodedPcm pcm(2); + std::vector stereo = encode({ 0.5f, -0.5f }, DecodedPcm::Int16); + std::vector mono = encode({ 0.25f, 0.75f }, DecodedPcm::Float); + QCOMPARE(pcm.add(stereo.data(), stereo.size(), DecodedPcm::Int16, 2), + size_t(1)); + QCOMPARE(pcm.add(mono.data(), mono.size(), DecodedPcm::Float, 1), + size_t(2)); + std::vector out(6); + QCOMPARE(pcm.read(out.data(), 3), size_t(3)); + QCOMPARE(out, (std::vector { 0.5f, -0.5f, 0.25f, 0.25f, + 0.75f, 0.75f })); + } + + // What is not whole frames, or not PCM Tony knows, is not taken + void part_frames_and_unknown_encodings_are_dropped() { + DecodedPcm pcm(2); + std::vector bytes = encode({ 0.5f, -0.5f, 0.25f }, DecodedPcm::Int16); + QCOMPARE(pcm.add(bytes.data(), bytes.size(), DecodedPcm::Int16, 2), + size_t(1)); + QCOMPARE(pcm.getBytesDropped(), int64_t(2)); + QCOMPARE(pcm.add(bytes.data(), bytes.size(), 5, 2), size_t(0)); + QCOMPARE(pcm.add(bytes.data(), bytes.size(), DecodedPcm::Int16, 0), + size_t(0)); + QCOMPARE(pcm.getBytesDropped(), int64_t(2 + 6 + 6)); + QCOMPARE(pcm.getFramesAdded(), int64_t(1)); + QCOMPARE(pcm.getAvailable(), size_t(1)); + } +}; + +#endif diff --git a/main/test/TestDevChecks.h b/main/test/TestDevChecks.h new file mode 100644 index 00000000..f9eb731c --- /dev/null +++ b/main/test/TestDevChecks.h @@ -0,0 +1,1525 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TEST_DEV_CHECKS_H +#define TEST_DEV_CHECKS_H + +#ifdef TONY_DEV_CHECKS + +// Tier 5, as TestAudioCheck: the development checks (DevChecks) on the +// real MainWindow, recording from the fake device with its output +// looped back into its input. A run records two punch-ins far apart +// into a long song of 60 s here, then two against the 40 s dev +// reference, one over the end of the second, one near the start of the +// song, two that meet inside a held tone, then saves the session and +// opens it again: about 50 s of real time. The runs that look at other +// things leave the long song out. One runs in the shape of the user's +// phone, calibrated from the dialog first, as there. +// +// The fixture is TestAudioCheck's, copied rather than shared. The +// application's data directory, where the check writes its references, +// is Qt's test location while this class runs; the report and the +// scratch folders go to directories of the tests' own, so that a +// failing run's report never lands among the suites' results. + +#include "TestMainWindow.h" +#include "TestSignals.h" + +#include "../AudioCheckRunner.h" +#include "../CalibrateAudioDialog.h" +#include "../LatencyCheck.h" +#include "../TakesFile.h" +#include "../dev/DevChecks.h" + +#include "version.h" + +#include "base/PlayParameters.h" +#include "base/RecordDirectory.h" +#include "layer/Layer.h" +#include "transform/ModelTransformerFactory.h" +#include "widgets/InteractiveFileFinder.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +class TestDevChecks : public QObject +{ + Q_OBJECT + + static constexpr double rate = 44100.0; + + // The rate the user's phone records and plays at + static constexpr double phoneRate = 48000.0; + // A device at another rate, as WASAPI's mixer often runs + static constexpr double otherRate = 48000.0; + + // What the device reports, and what the round trip really is: as + // TestAudioCheck's, 123 frames (2.8 ms) more than reported + static constexpr int reportedOut = 2 * 4096; + static constexpr int reportedIn = 4096; + static constexpr int roundTrip = 3 * 4096 + 123; + + // How far the fake's input moves against its output each time its + // stream starts again: 10 ms, as TestAudioCheck's + static constexpr int restartShift = 441; + + // The long song of the passing run: a quarter of the real one, long + // enough that its analysis takes well over twice a punch-in's + static constexpr double longSeconds = 60.0; + + QTemporaryDir m_dir; + TestMainWindow *m_window = nullptr; + QTimer m_watchdog; + QStringList m_dialogs; + + // What the dev checks said when the run ended, and how often; the + // stages they began; and every result of the runner's + DevReport m_report; + int m_finished = 0; + QStringList m_stages; + std::vector m_checks; + + // How often a test's fault was put in, and how many event loops of + // a dialog's were run (dev_checks_see_a_dialog_qt_does_not_draw()) + int m_faults = 0; + int m_loops = 0; + + // A stall of the GUI thread during the re-recording's lead-in + // (stallTheReRecording()): watched for from the runner's first + // report that it records; from where in the reference it is due, in + // seconds (negative for none), and how long; how often it came, and + // where playback was as it began and as it ended + QTimer m_stallWatch; + double m_stallAt = -1.0; + int m_stallMs = 0; + int m_stalls = 0; + double m_stalledFrom = 0.0; + double m_stalledTo = 0.0; + + void makeWindow(FakeAudioIO::Config config) { + delete m_window; + m_window = new TestMainWindow(config); + m_report = DevReport(); + m_finished = 0; + m_stages.clear(); + m_checks.clear(); + m_faults = 0; + m_loops = 0; + m_stallWatch.stop(); + m_stallAt = -1.0; + m_stallMs = 0; + m_stalls = 0; + connect(m_window->devChecks(), &DevChecks::finished, + this, [this](const DevReport &report) { + m_report = report; + ++m_finished; + }); + connect(m_window->devChecks(), &DevChecks::progress, + this, [this](QString stage, int n, int of) { + m_stages << QString("%1 of %2: %3").arg(n).arg(of) + .arg(stage); + }); + connect(m_window->audioCheck(), &AudioCheckRunner::finished, + this, [this](const AudioCheckResult &result) { + m_checks.push_back(result); + }); + } + + static FakeAudioIO::Config loopback() { + FakeAudioIO::Config config; + config.playbackLatency = reportedOut; + config.recordLatency = reportedIn; + config.inputDelay = roundTrip; + config.loopback = true; + config.reportLevels = true; + return config; + } + + // The loopback with a room's noise on the input, at -60 dBFS: the + // takes then hold something where the reference is silent, as they + // do on a real device, so that a take played back out shows in the + // output there. Too quiet for the sweep finder, and no pitch for the + // live tracker or pYIN. And all along, the fans the user's room had: + // a steady 306 Hz at -66 dBFS, in which YIN alone finds a pitch, and + // the live tracker's level floor none + static FakeAudioIO::Config loopbackInARoom() { + FakeAudioIO::Config config = loopback(); + config.input = TestSignals::whiteNoise(int(10 * rate), 1, 0.001); + config.humHz = 306.0; + config.humGain = float(std::pow(10.0, -66.0 / 20.0) * std::sqrt(2.0)); + return config; + } + + // The same in the shape of the user's phone as OboeAudioIO opens it: + // at 48 kHz, the reference being at 44.1; one input channel, the + // phone's microphone, and two output channels; and the route, as + // TestAudioCheck's phoneRoute() has it. The latencies and the delay + // count the device's frames + static FakeAudioIO::Config phoneInARoom() { + FakeAudioIO::Config config = loopbackInARoom(); + config.sampleRate = int(phoneRate); + config.inputChannels = 1; + AudioRoute::Route &route = config.route; + route.driver = "oboe"; + route.output.id = 3; + route.output.type = 2; + route.output.productName = "Pixel 7"; + route.hasInput = true; + route.input.id = 7; + route.input.type = 15; + route.input.productName = "Pixel 7"; + route.rate = phoneRate; + route.outputStreams = + "AAudio (MMAP), 48000 Hz, Exclusive, burst 96, buffer 192 of 1920"; + route.inputStreams = "AAudio (MMAP), 48000 Hz, Exclusive, burst 96, " + "buffer 11424 of 11520, preset VoicePerformance"; + return config; + } + + QString reportDirectory() { return m_dir.filePath("report"); } + QString scratchDirectory() { return m_dir.filePath("scratch"); } + + // With the long song, unless a length of 0 leaves it out + DevChecks::Options options(double roundTripSeconds, + double longSongSeconds = longSeconds) { + DevChecks::Options o; + o.roundTrip = roundTripSeconds; + o.longSeconds = longSongSeconds; + o.reportDirectory = reportDirectory(); + o.scratchDirectory = scratchDirectory(); + return o; + } + + // TestAudioCheck's short calibration, with its reference where the + // check writes one of its own: in the application's data directory + AudioCheckRunner::Plan shortPlan() { + AudioCheckRunner::Plan plan; + plan.layout = LatencyCheck::calibrationLayout(); + plan.layout.length = plan.layout.events[5].sweepStart; + plan.layout.events.resize(5); + plan.punchIns = 2; + plan.eventsEach = 2; + return plan; + } + + void startDevChecks(double roundTripSeconds, + double longSongSeconds = longSeconds) { + m_window->discardModifications(); + QVERIFY(m_window->devChecks()->start(options(roundTripSeconds, + longSongSeconds))); + QVERIFY(m_window->devChecks()->isRunning()); + } + + void runDevChecks(double roundTripSeconds, + double longSongSeconds = longSeconds) { + startDevChecks(roundTripSeconds, longSongSeconds); + if (QTest::currentTestFailed()) return; + QTRY_VERIFY_WITH_TIMEOUT(m_finished > 0, 120000); + QCOMPARE(m_finished, 1); + QVERIFY(!m_window->devChecks()->isRunning()); + QVERIFY(!m_window->audioCheck()->isRunning()); + } + + const CheckResult *check(int item) const { + for (const CheckResult &c : m_report.checks) { + if (c.item == item) return &c; + } + return nullptr; + } + + static QString number(const CheckResult &c, QString label) { + for (const auto &n : c.numbers) { + if (n.first == label) return n.second; + } + return QString(); + } + + // "-20.1 ms" as -20.1; NaN for anything else + static double milliseconds(QString text) { + bool ok = false; + double ms = text.endsWith(" ms") ? text.chopped(3).toDouble(&ok) : 0.0; + return ok ? ms : std::nan(""); + } + + // Item 3's "279: 262 on the tones, 12 at edges, 5 on the sweeps, 0 + // elsewhere" as 279; -1 for anything else + static int dotCount(QString text) { + bool ok = false; + int n = text.section(':', 0, 0).toInt(&ok); + return ok ? n : -1; + } + + QString reportText() { + QFile file(m_report.reportPath); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) return {}; + return QString::fromUtf8(file.readAll()); + } + + QString lastReportLine() { + const QStringList lines = reportText().trimmed().split('\n'); + return lines.isEmpty() ? QString() : lines.last(); + } + + // Every check, or only the item given: QtTest cuts a long message + QByteArray describe(int item = 0) { + QStringList words; + words << "failure: " + m_report.failure; + for (const CheckResult &c : m_report.checks) { + if (item > 0 && c.item != item) continue; + QStringList numbers; + for (const auto &n : c.numbers) numbers << n.first + ": " + n.second; + words << QString("item %1 %2 %3 (%4) [%5]").arg(c.item).arg(c.name) + .arg(CheckResult::verdictName(c.verdict)).arg(c.message) + .arg(numbers.join("; ")); + } + return words.join(" | ").toUtf8(); + } + + // The three toggles of the take path, and the settings they and the + // pre-roll's length are kept in + QStringList toggles() { + QSettings settings; + settings.beginGroup("MainWindow"); + QStringList state; + state << QString("play reference %1, setting %2") + .arg(m_window->playReferenceWhileRecordingAction()->isChecked()) + .arg(settings.value("playrefwhilerecording").toString()); + state << QString("pre-roll %1, setting %2, %3 s") + .arg(m_window->preRollAction()->isChecked()) + .arg(settings.value("preroll").toString()) + .arg(settings.value("prerollseconds").toString()); + state << QString("record into selection %1, setting %2") + .arg(m_window->recordIntoSelectionAction()->isChecked()) + .arg(settings.value("recordintoselection").toString()); + settings.endGroup(); + return state; + } + + // Every measured round trip kept, as the settings hold it + static QStringList storedLatency() { + QSettings settings; + settings.beginGroup("LatencyCalibration"); + QStringList all; + for (const QString &key : settings.allKeys()) { + all << key + "=" + settings.value(key).toString(); + } + settings.endGroup(); + all.sort(); + return all; + } + + // A round trip of 300 ms kept for the fake device, as Use this + // latency keeps one, and the user's toggles set otherwise than the + // check sets them for its takes + void setUserState() { + LatencyCalibration::InUse reported = m_window->latencyInUse(); + LatencyCalibration::Figure figure; + figure.roundTrip = 0.3; + figure.date = QDateTime::currentDateTimeUtc(); + figure.reportedOutput = reported.reportedOutput; + figure.reportedInput = reported.reportedInput; + LatencyCalibration::Key key; + key.rate = rate; + QSettings settings; + LatencyCalibration::store(settings, key, figure); + settings.setValue("MainWindow/prerollseconds", 3.0); + m_window->setPlayReferenceWhileRecording(false); + m_window->setPreRoll(true); + m_window->setRecordIntoSelection(false); + } + + // A run ended early: finished once, every check skipped, nothing + // recording, and none of the user's state changed + void verifyEndedEarly(QStringList togglesBefore, + QStringList storedBefore) { + QCOMPARE(m_finished, 1); + QVERIFY(m_report.failure != ""); + QVERIFY(!m_window->devChecks()->isRunning()); + QVERIFY(!m_window->audioCheck()->isRunning()); + QVERIFY(!m_window->recordTarget()->isRecording()); + QVERIFY(!m_window->audioCheckTakes()); + QCOMPARE(int(m_report.checks.size()), 11); + for (const CheckResult &c : m_report.checks) { + QVERIFY2(c.verdict == CheckResult::Verdict::Skipped, describe()); + QVERIFY2(c.message.contains(m_report.failure), describe()); + } + QCOMPARE(lastReportLine(), + QString("Totals: 0 passed, 0 failed, 0 measured, 11 skipped")); + + QTest::qWait(500); + QCOMPARE(m_finished, 1); + QVERIFY(!m_window->recordTarget()->isRecording()); + QCOMPARE(toggles(), togglesBefore); + QCOMPARE(storedLatency(), storedBefore); + } + + // The GUI thread held up, busy, for ms, as a busy system can hold a + // window up: no timer fires meanwhile, so the observer takes no + // look. It begins once the re-recording has played the reference up + // to "at", in seconds. By default over the reference's silent gap + // just before the punch-in (18.8 to 19.2 s): no look there lies + // wholly in the gap + void stallTheReRecording(double at = DevChecks::reRecording().start - 0.45, + int ms = 450) { + m_stallAt = at; + m_stallMs = ms; + connect(m_window->audioCheck(), &AudioCheckRunner::progress, + this, [this](const AudioCheckRunner::Progress &state) { + // Reported again as the seconds left go down + if (state.step == AudioCheckRunner::Step::Recording && + m_stalls == 0 && !m_stallWatch.isActive() && + !m_stages.isEmpty() && + m_stages.last().endsWith(": Re-record")) { + m_stallWatch.start(); + } + }); + } + + // Not a slot: QtTest would run it as a test + void stallIfDue() { + sv::AudioCallbackRecordTarget *target = + m_window ? m_window->recordTarget() : nullptr; + if (m_stallAt < 0.0 || !target || !target->isRecording()) return; + + // Where the reference being handed out is: where playback + // started, and the frames come in since, which the audio thread + // counts. The record duration, which the cursor goes by, is + // counted on this thread, and would stand still during the stall + const sv::sv_frame_t start = + m_window->playbackFrame() - target->getRecordDuration(); + auto played = [&]() { + return double(start + target->getFramesReceived()) / rate; + }; + const double at = played(); + if (at < m_stallAt) return; + m_stallWatch.stop(); + QElapsedTimer held; + held.start(); + while (held.elapsed() < m_stallMs) { } + m_stalledFrom = at; + m_stalledTo = played(); + ++m_stalls; + } + + // Not a slot: QtTest would run it as a test. As TestAudioCheck's + void dismissDialog() { + QWidget *modal = QApplication::activeModalWidget(); + if (!modal) return; + QString description = modal->windowTitle(); + if (auto box = qobject_cast(modal)) { + description += ": " + box->text(); + m_dialogs.push_back(description); + QList buttons = box->buttons(); + if (!buttons.isEmpty()) { + buttons.last()->click(); + return; + } + } else { + m_dialogs.push_back(description); + } + if (auto dialog = qobject_cast(modal)) { + dialog->reject(); + } else { + modal->close(); + } + } + +private slots: + void initTestCase() { + QVERIFY(m_dir.isValid()); + + // The check's references go where Qt keeps the tests' data + QStandardPaths::setTestModeEnabled(true); + + QSettings().clear(); + + // Otherwise the MainWindow constructor asks, in a dialog + QSettings settings; + settings.beginGroup("Preferences"); + settings.setValue(QString("network-permission-%1").arg(TONY_VERSION), + false); + settings.endGroup(); + + sv::InteractiveFileFinder::getInstance() + ->setApplicationSessionExtension("ton"); + + // Recordings and takes go here and not among the user's + sv::RecordDirectory::setRecordContainerDirectory + (m_dir.filePath("recorded")); + + connect(&m_watchdog, &QTimer::timeout, + this, [this]() { dismissDialog(); }); + m_watchdog.start(50); + + m_stallWatch.setInterval(5); + connect(&m_stallWatch, &QTimer::timeout, + this, [this]() { stallIfDue(); }); + } + + void init() { + m_dialogs.clear(); + QSettings settings; + settings.beginGroup("MainWindow"); + settings.setValue("playrefwhilerecording", false); + settings.setValue("preroll", false); + settings.setValue("recordintoselection", false); + settings.remove("prerollseconds"); + settings.endGroup(); + settings.beginGroup("Analyser"); + settings.remove(""); + settings.endGroup(); + settings.beginGroup("Preferences"); + settings.remove("audio-playback-device"); + settings.remove("audio-record-device"); + settings.endGroup(); + SingingTakes::setOverwriteConfirmationWanted(true); + } + + void cleanup() { + QSettings().remove("LatencyCalibration"); + m_stallWatch.stop(); + + if (m_window) { + if (m_window->recordTarget()->isRecording()) { + m_window->doRecord(); + } + QTRY_VERIFY_WITH_TIMEOUT + (!sv::ModelTransformerFactory::getInstance() + ->haveRunningTransformers(), 30000); + m_window->doCloseSession(); + delete m_window; + m_window = nullptr; + } + QVERIFY2(m_dialogs.isEmpty(), + qPrintable("unexpected dialog: " + m_dialogs.join(" | "))); + } + + void cleanupTestCase() { + m_watchdog.stop(); + sv::RecordDirectory::setRecordContainerDirectory(""); + QStandardPaths::setTestModeEnabled(false); + } + + // Each run gets a folder of its own, and the folders of earlier runs + // go, but the one the session open now lives in, and anything not + // named as they are + void dev_checks_scratch_folders() { + QTemporaryDir dir; + QVERIFY(dir.isValid()); + const QString one = dir.filePath("dev-checks-1"); + const QString two = dir.filePath("dev-checks-2"); + + QCOMPARE(DevChecks::nextScratchFolder(dir.path(), ""), one); + QVERIFY(QFileInfo(one).isDir()); + { + QFile session(QDir(one).filePath("dev-checks.ton")); + QVERIFY(session.open(QIODevice::WriteOnly)); + } + QVERIFY(QDir().mkpath(dir.filePath("dev-checks-x"))); + QVERIFY(QDir().mkpath(dir.filePath("mine"))); + + // With the first run's session open + const QString session = QDir(one).filePath("dev-checks.ton"); + QCOMPARE(DevChecks::nextScratchFolder(dir.path(), session), two); + QVERIFY(QFileInfo::exists(session)); + + // With the second's, whose folder holds a takes folder + QVERIFY(QDir().mkpath(QDir(two).filePath("dev-checks.takes"))); + QCOMPARE(DevChecks::nextScratchFolder + (dir.path(), QDir(two).filePath("dev-checks.ton")), one); + QVERIFY(!QFileInfo::exists(session)); + QVERIFY(QFileInfo(QDir(two).filePath("dev-checks.takes")).isDir()); + QCOMPARE(QDir(dir.path()).entryList(QDir::Dirs | QDir::NoDotAndDotDot, + QDir::Name), + QStringList() << "dev-checks-1" << "dev-checks-2" + << "dev-checks-x" << "mine"); + } + + // The loopback fake in a room, placed with its true round trip: every + // sweep of every punch-in lands within item 1's 6 ms (the fake within + // a frame or two), the session saved and + // opened again holds the same take, and each punch-in measured its own + // start gap. The re-recording changed nothing outside its range and + // nothing before it, and nothing of the take was heard during its + // lead-in; the punch-in near the start played from the start of the + // song; both stopped by themselves. Stop on the long song analysed + // only the ranges; the joins have no step and the pitch runs through + // them, but the note does not (expected to fail, a defect of the + // notes merge). The report ends with its totals, + // and the session open afterwards is the one saved in the scratch + // folder + void dev_checks_pass_with_the_true_round_trip() { + makeWindow(loopbackInARoom()); + + runDevChecks(roundTrip / rate); + if (QTest::currentTestFailed()) return; + + // Line by line, so that none begins a line of the suite's log: + // its Totals would be taken for the suite's + for (const QString &line : reportText().split('\n')) { + qDebug().noquote() << "report:" << line; + } + QVERIFY2(m_report.failure == "", describe()); + QCOMPARE(int(m_report.checks.size()), 11); + const CheckResult *latency = check(1); + const CheckResult *phrases = check(2); + QVERIFY(latency && phrases); + QCOMPARE(latency->name, QString("latency_on_this_machine")); + QCOMPARE(phrases->name, QString("several_phrases_in_one_take")); + QVERIFY2(latency->verdict == CheckResult::Verdict::Pass, describe()); + QVERIFY2(phrases->verdict == CheckResult::Verdict::Pass, describe()); + QCOMPARE(number(*latency, "round trip used"), + QString("%1 ms").arg(roundTrip * 1000.0 / rate, 0, 'f', 1)); + QVERIFY2(number(*latency, "pitch after reopening") + .endsWith("pitch events, the same"), describe()); + + // What was seen of each punch-in: the live dots on the + // reference's sounds, and behind the cursor by the round trip at + // least, since the cursor runs with what has been recorded; + // nothing played where the reference is silent, though the + // reference itself was heard; and the loopback on both inputs + const CheckResult *dots = check(3); + const CheckResult *speakers = check(4); + const CheckResult *mic = check(5); + QVERIFY(dots && speakers && mic); + QCOMPARE(dots->name, QString("live_dots")); + QCOMPARE(speakers->name, + QString("nothing_of_the_take_in_the_speakers")); + QCOMPARE(mic->name, QString("mic_on_input_2")); + QVERIFY2(dots->verdict == CheckResult::Verdict::Pass, describe()); + QVERIFY2(speakers->verdict == CheckResult::Verdict::Pass, describe()); + QVERIFY2(mic->verdict == CheckResult::Verdict::Measured, describe()); + for (QString label : { QString("dots, punch-in 1 (6.30 to 10.20 s)"), + QString("dots, punch-in 2 (16.80 to 21.20 " + "s)") }) { + QVERIFY2(dotCount(number(*dots, label)) > DevChecks::kMinDots, + describe()); + QVERIFY2(number(*dots, label).endsWith(", 0 elsewhere"), + describe()); + } + QVERIFY2(milliseconds(number(*dots, "dots behind the cursor, median")) + >= roundTrip * 1000.0 / rate, describe()); + QCOMPARE(number(*speakers, "second arrival"), QString("none heard")); + const QString gaps = + number(*speakers, "largest output level in the silent gaps"); + QVERIFY2(gaps.startsWith("silence, over ") && + !gaps.endsWith(" 0 looks"), describe()); + QCOMPARE(number(*speakers, "largest output level"), + QString("-12.0 dBFS")); + QVERIFY2(number(*mic, "the mic is on").startsWith("inputs 1 and 2, "), + describe()); + QVERIFY2(mic->message.startsWith("Not applicable here"), describe()); + + // What the device says of itself, at the head of the report, and + // the driver it was opened through with the latency asked of it + for (QString words : { QString("Audio driver: (auto)\n"), + QString("Latency asked for: 200.0 ms\n"), + QString("Audio drivers built in: "), + QString("Playback latency reported: 8192 " + "frames (185.8 ms)"), + QString("Record latency reported: 4096 " + "frames (92.9 ms)") }) { + QVERIFY2(reportText().contains(words), qPrintable(words)); + } + + QCOMPARE(m_stages, QStringList() << "1 of 6: Long song" + << "2 of 6: Fresh punch-ins" << "3 of 6: Re-record" + << "4 of 6: Pre-roll near the start" << "5 of 6: Joins" + << "6 of 6: Save and reopen"); + + // Two punch-ins far apart into the long song, each judging one + // sweep; two into a reference of the dev layout, recorded in the + // order given; then one over the end of the second, which judges + // the sweep at 20.1 s; then one near the start, which judges the + // sweep at 3.1 s; then two that meet at 28.7 s, judging the sweeps + // at 26.9 and 30.9 s. Items 1 and 2 count all eight, numbered + // along the run + QCOMPARE(int(m_checks.size()), 5); + for (int i : { 0, 1, 4 }) { + const LatencyCheck::TakeSummary &s = m_checks[i].summary; + QCOMPARE(int(s.punchIns.size()), 2); + QCOMPARE(s.judged, i == 1 ? 4 : 2); + QCOMPARE(s.found, s.judged); + } + for (int i : { 2, 3 }) { + QCOMPARE(int(m_checks[i].summary.punchIns.size()), 1); + QCOMPARE(m_checks[i].summary.judged, 1); + QCOMPARE(m_checks[i].summary.found, 1); + } + QVERIFY(std::fabs(m_checks[2].summary.events[0].expectedSeconds - + 20.1) < 1e-4); + QVERIFY(std::fabs(m_checks[3].summary.events[0].expectedSeconds - + 3.1) < 1e-4); + QVERIFY(std::fabs(m_checks[4].summary.events[1].expectedSeconds - + 30.9) < 1e-4); + + // The long song's sweeps: the first from a quarter of the way in, + // and the first from five eighths + const LatencyCheck::TakeSummary &song = m_checks[0].summary; + for (int i : { 0, 1 }) { + const double from = (i == 0 ? 0.25 : 0.625) * longSeconds; + const double at = song.events[i].expectedSeconds; + QVERIFY2(at >= from && at < from + 2.6, + qPrintable(QString::number(at))); + } + const LatencyCheck::PunchIn first = song.punchIns[0].range; + for (QString label : { QString("offsets, punch-in 1 (%1 to %2 s)") + .arg(first.start, 0, 'f', 2) + .arg(first.end, 0, 'f', 2), + QString("offsets, punch-in 5 (19.20 to 21.20 " + "s)"), + QString("offsets, punch-in 6 (1.00 to 4.20 " + "s)"), + QString("offsets, punch-in 8 (28.70 to 32.00 " + "s)") }) { + QVERIFY2(number(*latency, label) != "", describe()); + } + QVERIFY2(number(*phrases, "punch-in 8, start gap").endsWith("measured"), + describe()); + + const CheckResult *position = check(7); + const CheckResult *leadIn = check(12); + const CheckResult *nearStart = check(13); + const CheckResult *stops = check(14); + QVERIFY(position && leadIn && nearStart && stops); + QCOMPARE(position->name, QString("record_from_a_position")); + QCOMPARE(leadIn->name, + QString("nothing_heard_or_changed_in_the_lead_in")); + QCOMPARE(nearStart->name, QString("pre_roll_near_the_start")); + QCOMPARE(stops->name, + QString("record_into_selection_stops_by_itself")); + for (const CheckResult *c : { position, leadIn, nearStart, stops }) { + QVERIFY2(c->verdict == CheckResult::Verdict::Pass, describe()); + } + + // Outside the re-recording's range, and before it, the take as it + // was, and something there to compare + QCOMPARE(number(*position, "audio outside the range"), + QString("the same, bit for bit")); + QCOMPARE(number(*leadIn, "audio before 19.20 s"), + QString("the same, bit for bit")); + for (const CheckResult *c : { position, leadIn }) { + for (QString label : { QString("pitch"), QString("notes") }) { + const QString words = number(*c, label); + QVERIFY2(words.endsWith(", unchanged") && + words.section(' ', 0, 0).toInt() > 0, describe()); + } + } + + // The take under the lead-in holds the room's noise where the + // reference is silent, and none of it was heard + const QString leadInGaps = + number(*leadIn, "output in the lead-in's silent gaps"); + QVERIFY2(leadInGaps.startsWith("silence, over ") && + !leadInGaps.endsWith(" 0 looks"), describe()); + + // A lead-in of all the 1 s there is before the punch-in, from the + // start of the song, counted down from 2: 1 s and the round trip + // of 0.28 s, with the start gap. For the moment before the round + // trip is known the count is of the lead-in alone, 1 + QCOMPARE(number(*nearStart, "lead-in"), + QString("1.00 s, of the 3.00 s asked for")); + QCOMPARE(number(*nearStart, "playback from"), QString("0.00 s")); + QCOMPARE(number(*nearStart, "cursor, lowest"), QString("0.00 s")); + QVERIFY2(QRegularExpression("^(1, )?2, 1; at most 2, ") + .match(number(*nearStart, "countdown")).hasMatch(), + describe()); + + // Each stopped within a look of the take timer of when it could, + // and the one near the start added its range to the take + QVERIFY2(number(*stops, "coverage after, 1.00 to 4.20 s") == + "1.00 to 4.20 s, 6.30 to 10.20 s, 16.80 to 21.20 s", + describe()); + QVERIFY2(number(*stops, "dialogs, 19.20 to 21.20 s") + .startsWith("none, over "), describe()); + + // Stop on the long song analysed each punch-in's range alone, in + // under half the time the whole song's analysis took, and the + // second left the first's pitch as it was + const CheckResult *longSong = check(9); + QVERIFY(longSong); + QCOMPARE(longSong->name, QString("stop_on_a_long_song")); + QVERIFY2(longSong->verdict == CheckResult::Verdict::Pass, describe()); + const QString kept = number(*longSong, "pitch, punch-in 2"); + QVERIFY2(kept.endsWith(", unchanged") && + kept.section(' ', 0, 0).toInt() > 0, describe()); + + // The punch-ins that meet inside the held tone, placed alike: no + // step in the samples there, the pitch running through, and + // nothing moved outside the two + const CheckResult *joins = check(10); + QVERIFY(joins); + QCOMPARE(joins->name, QString("the_joins")); + QCOMPARE(number(*joins, "second punch-in against the first"), + QString("0.0 ms")); + for (QString part : { QString("step: "), QString("pitch: "), + QString("outside: ") }) { + QVERIFY2(!joins->message.contains(part), describe()); + } + + // And one note through the join. The second punch-in's analysis + // starts 0.5 s before the join, inside the tone, so its note begins + // before the merge window: the first's note, which ended at the + // join, is that note going on and takes its end + QVERIFY2(joins->verdict == CheckResult::Verdict::Pass, describe()); + + QVERIFY(QFileInfo(m_report.reportPath).fileName() == "DevChecks.txt"); + QVERIFY(TakesFile::isInFolder(reportDirectory(), m_report.reportPath)); + QCOMPARE(lastReportLine(), + QString("Totals: 10 passed, 0 failed, 1 measured, 0 skipped")); + + // The take had pitch wherever a check compares it: every part was + // judged (dev_checks_without_pitch() has the other way). Not + // item 3, which leaves the dots at a sound's onset unjudged + // whatever the take holds + for (const CheckResult &c : m_report.checks) { + if (c.item == 3) continue; + QVERIFY2(!c.message.contains("not judged"), describe(c.item)); + } + + QVERIFY(m_report.sessionPath != ""); + QCOMPARE(m_window->sessionFile(), m_report.sessionPath); + QVERIFY2(TakesFile::isInFolder(scratchDirectory(), m_report.sessionPath), + qPrintable(m_report.sessionPath)); + QVERIFY2(TakesFile::isInFolder + (TakesFile::takesFolder(m_report.sessionPath), + m_window->takes()->getAudioPath()), + qPrintable(m_window->takes()->getAudioPath())); + QVERIFY(!m_window->isDocumentModified()); + QVERIFY(!m_window->audioCheckTakes()); + QVERIFY(m_window->recordAction()->isEnabled()); + } + + // A whole run on a device at 48 kHz against references at 44.1, as + // WASAPI's runs were: the takes are recorded at the device's rate + // and converted as they are spliced, and each check counts every + // figure at its own rate. The fake's delay counts its own frames, so + // the true round trip is roundTrip frames at 48 kHz; bqaudioio's + // ResamplerWrapper holds the output back about 1.1 ms more, which + // nothing reports, and the sweeps land that late, within item 1's + // 6 ms. Every item passes. Item 14 finds each take stopping about as + // far past its selection as at 44.1 kHz, where it reads 0.25 to 0.35 + // s: with the round trip in the device's frames added to the lead-in + // and the selection in the reference's, it read 0.34 s more, and + // failed, as it did on the user's runs + void dev_checks_pass_at_48000() { + FakeAudioIO::Config config = loopbackInARoom(); + config.sampleRate = int(otherRate); + makeWindow(config); + + runDevChecks(roundTrip / otherRate); + if (QTest::currentTestFailed()) return; + + QVERIFY2(m_report.failure == "", describe()); + QCOMPARE(int(m_checks.size()), 5); + for (const AudioCheckResult &r : m_checks) { + QCOMPARE(r.recordingRate, otherRate); + QCOMPARE(r.referenceRate, rate); + } + QCOMPARE(int(m_report.checks.size()), 11); + for (const CheckResult &c : m_report.checks) { + // Item 5 is measured only: the loopback is on both inputs + const CheckResult::Verdict expected = (c.item == 5 ? + CheckResult::Verdict::Measured : CheckResult::Verdict::Pass); + QVERIFY2(c.verdict == expected, describe(c.item)); + } + QCOMPARE(lastReportLine(), + QString("Totals: 10 passed, 0 failed, 1 measured, 0 skipped")); + + const CheckResult *stops = check(14); + QVERIFY(stops); + for (QString range : { QString("19.20 to 21.20 s"), + QString("1.00 to 4.20 s") }) { + const QString words = number(*stops, "stopped, " + range); + bool ok = false; + const double past = words.section(' ', 0, 0).toDouble(&ok); + QVERIFY2(ok && past >= 0.2 && past <= 0.45, + qPrintable(range + ": " + words)); + } + } + + // The same with a round trip 20 ms too long: every take is spliced + // from 20 ms too late, and lands 20 ms early. Items 1, 2, 7 and 13, + // which ask where it landed, fail, and the report shows where the + // sweeps landed + void dev_checks_fail_with_the_round_trip_off() { + makeWindow(loopback()); + + runDevChecks(roundTrip / rate + 0.020, 0.0); + if (QTest::currentTestFailed()) return; + + QVERIFY2(m_report.failure == "", describe()); + const CheckResult *latency = check(1); + const CheckResult *phrases = check(2); + QVERIFY(latency && phrases); + QVERIFY2(latency->verdict == CheckResult::Verdict::Fail, describe()); + QVERIFY2(phrases->verdict == CheckResult::Verdict::Fail, describe()); + + const QString largest = number(*latency, "largest offset"); + QVERIFY2(std::fabs(milliseconds(largest) + 20.0) <= 1.0, describe()); + for (QString label : { QString("punch-in 1, median offset"), + QString("punch-in 2, median offset") }) { + QVERIFY2(std::fabs(milliseconds(number(*phrases, label)) + 20.0) + <= 1.0, describe()); + } + + // What the save and reopen kept is still right: only the placing + QCOMPARE(number(*latency, "offsets after reopening"), + QString("the same")); + + const QString text = reportText(); + for (QString words : { QString("offsets, punch-in 1 (6.30 to 10.20 " + "s): "), + QString("offsets, punch-in 2 (16.80 to 21.20 " + "s): "), + "largest offset: " + largest, + "punch-in 1, median offset: " + + number(*phrases, "punch-in 1, median offset"), + QString("latency_on_this_machine: FAIL"), + QString("several_phrases_in_one_take: FAIL") }) { + QVERIFY2(text.contains(words), qPrintable(words + " not in:\n" + + text)); + } + + // The re-recording and the punch-in near the start landed 20 ms + // early as well, and fail for that alone + for (int item : { 7, 13 }) { + const CheckResult *c = check(item); + QVERIFY2(c && c->verdict == CheckResult::Verdict::Fail, + describe()); + QVERIFY2(c->message.startsWith("the sweep at ") && + c->message.contains(" landed -") && + !c->message.contains(";"), describe()); + QVERIFY2(std::fabs(milliseconds(number(*c, "offsets")) + 20.0) + <= 1.0, describe()); + } + + // Items 4, 5, 12 and 14 do not depend on where the take is + // placed. The dots are 20 ms early too, which is about as far as + // item 3 lets them be: whether they pass depends on where the + // tracker's hops fall + for (int item : { 4, 12, 14 }) { + QVERIFY2(check(item) && + check(item)->verdict == CheckResult::Verdict::Pass, + describe()); + } + QVERIFY2(check(5) && + check(5)->verdict == CheckResult::Verdict::Measured, + describe()); + const bool dotsPass = + check(3) && check(3)->verdict == CheckResult::Verdict::Pass; + + // Without the long song, which this run leaves out; the joins as + // in any run, placed alike + QVERIFY2(check(9) && + check(9)->verdict == CheckResult::Verdict::Skipped && + check(9)->message == "The long song was left out of this " + "run.", describe()); + QVERIFY2(check(10) && + check(10)->verdict == CheckResult::Verdict::Pass, + describe()); + QCOMPARE(lastReportLine(), + QString("Totals: %1 passed, %2 failed, 1 measured, 1 skipped") + .arg(dotsPass ? 5 : 4).arg(dotsPass ? 4 : 5)); + } + + // A room as loud as the reference (white noise at -19 dBFS RMS on the + // input, a take long): the finder still finds every sweep where it + // is, but neither pYIN nor the live tracker finds any pitch, as on + // the phone whose speaker played none of the tones. The parts of + // items 1, 7, 9, 10 and 12 that compare pitch have nothing to judge + // and say so, and the verdict is the rest's, which pass; item 3, + // whose whole point is the dots, fails + void dev_checks_without_pitch() { + FakeAudioIO::Config config = loopback(); + config.input = TestSignals::whiteNoise(int(12 * rate), 1, 0.2); + makeWindow(config); + + runDevChecks(roundTrip / rate); + if (QTest::currentTestFailed()) return; + + for (const QString &line : reportText().split('\n')) { + qDebug().noquote() << "report:" << line; + } + QVERIFY2(m_report.failure == "", describe()); + QCOMPARE(int(m_report.checks.size()), 11); + for (int item : { 1, 7, 9, 10, 12 }) { + const CheckResult *c = check(item); + QVERIFY2(c && c->verdict == CheckResult::Verdict::Pass && + c->message.contains("not judged"), describe(item)); + } + QVERIFY2(check(1)->message.contains("gives the same offsets. Its " + "pitch and notes after reopening " + "were not judged: "), describe(1)); + QVERIFY2(check(9)->message.contains("The take's pitch outside the " + "punch-ins was not judged: "), + describe(9)); + for (QString part : { QString("pitch: not judged, "), + QString("note: not judged, "), + QString("outside: the take's pitch and notes " + "not judged, ") }) { + QVERIFY2(check(10)->message.contains(part), describe(10)); + } + QVERIFY2(check(10)->message.startsWith("Two punch-ins meeting at " + "28.70 s, in the middle of a " + "held tone, left no step in " + "the samples there, and passed " + "every part that could be " + "judged."), describe(10)); + for (int item : { 7, 12 }) { + QVERIFY2(check(item)->message.contains + ("audio is the same bit for bit") && + check(item)->message.contains + ("Its pitch and notes ") && + check(item)->message.contains + (" were not judged: the take had no pitch there to " + "compare."), describe(item)); + } + // What the lead-in played was judged, and the pitch was not + QVERIFY2(check(12)->message.contains("Tony played nothing where the " + "reference is silent. Its pitch " + "and notes before the punch-in " + "were not judged"), describe(12)); + + // Nothing to judge elsewhere either, and nothing wrong + for (int item : { 2, 4, 13, 14 }) { + const CheckResult *c = check(item); + QVERIFY2(c && c->verdict == CheckResult::Verdict::Pass && + !c->message.contains("not judged"), describe(item)); + } + QVERIFY2(check(3) && check(3)->verdict == CheckResult::Verdict::Fail, + describe(3)); + QCOMPARE(lastReportLine(), + QString("Totals: 9 passed, 1 failed, 1 measured, 0 skipped")); + } + + // A device whose input moves 10 ms against its output each time its + // stream starts, with the stream kept running between takes, as the + // application keeps it: started once, at the run's first take, so + // that every take shares one alignment, and every item passes. + // Suspended at each Stop, as svapp does unless told + // otherwise, the takes land 10 ms apart in turn, and items 1, 2, 7 + // and 13 fail. The round trip is 4 ms more than the true one, as a + // calibration leaves it when a stream on two sound cards has drifted + // since: every take lands 4 ms early, within those items' 6 ms (with + // the 2 ms they had, they fail) + void dev_checks_pass_with_the_stream_kept_running() { + FakeAudioIO::Config config = loopbackInARoom(); + config.restartShift = restartShift; + makeWindow(config); + m_window->keepAudioRunning(true); + + runDevChecks(roundTrip / rate + 0.004); + if (QTest::currentTestFailed()) return; + + QVERIFY2(m_report.failure == "", describe()); + QCOMPARE(int(m_checks.size()), 5); + QCOMPARE(int(m_report.checks.size()), 11); + for (const CheckResult &c : m_report.checks) { + // Item 5 is measured only: the loopback is on both inputs + const CheckResult::Verdict expected = (c.item == 5 ? + CheckResult::Verdict::Measured : CheckResult::Verdict::Pass); + QVERIFY2(c.verdict == expected, describe(c.item)); + } + QCOMPARE(lastReportLine(), + QString("Totals: 10 passed, 0 failed, 1 measured, 0 skipped")); + QVERIFY(check(1)); + const double largest = + milliseconds(number(*check(1), "largest offset")); + QVERIFY2(largest > -5.0 && largest < -3.0, describe(1)); + QVERIFY(check(10)); + QCOMPARE(number(*check(10), "second punch-in against the first"), + QString("0.0 ms")); + QCOMPARE(m_window->fake()->getResumeCount(), 1); + QVERIFY(!m_window->fake()->isSuspended()); + } + + // The loopback heard a second time, 50 ms later at half the level, as + // a mic hears an input that the system plays back out; and the input + // on channel 2 only, as a mic on input 2 of an interface. Item 4 + // fails on the second arrival; item 5 names input 2, and passes + // because the live dots were drawn all the same + void dev_checks_echo_and_the_mic_on_input_2() { + FakeAudioIO::Config config = loopback(); + config.echoDelay = 2205; + config.echoGain = 0.5f; + config.inputChannel = 1; + makeWindow(config); + + runDevChecks(roundTrip / rate, 0.0); + if (QTest::currentTestFailed()) return; + + QVERIFY2(m_report.failure == "", describe()); + const CheckResult *speakers = check(4); + const CheckResult *mic = check(5); + QVERIFY(speakers && mic); + QVERIFY2(speakers->verdict == CheckResult::Verdict::Fail, describe()); + QVERIFY2(speakers->message.contains("arrived a second time"), + describe()); + const QString echo = number(*speakers, "second arrival"); + const double delayMs = milliseconds(echo.section(" after", 0, 0)); + const double levelDb = echo.section(", ", 1, 1).chopped(3).toDouble(); + QVERIFY2(std::fabs(delayMs - 50.0) <= 1.0, describe()); + QVERIFY2(std::fabs(levelDb + 6.0) <= 1.0, describe()); + + // Tony played nothing more than without the echo + QVERIFY2(number(*speakers, "largest output level in the silent gaps") + .startsWith("silence, "), describe()); + + QVERIFY2(mic->verdict == CheckResult::Verdict::Pass, describe()); + QVERIFY2(number(*mic, "the mic is on").startsWith("input 2, "), + describe()); + QVERIFY2(number(*mic, "input peaks, punch-in 1") + .startsWith("input 1 silence, input 2 -"), describe()); + } + + // The take heard during the re-recording's lead-in, as if Tony did + // not keep it silent: its audio made audible as the runner reports + // that punch-in recording, before the reference starts to play. The + // lead-in plays over what stage 1 recorded, which in a room holds + // noise where the reference is silent, and items 4 and 12 fail on + // the output there; nothing before the punch-in changed. The window + // is held up over the lead-in's last silent gap, as in + // dev_checks_lead_in_through_a_stall(): the take is heard in the gap + // before it all the same. The run is cancelled as the stage after + // the re-recording begins: the checks of the stages it got through + // are worked out all the same + void dev_checks_take_heard_during_the_lead_in() { + makeWindow(loopbackInARoom()); + stallTheReRecording(); + connect(m_window->audioCheck(), &AudioCheckRunner::progress, + this, [this](const AudioCheckRunner::Progress &state) { + // Reported again as the seconds left go down + if (state.step != AudioCheckRunner::Step::Recording || + m_faults > 0 || m_stages.isEmpty() || + !m_stages.last().endsWith(": Re-record")) { + return; + } + Analyser *take = m_window->analyser2(); + sv::Layer *audio = + take ? take->getLayer(Analyser::Audio) : nullptr; + auto params = audio ? audio->getPlayParameters() : nullptr; + if (params) { + params->setPlayAudible(true); + ++m_faults; + } + }); + connect(m_window->devChecks(), &DevChecks::progress, + this, [this](QString stage, int, int) { + if (stage == "Pre-roll near the start") { + m_window->devChecks()->cancel(); + } + }); + + runDevChecks(roundTrip / rate, 0.0); + if (QTest::currentTestFailed()) return; + QCOMPARE(m_faults, 1); + QCOMPARE(m_stalls, 1); + QCOMPARE(m_report.failure, QString("The dev checks were cancelled.")); + QCOMPARE(int(m_checks.size()), 2); + + const CheckResult *speakers = check(4); + const CheckResult *leadIn = check(12); + QVERIFY(speakers && leadIn); + QVERIFY2(speakers->verdict == CheckResult::Verdict::Fail, describe(4)); + QVERIFY2(speakers->message.startsWith("Tony played something where " + "the reference is silent: -"), + describe(4)); + QVERIFY2(speakers->message.contains(", in punch-in 3."), describe(4)); + QCOMPARE(number(*speakers, "second arrival"), QString("none heard")); + + QVERIFY2(leadIn->verdict == CheckResult::Verdict::Fail, describe(12)); + QVERIFY2(leadIn->message.startsWith("during the lead-in Tony played " + "something where the reference " + "is silent: -"), describe(12)); + QVERIFY2(!leadIn->message.contains(";"), describe(12)); + QCOMPARE(number(*leadIn, "audio before 19.20 s"), + QString("the same, bit for bit")); + + // Not reached: the save, and the punch-in near the start + for (int item : { 1, 13 }) { + QVERIFY2(check(item) && + check(item)->verdict == CheckResult::Verdict::Skipped, + describe()); + } + } + + // The window held up during the re-recording's lead-in, as a busy + // system may hold it up: the observer takes no look meanwhile, and + // the look across the stall reaches over the sounds either side, so + // it is left out. Held up for 0.45 s over the silent gap just before + // the punch-in, the lead-in still has the gap from 16.8 to 17.7 s + // looked at, so item 12 judges what was played, and passes, as do + // the re-recording's other checks. Held up over the whole lead-in, + // no look lies in a gap, and that part of item 12 is not judged, + // with the reason, rather than failed. Cancelled as the stage after + // the re-recording begins + void dev_checks_lead_in_through_a_stall_data() { + QTest::addColumn("at"); + QTest::addColumn("ms"); + QTest::addColumn("judged"); + const double start = DevChecks::reRecording().start; + QTest::newRow("over the last gap") << start - 0.45 << 450 << true; + QTest::newRow("over the whole lead-in") + << start - DevChecks::kReRecordPreRollSeconds + << int(1000 * DevChecks::kReRecordPreRollSeconds) + 50 << false; + } + + void dev_checks_lead_in_through_a_stall() { + QFETCH(double, at); + QFETCH(int, ms); + QFETCH(bool, judged); + makeWindow(loopbackInARoom()); + stallTheReRecording(at, ms); + connect(m_window->devChecks(), &DevChecks::progress, + this, [this](QString stage, int, int) { + if (stage == "Pre-roll near the start") { + m_window->devChecks()->cancel(); + } + }); + + runDevChecks(roundTrip / rate, 0.0); + if (QTest::currentTestFailed()) return; + QCOMPARE(m_report.failure, QString("The dev checks were cancelled.")); + QCOMPARE(int(m_checks.size()), 2); + + // Held up from where it was due until the punch-in, and the + // observer saw it + QCOMPARE(m_stalls, 1); + const double start = DevChecks::reRecording().start; + QVERIFY2(m_stalledFrom < at + 0.05 && m_stalledTo > start - 0.05, + qPrintable(QString("stalled from %1 to %2 s") + .arg(m_stalledFrom).arg(m_stalledTo))); + const CheckResult *leadIn = check(12); + QVERIFY(leadIn); + QVERIFY2(milliseconds(number(*leadIn, "longest wait between two " + "looks in the lead-in")) >= ms, + describe(12)); + + QVERIFY2(leadIn->verdict == CheckResult::Verdict::Pass, describe(12)); + const QString gaps = + number(*leadIn, "output in the lead-in's silent gaps"); + if (judged) { + QVERIFY2(gaps.startsWith("silence, over ") && + !gaps.endsWith(" 0 looks"), describe(12)); + QVERIFY2(!leadIn->message.contains("not judged"), describe(12)); + } else { + QCOMPARE(gaps, QString("silence, over 0 looks")); + QVERIFY2(leadIn->message.contains + ("What Tony played during the lead-in was not judged: " + "no look at the output lay wholly in one of the " + "reference's silent gaps"), describe(12)); + QVERIFY2(!leadIn->message.contains("Tony played nothing"), + describe(12)); + } + for (int item : { 4, 7, 14 }) { + QVERIFY2(check(item) && + check(item)->verdict == CheckResult::Verdict::Pass, + describe(item)); + } + } + + // Cancelled during a take: the take stops, the run ends once with + // every check skipped, and the user's toggles and stored round trip + // are as they were + void dev_checks_cancelled() { + makeWindow(loopback()); + setUserState(); + const QStringList togglesBefore = toggles(); + const QStringList storedBefore = storedLatency(); + QVERIFY(!storedBefore.isEmpty()); + + startDevChecks(roundTrip / rate); + if (QTest::currentTestFailed()) return; + QTRY_VERIFY_WITH_TIMEOUT(m_window->recordTarget()->isRecording(), + 30000); + QVERIFY(m_window->audioCheckTakes()); + QVERIFY(!m_window->recordAction()->isEnabled()); + QTest::qWait(500); + + m_window->devChecks()->cancel(); + verifyEndedEarly(togglesBefore, storedBefore); + if (QTest::currentTestFailed()) return; + QVERIFY(m_window->recordAction()->isEnabled()); + } + + // The same when the session is closed during a take + void dev_checks_end_when_the_session_closes() { + makeWindow(loopback()); + setUserState(); + const QStringList togglesBefore = toggles(); + const QStringList storedBefore = storedLatency(); + + startDevChecks(roundTrip / rate); + if (QTest::currentTestFailed()) return; + QTRY_VERIFY_WITH_TIMEOUT(m_window->recordTarget()->isRecording(), + 30000); + QTest::qWait(500); + + m_window->doCloseSession(); + verifyEndedEarly(togglesBefore, storedBefore); + } + + // Deleted during a take, as the window's destructor deletes them: + // the audio check's run they started ends with them, silently, and + // the take in progress is left to the window, which stops it at the + // end of its range as any take into a selection. Then the window + // itself, deleted during a take of theirs + void dev_checks_deleted_during_a_run() { + makeWindow(loopback()); + startDevChecks(roundTrip / rate, 0.0); + if (QTest::currentTestFailed()) return; + QTRY_VERIFY_WITH_TIMEOUT(m_window->recordTarget()->isRecording(), + 30000); + + m_window->doDeleteDevChecks(); + QVERIFY(!m_window->audioCheck()->isRunning()); + QVERIFY(!m_window->audioCheckTakes()); + QCOMPARE(m_finished, 0); + QTRY_VERIFY_WITH_TIMEOUT(!m_window->recordTarget()->isRecording(), + 30000); + QTRY_VERIFY_WITH_TIMEOUT(m_window->recordAction()->isEnabled(), + 10000); + QTRY_VERIFY_WITH_TIMEOUT + (!sv::ModelTransformerFactory::getInstance() + ->haveRunningTransformers(), 30000); + + makeWindow(loopback()); + startDevChecks(roundTrip / rate, 0.0); + if (QTest::currentTestFailed()) return; + QTRY_VERIFY_WITH_TIMEOUT(m_window->recordTarget()->isRecording(), + 30000); + delete m_window; + m_window = nullptr; + QCOMPARE(m_finished, 0); + } + + // Playback > Calibrate Audio with the checkbox on, as it is by + // default. A calibration that cannot be used (cancelled) is shown + // with a word that the dev checks did not run. Check Again, run to + // the end: the dev checks carry on from it, with the round trip it + // measured, and Cancel then ends them; the result page shows the + // calibration, a line for each check and the report's path + void dev_checks_after_calibrating_from_the_dialog() { + makeWindow(loopback()); + m_window->calibrateAudioAction()->trigger(); + CalibrateAudioDialog *dialog = m_window->calibrateAudioDialog(); + QVERIFY(dialog); + QVERIFY(dialog->devChecksWanted()); + dialog->setPlan(shortPlan()); + dialog->setDevOptions(options(-1.0)); + + m_window->discardModifications(); + dialog->startCheck(); + QTRY_VERIFY_WITH_TIMEOUT(m_window->recordTarget()->isRecording(), + 30000); + dialog->cancelCheck(); + QVERIFY(dialog->page() == CalibrateAudioDialog::Page::Result); + QVERIFY(dialog->isVisible()); + QVERIFY(!dialog->isCollapsed()); + QVERIFY2(dialog->pageText().contains("The dev checks did not run"), + qPrintable(dialog->pageText())); + QVERIFY(!m_window->devChecks()->isRunning()); + QCOMPARE(m_finished, 0); + + dialog->startCheck(); + QTRY_VERIFY_WITH_TIMEOUT(m_window->devChecks()->isRunning(), 60000); + QCOMPARE(int(m_checks.size()), 2); + const AudioCheckResult calibration = m_checks.back(); + QVERIFY(calibration.calibrationUsable()); + QVERIFY(dialog->page() == CalibrateAudioDialog::Page::Progress); + QTRY_VERIFY_WITH_TIMEOUT + (dialog->pageText().contains("Dev checks, stage 1 of 6"), 10000); + // Still small, carrying on from the calibration, the indicator + // following the stages + QVERIFY(dialog->isCollapsed()); + QVERIFY(!dialog->isVisible()); + QVERIFY2(dialog->indicator()->text() + .startsWith("Dev checks, stage 1 of 6: "), + qPrintable(dialog->indicator()->text())); + QCOMPARE(dialog->indicator()->progress(), -1); + QTRY_VERIFY_WITH_TIMEOUT(m_window->recordTarget()->isRecording(), + 30000); + QVERIFY(!m_window->calibrateAudioAction()->isEnabled()); + QVERIFY(!m_window->recordAction()->isEnabled()); + + dialog->cancelCheck(); + QCOMPARE(m_finished, 1); + QVERIFY(!m_window->devChecks()->isRunning()); + QVERIFY(!m_window->recordTarget()->isRecording()); + QVERIFY(dialog->page() == CalibrateAudioDialog::Page::Result); + QVERIFY(dialog->isVisible()); + QVERIFY(dialog->indicator()->isHidden()); + + const QString words = dialog->pageText(); + for (QString w : { QString("came back steadily"), + QString("They ended early: The dev checks were " + "cancelled."), + QString("Item 1, latency_on_this_machine: Skipped"), + QString("Item 2, several_phrases_in_one_take: " + "Skipped"), + "Report: " + m_report.reportPath }) { + QVERIFY2(words.contains(w), qPrintable(w + " not in: " + words)); + } + QVERIFY(dialog->canUseLatency()); + + // The dev checks were given the round trip the calibration measured + QVERIFY2(reportText().contains + (QString("Round trip for the run: %1 ms") + .arg(calibration.calibratedRoundTrip * 1000.0, 0, 'f', 1)), + qPrintable(reportText())); + } + + // The dev run as on the user's phone: calibrated from the dialog, on + // a device in the phone's shape (phoneInARoom()), and carried on into + // the dev checks with the round trip measured. Every check passes as + // on the desktop's loopback, at the device's 48 kHz, but item 5, + // which has no second input to find the mic on. The report's head + // names the driver and how its streams opened. The result page's + // report, which Copy takes and on a phone Save Report... saves, holds + // the calibration and the report file whole, since a phone keeps that + // file where only Tony can read it + void dev_checks_on_a_phone() { + makeWindow(phoneInARoom()); + m_window->calibrateAudioAction()->trigger(); + CalibrateAudioDialog *dialog = m_window->calibrateAudioDialog(); + QVERIFY(dialog); + QVERIFY(dialog->devChecksWanted()); + dialog->setPlan(shortPlan()); + dialog->setDevOptions(options(-1.0)); + + m_window->discardModifications(); + dialog->startCheck(); + QTRY_VERIFY_WITH_TIMEOUT(m_finished > 0, 180000); + QCOMPARE(m_finished, 1); + QVERIFY(!m_window->devChecks()->isRunning()); + QVERIFY(dialog->page() == CalibrateAudioDialog::Page::Result); + QVERIFY(dialog->isVisible()); + for (const QString &line : reportText().split('\n')) { + qDebug().noquote() << "report:" << line; + } + + QVERIFY(!m_checks.empty()); + const AudioCheckResult calibration = m_checks.front(); + QVERIFY2(calibration.calibrationUsable(), + qPrintable(calibration.failure)); + QCOMPARE(calibration.recordingRate, phoneRate); + QCOMPARE(calibration.referenceRate, rate); + QCOMPARE(calibration.route.driver, QString("oboe")); + + QVERIFY2(m_report.failure == "", describe()); + QCOMPARE(int(m_report.checks.size()), 11); + for (const CheckResult &c : m_report.checks) { + if (c.item == 5) continue; + QVERIFY2(c.verdict == CheckResult::Verdict::Pass, + describe(c.item)); + } + const CheckResult *mic = check(5); + QVERIFY(mic); + QVERIFY2(mic->verdict == CheckResult::Verdict::Measured, describe(5)); + QCOMPARE(mic->message, QString("Not applicable here: the device " + "records one input channel.")); + QVERIFY2(number(*mic, "input peaks, punch-in 1").startsWith("input 1 -") + && !number(*mic, "input peaks, punch-in 1") + .contains("input 2"), describe(5)); + QCOMPARE(lastReportLine(), + QString("Totals: 10 passed, 0 failed, 1 measured, 0 skipped")); + + const QString file = reportText(); + for (QString words : { QString("\nAudio driver in use: oboe\n"), + "\nOutput streams: " + + phoneInARoom().route.outputStreams + "\n", + "\nInput streams: " + + phoneInARoom().route.inputStreams + "\n" }) { + QVERIFY2(file.contains(words), qPrintable(words + " not in:\n" + + file)); + } + + const QString report = dialog->reportText(); + for (QString words : { QString("came back steadily"), + QString("Item 5, mic_on_input_2: Measured"), + "The dev checks' report, " + + m_report.reportPath + ":\n\n" + file }) { + QVERIFY2(report.contains(words), + qPrintable(words + " not in:\n" + report)); + } + QVERIFY(report.trimmed().endsWith + ("Totals: 10 passed, 0 failed, 1 measured, 0 skipped")); + } + + // A dialog Android draws itself, as it draws a message box or the file + // picker, is never a modal widget of Qt's: what shows it is the event + // loop its exec() runs. One such loop during the re-recording's take, + // with nothing on screen, is a dialog to item 14, which fails on it + // and on nothing else. Cancelled as the stage after begins + void dev_checks_see_a_dialog_qt_does_not_draw() { + makeWindow(loopback()); + connect(m_window->audioCheck(), &AudioCheckRunner::progress, + this, [this](const AudioCheckRunner::Progress &state) { + // Reported again as the seconds left go down + if (state.step != AudioCheckRunner::Step::Recording || + m_faults > 0 || m_stages.isEmpty() || + !m_stages.last().endsWith(": Re-record")) { + return; + } + ++m_faults; + // Outside the runner's poll, as a box shown by + // something else would be + QTimer::singleShot(200, this, [this]() { + QEventLoop loop; + QTimer::singleShot(300, &loop, &QEventLoop::quit); + loop.exec(); + ++m_loops; + }); + }); + connect(m_window->devChecks(), &DevChecks::progress, + this, [this](QString stage, int, int) { + if (stage == "Pre-roll near the start") { + m_window->devChecks()->cancel(); + } + }); + + runDevChecks(roundTrip / rate, 0.0); + if (QTest::currentTestFailed()) return; + QCOMPARE(m_faults, 1); + QCOMPARE(m_loops, 1); + QCOMPARE(m_report.failure, QString("The dev checks were cancelled.")); + + const CheckResult *stops = check(14); + QVERIFY(stops); + QVERIFY2(stops->verdict == CheckResult::Verdict::Fail, describe(14)); + QVERIFY2(stops->message.startsWith("a dialog was up ") && + stops->message.endsWith(" into the take at 19.20 to 21.20 " + "s.") && + !stops->message.contains(";"), describe(14)); + QVERIFY2(number(*stops, "dialogs, 19.20 to 21.20 s") + .startsWith("one up "), describe(14)); + QVERIFY2(check(7) && check(7)->verdict == CheckResult::Verdict::Pass, + describe(7)); + } +}; + +#endif +#endif diff --git a/main/test/TestLatencyCalibration.h b/main/test/TestLatencyCalibration.h new file mode 100644 index 00000000..4d526672 --- /dev/null +++ b/main/test/TestLatencyCalibration.h @@ -0,0 +1,444 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TEST_LATENCY_CALIBRATION_H +#define TEST_LATENCY_CALIBRATION_H + +// Tier 2: the stored round trip, its key and its staleness, and the +// choice between it and the reported latencies. No window and no +// device. The settings are an INI file of each test's own, in a +// temporary directory, and never the suite's application settings. + +#include "../LatencyCalibration.h" +#include "../LatencyUtils.h" + +#include +#include +#include +#include +#include + +class TestLatencyCalibration : public QObject +{ + Q_OBJECT + + typedef LatencyCalibration::Key Key; + typedef LatencyCalibration::Figure Figure; + typedef LatencyCalibration::InUse InUse; + typedef LatencyCalibration::Source Source; + + QTemporaryDir m_dir; + int m_counter = 0; + + // The settings file of the test that is running + QString m_path; + + static Key key(QString playback, QString record, double rate = 44100, + QString implementation = "") { + Key k; + k.implementation = implementation; + k.playbackDevice = playback; + k.recordDevice = record; + k.rate = rate; + return k; + } + + static Figure figure(double roundTrip, double output, double input) { + Figure f; + f.roundTrip = roundTrip; + f.spread = 0.0021; + f.date = QDateTime::fromString("2026-09-26T10:30:15.250Z", + Qt::ISODateWithMs); + f.reportedOutput = output; + f.reportedInput = input; + return f; + } + + static double loaded(QSettings &settings, const Key &k) { + Figure f; + if (!LatencyCalibration::load(settings, k, f)) return -1.0; + return f.roundTrip; + } + +private slots: + void initTestCase() { + QVERIFY(m_dir.isValid()); + } + + void init() { + m_path = m_dir.filePath(QString("settings-%1.ini").arg(++m_counter)); + } + + void cleanup() { + QFile::remove(m_path); + } + + // What is stored comes back, from the file, with device names that + // hold the characters QSettings takes as a subgroup, and more; the + // names make no subgroups of their own; and forget() takes it away + void stored_figure_comes_back() { + const Key k = key(QString::fromUtf8("Line 1/2 (Ger\xc3\xa4t f\xc3\xbcr Audio)"), + QString::fromUtf8("Mic \\ In | 100% (\xc3\xa4)")); + const Figure stored = figure(0.2814285714, 8192 / 44100.0, + 4096 / 44100.0); + { + QSettings settings(m_path, QSettings::IniFormat); + Figure none; + QVERIFY(!LatencyCalibration::load(settings, k, none)); + LatencyCalibration::store(settings, k, stored); + settings.sync(); + QCOMPARE(settings.status(), QSettings::NoError); + } + + QSettings settings(m_path, QSettings::IniFormat); + Figure f; + QVERIFY(LatencyCalibration::load(settings, k, f)); + QCOMPARE(f.roundTrip, stored.roundTrip); + QCOMPARE(f.spread, stored.spread); + QCOMPARE(f.date, stored.date); + QCOMPARE(f.reportedOutput, stored.reportedOutput); + QCOMPARE(f.reportedInput, stored.reportedInput); + + // One group for the devices, one within it for the rate, and + // nothing more + settings.beginGroup("LatencyCalibration"); + QCOMPARE(settings.childGroups().size(), 1); + settings.beginGroup(settings.childGroups().front()); + QCOMPARE(settings.childGroups(), QStringList { "44100" }); + QVERIFY(settings.childKeys().isEmpty()); + settings.endGroup(); + settings.endGroup(); + + LatencyCalibration::forget(settings, k); + QVERIFY(!LatencyCalibration::load(settings, k, f)); + settings.beginGroup("LatencyCalibration"); + QVERIFY(settings.childGroups().isEmpty()); + settings.endGroup(); + + // Forgetting what is not there does nothing + LatencyCalibration::forget(settings, k); + QVERIFY(!LatencyCalibration::load(settings, k, f)); + } + + // Different devices, driver or rate: each figure is its own. The "|" + // between the names is not confused with one inside a name + void keys_stay_apart() { + QSettings settings(m_path, QSettings::IniFormat); + const std::vector keys { + key("Speakers", "Mic"), + key("Headphones", "Mic"), + key("Speakers", "Line In"), + key("Speakers", "Mic", 48000), + key("Speakers", "Mic", 44100, "portaudio"), + key("a|b", "c"), + key("a", "b|c"), + key("a/b", "c"), + key("a", "b/c"), + }; + for (int i = 0; i < int(keys.size()); ++i) { + LatencyCalibration::store(settings, keys[i], + figure(0.1 + 0.01 * i, 0.1, 0.05)); + } + for (int i = 0; i < int(keys.size()); ++i) { + QCOMPARE(loaded(settings, keys[i]), 0.1 + 0.01 * i); + } + QCOMPARE(loaded(settings, key("Speakers", "Mic", 96000)), -1.0); + QCOMPARE(loaded(settings, key("Speakers", "")), -1.0); + + // Forgetting one leaves the rest, the same devices at another + // rate among them + LatencyCalibration::forget(settings, keys[0]); + QCOMPARE(loaded(settings, keys[0]), -1.0); + for (int i = 1; i < int(keys.size()); ++i) { + QCOMPARE(loaded(settings, keys[i]), 0.1 + 0.01 * i); + } + } + + // The key names the devices MainWindowBase::createAudioIO() opens: + // the plain preferences, or, with a driver named, those suffixed + // with it + void key_follows_the_preferences() { + QSettings settings(m_path, QSettings::IniFormat); + Key k = LatencyCalibration::currentKey(settings, 48000); + QCOMPARE(k.implementation, QString()); + QCOMPARE(k.playbackDevice, QString()); + QCOMPARE(k.recordDevice, QString()); + QCOMPARE(k.rate, 48000.0); + + settings.setValue("Preferences/audio-playback-device", "Speakers"); + settings.setValue("Preferences/audio-record-device", "Mic"); + k = LatencyCalibration::currentKey(settings, 44100); + QCOMPARE(k.playbackDevice, QString("Speakers")); + QCOMPARE(k.recordDevice, QString("Mic")); + QCOMPARE(k.rate, 44100.0); + + settings.setValue("Preferences/audio-target", "portaudio"); + settings.setValue("Preferences/audio-playback-device-portaudio", + "Headphones"); + k = LatencyCalibration::currentKey(settings, 44100); + QCOMPARE(k.implementation, QString("portaudio")); + QCOMPARE(k.playbackDevice, QString("Headphones")); + QCOMPARE(k.recordDevice, QString()); + } + + // A phone's route: its driver, and each device by its type and + // product name, never by its id, which a headset gets anew each time + // it is plugged in. A device open for playback only has no input yet. + // Routes stay apart, and a figure comes back under a name that holds + // the characters the settings take for subgroups + void route_key_names_the_devices() { + AudioRoute::Route route; + route.driver = "oboe"; + route.output.id = 3; + route.output.type = 2; + route.output.productName = "Pixel 7"; + route.hasInput = true; + route.input.id = 7; + route.input.type = 15; + route.input.productName = " Pixel 7 "; + route.rate = 48000; + + Key k = LatencyCalibration::routeKey(route, 48000); + QCOMPARE(k.implementation, QString("oboe")); + QCOMPARE(k.playbackDevice, QString("Built-in speaker (Pixel 7)")); + QCOMPARE(k.recordDevice, QString("Built-in microphone (Pixel 7)")); + QCOMPARE(k.rate, 48000.0); + + AudioRoute::Route again = route; + again.output.id = 31; + again.input.id = 32; + Key k2 = LatencyCalibration::routeKey(again, 48000); + QCOMPARE(k2.playbackDevice, k.playbackDevice); + QCOMPARE(k2.recordDevice, k.recordDevice); + + AudioRoute::Route playbackOnly = route; + playbackOnly.hasInput = false; + QCOMPARE(LatencyCalibration::routeKey(playbackOnly, 48000).recordDevice, + QString()); + + AudioRoute::Device unknown; + QCOMPARE(AudioRoute::deviceName(unknown), QString("Unknown device")); + unknown.type = 99; + QCOMPARE(AudioRoute::deviceName(unknown), QString("Device of type 99")); + AudioRoute::Device headset; + headset.type = 8; + headset.productName = "WH-1000XM4 / Ren's | 100%"; + QCOMPARE(AudioRoute::deviceName(headset), + QString("Bluetooth (WH-1000XM4 / Ren's | 100%)")); + + QSettings settings(m_path, QSettings::IniFormat); + AudioRoute::Route bluetooth = route; + bluetooth.output = headset; + const Key kb = LatencyCalibration::routeKey(bluetooth, 48000); + Figure f = figure(0.21, 0.004, 0.003); + f.outputStreams = "AAudio, 48000 Hz, Shared, burst 240"; + f.inputStreams = "AAudio (MMAP), 48000 Hz, Exclusive, burst 96"; + LatencyCalibration::store(settings, k, figure(0.03, 0.005, 0.003)); + LatencyCalibration::store(settings, kb, f); + QCOMPARE(loaded(settings, k), 0.03); + Figure back; + QVERIFY(LatencyCalibration::load(settings, kb, back)); + QCOMPARE(back.roundTrip, 0.21); + QCOMPARE(back.outputStreams, f.outputStreams); + QCOMPARE(back.inputStreams, f.inputStreams); + + // A desktop's figure keeps no streams + Figure desk; + QVERIFY(LatencyCalibration::load(settings, k, desk)); + QCOMPARE(desk.outputStreams, QString()); + QCOMPARE(desk.inputStreams, QString()); + } + + // With the input not open, the one input a figure is kept with for + // the driver and output at the rate, if there is only one; names that + // hold what is encoded in a group's name come back whole + void only_record_device_for_an_output() { + QSettings settings(m_path, QSettings::IniFormat); + const QString odd = QString::fromUtf8("Mic 1/2 | \\ 100%2F (\xc3\xa4)"); + LatencyCalibration::store(settings, key("Speaker", odd, 48000, "oboe"), + figure(0.03, 0.005, 0.003)); + LatencyCalibration::store(settings, + key("Headset", "Headset mic", 48000, "oboe"), + figure(0.04, 0.005, 0.003)); + LatencyCalibration::store(settings, + key("Speaker", "Other", 44100, "oboe"), + figure(0.05, 0.005, 0.003)); + LatencyCalibration::store(settings, key("Speaker", "Desk", 48000), + figure(0.06, 0.005, 0.003)); + + QString record; + QVERIFY(LatencyCalibration::onlyRecordDevice + (settings, key("Speaker", "", 48000, "oboe"), record)); + QCOMPARE(record, odd); + QVERIFY(LatencyCalibration::onlyRecordDevice + (settings, key("Headset", "", 48000, "oboe"), record)); + QCOMPARE(record, QString("Headset mic")); + QVERIFY(LatencyCalibration::onlyRecordDevice + (settings, key("Speaker", "", 44100, "oboe"), record)); + QCOMPARE(record, QString("Other")); + + record = "unchanged"; + QVERIFY(!LatencyCalibration::onlyRecordDevice + (settings, key("Speaker", "", 96000, "oboe"), record)); + QVERIFY(!LatencyCalibration::onlyRecordDevice + (settings, key("Speak", "", 48000, "oboe"), record)); + QVERIFY(!LatencyCalibration::onlyRecordDevice + (settings, key("Bluetooth", "", 48000, "oboe"), record)); + QCOMPARE(record, QString("unchanged")); + + // Two inputs calibrated with the speaker: which one is not known + LatencyCalibration::store(settings, + key("Speaker", "USB mic", 48000, "oboe"), + figure(0.07, 0.005, 0.003)); + QVERIFY(!LatencyCalibration::onlyRecordDevice + (settings, key("Speaker", "", 48000, "oboe"), record)); + QCOMPARE(record, QString("unchanged")); + } + + // A device that describes its streams is stale when it opened either + // otherwise, not when the latencies it reports move: Oboe's moved by + // 4 ms from one take to the next on the phone. A stream it does not + // describe (the input, before it is opened) is not compared. A figure + // kept without streams is stale for such a device; and a device that + // describes none is judged by its latencies as before + void stale_by_the_streams() { + Figure f = figure(0.030, 252 / 48000.0, 134 / 48000.0); + f.outputStreams = "AAudio (MMAP), 48000 Hz, Exclusive, burst 96"; + f.inputStreams = "AAudio (MMAP), 48000 Hz, Exclusive, preset " + "VoicePerformance"; + const double out = 401 / 48000.0; + const double in = 222 / 48000.0; + + QVERIFY(!LatencyCalibration::isStale(f, out, in, f.outputStreams, + f.inputStreams)); + QVERIFY(!LatencyCalibration::isStale(f, out, 0.0, f.outputStreams, "")); + QVERIFY(LatencyCalibration::isStale + (f, out, in, "AAudio, 48000 Hz, Shared, burst 96", + f.inputStreams)); + QVERIFY(LatencyCalibration::isStale + (f, out, in, f.outputStreams, "AAudio, 48000 Hz, Shared")); + QVERIFY(LatencyCalibration::isStale + (f, out, in, "", "AAudio, 48000 Hz, Shared")); + + Figure plain = figure(0.030, 252 / 48000.0, 134 / 48000.0); + QVERIFY(LatencyCalibration::isStale(plain, 252 / 48000.0, + 134 / 48000.0, f.outputStreams, + f.inputStreams)); + + // No streams now: the reported pair, to the millisecond + QVERIFY(LatencyCalibration::isStale(f, out, in)); + QVERIFY(!LatencyCalibration::isStale(f, 252 / 48000.0, 134 / 48000.0)); + + InUse measured = LatencyCalibration::roundTripInUse + (&f, out, in, f.outputStreams, f.inputStreams); + QVERIFY(measured.source == Source::Measured); + QCOMPARE(measured.roundTrip, 0.030); + QCOMPARE(measured.reportedOutput, out); + InUse other = LatencyCalibration::roundTripInUse + (&f, out, in, "OpenSLES, 48000 Hz", f.inputStreams); + QVERIFY(other.source == Source::Reported); + QVERIFY(other.stale); + QCOMPARE(other.roundTrip, out + in); + } + + // Stale when either reported latency has moved by more than the + // tolerance, either way; not when it has moved by less + void stale_beyond_the_tolerance() { + const double out = 8192 / 44100.0; + const double in = 4096 / 44100.0; + const Figure f = figure(0.28, out, in); + const double within = 0.9 * LatencyCalibration::kStaleToleranceSeconds; + const double beyond = 1.1 * LatencyCalibration::kStaleToleranceSeconds; + + QVERIFY(!LatencyCalibration::isStale(f, out, in)); + for (double sign : { 1.0, -1.0 }) { + QVERIFY(!LatencyCalibration::isStale(f, out + sign * within, in)); + QVERIFY(!LatencyCalibration::isStale(f, out, in + sign * within)); + QVERIFY(LatencyCalibration::isStale(f, out + sign * beyond, in)); + QVERIFY(LatencyCalibration::isStale(f, out, in + sign * beyond)); + } + } + + // The stored figure while it is fresh, the reported sum otherwise; + // and either way the reported pair it was chosen against + void round_trip_in_use() { + const double out = 0.2; + const double in = 0.1; + + InUse none = LatencyCalibration::roundTripInUse(nullptr, out, in); + QVERIFY(none.source == Source::Reported); + QCOMPARE(none.roundTrip, out + in); + QVERIFY(none.date.isNull()); + QVERIFY(!none.stale); + QCOMPARE(none.reportedOutput, out); + QCOMPARE(none.reportedInput, in); + + const Figure fresh = figure(0.345, out, in); + InUse measured = LatencyCalibration::roundTripInUse(&fresh, out, in); + QVERIFY(measured.source == Source::Measured); + QCOMPARE(measured.roundTrip, 0.345); + QCOMPARE(measured.date, fresh.date); + QVERIFY(!measured.stale); + QCOMPARE(measured.reportedOutput, out); + QCOMPARE(measured.reportedInput, in); + + const Figure old = figure(0.345, out + 0.01, in); + InUse stale = LatencyCalibration::roundTripInUse(&old, out, in); + QVERIFY(stale.source == Source::Reported); + QCOMPARE(stale.roundTrip, out + in); + QVERIFY(stale.date.isNull()); + QVERIFY(stale.stale); + QCOMPARE(stale.reportedOutput, out); + QCOMPARE(stale.reportedInput, in); + + // A latency reported as less than nothing counts as none + InUse negative = LatencyCalibration::roundTripInUse(nullptr, -0.5, in); + QCOMPARE(negative.roundTrip, in); + } + + // With nothing stored and the device at 44.1 kHz, the round trip in + // frames is exactly the reported sum, as it was before there was a + // stored figure. At another rate each latency is converted from the + // frames it counts: the output's are the session's (bqaudioio's + // ResamplerWrapper converts them), the input's the device's + void frames_at_the_recording_rate() { + const double rate = 44100; + const std::vector latencies { + -100, -1, 0, 1, 255, 512, 4096, 8191, 8192, 8820, 12345, + 44100, 88200, 1000003 + }; + for (sv::sv_frame_t out : latencies) { + for (sv::sv_frame_t in : latencies) { + double seconds = + LatencyCalibration::reportedSeconds(out, rate) + + LatencyCalibration::reportedSeconds(in, rate); + QCOMPARE(LatencyCalibration::toFrames(seconds, rate), + computeRecordingLatency(out, in)); + } + } + + // 8192 frames out and 4096 in at 48 kHz; the play source has the + // output latency as round(8192 * 44100 / 48000) = 7526 frames + double seconds = LatencyCalibration::reportedSeconds(7526, rate) + + LatencyCalibration::reportedSeconds(4096, 48000); + QVERIFY(std::llabs(LatencyCalibration::toFrames(seconds, 48000) - + 12288) <= 1); + + QCOMPARE(LatencyCalibration::toFrames(0.0, rate), sv::sv_frame_t(0)); + QCOMPARE(LatencyCalibration::toFrames(-0.1, rate), sv::sv_frame_t(0)); + QCOMPARE(LatencyCalibration::toFrames(0.1, 0), sv::sv_frame_t(0)); + QCOMPARE(LatencyCalibration::reportedSeconds(4096, 0), 0.0); + } +}; + +#endif diff --git a/main/test/TestLatencyCheck.h b/main/test/TestLatencyCheck.h new file mode 100644 index 00000000..031507eb --- /dev/null +++ b/main/test/TestLatencyCheck.h @@ -0,0 +1,1228 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TEST_LATENCY_CHECK_H +#define TEST_LATENCY_CHECK_H + +// Tier 2: the test reference that Calibrate Audio plays, and the finder +// that locates its sweeps in a recording. The takes are made here from +// the reference itself: shifted, filtered, with noise or a reflection +// added. No window, no device and no files. + +#include "../LatencyCheck.h" + +#include "TestSignals.h" + +#include "bqfft/FFT.h" + +#include +#include + +#include +#include +#include +#include + +class TestLatencyCheck : public QObject +{ + Q_OBJECT + + typedef sv::sv_frame_t frame_t; + typedef std::vector samples_t; + + static constexpr double kRate = 44100.0; + + static frame_t framesOf(double seconds, double rate = kRate) { + return frame_t(std::llround(seconds * rate)); + } + + // The first n events of the calibration layout, and the reference up + // to where the next would begin: as much as a finder test needs + static LatencyCheck::Layout firstEvents(int n, double rate = kRate) { + LatencyCheck::Layout layout = LatencyCheck::calibrationLayout(rate); + layout.length = layout.events[n].sweepStart; + layout.events.resize(n); + return layout; + } + + static double expectedAt(const LatencyCheck::Layout &layout, int i) { + return double(layout.events[i].sweepStart) / layout.rate; + } + + // The take as it would be if all of the reference arrived this many + // frames late (early, if negative) + static samples_t shifted(const samples_t &x, frame_t by) { + const frame_t n = frame_t(x.size()); + samples_t y(x.size(), 0.f); + for (frame_t i = std::max(frame_t(0), by); i < n && i - by < n; ++i) { + y[i] = x[i - by]; + } + return y; + } + + static samples_t mixed(const samples_t &x, const samples_t &y, + double gain) { + samples_t out(x); + for (size_t i = 0; i < out.size() && i < y.size(); ++i) { + out[i] += float(gain * y[i]); + } + return out; + } + + static double ratioOf(double db) { return std::pow(10.0, db / 20.0); } + + static double rms(const samples_t &x) { + double sum = 0.0; + for (float v : x) sum += double(v) * v; + return x.empty() ? 0.0 : std::sqrt(sum / double(x.size())); + } + + static double peakOf(const samples_t &x, frame_t from, frame_t to) { + double peak = 0.0; + for (frame_t i = from; i < to; ++i) { + peak = std::max(peak, double(std::fabs(x[i]))); + } + return peak; + } + + // Sign changes: twice the cycles of a sine, whatever its level + static int crossings(const samples_t &x, frame_t from, frame_t to) { + int n = 0; + for (frame_t i = from + 1; i < to; ++i) { + if ((x[i-1] < 0.f) != (x[i] < 0.f)) ++n; + } + return n; + } + + // A second-order Butterworth section (the Audio EQ Cookbook's), as a + // small speaker's roll-off + static samples_t filtered(const samples_t &x, double rate, double hz, + bool highPass) { + const double w = 2.0 * TestSignals::kPi * hz / rate; + const double alpha = std::sin(w) / std::sqrt(2.0); + const double cosw = std::cos(w); + const double b0 = highPass ? (1.0 + cosw) / 2.0 : (1.0 - cosw) / 2.0; + const double b1 = highPass ? -(1.0 + cosw) : (1.0 - cosw); + const double b2 = b0; + const double a0 = 1.0 + alpha, a1 = -2.0 * cosw, a2 = 1.0 - alpha; + samples_t y(x.size()); + double x1 = 0.0, x2 = 0.0, y1 = 0.0, y2 = 0.0; + for (size_t i = 0; i < x.size(); ++i) { + double in = x[i]; + double out = (b0 * in + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2) / a0; + x2 = x1; x1 = in; + y2 = y1; y1 = out; + y[i] = float(out); + } + return y; + } + + static LatencyCheck::Arrival find(const samples_t &take, double rate, + double expectedSeconds) { + return LatencyCheck::findSweep(take.data(), frame_t(take.size()), + rate, expectedSeconds); + } + + static QByteArray describe(const LatencyCheck::Arrival &a) { + return QString("found %1, error %2 frames (%3 ms), %4 dB over the " + "median, %5 dB over the second, level %6 dB") + .arg(a.found ? "yes" : "no") + .arg(qint64(a.errorFrames)) + .arg(a.errorSeconds * 1000.0) + .arg(a.peakOverMedianDb) + .arg(a.peakOverSecondDb) + .arg(a.levelDb) + .toUtf8(); + } + + typedef std::vector punchins_t; + + // The take as the splice would make it: inside each punch-in's + // range, the reference as it arrived that time, this many frames + // late (early, if negative); silence outside them. A later punch-in + // replaces an earlier one where they overlap + static samples_t spliced(const samples_t &reference, double rate, + const punchins_t &punchIns, + const std::vector &shifts) { + const frame_t n = frame_t(reference.size()); + samples_t take(reference.size(), 0.f); + for (size_t p = 0; p < punchIns.size(); ++p) { + const frame_t from = std::max(frame_t(0), + framesOf(punchIns[p].start, rate)); + const frame_t to = std::min(n, framesOf(punchIns[p].end, rate)); + for (frame_t f = from; f < to; ++f) { + const frame_t g = f - shifts[p]; + take[f] = (g >= 0 && g < n) ? reference[g] : 0.f; + } + } + return take; + } + + // Four punch-ins at about 2, 8, 14 and 20 s, as a calibration makes + // them. In the calibration layout they judge two events each, and + // one in the last: 3.1, 4.7 / 9.1, 11.4 / 15.7, 17.7 / 21.9 s + static punchins_t fourPunchIns() { + return { { 2.0, 7.0 }, { 8.0, 13.0 }, { 14.0, 19.0 }, { 20.0, 25.0 } }; + } + + // Room noise at about -65 dBFS RMS, far under the sweeps + static samples_t withRoomNoise(const samples_t &x) { + return mixed(x, TestSignals::whiteNoise(int(x.size()), 4711, 0.001), + 1.0); + } + + // Scale one event's sweep, as it lies in a take placed this late + static void scaleSweep(samples_t &take, const LatencyCheck::Layout &layout, + int event, frame_t shift, double gain) { + const frame_t from = layout.events[event].sweepStart + shift; + const frame_t length = framesOf(LatencyCheck::kSweepSeconds, layout.rate); + for (frame_t f = from; f < from + length; ++f) { + take[f] = float(take[f] * gain); + } + } + + static LatencyCheck::TakeSummary judge(const LatencyCheck::Layout &layout, + const samples_t &take, double rate, + const punchins_t &punchIns) { + return LatencyCheck::judgeTake(layout, take.data(), + frame_t(take.size()), rate, punchIns); + } + + static QByteArray describe(const LatencyCheck::TakeSummary &s) { + QStringList flags; + for (LatencyCheck::Verdict v : s.flags) { + flags << LatencyCheck::verdictName(v); + } + return QString("%1 [%2], found %3 of %4, median %5 ms, spread %6 " + "ms, slope %7 %, residual %8 ms, input peak %9, " + "fading %10 dB, echo %11 at %12 ms %13 dB") + .arg(LatencyCheck::verdictName(s.verdict)) + .arg(flags.join(" ")) + .arg(s.found) + .arg(s.judged) + .arg(s.medianOffset * 1000.0) + .arg(s.spread * 1000.0) + .arg(s.slope * 100.0) + .arg(s.slopeResidual * 1000.0) + .arg(s.inputPeak) + .arg(s.fadingDb) + .arg(s.echo.heard ? "heard" : "none") + .arg(s.echo.delaySeconds * 1000.0) + .arg(s.echo.levelDb) + .toUtf8(); + } + +private slots: + // The same layout and the same samples every time: nothing in the + // reference depends on a clock or a random source + void generator_is_deterministic() { + const LatencyCheck::Layout a = LatencyCheck::devLayout(); + const LatencyCheck::Layout b = LatencyCheck::devLayout(); + QCOMPARE(a.length, b.length); + QCOMPARE(a.events.size(), b.events.size()); + for (size_t i = 0; i < a.events.size(); ++i) { + QCOMPARE(a.events[i].sweepStart, b.events[i].sweepStart); + QCOMPARE(a.events[i].toneStart, b.events[i].toneStart); + QCOMPARE(a.events[i].toneLength, b.events[i].toneLength); + QCOMPARE(a.events[i].toneHz, b.events[i].toneHz); + } + QVERIFY(LatencyCheck::generate(a) == LatencyCheck::generate(b)); + } + + // Each event is where the layout says and what it says: silence, a + // sweep rising from 1 to 8 kHz, a pause, a tone at its pitch, silence + void generator_puts_events_where_it_says() { + const LatencyCheck::Layout layout = LatencyCheck::calibrationLayout(); + const samples_t x = LatencyCheck::generate(layout); + const samples_t sweep = LatencyCheck::sweep(kRate); + const frame_t sweepLength = frame_t(sweep.size()); + + QCOMPARE(layout.rate, kRate); + QCOMPARE(layout.length, framesOf(26.0)); + QCOMPARE(frame_t(x.size()), layout.length); + QCOMPARE(int(layout.events.size()), 12); + QCOMPARE(sweepLength, framesOf(0.2)); + + std::set pitches; + frame_t silentFrom = 0; + + for (const LatencyCheck::Event &e : layout.events) { + + QCOMPARE(peakOf(x, silentFrom, e.sweepStart), 0.0); + + int misplaced = 0; + for (frame_t i = 0; i < sweepLength; ++i) { + if (x[e.sweepStart + i] != sweep[i]) ++misplaced; + } + QCOMPARE(misplaced, 0); + + // Rising: 1 to 4.5 kHz in the first 0.1 s is 550 sign + // changes, 1 to 8 kHz in 0.2 s is 1800. Falling, the first + // half would have 1250 + int half = crossings(x, e.sweepStart, e.sweepStart + sweepLength / 2); + int whole = crossings(x, e.sweepStart, e.sweepStart + sweepLength); + QVERIFY2(std::abs(half - 550) <= 3, qPrintable(QString::number(half))); + QVERIFY2(std::abs(whole - 1800) <= 3, qPrintable(QString::number(whole))); + + QCOMPARE(e.toneStart - e.sweepStart, framesOf(0.3)); + QCOMPARE(peakOf(x, e.sweepStart + sweepLength, e.toneStart), 0.0); + + // A whole number of samples per period at 44.1 kHz, on + // average; that pitch in the samples is + // generator_tones_have_their_harmonics()'s + QCOMPARE(e.toneLength, framesOf(0.8)); + const double period = kRate / e.toneHz; + QCOMPARE(period, std::round(period)); + pitches.insert(e.toneHz); + + silentFrom = e.toneStart + e.toneLength; + } + + QCOMPARE(peakOf(x, silentFrom, frame_t(x.size())), 0.0); + QCOMPARE(pitches, (std::set { 196.0, 220.5, 245.0, 294.0 })); + } + + // Sweep to sweep: never less than twice the finder's reach, so that + // a neighbour's sweep is never in its window; 1.6 to 2.6 s between + // events with short tones; and all different, or in the long + // layout, any eleven in a row + void generator_spacings_are_irregular() { + struct Case { + LatencyCheck::Layout layout; + double seconds; + int held; + int run; + }; + const Case cases[] = { + { LatencyCheck::calibrationLayout(), 26.0, 0, 0 }, + { LatencyCheck::devLayout(), 40.0, 3, 0 }, + { LatencyCheck::longLayout(), 240.0, 0, 11 }, + }; + + for (const Case &c : cases) { + const LatencyCheck::Layout &layout = c.layout; + const int n = int(layout.events.size()); + QCOMPARE(layout.length, framesOf(c.seconds)); + + std::vector spacings; + int held = 0; + + for (int i = 0; i < n; ++i) { + const LatencyCheck::Event &e = layout.events[i]; + const frame_t end = e.toneStart + e.toneLength; + QVERIFY(end <= layout.length); + const bool isHeld = (e.toneLength == framesOf(3.0)); + if (isHeld) ++held; + if (i + 1 == n) break; + + const LatencyCheck::Event &next = layout.events[i + 1]; + QVERIFY(end < next.sweepStart); + const double spacing = + double(next.sweepStart - e.sweepStart) / layout.rate; + QVERIFY2(spacing >= 2.0 * LatencyCheck::kSearchSeconds - 1e-9, + qPrintable(QString::number(spacing))); + if (!isHeld && next.toneLength == framesOf(0.8)) { + QVERIFY2(spacing <= 2.6 + 1e-9, + qPrintable(QString::number(spacing))); + } + spacings.push_back(spacing); + } + + QCOMPARE(held, c.held); + + const int count = int(spacings.size()); + const int run = c.run > 0 ? c.run : count; + for (int i = 0; i < count; ++i) { + for (int j = i + 1; j < count && j < i + run; ++j) { + QVERIFY2(std::fabs(spacings[i] - spacings[j]) >= 0.1 - 1e-9, + qPrintable(QString("%1 and %2").arg(i).arg(j))); + } + } + } + + QVERIFY(LatencyCheck::longLayout().events.size() > 100); + } + + // A long layout of another length is the start of the 4-minute one: + // its events, as many as end in time for the calibration's 0.8 s of + // silence, and the length asked for + void long_layout_of_another_length() { + const LatencyCheck::Layout whole = LatencyCheck::longLayout(); + QCOMPARE(LatencyCheck::kLongSeconds, 240.0); + QCOMPARE(whole.length, framesOf(240.0)); + + for (double seconds : { 60.0, 61.3 }) { + const LatencyCheck::Layout part = + LatencyCheck::longLayout(kRate, seconds); + QCOMPARE(part.rate, kRate); + QCOMPARE(part.length, framesOf(seconds)); + + const int n = int(part.events.size()); + QVERIFY2(n > 20 && n < int(whole.events.size()), + qPrintable(QString::number(n))); + for (int i = 0; i < n; ++i) { + const LatencyCheck::Event &a = part.events[i]; + const LatencyCheck::Event &b = whole.events[i]; + QCOMPARE(a.sweepStart, b.sweepStart); + QCOMPARE(a.toneStart, b.toneStart); + QCOMPARE(a.toneLength, b.toneLength); + QCOMPARE(a.toneHz, b.toneHz); + } + + auto endOf = [](const LatencyCheck::Event &e) { + return e.toneStart + e.toneLength; + }; + QVERIFY(endOf(part.events[n - 1]) + framesOf(0.8) <= part.length); + QVERIFY(endOf(whole.events[n]) + framesOf(0.8) > part.length); + } + + // 61.3 s has room for one event more than 60 s + QCOMPARE(LatencyCheck::longLayout(kRate, 61.3).events.size(), + LatencyCheck::longLayout(kRate, 60.0).events.size() + 1); + } + + // Each tone is its pitch and every harmonic up to 4 kHz, the n-th at + // 1/n of the pitch's amplitude, and nothing above; its pitch swings + // with the vibrato about the tone's, and no further. Read from the + // spectrum of the tone's middle, each harmonic's power summed over + // the band half way to its neighbours, which holds the vibrato's + // sidebands + void generator_tones_have_their_harmonics() { + const LatencyCheck::Layout layout = LatencyCheck::calibrationLayout(); + const samples_t x = LatencyCheck::generate(layout); + const int size = 32768; // 0.74 s: the tone less its fades + const double binHz = kRate / size; + breakfastquay::FFT fft(size); + + std::set seen; + for (const LatencyCheck::Event &e : layout.events) { + if (!seen.insert(e.toneHz).second) continue; + const frame_t from = e.toneStart + (e.toneLength - size) / 2; + std::vector in(size), re(size / 2 + 1), im(size / 2 + 1); + for (int i = 0; i < size; ++i) { + const double hann = + 0.5 - 0.5 * std::cos(2.0 * TestSignals::kPi * i / size); + in[i] = hann * x[from + i]; + } + fft.forward(in.data(), re.data(), im.data()); + + // Power, and power-weighted frequency and its square, over the + // band about harmonic h + auto band = [&](int h, double &mean, double &spread) { + const int lo = int(std::ceil((h - 0.5) * e.toneHz / binHz)); + const int hi = int(std::floor((h + 0.5) * e.toneHz / binHz)); + double power = 0.0, f = 0.0, ff = 0.0; + for (int k = lo; k <= hi && k <= size / 2; ++k) { + const double p = re[k] * re[k] + im[k] * im[k]; + power += p; + f += p * k * binHz; + ff += p * k * binHz * k * binHz; + } + mean = (power > 0.0 ? f / power : 0.0); + spread = (power > 0.0 ? + std::sqrt(std::max(0.0, ff / power - mean * mean)) + : 0.0); + return power; + }; + + double mean = 0.0, spread = 0.0; + const double fundamental = band(1, mean, spread); + QVERIFY(fundamental > 0.0); + const double swingHz = e.toneHz * + (std::pow(2.0, LatencyCheck::kVibratoCents / 1200.0) - 1.0); + QVERIFY2(std::fabs(TestSignals::centsBetween(mean, e.toneHz)) < 0.5 && + spread < swingHz + 2.0 * binHz, + qPrintable(QString("at %1 Hz: the fundamental's power " + "centred on %2 Hz, spread %3 Hz, " + "against a swing of %4 Hz") + .arg(e.toneHz).arg(mean).arg(spread) + .arg(swingHz))); + + const int top = int(LatencyCheck::kToneTopHz / e.toneHz); + QVERIFY2(top >= 13, qPrintable(QString::number(top))); + for (int h = 2; h * e.toneHz <= 2.0 * LatencyCheck::kToneTopHz; + ++h) { + const double ratio = std::sqrt(band(h, mean, spread) / + fundamental); + const bool wanted = (h <= top); + QVERIFY2(wanted ? std::fabs(ratio * h - 1.0) < 0.02 + : ratio < 1e-3, + qPrintable(QString("at %1 Hz, harmonic %2: %3 of " + "the fundamental, not %4") + .arg(e.toneHz).arg(h).arg(ratio) + .arg(wanted ? 1.0 / h : 0.0))); + } + } + QCOMPARE(seen, (std::set { 196.0, 220.5, 245.0, 294.0 })); + } + + // Every sweep and every tone peaks at -12 dBFS, and nothing is louder + void generator_peaks_at_minus_12_dbfs() { + const LatencyCheck::Layout layout = LatencyCheck::devLayout(); + const samples_t x = LatencyCheck::generate(layout); + const double peak = ratioOf(LatencyCheck::kPeakDbfs); + const frame_t sweepLength = framesOf(LatencyCheck::kSweepSeconds); + + QVERIFY(peakOf(x, 0, frame_t(x.size())) <= peak * (1.0 + 1e-6)); + for (const LatencyCheck::Event &e : layout.events) { + QVERIFY(peakOf(x, e.sweepStart, e.sweepStart + sweepLength) >= + peak * ratioOf(-0.05)); + QVERIFY(peakOf(x, e.toneStart, e.toneStart + e.toneLength) >= + peak * ratioOf(-0.05)); + } + } + + // The reference as generated: every event where it is, at the level + // it was made at + void finder_finds_every_event_of_the_reference() { + const LatencyCheck::Layout layout = LatencyCheck::devLayout(); + const samples_t x = LatencyCheck::generate(layout); + for (int i = 0; i < int(layout.events.size()); ++i) { + LatencyCheck::Arrival a = find(x, kRate, expectedAt(layout, i)); + QVERIFY2(a.found, describe(a).constData()); + QCOMPARE(a.errorFrames, frame_t(0)); + QVERIFY2(std::fabs(a.errorSeconds) < 1e-9, describe(a).constData()); + QVERIFY2(std::fabs(a.levelDb) < 0.01, describe(a).constData()); + // The tones' partials in the sweep's band, which the finder + // hears a few tenths of a second after it, are well under + // what an echo would have to be + QVERIFY2(a.secondLevelDb < -(LatencyCheck::kEchoMaxBelowDb + 5.0), + describe(a).constData()); + } + } + + // Takes that land early and late, by up to nearly the finder's + // reach. At 0.75 s, the neighbour across the smallest spacing (1.6 + // s, between the second and third events) is 0.85 s from where this + // sweep is expected: just out of reach, which is what the spacing + // is for + void finder_finds_shifts_across_the_window() { + const LatencyCheck::Layout layout = firstEvents(4); + const samples_t reference = LatencyCheck::generate(layout); + + const frame_t shifts[] = { + -framesOf(0.75), -framesOf(0.5), -8821, -1, 0, 1, + framesOf(0.1), 13231, framesOf(0.75) + }; + for (frame_t shift : shifts) { + const samples_t take = shifted(reference, shift); + for (int i = 0; i < int(layout.events.size()); ++i) { + LatencyCheck::Arrival a = find(take, kRate, expectedAt(layout, i)); + QVERIFY2(a.found, describe(a).constData()); + QCOMPARE(a.errorFrames, shift); + QVERIFY2(std::fabs(a.errorSeconds - shift / kRate) < 1e-9, + describe(a).constData()); + } + } + } + + // Half a frame late. The reference made at twice the rate, read at + // every other sample, is the reference itself from an even sample + // and the reference half a frame late from an odd one + void finder_finds_a_fractional_shift() { + const LatencyCheck::Layout layout = firstEvents(4); + const samples_t reference = LatencyCheck::generate(layout); + const samples_t doubled = LatencyCheck::generate(firstEvents(4, 2 * kRate)); + QCOMPARE(doubled.size(), 2 * reference.size()); + + int different = 0; + for (size_t i = 0; i < reference.size(); ++i) { + if (doubled[2 * i] != reference[i]) ++different; + } + QCOMPARE(different, 0); + + const frame_t whole = 100; + samples_t take(reference.size(), 0.f); + for (frame_t i = 0; i < frame_t(take.size()); ++i) { + frame_t j = 2 * i - (2 * whole + 1); + if (j >= 0) take[i] = doubled[j]; + } + + for (int i = 0; i < int(layout.events.size()); ++i) { + LatencyCheck::Arrival a = find(take, kRate, expectedAt(layout, i)); + QVERIFY2(a.found, describe(a).constData()); + QVERIFY2(a.errorFrames == whole || a.errorFrames == whole + 1, + describe(a).constData()); + QVERIFY2(std::fabs(a.errorSeconds - (whole + 0.5) / kRate) + <= 0.5 / kRate + 1e-9, describe(a).constData()); + } + } + + // A device at 48 kHz: the take's frames are 48 kHz frames, and the + // expected times come from the session's 44.1 kHz layout. The take + // is the same reference made at 48 kHz, the same times in seconds + void finder_works_at_the_take_rate() { + const double deviceRate = 48000.0; + const LatencyCheck::Layout session = firstEvents(4); + const LatencyCheck::Layout device = firstEvents(4, deviceRate); + for (int i = 0; i < 4; ++i) { + QCOMPARE(device.events[i].sweepStart * 441, + session.events[i].sweepStart * 480); + } + + const frame_t shift = 4801; + const samples_t take = shifted(LatencyCheck::generate(device), shift); + for (int i = 0; i < 4; ++i) { + LatencyCheck::Arrival a = + find(take, deviceRate, expectedAt(session, i)); + QVERIFY2(a.found, describe(a).constData()); + QCOMPARE(a.errorFrames, shift); + QVERIFY2(std::fabs(a.errorSeconds - shift / deviceRate) < 1e-9, + describe(a).constData()); + QVERIFY2(std::fabs(a.levelDb) < 0.01, describe(a).constData()); + } + } + + // White noise as loud as the sweep, and ten times louder in power. + // The sweep is still found to the frame; what the noise changes is + // how sure the finder is + void finder_hears_through_noise() { + const LatencyCheck::Layout layout = firstEvents(4); + const frame_t shift = 12345; + const samples_t take = shifted(LatencyCheck::generate(layout), shift); + const double sweepRms = rms(LatencyCheck::sweep(kRate)); + + std::vector sureness(layout.events.size(), 1000.0); + for (double snr : { 0.0, -10.0 }) { + // Uniform noise: its RMS is its amplitude over root 3 + const double amplitude = sweepRms * ratioOf(-snr) * std::sqrt(3.0); + const samples_t noisy = + mixed(take, TestSignals::whiteNoise(int(take.size()), 20260925, + amplitude), 1.0); + for (int i = 0; i < int(layout.events.size()); ++i) { + LatencyCheck::Arrival a = find(noisy, kRate, expectedAt(layout, i)); + QVERIFY2(a.found, describe(a).constData()); + QVERIFY2(std::abs(a.errorFrames - shift) <= 1, describe(a).constData()); + QVERIFY2(a.peakOverMedianDb < sureness[i], describe(a).constData()); + sureness[i] = a.peakOverMedianDb; + } + } + } + + // A small speaker, or an earcup held to a mic: nothing much below + // 1 kHz or above 4 kHz. The filters delay the band a little + // themselves (the 4 kHz low-pass by about 60 microseconds), which + // is all the error there is + void finder_hears_a_band_limited_path() { + const LatencyCheck::Layout layout = firstEvents(4); + const frame_t shift = 12345; + const samples_t take = shifted(LatencyCheck::generate(layout), shift); + + const samples_t highPassed = filtered(take, kRate, 1000.0, true); + const samples_t lowPassed = filtered(take, kRate, 4000.0, false); + const samples_t both = filtered(highPassed, kRate, 4000.0, false); + + for (const samples_t *path : { &highPassed, &lowPassed, &both }) { + for (int i = 0; i < int(layout.events.size()); ++i) { + LatencyCheck::Arrival a = find(*path, kRate, expectedAt(layout, i)); + QVERIFY2(a.found, describe(a).constData()); + QVERIFY2(std::fabs(a.errorSeconds - shift / kRate) < 0.0001, + describe(a).constData()); + } + } + } + + // A speaker or mic wired the other way round + void finder_ignores_polarity() { + const LatencyCheck::Layout layout = firstEvents(4); + const frame_t shift = 12345; + samples_t take = shifted(LatencyCheck::generate(layout), shift); + for (float &v : take) v = -v; + + for (int i = 0; i < int(layout.events.size()); ++i) { + LatencyCheck::Arrival a = find(take, kRate, expectedAt(layout, i)); + QVERIFY2(a.found, describe(a).constData()); + QCOMPARE(a.errorFrames, shift); + QVERIFY2(std::fabs(a.levelDb) < 0.01, describe(a).constData()); + } + } + + // A reflection 7 ms after the direct sound, and stronger than it: + // the direct sound's time is the one wanted. Exactly kEarliestPeakDb + // stronger is the rule's edge, where rounding decides, so the test + // is half a dB inside it; a dB outside it the reflection is taken, + // which is where the rule gives up + void finder_takes_the_direct_sound_before_a_stronger_reflection() { + const LatencyCheck::Layout layout = firstEvents(4); + const samples_t reference = LatencyCheck::generate(layout); + const frame_t shift = 12345; + const frame_t reflection = framesOf(0.007); + const samples_t direct = shifted(reference, shift); + const samples_t late = shifted(reference, shift + reflection); + + const samples_t inside = + mixed(direct, late, ratioOf(LatencyCheck::kEarliestPeakDb - 0.5)); + const samples_t outside = + mixed(direct, late, ratioOf(LatencyCheck::kEarliestPeakDb + 1.0)); + + for (int i = 0; i < int(layout.events.size()); ++i) { + LatencyCheck::Arrival a = find(inside, kRate, expectedAt(layout, i)); + QVERIFY2(a.found, describe(a).constData()); + QCOMPARE(a.errorFrames, shift); + + a = find(outside, kRate, expectedAt(layout, i)); + QVERIFY2(a.found, describe(a).constData()); + QCOMPARE(a.errorFrames, shift + reflection); + } + } + + // A path that loses the middle of the band (2.75 to 6.25 kHz: the + // middle 100 ms of each sweep) makes the envelope beat, with + // near-equal peaks a fraction of a millisecond apart. Those are one + // arrival, not an earlier one + void finder_takes_one_arrival_as_one() { + const LatencyCheck::Layout layout = firstEvents(4); + const frame_t shift = 12345; + samples_t take = shifted(LatencyCheck::generate(layout), shift); + for (const LatencyCheck::Event &e : layout.events) { + for (frame_t f = framesOf(0.05); f < framesOf(0.15); ++f) { + take[e.sweepStart + shift + f] = 0.f; + } + } + + for (int i = 0; i < int(layout.events.size()); ++i) { + LatencyCheck::Arrival a = find(take, kRate, expectedAt(layout, i)); + QVERIFY2(a.found, describe(a).constData()); + QCOMPARE(a.errorFrames, shift); + } + } + + // Nothing but silence, or nothing but room noise: no sweep there + void finder_finds_nothing_in_silence() { + const samples_t silence(size_t(framesOf(5.0)), 0.f); + const samples_t noise = + TestSignals::whiteNoise(int(framesOf(5.0)), 7, 0.05); + + for (double expected : { 1.0, 2.5, 4.0 }) { + LatencyCheck::Arrival a = find(silence, kRate, expected); + QVERIFY2(!a.found, describe(a).constData()); + QVERIFY2(a.peakOverMedianDb < LatencyCheck::kMinPeakOverMedianDb, + describe(a).constData()); + + a = find(noise, kRate, expected); + QVERIFY2(!a.found, describe(a).constData()); + } + } + + // The sweep's level as it arrived, and the loudest sample around it + void finder_reports_levels() { + const LatencyCheck::Layout layout = firstEvents(1); + samples_t take = shifted(LatencyCheck::generate(layout), 12345); + for (float &v : take) v *= 0.5f; + + LatencyCheck::Arrival a = find(take, kRate, expectedAt(layout, 0)); + QVERIFY2(a.found, describe(a).constData()); + QVERIFY2(std::fabs(a.levelDb - 20.0 * std::log10(0.5)) < 0.01, + describe(a).constData()); + QVERIFY(std::fabs(a.inputPeak - + 0.5 * ratioOf(LatencyCheck::kPeakDbfs)) < 1e-5); + } + + // The window stops at the ends of the take + void finder_window_stops_at_the_ends_of_the_take() { + const LatencyCheck::Layout layout = firstEvents(1); + const samples_t reference = LatencyCheck::generate(layout); + const frame_t sweepStart = layout.events[0].sweepStart; + + // From 0.1 s before the sweep to 50 ms after it: less than the + // reach on either side + const samples_t take(reference.begin() + (sweepStart - framesOf(0.1)), + reference.begin() + (sweepStart + framesOf(0.25))); + + LatencyCheck::Arrival a = find(take, kRate, 0.1); + QVERIFY2(a.found, describe(a).constData()); + QCOMPARE(a.errorFrames, frame_t(0)); + + // Expected before the take begins, and still within reach + a = find(take, kRate, -0.5); + QVERIFY2(a.found, describe(a).constData()); + QCOMPARE(a.errorFrames, framesOf(0.6)); + + // Out of reach either side, or no take at all + QVERIFY(!find(take, kRate, 5.0).found); + QVERIFY(!find(take, kRate, -1.0).found); + QVERIFY(!find(samples_t(), kRate, 0.1).found); + } + + // Judging a take. Four punch-ins all placed the same, 12345 frames + // late: every judged event found at that offset, in the order the + // punch-ins were recorded, and nothing else to say + void judge_a_steady_take() { + const LatencyCheck::Layout layout = LatencyCheck::calibrationLayout(); + const frame_t shift = 12345; + const punchins_t punchIns = fourPunchIns(); + const samples_t take = spliced(LatencyCheck::generate(layout), kRate, + punchIns, { shift, shift, shift, shift }); + + const LatencyCheck::TakeSummary s = judge(layout, take, kRate, punchIns); + QVERIFY2(s.verdict == LatencyCheck::Verdict::Ok, describe(s).constData()); + QVERIFY2(s.flags.empty(), describe(s).constData()); + QCOMPARE(s.judged, 7); + QCOMPARE(s.found, 7); + QVERIFY2(std::fabs(s.medianOffset - shift / kRate) < 1e-9, + describe(s).constData()); + QVERIFY2(s.spread < 1e-9 && std::fabs(s.slope) < 1e-9 && + s.slopeResidual < 1e-9, describe(s).constData()); + QVERIFY2(std::fabs(s.inputPeak - ratioOf(LatencyCheck::kPeakDbfs)) < 1e-5, + describe(s).constData()); + QVERIFY2(std::fabs(s.fadingDb) < 0.01, describe(s).constData()); + QVERIFY2(!s.echo.heard, describe(s).constData()); + + QCOMPARE(int(s.punchIns.size()), 4); + const int judgedIn[] = { 2, 2, 2, 1 }; + for (int p = 0; p < 4; ++p) { + QCOMPARE(s.punchIns[p].range.start, punchIns[p].start); + QCOMPARE(s.punchIns[p].judged, judgedIn[p]); + QCOMPARE(s.punchIns[p].found, judgedIn[p]); + QVERIFY(std::fabs(s.punchIns[p].medianOffset - shift / kRate) < 1e-9); + QVERIFY(s.punchIns[p].spread < 1e-9); + } + + const int events[] = { 1, 2, 4, 5, 7, 8, 10 }; + const int in[] = { 0, 0, 1, 1, 2, 2, 3 }; + QCOMPARE(int(s.events.size()), 7); + for (int k = 0; k < 7; ++k) { + QCOMPARE(s.events[k].event, events[k]); + QCOMPARE(s.events[k].punchIn, in[k]); + QCOMPARE(s.events[k].expectedSeconds, expectedAt(layout, events[k])); + QCOMPARE(s.events[k].arrival.errorFrames, shift); + } + } + + // A sweep lost, in room noise. Found out of judged is what counts: + // one or two of six lost still measures, three is too many, and + // silence is nothing at all. The first sweep lost is in the second + // half of the run, and one of three there does not make it Fading + void judge_a_take_with_events_missing() { + const LatencyCheck::Layout layout = LatencyCheck::calibrationLayout(); + const frame_t shift = 12345; + const punchins_t punchIns = { { 2.0, 7.0 }, { 8.0, 13.0 }, { 14.0, 19.0 } }; + samples_t take = spliced(LatencyCheck::generate(layout), kRate, + punchIns, { shift, shift, shift }); + + // Events 1, 2 / 4, 5 / 7, 8 judged + scaleSweep(take, layout, 5, shift, 0.0); + LatencyCheck::TakeSummary s = + judge(layout, withRoomNoise(take), kRate, punchIns); + QVERIFY2(s.verdict == LatencyCheck::Verdict::Ok, describe(s).constData()); + QCOMPARE(s.judged, 6); + QCOMPARE(s.found, 5); + QCOMPARE(s.punchIns[1].found, 1); + QVERIFY(!s.events[3].arrival.found); + QCOMPARE(s.events[3].event, 5); + QVERIFY2(std::abs(s.medianOffset * kRate - shift) < 1.0, + describe(s).constData()); + + // Exactly two thirds found + scaleSweep(take, layout, 1, shift, 0.0); + s = judge(layout, withRoomNoise(take), kRate, punchIns); + QCOMPARE(s.found, 4); + QVERIFY2(!s.flagged(LatencyCheck::Verdict::NoSignal), describe(s).constData()); + + scaleSweep(take, layout, 7, shift, 0.0); + s = judge(layout, withRoomNoise(take), kRate, punchIns); + QCOMPARE(s.found, 3); + QVERIFY2(s.verdict == LatencyCheck::Verdict::NoSignal, describe(s).constData()); + + s = judge(layout, withRoomNoise(samples_t(take.size(), 0.f)), kRate, + punchIns); + QCOMPARE(s.judged, 6); + QCOMPARE(s.found, 0); + QVERIFY2(s.verdict == LatencyCheck::Verdict::NoSignal, describe(s).constData()); + QCOMPARE(s.medianOffset, 0.0); + } + + // Something in the input path taking the sweeps out as the run goes + // on: 5 dB quieter at every event, down to -30 dB, all still found + void judge_a_fading_take() { + const LatencyCheck::Layout layout = LatencyCheck::calibrationLayout(); + const frame_t shift = 12345; + const punchins_t punchIns = fourPunchIns(); + samples_t take = spliced(LatencyCheck::generate(layout), kRate, + punchIns, { shift, shift, shift, shift }); + const int events[] = { 1, 2, 4, 5, 7, 8, 10 }; + for (int k = 0; k < 7; ++k) { + scaleSweep(take, layout, events[k], shift, ratioOf(-5.0 * k)); + } + + const LatencyCheck::TakeSummary s = judge(layout, take, kRate, punchIns); + QVERIFY2(s.verdict == LatencyCheck::Verdict::Fading, describe(s).constData()); + QCOMPARE(int(s.flags.size()), 1); + QCOMPARE(s.found, 7); + // -5 dB against -25, the halves' medians + QVERIFY2(std::fabs(s.fadingDb - 20.0) < 0.1, describe(s).constData()); + QVERIFY2(std::fabs(s.medianOffset - shift / kRate) < 1e-9, + describe(s).constData()); + } + + // Too loud for the input: clipped at full scale. Found where they + // are all the same, but the verdict is the level. Just under it + // (-1.1 dBFS) is fine + void judge_a_clipped_take() { + const LatencyCheck::Layout layout = LatencyCheck::calibrationLayout(); + const frame_t shift = 12345; + const punchins_t punchIns = fourPunchIns(); + const samples_t take = spliced(LatencyCheck::generate(layout), kRate, + punchIns, { shift, shift, shift, shift }); + + samples_t loud(take), clipped(take); + for (float &v : loud) v *= 3.5f; + for (float &v : clipped) v = std::max(-1.f, std::min(1.f, v * 8.f)); + + LatencyCheck::TakeSummary s = judge(layout, clipped, kRate, punchIns); + QVERIFY2(s.verdict == LatencyCheck::Verdict::Clipped, describe(s).constData()); + QCOMPARE(s.inputPeak, 1.0); + QCOMPARE(s.found, 7); + QVERIFY2(std::abs(s.medianOffset * kRate - shift) < 1.0, + describe(s).constData()); + + s = judge(layout, loud, kRate, punchIns); + QVERIFY2(s.verdict == LatencyCheck::Verdict::Ok, describe(s).constData()); + QVERIFY2(s.inputPeak < ratioOf(-1.0), describe(s).constData()); + } + + // A device at 48 kHz, its take placed frame for frame on the 44.1 + // kHz timeline: each punch-in lands early by P (1 - 44100/48000), + // P being where it starts, and the take is read at 48 kHz. On a + // line, so PositionDependent (and Scattered, since it is). The same + // punch-ins at offsets whose line is as steep but leaves 30 ms + // about it: Scattered. The punch-ins stay under 10 s, where the + // misplacement is still within the finder's reach + void judge_position_dependent_against_scattered() { + const double deviceRate = 48000.0; + const LatencyCheck::Layout layout = LatencyCheck::calibrationLayout(); + const samples_t recorded = + LatencyCheck::generate(LatencyCheck::calibrationLayout(deviceRate)); + + // Events 0, 1 and 3, one each + const punchins_t punchIns = { { 0.1, 2.1 }, { 2.2, 4.2 }, { 6.3, 8.3 } }; + std::vector shifts; + for (const LatencyCheck::PunchIn &p : punchIns) { + shifts.push_back(-framesOf(p.start * (1.0 - kRate / deviceRate), + deviceRate)); + } + + LatencyCheck::TakeSummary s = + judge(layout, spliced(recorded, deviceRate, punchIns, shifts), + deviceRate, punchIns); + QCOMPARE(s.found, 3); + QVERIFY2(s.verdict == LatencyCheck::Verdict::PositionDependent, + describe(s).constData()); + QVERIFY2(s.flagged(LatencyCheck::Verdict::Scattered), describe(s).constData()); + QVERIFY2(std::fabs(s.slope + (1.0 - kRate / deviceRate)) < 1e-4, + describe(s).constData()); + QVERIFY2(s.slopeResidual < 0.0001, describe(s).constData()); + + // Slope about -0.7 %, residuals +12, -18 and +6 ms + const double offsets[] = { 0.0, -0.045, -0.050 }; + for (int p = 0; p < 3; ++p) shifts[p] = framesOf(offsets[p], deviceRate); + s = judge(layout, spliced(recorded, deviceRate, punchIns, shifts), + deviceRate, punchIns); + QCOMPARE(s.found, 3); + QVERIFY2(std::fabs(s.slope) > LatencyCheck::kPositionSlope, + describe(s).constData()); + QVERIFY2(s.verdict == LatencyCheck::Verdict::Scattered, + describe(s).constData()); + QVERIFY2(!s.flagged(LatencyCheck::Verdict::PositionDependent), + describe(s).constData()); + } + + // Two stream starts that placed their punch-ins differently. Two + // punch-ins always lie on a line, so however steep (here 20 ms over + // 2.1 s), it is never PositionDependent + void judge_two_punch_ins_apart() { + const LatencyCheck::Layout layout = firstEvents(3); + const samples_t reference = LatencyCheck::generate(layout); + const punchins_t punchIns = { { 0.1, 2.1 }, { 2.2, 4.2 } }; + const frame_t shift = framesOf(0.1); + + struct { double apart; LatencyCheck::Verdict verdict; } cases[] = { + { 0.020, LatencyCheck::Verdict::Scattered }, + { 0.010, LatencyCheck::Verdict::Unsteady }, + { 0.003, LatencyCheck::Verdict::Ok }, + }; + for (const auto &c : cases) { + const frame_t other = shift + framesOf(c.apart); + const LatencyCheck::TakeSummary s = + judge(layout, spliced(reference, kRate, punchIns, { shift, other }), + kRate, punchIns); + QCOMPARE(s.found, 2); + QVERIFY2(s.verdict == c.verdict, describe(s).constData()); + QCOMPARE(int(s.flags.size()), c.verdict == LatencyCheck::Verdict::Ok ? 0 : 1); + QVERIFY2(std::fabs(s.spread - c.apart) < 2.0 / kRate, + describe(s).constData()); + QVERIFY2(std::fabs(s.medianOffset - (shift + other) / 2.0 / kRate) + < 1e-9, describe(s).constData()); + } + } + + // The take's own input played back out and heard again, 40 ms + // later and 12 dB down: named, and the take still measures. At 3 + // dB down it hides the sweeps from the finder, and is still named. + // Noise at 0 dB SNR has second peaks as loud, at no one delay. A + // reflection 9 ms late and 5.5 dB stronger leaves a second peak + // at 10.1 ms, 23 dB down, after every sweep: too early for an echo + void judge_hears_a_monitoring_echo() { + const LatencyCheck::Layout layout = LatencyCheck::calibrationLayout(); + const frame_t shift = 12345; + const punchins_t punchIns = fourPunchIns(); + const samples_t take = spliced(LatencyCheck::generate(layout), kRate, + punchIns, { shift, shift, shift, shift }); + const frame_t delay = framesOf(0.040); + + LatencyCheck::TakeSummary s = + judge(layout, mixed(take, shifted(take, delay), ratioOf(-12.0)), + kRate, punchIns); + QVERIFY2(s.verdict == LatencyCheck::Verdict::Ok, describe(s).constData()); + QVERIFY2(s.echo.heard, describe(s).constData()); + QCOMPARE(s.echo.events, 7); + QVERIFY2(std::fabs(s.echo.delaySeconds - delay / kRate) < 1e-9, + describe(s).constData()); + QVERIFY2(std::fabs(s.echo.levelDb + 12.0) < 0.5, describe(s).constData()); + + s = judge(layout, mixed(take, shifted(take, delay), ratioOf(-3.0)), + kRate, punchIns); + QCOMPARE(s.found, 0); + QVERIFY2(s.verdict == LatencyCheck::Verdict::NoSignal, describe(s).constData()); + QVERIFY2(s.echo.heard, describe(s).constData()); + QVERIFY2(std::fabs(s.echo.levelDb + 3.0) < 0.5, describe(s).constData()); + + const double sweepRms = rms(LatencyCheck::sweep(kRate)); + const samples_t noisy = + mixed(take, TestSignals::whiteNoise(int(take.size()), 20260925, + sweepRms * std::sqrt(3.0)), 1.0); + s = judge(layout, noisy, kRate, punchIns); + QCOMPARE(s.found, 7); + for (const LatencyCheck::EventResult &e : s.events) { + QVERIFY(e.arrival.secondLevelDb > -LatencyCheck::kEchoMaxBelowDb); + } + QVERIFY2(!s.echo.heard, describe(s).constData()); + + s = judge(layout, mixed(take, shifted(take, framesOf(0.009)), + ratioOf(5.5)), + kRate, punchIns); + QCOMPARE(s.found, 7); + QVERIFY2(std::fabs(s.medianOffset - shift / kRate) < 1e-9, + describe(s).constData()); + for (const LatencyCheck::EventResult &e : s.events) { + QVERIFY(e.arrival.secondLevelDb > -LatencyCheck::kEchoMaxBelowDb); + } + QVERIFY2(!s.echo.heard, describe(s).constData()); + } + + // What the finder reads for an event has to lie inside the punch-in, + // kJudgeMarginSeconds from its ends; otherwise the event is not + // judged, since a sweep cut by the splice is no fault of the path. + // Where a later punch-in overlaps, the event is judged in that one + void judge_only_events_inside_a_punch_in() { + const LatencyCheck::Layout layout = LatencyCheck::calibrationLayout(); + const samples_t reference = LatencyCheck::generate(layout); + const frame_t shift = framesOf(0.1); + const double before = + LatencyCheck::kSearchSeconds + LatencyCheck::kJudgeMarginSeconds; + const double after = LatencyCheck::kSearchSeconds + + LatencyCheck::kSweepSeconds + LatencyCheck::kJudgeMarginSeconds; + + // The sweep at 3.1 s arrives at 3.2 s, where the range ends + punchins_t punchIns = { { 2.0, 3.2 } }; + LatencyCheck::TakeSummary s = + judge(layout, spliced(reference, kRate, punchIns, { shift }), + kRate, punchIns); + QCOMPARE(s.judged, 0); + QVERIFY2(s.verdict == LatencyCheck::Verdict::NoSignal, describe(s).constData()); + + // Just inside the margin at either end, then just outside it + const double t = expectedAt(layout, 4); + for (double by : { 0.001, -0.001 }) { + const bool inside = by > 0.0; + for (const LatencyCheck::PunchIn &p : + { LatencyCheck::PunchIn(t - before - by, t + after + 0.1), + LatencyCheck::PunchIn(t - before - 0.1, t + after + by) }) { + punchIns = { p }; + s = judge(layout, spliced(reference, kRate, punchIns, { shift }), + kRate, punchIns); + QCOMPARE(s.judged, inside ? 1 : 0); + QCOMPARE(s.found, inside ? 1 : 0); + } + } + + // Event 5 in the first punch-in only; event 6 in both, so in the + // second, which recorded over the first. They are far enough + // apart for the second to start after all the finder reads for + // event 5 + const double t5 = expectedAt(layout, 5); + const double t6 = expectedAt(layout, 6); + QVERIFY(t6 - t5 > before + after + 0.2); + const frame_t later = framesOf(0.2); + punchIns = { { t5 - before - 0.1, t6 + after + 0.1 }, + { t6 - before - 0.1, t6 + after + 0.1 } }; + s = judge(layout, spliced(reference, kRate, punchIns, { shift, later }), + kRate, punchIns); + QCOMPARE(s.judged, 2); + QCOMPARE(s.punchIns[0].judged, 1); + QCOMPARE(s.punchIns[1].judged, 1); + QCOMPARE(s.events[0].event, 5); + QCOMPARE(s.events[1].event, 6); + QCOMPARE(s.events[1].punchIn, 1); + QCOMPARE(s.events[1].arrival.errorFrames, later); + } + + // The punch-ins of a run of takes. Each holds the events asked for, + // and judgeTake() judges those in it and no other: the reference + // itself, as a take placed right, finds every one where it is. Also + // with the ranges rounded to whole frames, as the takes record them. + // Between two ranges an event may have to be left out: in the + // calibration layout the sweep at 4.7 s is only 1.6 s after the one + // at 3.1 s, and the finder reads 1.9 s around the two of them + void punch_ins_hold_the_events_asked_for() { + const LatencyCheck::Layout layout = LatencyCheck::calibrationLayout(); + const samples_t reference = LatencyCheck::generate(layout); + const double length = double(layout.length) / layout.rate; + + struct Case { int count; int each; }; + // 4 x 3 is what a calibration records (spec 2), and fits only + // because the spacings where the punch-ins meet are wide enough + for (Case c : { Case { 2, 2 }, Case { 4, 2 }, Case { 3, 3 }, + Case { 4, 3 }, Case { 1, 12 } }) { + + const punchins_t exact = + LatencyCheck::punchInsFor(layout, c.count, c.each); + QCOMPARE(int(exact.size()), c.count); + + punchins_t rounded; + for (const LatencyCheck::PunchIn &p : exact) { + rounded.push_back(LatencyCheck::PunchIn + (framesOf(p.start) / kRate, + framesOf(p.end) / kRate)); + } + + for (const punchins_t &punchIns : { exact, rounded }) { + for (int p = 0; p < c.count; ++p) { + QVERIFY(punchIns[p].start >= 0.0); + QVERIFY(punchIns[p].end <= length); + QVERIFY(punchIns[p].end > punchIns[p].start); + if (p > 0) QVERIFY(punchIns[p].start >= punchIns[p-1].end); + } + + const LatencyCheck::TakeSummary s = + judge(layout, reference, kRate, punchIns); + QVERIFY2(s.verdict == LatencyCheck::Verdict::Ok, + describe(s).constData()); + QCOMPARE(s.judged, c.count * c.each); + QCOMPARE(s.found, c.count * c.each); + for (int p = 0; p < c.count; ++p) { + QCOMPARE(s.punchIns[p].judged, c.each); + } + // Consecutive events within each punch-in + for (int k = 1; k < s.judged; ++k) { + if (s.events[k].punchIn == s.events[k-1].punchIn) { + QCOMPARE(s.events[k].event, s.events[k-1].event + 1); + } + } + } + } + + // The two punch-ins of the app suite's check: the event at 4.7 s + // is left out + const punchins_t two = LatencyCheck::punchInsFor(layout, 2, 2); + const LatencyCheck::TakeSummary s = judge(layout, reference, kRate, two); + const int events[] = { 0, 1, 3, 4 }; + QCOMPARE(int(s.events.size()), 4); + for (int k = 0; k < 4; ++k) QCOMPARE(s.events[k].event, events[k]); + + // More than the layout holds, and nothing asked for + QVERIFY(LatencyCheck::punchInsFor(layout, 7, 2).empty()); + QVERIFY(LatencyCheck::punchInsFor(layout, 1, 13).empty()); + QVERIFY(LatencyCheck::punchInsFor(layout, 0, 2).empty()); + } + + // Punch-ins placed with different round trips, as on a phone, whose + // reported latencies move from one stream start to the next (Oboe + // measures them each time), through a path whose round trip stays + // 0.12 s. Each lands late by what its round trip fell short: 20, 12, + // 17 and 9 ms. Counted as if placed with the first one's, they agree: + // Ok, and the round trip calibrated from the first one's is the + // path's. Not told how they were placed, the placing looks like the + // device moving: 11 ms apart, Unsteady, and 5.5 ms short + void judge_punch_ins_placed_differently() { + const LatencyCheck::Layout layout = LatencyCheck::calibrationLayout(); + const samples_t reference = LatencyCheck::generate(layout); + const double roundTrip = 0.120; + const double used[] = { 0.100, 0.108, 0.103, 0.111 }; + + punchins_t punchIns = fourPunchIns(); + std::vector shifts; + for (int p = 0; p < 4; ++p) { + shifts.push_back(framesOf(roundTrip - used[p])); + } + const samples_t take = spliced(reference, kRate, punchIns, shifts); + + LatencyCheck::TakeSummary s = judge(layout, take, kRate, punchIns); + QCOMPARE(s.found, 7); + QVERIFY2(s.verdict == LatencyCheck::Verdict::Unsteady, + describe(s).constData()); + QVERIFY2(std::fabs(LatencyCheck::calibratedRoundTrip + (used[0], s.medianOffset) - roundTrip) > 0.005, + describe(s).constData()); + + for (int p = 0; p < 4; ++p) punchIns[p].placedWith = used[p]; + s = judge(layout, take, kRate, punchIns); + QCOMPARE(s.found, 7); + QVERIFY2(s.verdict == LatencyCheck::Verdict::Ok, describe(s).constData()); + QVERIFY2(s.spread < 1.5 / kRate && s.slopeResidual < 1.5 / kRate, + describe(s).constData()); + QVERIFY2(std::fabs(LatencyCheck::calibratedRoundTrip + (used[0], s.medianOffset) - roundTrip) < 1.0 / kRate, + describe(s).constData()); + + // Where each landed is kept as it was + for (int p = 0; p < 4; ++p) { + QVERIFY2(std::fabs(s.punchIns[p].medianOffset - + shifts[p] / kRate) < 1e-9, + describe(s).constData()); + QCOMPARE(s.punchIns[p].range.placedWith, used[p]); + } + } + + // The calibration's arithmetic. A take that landed late was placed + // with too small a round trip, and the new one is larger by the + // offset; early, smaller. From takes placed with 0.2 and 0.31 s + // through a path whose round trip is 0.25 s + void calibration_adds_the_offset() { + QVERIFY(std::fabs(LatencyCheck::calibratedRoundTrip(0.2, 0.03) - 0.23) + < 1e-12); + QVERIFY(std::fabs(LatencyCheck::calibratedRoundTrip(0.2, -0.05) - 0.15) + < 1e-12); + + const LatencyCheck::Layout layout = LatencyCheck::calibrationLayout(); + const samples_t reference = LatencyCheck::generate(layout); + const punchins_t punchIns = { { 2.0, 7.0 } }; + const double roundTrip = 0.25; + + for (double used : { 0.2, 0.31 }) { + const frame_t late = framesOf(roundTrip - used); + const LatencyCheck::TakeSummary s = + judge(layout, spliced(reference, kRate, punchIns, { late }), + kRate, punchIns); + QCOMPARE(s.found, 2); + QVERIFY2((s.medianOffset > 0.0) == (used < roundTrip), + describe(s).constData()); + const double measured = + LatencyCheck::calibratedRoundTrip(used, s.medianOffset); + QVERIFY2(std::fabs(measured - roundTrip) < 1e-9, + describe(s).constData()); + } + } +}; + +#endif diff --git a/main/test/TestLatencyShift.h b/main/test/TestLatencyShift.h new file mode 100644 index 00000000..02d2056a --- /dev/null +++ b/main/test/TestLatencyShift.h @@ -0,0 +1,129 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TEST_LATENCY_SHIFT_H +#define TEST_LATENCY_SHIFT_H + +// Tier 2: what is done with the latency figure. A singing take is +// aligned with the reference by giving its model a negative start +// frame; these tests pin down what that does to reads. + +#include "../LatencyUtils.h" + +#include "data/model/WritableWaveFileModel.h" + +#include +#include +#include + +#include +#include + +class TestLatencyShift : public QObject +{ + Q_OBJECT + + static const int kLength = 8192; + static const int kImpulseAt = 1000; // the latency, N + + QTemporaryDir m_dir; + int m_fileCounter = 0; + + // A finished take: silence with a single impulse at kImpulseAt + std::shared_ptr makeTake() { + QString path = m_dir.filePath + (QString("take-%1.wav").arg(++m_fileCounter)); + auto model = std::make_shared + (path, 44100.0, 1, + sv::WritableWaveFileModel::Normalisation::None); + std::vector data(kLength, 0.f); + data[kImpulseAt] = 1.f; + const float *ptr = data.data(); + model->addSamples(&ptr, kLength); + model->writeComplete(); + return model; + } + +private slots: + void initTestCase() { + QVERIFY(m_dir.isValid()); + } + + void start_frame_shifts_reads() { + auto model = makeTake(); + QCOMPARE(model->getData(0, kImpulseAt, 1).size(), size_t(1)); + QCOMPARE(model->getData(0, kImpulseAt, 1)[0], 1.f); + + model->setStartFrame(-kImpulseAt); + + QCOMPARE(model->getStartFrame(), sv::sv_frame_t(-kImpulseAt)); + auto atZero = model->getData(0, 0, 1); + QCOMPARE(atZero.size(), size_t(1)); + QCOMPARE(atZero[0], 1.f); + auto atOld = model->getData(0, kImpulseAt, 1); + QCOMPARE(atOld.size(), size_t(1)); + QCOMPARE(atOld[0], 0.f); + } + + void negative_region_is_silent() { + auto model = makeTake(); + model->setStartFrame(-kImpulseAt); + + // Wholly before the start: nothing, or silence + for (float v : model->getData(0, -kImpulseAt - 100, 50)) { + QCOMPARE(v, 0.f); + } + + // Straddling the start: a short read is acceptable, but whatever + // comes back must be the opening silence of the file, not the + // impulse and not garbage + auto straddling = model->getData(0, -kImpulseAt - 10, 20); + QVERIFY(straddling.size() <= size_t(20)); + for (float v : straddling) { + QCOMPARE(v, 0.f); + } + } + + void end_frame_shifts() { + auto model = makeTake(); + sv::sv_frame_t before = model->getEndFrame(); + model->setStartFrame(-kImpulseAt); + QCOMPARE(model->getEndFrame(), before - kImpulseAt); + } + + void latency_sum() { + QCOMPARE(computeRecordingLatency(512, 256), sv::sv_frame_t(768)); + QCOMPARE(computeRecordingLatency(512, 0), sv::sv_frame_t(512)); + QCOMPARE(computeRecordingLatency(0, 256), sv::sv_frame_t(256)); + QCOMPARE(computeRecordingLatency(0, 0), sv::sv_frame_t(0)); + // An unavailable figure must never pull the take later + QCOMPARE(computeRecordingLatency(-1, 256), sv::sv_frame_t(256)); + QCOMPARE(computeRecordingLatency(-100, -100), sv::sv_frame_t(0)); + } + + // Review finding 8: a live dot goes where the finished pitch track + // will put the same sound + void live_dot_shift() { + QCOMPARE(compensatedLiveFrame(5000, 768), sv::sv_frame_t(4232)); + QCOMPARE(compensatedLiveFrame(768, 768), sv::sv_frame_t(0)); + QCOMPARE(compensatedLiveFrame(5000, 0), sv::sv_frame_t(5000)); + // Sound from before the reference started has nowhere to go: + // the caller drops anything negative + QVERIFY(compensatedLiveFrame(767, 768) < 0); + QVERIFY(compensatedLiveFrame(0, 768) < 0); + // An unavailable latency must never pull the dot later + QCOMPARE(compensatedLiveFrame(5000, -1), sv::sv_frame_t(5000)); + } +}; + +#endif diff --git a/main/test/TestLiveDotsFeed.h b/main/test/TestLiveDotsFeed.h new file mode 100644 index 00000000..0fa63dd9 --- /dev/null +++ b/main/test/TestLiveDotsFeed.h @@ -0,0 +1,317 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TEST_LIVE_DOTS_FEED_H +#define TEST_LIVE_DOTS_FEED_H + +// Tier 2: the live tracker's estimates brought to the GUI thread in +// batches. That a take's dots keep up with a slow GUI thread is +// TestRecordWorkflow's business (live_dots_keep_up_with_a_slow_gui). + +#include "../LiveDotsFeed.h" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +class TestLiveDotsFeed : public QObject +{ + Q_OBJECT + + typedef RealtimePitchTracker::Estimate Estimate; + typedef RealtimePitchTracker::Estimates Estimates; + + static constexpr int kInterval = 20; + + // What the tracker would hold: found on another thread, taken whole + struct Found { + std::mutex mutex; + Estimates estimates; + sv::sv_frame_t next = 1024; + + void add(int n) { + std::lock_guard guard(mutex); + for (int i = 0; i < n; ++i) { + estimates.push_back({ next, 220.0 }); + next += 256; + } + } + Estimates take() { + Estimates taken; + std::lock_guard guard(mutex); + taken.swap(estimates); + return taken; + } + int waiting() { + std::lock_guard guard(mutex); + return int(estimates.size()); + } + }; + + static void busy(int ms) { + QElapsedTimer timer; + timer.start(); + while (timer.elapsed() < ms) { } + } + + // A widget, as far as its paint events go: they take a while + class Painted : public QObject + { + public: + int paints = 0; + bool event(QEvent *e) override { + if (e->type() == QEvent::Paint) { + ++paints; + busy(5); + return true; + } + return QObject::event(e); + } + }; + +private slots: + void hands_over_everything_at_once() { + Found found; + std::vector batches; + int looked = 0; + LiveDotsFeed feed(kInterval); + found.add(100); + feed.start([&]() { ++looked; return found.take(); }, + [&](const Estimates &e) { batches.push_back(e); }); + // Until it has looked a few times, however late the timer fires + QTRY_VERIFY_WITH_TIMEOUT(looked >= 3, 5000); + feed.stop(); + + QCOMPARE(int(batches.size()), 1); + QCOMPARE(int(batches[0].size()), 100); + for (int i = 1; i < 100; ++i) { + QVERIFY(batches[0][i].frame > batches[0][i-1].frame); + } + QCOMPARE(found.waiting(), 0); + } + + void nothing_found_nothing_handed_over() { + int looked = 0, handed = 0; + LiveDotsFeed feed(kInterval); + feed.start([&]() { ++looked; return Estimates(); }, + [&](const Estimates &) { ++handed; }); + // Until it has looked a few times, however late the timer fires: + // three times late and more on CI's macOS + QTRY_VERIFY_WITH_TIMEOUT(looked >= 3, 5000); + feed.stop(); + QCOMPARE(handed, 0); + } + + // The point of it all: estimates come in faster than a slow GUI + // thread could take them one by one, and it keeps up all the same, + // with bigger batches, one a look + void a_slow_gui_takes_bigger_batches() { + Found found; + std::atomic produced(0); + std::atomic producing(true); + std::thread producer([&]() { + // One a hop, as the tracker finds them: 5.8 ms. Counted by the + // clock, not slept one by one: a sleep can take far longer + // than asked (5 times on CI's macOS), and the GUI would then + // not be slow next to the estimates at all + QElapsedTimer clock; + clock.start(); + while (producing) { + int due = int(clock.nsecsElapsed() / 5800000); + int now = produced; + if (due > now) { + found.add(due - now); + produced = due; + } + std::this_thread::sleep_for(std::chrono::microseconds(5800)); + } + }); + + int handedOver = 0, calls = 0, worstBehind = 0; + LiveDotsFeed feed(kInterval); + QElapsedTimer timer; + timer.start(); + feed.start([&]() { return found.take(); }, + [&](const Estimates &e) { + ++calls; + handedOver += int(e.size()); + // far more than an estimate's worth of time + busy(25); + worstBehind = std::max + (worstBehind, produced - handedOver); + }); + QTest::qWait(1500); + producing = false; + producer.join(); + QTRY_COMPARE_WITH_TIMEOUT(found.waiting(), 0, 5000); + feed.stop(); + qint64 elapsed = timer.elapsed(); + + // Everything arrived, and never more than a look and a slow + // batch's worth of them waited: 45 ms is 8 estimates; handed over + // one at a time, 25 ms each, some 1100 would be waiting by now + QCOMPARE(handedOver, int(produced)); + QCOMPARE(found.waiting(), 0); + QVERIFY2(worstBehind <= 40, + qPrintable(QString("%1 estimates were waiting at worst") + .arg(worstBehind))); + QVERIFY2(calls <= elapsed / kInterval + 2, + qPrintable(QString("%1 batches in %2 ms") + .arg(calls).arg(elapsed))); + QVERIFY2(double(handedOver) / calls >= 4.0, + qPrintable(QString("%1 estimates in %2 batches") + .arg(handedOver).arg(calls))); + } + + void stop_leaves_the_rest_with_the_source() { + Found found; + int handed = 0; + LiveDotsFeed feed(kInterval); + feed.start([&]() { return found.take(); }, + [&](const Estimates &) { ++handed; }); + QVERIFY(feed.isRunning()); + feed.stop(); + QVERIFY(!feed.isRunning()); + found.add(10); + QTest::qWait(kInterval * 5); + QCOMPARE(handed, 0); + QCOMPARE(found.waiting(), 10); + } + + void reports_once_a_second() { + Found found; + std::vector reports; + int handedOver = 0; + sv::sv_frame_t newest = -1; + // The looks and batches as the test sees them, by its own clock, + // and how many of each there had been when each report was made: + // what a report says is checked against these, not against how + // punctual the machine's timers are (three times late and more + // on CI's macOS) + QElapsedTimer clock; + std::vector looks; + int batches = 0; + std::vector> atReport; + LiveDotsFeed feed(kInterval); + feed.setReporter([&](const LiveDotsFeed::Report &r) { + reports.push_back(r); + atReport.push_back({ int(looks.size()), batches }); + }); + clock.start(); + feed.start([&]() { + looks.push_back(double(clock.nsecsElapsed()) / 1.0e6); + return found.take(); + }, + [&](const Estimates &e) { + ++batches; + handedOver += int(e.size()); + newest = e.back().frame; + }); + QElapsedTimer timer; + timer.start(); + while (timer.elapsed() < 2300) { + found.add(3); + QTest::qWait(30); + } + feed.stop(); + + QCOMPARE(int(reports.size()), 2); + int reported = 0; + for (int i = 0; i < 2; ++i) { + const auto &r = reports[i]; + QVERIFY2(r.seconds >= 0.95 && r.seconds < 1.5, + qPrintable(QString("a report of %1 s").arg(r.seconds))); + // The looks whose interval since the one before this report + // holds: the first look of all has none + int first = (i == 0 ? 1 : atReport[i-1].first); + int last = atReport[i].first; + QVERIFY(last - first >= 5); + QCOMPARE(r.intervals.count, last - first); + double seen = (looks[last - 1] - looks[first - 1]) / + (last - first); + QVERIFY2(std::fabs(r.intervals.averageMs() - seen) < 1.0, + qPrintable(QString("reported a look every %1 ms, " + "seen every %2 ms") + .arg(r.intervals.averageMs()).arg(seen))); + QVERIFY2(r.intervals.averageMs() >= kInterval * 0.8, + qPrintable(QString("looked every %1 ms") + .arg(r.intervals.averageMs()))); + QCOMPARE(r.batches.count, atReport[i].second - + (i == 0 ? 0 : atReport[i-1].second)); + QCOMPARE(r.paints.count, 0); + reported += r.estimates; + } + QVERIFY(reported > 0 && reported <= handedOver); + QVERIFY(reports.back().newestFrame > 0); + QVERIFY(reports.back().newestFrame <= newest); +#if defined(Q_OS_LINUX) || defined(Q_OS_MACOS) + QVERIFY(reports.back().guiThreadShare >= 0.0); +#endif + } + + void times_the_paints_of_a_widget() { + Found found; + Painted painted; + std::vector reports; + LiveDotsFeed feed(kInterval); + feed.setReporter([&](const LiveDotsFeed::Report &r) { + reports.push_back(r); + }); + feed.start([&]() { return found.take(); }, + [&](const Estimates &) { }); + feed.timePaintsOf(&painted); + for (int i = 0; i < 3; ++i) { + QEvent paint(QEvent::Paint); + QCoreApplication::sendEvent(&painted, &paint); + } + // and they still reach the widget + QCOMPARE(painted.paints, 3); + QTRY_VERIFY_WITH_TIMEOUT(!reports.empty(), 3000); + QCOMPARE(reports[0].paints.count, 3); + QVERIFY(reports[0].paints.averageMs() >= 4.5); + QVERIFY(reports[0].paints.maxMs >= 4.5); + + // Not timed after the take, and not held up either + feed.stop(); + QEvent paint(QEvent::Paint); + QCoreApplication::sendEvent(&painted, &paint); + QCOMPARE(painted.paints, 4); + } + + void describes_a_report() { + LiveDotsFeed::Report r; + r.seconds = 1.0; + r.estimates = 175; + for (int i = 0; i < 25; ++i) r.batches.add(i == 3 ? 1.5 : 0.25); + for (int i = 0; i < 25; ++i) r.intervals.add(i == 7 ? 95.0 : 40.0); + for (int i = 0; i < 49; ++i) r.paints.add(i == 0 ? 18.0 : 3.0); + r.guiThreadShare = 0.284; + QString text = LiveDotsFeed::describe(r); + QVERIFY2(text.startsWith("25 batches of 7.0 dots, 0.30 ms each " + "(at most 1.5), every 42 ms (at most 95); " + "pane painted 49 times, 3.31 ms each " + "(at most 18.0); GUI thread 28% of a core"), + qPrintable(text)); + r.guiThreadShare = -1.0; + QVERIFY(!LiveDotsFeed::describe(r).contains("GUI thread")); + } +}; + +#endif diff --git a/main/test/TestLogFile.h b/main/test/TestLogFile.h new file mode 100644 index 00000000..09ed91e9 --- /dev/null +++ b/main/test/TestLogFile.h @@ -0,0 +1,121 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TEST_LOG_FILE_H +#define TEST_LOG_FILE_H + +// Tier 1: the copy of Tony's output kept on Android for Help > Save +// Log... (LogFile): an earlier run's kept once, the whole never much +// more than twice its limit, and read back oldest first. + +#include "../LogFile.h" + +#include +#include +#include +#include +#include +#include + +class TestLogFile : public QObject +{ + Q_OBJECT + + QTemporaryDir m_dir; + int m_counter = 0; + + QString newPath() { + return m_dir.filePath(QString("log-%1/tony.log").arg(++m_counter)); + } + + static bool writeFile(QString path, QByteArray content) { + QDir().mkpath(QFileInfo(path).absolutePath()); + QFile file(path); + if (!file.open(QIODevice::WriteOnly)) return false; + bool ok = (file.write(content) == content.size()); + file.close(); + return ok; + } + + static qint64 size(QString path) { + return QFileInfo(path).size(); + } + +private slots: + void initTestCase() { + QVERIFY(m_dir.isValid()); + } + + void a_log_is_begun_in_a_folder_not_there_yet() { + QString path = newPath(); + LogFile log(path, 1000); + QVERIFY(!log.isOpen()); + QVERIFY(log.open()); + QVERIFY(log.isOpen()); + + log.write("Setting VAMP_PATH to /data/user/0/io.github.jhhr.tony/files/vamp"); + QCOMPARE(LogFile::contents(path), + QByteArray("Setting VAMP_PATH to /data/user/0/" + "io.github.jhhr.tony/files/vamp\n")); + } + + void the_run_before_is_kept_and_the_one_before_that_goes() { + QString path = newPath(); + QVERIFY(writeFile(LogFile::olderPath(path), "two runs ago\n")); + QVERIFY(writeFile(path, "the last run\n")); + + LogFile log(path, 1000); + QVERIFY(log.open()); + log.write("this run"); + + QCOMPARE(LogFile::contents(path), + QByteArray("the last run\nthis run\n")); + } + + void a_log_past_its_limit_begins_again() { + QString path = newPath(); + const qint64 limit = 100; + LogFile log(path, limit); + QVERIFY(log.open()); + + // Each line 10 bytes with its newline + for (int i = 0; i < 50; ++i) { + log.write(QString("line %1...").arg(i, 2, 10, QChar('0')).toUtf8() + .left(9)); + } + + QVERIFY(size(path) < limit); + QVERIFY(size(LogFile::olderPath(path)) <= limit); + + QByteArray all = LogFile::contents(path); + QVERIFY(all.size() <= 2 * limit); + QVERIFY(all.size() >= limit); + QVERIFY(all.endsWith("line 49..\n")); + QVERIFY(!all.contains("line 00")); + + // In order, oldest first, no line cut + QList lines = all.split('\n'); + lines.removeLast(); + int first = lines.first().mid(5, 2).toInt(); + for (int i = 0; i < lines.size(); ++i) { + QCOMPARE(lines[i], QString("line %1..").arg(first + i, 2, 10, + QChar('0')).toUtf8()); + } + } + + void there_is_nothing_to_read_before_a_log_is_begun() { + QCOMPARE(LogFile::contents(newPath()), QByteArray()); + } +}; + +#endif diff --git a/main/test/TestLyrics.h b/main/test/TestLyrics.h new file mode 100644 index 00000000..8b4e9d97 --- /dev/null +++ b/main/test/TestLyrics.h @@ -0,0 +1,793 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TEST_LYRICS_H +#define TEST_LYRICS_H + +// Tier 2: reading LRC files, and the events the lyrics become. No +// window and no model: what the application does with them is Tier +// 5's business (TestRecordWorkflow). The files in testdata/lyrics are +// written as Moises-Lyric-Exporter writes them, with invented text. + +#include "../Lyrics.h" + +#include "base/EventSeries.h" +#include "base/XmlExportable.h" + +#include +#include +#include +#include +#include + +#include + +class TestLyrics : public QObject +{ + Q_OBJECT + + // The inferred lengths, as the parser has them + static constexpr double W = Lyrics::inferredWordSeconds; + static constexpr double L = Lyrics::inferredLastLineSeconds; + + static LyricsParseResult parse(const char *text) { + return parseLrc(QByteArray(text)); + } + + static QByteArray fixture(const char *name) { + QFile file(QString(TONY_TEST_DATA_DIR) + "/lyrics/" + name); + if (!file.open(QIODevice::ReadOnly)) return QByteArray(); + return file.readAll(); + } + + static int wordCount(const LyricsParseResult &r) { + return int(r.lyrics.words.size()); + } + + struct Expected { + double start; + double end; + const char *text; + int line; + bool endGiven; + }; + + static QString describe(const LyricWord &w) { + return QString("\"%1\" %2-%3 line %4%5") + .arg(w.text).arg(w.start, 0, 'f', 3).arg(w.end, 0, 'f', 3) + .arg(w.line).arg(w.endGiven ? ", end given" : ""); + } + + static QString describe(const Lyrics &lyrics) { + QStringList list; + for (const LyricWord &w : lyrics.words) list << describe(w); + return list.join("; "); + } + + // Every LRC time is whole milliseconds, and so is every expected + // time here: this only absorbs the last bit of a double + static bool sameTime(double a, double b) { + return std::abs(a - b) < 1e-9; + } + + // All the words, in order. A failure names the word that differs. + static void compareWords(const Lyrics &lyrics, + const QVector &expected) { + QVERIFY2(lyrics.words.size() == expected.size(), + qPrintable(QString("%1 words, expected %2: %3") + .arg(lyrics.words.size()).arg(expected.size()) + .arg(describe(lyrics)))); + for (int i = 0; i < expected.size(); ++i) { + const LyricWord &w = lyrics.words[i]; + LyricWord want; + want.start = expected[i].start; + want.end = expected[i].end; + want.text = QString::fromUtf8(expected[i].text); + want.line = expected[i].line; + want.endGiven = expected[i].endGiven; + bool same = (w.text == want.text && + sameTime(w.start, want.start) && + sameTime(w.end, want.end) && + w.line == want.line && + w.endGiven == want.endGiven); + QVERIFY2(same, qPrintable(QString("word %1 is %2, expected %3") + .arg(i).arg(describe(w)) + .arg(describe(want)))); + } + } + +private slots: + // Line timing: a line lasts until the next one starts, however far + // away that is, and the last one for a default length + void line_lrc_starts_and_ends() { + LyricsParseResult r = parse("[00:01.00]Ensimmäinen rivi\n" + "[00:04.50]Toinen rivi\n" + "[00:30.00]Kolmas rivi\n"); + QCOMPARE(r.error, QString()); + QCOMPARE(r.warnings, QStringList()); + QVERIFY(!r.lyrics.wordTimed); + QCOMPARE(r.lyrics.lineCount(), 3); + compareWords(r.lyrics, { + { 1.0, 4.5, "Ensimmäinen rivi", 0, false }, + { 4.5, 30.0, "Toinen rivi", 1, false }, + { 30.0, 30.0 + L, "Kolmas rivi", 2, false }, + }); + } + + // One fraction digit is tenths, two hundredths, three thousandths, + // and ':' does as well as '.' + void time_tag_precision() { + const char *tags[] = { + "[01:02.5]", "[01:02.50]", "[01:02.500]", "[01:02:50]", + "[01:02:5]", "[01:02:500]" + }; + for (const char *tag : tags) { + LyricsParseResult r = parseLrc(QByteArray(tag) + "sana\n"); + QVERIFY2(r.error.isEmpty(), tag); + QCOMPARE(wordCount(r), 1); + QVERIFY2(r.lyrics.words[0].start == 62.5, tag); + } + + LyricsParseResult r = parse("[01:02]sana\n"); + QCOMPARE(wordCount(r), 1); + QCOMPARE(r.lyrics.words[0].start, 62.0); + + r = parse("[00:01.234]a\n[00:01.3]b\n"); + QCOMPARE(wordCount(r), 2); + QCOMPARE(r.lyrics.words[0].start, 1.234); + QCOMPARE(r.lyrics.words[1].start, 1.3); + + // Word tags are read the same way + r = parse("[01:00.00]<01:02.5>a <01:03:25>b <01:04.125>c\n"); + QCOMPARE(r.warnings, QStringList()); + compareWords(r.lyrics, { + { 62.5, 63.25, "a", 0, true }, + { 63.25, 64.125, "b", 0, true }, + { 64.125, 64.125 + W, "c", 0, false }, + }); + + // Four fraction digits make no tag + r = parse("[00:01.2345]ei\n[00:02.00]kyllä\n"); + QCOMPARE(wordCount(r), 1); + QCOMPARE(r.lyrics.words[0].text, QString("kyllä")); + QCOMPARE(r.warnings.size(), qsizetype(1)); + } + + // Songs over an hour: minutes have as many digits as they need + void minutes_over_59() { + LyricsParseResult r = parse("[75:00.00]pitkä\n" + "[123:04.5]<123:04.5>hyvin <123:05.25>pitkä\n"); + QCOMPARE(r.error, QString()); + QCOMPARE(r.warnings, QStringList()); + compareWords(r.lyrics, { + { 4500.0, 7384.5, "pitkä", 0, false }, + { 7384.5, 7385.25, "hyvin", 1, true }, + { 7385.25, 7385.25 + W, "pitkä", 1, false }, + }); + + // but not so many that the arithmetic overflows: that is no + // time tag, and the line is skipped + r = parse("[00:01.00]sana\n[99999999999999999999:00.00]ei\n"); + QCOMPARE(wordCount(r), 1); + QCOMPARE(r.warnings.size(), qsizetype(1)); + } + + // Seconds of 60 or more make no time tag: the line is skipped + void seconds_of_60_or_more_skip_the_line() { + LyricsParseResult r = parse("[00:60.00]ei\n" + "[00:01.00]kyllä\n" + "[01:75.50]ei tämäkään\n"); + QCOMPARE(r.error, QString()); + compareWords(r.lyrics, { { 1.0, 1.0 + L, "kyllä", 0, false } }); + QCOMPARE(r.warnings.size(), qsizetype(1)); + QVERIFY2(r.warnings[0].startsWith("2 "), qPrintable(r.warnings[0])); + + // Inside a line such a word tag is just text + r = parse("[00:01.00]<00:01.00>a <00:61.00>b\n"); + QCOMPARE(r.warnings, QStringList()); + compareWords(r.lyrics, { { 1.0, 1.0 + W, "a <00:61.00>b", 0, false } }); + } + + // [00:12.00][01:30.00]chorus puts the line in at both times, each + // sorted into place + void several_leading_time_tags_repeat_the_line() { + LyricsParseResult r = parse("[00:30.00]säkeistö\n" + "[00:12.00][01:30.00]kertosäe\n" + "[01:00.00]väliosa\n"); + QCOMPARE(r.warnings, QStringList()); + QCOMPARE(r.lyrics.lineCount(), 4); + compareWords(r.lyrics, { + { 12.0, 30.0, "kertosäe", 0, false }, + { 30.0, 60.0, "säkeistö", 1, false }, + { 60.0, 90.0, "väliosa", 2, false }, + { 90.0, 90.0 + L, "kertosäe", 3, false }, + }); + + // Word tags move with the stamp the line is repeated at + r = parse("[00:10.00][00:20.00]<00:10.00>la <00:10.50>lu\n"); + QCOMPARE(r.warnings, QStringList()); + compareWords(r.lyrics, { + { 10.0, 10.5, "la", 0, true }, + { 10.5, 10.5 + W, "lu", 0, false }, + { 20.0, 20.5, "la", 1, true }, + { 20.5, 20.5 + W, "lu", 1, false }, + }); + } + + // Only the title and the artist are kept; other metadata, whatever + // the case of its key, is neither lyrics nor a problem + void metadata_ignored_title_and_artist_kept() { + LyricsParseResult r = parse("[ti: Kesäyö ]\n" + "[AR:Testiryhmä]\n" + "[al:Levy]\n" + "[by:Joku]\n" + "[au:Tekijä]\n" + "[length: 03:25]\n" + "[#:kommentti]\n" + "[00:01.00]sana\n"); + QCOMPARE(r.error, QString()); + QCOMPARE(r.warnings, QStringList()); + QCOMPARE(r.lyrics.title, QString("Kesäyö")); + QCOMPARE(r.lyrics.artist, QString("Testiryhmä")); + compareWords(r.lyrics, { { 1.0, 1.0 + L, "sana", 0, false } }); + } + + // A positive [offset:] makes the lyrics appear sooner, as LRC has + // it; a negative one later + void offset_sign() { + LyricsParseResult r = parse("[offset:+500]\n" + "[00:01.00]yksi\n" + "[00:03.00]<00:03.00>kaksi <00:03.40>kolme\n"); + QCOMPARE(r.warnings, QStringList()); + compareWords(r.lyrics, { + { 0.5, 2.5, "yksi", 0, false }, + { 2.5, 2.9, "kaksi", 1, true }, + { 2.9, 2.9 + W, "kolme", 1, false }, + }); + + r = parse("[offset:-500]\n[00:01.00]yksi\n"); + compareWords(r.lyrics, { { 1.5, 1.5 + L, "yksi", 0, false } }); + + // Wherever it is in the file, it counts for all of it + r = parse("[00:01.00]yksi\n[offset:250]\n"); + compareWords(r.lyrics, { { 0.75, 0.75 + L, "yksi", 0, false } }); + + // A word it pushes below 0 is dropped, and the lines are + // numbered without it + r = parse("[offset:+1500]\n[00:01.00]yksi\n[00:03.00]kaksi\n"); + compareWords(r.lyrics, { { 1.5, 1.5 + L, "kaksi", 0, false } }); + QCOMPARE(r.warnings.size(), qsizetype(1)); + + // Exactly 0 is not below it + r = parse("[offset:1000]\n[00:01.00]yksi\n"); + QCOMPARE(r.warnings, QStringList()); + compareWords(r.lyrics, { { 0.0, L, "yksi", 0, false } }); + + // Not a number: no offset, and a warning + r = parse("[offset:puoli sekuntia]\n[00:01.00]yksi\n"); + compareWords(r.lyrics, { { 1.0, 1.0 + L, "yksi", 0, false } }); + QCOMPARE(r.warnings.size(), qsizetype(1)); + } + + // Word timing: a word ends where the next word tag is, or where a + // trailing tag says; the last word of a line with neither ends at + // the next line, or a default length after its start + void word_lrc_starts_and_ends() { + LyricsParseResult r = parse + ("[00:01.00]<00:01.00>Päivä <00:01.50>paistaa <00:02.20>ja<00:02.60>\n" + "[00:05.00]<00:05.00>kuu <00:05.40>nousee\n" + "[00:06.00]<00:06.00>yö\n"); + QCOMPARE(r.error, QString()); + QCOMPARE(r.warnings, QStringList()); + QVERIFY(r.lyrics.wordTimed); + QCOMPARE(r.lyrics.lineCount(), 3); + compareWords(r.lyrics, { + { 1.0, 1.5, "Päivä", 0, true }, + { 1.5, 2.2, "paistaa", 0, true }, + { 2.2, 2.6, "ja", 0, true }, + { 5.0, 5.4, "kuu", 1, true }, + { 5.4, 6.0, "nousee", 1, false }, + { 6.0, 6.0 + W, "yö", 2, false }, + }); + + // Text before the first word tag starts at the line's own time + r = parse("[00:10.00] Alku <00:10.50>loppu\n"); + compareWords(r.lyrics, { + { 10.0, 10.5, "Alku", 0, true }, + { 10.5, 10.5 + W, "loppu", 0, false }, + }); + } + + // A timed line with no text, or only notes, ends the line before + // it and is no line itself. With neither, the last word of a line + // lasts until the next line, but no longer than the cap. + void empty_or_music_line_ends_the_line_before() { + LyricsParseResult r = parse("[00:01.00]<00:01.00>yksi <00:01.50>kaksi\n" + "[00:04.00]\n" + "[00:10.00]<00:10.00>kolme\n" + "[00:12.50] ♪\n" + "[00:15.00]<00:15.00>neljä\n" + "[00:15.80] ♫ ♪ \n" + "[00:20.00]<00:20.00>viisi <00:20.40>kuusi\n" + "[00:30.00]<00:30.00>seitsemän\n"); + QCOMPARE(r.warnings, QStringList()); + QCOMPARE(r.lyrics.lineCount(), 5); + compareWords(r.lyrics, { + { 1.0, 1.5, "yksi", 0, true }, + { 1.5, 4.0, "kaksi", 0, true }, + { 10.0, 12.5, "kolme", 1, true }, + { 15.0, 15.8, "neljä", 2, true }, + { 20.0, 20.4, "viisi", 3, true }, + { 20.4, 20.4 + W, "kuusi", 3, false }, + { 30.0, 30.0 + W, "seitsemän", 4, false }, + }); + + // The same for line timing, where nothing but such a line caps + // how long a line lasts + r = parse("[00:01.00]rivi yksi\n" + "[00:03.00]\n" + "[00:10.00]rivi kaksi\n" + "[00:12.00]♫\n" + "[00:20.00]rivi kolme\n" + "[00:40.00]rivi neljä\n"); + QCOMPARE(r.warnings, QStringList()); + compareWords(r.lyrics, { + { 1.0, 3.0, "rivi yksi", 0, true }, + { 10.0, 12.0, "rivi kaksi", 1, true }, + { 20.0, 40.0, "rivi kolme", 2, false }, + { 40.0, 40.0 + L, "rivi neljä", 3, false }, + }); + } + + // The exporter leaves out the space before a word with punctuation + // in it: words are split on the tags, never on spaces, and the + // spaces around them are not part of them + void glued_words_split_on_tags() { + LyricsParseResult r = parse + ("[00:04.640] <00:04.640>Tämä <00:04.920>on <00:05.160>keksitty<00:05.620>laulu,\n" + "[00:07.000] <00:07.000> väli <00:07.500> lopussa \n"); + QCOMPARE(r.warnings, QStringList()); + compareWords(r.lyrics, { + { 4.64, 4.92, "Tämä", 0, true }, + { 4.92, 5.16, "on", 0, true }, + { 5.16, 5.62, "keksitty", 0, true }, + { 5.62, 7.0, "laulu,", 0, false }, + { 7.0, 7.5, "väli", 1, true }, + { 7.5, 7.5 + W, "lopussa", 1, false }, + }); + + // Nor is one tag's text split at its spaces: it is one label + r = parse("[00:01.00]<00:01.00>kaksi sanaa<00:02.00>\n"); + compareWords(r.lyrics, { { 1.0, 2.0, "kaksi sanaa", 0, true } }); + } + + // The exporter clamps negative times to 0, so several lines can + // share that stamp: all of them stay, in file order + void lines_stamped_zero_kept_in_file_order() { + LyricsParseResult r = parse("[00:00.000] <00:00.000>eka\n" + "[00:00.000] <00:00.000>toka\n" + "[00:00.000] <00:00.000>kolmas\n" + "[00:02.500] <00:02.500>neljäs\n"); + QCOMPARE(r.warnings, QStringList()); + compareWords(r.lyrics, { + { 0.0, 0.0, "eka", 0, false }, + { 0.0, 0.0, "toka", 1, false }, + { 0.0, W, "kolmas", 2, false }, + { 2.5, 2.5 + W, "neljäs", 3, false }, + }); + + // and all of them become events, one frame long at least + sv::EventVector events = lyricsToEvents(r.lyrics, 44100); + QCOMPARE(int(events.size()), 4); + for (int i = 0; i < 3; ++i) { + QCOMPARE(events[i].getFrame(), sv::sv_frame_t(0)); + QVERIFY(events[i].getDuration() >= 1); + } + + r = parse("[00:00.00]eka\n[00:00.00]toka\n[00:01.00]kolmas\n"); + compareWords(r.lyrics, { + { 0.0, 0.0, "eka", 0, false }, + { 0.0, 1.0, "toka", 1, false }, + { 1.0, 1.0 + L, "kolmas", 2, false }, + }); + } + + // A metadata value runs to the last ']'; the exporter's own header + // lines are no trouble + void bracketed_title_and_exporter_header() { + LyricsParseResult r = parse + ("[ti:Laulu [Live]]\n" + "[re:Moises Lyrics Exporter Pro]\n" + "[ve:2.0]\n" + "[co:avg_confidence=1.000; low_confidence_count=0]\n" + "\n" + "[00:01.000] <00:01.000>sana\n"); + QCOMPARE(r.error, QString()); + QCOMPARE(r.warnings, QStringList()); + QCOMPARE(r.lyrics.title, QString("Laulu [Live]")); + QCOMPARE(r.lyrics.artist, QString()); + compareWords(r.lyrics, { { 1.0, 1.0 + W, "sana", 0, false } }); + } + + // A word tag earlier than the word before it is moved up to that + // word's start, with one warning that counts them + void backward_word_tags_clamped() { + LyricsParseResult r = parse + ("[00:10.00]<00:10.00>a <00:09.50>b <00:10.50>c<00:10.20>\n"); + compareWords(r.lyrics, { + { 10.0, 10.0, "a", 0, true }, + { 10.0, 10.5, "b", 0, true }, + { 10.5, 10.5, "c", 0, true }, + }); + QCOMPARE(r.warnings.size(), qsizetype(1)); + QVERIFY2(r.warnings[0].startsWith("2 "), qPrintable(r.warnings[0])); + } + + // Lines are put in time order, and numbered in it; a line's words + // go with it + void unsorted_lines_sorted() { + LyricsParseResult r = parse("[00:20.00]kolmas\n" + "[00:05.00]ensimmäinen\n" + "[00:10.00]toinen\n"); + QCOMPARE(r.warnings, QStringList()); + compareWords(r.lyrics, { + { 5.0, 10.0, "ensimmäinen", 0, false }, + { 10.0, 20.0, "toinen", 1, false }, + { 20.0, 20.0 + L, "kolmas", 2, false }, + }); + + r = parse("[00:08.00]<00:08.00>c <00:08.50>d\n" + "[00:02.00]<00:02.00>a <00:02.50>b\n"); + compareWords(r.lyrics, { + { 2.0, 2.5, "a", 0, true }, + { 2.5, 2.5 + W, "b", 0, false }, + { 8.0, 8.5, "c", 1, true }, + { 8.5, 8.5 + W, "d", 1, false }, + }); + } + + // Finnish text survives UTF-8 with and without a BOM, and UTF-16 + // and UTF-32 with one + void unicode_with_and_without_bom() { + const QString title = QString::fromUtf8("Hämärä yö"); + const QString line = QString::fromUtf8("Äiti öisin söi jäätelöä"); + const QString text = "[ti:" + title + "]\n[00:01.00]" + line + "\n"; + + QVector files; + files << text.toUtf8(); + files << QByteArray("\xEF\xBB\xBF") + text.toUtf8(); + const QStringConverter::Encoding encodings[] = { + QStringConverter::Utf16LE, QStringConverter::Utf16BE, + QStringConverter::Utf32LE, QStringConverter::Utf32BE + }; + for (QStringConverter::Encoding e : encodings) { + QStringEncoder encoder(e, QStringConverter::Flag::WriteBom); + QByteArray bytes = encoder.encode(text); + QVERIFY(QStringConverter::encodingForData(bytes) == e); + files << bytes; + } + + for (int i = 0; i < files.size(); ++i) { + LyricsParseResult r = parseLrc(files[i]); + QVERIFY2(r.error.isEmpty(), qPrintable(QString::number(i))); + QVERIFY2(r.warnings.isEmpty(), + qPrintable(QString("%1: %2").arg(i) + .arg(r.warnings.join(" ")))); + QCOMPARE(r.lyrics.title, title); + QCOMPARE(wordCount(r), 1); + QCOMPARE(r.lyrics.words[0].text, line); + } + } + + // Bytes that are not UTF-8 are read as Latin-1, with a warning, so + // that the ä and ö of an older file survive + void non_utf8_read_as_latin1() { + LyricsParseResult r = parse("[ti:H\xE4m\xE4r\xE4 y\xF6]\n" + "[00:01.00]\xC4iti \xF6isin\n"); + QCOMPARE(r.error, QString()); + QCOMPARE(r.lyrics.title, QString::fromUtf8("Hämärä yö")); + QCOMPARE(wordCount(r), 1); + QCOMPARE(r.lyrics.words[0].text, QString::fromUtf8("Äiti öisin")); + QCOMPARE(r.warnings.size(), qsizetype(1)); + + // Even when the only such byte is the file's last, where a + // decoder that waits for more would say nothing + r = parse("[00:01.00]Hyv\xE4"); + QCOMPARE(wordCount(r), 1); + QCOMPARE(r.lyrics.words[0].text, QString::fromUtf8("Hyvä")); + QCOMPARE(r.warnings.size(), qsizetype(1)); + + // With a BOM the file says it is UTF-8: what cannot be read is + // replaced, with a warning + r = parse("\xEF\xBB\xBF[00:01.00]a\xFF" "b\n"); + QCOMPARE(wordCount(r), 1); + QCOMPARE(r.lyrics.words[0].text, QString("a") + QChar(0xFFFD) + "b"); + QCOMPARE(r.warnings.size(), qsizetype(1)); + } + + // CRLF, LF and CR, with or without a newline at the end + void line_endings() { + const char *files[] = { + "[00:01.00]yksi\r\n[00:02.00]kaksi\r\n[00:03.00]kolme", + "[00:01.00]yksi\r\n[00:02.00]kaksi\r\n[00:03.00]kolme\r\n", + "[00:01.00]yksi\r[00:02.00]kaksi\r[00:03.00]kolme\r", + "[00:01.00]yksi\n[00:02.00]kaksi\n[00:03.00]kolme", + }; + for (const char *file : files) { + LyricsParseResult r = parse(file); + QCOMPARE(r.warnings, QStringList()); + compareWords(r.lyrics, { + { 1.0, 2.0, "yksi", 0, false }, + { 2.0, 3.0, "kaksi", 1, false }, + { 3.0, 3.0 + L, "kolme", 2, false }, + }); + if (QTest::currentTestFailed()) return; + } + } + + // Control characters go, as the session file (XML 1.0) could not + // be read back with one in a label; a tab becomes a space, as a + // session file would give it back. A long word is cut. + void control_characters_stripped_and_long_words_cut() { + QByteArray bytes("[ti:Ni\x01mi]\n[00:01.00]a"); + bytes += '\0'; + bytes += "b\x1F" "c\x7F" "d\te"; + bytes += "\xEF\xBF\xBF" "f\xEF\xBF\xBE" "g\n"; // U+FFFF, U+FFFE + bytes += "[00:02.00]<00:02.00>h\x02i <00:02.50>\x03 <00:03.00>j\n"; + LyricsParseResult r = parseLrc(bytes); + QCOMPARE(r.error, QString()); + QCOMPARE(r.warnings, QStringList()); + + // First what it is for: the labels and the title, as a session + // holds them, are well-formed XML + QString xml = "\n" + "\n"; + for (const sv::Event &e : lyricsToEvents(r.lyrics, 44100)) { + xml += e.toXmlString(" "); + } + xml += "\n"; + QXmlStreamReader reader(xml.toUtf8()); + int points = 0; + while (!reader.atEnd()) { + if (reader.readNext() == QXmlStreamReader::StartElement && + reader.name() == QLatin1String("point")) { + ++points; + } + } + QVERIFY2(!reader.hasError(), qPrintable(reader.errorString())); + QCOMPARE(points, 3); + + QCOMPARE(r.lyrics.title, QString("Nimi")); + compareWords(r.lyrics, { + { 1.0, 2.0, "abcd efg", 0, false }, + { 2.0, 2.5, "hi", 1, true }, + { 3.0, 3.0 + W, "j", 1, false }, + }); + if (QTest::currentTestFailed()) return; + + // A word, or a line, of 1000 characters is cut to the limit + r = parseLrc("[00:01.00]<00:01.00>" + QByteArray(1000, 'x') + + " <00:02.00>lyhyt\n[00:03.00]" + QByteArray(1000, 'y')); + QCOMPARE(wordCount(r), 3); + QCOMPARE(r.lyrics.words[0].text, QString(Lyrics::maxLabelLength, 'x')); + QCOMPARE(r.lyrics.words[1].text, QString("lyhyt")); + QCOMPARE(r.lyrics.words[2].text, QString(Lyrics::maxLabelLength, 'y')); + + // never through the middle of a character that needs two + const char32_t clef[] = { 0x1D11E }; + QString text = QString(Lyrics::maxLabelLength - 1, 'z') + + QString::fromUcs4(clef, 1) + "zz"; + r = parseLrc(("[00:01.00]" + text).toUtf8()); + QCOMPARE(wordCount(r), 1); + QCOMPARE(r.lyrics.words[0].text, + QString(Lyrics::maxLabelLength - 1, 'z')); + } + + // Brackets and ampersands that are not part of a tag are text + void markup_characters_kept() { + LyricsParseResult r = parse + ("[00:01.00]rock & roll [x] ad <00:6> <1:02.00\n" + "[00:03.00]<00:03.00>a&b <00:03.50><3 <00:04.00>[x]<00:04.50>x>y <00:05.00>&\n"); + QCOMPARE(r.warnings, QStringList()); + compareWords(r.lyrics, { + { 1.0, 3.0, "rock & roll [x] ad <00:6> <1:02.00", 0, false }, + { 3.0, 3.5, "a&b", 1, true }, + { 3.5, 4.0, "<3", 1, true }, + { 4.0, 4.5, "[x]", 1, true }, + { 4.5, 5.0, "x>y", 1, true }, + { 5.0, 5.0 + W, "&", 1, false }, + }); + } + + // Nothing usable is an error, and gives no lyrics + void unusable_files_are_errors() { + LyricsParseResult r = parse("Tämä on vain tekstiä\nilman aikoja\n"); + QVERIFY(!r.error.isEmpty()); + QVERIFY(r.lyrics.isEmpty()); + + // Timed lines, but none with words + r = parse("[ti:Nimi]\n[00:01.00]\n[00:02.00] ♪\n"); + QVERIFY(!r.error.isEmpty()); + QVERIFY(r.lyrics.isEmpty()); + QCOMPARE(r.lyrics.title, QString()); + + // Not text at all + QByteArray binary; + for (int i = 0; i < 4096; ++i) binary += char((i * 37) % 256); + r = parseLrc(binary); + QVERIFY(!r.error.isEmpty()); + + r = parseLrc(QByteArray()); + QVERIFY(!r.error.isEmpty()); + + // Exactly the limit is read; one byte more is not + QByteArray big("[00:01.00]sana\n"); + big += QByteArray(Lyrics::maxFileBytes - big.size(), '\n'); + r = parseLrc(big); + QCOMPARE(r.error, QString()); + QCOMPARE(wordCount(r), 1); + big += '\n'; + r = parseLrc(big); + QVERIFY(!r.error.isEmpty()); + QVERIFY(r.lyrics.isEmpty()); + } + + // Frames are the seconds times the rate, rounded; a word with no + // length gets one frame; the value is the line. Back again, the + // same words come out, to the frame. + void events_at_44100_and_48000() { + LyricsParseResult r = parse("[00:01.234]<00:01.234>a <00:01.234>b <00:02.000>c\n" + "[01:02.500]<01:02.500>d\n"); + QCOMPARE(r.warnings, QStringList()); + compareWords(r.lyrics, { + { 1.234, 1.234, "a", 0, true }, + { 1.234, 2.0, "b", 0, true }, + { 2.0, 2.0 + W, "c", 0, false }, + { 62.5, 62.5 + W, "d", 1, false }, + }); + if (QTest::currentTestFailed()) return; + + // 1.234 s is 54419.4 frames at 44100, and 59232 at 48000 + const sv::sv_frame_t w44 = sv::sv_frame_t(std::llround(W * 44100)); + const sv::sv_frame_t w48 = sv::sv_frame_t(std::llround(W * 48000)); + struct Case { + double rate; + sv::EventVector expected; + } cases[] = { + { 44100, { + sv::Event(54419, 0.f, 1, "a"), + sv::Event(54419, 0.f, 88200 - 54419, "b"), + sv::Event(88200, 0.f, w44, "c"), + sv::Event(2756250, 1.f, w44, "d"), + } }, + { 48000, { + sv::Event(59232, 0.f, 1, "a"), + sv::Event(59232, 0.f, 96000 - 59232, "b"), + sv::Event(96000, 0.f, w48, "c"), + sv::Event(3000000, 1.f, w48, "d"), + } }, + }; + + for (const Case &c : cases) { + sv::EventVector events = lyricsToEvents(r.lyrics, c.rate); + QCOMPARE(int(events.size()), int(c.expected.size())); + for (int i = 0; i < int(events.size()); ++i) { + QVERIFY2(events[i] == c.expected[i], + qPrintable(events[i].toXmlString() + " expected " + + c.expected[i].toXmlString())); + } + + // Back from the events in any order, as a model would give + // them: the same words, and the same events again + sv::EventVector reversed(events.rbegin(), events.rend()); + Lyrics back = lyricsFromEvents(reversed, c.rate); + QCOMPARE(back.words.size(), r.lyrics.words.size()); + for (int i = 0; i < int(back.words.size()); ++i) { + const LyricWord &w = back.words[i]; + const LyricWord &o = r.lyrics.words[i]; + QCOMPARE(w.text, o.text); + QCOMPARE(w.line, o.line); + QVERIFY(std::abs(w.start - o.start) <= 0.5 / c.rate); + } + QVERIFY(lyricsToEvents(back, c.rate) == events); + } + } + + // Two words at the same frame are two events, even when nothing + // tells them apart, and a model's event series keeps both + void identical_words_at_one_frame_both_kept() { + LyricsParseResult r = parse("[00:01.00]<00:01.00>la<00:01.00>la<00:01.00>\n"); + QCOMPARE(r.warnings, QStringList()); + compareWords(r.lyrics, { + { 1.0, 1.0, "la", 0, true }, + { 1.0, 1.0, "la", 0, true }, + }); + + sv::EventVector events = lyricsToEvents(r.lyrics, 44100); + QCOMPARE(int(events.size()), 2); + QVERIFY(events[0] == events[1]); + + sv::EventSeries series; + for (const sv::Event &e : events) series.add(e); + QCOMPARE(series.count(), 2); + } + + // What Moises-Lyric-Exporter writes in word mode: no end times, + // clamped stamps at 0, glued words and a gap marker + void exporter_word_fixture() { + QByteArray bytes = fixture("moises-exporter-words.lrc"); + QVERIFY(!bytes.isEmpty()); + LyricsParseResult r = parseLrc(bytes); + QCOMPARE(r.error, QString()); + QCOMPARE(r.warnings, QStringList()); + QCOMPARE(r.lyrics.title, QString::fromUtf8("Kesäyön testilaulu")); + QVERIFY(r.lyrics.wordTimed); + QCOMPARE(r.lyrics.lineCount(), 4); + compareWords(r.lyrics, { + { 0.0, 0.0, "Alku", 0, true }, + { 0.0, 0.31, "ennen", 0, true }, + { 0.31, 0.31 + W, "nollaa", 0, false }, + { 4.64, 4.92, "Tämä", 1, true }, + { 4.92, 5.16, "on", 1, true }, + { 5.16, 5.62, "keksitty", 1, true }, + { 5.62, 6.0, "laulu,", 1, true }, + { 21.5, 21.8, "Yö", 2, true }, + { 21.8, 22.05, "on", 2, true }, + { 22.05, 23.4, "hämärä", 2, false }, + { 23.4, 23.7, "Nyt", 3, true }, + { 23.7, 24.0, "se", 3, true }, + { 24.0, 24.0 + W, "loppuu!", 3, false }, + }); + } + + // The same in line mode, with two decimals + void exporter_line_fixture() { + QByteArray bytes = fixture("moises-exporter-lines.lrc"); + QVERIFY(!bytes.isEmpty()); + LyricsParseResult r = parseLrc(bytes); + QCOMPARE(r.error, QString()); + QCOMPARE(r.warnings, QStringList()); + QCOMPARE(r.lyrics.title, QString::fromUtf8("Kesäyön testilaulu")); + QVERIFY(!r.lyrics.wordTimed); + QCOMPARE(r.lyrics.lineCount(), 4); + compareWords(r.lyrics, { + { 0.0, 4.64, "Alku ennen nollaa", 0, false }, + { 4.64, 6.0, "Tämä on keksittylaulu,", 1, true }, + { 21.5, 23.4, "Yö on hämärä", 2, false }, + { 23.4, 23.4 + L, "Nyt seloppuu!", 3, false }, + }); + } + + // An enhanced LRC that gives its ends: trailing word tags and + // empty lines + void fixture_with_ends() { + QByteArray bytes = fixture("lrc-with-ends.lrc"); + QVERIFY(!bytes.isEmpty()); + LyricsParseResult r = parseLrc(bytes); + QCOMPARE(r.error, QString()); + QCOMPARE(r.warnings, QStringList()); + QCOMPARE(r.lyrics.title, QString::fromUtf8("Päivä [Live]")); + QCOMPARE(r.lyrics.artist, QString::fromUtf8("Testiryhmä")); + QVERIFY(r.lyrics.wordTimed); + compareWords(r.lyrics, { + { 1.0, 1.5, "Päivä", 0, true }, + { 1.5, 2.2, "paistaa", 0, true }, + { 3.0, 3.4, "Pöllö", 1, true }, + { 3.4, 4.8, "huhuilee", 1, true }, + { 6.0, 8.5, "Koko rivi yhdellä leimalla", 2, true }, + }); + } +}; + +#endif diff --git a/main/test/TestLyricsEdit.h b/main/test/TestLyricsEdit.h new file mode 100644 index 00000000..a5f56b77 --- /dev/null +++ b/main/test/TestLyricsEdit.h @@ -0,0 +1,815 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TEST_LYRICS_EDIT_H +#define TEST_LYRICS_EDIT_H + +// Tier 2: what an edit of the lyrics may do, as numbers: which edge +// the pointer is on, how far a dragged edge goes, where a new word +// goes and which line it joins, what typed text becomes, and how far +// all the words shift together. No window +// and no model: what the editor does with the answers is the app +// suite's business. + +#include "../LyricsEdit.h" +#include "../Lyrics.h" + +#include +#include + +#include + +class TestLyricsEdit : public QObject +{ + Q_OBJECT + + typedef sv::sv_frame_t frame_t; + typedef LyricsEdit::Boxes Boxes; + typedef LyricsEdit::Span Span; + + static constexpr double kRate = 44100.0; + + // 20 ms and 0.5 s at that rate + static constexpr frame_t kMin = 882; + static constexpr frame_t kNew = 22050; + + static constexpr frame_t kSecond = 44100; + + // The grab width the editor is meant to use, about 6 pixels + static constexpr int kGrab = 6; + + static sv::Event word(frame_t start, frame_t end, float line = 0.f, + const char *text = "word") { + return sv::Event(start, line, end - start, QString(text)); + } + + static QString describe(const sv::Event &e) { + return QString("\"%1\" %2-%3 line %4") + .arg(e.getLabel()).arg(e.getFrame()) + .arg(e.getFrame() + e.getDuration()).arg(e.getValue()); + } + + static QString describe(const LyricsEdit::Hit &hit) { + switch (hit.part) { + case LyricsEdit::Part::Nothing: return QString("nothing %1").arg(hit.word); + case LyricsEdit::Part::Start: return QString("start of %1").arg(hit.word); + case LyricsEdit::Part::End: return QString("end of %1").arg(hit.word); + case LyricsEdit::Part::Inside: return QString("inside %1").arg(hit.word); + } + return QString("?"); + } + + // What the pointer at x is on + static QString at(const Boxes &boxes, int x, int grab = kGrab) { + return describe(LyricsEdit::hitTest(boxes, x, grab)); + } + + // Where Add Word puts a word for a click at frame + static QString newSpan(const sv::EventVector &words, frame_t frame) { + Span s; + if (!LyricsEdit::newWordSpan(words, frame, kRate, s)) return "none"; + return QString("%1-%2").arg(s.start).arg(s.end); + } + + // The same, as expected + static QString span(frame_t start, frame_t end) { + return QString("%1-%2").arg(start).arg(end); + } + + static frame_t startTo(const sv::EventVector &words, int i, frame_t f) { + return LyricsEdit::clampStart(words, i, f, kRate); + } + + static frame_t endTo(const sv::EventVector &words, int i, frame_t f) { + return LyricsEdit::clampEnd(words, i, f, kRate); + } + + static frame_t endOf(const sv::Event &e) { + return e.getFrame() + e.getDuration(); + } + + static frame_t overlap(frame_t s0, frame_t e0, frame_t s1, frame_t e1) { + return std::max(frame_t(0), std::min(e0, e1) - std::max(s0, s1)); + } + +private slots: + // 20 ms and 0.5 s to the nearest frame: whole numbers at the usual + // rates, whatever 0.02 is in binary + void limits_in_frames() { + QCOMPARE(LyricsEdit::minWordSeconds, 0.020); + QCOMPARE(LyricsEdit::newWordSeconds, 0.5); + QCOMPARE(LyricsEdit::minWordFrames(44100), kMin); + QCOMPARE(LyricsEdit::minWordFrames(48000), frame_t(960)); + QCOMPARE(LyricsEdit::minWordFrames(22050), frame_t(441)); + QCOMPARE(LyricsEdit::minWordFrames(96000), frame_t(1920)); + QCOMPARE(LyricsEdit::newWordFrames(44100), kNew); + QCOMPARE(LyricsEdit::newWordFrames(48000), frame_t(24000)); + QCOMPARE(LyricsEdit::minWordFrames(0), frame_t(0)); + QCOMPARE(LyricsEdit::newWordFrames(0), frame_t(0)); + + // A shift typed in seconds, earlier or later, three decimals + QCOMPARE(LyricsEdit::framesFor(0.2, 44100), frame_t(8820)); + QCOMPARE(LyricsEdit::framesFor(-0.2, 44100), frame_t(-8820)); + QCOMPARE(LyricsEdit::framesFor(-1.234, 48000), frame_t(-59232)); + QCOMPARE(LyricsEdit::framesFor(0.001, 44100), frame_t(44)); + QCOMPARE(LyricsEdit::framesFor(0.0, 44100), frame_t(0)); + QCOMPARE(LyricsEdit::framesFor(1.0, -44100), frame_t(0)); + } + + // A box runs from the x of the word's start to the x of its end + void boxes_from_the_word_times() { + sv::EventVector words { word(1000, 2500), word(2500, 2560), + word(5000, 5000) }; + Boxes boxes = LyricsEdit::boxesFor + (words, [](frame_t f) { return int(f / 100) + 7; }); + QCOMPARE(int(boxes.size()), 3); + QCOMPARE(boxes[0].x0, 17); + QCOMPARE(boxes[0].x1, 32); + QCOMPARE(boxes[1].x0, 32); + QCOMPARE(boxes[1].x1, 32); + QCOMPARE(boxes[2].x0, 57); + QCOMPARE(boxes[2].x1, 57); + QVERIFY(LyricsEdit::boxesFor({}, [](frame_t) { return 0; }).empty()); + } + + // A box holds its columns x0 to x1 - 1, and one too short to reach + // a column is painted, and found, in the column x0 + void word_at_a_column() { + Boxes boxes { { 100, 150 }, { 150, 200 }, { 220, 221 }, { 300, 300 } }; + QCOMPARE(LyricsEdit::wordAt(boxes, 99), -1); + QCOMPARE(LyricsEdit::wordAt(boxes, 100), 0); + QCOMPARE(LyricsEdit::wordAt(boxes, 149), 0); + QCOMPARE(LyricsEdit::wordAt(boxes, 150), 1); + QCOMPARE(LyricsEdit::wordAt(boxes, 199), 1); + QCOMPARE(LyricsEdit::wordAt(boxes, 200), -1); + QCOMPARE(LyricsEdit::wordAt(boxes, 220), 2); + QCOMPARE(LyricsEdit::wordAt(boxes, 221), -1); + QCOMPARE(LyricsEdit::wordAt(boxes, 299), -1); + QCOMPARE(LyricsEdit::wordAt(boxes, 300), 3); + QCOMPARE(LyricsEdit::wordAt(boxes, 301), -1); + QCOMPARE(LyricsEdit::wordAt({}, 100), -1); + + // Overlapping: the one that starts latest, painted on top + Boxes crossing { { 100, 200 }, { 150, 250 } }; + QCOMPARE(LyricsEdit::wordAt(crossing, 149), 0); + QCOMPARE(LyricsEdit::wordAt(crossing, 150), 1); + QCOMPARE(LyricsEdit::wordAt(crossing, 199), 1); + QCOMPARE(LyricsEdit::wordAt(crossing, 249), 1); + + Boxes within { { 100, 300 }, { 150, 200 } }; + QCOMPARE(LyricsEdit::wordAt(within, 149), 0); + QCOMPARE(LyricsEdit::wordAt(within, 150), 1); + QCOMPARE(LyricsEdit::wordAt(within, 199), 1); + QCOMPARE(LyricsEdit::wordAt(within, 200), 0); + + // Starting in one column: the later one in order + Boxes together { { 100, 101 }, { 100, 200 } }; + QCOMPARE(LyricsEdit::wordAt(together, 100), 1); + Boxes shortLast { { 100, 150 }, { 100, 100 } }; + QCOMPARE(LyricsEdit::wordAt(shortLast, 100), 1); + QCOMPARE(LyricsEdit::wordAt(shortLast, 101), 0); + } + + // Decision 3: at an edge two words share, the column to its left is + // the earlier word's end, the column to its right the later word's + // start, each for grab columns + void shared_edge_from_each_side() { + Boxes boxes { { 100, 150 }, { 150, 200 } }; + QCOMPARE(at(boxes, 149), QString("end of 0")); + QCOMPARE(at(boxes, 144), QString("end of 0")); + QCOMPARE(at(boxes, 143), QString("inside 0")); + QCOMPARE(at(boxes, 150), QString("start of 1")); + QCOMPARE(at(boxes, 155), QString("start of 1")); + QCOMPARE(at(boxes, 156), QString("inside 1")); + + // The outer edges, from inside and from outside + QCOMPARE(at(boxes, 100), QString("start of 0")); + QCOMPARE(at(boxes, 105), QString("start of 0")); + QCOMPARE(at(boxes, 106), QString("inside 0")); + QCOMPARE(at(boxes, 99), QString("start of 0")); + QCOMPARE(at(boxes, 94), QString("start of 0")); + QCOMPARE(at(boxes, 93), QString("nothing -1")); + QCOMPARE(at(boxes, 199), QString("end of 1")); + QCOMPARE(at(boxes, 194), QString("end of 1")); + QCOMPARE(at(boxes, 193), QString("inside 1")); + QCOMPARE(at(boxes, 200), QString("end of 1")); + QCOMPARE(at(boxes, 205), QString("end of 1")); + QCOMPARE(at(boxes, 206), QString("nothing -1")); + + // With a grab of one column, only the columns touching the edge + QCOMPARE(at(boxes, 149, 1), QString("end of 0")); + QCOMPARE(at(boxes, 150, 1), QString("start of 1")); + QCOMPARE(at(boxes, 148, 1), QString("inside 0")); + QCOMPARE(at(boxes, 151, 1), QString("inside 1")); + } + + // In a box too narrow for both edges' grab columns, the nearer edge, + // and the end where they are equally near + void narrow_boxes() { + Boxes four { { 100, 104 } }; + QCOMPARE(at(four, 100), QString("start of 0")); + QCOMPARE(at(four, 101), QString("start of 0")); + QCOMPARE(at(four, 102), QString("end of 0")); + QCOMPARE(at(four, 103), QString("end of 0")); + + Boxes three { { 100, 103 } }; + QCOMPARE(at(three, 100), QString("start of 0")); + QCOMPARE(at(three, 101), QString("end of 0")); + QCOMPARE(at(three, 102), QString("end of 0")); + + Boxes two { { 100, 102 } }; + QCOMPARE(at(two, 100), QString("start of 0")); + QCOMPARE(at(two, 101), QString("end of 0")); + + // One column wide: its end inside, and each edge from its side + Boxes one { { 100, 101 } }; + QCOMPARE(at(one, 100), QString("end of 0")); + QCOMPARE(at(one, 99), QString("start of 0")); + QCOMPARE(at(one, 94), QString("start of 0")); + QCOMPARE(at(one, 93), QString("nothing -1")); + QCOMPARE(at(one, 101), QString("end of 0")); + QCOMPARE(at(one, 106), QString("end of 0")); + QCOMPARE(at(one, 107), QString("nothing -1")); + QCOMPARE(at(one, 100, 1), QString("end of 0")); + QCOMPARE(at(one, 99, 1), QString("start of 0")); + QCOMPARE(at(one, 101, 1), QString("end of 0")); + QCOMPARE(at(one, 98, 1), QString("nothing -1")); + QCOMPARE(at(one, 102, 1), QString("nothing -1")); + + // No width at all is painted one column wide, and is the same + Boxes none { { 100, 100 } }; + QCOMPARE(at(none, 100), QString("end of 0")); + QCOMPARE(at(none, 99), QString("start of 0")); + QCOMPARE(at(none, 101), QString("end of 0")); + + // A one-column word between two others: its start is under the + // earlier word's end, as painted + Boxes squeezed { { 50, 100 }, { 100, 101 }, { 101, 150 } }; + QCOMPARE(at(squeezed, 99), QString("end of 0")); + QCOMPARE(at(squeezed, 100), QString("end of 1")); + QCOMPARE(at(squeezed, 101), QString("start of 2")); + } + + // Overlapping words from a file: the box that starts latest is the + // one the pointer is in, and its edges are the ones found + void overlapping_boxes() { + Boxes crossing { { 100, 200 }, { 150, 250 } }; + QCOMPARE(at(crossing, 101), QString("start of 0")); + QCOMPARE(at(crossing, 149), QString("inside 0")); + QCOMPARE(at(crossing, 150), QString("start of 1")); + QCOMPARE(at(crossing, 155), QString("start of 1")); + QCOMPARE(at(crossing, 160), QString("inside 1")); + // The earlier word's end is under the later word + QCOMPARE(at(crossing, 199), QString("inside 1")); + QCOMPARE(at(crossing, 200), QString("inside 1")); + QCOMPARE(at(crossing, 249), QString("end of 1")); + QCOMPARE(at(crossing, 252), QString("end of 1")); + + Boxes within { { 100, 300 }, { 150, 200 } }; + QCOMPARE(at(within, 150), QString("start of 1")); + QCOMPARE(at(within, 199), QString("end of 1")); + QCOMPARE(at(within, 200), QString("inside 0")); + QCOMPARE(at(within, 298), QString("end of 0")); + + // Two starting in one column, or ending in one: the later word + // in order, from inside and from outside alike + Boxes together { { 100, 101 }, { 100, 200 } }; + QCOMPARE(at(together, 100), QString("start of 1")); + QCOMPARE(at(together, 99), QString("start of 1")); + Boxes endTogether { { 100, 200 }, { 150, 200 } }; + QCOMPARE(at(endTogether, 199), QString("end of 1")); + QCOMPARE(at(endTogether, 200), QString("end of 1")); + } + + // Between boxes, the nearest edge within grab columns, and the end + // where the two are equally near + void gap_between_boxes() { + Boxes wide { { 100, 150 }, { 170, 200 } }; + QCOMPARE(at(wide, 150), QString("end of 0")); + QCOMPARE(at(wide, 155), QString("end of 0")); + QCOMPARE(at(wide, 156), QString("nothing -1")); + QCOMPARE(at(wide, 163), QString("nothing -1")); + QCOMPARE(at(wide, 164), QString("start of 1")); + QCOMPARE(at(wide, 169), QString("start of 1")); + + // An odd gap: its middle column is as near to both + Boxes odd { { 100, 150 }, { 155, 200 } }; + QCOMPARE(at(odd, 150), QString("end of 0")); + QCOMPARE(at(odd, 151), QString("end of 0")); + QCOMPARE(at(odd, 152), QString("end of 0")); + QCOMPARE(at(odd, 153), QString("start of 1")); + QCOMPARE(at(odd, 154), QString("start of 1")); + + Boxes even { { 100, 150 }, { 154, 200 } }; + QCOMPARE(at(even, 150), QString("end of 0")); + QCOMPARE(at(even, 151), QString("end of 0")); + QCOMPARE(at(even, 152), QString("start of 1")); + QCOMPARE(at(even, 153), QString("start of 1")); + } + + void nothing_near() { + QCOMPARE(at({}, 0), QString("nothing -1")); + QCOMPARE(at({}, 100), QString("nothing -1")); + + Boxes boxes { { 100, 150 }, { 150, 200 } }; + QCOMPARE(at(boxes, 0), QString("nothing -1")); + QCOMPARE(at(boxes, -50), QString("nothing -1")); + QCOMPARE(at(boxes, 1000), QString("nothing -1")); + + // No grab, no edges + QCOMPARE(at(boxes, 100, 0), QString("inside 0")); + QCOMPARE(at(boxes, 149, 0), QString("inside 0")); + QCOMPARE(at(boxes, 150, 0), QString("inside 1")); + QCOMPARE(at(boxes, 99, 0), QString("nothing -1")); + QCOMPARE(at(boxes, 200, 0), QString("nothing -1")); + } + + // Words that do not overlap: a start between the previous word's end + // (or frame 0) and 20 ms before its own end; an end between 20 ms + // after its start and the next word's start, or anywhere after the + // last word + void clamps_between_neighbours() { + sv::EventVector words { word(0, 10000), word(12000, 20000), + word(20000, 30000) }; + + QCOMPARE(startTo(words, 1, 11000), frame_t(11000)); + QCOMPARE(startTo(words, 1, 12000), frame_t(12000)); + QCOMPARE(startTo(words, 1, 10000), frame_t(10000)); + QCOMPARE(startTo(words, 1, 9999), frame_t(10000)); + QCOMPARE(startTo(words, 1, -500), frame_t(10000)); + QCOMPARE(startTo(words, 1, 20000 - kMin), 20000 - kMin); + QCOMPARE(startTo(words, 1, 20000 - kMin + 1), 20000 - kMin); + QCOMPARE(startTo(words, 1, 25000), 20000 - kMin); + + // Touching the word before: no further back at all + QCOMPARE(startTo(words, 2, 19000), frame_t(20000)); + QCOMPARE(startTo(words, 2, 29500), 30000 - kMin); + + // The first word: not before frame 0 + QCOMPARE(startTo(words, 0, -100), frame_t(0)); + QCOMPARE(startTo(words, 0, 5000), frame_t(5000)); + QCOMPARE(startTo(words, 0, 9500), 10000 - kMin); + sv::EventVector later { word(5000, 8000) }; + QCOMPARE(startTo(later, 0, 100), frame_t(100)); + QCOMPARE(startTo(later, 0, -100), frame_t(0)); + + QCOMPARE(endTo(words, 0, 11000), frame_t(11000)); + QCOMPARE(endTo(words, 0, 12000), frame_t(12000)); + QCOMPARE(endTo(words, 0, 12001), frame_t(12000)); + QCOMPARE(endTo(words, 0, 13000), frame_t(12000)); + QCOMPARE(endTo(words, 0, kMin), kMin); + QCOMPARE(endTo(words, 0, kMin - 1), kMin); + QCOMPARE(endTo(words, 0, -500), kMin); + + // Touching the word after: no further forward at all + QCOMPARE(endTo(words, 1, 21000), frame_t(20000)); + QCOMPARE(endTo(words, 1, 19000), frame_t(19000)); + QCOMPARE(endTo(words, 1, 12500), 12000 + kMin); + + // The last word: no limit forward + QCOMPARE(endTo(words, 2, 1000000), frame_t(1000000)); + QCOMPARE(endTo(words, 2, 20100), 20000 + kMin); + } + + // The dragged word keeps its other edge, its text and its line + void dragged_word_keeps_the_rest() { + sv::EventVector words { word(0, 10000, 2.f, "hello"), + word(12000, 20000, 2.f, "world") }; + + QCOMPARE(describe(LyricsEdit::startDraggedTo(words, 1, 11000, kRate)), + describe(word(11000, 20000, 2.f, "world"))); + QCOMPARE(describe(LyricsEdit::startDraggedTo(words, 1, 9000, kRate)), + describe(word(10000, 20000, 2.f, "world"))); + QCOMPARE(describe(LyricsEdit::startDraggedTo(words, 1, 19900, kRate)), + describe(word(20000 - kMin, 20000, 2.f, "world"))); + QCOMPARE(describe(LyricsEdit::endDraggedTo(words, 0, 11000, kRate)), + describe(word(0, 11000, 2.f, "hello"))); + QCOMPARE(describe(LyricsEdit::endDraggedTo(words, 0, 13000, kRate)), + describe(word(0, 12000, 2.f, "hello"))); + QCOMPARE(describe(LyricsEdit::endDraggedTo(words, 1, 50000, kRate)), + describe(word(12000, 50000, 2.f, "world"))); + + // Not dragged anywhere: the same event, which a command folds + // away + QVERIFY(LyricsEdit::startDraggedTo(words, 1, 12000, kRate) == words[1]); + QVERIFY(LyricsEdit::endDraggedTo(words, 1, 20000, kRate) == words[1]); + + // No such word: nothing to clamp, and no crash + QCOMPARE(startTo(words, 2, 500), frame_t(500)); + QCOMPARE(endTo(words, -1, 500), frame_t(500)); + } + + // Decision 6: an overlap already in a file may stay, and shrink, but + // never grows; the limits come from the words as they were when the + // drag began, so a drag back puts the word back + void clamps_with_an_overlap_already_there() { + sv::EventVector crossing { word(0, 15000), word(12000, 20000), + word(25000, 30000) }; + QCOMPARE(startTo(crossing, 1, 11000), frame_t(12000)); + QCOMPARE(startTo(crossing, 1, 13000), frame_t(13000)); + QCOMPARE(startTo(crossing, 1, 16000), frame_t(16000)); + QCOMPARE(endTo(crossing, 0, 16000), frame_t(15000)); + QCOMPARE(endTo(crossing, 0, 14000), frame_t(14000)); + QCOMPARE(endTo(crossing, 0, 11000), frame_t(11000)); + QCOMPARE(endTo(crossing, 1, 22000), frame_t(22000)); + QCOMPARE(endTo(crossing, 1, 26000), frame_t(25000)); + + // One word inside another: the inner one only shrinks, the + // outer one's end is not held by it + sv::EventVector within { word(0, 30000), word(10000, 12000), + word(40000, 50000) }; + QCOMPARE(startTo(within, 1, 5000), frame_t(10000)); + QCOMPARE(startTo(within, 1, 11500), 12000 - kMin); + QCOMPARE(endTo(within, 1, 20000), frame_t(12000)); + QCOMPARE(endTo(within, 1, 11000), frame_t(11000)); + QCOMPARE(endTo(within, 0, 35000), frame_t(35000)); + QCOMPARE(endTo(within, 0, 45000), frame_t(40000)); + QCOMPARE(endTo(within, 0, 11000), frame_t(11000)); + + // Two words at one frame, as an LRC file gives them: the first + // one frame long. A word starting at the same frame holds + // neither start back, but it does hold the short one's end + sv::EventVector together { word(0, 5000), word(10000, 10001), + word(10000, 20000) }; + QCOMPARE(startTo(together, 2, 7000), frame_t(7000)); + QCOMPARE(startTo(together, 2, 4000), frame_t(5000)); + QCOMPARE(startTo(together, 1, 3000), frame_t(5000)); + QCOMPARE(endTo(together, 1, 15000), frame_t(10001)); + QCOMPARE(endTo(together, 2, 25000), frame_t(25000)); + + // The same word twice: each is free of the other + sv::EventVector twice { word(10000, 20000), word(10000, 20000) }; + QCOMPARE(startTo(twice, 0, 5000), frame_t(5000)); + QCOMPARE(endTo(twice, 1, 25000), frame_t(25000)); + } + + // A word already shorter than 20 ms does not get shorter, but can + // be made longer; once it is, the 20 ms hold + void clamps_of_a_word_under_20ms() { + sv::EventVector words { word(0, 9000), word(10000, 10400), + word(11000, 20000) }; + QCOMPARE(startTo(words, 1, 10200), frame_t(10000)); + QCOMPARE(startTo(words, 1, 12000), frame_t(10000)); + QCOMPARE(endTo(words, 1, 10200), frame_t(10400)); + QCOMPARE(endTo(words, 1, 0), frame_t(10400)); + QCOMPARE(startTo(words, 1, 9500), frame_t(9500)); + QCOMPARE(startTo(words, 1, 8000), frame_t(9000)); + QCOMPARE(endTo(words, 1, 10800), frame_t(10800)); + QCOMPARE(endTo(words, 1, 12000), frame_t(11000)); + + sv::EventVector longer { word(0, 9000), word(9500, 10800), + word(11000, 20000) }; + QCOMPARE(startTo(longer, 1, 10500), 10800 - kMin); + + // A word of no length (never made by the parsers) stays at no + // length, not less + sv::EventVector none { word(0, 9000), word(10000, 10000), + word(11000, 20000) }; + QCOMPARE(startTo(none, 1, 10500), frame_t(10000)); + QCOMPARE(endTo(none, 1, 9500), frame_t(10000)); + QCOMPARE(startTo(none, 1, 9500), frame_t(9500)); + QCOMPARE(endTo(none, 1, 10500), frame_t(10500)); + } + + // Whatever the words and wherever the pointer goes: the edge ends up + // between where it was and where it was dragged, the word is never + // shorter than 20 ms or than it was, and no overlap grows + void no_drag_jumps_or_grows_an_overlap() { + const std::vector cases { + { word(0, 10000), word(10000, 20000), word(20000, 30000) }, + { word(1000, 5000), word(8000, 9000), word(15000, 40000) }, + { word(0, 15000), word(12000, 20000), word(25000, 30000) }, + { word(0, 30000), word(10000, 12000), word(40000, 50000) }, + { word(0, 5000), word(10000, 10001), word(10000, 20000) }, + { word(10000, 20000), word(10000, 20000) }, + { word(0, 9000), word(10000, 10400), word(11000, 20000) }, + { word(0, 9000), word(10000, 10000), word(11000, 20000) }, + { word(0, 20000), word(5000, 25000), word(8000, 12000), + word(30000, 30500) }, + }; + + const frame_t minimum = LyricsEdit::minWordFrames(kRate); + + for (int c = 0; c < int(cases.size()); ++c) { + const sv::EventVector &words = cases[c]; + + std::vector targets; + for (frame_t f = -2000; f < 55000; f += 97) targets.push_back(f); + for (const sv::Event &e : words) { + for (frame_t f : { e.getFrame(), endOf(e) }) { + for (frame_t d : { frame_t(-1), frame_t(0), frame_t(1), + -kMin, kMin }) { + targets.push_back(f + d); + } + } + } + + for (int i = 0; i < int(words.size()); ++i) { + const frame_t s = words[i].getFrame(); + const frame_t e = endOf(words[i]); + const frame_t shortest = + std::min(e - s, minimum); + + for (frame_t wanted : targets) { + for (bool start : { true, false }) { + sv::Event moved = start ? + LyricsEdit::startDraggedTo(words, i, wanted, kRate) : + LyricsEdit::endDraggedTo(words, i, wanted, kRate); + const frame_t ms = moved.getFrame(); + const frame_t me = endOf(moved); + const frame_t edge = start ? ms : me; + const frame_t was = start ? s : e; + const frame_t other = start ? me : ms; + const frame_t otherWas = start ? e : s; + + const QString where = + QString("case %1, word %2, %3 to %4: %5-%6") + .arg(c).arg(i).arg(start ? "start" : "end") + .arg(wanted).arg(ms).arg(me); + + QVERIFY2(other == otherWas, qPrintable(where)); + QVERIFY2(edge >= std::min(was, wanted) && + edge <= std::max(was, wanted), + qPrintable(where)); + QVERIFY2(moved.getDuration() >= shortest, + qPrintable(where)); + QVERIFY2(ms >= 0, qPrintable(where)); + for (int j = 0; j < int(words.size()); ++j) { + if (j == i) continue; + const frame_t js = words[j].getFrame(); + const frame_t je = endOf(words[j]); + QVERIFY2(overlap(ms, me, js, je) <= + overlap(s, e, js, je), + qPrintable(where + QString(", word %1") + .arg(j))); + } + } + } + } + } + } + + // Decision 9: 0.5 s from the click where there is room, up to the + // next word if that is nearer, moved back into the gap as far as + // that makes it longer + void new_word_where_there_is_room() { + const frame_t S = kSecond; + sv::EventVector words { word(0, S), word(3 * S, 4 * S) }; + + QCOMPARE(newSpan(words, S + S / 2), span(S + S / 2, S + S / 2 + kNew)); + QCOMPARE(newSpan(words, 2 * S), span(2 * S, 2 * S + kNew)); + + // At exactly a word's end: the gap after it begins there + QCOMPARE(newSpan(words, S), span(S, S + kNew)); + + // Near the next word: 0.5 s ending at it + QCOMPARE(newSpan(words, 3 * S - 8820), span(3 * S - kNew, 3 * S)); + QCOMPARE(newSpan(words, 3 * S - 1), span(3 * S - kNew, 3 * S)); + QCOMPARE(newSpan(words, 3 * S - kNew), span(3 * S - kNew, 3 * S)); + QCOMPARE(newSpan(words, 3 * S - kNew - 1), span(3 * S - kNew - 1, 3 * S - 1)); + } + + void new_word_with_little_room() { + const frame_t S = kSecond; + + // A gap shorter than 0.5 s: the whole of it, wherever the click + sv::EventVector words { word(0, S), word(S + 10000, 2 * S) }; + QCOMPARE(newSpan(words, S), span(S, S + 10000)); + QCOMPARE(newSpan(words, S + 5000), span(S, S + 10000)); + QCOMPARE(newSpan(words, S + 9999), span(S, S + 10000)); + + // 20 ms is room enough; less is not + sv::EventVector just { word(0, S), word(S + kMin, 2 * S) }; + QCOMPARE(newSpan(just, S + 400), span(S, S + kMin)); + sv::EventVector under { word(0, S), word(S + kMin - 1, 2 * S) }; + QCOMPARE(newSpan(under, S), QString("none")); + QCOMPARE(newSpan(under, S + 400), QString("none")); + } + + // Add Word is for empty space: none in a word, before frame 0, or + // without a sample rate + void new_word_none_in_a_word() { + const frame_t S = kSecond; + sv::EventVector words { word(0, S), word(3 * S, 4 * S) }; + QCOMPARE(newSpan(words, 0), QString("none")); + QCOMPARE(newSpan(words, S / 2), QString("none")); + QCOMPARE(newSpan(words, S - 1), QString("none")); + QCOMPARE(newSpan(words, 3 * S), QString("none")); + QCOMPARE(newSpan(words, 4 * S - 1), QString("none")); + QCOMPARE(newSpan({}, -1), QString("none")); + + Span s; + s.start = 7; + s.end = 9; + QVERIFY(!LyricsEdit::newWordSpan(words, 2 * S, 0, s)); + QVERIFY(!LyricsEdit::newWordSpan(words, S / 2, kRate, s)); + QCOMPARE(s.start, frame_t(7)); + QCOMPARE(s.end, frame_t(9)); + } + + // Before the first word the gap starts at frame 0; after the last + // it has no end + void new_word_before_the_first_and_after_the_last() { + const frame_t S = kSecond; + sv::EventVector words { word(3 * S, 4 * S) }; + QCOMPARE(newSpan(words, 0), span(0, kNew)); + QCOMPARE(newSpan(words, S), span(S, S + kNew)); + QCOMPARE(newSpan(words, 4 * S), span(4 * S, 4 * S + kNew)); + QCOMPARE(newSpan(words, 10 * S), span(10 * S, 10 * S + kNew)); + + // A first word 0.3 s in: the new one fills the 0.3 s before it + sv::EventVector early { word(13230, S) }; + QCOMPARE(newSpan(early, 4410), span(0, 13230)); + sv::EventVector tooEarly { word(kMin - 1, S) }; + QCOMPARE(newSpan(tooEarly, 100), QString("none")); + + QCOMPARE(newSpan({}, 1000), span(1000, 1000 + kNew)); + QCOMPARE(newSpan({}, 0), span(0, kNew)); + } + + // Overlapping words from a file: the gap is between the latest end + // before the click and the earliest start after it + void new_word_among_overlapping_words() { + sv::EventVector crossing { word(0, 30000), word(10000, 50000), + word(60000, 100000) }; + QCOMPARE(newSpan(crossing, 40000), QString("none")); + QCOMPARE(newSpan(crossing, 55000), span(50000, 60000)); + + sv::EventVector within { word(0, 50000), word(10000, 20000), + word(60000, 100000) }; + QCOMPARE(newSpan(within, 30000), QString("none")); + QCOMPARE(newSpan(within, 55000), span(50000, 60000)); + } + + // Decision 10: the line of the nearer neighbour by the gap, of the + // word before on a tie, of the only one if one, 0 if none + void new_word_line() { + sv::EventVector words { word(0, 10000, 0.f), word(30000, 40000, 1.f) }; + auto line = [&](frame_t s, frame_t e) { + Span added; + added.start = s; + added.end = e; + return LyricsEdit::newWordLine(words, added); + }; + QCOMPARE(line(12000, 14000), 0.f); + QCOMPARE(line(25000, 28000), 1.f); + QCOMPARE(line(15000, 25000), 0.f); + QCOMPARE(line(15000, 24999), 0.f); + QCOMPARE(line(15001, 25000), 1.f); + QCOMPARE(line(10000, 30000), 0.f); + QCOMPARE(line(50000, 60000), 1.f); + + words = { word(20000, 30000, 3.f) }; + QCOMPARE(line(0, 10000), 3.f); + QCOMPARE(line(40000, 50000), 3.f); + + words = {}; + QCOMPARE(line(0, 10000), 0.f); + + // The word before is the one ending latest, not the last to + // start + words = { word(0, 50000, 4.f), word(10000, 20000, 5.f), + word(80000, 90000, 6.f) }; + QCOMPARE(line(52000, 60000), 4.f); + QCOMPARE(line(70000, 78000), 6.f); + + // From newWordSpan(), as the editor uses them together: just + // after a line's last word, just before the next line's first, + // and filling a gap between the two wholly + const frame_t S = kSecond; + words = { word(0, S, 0.f), word(2 * S, 3 * S, 1.f), + word(6 * S, 7 * S, 2.f), word(7 * S + 10000, 8 * S, 3.f) }; + Span added; + QVERIFY(LyricsEdit::newWordSpan(words, S + 1000, kRate, added)); + QCOMPARE(span(added.start, added.end), span(S + 1000, S + 1000 + kNew)); + QCOMPARE(LyricsEdit::newWordLine(words, added), 0.f); + QVERIFY(LyricsEdit::newWordSpan(words, 2 * S - 1000, kRate, added)); + QCOMPARE(span(added.start, added.end), span(2 * S - kNew, 2 * S)); + QCOMPARE(LyricsEdit::newWordLine(words, added), 1.f); + QVERIFY(LyricsEdit::newWordSpan(words, 4 * S, kRate, added)); + QCOMPARE(LyricsEdit::newWordLine(words, added), 1.f); + QVERIFY(LyricsEdit::newWordSpan(words, 6 * S - 1000, kRate, added)); + QCOMPARE(LyricsEdit::newWordLine(words, added), 2.f); + QVERIFY(LyricsEdit::newWordSpan(words, 7 * S + 5000, kRate, added)); + QCOMPARE(span(added.start, added.end), span(7 * S, 7 * S + 10000)); + QCOMPARE(LyricsEdit::newWordLine(words, added), 2.f); + } + + // Decision 11: typed text is cleaned as the parsers clean labels + void typed_text_cleaned() { + QCOMPARE(LyricsEdit::cleanText("hello"), QString("hello")); + QCOMPARE(LyricsEdit::cleanText(" hello \t"), QString("hello")); + QCOMPARE(LyricsEdit::cleanText("a\tb"), QString("a b")); + QCOMPARE(LyricsEdit::cleanText(QString("a\x01" "b\x7f" "c")), + QString("abc")); + QCOMPARE(LyricsEdit::cleanText(QString("a") + QChar(0xFFFE) + "b"), + QString("ab")); + QCOMPARE(LyricsEdit::cleanText("a\nb"), QString("ab")); + QCOMPARE(LyricsEdit::cleanText("&"), QString("&")); + QCOMPARE(LyricsEdit::cleanText(QString::fromUtf8("syd\xc3\xa4n")), + QString::fromUtf8("syd\xc3\xa4n")); + + QString longText(Lyrics::maxLabelLength + 50, QChar('x')); + QCOMPARE(LyricsEdit::cleanText(longText).size(), + qsizetype(Lyrics::maxLabelLength)); + + // Nothing left: empty, which the edit refuses + QCOMPARE(LyricsEdit::cleanText(""), QString()); + QVERIFY(LyricsEdit::cleanText(" ").isEmpty()); + QVERIFY(LyricsEdit::cleanText("\t\n").isEmpty()); + QVERIFY(LyricsEdit::cleanText(QString("\x01\x02")).isEmpty()); + } + + // Decision 17: all the words together, later as far as asked, and + // earlier only until the first word starts at 0 + void shift_clamp() { + const frame_t S = kSecond; + sv::EventVector words = { word(S, 2 * S), word(2 * S, 3 * S, 1.f), + word(5 * S, 6 * S, 2.f) }; + + QCOMPARE(LyricsEdit::clampShift(words, 0), frame_t(0)); + QCOMPARE(LyricsEdit::clampShift(words, 8820), frame_t(8820)); + QCOMPARE(LyricsEdit::clampShift(words, 100 * S), 100 * S); + QCOMPARE(LyricsEdit::clampShift(words, -8820), frame_t(-8820)); + QCOMPARE(LyricsEdit::clampShift(words, -S), -S); + QCOMPARE(LyricsEdit::clampShift(words, -S - 1), -S); + QCOMPARE(LyricsEdit::clampShift(words, -10 * S), -S); + + // The earliest start wherever it is in the vector, and not the + // first word's if another starts before it (one inside another) + sv::EventVector unordered = { word(3 * S, 4 * S), word(S / 2, S), + word(2 * S, 3 * S) }; + QCOMPARE(LyricsEdit::clampShift(unordered, -S), -S / 2); + sv::EventVector inside = { word(S, 5 * S), word(S / 4, S / 2) }; + QCOMPARE(LyricsEdit::clampShift(inside, -S), -S / 4); + + // A first word at 0 goes nowhere earlier, but anywhere later + sv::EventVector atZero = { word(0, S), word(S, 2 * S) }; + QCOMPARE(LyricsEdit::clampShift(atZero, -1), frame_t(0)); + QCOMPARE(LyricsEdit::clampShift(atZero, -S), frame_t(0)); + QCOMPARE(LyricsEdit::clampShift(atZero, 1), frame_t(1)); + + // A word before 0 already, which no parser makes, is not taken + // further back; later is still allowed + sv::EventVector before = { word(-100, S), word(S, 2 * S) }; + QCOMPARE(LyricsEdit::clampShift(before, -S), frame_t(0)); + QCOMPARE(LyricsEdit::clampShift(before, S), S); + + // Nothing to move + QCOMPARE(LyricsEdit::clampShift({}, -S), frame_t(0)); + QCOMPARE(LyricsEdit::clampShift({}, S), frame_t(0)); + } + + // Every word moved by the same amount, start and end: lengths, texts, + // lines and order kept, overlaps and gaps from the file as they were + void shifted_words() { + const frame_t S = kSecond; + sv::EventVector words = { word(S, 2 * S, 0.f, "Yksi"), + word(2 * S, 3 * S, 0.f, "kaksi"), + word(2 * S + 100, 4 * S, 1.f, "kolme"), + word(6 * S, 6 * S + 10, 2.f, "nelja") }; + + auto verifyMovedBy = [&](const sv::EventVector &moved, frame_t by) { + QCOMPARE(int(moved.size()), int(words.size())); + for (int i = 0; i < int(words.size()); ++i) { + QCOMPARE(moved[i].getFrame(), words[i].getFrame() + by); + QCOMPARE(endOf(moved[i]), endOf(words[i]) + by); + QCOMPARE(moved[i].getDuration(), words[i].getDuration()); + QCOMPARE(moved[i].getLabel(), words[i].getLabel()); + QCOMPARE(moved[i].getValue(), words[i].getValue()); + QVERIFY2(i == 0 || !(moved[i] < moved[i - 1]), + qPrintable(describe(moved[i]))); + } + }; + + verifyMovedBy(LyricsEdit::shifted(words, 8820), 8820); + verifyMovedBy(LyricsEdit::shifted(words, -8820), -8820); + verifyMovedBy(LyricsEdit::shifted(words, 0), 0); + QCOMPARE(LyricsEdit::shifted(words, 0), words); + + // Clamped: the first word lands on 0, the rest keep their places + // relative to it + sv::EventVector clamped = LyricsEdit::shifted(words, -5 * S); + verifyMovedBy(clamped, -S); + QCOMPARE(clamped[0].getFrame(), frame_t(0)); + QCOMPARE(LyricsEdit::shifted(clamped, -S), clamped); + + // There and back + QCOMPARE(LyricsEdit::shifted(LyricsEdit::shifted(words, 12345), + -12345), words); + + QVERIFY(LyricsEdit::shifted({}, S).empty()); + } +}; + +#endif diff --git a/main/test/TestLyricsLayer.h b/main/test/TestLyricsLayer.h new file mode 100644 index 00000000..2acb8ad9 --- /dev/null +++ b/main/test/TestLyricsLayer.h @@ -0,0 +1,634 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TEST_LYRICS_LAYER_H +#define TEST_LYRICS_LAYER_H + +// Tier 3: the lyrics plot style of RegionLayer (svgui fork), which +// draws the words of the lyrics along the bottom of pane 0. Where each +// label goes and how big its font is are worked out by pure functions, +// tested first; then the layer is painted into images, with no +// MainWindow. + +#include "framework/Document.h" +#include "view/Pane.h" +#include "view/PaneStack.h" +#include "view/ViewManager.h" +#include "layer/RegionLayer.h" +#include "layer/LayerFactory.h" +#include "data/model/RegionModel.h" +#include "data/model/WritableWaveFileModel.h" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +class TestLyricsLayer : public QObject +{ + Q_OBJECT + + typedef std::vector> Places; + + static Places place(const std::vector &labels, + int rows = 2, double gap = 4) { + Places places; + for (const auto &p : sv::RegionLayer::placeLyricsLabels(labels, rows, gap)) { + places.push_back({ p.row, p.left }); + } + return places; + } + + QTemporaryDir m_dir; + + sv::ViewManager *m_viewManager = nullptr; + sv::PaneStack *m_paneStack = nullptr; + sv::Document *m_document = nullptr; + sv::Pane *m_pane = nullptr; + sv::RegionLayer *m_layer = nullptr; + + static constexpr double kRate = 44100.0; + static constexpr int kWidth = 800; + static constexpr int kHeight = 300; + + sv::ModelId makeAudioModel() { + auto model = std::make_shared + (m_dir.filePath("audio.wav"), kRate, 1, + sv::WritableWaveFileModel::Normalisation::None); + std::vector data(size_t(kRate) * 10, 0.25f); + const float *ptr = data.data(); + model->addSamples(&ptr, sv::sv_frame_t(data.size())); + model->writeComplete(); + return sv::ModelById::add(model); + } + + // Words a quarter of a second apart, each longer than that at the + // zoom the tests use (100 pixels a second), so that they do not + // all fit in one row; a new line every four words + void addWords(int count) { + auto model = sv::ModelById::getAs(m_layer->getModel()); + QVERIFY(model); + for (int i = 0; i < count; ++i) { + sv::sv_frame_t frame = sv::sv_frame_t(kRate * 0.25 * i); + model->add(sv::Event(frame, float(i / 4), sv::sv_frame_t(kRate * 0.2), + QString("sanaseppo%1").arg(i))); + } + } + + // The layer painted into a white image, clip rect by clip rect, as + // the view paints what has scrolled into sight + QImage render(const std::vector &rects) { + QImage image(kWidth, kHeight, QImage::Format_ARGB32); + image.fill(Qt::white); + QPainter painter(&image); + for (const QRect &rect : rects) { + painter.save(); + painter.setClipRect(rect); + m_layer->paint(m_pane, painter, rect); + painter.restore(); + } + painter.end(); + return image; + } + + static bool rowIsWhite(const QImage &image, int y) { + for (int x = 0; x < image.width(); ++x) { + if (image.pixel(x, y) != qRgb(255, 255, 255)) return false; + } + return true; + } + + // Words two seconds apart and a second and a half long, with short + // labels: at the tests' zoom each box is 150 pixels of region with + // its label well inside it, and nothing is left out + void addSpacedWords(int count) { + auto model = sv::ModelById::getAs(m_layer->getModel()); + QVERIFY(model); + for (int i = 0; i < count; ++i) { + model->add(sv::Event(sv::sv_frame_t(kRate * 2.0 * i), 0.f, + sv::sv_frame_t(kRate * 1.5), + QString("w%1").arg(i))); + } + } + + sv::Event spacedWord(int i) { + return sv::Event(sv::sv_frame_t(kRate * 2.0 * i), 0.f, + sv::sv_frame_t(kRate * 1.5), QString("w%1").arg(i)); + } + + // The columns in which two images differ, as [first, last], or + // (-1, -1) if they are the same + static std::pair differingColumns(const QImage &a, const QImage &b) { + int first = -1, last = -1; + for (int x = 0; x < a.width(); ++x) { + for (int y = 0; y < a.height(); ++y) { + if (a.pixel(x, y) != b.pixel(x, y)) { + if (first < 0) first = x; + last = x; + break; + } + } + } + return { first, last }; + } + +private slots: + void initTestCase() { + QVERIFY(m_dir.isValid()); + } + + void init() { + m_viewManager = new sv::ViewManager; + m_paneStack = new sv::PaneStack(nullptr, m_viewManager); + m_document = new sv::Document; + m_document->setMainModel(makeAudioModel()); + m_pane = m_paneStack->addPane(); + m_pane->resize(kWidth, kHeight); + m_pane->setZoomLevel(sv::ZoomLevel(sv::ZoomLevel::FramesPerPixel, + int(kRate / 100))); + m_pane->setStartFrame(0); + + auto model = std::make_shared(kRate, 1); + sv::ModelId id = sv::ModelById::add(model); + m_document->addNonDerivedModel(id); + m_layer = qobject_cast + (m_document->createLayer(sv::LayerFactory::Regions)); + QVERIFY(m_layer); + m_document->setModel(m_layer, id); + m_layer->setPlotStyle(sv::RegionLayer::PlotLyrics); + m_document->attachLayerToView(m_pane, m_layer); + } + + void cleanup() { + // The document force-deletes its layers from their views, so + // it has to go while the panes still exist + delete m_document; + delete m_paneStack; + delete m_viewManager; + m_document = nullptr; + m_paneStack = nullptr; + m_viewManager = nullptr; + m_pane = nullptr; + m_layer = nullptr; + } + + // --- Where labels go ------------------------------------------------- + + void contiguous_labels_that_fit_their_boxes_share_the_first_row() { + // Each word ends where the next starts, as in the lyrics + QCOMPARE(place({ { 0, 100, 50 }, { 100, 200, 50 }, { 200, 300, 50 } }), + Places({ { 0, 25 }, { 0, 125 }, { 0, 225 } })); + } + + void a_label_wider_than_its_box_moves_right_into_room() { + QCOMPARE(place({ { 0, 40, 20 }, { 40, 80, 60 } }), + Places({ { 0, 10 }, { 0, 34 } })); + } + + void the_labels_before_move_left_to_make_room() { + QCOMPARE(place({ { 0, 40, 30 }, { 40, 60, 50 } }), + Places({ { 0, 1 }, { 0, 35 } })); + // Through more than one of them, each only as far as it must + QCOMPARE(place({ { 0, 40, 30 }, { 40, 80, 30 }, { 80, 100, 60 } }), + Places({ { 0, 2 }, { 0, 36 }, { 0, 70 } })); + QCOMPARE(place({ { 0, 40, 30 }, { 40, 80, 30 }, { 80, 100, 50 } }), + Places({ { 0, 5 }, { 0, 41 }, { 0, 75 } })); + } + + void a_label_whose_middle_would_leave_its_box_goes_to_the_next_row() { + // The first would have to move 34 left, but may move only 10; + // it stays where it was + QCOMPARE(place({ { 0, 20, 60 }, { 20, 40, 60 } }), + Places({ { 0, -20 }, { 1, 0 } })); + // A long label centred on a short region reaches back past the + // label before it, and cannot move far enough + QCOMPARE(place({ { 95, 105, 10 }, { 105, 115, 100 }, { 120, 130, 10 } }), + Places({ { 0, 95 }, { 1, 60 }, { 0, 120 } })); + } + + void a_label_with_no_room_in_any_row_is_left_out() { + QCOMPARE(place({ { 0, 20, 60 }, { 20, 40, 60 }, { 40, 60, 60 }, + { 200, 220, 20 } }), + Places({ { 0, -20 }, { 1, 0 }, { -1, 20 }, { 0, 200 } })); + } + + void the_gap_is_the_least_space_between_labels() { + // Regions of no width: their labels cannot move + QCOMPARE(place({ { 5, 5, 10 }, { 19, 19, 10 } }), + Places({ { 0, 0 }, { 0, 14 } })); + QCOMPARE(place({ { 5, 5, 10 }, { 18.5, 18.5, 10 } }), + Places({ { 0, 0 }, { 1, 13.5 } })); + } + + void no_labels_and_no_rows() { + QCOMPARE(place({}), Places()); + QCOMPARE(place({ { 0, 10, 10 } }, 0), Places({ { -1, 0 } })); + } + + void the_font_grows_with_the_zoom_from_twice_to_four_times() { + // Base 13 pixels in a tall view: 26 to 52, growing from 260 + // pixels per second with the square root of the zoom + QCOMPARE(sv::RegionLayer::getLyricsFontPixelSize(50, 13, 800), 26); + QCOMPARE(sv::RegionLayer::getLyricsFontPixelSize(260, 13, 800), 26); + QCOMPARE(sv::RegionLayer::getLyricsFontPixelSize(300, 13, 800), 28); + QCOMPARE(sv::RegionLayer::getLyricsFontPixelSize(585, 13, 800), 39); + QCOMPARE(sv::RegionLayer::getLyricsFontPixelSize(1040, 13, 800), 52); + QCOMPARE(sv::RegionLayer::getLyricsFontPixelSize(5000, 13, 800), 52); + // Never more than an eighth of the view, nor less than its font + QCOMPARE(sv::RegionLayer::getLyricsFontPixelSize(5000, 13, 200), 25); + QCOMPARE(sv::RegionLayer::getLyricsFontPixelSize(50, 13, 200), 25); + QCOMPARE(sv::RegionLayer::getLyricsFontPixelSize(50, 13, 40), 13); + + int last = 0; + for (double pps = 10; pps < 2000; pps *= 1.3) { + int size = sv::RegionLayer::getLyricsFontPixelSize(pps, 13, 600); + QVERIFY2(size >= last, "the font got smaller as the view zoomed in"); + last = size; + } + } + + void the_font_grows_more_slowly_than_the_boxes() { + // Zooming in twice as far makes every box twice as long and its + // word less than one and a half times as large, so that it fits + // better + for (double pps = 260; pps <= 520; pps *= 1.1) { + int size = sv::RegionLayer::getLyricsFontPixelSize(pps, 13, 800); + int zoomedIn = sv::RegionLayer::getLyricsFontPixelSize(pps * 2, 13, 800); + QVERIFY2(zoomedIn <= 1.5 * size, + qPrintable(QString("%1 pixels at %2 pixels a second, %3 at " + "twice the zoom") + .arg(size).arg(pps).arg(zoomedIn))); + } + } + + // --- The layer -------------------------------------------------------- + + void lyrics_take_no_vertical_scale_and_no_edits() { + // Like the coverage strip: nothing in the pane may align its + // scale to the lyrics, and no tool may edit them + QVERIFY(!m_layer->isLayerEditable()); + QVERIFY(m_layer->getVerticalExtents().first == + sv::Layer::NO_VERTICAL_EXTENTS.first); + addWords(4); + QPoint pos(10, 10); + QCOMPARE(m_layer->getFeatureDescription(m_pane, pos), QString()); + } + + void lyrics_are_drawn_only_along_the_bottom() { + addWords(20); + QImage image = render({ QRect(0, 0, kWidth, kHeight) }); + + bool drawn = false; + for (int y = kHeight * 3 / 4; y < kHeight; ++y) { + if (!rowIsWhite(image, y)) drawn = true; + } + QVERIFY2(drawn, "nothing was drawn along the bottom of the pane"); + + for (int y = 0; y < kHeight / 2; ++y) { + QVERIFY2(rowIsWhite(image, y), + qPrintable(QString("something was drawn at y = %1, " + "above the band of the lyrics").arg(y))); + } + + // The coverage strip's band, the bottom six pixels, is left to it + for (int y = kHeight - m_pane->scalePixelSize(6); y < kHeight; ++y) { + QVERIFY2(rowIsWhite(image, y), + qPrintable(QString("something was drawn at y = %1, " + "where the coverage strip goes").arg(y))); + } + } + + void a_label_is_centred_in_its_box() { + addSpacedWords(3); + QImage image = render({ QRect(0, 0, kWidth, kHeight) }); + + // The dark pixels of the text of the second word, which spans + // x = 200 to 350, above the bars + int x0 = m_pane->getXForFrame(spacedWord(1).getFrame()); + int x1 = m_pane->getXForFrame(spacedWord(1).getFrame() + + spacedWord(1).getDuration()); + int left = -1, right = -1; + for (int x = x0; x <= x1; ++x) { + for (int y = kHeight / 2; y < kHeight - 12; ++y) { + if (qGray(image.pixel(x, y)) < 100) { + if (left < 0) left = x; + right = x; + break; + } + } + } + QVERIFY2(left >= 0, "no text was found in the box"); + double textMiddle = (left + right) / 2.0; + double boxMiddle = (x0 + x1) / 2.0; + QVERIFY2(std::fabs(textMiddle - boxMiddle) <= 3.0, + qPrintable(QString("the text is centred on x = %1, the box " + "on x = %2").arg(textMiddle).arg(boxMiddle))); + } + + void the_highlight_follows_the_frame_and_repaints_only_for_a_new_word() { + addSpacedWords(4); + QSignalSpy repaints(m_layer, &sv::Layer::layerParametersChanged); + sv::Event e(0); + + m_layer->setHighlightFrame(sv::sv_frame_t(kRate * 2.1)); + QCOMPARE(int(repaints.count()), 1); + QVERIFY(m_layer->getHighlightedEvent(e)); + QVERIFY(e == spacedWord(1)); + + // Within the same word: nothing to paint again + m_layer->setHighlightFrame(sv::sv_frame_t(kRate * 3.0)); + QCOMPARE(int(repaints.count()), 1); + + // Between words: none + m_layer->setHighlightFrame(sv::sv_frame_t(kRate * 3.7)); + QCOMPARE(int(repaints.count()), 2); + QVERIFY(!m_layer->getHighlightedEvent(e)); + m_layer->setHighlightFrame(-1); + QCOMPARE(int(repaints.count()), 2); + + m_layer->setHighlightFrame(sv::sv_frame_t(kRate * 4.0)); + QCOMPARE(int(repaints.count()), 3); + QVERIFY(m_layer->getHighlightedEvent(e)); + QVERIFY(e == spacedWord(2)); + } + + void the_highlighted_word_is_drawn_differently_and_nothing_else_is() { + addSpacedWords(4); + QImage plain = render({ QRect(0, 0, kWidth, kHeight) }); + + m_layer->setHighlightFrame(sv::sv_frame_t(kRate * 2.1)); + QImage highlighted = render({ QRect(0, 0, kWidth, kHeight) }); + + auto columns = differingColumns(plain, highlighted); + QVERIFY2(columns.first >= 0, "the highlighted word looks the same"); + int x0 = m_pane->getXForFrame(spacedWord(1).getFrame()); + int x1 = m_pane->getXForFrame(spacedWord(1).getFrame() + + spacedWord(1).getDuration()); + QVERIFY2(columns.first >= x0 && columns.second <= x1, + qPrintable(QString("pixels changed from x = %1 to %2, outside " + "the word's box at %3 to %4") + .arg(columns.first).arg(columns.second) + .arg(x0).arg(x1))); + } + + // The highlight's box colour, and nothing else drawn is like it + static int highlightPixels(const QImage &image) { + int n = 0; + for (int y = 0; y < image.height(); ++y) { + for (int x = 0; x < image.width(); ++x) { + QRgb p = image.pixel(x, y); + if (qRed(p) > 240 && qGreen(p) > 195 && qGreen(p) < 225 && + qBlue(p) > 100 && qBlue(p) < 150) ++n; + } + } + return n; + } + + void the_highlighted_word_is_shown_even_with_no_room_of_its_own() { + // Words this close fill both rows, and the third has no room + addWords(3); + QImage plain = render({ QRect(0, 0, kWidth, kHeight) }); + QCOMPARE(highlightPixels(plain), 0); + + m_layer->setHighlightFrame(sv::sv_frame_t(kRate * 0.55)); + sv::Event e(0); + QVERIFY(m_layer->getHighlightedEvent(e)); + QCOMPARE(e.getLabel(), QString("sanaseppo2")); + QImage highlighted = render({ QRect(0, 0, kWidth, kHeight) }); + + // Its box is there whatever happens; its label, far wider, + // has the halo of a highlighted box around it + int x0 = m_pane->getXForFrame(e.getFrame()); + int x1 = m_pane->getXForFrame(e.getFrame() + e.getDuration()); + QImage outside = highlighted; + QPainter painter(&outside); + painter.fillRect(x0, 0, x1 - x0, kHeight, Qt::white); + painter.end(); + QVERIFY2(highlightPixels(outside) > 50, + "the word being sung was not drawn: it had no row"); + } + + // The top edge of the boxes: the highest row in which this column + // has anything drawn in it + static int topDrawnRow(const QImage &image, int x) { + for (int y = 0; y < image.height(); ++y) { + if (image.pixel(x, y) != qRgb(255, 255, 255)) return y; + } + return -1; + } + + static bool isBoxEdge(QRgb p) { + return qRed(p) < 200 && qBlue(p) - qRed(p) >= 20; + } + + void contiguous_words_that_fit_are_drawn_in_one_row() { + // Each word ends where the next starts, and each label is far + // shorter than its box + auto model = sv::ModelById::getAs(m_layer->getModel()); + QVERIFY(model); + const double length = 0.6; + for (int i = 0; i < 10; ++i) { + model->add(sv::Event(sv::sv_frame_t(kRate * length * i), 0.f, + sv::sv_frame_t(kRate * length), + QString("w%1").arg(i))); + } + QImage image = render({ QRect(0, 0, kWidth, kHeight) }); + + int top = -1; + for (int i = 0; i < 10; ++i) { + int x = m_pane->getXForFrame(sv::sv_frame_t(kRate * length * i)) + 8; + if (x >= kWidth) break; + int y = topDrawnRow(image, x); + QVERIFY2(y > 0, qPrintable(QString("nothing drawn for word %1").arg(i))); + if (top < 0) top = y; + QVERIFY2(y == top, + qPrintable(QString("word %1's box starts at y = %2, the " + "first word's at y = %3") + .arg(i).arg(y).arg(top))); + } + for (int y = 0; y < top; ++y) { + QVERIFY2(rowIsWhite(image, y), + qPrintable(QString("something was drawn at y = %1, above " + "the boxes at y = %2").arg(y).arg(top))); + } + } + + void a_box_is_its_region_even_when_its_label_is_wider() { + // Twenty pixels of region, and a label several times as wide + auto model = sv::ModelById::getAs(m_layer->getModel()); + QVERIFY(model); + sv::Event e(sv::sv_frame_t(kRate * 3.0), 0.f, sv::sv_frame_t(kRate * 0.2), + QString("sanaseppo")); + model->add(e); + QImage image = render({ QRect(0, 0, kWidth, kHeight) }); + + int x0 = m_pane->getXForFrame(e.getFrame()); + int x1 = m_pane->getXForFrame(e.getFrame() + e.getDuration()); + QCOMPARE(x1 - x0, 20); + + // The box's top edge, a row above anything of the label's + int top = -1; + for (int y = 0; y < kHeight; ++y) { + if (isBoxEdge(image.pixel(x0 + 10, y))) { + top = y; + break; + } + } + QVERIFY2(top >= 0, "no box edge was found"); + + int left = -1, right = -1; + for (int x = 0; x < kWidth; ++x) { + if (isBoxEdge(image.pixel(x, top))) { + if (left < 0) left = x; + right = x; + } + } + QVERIFY2(left >= x0 && right <= x1 - 1, + qPrintable(QString("the box's top edge runs from x = %1 to %2, " + "its region from %3 to %4") + .arg(left).arg(right).arg(x0).arg(x1 - 1))); + + // And the label is drawn, past both ends of the box + bool textLeft = false, textRight = false; + for (int y = top; y < kHeight; ++y) { + for (int x = 0; x < x0 - 2; ++x) { + if (qGray(image.pixel(x, y)) < 100) textLeft = true; + } + for (int x = x1 + 2; x < kWidth; ++x) { + if (qGray(image.pixel(x, y)) < 100) textRight = true; + } + } + QVERIFY2(textLeft && textRight, + "the label is not drawn centred over its box, past both ends"); + } + + void an_edited_word_is_laid_out_again() { + // The same number of words, over the same stretch of time: all + // that a stale layout would have noticed + addSpacedWords(3); + render({ QRect(0, 0, kWidth, kHeight) }); + + auto model = sv::ModelById::getAs(m_layer->getModel()); + QVERIFY(model); + QSignalSpy repaints(m_layer, &sv::Layer::layerParametersChanged); + model->remove(spacedWord(1)); + model->add(spacedWord(1).withLabel("sanaseppo")); + QVERIFY2(repaints.count() > 0, "the edit did not ask for a repaint"); + + QImage image = render({ QRect(0, 0, kWidth, kHeight) }); + int x0 = m_pane->getXForFrame(spacedWord(1).getFrame()); + int x1 = m_pane->getXForFrame(spacedWord(1).getFrame() + + spacedWord(1).getDuration()); + int dark = 0; + for (int x = x0; x < x1; ++x) { + for (int y = kHeight / 2; y < kHeight; ++y) { + if (qGray(image.pixel(x, y)) < 100) ++dark; + } + } + QVERIFY2(dark > 20, "the edited word's label is not drawn"); + } + + void the_highlight_follows_an_edit_of_the_word_being_sung() { + addSpacedWords(3); + auto model = sv::ModelById::getAs(m_layer->getModel()); + QVERIFY(model); + m_layer->setHighlightFrame(sv::sv_frame_t(kRate * 2.1)); + + sv::Event renamed = spacedWord(1).withLabel("sana"); + model->remove(spacedWord(1)); + model->add(renamed); + sv::Event e(0); + QVERIFY(m_layer->getHighlightedEvent(e)); + QCOMPARE(e.getLabel(), QString("sana")); + + // Shortened to end before the frame: no word is being sung + model->remove(renamed); + model->add(renamed.withDuration(sv::sv_frame_t(kRate * 0.05))); + QVERIFY(!m_layer->getHighlightedEvent(e)); + } + + void the_box_row_is_where_the_boxes_are_painted() { + QVERIFY(m_layer->getLyricsBoxRow(m_pane).isEmpty()); + addSpacedWords(3); + QImage image = render({ QRect(0, 0, kWidth, kHeight) }); + QRect row = m_layer->getLyricsBoxRow(m_pane); + QVERIFY(!row.isEmpty()); + + int x = m_pane->getXForFrame(spacedWord(1).getFrame()) + 8; + QCOMPARE(topDrawnRow(image, x), row.top()); + int bottom = row.top(); + while (bottom + 1 < kHeight && + image.pixel(x, bottom + 1) != qRgb(255, 255, 255)) { + ++bottom; + } + QCOMPARE(bottom, row.bottom()); + QCOMPARE(row.left(), 0); + QCOMPARE(row.width(), m_pane->getPaintWidth()); + } + + // A phone draws the words smaller (LyricsTrack::textScale()): the + // row of boxes, as high as a line of the font and a margin, comes + // down with the font + void the_words_are_drawn_at_the_text_scale() { + QCOMPARE(m_layer->getLyricsTextScale(), 1.0); + addSpacedWords(3); + render({ QRect(0, 0, kWidth, kHeight) }); + int margin = m_pane->scalePixelSize(4); + int whole = m_layer->getLyricsBoxRow(m_pane).height() - margin; + QVERIFY(whole > 10); + + m_layer->setLyricsTextScale(0.65); + render({ QRect(0, 0, kWidth, kHeight) }); + int scaled = m_layer->getLyricsBoxRow(m_pane).height() - margin; + QVERIFY2(std::abs(scaled - 0.65 * whole) <= 2.0, + qPrintable(QString("a line of the words is %1 px high at " + "65%, against %2 px at 100%") + .arg(scaled).arg(whole))); + + m_layer->setLyricsTextScale(1.0); + render({ QRect(0, 0, kWidth, kHeight) }); + QCOMPARE(m_layer->getLyricsBoxRow(m_pane).height() - margin, whole); + } + + void painting_in_strips_matches_painting_whole() { + // A view that scrolls repaints only the strip that comes into + // sight; the labels in it must be where they were when the + // whole view was painted, or the text breaks at the seams. + // With words this close some go to the second row and some are + // left out, which is what depends on the neighbours + addWords(24); + QImage whole = render({ QRect(0, 0, kWidth, kHeight) }); + QImage strips = render({ QRect(0, 0, 230, kHeight), + QRect(230, 0, 310, kHeight), + QRect(540, 0, kWidth - 540, kHeight) }); + QVERIFY2(whole == strips, + "the layer painted in strips differs from the layer painted whole"); + + // And again after the view has scrolled on by part of a label + m_pane->setStartFrame(sv::sv_frame_t(kRate * 0.37)); + whole = render({ QRect(0, 0, kWidth, kHeight) }); + strips = render({ QRect(0, 0, 400, kHeight), + QRect(400, 0, kWidth - 400, kHeight) }); + QVERIFY2(whole == strips, + "after scrolling, the layer painted in strips differs from " + "the layer painted whole"); + } +}; + +#endif diff --git a/main/test/TestLyricsTtml.h b/main/test/TestLyricsTtml.h new file mode 100644 index 00000000..c1c266c4 --- /dev/null +++ b/main/test/TestLyricsTtml.h @@ -0,0 +1,909 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TEST_LYRICS_TTML_H +#define TEST_LYRICS_TTML_H + +// Tier 2: reading and writing TTML lyrics, and parseLyrics() choosing +// between TTML and LRC. No window and no model. Two of the files in +// testdata/lyrics were written by the Moises-Lyric-Exporter's own TTML +// code, run on an invented lyrics.json; the third is written by hand +// as AMLL TTML Tool writes its files. + +#include "../Lyrics.h" +#include "../LyricsTtml.h" + +#include +#include +#include +#include + +#include + +class TestLyricsTtml : public QObject +{ + Q_OBJECT + + // The inferred lengths, as the parser has them + static constexpr double W = Lyrics::inferredWordSeconds; + static constexpr double L = Lyrics::inferredLastLineSeconds; + + // A whole file around the given

content, and head metadata + static QByteArray ttml(const char *div, const char *metadata = "") { + return QByteArray("\n" + "" + "") + metadata + + "
" + div + "
\n"; + } + + static LyricsParseResult parse(const char *div) { + return parseTtml(ttml(div)); + } + + static QByteArray fixture(const char *name) { + QFile file(QString(TONY_TEST_DATA_DIR) + "/lyrics/" + name); + if (!file.open(QIODevice::ReadOnly)) return QByteArray(); + return file.readAll(); + } + + static int wordCount(const LyricsParseResult &r) { + return int(r.lyrics.words.size()); + } + + struct Expected { + double start; + double end; + const char *text; + int line; + bool endGiven; + }; + + static QString describe(const LyricWord &w) { + return QString("\"%1\" %2-%3 line %4%5") + .arg(w.text).arg(w.start, 0, 'f', 6).arg(w.end, 0, 'f', 6) + .arg(w.line).arg(w.endGiven ? ", end given" : ""); + } + + static QString describe(const Lyrics &lyrics) { + QStringList list; + for (const LyricWord &w : lyrics.words) list << describe(w); + return list.join("; "); + } + + // Times of whole milliseconds are read as the nearest double to + // the decimal: this only absorbs the last bit of one + static bool sameTime(double a, double b) { + return std::abs(a - b) < 1e-9; + } + + // All the words, in order. A failure names the word that differs. + static void compareWords(const Lyrics &lyrics, + const QVector &expected) { + QVERIFY2(lyrics.words.size() == expected.size(), + qPrintable(QString("%1 words, expected %2: %3") + .arg(lyrics.words.size()).arg(expected.size()) + .arg(describe(lyrics)))); + for (int i = 0; i < expected.size(); ++i) { + const LyricWord &w = lyrics.words[i]; + LyricWord want; + want.start = expected[i].start; + want.end = expected[i].end; + want.text = QString::fromUtf8(expected[i].text); + want.line = expected[i].line; + want.endGiven = expected[i].endGiven; + bool same = (w.text == want.text && + sameTime(w.start, want.start) && + sameTime(w.end, want.end) && + w.line == want.line && + w.endGiven == want.endGiven); + QVERIFY2(same, qPrintable(QString("word %1 is %2, expected %3") + .arg(i).arg(describe(w)) + .arg(describe(want)))); + } + } + + // One warning, starting with the count it should give + static void verifyOneWarning(const LyricsParseResult &r, + const QString &count) { + QVERIFY2(r.warnings.size() == 1 && r.warnings[0].startsWith(count), + qPrintable(QString("warnings: [%1], expected one " + "starting \"%2\"") + .arg(r.warnings.join(" | ")).arg(count))); + } + + // Back from TTML, the same words, texts and lines, each time + // within half a millisecond + static void verifyRoundTrip(const Lyrics &lyrics, const char *what) { + const QByteArray written = writeTtml(lyrics); + LyricsParseResult r = parseTtml(written); + QVERIFY2(r.error.isEmpty(), + qPrintable(QString("%1: %2").arg(what).arg(r.error))); + QVERIFY2(r.warnings.isEmpty(), + qPrintable(QString("%1: %2").arg(what) + .arg(r.warnings.join(" | ")))); + QCOMPARE(r.lyrics.title, lyrics.title); + QVERIFY(r.lyrics.wordTimed); + QVERIFY2(r.lyrics.words.size() == lyrics.words.size(), + qPrintable(QString("%1: %2 words back, expected %3: %4") + .arg(what).arg(r.lyrics.words.size()) + .arg(lyrics.words.size()) + .arg(describe(r.lyrics)))); + const double halfMs = 0.0005 + 1e-9; + for (int i = 0; i < lyrics.words.size(); ++i) { + const LyricWord &w = r.lyrics.words[i]; + const LyricWord &o = lyrics.words[i]; + bool same = (w.text == o.text && w.line == o.line && + std::abs(w.start - o.start) <= halfMs && + std::abs(w.end - o.end) <= halfMs); + QVERIFY2(same, qPrintable(QString("%1: word %2 came back as " + "%3, was %4") + .arg(what).arg(i).arg(describe(w)) + .arg(describe(o)))); + } + // and writing it again gives the same file + QCOMPARE(writeTtml(r.lyrics), written); + } + +private slots: + // Clock times with and without hours, minutes past 59, plain + // seconds and offset times, all read as absolute + void times_in_every_accepted_form() { + struct Case { + const char *time; + double seconds; + } cases[] = { + { "0:01.000", 1.0 }, // the exporter + { "1:02.500", 62.5 }, + { "01:02.500", 62.5 }, // AMLL TTML Tool + { "1:02.5", 62.5 }, + { "1:02.50", 62.5 }, + { "1:02", 62.0 }, + { "1:2.25", 62.25 }, + { "75:00.000", 4500.0 }, // minutes past 59, no hours + { "1:01:02.5", 3662.5 }, + { "01:01:02.250", 3662.25 }, + { "0:00:00.001", 0.001 }, + { "62.5", 62.5 }, // plain seconds + { "62", 62.0 }, + { "4500.25", 4500.25 }, + { "0", 0.0 }, + { "62.5s", 62.5 }, // offset times + { "62500ms", 62.5 }, + { "250.5ms", 0.2505 }, + { "1.5m", 90.0 }, + { "2m", 120.0 }, + { "0.5h", 1800.0 }, + { " 1:02.500 ", 62.5 }, + { "1:02.1234567", 62.123456 }, // microseconds are plenty + { "24:00:00", 86400.0 }, // a day is the most + }; + for (const Case &c : cases) { + QByteArray div = QByteArray("

sana

"; + LyricsParseResult r = parseTtml(ttml(div.constData())); + QVERIFY2(r.error.isEmpty() && r.warnings.isEmpty(), + qPrintable(QString("\"%1\": %2 %3").arg(c.time) + .arg(r.error).arg(r.warnings.join(" ")))); + QCOMPARE(wordCount(r), 1); + QVERIFY2(sameTime(r.lyrics.words[0].start, c.seconds) && + sameTime(r.lyrics.words[0].end, c.seconds), + qPrintable(QString("\"%1\" read as %2") + .arg(c.time) + .arg(describe(r.lyrics.words[0])))); + } + } + + // Frames, ticks and anything else that is no time: the word is + // left out, with a warning, and the rest of the line is read + void refused_times() { + const char *times[] = { + "00:00:01:12", // hh:mm:ss:ff + "25f", "100t", // frames and ticks + "1:60.000", "1:60:00", "1:02:60", + "-1.0", "", " ", "abc", "1.2.3", "1:02.", ".5", "1e3", + "12 s", "1:02.5s", "1::02", ":02", "1:", + "24:00:00.001", "25:00:00", "1441m", + "99999999999999999999", + }; + for (const char *time : times) { + QByteArray div = QByteArray("

ei " + "kyllä

"; + LyricsParseResult r = parseTtml(ttml(div.constData())); + QVERIFY2(r.error.isEmpty(), time); + compareWords(r.lyrics, { { 1.0, 2.0, "kyllä", 0, true } }); + verifyOneWarning(r, "1 word had a start time"); + if (QTest::currentTestFailed()) { + qWarning() << "time:" << time; + return; + } + + // A line-timed line likewise + div = QByteArray("

" + "ei

kyllä

"; + r = parseTtml(ttml(div.constData())); + QVERIFY2(r.error.isEmpty(), time); + compareWords(r.lyrics, { { 1.0, 2.0, "kyllä", 0, true } }); + verifyOneWarning(r, "1 word had a start time"); + if (QTest::currentTestFailed()) { + qWarning() << "line time:" << time; + return; + } + } + } + + // A word ends at its end, or else at its begin + dur; an end that + // cannot be read is ignored, with one warning for all of them + void end_or_dur() { + LyricsParseResult r = parse + ("

a " + "b " + "c " + "d " + "e " + "f

"); + QCOMPARE(r.error, QString()); + compareWords(r.lyrics, { + { 1.0, 1.5, "a", 0, true }, + { 2.0, 2.25, "b", 0, true }, + { 3.0, 3.5, "c", 0, true }, + { 4.0, 4.25, "d", 0, true }, + { 5.0, 5.5, "e", 0, true }, + { 6.0, 6.0 + W, "f", 0, false }, + }); + verifyOneWarning(r, "2 "); + } + + // Timed spans with no white space between them are one word: + // first start, last end, the texts together + void syllables_joined() { + LyricsParseResult r = parse + ("

" + "kek" + "sit" + "ty " + "laulu

"); + QCOMPARE(r.error, QString()); + QCOMPARE(r.warnings, QStringList()); + QVERIFY(r.lyrics.wordTimed); + compareWords(r.lyrics, { + { 1.0, 2.0, "keksitty", 0, true }, + { 2.1, 3.0, "laulu", 0, true }, + }); + if (QTest::currentTestFailed()) return; + + // The end is the last syllable's: if it has none, the word's + // end is inferred + r = parse("

la" + "lu " + "x

"); + QCOMPARE(r.warnings, QStringList()); + compareWords(r.lyrics, { + { 1.0, 2.0, "lalu", 0, false }, + { 2.0, 2.5, "x", 0, true }, + }); + } + + // White space separates words wherever it is: line breaks and + // indentation of a laid-out file, CRLF, tabs,
, and blanks + // inside a span at either end of its text + void whitespace_separates_words() { + LyricsParseResult r = parse + ("\n

\r\n" + " yksi\r\n" + " kaksi
" + "kolme " + "neljä" + " viisi " + "kuusi\n" + "seitsemän\n" + "

\n"); + QCOMPARE(r.error, QString()); + QCOMPARE(r.warnings, QStringList()); + compareWords(r.lyrics, { + { 1.0, 1.5, "yksi", 0, true }, + { 1.5, 2.0, "kaksi", 0, true }, + { 2.0, 2.5, "kolme", 0, true }, + { 2.5, 3.0, "neljä", 0, true }, + { 3.0, 3.5, "viisi", 0, true }, + { 3.5, 4.0, "kuusi", 0, true }, + { 4.0, 4.5, "seitsemän", 0, true }, + }); + } + + // Text straight after a word is part of it; other text in a line + // of timed words is left out, with a warning + void punctuation_appended() { + LyricsParseResult r = parse + ("

sana, " + "toinen! " + "laulu" + ", " + "loppu...

"); + QCOMPARE(r.error, QString()); + QCOMPARE(r.warnings, QStringList()); + compareWords(r.lyrics, { + { 1.0, 1.5, "sana,", 0, true }, + { 2.0, 2.5, "toinen!", 0, true }, + { 3.0, 3.6, "laulu,", 0, true }, + { 4.0, 4.5, "loppu...", 0, true }, + }); + if (QTest::currentTestFailed()) return; + + r = parse("

\"lainaus\" ja " + "muuta " + "(sivuhuomio)

"); + QCOMPARE(r.error, QString()); + compareWords(r.lyrics, { + { 1.0, 1.5, "lainaus\"", 0, true }, + { 2.0, 2.5, "muuta", 0, true }, + }); + verifyOneWarning(r, "3 "); + } + + // A span with no begin is read through, as if it were not there + void wrappers_read_through() { + LyricsParseResult r = parse + ("

yksi " + "kak" + "si " + "" + "kolme " + "neljä!

"); + QCOMPARE(r.error, QString()); + QCOMPARE(r.warnings, QStringList()); + compareWords(r.lyrics, { + { 1.0, 1.5, "yksi", 0, true }, + { 1.5, 2.5, "kaksi", 0, true }, + { 3.0, 3.5, "kolme", 0, true }, + { 4.0, 4.5, "neljä!", 0, true }, + }); + } + + // Background vocals, translations and romanisations are left out + // with everything in them, whatever namespace their role is in, + // with one warning that counts them. One between two words does + // not join them. + void skipped_roles_warned_once() { + LyricsParseResult r = parse + ("

yksi" + "(tausta " + "ääni)" + "kaksi" + "one two " + "kolme" + "kolme" + "k " + "tausta " + "ei " + "laulaja" + "

"); + QCOMPARE(r.error, QString()); + compareWords(r.lyrics, { + { 1.0, 1.5, "yksi", 0, true }, + { 2.5, 3.0, "kaksi", 0, true }, + { 3.0, 3.5, "kolme", 0, true }, + { 5.0, 5.5, "laulaja", 0, true }, + }); + verifyOneWarning(r, "6 "); + } + + // A

with no timed spans is one word, the whole line, white + // space made single spaces; with no begin it is left out + void line_timed_paragraphs() { + LyricsParseResult r = parse + ("

Koko rivi\n" + " yhdellä
leimalla

\n" + "

Rivi " + "käärittynä

\n" + "

Ei aikaa

\n" + "

\n" + "

Tekstiä" + "Text

\n"); + QCOMPARE(r.error, QString()); + QVERIFY(!r.lyrics.wordTimed); + QCOMPARE(r.lyrics.lineCount(), 3); + compareWords(r.lyrics, { + { 1.0, 3.0, "Koko rivi yhdellä leimalla", 0, true }, + { 4.0, 5.0, "Rivi käärittynä", 1, true }, + { 8.0, 9.0, "Tekstiä", 2, true }, + }); + QCOMPARE(r.warnings.size(), qsizetype(2)); + QVERIFY2(r.warnings[0].startsWith("1 line had no time"), + qPrintable(r.warnings.join(" | "))); + } + + // A missing end is the next word's start in the line, else the + // line's end, else inferred as the LRC parser infers it. A line of + // timed words needs no begin of its own, so one it cannot read + // takes nothing away. + void missing_ends_inferred() { + LyricsParseResult r = parse + ("

a b

" + "

c " + "d

" + "

e

" + "

rivi

" + "

kesto

" + "

viimeinen rivi

"); + QCOMPARE(r.error, QString()); + QCOMPARE(r.warnings, QStringList()); + QVERIFY(r.lyrics.wordTimed); + compareWords(r.lyrics, { + { 1.0, 1.5, "a", 0, false }, + { 1.5, 2.5, "b", 0, false }, // up to the next line + { 2.5, 3.0, "c", 1, false }, + { 3.0, 4.0, "d", 1, true }, // the line's end + { 10.0, 10.0 + W, "e", 2, false }, + { 20.0, 30.0, "rivi", 3, false }, + { 30.0, 31.5, "kesto", 4, true }, + { 40.0, 40.0 + L, "viimeinen rivi", 5, false }, + }); + if (QTest::currentTestFailed()) return; + + r = parse("

a

"); + compareWords(r.lyrics, { { 1.0, 1.0 + W, "a", 0, false } }); + } + + // An end before its start is made the start, with a warning + void end_before_start_becomes_start() { + LyricsParseResult r = parse + ("

a " + "b

" + "

rivi

"); + QCOMPARE(r.error, QString()); + compareWords(r.lyrics, { + { 2.0, 2.0, "a", 0, true }, + { 3.0, 3.5, "b", 0, true }, + { 5.0, 5.0, "rivi", 1, true }, + }); + verifyOneWarning(r, "2 "); + } + + // Entities are decoded, then the text is cleaned as the LRC + // parser cleans it; blanks inside a word stay, as in LRC + void text_cleaned() { + QByteArray div = + "

rock & roll " + "<3 " + "äitiä " + ""'> " + " " + "ab\x7F" "c " + "x y z w " + "kaksi sanaa " + "" + QByteArray(1000, 'x') + + "

"; + LyricsParseResult r = parseTtml(ttml(div.constData())); + QCOMPARE(r.error, QString()); + QCOMPARE(r.warnings, QStringList()); + compareWords(r.lyrics, { + { 1.0, 1.5, "rock & roll", 0, true }, + { 2.0, 2.5, "<3", 0, true }, + { 3.0, 3.5, "äitiä", 0, true }, + { 4.0, 4.5, "\"'>", 0, true }, + { 5.0, 5.5, "a" + text + + "

").toUtf8().constData())); + QCOMPARE(wordCount(r), 1); + QCOMPARE(r.lyrics.words[0].text, + QString(Lyrics::maxLabelLength - 1, 'z')); + } + + // in the head's metadata is the title: the first one, + // and none from a

's metadata, which is not lyrics either + void title_from_head_metadata() { + LyricsParseResult r = parseTtml + (ttml("

Ei" + "sana

", + "" + "Kesäyön &\n testi" + "Toinen")); + QCOMPARE(r.error, QString()); + QCOMPARE(r.warnings, QStringList()); + QCOMPARE(r.lyrics.title, QString::fromUtf8("Kesäyön & testi")); + QCOMPARE(r.lyrics.artist, QString()); + compareWords(r.lyrics, { { 1.0, 2.0, "sana", 0, true } }); + + r = parse("

sana

"); + QCOMPARE(r.lyrics.title, QString()); + } + + // Nothing usable is an error, and gives no lyrics + void no_words_is_an_error() { + const QByteArray files[] = { + ttml(""), + ttml("

ilman aikaa

", "Nimi"), + ttml("

"), + ttml("

" + "tausta

"), + ttml("

sana

"), + QByteArray("
Hei
"), + QByteArray(""), + QByteArray(), + }; + for (const QByteArray &file : files) { + LyricsParseResult r = parseTtml(file); + QVERIFY2(!r.error.isEmpty(), file.constData()); + QVERIFY(r.lyrics.isEmpty()); + QCOMPARE(r.lyrics.title, QString()); + } + } + + // Malformed XML is an error that names the line, even when words + // were read before it + void malformed_xml_names_the_line() { + LyricsParseResult r = parseTtml + ("\n\n
\n" + "

a

\n" + "
\n\n
\n"); + QVERIFY(r.lyrics.isEmpty()); + QVERIFY2(r.error.contains("line 4"), qPrintable(r.error)); + + QByteArray whole = fixture("moises-exporter-words.ttml"); + QVERIFY(!whole.isEmpty()); + r = parseTtml(whole.left(whole.indexOf("itunes:key=\"L3\""))); + QVERIFY(r.lyrics.isEmpty()); + QVERIFY2(r.error.contains("line 12"), qPrintable(r.error)); + + // An entity XML itself does not have + r = parse("

a b

"); + QVERIFY(r.lyrics.isEmpty()); + QVERIFY2(r.error.contains("line 2"), qPrintable(r.error)); + + // Bytes that are not the UTF-8 it says it is. The reader + // decodes ahead of where it reads, so the line it names is not + // the one they are on. + r = parseTtml("\n" + "

" + "H\xE4m\xE4r\xE4

"); + QVERIFY(r.lyrics.isEmpty()); + QVERIFY2(r.error.contains("line "), qPrintable(r.error)); + } + + // A file with a DTD is refused before anything in it is read: it + // is how entity tricks get in, and TTML has none + void dtd_refused() { + const char *files[] = { + "\n" + "\n" + " ]>\n" + "

&b;

" + "
\n", + + "\n" + "

sana

" + "
\n", + + "\n" + "

sana

" + "
\n", + }; + for (const char *file : files) { + LyricsParseResult r = parseTtml(file); + QVERIFY2(r.error.contains("DOCTYPE"), qPrintable(r.error)); + QVERIFY2(r.lyrics.isEmpty(), qPrintable(describe(r.lyrics))); + } + } + + // Exactly the limit is read; one byte more is not + void too_big_refused() { + QByteArray big = ttml("

sana

"); + big += QByteArray(Lyrics::maxFileBytes - big.size(), '\n'); + LyricsParseResult r = parseTtml(big); + QCOMPARE(r.error, QString()); + QCOMPARE(wordCount(r), 1); + + big += '\n'; + r = parseTtml(big); + QVERIFY(!r.error.isEmpty()); + QVERIFY(r.lyrics.isEmpty()); + QCOMPARE(parseLyrics(big).error, r.error); + } + + // Lines in time order by their first start, those that start + // together in file order; the words of a line in time order + void lines_sorted_by_first_start() { + LyricsParseResult r = parse + ("

kolmas

" + "

toinen " + "ensin

" + "

eka

" + "

toka

"); + QCOMPARE(r.error, QString()); + QCOMPARE(r.warnings, QStringList()); + compareWords(r.lyrics, { + { 0.0, 1.0, "eka", 0, true }, + { 0.0, 0.5, "toka", 1, true }, + { 5.0, 5.5, "ensin", 2, true }, + { 5.5, 6.0, "toinen", 2, true }, + { 20.0, 21.0, "kolmas", 3, true }, + }); + } + + // Files without the TTML namespace, with a prefix for it, and + // with the time attributes in another namespace + void names_matched_by_local_name() { + const char *files[] = { + "

sana" + "

", + + "" + "sana" + "", + + "" + "

sana" + "

", + }; + for (const char *file : files) { + LyricsParseResult r = parseTtml(file); + QVERIFY2(r.error.isEmpty(), qPrintable(r.error)); + QCOMPARE(r.warnings, QStringList()); + compareWords(r.lyrics, { { 1.0, 2.0, "sana", 0, true } }); + if (QTest::currentTestFailed()) return; + } + } + + // What the exporter's own TTML code writes in word mode, from an + // invented lyrics.json whose words were, in seconds: + // Tämä 0.52-0.8, on 0.84-1.02, keksitty 1.1-1.9 (syllables kek + // 1.1-1.35, sit 1.35-1.62, ty 1.62-1.9), laulu 2.0-2.7, "," + // 2.7-2.75 | Yö 4.64-4.9, on 4.92-5.1, "hämärä," 5.3-6.2, kuu + // 6.25-6.8, "nousee." 6.8-7.5 | Nyt 61.25-61.6, se 61.7-61.9, + // "loppuu!" 62.0-63.5 + // The exporter glues a word that starts with punctuation to the + // one before it, so the comma joins "laulu" as a syllable would. + void exporter_word_fixture() { + QByteArray bytes = fixture("moises-exporter-words.ttml"); + QVERIFY(!bytes.isEmpty()); + LyricsParseResult r = parseTtml(bytes); + QCOMPARE(r.error, QString()); + QCOMPARE(r.warnings, QStringList()); + QCOMPARE(r.lyrics.title, QString()); + QVERIFY(r.lyrics.wordTimed); + QCOMPARE(r.lyrics.lineCount(), 3); + compareWords(r.lyrics, { + { 0.52, 0.8, "Tämä", 0, true }, + { 0.84, 1.02, "on", 0, true }, + { 1.1, 1.9, "keksitty", 0, true }, + { 2.0, 2.75, "laulu,", 0, true }, + { 4.64, 4.9, "Yö", 1, true }, + { 4.92, 5.1, "on", 1, true }, + { 5.3, 6.2, "hämärä,", 1, true }, + { 6.25, 6.8, "kuu", 1, true }, + { 6.8, 7.5, "nousee.", 1, true }, + { 61.25, 61.6, "Nyt", 2, true }, + { 61.7, 61.9, "se", 2, true }, + { 62.0, 63.5, "loppuu!", 2, true }, + }); + } + + // The same in line mode: each line one word, from its first + // word's start to its last word's end + void exporter_line_fixture() { + QByteArray bytes = fixture("moises-exporter-lines.ttml"); + QVERIFY(!bytes.isEmpty()); + LyricsParseResult r = parseTtml(bytes); + QCOMPARE(r.error, QString()); + QCOMPARE(r.warnings, QStringList()); + QVERIFY(!r.lyrics.wordTimed); + QCOMPARE(r.lyrics.lineCount(), 3); + compareWords(r.lyrics, { + { 0.52, 2.75, "Tämä on keksitty laulu,", 0, true }, + { 4.64, 7.5, "Yö on hämärä, kuu nousee.", 1, true }, + { 61.25, 63.5, "Nyt se loppuu!", 2, true }, + }); + } + + // As AMLL TTML Tool writes it: mm:ss.mmm, two agents, syllables, + // a background vocal and translations, which are skipped + void amll_fixture() { + QByteArray bytes = fixture("amll-style.ttml"); + QVERIFY(!bytes.isEmpty()); + LyricsParseResult r = parseTtml(bytes); + QCOMPARE(r.error, QString()); + verifyOneWarning(r, "3 "); + QCOMPARE(r.lyrics.title, QString()); + QVERIFY(r.lyrics.wordTimed); + QCOMPARE(r.lyrics.lineCount(), 3); + compareWords(r.lyrics, { + { 3.2, 3.55, "Pöllö", 0, true }, + { 3.6, 4.05, "huhuilee", 0, true }, + { 4.3, 4.6, "ja", 0, true }, + { 4.6, 5.1, "kuu", 0, true }, + { 7.0, 7.65, "nousee", 1, true }, + { 7.8, 8.5, "metsän", 1, true }, + { 8.6, 9.4, "ylle", 1, true }, + { 65.0, 65.8, "Hiljaa", 2, true }, + { 65.9, 69.5, "hiljaa", 2, true }, + }); + } + + // TTML if the first character that is not blank, after a BOM, is + // '<'; LRC otherwise + void parse_lyrics_chooses_the_format() { + const QByteArray bom("\xEF\xBB\xBF"); + const QByteArray lrc = fixture("moises-exporter-words.lrc"); + const QByteArray tt = fixture("moises-exporter-words.ttml"); + QVERIFY(!lrc.isEmpty() && !tt.isEmpty()); + + const Lyrics fromLrc = parseLrc(lrc).lyrics; + const Lyrics fromTtml = parseTtml(tt).lyrics; + QVERIFY(!fromLrc.isEmpty() && !fromTtml.isEmpty()); + + auto same = [](const Lyrics &a, const Lyrics &b) { + if (a.words.size() != b.words.size()) return false; + for (int i = 0; i < a.words.size(); ++i) { + if (a.words[i].text != b.words[i].text || + a.words[i].start != b.words[i].start || + a.words[i].end != b.words[i].end) return false; + } + return true; + }; + + QVERIFY(same(parseLyrics(lrc).lyrics, fromLrc)); + QVERIFY(same(parseLyrics(bom + lrc).lyrics, fromLrc)); + QVERIFY(same(parseLyrics(" \r\n\t" + lrc).lyrics, fromLrc)); + QVERIFY(same(parseLyrics(tt).lyrics, fromTtml)); + QVERIFY(same(parseLyrics(bom + tt).lyrics, fromTtml)); + + // Blanks before the root are XML too, when there is no + // declaration, which has to come first + QByteArray noDeclaration = tt.mid(tt.indexOf("").error, parseTtml("").error); + QCOMPARE(parseLyrics("sana").error, parseLrc("sana").error); + QVERIFY(parseTtml("").error != parseLrc("sana").error); + QVERIFY(!parseLyrics(QByteArray()).error.isEmpty()); + } + + // The writer's layout, as the exporter's: one agent, a

per + // line and a per word with a space between, m:ss.mmm + void write_format() { + Lyrics lyrics; + lyrics.title = "Testi & laulu"; + lyrics.artist = "Ei kirjoiteta"; + auto word = [](double start, double end, const char *text, int line) { + LyricWord w; + w.start = start; + w.end = end; + w.text = QString::fromUtf8(text); + w.line = line; + return w; + }; + lyrics.words = { + word(0.5, 1.0, "Yö", 0), + word(1.0, 1.25, "on", 0), + word(65.4321, 66.0, "", 1), + word(66.0, 70.0006, "\"kaksi sanaa\"", 1), + }; + const QByteArray expected = + "\n" + "\n" + " \n" + " \n" + " Testi & laulu\n" + " \n" + " \n" + " \n" + " \n" + "

\n" + "

" + "Yö on" + "

\n" + "

" + "<kaunis> "kaksi sanaa"

\n" + "
\n" + " \n" + "\n"; + const QByteArray written = writeTtml(lyrics); + QVERIFY2(written == expected, + qPrintable(QString("written:\n%1\nexpected:\n%2") + .arg(QString::fromUtf8(written)) + .arg(QString::fromUtf8(expected)))); + } + + // What is written reads back as the same words, texts and lines, + // to half a millisecond: from LRC, from TTML, and awkward cases + void round_trip() { + const char *lrcFixtures[] = { + "moises-exporter-words.lrc", "moises-exporter-lines.lrc", + "lrc-with-ends.lrc", + }; + for (const char *name : lrcFixtures) { + LyricsParseResult r = parseLrc(fixture(name)); + QVERIFY2(r.error.isEmpty(), name); + verifyRoundTrip(r.lyrics, name); + if (QTest::currentTestFailed()) return; + } + const char *ttmlFixtures[] = { + "moises-exporter-words.ttml", "moises-exporter-lines.ttml", + "amll-style.ttml", + }; + for (const char *name : ttmlFixtures) { + LyricsParseResult r = parseTtml(fixture(name)); + QVERIFY2(r.error.isEmpty(), name); + verifyRoundTrip(r.lyrics, name); + if (QTest::currentTestFailed()) return; + } + + // Markup characters, blanks inside a word, a character that + // needs two, punctuation as a word of its own, words of no + // length and at the same time, times off the millisecond and + // past an hour + Lyrics lyrics; + lyrics.title = QString::fromUtf8("Hämärä & \"päivä\""); + auto word = [](double start, double end, const QString &text, + int line) { + LyricWord w; + w.start = start; + w.end = end; + w.text = text; + w.line = line; + return w; + }; + const char32_t clef[] = { 0x1D11E }; + lyrics.words = { + word(0.0, 0.0, "a&b", 0), + word(0.0, 1.2344, "<3", 0), + word(1.2346, 2.0, "koko rivi \"lainaus\" 'x'", 0), + word(2.0, 2.0, ",", 0), + word(2.5, 3.0, QString::fromUtf8("äiti ") + + QString::fromUcs4(clef, 1), 1), + word(2.9, 3.5, "]]>", 1), + word(3725.4996, 3726.0004, "tunti", 2), + word(3726.0004, 3726.0004, "yli", 2), + }; + verifyRoundTrip(lyrics, "awkward"); + } +}; + +#endif diff --git a/main/test/TestMainWindow.h b/main/test/TestMainWindow.h new file mode 100644 index 00000000..44a23014 --- /dev/null +++ b/main/test/TestMainWindow.h @@ -0,0 +1,511 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TEST_MAIN_WINDOW_H +#define TEST_MAIN_WINDOW_H + +// The real MainWindow for the suites that drive it: TestRecordWorkflow, +// TestUiChecks, TestAudioCheck and TestDevChecks + +#include "FakeAudioIO.h" + +#include "../MainWindow.h" +#include "../Analyser.h" +#include "../AudioDriverMenus.h" +#include "../CoverageStrip.h" +#include "../SingingTakes.h" + +#ifdef TONY_DEV_CHECKS +#include "../dev/DevChecks.h" +#endif + +#include "view/ViewManager.h" +#include "audio/AudioCallbackPlaySource.h" +#include "audio/AudioCallbackRecordTarget.h" + +#include +#include +#include +#include +#include +#include +#include + +#include + +/** + * MainWindow with the fake device in place of a real one, and the + * protected state of the singing workflow opened up for inspection. + */ +class TestMainWindow : public MainWindow +{ +public: + TestMainWindow(FakeAudioIO::Config config, bool installDevice = true) : + MainWindow(AUDIO_PLAYBACK_AND_RECORD, true, false), + m_fakeConfig(config), + m_installDevice(installDevice) { } + + FakeAudioIO *fake() { return dynamic_cast(m_audioIO); } + + // The route the fake reports from the next time it is opened, and the + // device opened again, as a phone's is when its route changes + void setFakeRoute(const AudioRoute::Route &route) { + m_fakeConfig.route = route; + } + void doRecreateAudioIO() { recreateAudioIO(); } + + void doRecord() { record(); } + void doPlay() { play(); } // and again to stop + void doAnalyseNow() { analyseNow(); } + void doLoadBackgroundMusic(QString path) { loadBackgroundMusic(path); } + + // Another audio file under the take's pitch and notes layers + QString doSwapSingingAudio(QString path) { + return swapSingingAudio(path); + } + + // Editing the singing of a take, as the two Edit menu actions do + void doEraseSingingInSelection() { eraseSingingInSelection(); } + void doSelectRecordingAtPlayhead() { selectRecordingAtPlayhead(); } + QAction *eraseSingingAction() { return m_eraseSingingAction; } + QAction *selectRecordingAction() { return m_selectRecordingAction; } + void doUpdateMenuStates() { updateMenuStates(); } + + // The takes of the session, as the Takes menu and the combo box do + bool doSwitchToTake(int index) { return switchToTake(index); } + void doChooseTakeInCombo(int index) { m_takeCombo->setCurrentIndex(index); } + void doNewEmptyTake() { newEmptyTake(); } + void doDuplicateTake() { duplicateTake(); } + void doRenameTake() { renameTake(); } + void doDeleteTake() { deleteTake(); } + bool doDeleteTakeAt(int index) { return deleteTakeAt(index); } + QComboBox *takeCombo() { return m_takeCombo; } + QAction *newTakeAction() { return m_newTakeAction; } + QAction *duplicateTakeAction() { return m_duplicateTakeAction; } + QAction *renameTakeAction() { return m_renameTakeAction; } + QAction *deleteTakeAction() { return m_deleteTakeAction; } + QMenu *takesMenu() { return m_takesMenu; } + + // The two questions the take operations ask, answered from here: the + // suite cannot answer a dialog + void setDeleteTakeAnswer(bool yes) { m_deleteTakeAnswer = yes; } + int deleteTakeQuestions() const { return m_deleteTakeQuestions; } + void setTakeNameAnswer(QString name) { m_takeNameAnswer = name; } + + // True between the start of the analysis of a recorded range and the + // merge of its result into the take's pitch and notes + bool analysingRange() { + return m_analyser2 && m_analyser2->isAnalysingRange(); + } + sv::sv_frame_t analysedRangeStart() { return m_takeAnalysisRange.start; } + sv::sv_frame_t analysedRangeEnd() { return m_takeAnalysisRange.end; } + + // Hold the merge of the analysis of each recorded range until let go, + // in the take's analyser and in every one made after it, so that a + // test can act while a range is being analysed: pYIN may analyse a + // short one before Stop returns (Analyser::setRangedMergeHeld()) + void holdRangedMerges(bool hold) { + m_holdRangedMerges = hold; + if (m_analyser2) m_analyser2->setRangedMergeHeld(hold); + } + + // Save As, with the file name given here instead of by a dialog: the + // session's own file is set, so that what is recorded next goes into + // its takes folder + bool doSaveSessionAs(QString path) { return saveSessionToPath(path); } + QString sessionFile() { return m_sessionFile; } + + // Save and Save As, as the File menu does them. Save As is given the + // file here, not by a dialog, if the test has named one + void doSaveSession() { saveSession(); } + void doSaveSessionAsAsked() { saveSessionAs(); } + void setSaveFileNameAnswer(QString path) { m_saveFileNameAnswer = path; } + int saveFileNameQuestions() const { return m_saveFileNameQuestions; } + + // A session that loaded without some of its audio, and the question + // asked before it is saved, answered from here + bool isSessionIncomplete() const { return sessionIsIncomplete(); } + bool doMaySaveUnasked() const { return maySaveUnasked(); } + void setSaveIncompleteAnswer(bool yes) { m_saveIncompleteAnswer = yes; } + int saveIncompleteQuestions() const { return m_saveIncompleteQuestions; } + + // The session's own file, as svapp's session reader would set it + void setSessionFile(QString path) { m_sessionFile = path; } + + // As answering "No" to "do you want to save?" + void discardModifications() { m_documentModified = false; } + bool isDocumentModified() { return m_documentModified; } + + // As any edit does + void markModified() { documentModified(); } + void doCloseSession() { discardModifications(); closeSession(); } + + void setPlayReferenceWhileRecording(bool on) { + m_playRefWhileRecording->setChecked(on); + } + void setPreRoll(bool on) { m_preRoll->setChecked(on); } + void setRecordIntoSelection(bool on) { + m_recordIntoSelection->setChecked(on); + } + QAction *playReferenceWhileRecordingAction() { + return m_playRefWhileRecording; + } + QAction *preRollAction() { return m_preRoll; } + QAction *recordIntoSelectionAction() { return m_recordIntoSelection; } + + // The audio check, the override it sets for its own takes, and what + // the last take was placed with + AudioCheckRunner *audioCheck() { return m_audioCheck; } + bool audioCheckTakes() { return m_audioCheckTakes; } + + // The Record button, as the user presses it + QAction *recordAction() { return m_recordAction; } + +#ifdef TONY_DEV_CHECKS + // The development checks; deleted as the window's destructor deletes + // them, with the window left, and then as a release build has it + DevChecks *devChecks() { return m_devChecks; } + void doDeleteDevChecks() { + delete m_devChecks; + m_devChecks = nullptr; + } +#endif + + // Playback > Calibrate Audio, the dialog it shows once it has been + // chosen, the lines under it, and the device menus above it + QAction *calibrateAudioAction() { return m_calibrateAudioAction; } + CalibrateAudioDialog *calibrateAudioDialog() { + return m_calibrateAudioDialog; + } + QAction *latencyLineAction() { return m_latencyLineAction; } + QAction *forgetLatencyAction() { return m_forgetLatencyAction; } + QMenu *playbackMenu() { return m_playbackMenu; } + QMenu *audioOutputMenu() { return m_audioDeviceMenu; } + QMenu *audioInputMenu() { return m_audioInputDeviceMenu; } + + // Playback > Audio Driver and Audio Latency, from the implementations + // given here: none unless a test gives some, whatever the platform + // has. Rebuilt as the app rebuilds them when they open + void setAudioImplementations(QStringList names) { + m_implementations = names; + } + AudioDriverMenus *audioDriverMenus() { return m_audioDriverMenus; } + void doRebuildAudioDriverMenus() { m_audioDriverMenus->rebuild(); } + void doRescanAudioDevices() { rescanAudioDevices(); } + + // Whether Stop, and the end of a take, leave the device running, as + // the application has them do. Not unless a test asks: + // the fake starts its programmed input again at every resume, and + // many tests rely on each take resuming it once + void keepAudioRunning(bool on) { m_keepAudioRunning = on; } + // What the application itself chooses, whatever this window does + bool applicationSuspendsAudioOnStop() const { + return MainWindow::suspendAudioOnStop(); + } + + // How long the device kept running may idle before it is suspended, + // in ms: never, unless a test asks, whatever the platform has + void setAudioIdleSuspendMillis(int ms) { m_audioIdleSuspendMillis = ms; } + int applicationAudioIdleSuspendMillis() const { + return MainWindow::audioIdleSuspendMillis(); + } + + // How often a device has been opened, and the driver and devices the + // Preferences named for the last one + int audioIOOpened() const { return m_audioIOOpened; } + LatencyCalibration::Key audioIOOpenedFor() const { + return m_audioIOOpenedFor; + } + TakeLatency takeLatency() { return m_takeLatency; } + QAction *playSingingAudioAction() { return m_playSingingAudio; } + + Analyser *analyser() { return m_analyser; } + Analyser *analyser2() { return m_analyser2; } + sv::Document *document() { return m_document; } + sv::PaneStack *paneStack() { return m_paneStack; } + sv::Layer *timeRuler() { return m_timeRulerLayer; } + sv::AudioCallbackRecordTarget *recordTarget() { return m_recordTarget; } + sv::AudioCallbackPlaySource *playSource() { return m_playSource; } + sv::ModelId mainModelId() { return getMainModelId(); } + + RealtimePitchTracker *realtimeTracker() { return m_realtimePitchTracker; } + sv::TimeValueLayer *realtimeLayer() { return m_realtimePitchLayer; } + sv::ModelId realtimeModelId() { return m_realtimePitchModelId; } + sv::ModelId currentRecordingModelId() { return m_currentRecordingModelId; } + sv::WaveformLayer *recordingLayer() { return m_recordingLayer; } + SingingTakes *takes() { return m_takes; } + sv::sv_frame_t takePosition() { return m_takePosition; } + sv::sv_frame_t takePreRoll() { return m_takePreRoll; } + sv::sv_frame_t takeEnd() { return m_takeEnd; } + bool takeTimerRunning() { return m_takeTimer && m_takeTimer->isActive(); } + + void seekTo(sv::sv_frame_t frame) { + m_viewManager->setPlaybackFrame(frame); + } + sv::sv_frame_t playbackFrame() { return m_viewManager->getPlaybackFrame(); } + + void selectRange(sv::sv_frame_t start, sv::sv_frame_t end) { + m_viewManager->addSelection(sv::Selection(start, end)); + } + void clearSelections() { m_viewManager->clearSelections(); } + sv::MultiSelection::SelectionList selections() { + return m_viewManager->getSelections(); + } + + // The question about recording over singing that is there is answered + // from here: the suite cannot answer a dialog + void setRecordOverAnswer(bool yes) { m_recordOverAnswer = yes; } + int recordOverQuestions() const { return m_recordOverQuestions; } + void clearRecordOverQuestions() { m_recordOverQuestions = 0; } + + // ... or by MainWindow's own dialog, for a test that answers it by + // pressing its buttons + void setRecordOverAskedInDialog(bool on) { m_recordOverInDialog = on; } + + sv::ModelId pendingSingingModelId() { return m_pendingSingingModelId; } + sv::ModelId backgroundMusicModelId() { return m_backgroundMusicModelId; } + sv::WaveformLayer *backgroundMusicLayer() { return m_backgroundMusicLayer; } + QAction *playBackgroundMusicAction() { return m_playBackgroundMusic; } + sv::LevelPanToolButton *backgroundMusicLevelPan() { return m_bgMusicLPW; } + bool recordingInProgress() { return m_recordingInProgress; } + bool recordingAsSingingTrack() { return m_recordingAsSingingTrack; } + sv::sv_frame_t recordingLatencyFrames() { return m_recordingLatencyFrames; } + int pendingExtraPaneCount() { return int(m_pendingExtraPanes.size()); } + + CoverageStrip *coverageStrip() { return m_coverageStrip; } + + AlternatePitchTrack *alternatePitch() { return m_alternatePitch; } + void doToggleAlternatePitch() { alternatePitchToggled(); } + void doStepAlternatePitch(bool up) { + if (up) alternatePitchUp(); else alternatePitchDown(); + } + QAction *alternatePitchAction() { return m_showAlternatePitch; } + QAction *alternatePitchUpAction() { return m_alternatePitchUpAction; } + QAction *alternatePitchDownAction() { return m_alternatePitchDownAction; } + + // The timed lyrics, and the four menu actions that act on them + LyricsTrack *lyrics() { return m_lyrics; } + bool doImportLyricsFrom(QString path) { return importLyricsFrom(path); } + bool doExportLyricsTo(QString path) { return exportLyricsTo(path); } + QAction *importLyricsAction() { return m_importLyricsAction; } + QAction *exportLyricsAction() { return m_exportLyricsAction; } + QAction *removeLyricsAction() { return m_removeLyricsAction; } + QAction *showLyricsAction() { return m_showLyrics; } + + // Edit > Edit Lyrics, and the editor it switches on; Edit > Shift + // Lyrics... + QAction *editLyricsAction() { return m_editLyricsAction; } + QAction *shiftLyricsAction() { return m_shiftLyricsAction; } + LyricsEditor *lyricsEditor() { return m_lyricsEditor; } + + // The file Import Lyrics asks for, answered from here: "" is Cancel + void setLyricsFileAnswer(QString path) { m_lyricsFileAnswer = path; } + int lyricsFileQuestions() const { return m_lyricsFileQuestions; } + + // The file Export Lyrics asks for, likewise, and the path it offered + void setLyricsExportAnswer(QString path) { m_lyricsExportAnswer = path; } + int lyricsExportQuestions() const { return m_lyricsExportQuestions; } + QString lyricsExportSuggestion() const { return m_lyricsExportSuggestion; } + + // The texts the lyrics editor asks for, answered from here in turn. + // A question with no answer left is cancelled + void answerWordText(QString text) { m_wordTextAnswers.push_back({true, text}); } + void cancelWordText() { m_wordTextAnswers.push_back({false, QString()}); } + int wordTextQuestions() const { return m_wordTextQuestions; } + int wordTextAnswersLeft() const { return int(m_wordTextAnswers.size()); } + // The text the last question offered, and whether it was for a new word + QString wordTextOffered() const { return m_wordTextOffered; } + bool wordTextWasNew() const { return m_wordTextWasNew; } + // Done while the next question is open, as anything can be while its + // dialog runs an event loop + void whileAskingWordText(std::function f) { m_whileAskingWordText = f; } + + // The seconds Shift Lyrics asks for, answered from here in turn, as + // the texts are. A question with no answer left is cancelled + void answerLyricsShift(double seconds) { m_shiftAnswers.push_back({true, seconds}); } + void cancelLyricsShift() { m_shiftAnswers.push_back({false, 0.0}); } + int lyricsShiftQuestions() const { return m_shiftQuestions; } + void whileAskingLyricsShift(std::function f) { m_whileAskingShift = f; } + + // An estimate of the live tracker's, handed to the window as the + // live dots feed does, in a batch of its own + void doRealtimePitchDetected(sv::sv_frame_t frame, double hz) { + onRealtimePitchDetected({ { frame, hz } }); + } + + // A GUI thread that takes this long, on top of the real work, each + // time the live dots are handed to it: a phone, several times slower + // than the machine the tests run on + void setLiveDotsDelay(int ms) { m_liveDotsDelayMs = ms; } + QString statusText() { return getStatusLabel()->text(); } + void setStatusText(QString text) { getStatusLabel()->setText(text); } + +protected: + void onRealtimePitchDetected + (const RealtimePitchTracker::Estimates &estimates) override { + if (m_liveDotsDelayMs > 0) { + QElapsedTimer timer; + timer.start(); + while (timer.elapsed() < m_liveDotsDelayMs) { } + } + MainWindow::onRealtimePitchDetected(estimates); + } + + // MainWindow::createAudioIO() has named the driver and applied its + // latency by now; the fake is opened in place of the device svapp + // would open, and what that would have been asked for is kept + void openAudioIO() override { + if (m_audioIO || m_playTarget) return; + if (!m_installDevice) return; + ++m_audioIOOpened; + QSettings settings; + m_audioIOOpenedFor = LatencyCalibration::currentKey(settings, 0); + m_fakeConfig.inputIsKept = [this]() { + return m_recordTarget->isRecording(); + }; + m_audioIO = new FakeAudioIO + (m_recordTarget, m_playSource->getApplicationPlaybackSource(), + m_fakeConfig); + m_playSource->setSystemPlaybackTarget(m_audioIO); + } + + QStringList audioImplementationNames() const override { + return m_implementations; + } + + bool suspendAudioOnStop() const override { + return !m_keepAudioRunning; + } + + int audioIdleSuspendMillis() const override { + return m_audioIdleSuspendMillis; + } + + bool confirmRecordingOverTake() override { + ++m_recordOverQuestions; + if (m_recordOverInDialog) { + return MainWindow::confirmRecordingOverTake(); + } + return m_recordOverAnswer; + } + + bool confirmDeleteTake(QString) override { + ++m_deleteTakeQuestions; + return m_deleteTakeAnswer; + } + + QString askForTakeName(QString current) override { + return m_takeNameAnswer == "" ? current : m_takeNameAnswer; + } + + bool askToSaveIncompleteSession() override { + ++m_saveIncompleteQuestions; + return m_saveIncompleteAnswer; + } + + QString getSaveFileName(sv::FileFinder::FileType type) override { + if (m_saveFileNameAnswer == "") return MainWindow::getSaveFileName(type); + ++m_saveFileNameQuestions; + return m_saveFileNameAnswer; + } + + // Every take analyser is made here, for a take's first recording and + // for each swap of its audio, before its range is analysed + void setupSingingTrackAnalyser(sv::ModelId singingModelId, + bool deferAnalysis = false) override { + MainWindow::setupSingingTrackAnalyser(singingModelId, deferAnalysis); + if (m_analyser2 && m_holdRangedMerges) { + m_analyser2->setRangedMergeHeld(true); + } + } + + QString askForLyricsFile() override { + ++m_lyricsFileQuestions; + return m_lyricsFileAnswer; + } + + QString askForLyricsExportFile(QString suggested) override { + ++m_lyricsExportQuestions; + m_lyricsExportSuggestion = suggested; + return m_lyricsExportAnswer; + } + + bool askForLyricsWordText(QString &text, bool isNew) override { + ++m_wordTextQuestions; + m_wordTextOffered = text; + m_wordTextWasNew = isNew; + if (m_whileAskingWordText) { + auto during = m_whileAskingWordText; + m_whileAskingWordText = nullptr; + during(); + } + if (m_wordTextAnswers.isEmpty()) return false; + auto answer = m_wordTextAnswers.takeFirst(); + if (!answer.first) return false; + text = answer.second; + return true; + } + + bool askForLyricsShift(double &seconds) override { + ++m_shiftQuestions; + if (m_whileAskingShift) { + auto during = m_whileAskingShift; + m_whileAskingShift = nullptr; + during(); + } + if (m_shiftAnswers.isEmpty()) return false; + auto answer = m_shiftAnswers.takeFirst(); + if (!answer.first) return false; + seconds = answer.second; + return true; + } + + // The base class deleteAudioIO() deletes m_audioIO, which is right + // for the fake as well + +private: + FakeAudioIO::Config m_fakeConfig; + bool m_installDevice; + QStringList m_implementations; + bool m_keepAudioRunning = false; + int m_audioIdleSuspendMillis = 0; + int m_audioIOOpened = 0; + LatencyCalibration::Key m_audioIOOpenedFor; + int m_liveDotsDelayMs = 0; + bool m_recordOverAnswer = true; + bool m_recordOverInDialog = false; + int m_recordOverQuestions = 0; + bool m_deleteTakeAnswer = true; + int m_deleteTakeQuestions = 0; + QString m_takeNameAnswer; + bool m_saveIncompleteAnswer = false; + int m_saveIncompleteQuestions = 0; + QString m_saveFileNameAnswer; + int m_saveFileNameQuestions = 0; + bool m_holdRangedMerges = false; + QString m_lyricsFileAnswer; + int m_lyricsFileQuestions = 0; + QString m_lyricsExportAnswer; + int m_lyricsExportQuestions = 0; + QString m_lyricsExportSuggestion; + QList> m_wordTextAnswers; + int m_wordTextQuestions = 0; + QString m_wordTextOffered; + bool m_wordTextWasNew = false; + std::function m_whileAskingWordText; + QList> m_shiftAnswers; + int m_shiftQuestions = 0; + std::function m_whileAskingShift; +}; + +#endif diff --git a/main/test/TestOctaveSlips.h b/main/test/TestOctaveSlips.h new file mode 100644 index 00000000..86cc75d3 --- /dev/null +++ b/main/test/TestOctaveSlips.h @@ -0,0 +1,184 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TEST_OCTAVE_SLIPS_H +#define TEST_OCTAVE_SLIPS_H + +// Tier 2: the live dots' octave slips. Hops in, the dots let through +// out; no tracker, no audio. + +#include "../OctaveSlips.h" + +#include +#include + +#include +#include + +class TestOctaveSlips : public QObject +{ + Q_OBJECT + + typedef std::vector Dots; + + static constexpr sv::sv_frame_t hop = 256; + static constexpr double tone = 220.5; + + // Pushes each pitch as the next hop (0 for an unvoiced one), and + // gives what was let through, and after which hop each came + struct Run { + OctaveSlips slips; + Dots out; + std::vector after; + int hops = 0; + + void push(double hz) { + Dots now; + slips.push(hops * hop, hz, now); + for (const OctaveSlips::Dot &d : now) { + out.push_back(d); + after.push_back(hops); + } + ++hops; + } + void push(double hz, int count) { + for (int i = 0; i < count; ++i) push(hz); + } + std::vector hopsOut() const { + std::vector h; + for (const OctaveSlips::Dot &d : out) h.push_back(int(d.frame / hop)); + return h; + } + }; + + static std::vector range(int from, int to) { + std::vector r; + for (int i = from; i < to; ++i) r.push_back(i); + return r; + } + static std::vector join(std::vector a, const std::vector &b) { + a.insert(a.end(), b.begin(), b.end()); + return a; + } + +private slots: + + // A steady tone, with a vibrato and a glide of a fifth: every dot + // at once, on the hop it was found on + void steady_dots_pass_at_once() { + Run r; + for (int i = 0; i < 20; ++i) { + r.push(tone * std::pow(2.0, (i % 2 ? 10.0 : -10.0) / 1200.0)); + } + r.push(tone * 1.5, 5); + QCOMPARE(r.hopsOut(), range(0, 25)); + for (int i = 0; i < 25; ++i) QCOMPARE(r.after[i], i); + QCOMPARE(r.slips.held(), 0); + QCOMPARE(r.out[22].hz, tone * 1.5); + } + + // One dot an octave low between two on the tone: dropped, and the + // dot after it comes at once + void one_dot_an_octave_low_is_dropped() { + Run r; + r.push(tone, 5); + r.push(tone / 2.0 * std::pow(2.0, 9.0 / 1200.0)); // -1191 cents + r.push(tone, 5); + QCOMPARE(r.hopsOut(), join(range(0, 5), range(6, 11))); + QCOMPARE(r.after[5], 6); + for (const OctaveSlips::Dot &d : r.out) QCOMPARE(d.hz, tone); + } + + // So is a run an octave high + void a_run_an_octave_high_is_dropped() { + Run r; + r.push(tone, 3); + r.push(tone * 2.0, 3); + r.push(tone, 3); + QCOMPARE(r.hopsOut(), join(range(0, 3), range(6, 9))); + } + + // A run of one window's hops is dropped; one hop longer is taken + // for the singing, let through whole and in order, and what follows + // at once + void a_run_longer_than_a_window_is_let_through() { + { + Run r; + r.push(tone, 3); + r.push(tone / 2.0, OctaveSlips::kMaxRunHops); + r.push(tone, 3); + QCOMPARE(r.hopsOut(), + join(range(0, 3), + range(3 + OctaveSlips::kMaxRunHops, + 6 + OctaveSlips::kMaxRunHops))); + } + { + Run r; + r.push(tone, 3); + r.push(tone / 2.0, OctaveSlips::kMaxRunHops + 3); + QCOMPARE(r.hopsOut(), range(0, 6 + OctaveSlips::kMaxRunHops)); + // Held for as long as it could be a slip, and no longer + QCOMPARE(r.after[3], 3 + OctaveSlips::kMaxRunHops); + QCOMPARE(r.after[3 + OctaveSlips::kMaxRunHops], + 3 + OctaveSlips::kMaxRunHops); + QCOMPARE(r.after.back(), 5 + OctaveSlips::kMaxRunHops); + QCOMPARE(r.slips.held(), 0); + } + } + + // A run that ends in silence, or at another pitch, may be where the + // singing went: let through + void a_run_not_back_on_the_pitch_is_let_through() { + { + Run r; + r.push(tone, 3); + r.push(tone / 2.0, 2); + QCOMPARE(r.slips.held(), 2); + r.push(0.0); + QCOMPARE(r.hopsOut(), range(0, 5)); + QCOMPARE(r.after.back(), 5); + } + { + Run r; + r.push(tone, 3); + r.push(tone / 2.0, 2); + r.push(tone * 1.5, 2); + QCOMPARE(r.hopsOut(), range(0, 7)); + } + } + + // At the start of a phrase there is nothing to be an octave from: + // the first dot passes, and a run after an unvoiced hop too + void a_phrase_starts_at_once() { + Run r; + r.push(tone / 2.0); + r.push(0.0); + r.push(tone, 3); + QCOMPARE(r.hopsOut(), join(range(0, 1), range(2, 5))); + QCOMPARE(r.after[0], 0); + QCOMPARE(r.after[1], 2); + } + + // Further than the tolerance from an octave is not a slip: a ninth + // down passes at once + void other_leaps_pass_at_once() { + Run r; + r.push(tone, 3); + r.push(tone / 2.0 * std::pow(2.0, -150.0 / 1200.0), 2); + r.push(tone, 3); + QCOMPARE(r.hopsOut(), range(0, 8)); + for (int i = 0; i < 8; ++i) QCOMPARE(r.after[i], i); + } +}; + +#endif diff --git a/main/test/TestPinchZoom.h b/main/test/TestPinchZoom.h new file mode 100644 index 00000000..3e58c521 --- /dev/null +++ b/main/test/TestPinchZoom.h @@ -0,0 +1,152 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TEST_PINCH_ZOOM_H +#define TEST_PINCH_ZOOM_H + +// Tier 2: the arithmetic of a pinch on a pane's time axis. Zoom levels +// and frames in, zoom levels and frames out; no view. + +#include "../PinchZoom.h" + +#include "data/model/RelativelyFineZoomConstraint.h" + +#include +#include + +#include +#include + +class TestPinchZoom : public QObject +{ + Q_OBJECT + + typedef sv::ZoomLevel ZoomLevel; + typedef sv::sv_frame_t frame_t; + + static ZoomLevel fpp(int level) { + return ZoomLevel(ZoomLevel::FramesPerPixel, level); + } + + static ZoomLevel ppf(int level) { + return ZoomLevel(ZoomLevel::PixelsPerFrame, level); + } + + static QString text(ZoomLevel z) { + return QString("%1 %2") + .arg(z.zone == ZoomLevel::FramesPerPixel ? "fpp" : "ppf") + .arg(z.level); + } + +private slots: + void frames_per_pixel_in_both_zones() { + QCOMPARE(PinchZoom::framesPerPixel(fpp(64)), 64.0); + QCOMPARE(PinchZoom::framesPerPixel(fpp(1)), 1.0); + QCOMPARE(PinchZoom::framesPerPixel(ppf(4)), 0.25); + } + + // Every level the mouse wheel steps through, from far out to as far + // in as it goes, is one a pinch can land on exactly + void pinch_levels_are_the_wheel_levels() { + sv::RelativelyFineZoomConstraint constraint; + ZoomLevel level = constraint.getNearestZoomLevel(fpp(65536)); + int steps = 0; + while (true) { + ZoomLevel found = + PinchZoom::nearestLevel(PinchZoom::framesPerPixel(level)); + QVERIFY2(found == level, + qPrintable(text(level) + " came back as " + + text(found))); + ZoomLevel next = constraint.getNearestZoomLevel + (level.decremented(), sv::ZoomConstraint::RoundDown); + if (next == level) break; + level = next; + ++steps; + } + QVERIFY(steps > 50); + QVERIFY(level == constraint.getMinZoomLevel()); + } + + void nearest_level_between_two() { + QVERIFY(PinchZoom::nearestLevel(33.0) == fpp(32)); + QVERIFY(PinchZoom::nearestLevel(0.26) == ppf(4)); + QVERIFY(PinchZoom::nearestLevel(1.0) == fpp(1)); + } + + // As far as the wheel goes and no further, whatever is asked + void nearest_level_is_limited_as_the_wheel_is() { + sv::RelativelyFineZoomConstraint constraint; + QVERIFY(PinchZoom::nearestLevel(1.0e12) == + constraint.getMaxZoomLevel()); + QVERIFY(PinchZoom::nearestLevel(1.0e-6) == + constraint.getMinZoomLevel()); + QVERIFY(PinchZoom::nearestLevel(0.0) == + constraint.getMinZoomLevel()); + QVERIFY(PinchZoom::nearestLevel + (std::numeric_limits::quiet_NaN()) == + constraint.getMinZoomLevel()); + } + + // 64 and 72 are neighbours. Between them, whichever the view is at + // stays until the other is nearer by 2%: at 67.2 and 68.6 frames per + // pixel + void pinch_between_two_levels_stays_where_it_is() { + for (double target : { 67.3, 68.0, 68.5 }) { + QVERIFY2(PinchZoom::pinchedLevel(fpp(64), target) == fpp(64), + qPrintable(QString("from 64 at %1").arg(target))); + QVERIFY2(PinchZoom::pinchedLevel(fpp(72), target) == fpp(72), + qPrintable(QString("from 72 at %1").arg(target))); + } + QVERIFY(PinchZoom::pinchedLevel(fpp(64), 68.8) == fpp(72)); + QVERIFY(PinchZoom::pinchedLevel(fpp(72), 67.0) == fpp(64)); + } + + void pinch_goes_as_far_as_it_asks() { + QVERIFY(PinchZoom::pinchedLevel(fpp(64), 16.0) == fpp(16)); + QVERIFY(PinchZoom::pinchedLevel(fpp(64), 256.0) == fpp(256)); + QVERIFY(PinchZoom::pinchedLevel(fpp(2), 0.25) == ppf(4)); + QVERIFY(PinchZoom::pinchedLevel(fpp(64), 0.0) == fpp(64)); + } + + // View::getFrameForX(): from the centre rounded down to a whole + // pixel, and width / 2 rounded down + void frame_at_x_as_the_view_has_it() { + QCOMPARE(PinchZoom::frameAtX(10030, fpp(64), 1000, 500), 9984.0); + QCOMPARE(PinchZoom::frameAtX(10030, fpp(64), 1000, 600), + 9984.0 + 6400.0); + QCOMPARE(PinchZoom::frameAtX(10030, fpp(64), 999, 499), 9984.0); + QCOMPARE(PinchZoom::frameAtX(10030, fpp(64), 1000, 499.5), + 9984.0 - 32.0); + QCOMPARE(PinchZoom::frameAtX(1000, ppf(4), 1000, 520), 1005.0); + } + + // What centreFor() puts at x is shown there, to within a pixel + void centre_for_puts_the_frame_at_x() { + for (ZoomLevel z : { fpp(64), fpp(7), fpp(1), ppf(4) }) { + for (double x : { 0.0, 250.0, 500.0, 731.5, 999.0 }) { + for (double frame : { 100000.0, 123457.0 }) { + frame_t centre = PinchZoom::centreFor(frame, z, 1000, x); + double shown = PinchZoom::frameAtX(centre, z, 1000, x); + double pixel = PinchZoom::framesPerPixel(z); + QVERIFY2(shown > frame - pixel - 0.5 && + shown <= frame + 0.5, + qPrintable(QString("%1 at x %2, %3: shows %4") + .arg(frame).arg(x).arg(text(z)) + .arg(shown))); + } + } + } + } +}; + +#endif diff --git a/main/test/TestPlotSize.h b/main/test/TestPlotSize.h new file mode 100644 index 00000000..bc2d9f5b --- /dev/null +++ b/main/test/TestPlotSize.h @@ -0,0 +1,361 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TEST_PLOT_SIZE_H +#define TEST_PLOT_SIZE_H + +// Tier 3: how large the panes draw the pitch tracks and the notes (the +// svgui fork's plot scale, which a pane applies with its pixel ratio), +// and View > Plot Size (PlotSize). The layers are painted into images +// as a pane paints them into its buffer, through a ViewProxy whose +// pixels are the buffer's, at ratio 1 and at a phone's ratio 3. No +// MainWindow; the menu in the window is TestCompactLayout's. + +#include "../PlotSize.h" + +#include "view/Pane.h" +#include "view/ViewManager.h" +#include "view/ViewProxy.h" +#include "layer/TimeValueLayer.h" +#include "layer/FlexiNoteLayer.h" +#include "layer/ColourDatabase.h" +#include "layer/CoordinateScale.h" +#include "data/model/SparseTimeValueModel.h" +#include "data/model/NoteModel.h" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +class TestPlotSize : public QObject +{ + Q_OBJECT + + static constexpr double kRate = 44100.0; + static constexpr int kHop = 256; + static constexpr int kWidth = 400; + static constexpr int kHeight = 200; + + sv::ViewManager *m_viewManager = nullptr; + sv::Pane *m_pane = nullptr; + sv::TimeValueLayer *m_pitch = nullptr; + sv::FlexiNoteLayer *m_notes = nullptr; + sv::ModelId m_pitchModel; + sv::ModelId m_noteModel; + + // Columns of the pane, in logical pixels: one through the first + // note's pitch, one through the middle of that note, well away from + // its ends + static constexpr int kPitchX = 50; + static constexpr int kNoteX = 100; + + static sv::sv_frame_t frames(double seconds) { + return sv::sv_frame_t(seconds * kRate); + } + + static QColor colourOf(sv::SingleColourLayer *layer) { + return sv::ColourDatabase::getInstance()->getColour + (layer->getBaseColour()); + } + + // The layer painted into a white image as the pane paints it into + // its buffer at this pixel ratio + QImage render(sv::Layer *layer, int ratio) { + QImage image(kWidth * ratio, kHeight * ratio, + QImage::Format_ARGB32_Premultiplied); + image.fill(Qt::white); + QPainter painter(&image); + sv::ViewProxy proxy(m_pane, ratio); + layer->paint(&proxy, painter, image.rect()); + painter.end(); + return image; + } + + // The first and last rows drawn in a column, or (-1, -1) + static std::pair drawnRows(const QImage &image, int x) { + int first = -1, last = -1; + for (int y = 0; y < image.height(); ++y) { + if (image.pixel(x, y) != qRgb(255, 255, 255)) { + if (first < 0) first = y; + last = y; + } + } + return { first, last }; + } + + static int drawnHeight(const QImage &image, int x) { + auto rows = drawnRows(image, x); + return rows.first < 0 ? 0 : rows.second - rows.first + 1; + } + + // The middle of a logical column, in the pixels of an image at a + // ratio + static int column(int x, int ratio) { + return x * ratio + ratio / 2; + } + + // Whether the note tool, with the pointer at this point of the pane, + // would edit the note there: the pointer changes to the cursor of an + // edit, and to the plain arrow off the note + bool hitsNote(int x, int y) { + m_pane->setCursor(Qt::WaitCursor); + QMouseEvent e(QEvent::MouseMove, QPointF(x, y), QPointF(x, y), + Qt::NoButton, Qt::NoButton, Qt::NoModifier); + m_notes->mouseMoveEvent(m_pane, &e); + Qt::CursorShape shape = m_pane->cursor().shape(); + return shape != Qt::ArrowCursor && shape != Qt::WaitCursor; + } + + static void forgetSetting() { + QSettings settings; + settings.beginGroup("MainWindow"); + settings.remove("plotsize"); + settings.endGroup(); + } + +private slots: + void init() { + forgetSetting(); + + m_viewManager = new sv::ViewManager(); + m_pane = new sv::Pane(); + m_pane->setViewManager(m_viewManager); + m_pane->resize(kWidth, kHeight); + m_pane->setZoomLevel(sv::ZoomLevel(sv::ZoomLevel::FramesPerPixel, + kHop)); + m_pane->setStartFrame(0); + + // A second at 220 Hz and a second at 330 Hz, a pitch at every + // hop and a note for each; the scales run from 100 to 500 Hz, + // which keeps them clear of the edges + auto pitch = std::make_shared + (kRate, kHop, 100.f, 500.f, false); + pitch->setScaleUnits("Hz"); + for (sv::sv_frame_t f = 0; f < frames(2.0); f += kHop) { + pitch->add(sv::Event(f, f < frames(1.0) ? 220.f : 330.f, "")); + } + auto notes = std::make_shared + (kRate, kHop, 100.f, 500.f, false, sv::NoteModel::FLEXI_NOTE); + notes->setScaleUnits("Hz"); + notes->add(sv::Event(0, 220.f, frames(1.0), 1.f, "")); + notes->add(sv::Event(frames(1.0), 330.f, frames(1.0), 1.f, "")); + m_pitchModel = sv::ModelById::add(pitch); + m_noteModel = sv::ModelById::add(notes); + + m_pitch = new sv::TimeValueLayer(); + m_pitch->setModel(m_pitchModel); + m_pitch->setPlotStyle(sv::TimeValueLayer::PlotPoints); + m_pitch->setVerticalScale(sv::TimeValueLayer::LinearScale); + m_notes = new sv::FlexiNoteLayer(); + m_notes->setModel(m_noteModel); + // As in Tony, the notes take the scale of the pitch, which + // has one of its own + m_pane->addLayer(m_pitch); + m_pane->addLayer(m_notes); + } + + void cleanup() { + delete m_pane; + m_pane = nullptr; + delete m_pitch; + delete m_notes; + m_pitch = nullptr; + m_notes = nullptr; + sv::ModelById::release(m_pitchModel); + sv::ModelById::release(m_noteModel); + delete m_viewManager; + m_viewManager = nullptr; + forgetSetting(); + } + + // At ratio 1 and the normal size the layers draw what they drew + // before there was a plot scale, pixel for pixel: a point is a + // rectangle two pixels high in the view's pen, a note one sixteen + // high in a pen of a pixel. (A whole pane of Tony's, with its + // waveform, pitch and notes, was compared pixel for pixel before + // and after the change to the fork, once.) + void desktop_draws_as_before() { + sv::CoordinateScale pitchScale = + m_pane->getEffectiveVerticalExtentsForLayer(m_pitch); + sv::CoordinateScale noteScale = + m_pane->getEffectiveVerticalExtentsForLayer(m_notes); + + QImage pitch(kWidth, kHeight, QImage::Format_ARGB32_Premultiplied); + pitch.fill(Qt::white); + { + QPainter paint(&pitch); + QColor colour = colourOf(m_pitch); + QColor fill = colour; + fill.setAlpha(80); + paint.setPen(QPen(colour, m_pane->scalePenWidth(1.0))); + paint.setBrush(fill); + auto model = sv::ModelById::getAs + (m_pitchModel); + int w = m_pane->getXForFrame(kHop) - m_pane->getXForFrame(0); + if (w < 1) w = 1; + for (const auto &e : model->getAllEvents()) { + int x = m_pane->getXForFrame(e.getFrame()); + int y = pitchScale.getCoordForValueRounded(m_pane, e.getValue()); + paint.drawRect(x, y - 1, w, 2); + } + } + + QImage notes(kWidth, kHeight, QImage::Format_ARGB32_Premultiplied); + notes.fill(Qt::white); + { + QPainter paint(¬es); + QColor colour = colourOf(m_notes); + QColor fill = colour; + fill.setAlpha(80); + paint.setPen(colour); + paint.setBrush(fill); + auto model = sv::ModelById::getAs(m_noteModel); + for (const auto &e : model->getAllEvents()) { + int x = m_pane->getXForFrame(e.getFrame()); + int w = m_pane->getXForFrame(e.getFrame() + e.getDuration()) - x; + int y = noteScale.getCoordForValueRounded(m_pane, e.getValue()); + paint.drawRect(x, y - 8, w, 16); + } + } + + QCOMPARE(drawnHeight(pitch, kPitchX), 3); + QCOMPARE(drawnHeight(notes, kNoteX), 17); + QVERIFY(render(m_pitch, 1) == pitch); + QVERIFY(render(m_notes, 1) == notes); + } + + // What is drawn keeps its size in logical pixels: at a phone's ratio + // 3, three times the pixels of ratio 1; and the plot size makes it + // larger again, at either ratio + void sizes_follow_the_ratio_and_the_plot_size() { + for (double scale : { 1.0, 1.5, 2.0 }) { + m_viewManager->setPlotScale(scale); + for (int ratio : { 1, 3 }) { + double size = ratio * scale; + int point = drawnHeight(render(m_pitch, ratio), + column(kPitchX, ratio)); + int note = drawnHeight(render(m_notes, ratio), + column(kNoteX, ratio)); + QString where = QString("at ratio %1 and plot scale %2: a " + "point %3 pixels high, a note %4") + .arg(ratio).arg(scale).arg(point).arg(note); + // Two pixels and the pen's one, a note's sixteen and + // the pen's one, all times the size: to a pixel, which + // a thick pen may round either way + QVERIFY2(std::abs(point - 3 * size) <= 1, qPrintable(where)); + QVERIFY2(std::abs(note - 17 * size) <= 1, qPrintable(where)); + } + } + } + + // The note tool picks a note where it is drawn, in the pane's own + // (logical) coordinates, at either ratio and every plot size + void note_hit_area_is_what_is_drawn() { + for (double scale : { 1.0, 1.5, 2.0 }) { + m_viewManager->setPlotScale(scale); + + int hitTop = -1, hitBottom = -1; + for (int y = 0; y < kHeight; ++y) { + if (hitsNote(kNoteX, y)) { + if (hitTop < 0) hitTop = y; + hitBottom = y; + } + } + QVERIFY(hitTop >= 0); + + for (int ratio : { 1, 3 }) { + auto rows = drawnRows(render(m_notes, ratio), + column(kNoteX, ratio)); + // Edges in logical pixels; the drawing's are the pen's + // outer edges, half a pen outside the note + double drawnTop = double(rows.first) / ratio; + double drawnBottom = double(rows.second + 1) / ratio; + QString where = QString("at ratio %1 and plot scale %2: " + "drawn from %3 to %4, picked from " + "%5 to %6") + .arg(ratio).arg(scale).arg(drawnTop).arg(drawnBottom) + .arg(hitTop).arg(hitBottom + 1); + QVERIFY2(std::abs(hitTop - drawnTop) <= scale, + qPrintable(where)); + QVERIFY2(std::abs(hitBottom + 1 - drawnBottom) <= scale, + qPrintable(where)); + } + } + } + + // --- View > Plot Size ------------------------------------------------- + + void plot_size_starts_at_the_default() { + PlotSize plotSize(m_viewManager, nullptr); + QCOMPARE(PlotSize::getDefaultPercent(), 100); // 150 on Android + QCOMPARE(plotSize.getPercent(), 100); + QCOMPARE(m_viewManager->getPlotScale(), 1.0); + + QStringList texts, checked; + for (QAction *action : plotSize.getActions()) { + texts << action->text(); + if (action->isChecked()) checked << action->text(); + } + QCOMPARE(texts, QStringList({ "100%", "150%", "200%" })); + QCOMPARE(checked, QStringList({ "100%" })); + + // Nothing is stored until a step is chosen + QVERIFY(!QSettings().contains("MainWindow/plotsize")); + } + + void a_step_applies_at_once_and_is_remembered() { + PlotSize plotSize(m_viewManager, nullptr); + QSignalSpy changed(m_viewManager, &sv::ViewManager::plotScaleChanged); + + plotSize.getActions().at(1)->trigger(); + QCOMPARE(m_viewManager->getPlotScale(), 1.5); + QCOMPARE(changed.count(), 1); + QCOMPARE(plotSize.getPercent(), 150); + QVERIFY(plotSize.getActions().at(1)->isChecked()); + QVERIFY(!plotSize.getActions().at(0)->isChecked()); + QCOMPARE(QSettings().value("MainWindow/plotsize").toInt(), 150); + + // The next start takes it up + sv::ViewManager other; + PlotSize again(&other, nullptr); + QCOMPARE(again.getPercent(), 150); + QCOMPARE(other.getPlotScale(), 1.5); + QVERIFY(again.getActions().at(1)->isChecked()); + } + + void only_the_steps_are_taken() { + PlotSize plotSize(m_viewManager, nullptr); + plotSize.setPercent(175); + QCOMPARE(plotSize.getPercent(), 100); + QCOMPARE(m_viewManager->getPlotScale(), 1.0); + QVERIFY(!QSettings().contains("MainWindow/plotsize")); + + for (QVariant stored : { QVariant(175), QVariant("large") }) { + QSettings().setValue("MainWindow/plotsize", stored); + sv::ViewManager other; + PlotSize again(&other, nullptr); + QCOMPARE(again.getPercent(), 100); + QCOMPARE(other.getPlotScale(), 1.0); + } + } +}; + +#endif diff --git a/main/test/TestPopupArea.h b/main/test/TestPopupArea.h new file mode 100644 index 00000000..f3f85dfc --- /dev/null +++ b/main/test/TestPopupArea.h @@ -0,0 +1,175 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TEST_POPUP_AREA_H +#define TEST_POPUP_AREA_H + +// Tier 1: where menus may go on a phone drawn edge to edge (PopupArea): +// inside the main window's safe area, clear of the top and bottom of +// the screen, and the one margin QMenu is given for that. The figures +// are a phone's in landscape, in device-independent pixels: the screen +// 915 by 412, a camera cutout at the left, the status bar at the top, +// gesture navigation at the bottom. + +#include "../PopupArea.h" + +#include +#include + +class TestPopupArea : public QObject +{ + Q_OBJECT + + const QRect screen = QRect(0, 0, 915, 412); + const QMargins bars = QMargins(35, 24, 0, 16); + +private slots: + void a_phone_in_landscape_keeps_popups_off_its_bars_and_edges() { + QRect usable = PopupArea::usable(screen, screen, bars, 50); + + // Clear of the cutout at the side, and of the top and bottom + // edges by the margin, which is wider than either bar + QCOMPARE(usable, QRect(QPoint(35, 50), QPoint(914, 361))); + } + + void a_bar_wider_than_the_margin_is_kept_clear_of() { + QRect usable = PopupArea::usable(screen, screen, + QMargins(0, 60, 48, 70), 50); + QCOMPARE(usable, QRect(QPoint(0, 60), QPoint(914 - 48, 411 - 70))); + } + + void a_window_short_of_the_screen_is_kept_inside() { + // In split screen, Tony has part of it + QRect window(0, 0, 915, 200); + QRect usable = PopupArea::usable(screen, window, QMargins(0, 24, 0, 0), + 10); + QCOMPARE(usable, QRect(QPoint(0, 24), QPoint(914, 199))); + } + + void without_a_window_the_screen_less_its_edges() { + QCOMPARE(PopupArea::usable(screen, QRect(), bars, 50), + QRect(QPoint(0, 50), QPoint(914, 361))); + QCOMPARE(PopupArea::usable(screen, QRect(), QMargins(), 0), screen); + + // As on the desktop, where the screen does not start at 0 + QRect desktop(0, 30, 1920, 1050); + QCOMPARE(PopupArea::usable(desktop, QRect(), QMargins(), 0), desktop); + QCOMPARE(PopupArea::usable(desktop, desktop, QMargins(), 20), + QRect(QPoint(0, 50), QPoint(1919, 1059))); + } + + void margins_that_leave_nothing_leave_the_screen() { + QCOMPARE(PopupArea::usable(screen, screen, bars, 300), screen); + QCOMPARE(PopupArea::usable(screen, QRect(2000, 0, 10, 10), bars, 0), + screen); + } + + void qmenu_is_given_the_widest_gap_as_its_margin() { + QRect usable = PopupArea::usable(screen, screen, bars, 50); + QCOMPARE(PopupArea::menuFrame(screen, usable, 0), 50); + + // The style's own, if wider + QCOMPARE(PopupArea::menuFrame(screen, usable, 60), 60); + + // A bar at the side wider than the top and bottom margins + usable = PopupArea::usable(screen, screen, QMargins(0, 24, 80, 0), 50); + QCOMPARE(PopupArea::menuFrame(screen, usable, 0), 80); + + // Nothing to keep clear of + QCOMPARE(PopupArea::menuFrame(screen, screen, 0), 0); + QCOMPARE(PopupArea::menuFrame(screen, QRect(), 3), 3); + } + + // Where QMenu puts a menu with that margin: inside the usable area + // (its placement in qmenu.cpp, QMenuPrivate::popup(), for a menu + // taller than the screen popped up at its top, which scrolls) + void a_menu_placed_by_that_margin_is_inside() { + QRect usable = PopupArea::usable(screen, screen, bars, 50); + int frame = PopupArea::menuFrame(screen, usable, 0); + + int top = screen.top() + frame; + int height = screen.bottom() - frame * 2 - top; + QRect menu(screen.left() + frame, top, 300, height); + QVERIFY(usable.contains(menu)); + } + + void a_combo_boxs_list_is_moved_and_shortened_to_fit() { + QRect usable(QPoint(35, 50), QPoint(914, 361)); + + // Up from the box near the top, under the status bar + QCOMPARE(PopupArea::fit(QRect(100, 10, 200, 150), usable), + QRect(100, 50, 200, 150)); + // Down past the bottom + QCOMPARE(PopupArea::fit(QRect(100, 300, 200, 150), usable), + QRect(100, 212, 200, 150)); + // Taller than the whole area + QCOMPARE(PopupArea::fit(QRect(100, 0, 200, 500), usable), + QRect(100, 50, 200, 312)); + // Off the side, and wider than the area + QCOMPARE(PopupArea::fit(QRect(0, 100, 200, 100), usable), + QRect(35, 100, 200, 100)); + QCOMPARE(PopupArea::fit(QRect(0, 100, 1000, 100), usable), + QRect(35, 100, 880, 100)); + // Inside already + QCOMPARE(PopupArea::fit(QRect(100, 100, 200, 100), usable), + QRect(100, 100, 200, 100)); + // Nothing to fit into + QCOMPARE(PopupArea::fit(QRect(1, 2, 3, 4), QRect()), QRect(1, 2, 3, 4)); + } + + // The phone of the Calibrate Audio test, as its log has it: a window + // of 923 by 411, the camera's cutout at the left, the status bar at + // the top and the navigation buttons at the right + void a_dialog_is_centred_in_the_safe_area_and_kept_inside() { + const QRect window(0, 0, 923, 411); + QRect usable = PopupArea::usable + (window, window, QMargins(58, 24, 48, 0), 0); + QCOMPARE(usable, QRect(58, 24, 817, 387)); + + // Shown afresh: centred in the safe area, not over the bars, as + // Qt's own centring on the window would put it + QCOMPARE(PopupArea::place(QSize(517, 301), usable, QPoint(0, 0), true), + QRect(208, 67, 517, 301)); + // Taller than the safe area: as tall as it, below the status bar + QCOMPARE(PopupArea::place(QSize(517, 450), usable, QPoint(0, 0), true), + QRect(208, 24, 517, 387)); + // On show: left where it is, unless it sticks out + QCOMPARE(PopupArea::place(QSize(300, 200), usable, QPoint(100, 150), + false), + QRect(100, 150, 300, 200)); + QCOMPARE(PopupArea::place(QSize(300, 200), usable, QPoint(700, 300), + false), + QRect(575, 211, 300, 200)); + // Nowhere to go + QCOMPARE(PopupArea::place(QSize(3, 4), QRect(), QPoint(1, 2), true), + QRect(1, 2, 3, 4)); + } + + void millimetres_become_pixels() { + QCOMPARE(PopupArea::pixels(8, 160), 50); + QCOMPARE(PopupArea::pixels(25.4, 96), 96); + QCOMPARE(PopupArea::pixels(0, 160), 0); + QCOMPARE(PopupArea::pixels(8, 0), 0); + QCOMPARE(PopupArea::pixels(-1, 160), 0); + } + + void a_fingers_width_is_eight_millimetres() { + QCOMPARE(PopupArea::fingerWidth(160), 50); + QCOMPARE(PopupArea::fingerWidth(140), 44); + // Figures no phone's screen has, in Qt's pixels + QCOMPARE(PopupArea::fingerWidth(0), 50); + QCOMPARE(PopupArea::fingerWidth(480), 50); + } +}; + +#endif diff --git a/main/test/TestRealtimePitchTracker.h b/main/test/TestRealtimePitchTracker.h new file mode 100644 index 00000000..c147186d --- /dev/null +++ b/main/test/TestRealtimePitchTracker.h @@ -0,0 +1,480 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TEST_REALTIME_PITCH_TRACKER_H +#define TEST_REALTIME_PITCH_TRACKER_H + +// Tier 2: the tracker thread, fed the way the record target feeds the +// model during a take. + +#include "../RealtimePitchTracker.h" + +#include "TestSignals.h" + +#include "data/model/WritableWaveFileModel.h" + +#include +#include +#include +#include + +#include +#include +#include + +// Takes the tracker's estimates on the test (GUI) thread, which is how +// MainWindow gets them (LiveDotsFeed): whatever has been found since the +// last look, whenever the count is asked for +class PitchCollector +{ +public: + struct Event { + sv::sv_frame_t frame; + double hz; + }; + + std::vector events; + + PitchCollector(RealtimePitchTracker *tracker) : m_tracker(tracker) { } + + int count() { + for (const auto &e : m_tracker->takeEstimates()) { + events.push_back({ e.frame, e.hz }); + } + return int(events.size()); + } + +private: + RealtimePitchTracker *m_tracker; +}; + +class TestRealtimePitchTracker : public QObject +{ + Q_OBJECT + + static constexpr double kRate = 44100.0; + static const int kWindow = 2048; + static const int kHop = 256; + static const int kBlock = 441; // 10 ms, the record target's cadence + + QTemporaryDir m_dir; + int m_fileCounter = 0; + + struct Recording { + std::shared_ptr model; + sv::ModelId id; + int channels = 1; + sv::sv_frame_t written = 0; + + ~Recording() { + if (!id.isNone()) sv::ModelById::release(id); + } + + // Append one channel-count-wide signal in record-sized blocks, + // making each block readable straight away + void append(const std::vector> &channelData) { + int n = int(channelData[0].size()); + for (int start = 0; start < n; start += kBlock) { + int count = std::min(kBlock, n - start); + std::vector ptrs; + for (const auto &c : channelData) { + ptrs.push_back(c.data() + start); + } + model->addSamples(ptrs.data(), count); + model->updateModel(); + } + written += n; + } + + void appendMono(const std::vector &signal) { + append({ signal }); + } + }; + + std::unique_ptr makeRecording(int channels = 1) { + auto r = std::make_unique(); + QString path = m_dir.filePath + (QString("take-%1.wav").arg(++m_fileCounter)); + r->model = std::make_shared + (path, kRate, channels, + sv::WritableWaveFileModel::Normalisation::None); + r->channels = channels; + r->id = sv::ModelById::add(r->model); + return r; + } + + // Wait until the tracker has had the chance to handle every hop in + // n frames: either the expected number of events has arrived, or + // the count has stopped growing + static void settle(PitchCollector &spy, int expected = -1) { + QElapsedTimer timer; + timer.start(); + int last = -1; + int stableFor = 0; + while (timer.elapsed() < 5000) { + QTest::qWait(50); + if (expected >= 0 && spy.count() >= expected) return; + if (spy.count() == last) { + if (++stableFor >= 6) return; + } else { + stableFor = 0; + last = int(spy.count()); + } + } + } + + // The peak of a sine whose RMS is the given level + static double peakAt(double dbfs) { + return std::pow(10.0, dbfs / 20.0) * std::sqrt(2.0); + } + + static int expectedHops(sv::sv_frame_t frames) { + if (frames < kWindow) return 0; + return int((frames - kWindow) / kHop) + 1; + } + +private slots: + void initTestCase() { + QVERIFY(m_dir.isValid()); + qRegisterMetaType("sv::sv_frame_t"); + qRegisterMetaType("sv_frame_t"); + } + + void emits_correct_pitch() { + auto rec = makeRecording(); + RealtimePitchTracker tracker(rec->id); + PitchCollector spy(&tracker); + tracker.start(); + rec->appendMono(TestSignals::sine(330.0, kRate, int(kRate))); + settle(spy, expectedHops(rec->written)); + tracker.stop(); + + QVERIFY(spy.count() > 0); + for (const auto &event : spy.events) { + double hz = event.hz; + QVERIFY2(std::abs(TestSignals::centsBetween(hz, 330.0)) < 10.0, + qPrintable(QString("%1 Hz").arg(hz))); + } + } + + void frame_grid() { + auto rec = makeRecording(); + RealtimePitchTracker tracker(rec->id); + PitchCollector spy(&tracker); + tracker.start(); + rec->appendMono(TestSignals::sine(330.0, kRate, int(kRate))); + settle(spy, expectedHops(rec->written)); + tracker.stop(); + + QVERIFY(spy.count() > 0); + sv::sv_frame_t previous = -1; + for (const auto &event : spy.events) { + sv::sv_frame_t frame = event.frame; + QVERIFY2(frame >= kWindow / 2 && (frame - kWindow / 2) % kHop == 0, + qPrintable(QString("frame %1 is off the hop grid") + .arg(frame))); + QVERIFY2(frame > previous, + qPrintable(QString("frame %1 after %2") + .arg(frame).arg(previous))); + previous = frame; + } + } + + void coverage() { + auto rec = makeRecording(); + RealtimePitchTracker tracker(rec->id); + PitchCollector spy(&tracker); + tracker.start(); + rec->appendMono(TestSignals::sine(330.0, kRate, int(kRate))); + int expected = expectedHops(rec->written); + settle(spy, expected); + tracker.stop(); + + QVERIFY2(std::abs(int(spy.count()) - expected) <= 2, + qPrintable(QString("%1 events for %2 hops") + .arg(spy.count()).arg(expected))); + } + + void unvoiced_gap() { + auto rec = makeRecording(); + RealtimePitchTracker tracker(rec->id); + PitchCollector spy(&tracker); + tracker.start(); + + const int n = int(kRate / 2); + rec->appendMono(TestSignals::sine(330.0, kRate, n)); + rec->appendMono(std::vector(n, 0.f)); + rec->appendMono(TestSignals::sine(330.0, kRate, n, 0.5, 0.0, 2 * n)); + settle(spy); + tracker.stop(); + + bool before = false, after = false; + for (const auto &event : spy.events) { + sv::sv_frame_t centre = event.frame; + sv::sv_frame_t windowStart = centre - kWindow / 2; + sv::sv_frame_t windowEnd = centre + kWindow / 2; + QVERIFY2(!(windowStart >= n && windowEnd <= 2 * n), + qPrintable(QString("event at %1 lies wholly in silence") + .arg(centre))); + if (windowEnd <= n) before = true; + if (windowStart >= 2 * n) after = true; + } + QVERIFY(before); + QVERIFY(after); + } + + // A steady tone below the level floor, as a room's fans are between + // the sounds, gives no pitch, though YIN alone finds one in it; the + // same tone above the floor does + void quiet_tone_gives_no_pitch() { + auto rec = makeRecording(); + RealtimePitchTracker tracker(rec->id); + PitchCollector spy(&tracker); + tracker.start(); + + const int n = int(kRate / 2); + rec->appendMono(TestSignals::sine(306.0, kRate, n, peakAt(-66.0))); + rec->appendMono(TestSignals::sine(306.0, kRate, n, peakAt(-50.0), + 0.0, n)); + settle(spy); + tracker.stop(); + + int loud = 0; + for (const auto &event : spy.events) { + QVERIFY2(event.frame + kWindow / 2 > n, + qPrintable(QString("a pitch at frame %1, of the quiet " + "tone alone").arg(event.frame))); + if (event.frame - kWindow / 2 >= n) ++loud; + } + QVERIFY2(loud > (expectedHops(n) * 3) / 4, + qPrintable(QString("%1 pitches of the loud tone").arg(loud))); + } + + // A loud sound with no pitch, starting after the quiet tone, gives no + // pitch either: not in the windows whose second half reaches into + // it, where YIN still hears the tone in their first half + void quiet_tone_before_a_sound_gives_no_pitch() { + auto rec = makeRecording(); + RealtimePitchTracker tracker(rec->id); + PitchCollector spy(&tracker); + tracker.start(); + + const int n = int(kRate / 2); + std::vector after = + TestSignals::sine(306.0, kRate, n, peakAt(-66.0), 0.0, n); + const std::vector noise = TestSignals::whiteNoise(n, 3, 0.1); + for (int i = 0; i < n; ++i) after[i] += noise[i]; + rec->appendMono(TestSignals::sine(306.0, kRate, n, peakAt(-66.0))); + rec->appendMono(after); + settle(spy); + tracker.stop(); + + QVERIFY2(spy.count() == 0, + qPrintable(QString("%1 pitches, the first at frame %2 " + "(the sound starts at %3)") + .arg(spy.count()) + .arg(spy.events.empty() ? -1 : + spy.events[0].frame) + .arg(n))); + } + + // The floor is each input's level: a quiet tone on both inputs, whose + // mixdown, their sum, reads 6 dB louder and above the floor, gives no + // pitch + void quiet_tone_on_both_inputs_gives_no_pitch() { + auto rec = makeRecording(2); + RealtimePitchTracker tracker(rec->id); + PitchCollector spy(&tracker); + tracker.start(); + + const int n = int(kRate / 2); + std::vector tone = + TestSignals::sine(306.0, kRate, n, peakAt(-63.0)); + rec->append({ tone, tone }); + settle(spy); + tracker.stop(); + + QVERIFY2(spy.count() == 0, + qPrintable(QString("%1 pitches").arg(spy.count()))); + } + + void stop_is_prompt() { + auto rec = makeRecording(); + RealtimePitchTracker tracker(rec->id); + tracker.start(); + rec->appendMono(TestSignals::sine(330.0, kRate, int(kRate) / 4)); + + QElapsedTimer timer; + timer.start(); + tracker.stop(); + QVERIFY2(timer.elapsed() < 200, + qPrintable(QString("stop() took %1 ms").arg(timer.elapsed()))); + QVERIFY(tracker.isFinished()); + } + + void no_events_after_stop() { + auto rec = makeRecording(); + RealtimePitchTracker tracker(rec->id); + PitchCollector spy(&tracker); + tracker.start(); + rec->appendMono(TestSignals::sine(330.0, kRate, int(kRate) / 2)); + settle(spy, expectedHops(rec->written)); + tracker.stop(); + QCoreApplication::processEvents(); + + auto countAtStop = spy.count(); + rec->appendMono(TestSignals::sine(330.0, kRate, int(kRate) / 2)); + QTest::qWait(100); + QCOMPARE(spy.count(), countAtStop); + } + + void model_released_midway() { + auto rec = makeRecording(); + RealtimePitchTracker tracker(rec->id); + PitchCollector spy(&tracker); + tracker.start(); + rec->appendMono(TestSignals::sine(330.0, kRate, int(kRate) / 2)); + settle(spy, expectedHops(rec->written)); + + // As when the document lets go of the model. We keep our own + // reference until the tracker has stopped: the tracker holds + // one per iteration, and if that were the last, the model (a + // QObject owning a timer) would be destroyed on its thread. + sv::ModelById::release(rec->id); + rec->id = {}; + + QTest::qWait(100); + QVERIFY(tracker.isRunning()); + tracker.stop(); + QVERIFY(tracker.isFinished()); + } + + void starts_before_data() { + auto rec = makeRecording(); + RealtimePitchTracker tracker(rec->id); + PitchCollector spy(&tracker); + tracker.start(); + QTest::qWait(100); + QCOMPARE(int(spy.count()), 0); + + rec->appendMono(TestSignals::sine(330.0, kRate, int(kRate) / 2)); + settle(spy, expectedHops(rec->written)); + tracker.stop(); + QVERIFY(spy.count() > 0); + } + + void stereo_signal_on_ch1() { + // A stereo interface with the microphone on its second input + auto rec = makeRecording(2); + RealtimePitchTracker tracker(rec->id); + PitchCollector spy(&tracker); + tracker.start(); + const int n = int(kRate) / 2; + rec->append({ std::vector(n, 0.f), + TestSignals::sine(330.0, kRate, n) }); + settle(spy, expectedHops(rec->written)); + tracker.stop(); + + QVERIFY(spy.count() > 0); + for (const auto &event : spy.events) { + double hz = event.hz; + QVERIFY2(std::abs(TestSignals::centsBetween(hz, 330.0)) < 10.0, + qPrintable(QString("%1 Hz").arg(hz))); + } + } + + void stereo_other_input_noisy() { + // As above, but the unused input is not silent: the mixdown + // carries its noise along with the voice + auto rec = makeRecording(2); + RealtimePitchTracker tracker(rec->id); + PitchCollector spy(&tracker); + tracker.start(); + const int n = int(kRate) / 2; + rec->append({ TestSignals::whiteNoise(n, 7, 0.02), + TestSignals::sine(330.0, kRate, n) }); + settle(spy, expectedHops(rec->written)); + tracker.stop(); + + QVERIFY(spy.count() > expectedHops(rec->written) / 2); + for (const auto &event : spy.events) { + double hz = event.hz; + QVERIFY2(std::abs(TestSignals::centsBetween(hz, 330.0)) < 20.0, + qPrintable(QString("%1 Hz").arg(hz))); + } + } + + // A tone of 220.5 Hz with its harmonics to 4 kHz (as the audio + // check's, without the vibrato), and 30 ms of its subharmonic under + // it, faded in and out. In the windows that hold the burst the dip + // at the period rises over YIN's threshold (to 0.25 or more) while + // the one at twice the period stays under (0.08 or less): 4 hops + // slip an octave low, and the hops either side are on the tone + // (0.11 or less), at every alignment of the burst with the hops. + // The slips are dropped, and every other hop's dot is there + void an_octave_slip_is_dropped() { + const double hz = 220.5; + const int n = int(kRate); + const int harmonics = int(4000.0 / hz); + std::vector signal(n); + double peak = 0.0; + std::vector x(n, 0.0); + for (int i = 0; i < n; ++i) { + double t = i / kRate; + for (int k = 1; k <= harmonics; ++k) { + x[i] += std::sin(2.0 * M_PI * hz * k * t + + M_PI * k * k / harmonics) / k; + } + peak = std::max(peak, std::fabs(x[i])); + } + const int burstStart = 22082, burstLength = 1323; + for (int i = 0; i < n; ++i) { + double v = 0.25 * x[i] / peak; + int b = i - burstStart; + if (b >= 0 && b < burstLength) { + double hann = 0.5 - 0.5 * std::cos + (2.0 * M_PI * b / (burstLength - 1)); + v += 0.1 * hann * std::sin(2.0 * M_PI * (hz / 2.0) * + b / kRate); + } + signal[i] = float(v); + } + + auto rec = makeRecording(); + RealtimePitchTracker tracker(rec->id); + PitchCollector spy(&tracker); + tracker.start(); + rec->appendMono(signal); + const int slipped = 4; + settle(spy, expectedHops(rec->written) - slipped); + tracker.stop(); + + sv::sv_frame_t widestGap = 0; + for (int i = 0; i < int(spy.events.size()); ++i) { + double cents = TestSignals::centsBetween(spy.events[i].hz, hz); + QVERIFY2(std::abs(cents) < 20.0, + qPrintable(QString("a dot at %1 Hz, frame %2") + .arg(spy.events[i].hz) + .arg(spy.events[i].frame))); + if (i > 0) { + widestGap = std::max(widestGap, spy.events[i].frame - + spy.events[i-1].frame); + } + } + QCOMPARE(int(spy.events.size()), + expectedHops(rec->written) - slipped); + QCOMPARE(widestGap, sv::sv_frame_t((slipped + 1) * kHop)); + } +}; + +#endif diff --git a/main/test/TestRealtimeYin.h b/main/test/TestRealtimeYin.h new file mode 100644 index 00000000..986599f2 --- /dev/null +++ b/main/test/TestRealtimeYin.h @@ -0,0 +1,326 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TEST_REALTIME_YIN_H +#define TEST_REALTIME_YIN_H + +// Tier 1: the pitch maths of RealtimePitchTracker, without the thread. + +#include "../LatencyCheck.h" +#include "../RealtimePitchTracker.h" + +#include "TestSignals.h" +#include "PyinReference.h" + +#include "bqfft/FFT.h" + +#include +#include + +#include +#include + +class TestRealtimeYin : public QObject +{ + Q_OBJECT + + static const int kWindow = RealtimePitchTracker::kWindowSize; + + static std::vector difference(const std::vector &buf) { + breakfastquay::FFT fft(int(buf.size())); + std::vector diff; + RealtimePitchTracker::yinDifferenceFFT(buf, diff, &fft); + return diff; + } + + static std::vector naiveDifference(const std::vector &buf) { + int half = int(buf.size() / 2); + std::vector diff(half, 0.0); + for (int tau = 1; tau < half; ++tau) { + for (int j = 0; j < half; ++j) { + double delta = double(buf[j]) - double(buf[j + tau]); + diff[tau] += delta * delta; + } + } + return diff; + } + + // One analysis window through the same steps, lag limits and + // range check as RealtimePitchTracker::run(). Returns Hz, or a + // negative value if nothing would have been emitted. + static double detect(const std::vector &buf, double sampleRate, + double minFreq = 60.0, double maxFreq = 1000.0, + double threshold = 0.15) { + std::vector diff = difference(buf); + RealtimePitchTracker::yinCMND(diff); + int minLag = std::max(1, int(std::floor(sampleRate / maxFreq))); + int maxLag = std::min(kWindow / 2 - 2, + int(std::ceil(sampleRate / minFreq))); + double lag = RealtimePitchTracker::yinFindPitch + (diff, minLag, maxLag, threshold); + if (lag <= 0.0) return -1.0; + double hz = sampleRate / lag; + if (hz < minFreq || hz > maxFreq) return -1.0; + return hz; + } + + static void compareDifference(const std::vector &actual, + const std::vector &expected, + double relativeTolerance) { + QCOMPARE(actual.size(), expected.size()); + // Relative to the largest value: near a period the difference + // function approaches zero and a per-value ratio means nothing + double scale = 0.0; + for (double e : expected) scale = std::max(scale, std::abs(e)); + QVERIFY(scale > 0.0); + for (int tau = 1; tau < int(expected.size()); ++tau) { + double err = std::abs(actual[tau] - expected[tau]) / scale; + if (err > relativeTolerance) { + QFAIL(qPrintable(QString("at lag %1: got %2, expected %3, " + "relative error %4") + .arg(tau).arg(actual[tau]) + .arg(expected[tau]).arg(err))); + } + } + } + +private slots: + void sine_accuracy_data() { + QTest::addColumn("hz"); + QTest::addColumn("sampleRate"); + for (double sr : { 44100.0, 48000.0 }) { + for (double hz : { 82.0, 110.0, 220.0, 440.0, 880.0 }) { + QTest::newRow(qPrintable(QString("%1 Hz at %2").arg(hz).arg(sr))) + << hz << sr; + } + } + } + + void sine_accuracy() { + QFETCH(double, hz); + QFETCH(double, sampleRate); + double detected = detect(TestSignals::sine(hz, sampleRate, kWindow), + sampleRate); + QVERIFY2(detected > 0.0, "no pitch detected"); + double cents = TestSignals::centsBetween(detected, hz); + QVERIFY2(std::abs(cents) < 5.0, + qPrintable(QString("detected %1 Hz, %2 cents out") + .arg(detected).arg(cents))); + } + + void harmonic_tone() { + double detected = detect(TestSignals::sawtooth(196.0, 44100.0, kWindow), + 44100.0); + QVERIFY2(detected > 0.0, "no pitch detected"); + double cents = TestSignals::centsBetween(detected, 196.0); + // An octave error would be 1200 cents out + QVERIFY2(std::abs(cents) < 10.0, + qPrintable(QString("detected %1 Hz, %2 cents out") + .arg(detected).arg(cents))); + } + + // Calibrate Audio's tones, as the reference has them and with their + // fundamental taken out, as a phone's speaker or an earbud held to + // the microphone loses it: the pitch all the same, from the window + // in the middle of each tone, at the reference's rate and a phone's + void reference_tones_with_and_without_their_fundamental_data() { + QTest::addColumn("sampleRate"); + QTest::addColumn("event"); + QTest::addColumn("withoutFundamental"); + for (double sr : { 44100.0, 48000.0 }) { + for (int event = 0; event < 4; ++event) { + for (bool without : { false, true }) { + QTest::newRow(qPrintable + (QString("event %1 at %2%3").arg(event) + .arg(sr) + .arg(without ? ", no fundamental" : ""))) + << sr << event << without; + } + } + } + } + + void reference_tones_with_and_without_their_fundamental() { + QFETCH(double, sampleRate); + QFETCH(int, event); + QFETCH(bool, withoutFundamental); + + const LatencyCheck::Layout layout = + LatencyCheck::calibrationLayout(sampleRate); + const LatencyCheck::Event &e = layout.events[event]; + std::vector x = LatencyCheck::generate(layout); + if (withoutFundamental) { + x = TestSignals::withoutFundamental(x, sampleRate, e.toneHz); + } + + const sv::sv_frame_t middle = e.toneStart + e.toneLength / 2; + std::vector window(x.begin() + (middle - kWindow / 2), + x.begin() + (middle + kWindow / 2)); + auto rms = [](const std::vector &v) { + double sum = 0.0; + for (float s : v) sum += double(s) * s; + return std::sqrt(sum / double(v.size())); + }; + if (withoutFundamental) { + // What is left is the harmonics, at more than half the + // tone's level: something a speaker without bass still plays + const std::vector generated = LatencyCheck::generate(layout); + const std::vector whole + (generated.begin() + (middle - kWindow / 2), + generated.begin() + (middle + kWindow / 2)); + QVERIFY2(rms(window) > 0.5 * rms(whole), + qPrintable(QString("%1 of %2").arg(rms(window)) + .arg(rms(whole)))); + + // Nothing of the fundamental is left: a pure tone at it, or + // as far off as the vibrato swings, taken out the same way, + // is 60 dB down by the time the window begins + for (double cents : { -LatencyCheck::kVibratoCents, 0.0, + LatencyCheck::kVibratoCents }) { + const std::vector pure = TestSignals::sine + (e.toneHz * std::pow(2.0, cents / 1200.0), sampleRate, + int(middle - e.toneStart)); + const std::vector left = TestSignals::withoutFundamental + (pure, sampleRate, e.toneHz); + double peak = 0.0; + for (size_t i = left.size() - kWindow / 2; i < left.size(); + ++i) { + peak = std::max(peak, double(std::fabs(left[i]))); + } + QVERIFY2(peak < 0.5e-3, + qPrintable(QString("%1 cents off: %2") + .arg(cents).arg(peak))); + } + } + + double detected = detect(window, sampleRate); + QVERIFY2(detected > 0.0, "no pitch detected"); + double cents = TestSignals::centsBetween(detected, e.toneHz); + QVERIFY2(std::abs(cents) < 10.0, + qPrintable(QString("detected %1 Hz, %2 cents from %3") + .arg(detected).arg(cents).arg(e.toneHz))); + } + + void silence() { + std::vector zeros(kWindow, 0.f); + QVERIFY(detect(zeros, 44100.0) < 0.0); + } + + void noise() { + QVERIFY(detect(TestSignals::whiteNoise(kWindow, 12345), 44100.0) < 0.0); + } + + void out_of_range_low() { + double detected = detect(TestSignals::sine(40.0, 44100.0, kWindow), + 44100.0); + QVERIFY2(detected < 0.0, + qPrintable(QString("detected %1 Hz").arg(detected))); + } + + void out_of_range_high() { + double detected = detect(TestSignals::sine(2000.0, 44100.0, kWindow), + 44100.0); + QVERIFY2(detected < 0.0, + qPrintable(QString("detected %1 Hz").arg(detected))); + } + + void dc_offset() { + double detected = detect + (TestSignals::sine(220.0, 44100.0, kWindow, 0.5, 0.3), 44100.0); + QVERIFY2(detected > 0.0, "no pitch detected"); + QVERIFY(std::abs(TestSignals::centsBetween(detected, 220.0)) < 10.0); + } + + void low_level() { + // -50 dBFS: guards the precision of the single-precision FFT + double amplitude = std::pow(10.0, -50.0 / 20.0); + double detected = detect + (TestSignals::sine(220.0, 44100.0, kWindow, amplitude), 44100.0); + QVERIFY2(detected > 0.0, "no pitch detected"); + QVERIFY(std::abs(TestSignals::centsBetween(detected, 220.0)) < 10.0); + } + + void window_level() { + // A sine's RMS is its peak less 3 dB + const double peak = std::pow(10.0, -40.0 / 20.0); + const double rms = -40.0 - 10.0 * std::log10(2.0); + std::vector one = TestSignals::sine(220.0, 44100.0, kWindow, peak); + double level = RealtimePitchTracker::level(one.data(), kWindow, 1); + QVERIFY2(std::abs(level - rms) < 0.1, + qPrintable(QString("%1 dBFS").arg(level))); + + // Two inputs carrying it mix down to their sum, twice as large: + // the level is still each input's + std::vector both(one); + for (float &v : both) v *= 2.f; + level = RealtimePitchTracker::level(both.data(), kWindow, 2); + QVERIFY2(std::abs(level - rms) < 0.1, + qPrintable(QString("%1 dBFS").arg(level))); + + const std::vector zeros(kWindow, 0.f); + QCOMPARE(RealtimePitchTracker::level(zeros.data(), kWindow, 1), + -200.0); + } + + void diff_matches_pyin_data() { + QTest::addColumn("useNoise"); + QTest::newRow("noise") << true; + QTest::newRow("sine") << false; + } + + void diff_matches_pyin() { + QFETCH(bool, useNoise); + std::vector buf = useNoise ? + TestSignals::whiteNoise(kWindow, 999) : + TestSignals::sine(330.0, 44100.0, kWindow); + std::vector in(buf.begin(), buf.end()); + compareDifference(difference(buf), pyinFastDifference(in), 1e-4); + } + + void diff_matches_naive() { + // Independent of pyin: the textbook O(n^2) sum + // d(tau) = sum_{j buf = TestSignals::whiteNoise(kWindow, 4242); + std::vector expected = naiveDifference(buf); + + // ...except for one known deviation, inherited from pYIN's + // YinUtil::fastDifference. The running power term for lag tau + // should cover x[tau] .. x[tau+half-1], but the recursion + // brings in x[tau+half] at each step rather than + // x[tau+half-1], so the window it sums skips x[half] and takes + // x[half+tau] instead. Harmless for pitch (one sample's energy + // in a thousand), but it has to be allowed for here, and it is + // kept in the implementation so as to stay in step with pYIN. + int half = kWindow / 2; + for (int tau = 1; tau < half; ++tau) { + expected[tau] += double(buf[half + tau]) * double(buf[half + tau]) + - double(buf[half]) * double(buf[half]); + } + + compareDifference(difference(buf), expected, 1e-4); + } + + void cmnd_properties() { + std::vector diff = + difference(TestSignals::sine(220.0, 44100.0, kWindow)); + RealtimePitchTracker::yinCMND(diff); + QCOMPARE(diff[0], 1.0); + for (double d : diff) QVERIFY(d >= 0.0); + + std::vector zeros(kWindow / 2, 0.0); + RealtimePitchTracker::yinCMND(zeros); + for (double d : zeros) QCOMPARE(d, 1.0); + } +}; + +#endif diff --git a/main/test/TestRecordWorkflow.h b/main/test/TestRecordWorkflow.h new file mode 100644 index 00000000..effafaa5 --- /dev/null +++ b/main/test/TestRecordWorkflow.h @@ -0,0 +1,9773 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TEST_RECORD_WORKFLOW_H +#define TEST_RECORD_WORKFLOW_H + +// Tier 5: the real MainWindow, recording from a fake audio device +// (FakeAudioIO.h) that runs in real time. Takes are about a second +// each, so this suite is the slow one. +// +// A take that works end to end shows no dialog, and a dialog would +// block the test for ever. A watchdog timer dismisses any modal +// dialog and records it; each test then fails in cleanup(). + +#include "TestSignals.h" +#include "TestMainWindow.h" + +#include "../MainWindow.h" +#include "../Analyser.h" +#include "../CoverageStrip.h" +#include "../Lyrics.h" +#include "../LyricsEditor.h" +#include "../LyricsTrack.h" +#include "../LyricsTtml.h" +#include "../SingingTakes.h" +#include "../TakeLayers.h" +#include "../TakesFile.h" + +#include "version.h" + +#include "framework/Document.h" +#include "view/Pane.h" +#include "view/PaneStack.h" +#include "view/ViewManager.h" +#include "layer/Layer.h" +#include "layer/ColourDatabase.h" +#include "layer/SingleColourLayer.h" +#include "layer/TimeValueLayer.h" +#include "layer/FlexiNoteLayer.h" +#include "layer/RegionLayer.h" +#include "layer/WaveformLayer.h" +#include "audio/AudioCallbackPlaySource.h" +#include "audio/AudioCallbackRecordTarget.h" +#include "data/model/WritableWaveFileModel.h" +#include "data/model/SparseTimeValueModel.h" +#include "data/model/NoteModel.h" +#include "data/model/RegionModel.h" +#include "data/fileio/BZipFileDevice.h" +#include "data/fileio/FileSource.h" +#include "data/fileio/WavFileReader.h" +#include "data/fileio/WavFileWriter.h" +#include "base/Command.h" +#include "base/PlayParameters.h" +#include "base/RecordDirectory.h" +#include "transform/ModelTransformerFactory.h" +#include "widgets/CommandHistory.h" +#include "widgets/InteractiveFileFinder.h" +#include "widgets/LevelPanToolButton.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +class TestRecordWorkflow : public QObject +{ + Q_OBJECT + + static constexpr double rate = 44100.0; + static constexpr int hop = 256; // as set in Analyser::addAnalyses() + + // Whole numbers of samples per period: see TestSingingAnalysis.h + static constexpr double lowHz = 220.5; + static constexpr double highHz = 294.0; + + // A third tone for the audio a swap puts in, no harmonic of either of + // the other two, so that what is in the output says which file it is + static constexpr double swapHz = 490.0; + + QTemporaryDir m_dir; + int m_fileCounter = 0; + TestMainWindow *m_window = nullptr; + QTimer m_watchdog; + QStringList m_dialogs; + + static std::vector tone(double hz, double seconds) { + return TestSignals::sawtooth(hz, rate, int(seconds * rate), 0.5f); + } + + // A low note then a high one. The step between them is the event + // whose position the latency test compares across the two tracks + static std::vector melody(double secondsPerNote) { + auto m = tone(lowHz, secondsPerNote); + auto high = tone(highHz, secondsPerNote); + m.insert(m.end(), high.begin(), high.end()); + return m; + } + + QString writeWav(const std::vector &data) { + QString path = m_dir.filePath + (QString("audio-%1.wav").arg(++m_fileCounter)); + sv::WavFileWriter writer(path, rate, 1, + sv::WavFileWriter::WriteToTarget); + const float *ptr = data.data(); + if (!writer.isOK() || + !writer.writeSamples(&ptr, sv::sv_frame_t(data.size())) || + !writer.close()) { + return {}; + } + return path; + } + + void makeWindow(FakeAudioIO::Config config, bool installDevice = true) { + delete m_window; + m_window = new TestMainWindow(config, installDevice); + } + + // Complete, and with the transform threads gone as well. A model + // released while a transform thread still holds it is destroyed + // on that thread, which is review finding 15; no test but the one + // about that finding should depend on it + static bool analysed(Analyser *a) { + return a && a->getLayer(Analyser::PitchTrack) && + a->getLayer(Analyser::Notes) && + a->getInitialAnalysisCompletion() >= 100 && + !a->isAnalysingRange() && + !sv::ModelTransformerFactory::getInstance() + ->haveRunningTransformers(); + } + + // Open path as the reference, replacing whatever is loaded, and + // wait for pYIN + void openReference(QString path) { + QVERIFY(!path.isEmpty()); + m_window->discardModifications(); + QCOMPARE(m_window->openPath(path, MainWindow::ReplaceSession), + MainWindow::FileOpenSucceeded); + QTRY_VERIFY_WITH_TIMEOUT(analysed(m_window->analyser()), 30000); + } + + void startTake() { + QVERIFY(!m_window->recordTarget()->isRecording()); + m_window->doRecord(); + QVERIFY(m_window->recordTarget()->isRecording()); + } + + // Stop, then wait for pYIN on the take + void stopTake() { + QVERIFY(m_window->recordTarget()->isRecording()); + m_window->doRecord(); + QVERIFY(!m_window->recordTarget()->isRecording()); + QTRY_VERIFY_WITH_TIMEOUT(analysed(m_window->analyser2()), 30000); + } + + // For a test that stops a take as soon as it has looked at something: + // stopped at once, under load the take can have nothing in it, and + // then there is no take for stopTake() to wait for + void waitForSomethingRecorded() { + QTRY_VERIFY_WITH_TIMEOUT + (m_window->recordTarget()->getRecordDuration() > rate / 2, 5000); + } + + void take(int ms) { + startTake(); + if (QTest::currentTestFailed()) return; + QTest::qWait(ms); + stopTake(); + } + + // A round trip as the audio check would have kept it for the fake + // device (the default devices, the Preferences naming none) at 44.1 + // kHz, measured while the device reported the given latencies, in + // frames. cleanup() forgets it + static void storeRoundTrip(int roundTrip, int reportedOutput, + int reportedInput) { + LatencyCalibration::Figure figure; + figure.roundTrip = roundTrip / rate; + figure.date = QDateTime::currentDateTimeUtc(); + figure.reportedOutput = reportedOutput / rate; + figure.reportedInput = reportedInput / rate; + QSettings settings; + LatencyCalibration::store + (settings, LatencyCalibration::currentKey(settings, rate), figure); + } + + static sv::EventVector pitchEvents(sv::Layer *layer) { + if (!layer) return {}; + auto model = sv::ModelById::getAs + (layer->getModel()); + if (!model) return {}; + return model->getAllEvents(); + } + + static sv::EventVector pitchEvents(Analyser *a) { + return a ? pitchEvents(a->getLayer(Analyser::PitchTrack)) + : sv::EventVector(); + } + + static sv::EventVector noteEvents(sv::Layer *layer) { + if (!layer) return {}; + auto model = sv::ModelById::getAs(layer->getModel()); + if (!model) return {}; + return model->getAllEvents(); + } + + static sv::EventVector eventsBetween(const sv::EventVector &events, + sv::sv_frame_t from, + sv::sv_frame_t to) { + sv::EventVector result; + for (const auto &e : events) { + if (e.getFrame() >= from && e.getFrame() < to) { + result.push_back(e); + } + } + return result; + } + + // The audio of the take: one file, starting at frame 0 of the + // reference's timeline, silent where nothing has been recorded + std::shared_ptr takeAudio() { + Analyser *a2 = m_window->analyser2(); + if (!a2) return nullptr; + return sv::ModelById::getAs(a2->getMainModelId()); + } + + // What is in the take's audio file itself over [from, to), rather + // than what a model makes of it + double takeAudioRms(sv::sv_frame_t from, sv::sv_frame_t to) { + QString path = m_window->takes()->getAudioPath(); + if (path.isEmpty() || to <= from) return -1.0; + sv::WavFileReader reader { sv::FileSource(path) }; + if (!reader.isOK()) return -1.0; + auto data = reader.getInterleavedFrames(from, to - from); + if (data.empty()) return -1.0; + double sum = 0.0; + for (float v : data) sum += double(v) * double(v); + return std::sqrt(sum / double(data.size())); + } + + static double medianHz(const sv::EventVector &events) { + std::vector values; + for (const auto &e : events) values.push_back(e.getValue()); + if (values.empty()) return 0.0; + std::sort(values.begin(), values.end()); + return values[values.size() / 2]; + } + + // Frame of the first event above the midpoint of the two notes, + // or -1 + static sv::sv_frame_t stepFrame(const sv::EventVector &events) { + double mid = std::sqrt(lowHz * highHz); + for (const auto &e : events) { + if (e.getValue() > mid) return e.getFrame(); + } + return -1; + } + + static int colourOf(sv::Layer *layer) { + auto scl = qobject_cast(layer); + return scl ? scl->getBaseColour() : -1; + } + + static int colourNamed(QString name) { + return sv::ColourDatabase::getInstance()->getColourIndex(name); + } + + // Amplitude of the hz component of data[from, from+n) + static double amplitudeAt(const std::vector &data, + size_t from, size_t n, double hz) { + if (from + n > data.size()) return -1.0; + double re = 0.0, im = 0.0; + for (size_t i = 0; i < n; ++i) { + double phase = 2.0 * TestSignals::kPi * hz * double(i) / rate; + re += data[from + i] * std::cos(phase); + im += data[from + i] * std::sin(phase); + } + return 2.0 * std::sqrt(re * re + im * im) / double(n); + } + + // Every model the play source holds is alive and in use by a layer + // (review finding 6) + void verifyPlaySourceClean() { + for (sv::ModelId id : m_window->playSource()->getModels()) { + QVERIFY2(sv::ModelById::get(id), + qPrintable(QString("the play source holds model %1, " + "which no longer exists") + .arg(id.untyped))); + QVERIFY2(layersOnModel(id) > 0, + qPrintable(QString("the play source holds model %1, " + "which no layer uses") + .arg(id.untyped))); + } + } + + int layersOnModel(sv::ModelId id) { + int n = 0; + for (sv::Layer *layer : m_window->document()->getLayers()) { + if (layer->getModel() == id) ++n; + } + return n; + } + + int backgroundMusicLayersInDocument() { + int n = 0; + for (sv::Layer *layer : m_window->document()->getLayers()) { + if (layer->objectName() == "Background Music") ++n; + } + return n; + } + + // One for the reference and one for the take: a second pair for the + // take means its layers were analysed again instead of being claimed + int noteLayersInPane0() { + int n = 0; + sv::Pane *pane = m_window->paneStack()->getPane(0); + if (!pane) return 0; + for (int i = 0; i < pane->getLayerCount(); ++i) { + if (qobject_cast(pane->getLayer(i))) ++n; + } + return n; + } + + bool paneHasLayer(int paneIndex, sv::Layer *layer) { + sv::Pane *pane = m_window->paneStack()->getPane(paneIndex); + if (!pane) return false; + for (int i = 0; i < pane->getLayerCount(); ++i) { + if (pane->getLayer(i) == layer) return true; + } + return false; + } + + bool documentHasLayer(sv::Layer *layer) { + for (sv::Layer *l : m_window->document()->getLayers()) { + if (l == layer) return true; + } + return false; + } + + // Save a session holding only the reference, and open it again. + // Only a session load sets MainWindowBase::m_timeRulerLayer, which + // is what puts the shared ruler into the extra panes + void reopenAsSession() { + openReference(writeWav(tone(lowHz, 1.0))); + if (QTest::currentTestFailed()) return; + QString session = m_dir.filePath + (QString("session-%1.ton").arg(++m_fileCounter)); + QVERIFY(m_window->saveSessionFile(session)); + m_window->doCloseSession(); + openReference(session); + if (QTest::currentTestFailed()) return; + QVERIFY2(m_window->timeRuler(), + "the session load did not find the time ruler"); + QVERIFY(paneHasLayer(1, m_window->timeRuler())); + } + + // Open a session that has just been saved, with the reference analysed + // (claimed, in fact: a restored session has its layers) and the takes + // of the session read from the file + void reopenSession(QString path) { + m_window->doCloseSession(); + m_window->discardModifications(); + QCOMPARE(m_window->openPath(path, MainWindow::ReplaceSession), + MainWindow::FileOpenSucceeded); + QTRY_VERIFY_WITH_TIMEOUT(analysed(m_window->analyser()), 30000); + } + + // Take the element out of a saved session file, making it what + // a .ton written before phase 7b is: the same document with no takes in + // it. The file is bzip2, as the session reader and writer leave it + // "" on success, else what went wrong + QString removeTakesElement(QString path) { + QByteArray document; + { + sv::BZipFileDevice file(path); + if (!file.open(QIODevice::ReadOnly)) { + return "could not read " + path + ": " + file.errorString(); + } + document = file.readAll(); + file.close(); + } + if (document.isEmpty()) return "read nothing from " + path; + + int from = document.indexOf(""); + if (from < 0 || to < from) return "no takes element in " + path; + document.remove(from, to + int(strlen("")) - from); + + sv::BZipFileDevice out(path); + if (!out.open(QIODevice::WriteOnly)) { + return "could not write " + path + ": " + out.errorString(); + } + qint64 written = out.write(document); + out.close(); + if (written != document.size()) { + return QString("wrote %1 of %2 bytes to ").arg(written) + .arg(document.size()) + path; + } + return ""; + } + + // A file's bytes, as they are on disk + static QByteArray fileContents(QString path) { + QFile f(path); + return f.open(QIODevice::ReadOnly) ? f.readAll() : QByteArray(); + } + + // Put text into a saved session file before the first occurrence of + // another, as removeTakesElement() takes some out. "" on success, + // else what went wrong + QString insertIntoSession(QString path, QByteArray before, + QByteArray text) { + QByteArray document; + { + sv::BZipFileDevice file(path); + if (!file.open(QIODevice::ReadOnly)) { + return "could not read " + path + ": " + file.errorString(); + } + document = file.readAll(); + file.close(); + } + int at = document.indexOf(before); + if (at < 0) return "no " + QString(before) + " in " + path; + document.insert(at, text); + + sv::BZipFileDevice out(path); + if (!out.open(QIODevice::WriteOnly)) { + return "could not write " + path + ": " + out.errorString(); + } + qint64 written = out.write(document); + out.close(); + if (written != document.size()) { + return QString("wrote %1 of %2 bytes to ").arg(written) + .arg(document.size()) + path; + } + return ""; + } + + // The dialogs the watchdog has dismissed so far, taken off the list so + // that cleanup() does not fail the test with them: for a test that + // expects one + QStringList takeDialogs() { + QStringList dialogs = m_dialogs; + m_dialogs.clear(); + return dialogs; + } + + // The dialogs seen so far whose text holds this, taken off the list + // along with the ones before them: for a test that expects one of its + // own among the dialogs another part of the application showed first + QStringList dialogsMatching(QString text) { + QStringList matching; + for (const QString &dialog : takeDialogs()) { + if (dialog.contains(text)) matching.push_back(dialog); + } + return matching; + } + + // As dialogsMatching(), for message boxes with this title as well. + // Not on macOS, which shows no title on a message box, and Qt keeps + // none there: the text alone must tell the box apart + QStringList messagesMatching(QString title, QString text) { + QStringList matching; + for (const QString &dialog : dialogsMatching(text)) { +#ifdef Q_OS_MACOS + Q_UNUSED(title); + matching.push_back(dialog); +#else + if (dialog.startsWith(title + ": ")) matching.push_back(dialog); +#endif + } + return matching; + } + + // Audio in the session besides the reference: one model per take that + // is on show, and nothing left over from a session load + int audioModelsBesidesReference() { + int n = 0; + for (sv::ModelId id : m_window->document()->getModels()) { + if (id == m_window->mainModelId()) continue; + if (sv::ModelById::isa(id)) ++n; + } + return n; + } + + int waveformLayersInPane0() { + int n = 0; + sv::Pane *pane = m_window->paneStack()->getPane(0); + if (!pane) return 0; + for (int i = 0; i < pane->getLayerCount(); ++i) { + if (qobject_cast(pane->getLayer(i))) ++n; + } + return n; + } + + // The same events after a session round trip. Frames, durations and + // labels come back exactly; a value goes through QString::arg(float) + // in Event::toXml(), which keeps six significant figures + void verifyEventsSurvived(const sv::EventVector &before, + const sv::EventVector &after, + const char *what) { + QVERIFY2(before.size() == after.size(), + qPrintable(QString("%1: %2 events before, %3 after") + .arg(what).arg(before.size()).arg(after.size()))); + for (size_t i = 0; i < before.size(); ++i) { + QVERIFY2(before[i].getFrame() == after[i].getFrame(), + qPrintable(QString("%1: event %2 was at frame %3, is at %4") + .arg(what).arg(i) + .arg(before[i].getFrame()) + .arg(after[i].getFrame()))); + QCOMPARE(after[i].getDuration(), before[i].getDuration()); + double value = before[i].getValue(); + QVERIFY2(std::fabs(after[i].getValue() - value) <= + 1e-5 * std::fabs(value) + 1e-6, + qPrintable(QString("%1: event %2 had the value %3, has %4") + .arg(what).arg(i).arg(value) + .arg(after[i].getValue()))); + } + } + + void verifyRulerIntact() { + QVERIFY(m_window->timeRuler()); + QVERIFY2(documentHasLayer(m_window->timeRuler()), + "the shared time ruler was deleted from the document"); + QVERIFY2(paneHasLayer(1, m_window->timeRuler()), + "the shared time ruler is no longer in the ruler pane"); + } + + // The coverage strip: the layer in pane 0 whose regions are the + // ranges of the take that hold recorded singing + + sv::RegionLayer *stripLayer() { + return m_window->coverageStrip()->getLayer(); + } + + // The strips of the active take, and of every take: each take of the + // session has one, and only the active take's is on show + int stripLayersInPane0() { + return stripLayersNamed + (CoverageStrip::layerName(m_window->takes()->getActiveName())); + } + + int allStripLayersInPane0() { + return stripLayersNamed(""); + } + + int stripLayersNamed(QString name) { + int n = 0; + sv::Pane *pane = m_window->paneStack()->getPane(0); + if (!pane) return 0; + for (int i = 0; i < pane->getLayerCount(); ++i) { + QString takeName; + TakeLayers::Kind kind; + if (!TakeLayers::parse(pane->getLayer(i)->objectName(), + takeName, kind)) continue; + if (kind != TakeLayers::Coverage) continue; + if (name != "" && pane->getLayer(i)->objectName() != name) continue; + ++n; + } + return n; + } + + // The layers of the take of this name, found the way MainWindow and + // (from 7b) a session restore find them: by their object names + TakeLayers::Found takeLayers(QString name) { + return TakeLayers::find(m_window->paneStack()->getPane(0), name); + } + + // What a take that is not the active one has to be: hidden, silent and + // out of the play source, so that it is neither seen nor heard and + // cannot hold playback open past the end of the active take's audio + void verifyTakeIsPutAway(QString name) { + sv::Pane *pane = m_window->paneStack()->getPane(0); + QVERIFY(pane); + TakeLayers::Found found = takeLayers(name); + auto models = m_window->playSource()->getModels(); + for (sv::Layer *layer : { static_cast(found.pitch), + static_cast(found.notes), + static_cast(found.coverage) }) { + if (!layer) continue; + QVERIFY2(layer->isLayerDormant(pane), + qPrintable(QString("%1 is not hidden") + .arg(layer->objectName()))); + auto params = layer->getPlayParameters(); + QVERIFY2(!params || !params->isPlayAudible(), + qPrintable(QString("%1 can be heard") + .arg(layer->objectName()))); + QVERIFY2(!models.count(layer->getModel()), + qPrintable(QString("%1 is still in the play source") + .arg(layer->objectName()))); + auto model = sv::ModelById::get(layer->getModel()); + QVERIFY2(model && model->getSourceModel().isNone(), + qPrintable(QString("%1 still has a source model, so an " + "analyser could claim it") + .arg(layer->objectName()))); + } + } + + // What the strip shows, from the regions of its model rather than + // from the object that keeps it + sv::EventVector stripEvents() { + sv::RegionLayer *layer = stripLayer(); + if (!layer) return {}; + auto model = sv::ModelById::getAs(layer->getModel()); + if (!model) return {}; + return model->getAllEvents(); + } + + // The topmost note layer of pane 0: what Pane::getTopFlexiNoteLayer() + // finds, and what NoteEditMode -- the only editing mode Tony sets for + // that pane -- acts on + sv::Layer *topNoteLayerInPane0() { + sv::Pane *pane = m_window->paneStack()->getPane(0); + if (!pane) return nullptr; + for (int i = pane->getLayerCount() - 1; i >= 0; --i) { + if (qobject_cast(pane->getLayer(i))) { + return pane->getLayer(i); + } + } + return nullptr; + } + + // One strip, its regions the take's coverage, and out of reach of the + // editing tools: not the pane's selected layer, and the layer the + // note tool acts on is still the take's notes + void verifyStripMatchesTake() { + QCOMPARE(stripLayersInPane0(), 1); + QVERIFY(stripLayer()); + QCOMPARE(stripEvents(), + m_window->takes()->getCoverage().toEvents()); + + sv::Pane *pane = m_window->paneStack()->getPane(0); + QVERIFY(pane && pane->getLayerCount() > 0); + QVERIFY2(pane->getSelectedLayer() != stripLayer(), + "the coverage strip is the pane's selected layer"); + QVERIFY(m_window->analyser2()); + QCOMPARE(topNoteLayerInPane0(), + m_window->analyser2()->getLayer(Analyser::Notes)); + } + + // The take has its sound: there is audio under it, and the file the + // take names holds the singing + void verifyTakeHasSound() { + QVERIFY(m_window->takes()->haveTake()); + QVERIFY(takeAudio()); + Coverage::Ranges ranges = m_window->takes()->getCoverage().getRanges(); + QVERIFY(!ranges.empty()); + QVERIFY2(takeAudioRms(ranges[0].start + 1000, ranges[0].end - 1000) > + 0.01, "the file the take names holds no sound"); + } + + // Undo and redo, and what they say they did. CommandHistory has no + // accessor for the top of its stack, and the name of the command it + // unexecutes is the same thing; "" means there was nothing to undo + QString undoOnce() { + QString name; + auto *history = sv::CommandHistory::getInstance(); + auto conn = connect(history, &sv::CommandHistory::commandUnexecuted, + this, [&name](sv::Command *c) { + if (c) name = c->getName(); + }); + history->undo(); + disconnect(conn); + return name; + } + + QString redoOnce() { + QString name; + auto *history = sv::CommandHistory::getInstance(); + auto conn = connect(history, + qOverload + (&sv::CommandHistory::commandExecuted), + this, [&name](sv::Command *c) { + if (c) name = c->getName(); + }); + history->redo(); + disconnect(conn); + return name; + } + + // Everything about the singing of a take that an undo or a redo has + // to restore exactly + struct TakeSnapshot { + QString path; + Coverage::Ranges coverage; + sv::EventVector strip; + sv::EventVector pitch; + sv::EventVector notes; + sv::sv_frame_t frames = -1; + }; + + TakeSnapshot snapshotTake() { + TakeSnapshot s; + s.path = m_window->takes()->getAudioPath(); + s.coverage = m_window->takes()->getCoverage().getRanges(); + s.strip = stripEvents(); + Analyser *a2 = m_window->analyser2(); + s.pitch = pitchEvents(a2); + s.notes = a2 ? noteEvents(a2->getLayer(Analyser::Notes)) + : sv::EventVector(); + // Waited for, as verifyTakeMatches() waits: the model of a file + // just opened (as after an erase) says 0 frames until it has read + // the file, and under load that can outlast the call + if (auto audio = takeAudio()) { + QElapsedTimer waited; + waited.start(); + while (!audio->isReady() && waited.elapsed() < 30000) { + QTest::qWait(10); + } + s.frames = audio->getFrameCount(); + } + return s; + } + + // The same take again, down to every pitch event and note. A model + // of a file that has just been opened takes a moment to say how long + // it is, so the frame count is the one thing worth waiting for + void verifyTakeMatches(const TakeSnapshot &s) { + QCOMPARE(m_window->takes()->getAudioPath(), s.path); + QCOMPARE(m_window->takes()->getCoverage().getRanges(), s.coverage); + QCOMPARE(stripEvents(), s.strip); + Analyser *a2 = m_window->analyser2(); + QCOMPARE(pitchEvents(a2), s.pitch); + QCOMPARE(a2 ? noteEvents(a2->getLayer(Analyser::Notes)) + : sv::EventVector(), s.notes); + if (s.frames >= 0) { + QTRY_VERIFY(takeAudio() && + takeAudio()->getFrameCount() == s.frames); + } + } + + int alternateLayersInDocument() { + int n = 0, octaves = 0; + for (sv::Layer *layer : m_window->document()->getLayers()) { + if (AlternatePitchTrack::octavesFromLayerName + (layer->objectName(), octaves)) ++n; + } + return n; + } + + // True once the alternate track is the reference's, note for note + bool alternateMatchesReference() { + auto alt = pitchEvents(m_window->alternatePitch()->getLayer()); + auto ref = pitchEvents(m_window->analyser()); + int octaves = m_window->alternatePitch()->getOctaves(); + if (ref.empty() || alt.size() != ref.size()) return false; + for (size_t i = 0; i < ref.size(); ++i) { + if (alt[i].getFrame() != ref[i].getFrame()) return false; + double want = AlternatePitchTrack::shifted + (ref[i].getValue(), octaves); + if (std::fabs(alt[i].getValue() - want) > 1e-3 * want) { + return false; + } + } + return true; + } + + // The timed lyrics. The layers are counted by the name a session + // knows the lyrics by, spelled out here: it is part of the file format + + int lyricsLayersInDocument() { + int n = 0; + for (sv::Layer *layer : m_window->document()->getLayers()) { + if (layer->objectName() == "Lyrics") ++n; + } + return n; + } + + int lyricsLayersInPane0() { + int n = 0; + sv::Pane *pane = m_window->paneStack()->getPane(0); + if (!pane) return 0; + for (int i = 0; i < pane->getLayerCount(); ++i) { + if (pane->getLayer(i)->objectName() == "Lyrics") ++n; + } + return n; + } + + // Found by name, as a session load finds it, and not from LyricsTrack: + // the point is often whether that still has the layer the pane shows + sv::Layer *lyricsLayerInPane0() { + sv::Pane *pane = m_window->paneStack()->getPane(0); + if (!pane) return nullptr; + for (int i = 0; i < pane->getLayerCount(); ++i) { + if (pane->getLayer(i)->objectName() == "Lyrics") { + return pane->getLayer(i); + } + } + return nullptr; + } + + sv::EventVector lyricsEvents() { + sv::RegionLayer *layer = m_window->lyrics()->getLayer(); + if (!layer) return {}; + auto model = sv::ModelById::getAs(layer->getModel()); + return model ? model->getAllEvents() : sv::EventVector(); + } + + // The lyrics are the very layer and model they were, with the same + // words, on show, under the top layer and out of the play source. The + // model id is what proves the layer is the same one: a model id is + // never used twice, unlike the address of a layer + void verifyLyricsUntouched(sv::Layer *layer, sv::ModelId model, + const sv::EventVector &events, QString when) { + sv::Pane *pane = m_window->paneStack()->getPane(0); + QVERIFY(pane); + QVERIFY2(lyricsLayersInDocument() == 1 && lyricsLayersInPane0() == 1, + qPrintable(when + ": not one lyrics layer")); + sv::Layer *found = lyricsLayerInPane0(); + QVERIFY2(found && found == layer && found->getModel() == model, + qPrintable(when + ": the lyrics layer was replaced")); + QVERIFY2(m_window->lyrics()->getLayer() == found, + qPrintable(when + ": LyricsTrack has let go of the layer")); + QVERIFY2(lyricsEvents() == events, + qPrintable(when + ": the words have changed")); + QVERIFY2(!found->isLayerDormant(pane) && m_window->lyrics()->isVisible(), + qPrintable(when + ": the lyrics are hidden")); + QVERIFY2(pane->getTopLayer() != found, + qPrintable(when + ": the lyrics are the pane's top layer")); + QVERIFY2(m_window->playSource()->getModels().count(model) == 0, + qPrintable(when + ": the lyrics are in the play source")); + + // and the waveforms under them faded, the take's as well: a new + // take, a switch and a session load each make the take's analyser + // and its waveform again + QString colour = waveformColour(m_window->analyser()); + QVERIFY2(colour == "Pale Grey", + qPrintable(when + ": the reference's waveform is " + colour)); + Analyser *a2 = m_window->analyser2(); + if (a2 && a2->getLayer(Analyser::Audio)) { + colour = waveformColour(a2); + QVERIFY2(colour == "Pale Grey", + qPrintable(when + ": the take's waveform is " + colour)); + } + } + + // The name of the colour of an analyser's waveform, "" if it has none + static QString waveformColour(Analyser *a) { + int colour = colourOf(a ? a->getLayer(Analyser::Audio) : nullptr); + if (colour < 0) return {}; + return sv::ColourDatabase::getInstance()->getColourName(colour); + } + + // The word the lyrics layer has highlighted, "" for none + QString highlightedWord() { + sv::RegionLayer *layer = m_window->lyrics()->getLayer(); + sv::Event e(0); + if (!layer || !layer->getHighlightedEvent(e)) return {}; + return e.getLabel(); + } + + // --- Editing the lyrics with the mouse in pane 0 --- + + sv::Pane *pane0() { return m_window->paneStack()->getPane(0); } + + // The lyrics' box row in pane 0, the pane painted first: the layer + // knows where the row is only once it has painted it there + QRect lyricsBoxRow() { + sv::Pane *pane = pane0(); + sv::RegionLayer *layer = m_window->lyrics()->getLayer(); + if (!pane || !layer) return {}; + pane->grab(); + return layer->getLyricsBoxRow(pane); + } + + // The word with this text as the model holds it now; frame -1 if + // there is none + sv::Event lyricsWord(QString text) { + for (const sv::Event &e : lyricsEvents()) { + if (e.getLabel() == text) return e; + } + return sv::Event(-1); + } + + static sv::sv_frame_t endOf(const sv::Event &e) { + return e.getFrame() + e.getDuration(); + } + + // The column of pane 0 that a frame falls in: a word's box runs from + // the column of its start to the one before the column of its end + int columnOf(sv::sv_frame_t frame) { return pane0()->getXForFrame(frame); } + + // A mouse event as Qt gives it to the pane: to its event filters + // first, then to the pane. Not QTest::mouseMove, which does not + // carry the buttons held + void sendMouse(QEvent::Type type, QPoint pos, Qt::MouseButton button, + Qt::MouseButtons buttons, + Qt::KeyboardModifiers modifiers = Qt::NoModifier) { + sv::Pane *pane = pane0(); + QVERIFY(pane); + QMouseEvent event(type, QPointF(pos), QPointF(pane->mapToGlobal(pos)), + button, buttons, modifiers); + QApplication::sendEvent(pane, &event); + } + + void hoverAt(QPoint pos) { + sendMouse(QEvent::MouseMove, pos, Qt::NoButton, Qt::NoButton); + } + void pressAt(QPoint pos) { + sendMouse(QEvent::MouseButtonPress, pos, Qt::LeftButton, Qt::LeftButton); + } + void moveHeldTo(QPoint pos) { + sendMouse(QEvent::MouseMove, pos, Qt::NoButton, Qt::LeftButton); + } + void releaseAt(QPoint pos) { + sendMouse(QEvent::MouseButtonRelease, pos, Qt::LeftButton, Qt::NoButton); + } + + // The same with Shift held + void shiftPressAt(QPoint pos) { + sendMouse(QEvent::MouseButtonPress, pos, Qt::LeftButton, Qt::LeftButton, + Qt::ShiftModifier); + } + void shiftMoveHeldTo(QPoint pos) { + sendMouse(QEvent::MouseMove, pos, Qt::NoButton, Qt::LeftButton, + Qt::ShiftModifier); + } + void shiftReleaseAt(QPoint pos) { + sendMouse(QEvent::MouseButtonRelease, pos, Qt::LeftButton, + Qt::NoButton, Qt::ShiftModifier); + } + + // Press at one point, move to the other a few pixels at a time with + // the button held, and let go there + void dragFromTo(QPoint from, QPoint to) { + pressAt(from); + int steps = std::max(1, std::abs(to.x() - from.x()) / 3); + for (int i = 1; i <= steps; ++i) { + moveHeldTo(QPoint(from.x() + (to.x() - from.x()) * i / steps, + from.y() + (to.y() - from.y()) * i / steps)); + } + releaseAt(to); + } + + // The same with Shift held throughout + void shiftDragFromTo(QPoint from, QPoint to) { + shiftPressAt(from); + int steps = std::max(1, std::abs(to.x() - from.x()) / 3); + for (int i = 1; i <= steps; ++i) { + shiftMoveHeldTo(QPoint(from.x() + (to.x() - from.x()) * i / steps, + from.y() + (to.y() - from.y()) * i / steps)); + } + shiftReleaseAt(to); + } + + // The words, every one moved by the same number of frames + static sv::EventVector shiftedBy(const sv::EventVector &words, + sv::sv_frame_t by) { + sv::EventVector moved; + for (const sv::Event &e : words) { + moved.push_back(e.withFrame(e.getFrame() + by)); + } + return moved; + } + + // A reference with the gapped lyrics on it, painted. Yksi and kaksi + // share an edge at 0.6 s; kolme comes after a gap. The row's middle + // is where the tests point, at the columns the words are in. + // + // The window is never shown, and its layout gives pane 0 what a + // 640x480 window leaves over, a few pixels high, or never lays a new + // pane out at all. A size of its own, then, and a zoom at which the + // whole reference is in view and a word is about a hundred pixels + // wide + QRect m_row; + void showEditableLyrics() { + openReference(writeWav(tone(lowHz, 2.0))); + if (QTest::currentTestFailed()) return; + QVERIFY(m_window->doImportLyricsFrom(writeLrc(gappedLyrics()))); + + sv::Pane *pane = pane0(); + pane->setFixedSize(1000, 120); + pane->setZoomLevel(sv::ZoomLevel(sv::ZoomLevel::FramesPerPixel, 128)); + pane->setCentreFrame(sv::sv_frame_t(rate * 1.0)); + m_row = lyricsBoxRow(); + QVERIFY2(!m_row.isEmpty(), "the lyrics were not painted"); + QVERIFY2(m_row.top() > 12 && m_row.bottom() < pane->height(), + qPrintable(QString("the box row is at %1 to %2 of %3") + .arg(m_row.top()).arg(m_row.bottom()) + .arg(pane->height()))); + + // All in view, and wide enough for the grab not to reach across + // a word + int x0 = columnOf(lyricsWord("Yksi").getFrame()); + int x1 = columnOf(endOf(lyricsWord("kolme"))); + QVERIFY2(x0 > 100 && x1 < pane->width() - 100, + qPrintable(QString("the words are at %1 to %2") + .arg(x0).arg(x1))); + int width = columnOf(endOf(lyricsWord("kaksi"))) - + columnOf(lyricsWord("kaksi").getFrame()); + QVERIFY2(width >= 80, qPrintable(QString("kaksi is %1 pixels wide") + .arg(width))); + + m_window->discardModifications(); + } + + // Edit > Edit Lyrics, as the user switches it on + void switchLyricsEditingOn() { + QAction *edit = m_window->editLyricsAction(); + QVERIFY(edit->isEnabled()); + QVERIFY(!edit->isChecked()); + edit->trigger(); + QVERIFY(edit->isChecked()); + QVERIFY(m_window->lyricsEditor()->isEnabled()); + } + + void lyricsEditFixture(FakeAudioIO::Config config = FakeAudioIO::Config()) { + makeWindow(config); + showEditableLyrics(); + if (QTest::currentTestFailed()) return; + switchLyricsEditingOn(); + } + + // A drag from the shared edge, which with edit mode off is the pane's + // own: it moves the view, and no word + void dragIsThePanes(const sv::EventVector &before) { + int edge = columnOf(lyricsWord("kaksi").getFrame()); + dragFromTo(inRow(edge), inRow(edge + 30)); + QCOMPARE(lyricsEvents(), before); + QVERIFY2(columnOf(lyricsWord("kaksi").getFrame()) != edge, + "the pane did not get the drag"); + } + + // How far the pane's frames move for a move of the pointer from one + // column to another + sv::sv_frame_t framesBetween(int x0, int x1) { + return pane0()->getFrameForX(x1) - pane0()->getFrameForX(x0); + } + + QPoint inRow(int x) { return QPoint(x, m_row.center().y()); } + + // A double-click as Qt gives it: a press and a release, then the + // second press as the double-click, and its release + void doubleClickAt(QPoint pos) { + pressAt(pos); + releaseAt(pos); + sendMouse(QEvent::MouseButtonDblClick, pos, Qt::LeftButton, + Qt::LeftButton); + releaseAt(pos); + } + + void rightPressAt(QPoint pos) { + sendMouse(QEvent::MouseButtonPress, pos, Qt::RightButton, + Qt::RightButton); + } + + // The texts of the entries of the words' menu at a point, with a "-" + // in front of a disabled one; none where the menu would not open + QStringList menuAt(QPoint pos) { + QStringList texts; + for (const auto &e : m_window->lyricsEditor()->menuEntriesAt(pos)) { + texts << (e.enabled ? "" : "-") + e.text; + } + return texts; + } + + // The entry of the words' menu at a point with this text, chosen as a + // click on it chooses it; false if there is none + bool chooseAt(QPoint pos, QString text) { + for (const auto &e : m_window->lyricsEditor()->menuEntriesAt(pos)) { + if (e.text == text) { + m_window->lyricsEditor()->choose(e); + return true; + } + } + return false; + } + + // The words' menu the editor has popped up over pane 0, if it is on + // show + QMenu *wordsMenu() { + for (QMenu *menu : pane0()->findChildren()) { + if (menu->isVisible()) return menu; + } + return nullptr; + } + + static QStringList actionTexts(QMenu *menu) { + QStringList texts; + if (menu) for (QAction *a : menu->actions()) texts << a->text(); + return texts; + } + + // Close every menu on show, the words' and Tony's own: both are + // popped up, with no event loop of their own to end. How many + int closeMenus() { + int n = 0; + for (QWidget *w : QApplication::topLevelWidgets()) { + QMenu *menu = qobject_cast(w); + if (menu && menu->isVisible()) { + menu->close(); + ++n; + } + } + return n; + } + + // The lyrics' model, to change the words straight in it, as a test's + // setup: no command, and nothing marked modified + std::shared_ptr lyricsModel() { + return sv::ModelById::getAs + (m_window->lyrics()->getModelId()); + } + + // One word in the model replaced by another, and the pane painted + // again: the editor goes by the boxes as painted + void replaceWord(const sv::Event &from, const sv::Event &to) { + auto model = lyricsModel(); + QVERIFY(model && model->containsEvent(from)); + model->remove(from); + model->add(to); + m_row = lyricsBoxRow(); + } + + // Every key of the settings, with its value, in the form the test + // messages show + static QMap allSettings() { + QSettings settings; + QMap all; + for (const QString &key : settings.allKeys()) { + all[key] = settings.value(key).toString(); + } + return all; + } + + static QStringList settingsChanged(const QMap &before, + const QMap &after) { + QStringList changed; + for (auto i = after.begin(); i != after.end(); ++i) { + if (!before.contains(i.key())) { + changed << i.key() + " added: " + i.value(); + } else if (before[i.key()] != i.value()) { + changed << i.key() + ": " + before[i.key()] + " -> " + i.value(); + } + } + for (auto i = before.begin(); i != before.end(); ++i) { + if (!after.contains(i.key())) changed << i.key() + " removed"; + } + return changed; + } + + static QString lyricsFixture(const char *name) { + return QString(TONY_TEST_DATA_DIR) + "/lyrics/" + name; + } + + // As the import reads it: TTML or LRC + static Lyrics lyricsIn(QString path) { + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) return {}; + return parseLyrics(file.readAll()).lyrics; + } + + // What an import of this file has to put in the model: the words on + // the reference's timeline + sv::EventVector expectedLyricsEvents(QString path) { + auto reference = sv::ModelById::get(m_window->mainModelId()); + if (!reference) return {}; + return lyricsToEvents(lyricsIn(path), reference->getSampleRate()); + } + + QString writeLrc(const QByteArray &text, const char *suffix = "lrc") { + QString path = m_dir.filePath + (QString("lyrics-%1.%2").arg(++m_fileCounter).arg(suffix)); + QFile file(path); + if (!file.open(QIODevice::WriteOnly) || + file.write(text) != text.size()) { + return {}; + } + return path; + } + + // Two words and a gap, then one more: every word with its end given + static QByteArray gappedLyrics() { + return "[00:00.20]<00:00.20>Yksi <00:00.60>kaksi <00:00.90>\n" + "[00:01.40]<00:01.40>kolme <00:01.80>\n"; + } + + // The pre-roll's length has no UI: it is read from the settings when + // Record is pressed. These are the test suite's own settings + // (tony-app-test), not the user's + static void setPreRollSeconds(double seconds) { + QSettings settings; + settings.beginGroup("MainWindow"); + settings.setValue("prerollseconds", seconds); + settings.endGroup(); + } + + // A recording of two notes, the first as long as "first" seconds and + // the second as long as "then": what is sung during a lead-in, or + // after the end of a selection, is the one the take must not hold + static std::vector twoNotes(double first, double then) { + auto signal = tone(lowHz, first); + auto second = tone(highHz, then); + signal.insert(signal.end(), second.begin(), second.end()); + return signal; + } + + // --- A device that does not run at the reference's rate --- + + // What phones run at. The reference is a model at 44.1 kHz whatever + // its file was, and the device records at its own rate + static constexpr int otherDeviceRate = 48000; + + // Sung at the device's rate: lowHz and highHz, as sines. pYIN gets + // the take at 44.1 kHz, where these two have whole numbers of samples + // per period; tones without (240 and 320 Hz, sines or sawtooths) came + // out as subharmonics. Sines, because a sawtooth made at 48 kHz that + // is not whole there aliases into partials that are not harmonics, + // and the sawtooths whole at both rates (100, 150, 300 Hz) came out + // an octave low after a step, or not, as the step fell between frames + static std::vector toneAt(double hz, double seconds, + int sampleRate) { + return TestSignals::sine(hz, sampleRate, int(seconds * sampleRate)); + } + + static std::vector twoTonesAt(double firstHz, double first, + double thenHz, double then, + int sampleRate) { + auto signal = toneAt(firstHz, first, sampleRate); + auto second = toneAt(thenHz, then, sampleRate); + signal.insert(signal.end(), second.begin(), second.end()); + return signal; + } + + // Record from P for ms milliseconds. recordedFrames receives the + // length of the recording the device made, which must be at its + // rate + void recordAt(sv::sv_frame_t P, int ms, int deviceRate, + sv::sv_frame_t &recordedFrames) { + recordedFrames = -1; + m_window->seekTo(P); + startTake(); + if (QTest::currentTestFailed()) return; + QString path; + { + // Not held past this: the recording is to be released, and + // its file closed, when the take stops + auto recording = sv::ModelById::getAs + (m_window->currentRecordingModelId()); + QVERIFY(recording); + path = recording->getLocation(); + } + QTest::qWait(ms); + stopTake(); + if (QTest::currentTestFailed()) return; + sv::WavFileReader reader { sv::FileSource(path) }; + QVERIFY(reader.isOK()); + QCOMPARE(int(reader.getSampleRate()), deviceRate); + recordedFrames = reader.getFrameCount(); + } + + // The first and the last frame of [from, to) at which the take's + // audio model is louder than a whisper, or -1 for both. The model, + // not the file: it is what is played and what pYIN is given, and it + // is on the reference's timeline whatever the file is + void soundInTake(sv::sv_frame_t from, sv::sv_frame_t to, + sv::sv_frame_t &first, sv::sv_frame_t &last) { + first = last = -1; + auto wave = takeAudio(); + if (!wave || to <= from) return; + auto data = wave->getData(0, from, to - from); + for (size_t i = 0; i < data.size(); ++i) { + if (std::fabs(data[i]) > 0.05f) { + if (first < 0) first = from + sv::sv_frame_t(i); + last = from + sv::sv_frame_t(i); + } + } + } + + // Where the singing of a recording made at deviceRate has to be: + // from P, for as long as it was sung, in the take's coverage, audio + // and pitch alike. The coverage range holding P is returned in range + void verifyRecordingPlaced(sv::sv_frame_t P, sv::sv_frame_t recordedFrames, + int deviceRate, Coverage::Range &range) { + const sv::sv_frame_t margin = sv::sv_frame_t(0.1 * rate); + const sv::sv_frame_t close = sv::sv_frame_t(0.01 * rate); + + range = Coverage::Range(); + for (const auto &r : m_window->takes()->getCoverage().getRanges()) { + if (r.start <= P && P < r.end) range = r; + } + QVERIFY2(range.length() > 0, + qPrintable(QString("no coverage holds the position %1") + .arg(P))); + + // As many seconds as the device recorded, at the reference's rate + sv::sv_frame_t want = sv::sv_frame_t + (std::llround(double(recordedFrames) * rate / deviceRate)); + QCOMPARE(range.start, P); + QVERIFY2(std::llabs(range.length() - want) <= 1, + qPrintable(QString("%1 frames were recorded at %2 Hz, which " + "is %3 at %4 Hz; the take covers %5") + .arg(recordedFrames).arg(deviceRate).arg(want) + .arg(rate).arg(range.length()))); + + // The file is at the reference's rate, so its frames are the + // reference's frames + QString path = m_window->takes()->getAudioPath(); + sv::WavFileReader file { sv::FileSource(path) }; + QVERIFY(file.isOK()); + QVERIFY2(file.getSampleRate() == rate, + qPrintable(QString("the take's audio file is at %1 Hz, not " + "the reference's %2") + .arg(file.getSampleRate()).arg(rate))); + + // The sound is where the coverage says it is + auto wave = takeAudio(); + QVERIFY(wave); + QTRY_VERIFY(wave->isReady()); + sv::sv_frame_t first = -1, last = -1; + soundInTake(std::max(sv::sv_frame_t(0), P - margin), + range.end + margin, first, last); + QVERIFY2(std::llabs(first - range.start) <= close && + std::llabs(last - range.end) <= close, + qPrintable(QString("the take's audio is loud over [%1,%2]; " + "the recording went into [%3,%4)") + .arg(first).arg(last) + .arg(range.start).arg(range.end))); + + // and so is the pitch, at the pitch that was sung + auto events = eventsBetween(pitchEvents(m_window->analyser2()), + range.start - margin, range.end + margin); + QVERIFY(!events.empty()); + QVERIFY2(std::llabs(events.front().getFrame() - range.start) <= margin && + std::llabs(events.back().getFrame() - range.end) <= margin, + qPrintable(QString("the take's pitch runs from %1 to %2; the " + "recording went into [%3,%4)") + .arg(events.front().getFrame()) + .arg(events.back().getFrame()) + .arg(range.start).arg(range.end))); + } + + // A first recording at P1 and a second in the gap after it, from a + // device at deviceRate against the reference at 44.1 kHz + void verifyTakesPlacedFromDeviceAt(int deviceRate) { + const double sungHz = highHz; + FakeAudioIO::Config config; + config.sampleRate = deviceRate; + config.input = toneAt(sungHz, 3.0, deviceRate); + makeWindow(config); + openReference(writeWav(tone(lowHz, 5.0))); + if (QTest::currentTestFailed()) return; + + const sv::sv_frame_t P1 = sv::sv_frame_t(1.0 * rate); + const sv::sv_frame_t P2 = sv::sv_frame_t(3.0 * rate); + const sv::sv_frame_t margin = sv::sv_frame_t(0.1 * rate); + + // As recordAt() does, with a look at the cursor on the way: while + // the take records, it runs from the position at the reference's + // rate + m_window->seekTo(P1); + startTake(); + if (QTest::currentTestFailed()) return; + QString firstPath; + { + auto recording = sv::ModelById::getAs + (m_window->currentRecordingModelId()); + QVERIFY(recording); + firstPath = recording->getLocation(); + } + QTest::qWait(500); + sv::sv_frame_t during = m_window->playbackFrame(); + sv::sv_frame_t recordedSoFar = + m_window->recordTarget()->getRecordDuration(); + sv::sv_frame_t cursorWant = P1 + sv::sv_frame_t + (std::llround(double(recordedSoFar) * rate / deviceRate)); + QVERIFY2(std::llabs(during - cursorWant) <= 1, + qPrintable(QString("%1 frames at %2 Hz into a take from " + "frame %3, the cursor is at %4, not %5") + .arg(recordedSoFar).arg(deviceRate).arg(P1) + .arg(during).arg(cursorWant))); + QTest::qWait(500); + stopTake(); + if (QTest::currentTestFailed()) return; + + sv::sv_frame_t firstFrames = -1; + { + sv::WavFileReader reader { sv::FileSource(firstPath) }; + QVERIFY(reader.isOK()); + QCOMPARE(int(reader.getSampleRate()), deviceRate); + firstFrames = reader.getFrameCount(); + } + + Coverage::Range first; + verifyRecordingPlaced(P1, firstFrames, deviceRate, first); + if (QTest::currentTestFailed()) return; + + auto wave = takeAudio(); + QVERIFY(wave); + QCOMPARE(wave->getFrameCount(), first.end); + QVERIFY2(std::fabs(TestSignals::centsBetween + (medianHz(pitchEvents(m_window->analyser2())), + sungHz)) < 10.0, + qPrintable(QString("the take's median pitch is %1 Hz; it " + "was sung at %2") + .arg(medianHz(pitchEvents(m_window->analyser2()))) + .arg(sungHz))); + sv::sv_frame_t loudFirst = -1, loudLast = -1; + soundInTake(0, P1 - margin, loudFirst, loudLast); + QVERIFY2(loudFirst < 0, "the take's audio is not silent before the " + "position it was recorded at"); + + // A second recording, into the gap after the first: the take so + // far is at the reference's rate, whatever the recording is at + sv::sv_frame_t secondFrames = -1; + recordAt(P2, 800, deviceRate, secondFrames); + if (QTest::currentTestFailed()) return; + + auto ranges = m_window->takes()->getCoverage().getRanges(); + QCOMPARE(int(ranges.size()), 2); + QCOMPARE(ranges[0], first); + + Coverage::Range second; + verifyRecordingPlaced(P2, secondFrames, deviceRate, second); + if (QTest::currentTestFailed()) return; + + wave = takeAudio(); + QVERIFY(wave); + QTRY_COMPARE(wave->getFrameCount(), second.end); + + soundInTake(first.end + margin, P2 - margin, loudFirst, loudLast); + QVERIFY2(loudFirst < 0, + "the gap between the two recordings is not silent"); + QVERIFY2(eventsBetween(pitchEvents(m_window->analyser2()), + first.end + margin, P2 - margin).empty(), + "the pitch track has something in the gap between the two " + "recordings"); + } + + // Not a slot: QtTest would run it as a test + void dismissDialog() { + QWidget *modal = QApplication::activeModalWidget(); + if (!modal) return; + QString description = modal->windowTitle(); + if (auto box = qobject_cast(modal)) { + description += ": " + box->text(); + m_dialogs.push_back(description); + // A question with buttons of its own is not answered by + // rejecting it: the session reader's "do you want to locate + // this file?" asks again until one of its buttons is pressed + // (InteractiveFileFinder::locateInteractive()). The last + // button is Cancel in every question Tony can show + QList buttons = box->buttons(); + if (!buttons.isEmpty()) { + buttons.last()->click(); + return; + } + } else { + m_dialogs.push_back(description); + } + if (auto dialog = qobject_cast(modal)) { + dialog->reject(); + } else { + modal->close(); + } + } + +private slots: + void initTestCase() { + QVERIFY(m_dir.isValid()); + + QSettings().clear(); + + // Otherwise the MainWindow constructor asks, in a dialog + QSettings settings; + settings.beginGroup("Preferences"); + settings.setValue(QString("network-permission-%1").arg(TONY_VERSION), + false); + settings.endGroup(); + + // As main() does; without it a .ton file is not a session + sv::InteractiveFileFinder::getInstance() + ->setApplicationSessionExtension("ton"); + + // Takes go here and not among the user's recordings + sv::RecordDirectory::setRecordContainerDirectory + (m_dir.filePath("recorded")); + + connect(&m_watchdog, &QTimer::timeout, + this, [this]() { dismissDialog(); }); + m_watchdog.start(50); + } + + void init() { + m_dialogs.clear(); + QSettings settings; + settings.beginGroup("MainWindow"); + settings.setValue("playrefwhilerecording", false); + // The toolbar toggles of the recording behaviour, and the length + // of the pre-roll: a test that switched one on must not leave it + // on for the next + settings.setValue("preroll", false); + settings.setValue("recordintoselection", false); + settings.remove("prerollseconds"); + settings.endGroup(); + + // The audible flags are shared by both analysers; a test that + // failed half way must not leave the next one's tracks muted + settings.beginGroup("Analyser"); + settings.remove(""); + settings.endGroup(); + + // Asking before recording over existing singing is the default, + // whatever a test that switched it off did + SingingTakes::setOverwriteConfirmationWanted(true); + } + + void cleanup() { + // A round trip a test stored would place the next test's takes. + // First, as the waits below return early when they fail + QSettings().remove("LatencyCalibration"); + + if (m_window) { + if (m_window->recordTarget()->isRecording()) { + m_window->doRecord(); + } + QTRY_VERIFY_WITH_TIMEOUT + (!sv::ModelTransformerFactory::getInstance() + ->haveRunningTransformers(), 30000); + m_window->doCloseSession(); + delete m_window; + m_window = nullptr; + } + QVERIFY2(m_dialogs.isEmpty(), + qPrintable("unexpected dialog: " + m_dialogs.join(" | "))); + } + + void cleanupTestCase() { + m_watchdog.stop(); + sv::RecordDirectory::setRecordContainerDirectory(""); + } + + void record_creates_singing_track() { + FakeAudioIO::Config config; + config.input = tone(highHz, 3.0); + makeWindow(config); + openReference(writeWav(tone(lowHz, 1.0))); + if (QTest::currentTestFailed()) return; + + int panes = m_window->paneStack()->getPaneCount(); + QCOMPARE(panes, 2); // analysis pane and ruler strip + QVERIFY(!m_window->analyser2()); + + take(1000); + if (QTest::currentTestFailed()) return; + + QCOMPARE(m_window->paneStack()->getPaneCount(), panes); + QCOMPARE(m_window->paneStack()->getHiddenPaneCount(), 0); + QCOMPARE(m_window->pendingExtraPaneCount(), 0); + + Analyser *a2 = m_window->analyser2(); + QVERIFY(a2); + sv::ModelId singing = a2->getMainModelId(); + QVERIFY(singing != m_window->mainModelId()); + + // The singing track is the take's own audio file, which the + // recording was spliced into: a plain wave file starting at frame + // 0, not the recording itself + auto wave = sv::ModelById::getAs(singing); + QVERIFY2(wave, "the singing model is not a wave file model"); + QVERIFY2(!sv::ModelById::isa(singing), + "the singing model is the recording, not the take's audio"); + QCOMPARE(wave->getStartFrame(), sv::sv_frame_t(0)); + QVERIFY(wave->getFrameCount() > sv::sv_frame_t(0.8 * rate)); + + // and the recording has been let go of + QVERIFY(!m_window->recordingLayer()); + QVERIFY(m_window->currentRecordingModelId().isNone()); + + // The take covers what was recorded, from frame 0 here + QVERIFY(m_window->takes()->haveTake()); + auto ranges = m_window->takes()->getCoverage().getRanges(); + QCOMPARE(int(ranges.size()), 1); + QCOMPARE(ranges[0].start, sv::sv_frame_t(0)); + QCOMPARE(ranges[0].end, wave->getFrameCount()); + + QCOMPARE(colourOf(a2->getLayer(Analyser::PitchTrack)), + colourNamed("Orange")); + QCOMPARE(colourOf(a2->getLayer(Analyser::Notes)), + colourNamed("Bright Purple")); + QVERIFY(paneHasLayer(0, a2->getLayer(Analyser::PitchTrack))); + QVERIFY(paneHasLayer(0, a2->getLayer(Analyser::Notes))); + + auto events = pitchEvents(a2); + QVERIFY(events.size() > 50); + QVERIFY(std::fabs(TestSignals::centsBetween + (medianHz(events), highHz)) < 10.0); + + // and the reference was left alone + QVERIFY(std::fabs(TestSignals::centsBetween + (medianHz(pitchEvents(m_window->analyser())), + lowHz)) < 10.0); + } + + // Kept running between takes, as the application keeps it, the + // device runs on after a take and after playback, and is suspended + // once it has idled for audioIdleSuspendMillis(): not before, not + // while it plays however long that is, and the next take resumes + // it. The application idles it for ever on desktop + void a_kept_running_device_is_suspended_once_idle() { + const int idle = 1500; + FakeAudioIO::Config config; + config.input = tone(highHz, 6.0); + makeWindow(config); + QCOMPARE(m_window->applicationAudioIdleSuspendMillis(), 0); + m_window->keepAudioRunning(true); + m_window->setAudioIdleSuspendMillis(idle); + openReference(writeWav(tone(lowHz, 4.0))); + if (QTest::currentTestFailed()) return; + FakeAudioIO *fake = m_window->fake(); + QVERIFY(fake); + + startTake(); + if (QTest::currentTestFailed()) return; + waitForSomethingRecorded(); + // Timed from before Stop: the take's splice, which Stop runs + // before it returns, comes after the idle time has started + QElapsedTimer stopped; + stopped.start(); + m_window->doRecord(); + QVERIFY(!m_window->recordTarget()->isRecording()); + QVERIFY(!fake->isSuspended()); + const int resumes = fake->getResumeCount(); + QTRY_VERIFY_WITH_TIMEOUT(fake->isSuspended(), 10000); + QVERIFY2(stopped.elapsed() >= idle * 9 / 10, + qPrintable(QString("suspended %1 ms after the take") + .arg(stopped.elapsed()))); + QTRY_VERIFY_WITH_TIMEOUT(analysed(m_window->analyser2()), 30000); + + m_window->doPlay(); + QVERIFY(m_window->playSource()->isPlaying()); + QCOMPARE(fake->getResumeCount(), resumes + 1); + QTest::qWait(idle + 700); + QVERIFY(m_window->playSource()->isPlaying()); + QVERIFY(!fake->isSuspended()); + m_window->doPlay(); + QVERIFY(!m_window->playSource()->isPlaying()); + QVERIFY(!fake->isSuspended()); + QTRY_VERIFY_WITH_TIMEOUT(fake->isSuspended(), 10000); + + take(500); + if (QTest::currentTestFailed()) return; + QCOMPARE(fake->getResumeCount(), resumes + 2); + } + + void live_dots_appear() { + FakeAudioIO::Config config; + config.input = tone(highHz, 3.0); + makeWindow(config); + openReference(writeWav(tone(lowHz, 1.0))); + if (QTest::currentTestFailed()) return; + + startTake(); + if (QTest::currentTestFailed()) return; + QTest::qWait(1000); + + QVERIFY(m_window->realtimeTracker()); + QVERIFY(m_window->realtimeLayer()); + QVERIFY(paneHasLayer(0, m_window->realtimeLayer())); + // Drawn by itself as dots come, not with every layer of the pane + QVERIFY(!m_window->realtimeLayer()->isCachedInView()); + auto model = sv::ModelById::getAs + (m_window->realtimeModelId()); + QVERIFY(model); + // One dot per hop (review finding 12) + QCOMPARE(int(model->getResolution()), + int(RealtimePitchTracker::kHopSize)); + auto events = model->getAllEvents(); + QVERIFY2(events.size() > 20, + qPrintable(QString("only %1 live dots after a second") + .arg(events.size()))); + QVERIFY(std::fabs(TestSignals::centsBetween + (medianHz(events), highHz)) < 10.0); + + stopTake(); + } + + // A microphone on input 2 of an interface, nothing on input 1: the + // dots and the take's pitch come from the mixdown, so they are there + void live_dots_from_the_second_input() { + FakeAudioIO::Config config; + config.channels = 2; + config.inputChannel = 1; + config.input = tone(highHz, 3.0); + makeWindow(config); + openReference(writeWav(tone(lowHz, 1.0))); + if (QTest::currentTestFailed()) return; + + startTake(); + if (QTest::currentTestFailed()) return; + QTest::qWait(1000); + auto model = sv::ModelById::getAs + (m_window->realtimeModelId()); + QVERIFY(model); + auto events = model->getAllEvents(); + QVERIFY2(events.size() > 20, + qPrintable(QString("only %1 live dots after a second of " + "singing into input 2") + .arg(events.size()))); + QVERIFY(std::fabs(TestSignals::centsBetween + (medianHz(events), highHz)) < 10.0); + + stopTake(); + if (QTest::currentTestFailed()) return; + auto pitch = pitchEvents(m_window->analyser2()); + QVERIFY2(pitch.size() > 20, "the take of input 2 has no pitch track"); + QVERIFY(std::fabs(TestSignals::centsBetween + (medianHz(pitch), highHz)) < 10.0); + } + + void live_dots_removed() { + FakeAudioIO::Config config; + config.input = tone(highHz, 3.0); + makeWindow(config); + openReference(writeWav(tone(lowHz, 1.0))); + if (QTest::currentTestFailed()) return; + + startTake(); + if (QTest::currentTestFailed()) return; + QTest::qWait(800); + sv::ModelId liveModel = m_window->realtimeModelId(); + QVERIFY(!liveModel.isNone()); + stopTake(); + if (QTest::currentTestFailed()) return; + + // The dots go when pYIN finishes, and the application hears of + // that an event-loop turn after the models say so + QTRY_VERIFY(!m_window->realtimeLayer()); + QVERIFY(!m_window->realtimeTracker()); + QVERIFY(m_window->realtimeModelId().isNone()); + QVERIFY2(!sv::ModelById::get(liveModel), + "the live pitch model outlived its layer"); + QVERIFY(!m_window->recordingInProgress()); + QVERIFY(!m_window->recordingAsSingingTrack()); + } + + // Review finding 9: the pitch track replaces the dots. Between Stop + // and the end of pYIN the dots are all the singer has to look at + void live_dots_stay_until_analysis() { + FakeAudioIO::Config config; + config.input = tone(highHz, 3.0); + makeWindow(config); + openReference(writeWav(tone(lowHz, 1.0))); + if (QTest::currentTestFailed()) return; + + startTake(); + if (QTest::currentTestFailed()) return; + QTest::qWait(1000); + sv::ModelId liveModel = m_window->realtimeModelId(); + QVERIFY(!liveModel.isNone()); + + // Stop. pYIN has been started and cannot have finished: its + // completion arrives through the event loop, which has not run + m_window->doRecord(); + QVERIFY(!m_window->recordTarget()->isRecording()); + QVERIFY(!analysed(m_window->analyser2())); + + QVERIFY2(m_window->realtimeLayer(), + "the live dots went at Stop, before pYIN had anything " + "to show in their place"); + QVERIFY(paneHasLayer(0, m_window->realtimeLayer())); + auto model = sv::ModelById::getAs(liveModel); + QVERIFY(model); + int dots = model->getEventCount(); + QVERIFY(dots > 20); + + // The take is over all the same: nothing is tracking it, and + // the state is that of a finished take + QVERIFY(!m_window->realtimeTracker()); + QVERIFY(!m_window->recordingInProgress()); + QVERIFY(!m_window->recordingAsSingingTrack()); + + // and they go no sooner than the pitch track is complete + QTRY_VERIFY_WITH_TIMEOUT(!m_window->realtimeLayer(), 30000); + QVERIFY(m_window->analyser2()->getInitialAnalysisCompletion() >= 100); + QVERIFY(!pitchEvents(m_window->analyser2()).empty()); + QVERIFY2(!sv::ModelById::get(liveModel), + "the live pitch model outlived its layer"); + QTRY_VERIFY_WITH_TIMEOUT(analysed(m_window->analyser2()), 30000); + } + + // Review finding 10: pitch events still queued when the take ends + // must not draw dots or write to the status bar + void stale_pitch_event_ignored() { + FakeAudioIO::Config config; + config.input = tone(highHz, 3.0); + makeWindow(config); + openReference(writeWav(tone(lowHz, 1.0))); + if (QTest::currentTestFailed()) return; + + startTake(); + if (QTest::currentTestFailed()) return; + QTest::qWait(600); + sv::ModelId liveModel = m_window->realtimeModelId(); + + // The real case: an event queued before the take ends and + // delivered after it. Frame 20000 is well inside the take, and + // with no reference playing there is no latency to take off + // it, so it would make a dot. The tracker may have queued + // events of its own as well; the same goes for them + QCOMPARE(m_window->recordingLatencyFrames(), sv::sv_frame_t(0)); + // Queued as a functor: invoking the slot by name depends on the + // Qt version matching "sv::sv_frame_t" against what moc recorded, + // and Qt 6.4 does not + TestMainWindow *window = m_window; + QVERIFY2(QMetaObject::invokeMethod + (m_window, [window]() { + window->doRealtimePitchDetected(20000, 440.0); + }, Qt::QueuedConnection), + "the pitch event could not be queued"); + m_window->doRecord(); + QVERIFY(!m_window->recordTarget()->isRecording()); + + auto model = sv::ModelById::getAs(liveModel); + QVERIFY(model); + int dots = model->getEventCount(); + m_window->setStatusText("after the take"); + QCoreApplication::processEvents(); + QVERIFY2(model->getEventCount() == dots, + qPrintable(QString("a pitch event queued during the take " + "and delivered after it drew a dot: %1 " + "dots, there were %2") + .arg(model->getEventCount()).arg(dots))); + QVERIFY2(m_window->statusText() == QString("after the take"), + qPrintable(QString("a pitch event queued during the take " + "and delivered after it wrote \"%1\" to " + "the status bar") + .arg(m_window->statusText()))); + + // and one that arrives later still + m_window->doRealtimePitchDetected(20000, 440.0); + QCOMPARE(model->getEventCount(), dots); + QCOMPARE(m_window->statusText(), QString("after the take")); + + QTRY_VERIFY_WITH_TIMEOUT(analysed(m_window->analyser2()), 30000); + QTRY_VERIFY(!m_window->realtimeLayer()); + m_window->setStatusText("after the analysis"); + m_window->doRealtimePitchDetected(20000, 440.0); + QCOMPARE(m_window->statusText(), QString("after the analysis")); + } + + // A phone's GUI thread is several times slower than this machine's. + // Made slower than the tracker finds pitch (an estimate a hop, 5.8 + // ms), it must still keep the dots up with the singing: given the + // dots one at a time it would fall further behind for as long as the + // take lasted + void live_dots_keep_up_with_a_slow_gui() { + FakeAudioIO::Config config; + config.input = tone(highHz, 5.0); + makeWindow(config); + openReference(writeWav(tone(lowHz, 5.0))); + if (QTest::currentTestFailed()) return; + + m_window->setLiveDotsDelay(10); + startTake(); + if (QTest::currentTestFailed()) return; + + // With no reference playing there is no latency, so a dot is at + // the frame of the recording its window was centred on. The most + // the newest may trail what the device has recorded: half a + // window, a record update, the tracker's poll, the wait for the + // next batch and the slow GUI thread, with room to spare. Per + // estimate, this GUI thread is 0.4 s behind after a second, 1.2 s + // after three + const sv::sv_frame_t bound = sv::sv_frame_t(0.4 * rate); + QElapsedTimer timer; + timer.start(); + sv::sv_frame_t worst = 0; + int looked = 0; + QString detail; + while (timer.elapsed() < 3000) { + QTest::qWait(100); + // (the tracker starts an event-loop turn after the take) + if (timer.elapsed() < 500) continue; + auto model = sv::ModelById::getAs + (m_window->realtimeModelId()); + QVERIFY(model); + auto events = model->getAllEvents(); + if (events.empty()) continue; + ++looked; + sv::sv_frame_t recorded = + m_window->recordTarget()->getFramesReceived(); + sv::sv_frame_t behind = recorded - events.back().getFrame(); + if (behind > worst) { + worst = behind; + detail = QString("%1 ms into the take the newest dot is at " + "%2 s, %3 ms behind the %4 s recorded") + .arg(timer.elapsed()) + .arg(double(events.back().getFrame()) / rate, 0, 'f', 2) + .arg(1000.0 * double(behind) / rate, 0, 'f', 0) + .arg(double(recorded) / rate, 0, 'f', 2); + } + } + m_window->setLiveDotsDelay(0); + qInfo("%s", qPrintable(detail)); + QVERIFY2(worst <= bound, qPrintable(detail)); + QVERIFY2(looked >= 10, "hardly any live dots"); + + stopTake(); + } + + // Review finding 8: during the take, the dots sit where the pitch + // track will. Same device and singer as latency_end_to_end, but + // looked at before Stop + void live_dots_compensated() { + const int K = 3 * 4096; + FakeAudioIO::Config config; + config.playbackLatency = 2 * 4096; + config.recordLatency = 4096; + config.input = melody(0.75); + config.inputDelay = K; + config.inputFollowsPlayback = true; + makeWindow(config); + m_window->setPlayReferenceWhileRecording(true); + openReference(writeWav(melody(0.75))); + if (QTest::currentTestFailed()) return; + + startTake(); + if (QTest::currentTestFailed()) return; + QTest::qWait(2200); + + auto model = sv::ModelById::getAs + (m_window->realtimeModelId()); + QVERIFY(model); + auto dots = model->getAllEvents(); + stopTake(); + if (QTest::currentTestFailed()) return; + + sv::sv_frame_t refStep = stepFrame(pitchEvents(m_window->analyser())); + sv::sv_frame_t dotStep = stepFrame(dots); + QVERIFY(refStep > 0); + QVERIFY2(dotStep > 0, "the live dots never reached the second note"); + + // The dots are coarser than pYIN: allow them a YIN window + sv::sv_frame_t error = dotStep - refStep; + QVERIFY2(std::llabs(error) <= 2048, + qPrintable(QString("live step at %1, reference step at %2: " + "%3 frames (%4 ms) apart; the round trip " + "is %5 frames and the reference started " + "%6 frames into the take") + .arg(dotStep).arg(refStep).arg(error) + .arg(1000.0 * double(error) / rate, 0, 'f', 1) + .arg(K) + .arg(m_window->fake() + ->getFramesBeforePlayStart()))); + } + + // The automated latency test. The device reports a round trip of K + // frames, and its input is the reference melody starting K frames + // after the first reference sample is played: a singer exactly on + // time. After the take the two pitch tracks should line up. + void latency_end_to_end() { + const int K = 3 * 4096; + FakeAudioIO::Config config; + config.playbackLatency = 2 * 4096; + config.recordLatency = 4096; + config.input = melody(0.75); + config.inputDelay = K; + config.inputFollowsPlayback = true; + makeWindow(config); + m_window->setPlayReferenceWhileRecording(true); + openReference(writeWav(melody(0.75))); + if (QTest::currentTestFailed()) return; + + take(2200); + if (QTest::currentTestFailed()) return; + + QVERIFY2(m_window->fake()->getPlayStartFrame() >= 0, + "the reference was never played"); + + auto wave = takeAudio(); + QVERIFY(wave); + // The compensation is in the audio: the recording was read from + // the frame the latency points at, so the take's own file starts + // at frame 0 of the reference's timeline + QCOMPARE(wave->getStartFrame(), sv::sv_frame_t(0)); + + // The compensation is the round trip plus what the application + // took the start gap to be (review finding 14). The device knows + // what the gap really was. The application counts in whole + // blocks, in the audio callback, so the two agree to within a + // few samples; an estimate made on the GUI thread is out by a + // block or more + sv::sv_frame_t gap = m_window->fake()->getFramesBeforePlayStart(); + sv::sv_frame_t assumedGap = m_window->recordingLatencyFrames() - K; + QVERIFY2(std::llabs(assumedGap - gap) <= 16, + qPrintable(QString("the application took the start gap to " + "be %1 frames; it was %2") + .arg(assumedGap).arg(gap))); + + sv::sv_frame_t refStep = stepFrame(pitchEvents(m_window->analyser())); + sv::sv_frame_t sungStep = stepFrame(pitchEvents(m_window->analyser2())); + QVERIFY(refStep > 0); + QVERIFY2(sungStep > 0, "the take never reached the second note"); + + sv::sv_frame_t error = sungStep - refStep; + QString detail = QString("sung step at %1, reference step at %2: " + "%3 frames (%4 ms) apart; the reference " + "started %5 frames into the take, and the " + "application took that to be %6") + .arg(sungStep).arg(refStep).arg(error) + .arg(1000.0 * double(error) / rate, 0, 'f', 1) + .arg(gap).arg(assumedGap); + + QVERIFY2(std::llabs(error) <= 2 * hop, qPrintable(detail)); + } + + // latency_end_to_end with a device that reports 50 ms less than its + // round trip of K frames, and the round trip the audio check measured + // kept for it: the take is placed with the measured figure, and the + // two pitch tracks line up + void latency_measured_round_trip_used() { + const int K = 3 * 4096; + const int reportedOut = 2 * 4096; + const int reportedIn = 4096 - 2205; + FakeAudioIO::Config config; + config.playbackLatency = reportedOut; + config.recordLatency = reportedIn; + config.input = melody(0.75); + config.inputDelay = K; + config.inputFollowsPlayback = true; + storeRoundTrip(K, reportedOut, reportedIn); + makeWindow(config); + m_window->setPlayReferenceWhileRecording(true); + openReference(writeWav(melody(0.75))); + if (QTest::currentTestFailed()) return; + + take(2200); + if (QTest::currentTestFailed()) return; + + sv::sv_frame_t refStep = stepFrame(pitchEvents(m_window->analyser())); + sv::sv_frame_t sungStep = stepFrame(pitchEvents(m_window->analyser2())); + QVERIFY(refStep > 0); + QVERIFY2(sungStep > 0, "the take never reached the second note"); + sv::sv_frame_t error = sungStep - refStep; + QVERIFY2(std::llabs(error) <= 2 * hop, + qPrintable(QString("sung step at %1, reference step at %2: " + "%3 frames (%4 ms) apart; the take was " + "placed with a round trip of %5 frames") + .arg(sungStep).arg(refStep).arg(error) + .arg(1000.0 * double(error) / rate, 0, 'f', 1) + .arg(m_window->takeLatency().roundTrip))); + + TakeLatency used = m_window->takeLatency(); + QVERIFY(used.measured); + QCOMPARE(used.roundTrip, sv::sv_frame_t(K)); + QCOMPARE(used.reportedOutput, reportedOut / rate); + QCOMPARE(used.reportedInput, reportedIn / rate); + } + + // A round trip measured while the device reported other latencies + // (its buffers have been changed since) is stale: the take is placed + // with the reported pair. With the latencies it was measured with, + // the same figure would be in use + void latency_stale_round_trip_ignored() { + const int reportedOut = 2 * 4096; + const int reportedIn = 4096; + FakeAudioIO::Config config; + config.playbackLatency = reportedOut; + config.recordLatency = reportedIn; + config.input = tone(highHz, 2.0); + const int stale = reportedOut + + int(2 * LatencyCalibration::kStaleToleranceSeconds * rate); + storeRoundTrip(15000, stale, reportedIn); + makeWindow(config); + m_window->setPlayReferenceWhileRecording(true); + openReference(writeWav(tone(lowHz, 1.0))); + if (QTest::currentTestFailed()) return; + + take(800); + if (QTest::currentTestFailed()) return; + + TakeLatency used = m_window->takeLatency(); + QVERIFY(!used.measured); + QCOMPARE(used.roundTrip, sv::sv_frame_t(reportedOut + reportedIn)); + + LatencyCalibration::InUse inUse = m_window->latencyInUse(); + QVERIFY(inUse.source == LatencyCalibration::Source::Reported); + QVERIFY(inUse.stale); + QCOMPARE(inUse.roundTrip, (reportedOut + reportedIn) / rate); + + storeRoundTrip(15000, reportedOut, reportedIn); + inUse = m_window->latencyInUse(); + QVERIFY(inUse.source == LatencyCalibration::Source::Measured); + QCOMPARE(inUse.roundTrip, 15000 / rate); + } + + // A device at 48 kHz reporting 2 x 4096 frames out and 4096 in: the + // take is placed with those 3 x 4096 frames of the recording, + // although the play source has the output latency in frames of the + // session, converted as it resamples; the take keeps each latency in + // seconds, the output's from those converted frames + void latency_reported_at_the_device_rate() { + const double deviceRate = 48000.0; + const int reportedOut = 2 * 4096; + const int reportedIn = 4096; + FakeAudioIO::Config config; + config.sampleRate = int(deviceRate); + config.playbackLatency = reportedOut; + config.recordLatency = reportedIn; + config.input = tone(highHz, 2.0); + makeWindow(config); + m_window->setPlayReferenceWhileRecording(true); + openReference(writeWav(tone(lowHz, 1.0))); + if (QTest::currentTestFailed()) return; + + take(800); + if (QTest::currentTestFailed()) return; + + TakeLatency used = m_window->takeLatency(); + QCOMPARE(used.recordingRate, deviceRate); + QVERIFY(!used.measured); + QCOMPARE(used.reportedOutput, + std::lround(reportedOut * rate / deviceRate) / rate); + QCOMPARE(used.reportedInput, reportedIn / deviceRate); + QVERIFY2(std::llabs(used.roundTrip - (reportedOut + reportedIn)) <= 2, + qPrintable(QString("placed with %1 frames at %2 Hz; the " + "device reports %3 + %4") + .arg(used.roundTrip).arg(used.recordingRate) + .arg(reportedOut).arg(reportedIn))); + } + + // The same device, chosen before any file is open, as from the audio + // device menu at startup. The play source has no rate yet, so its + // resampler passes the output latency on as the device counts it, + // and tells the play source the device's rate is 0: the round trip + // is the same, and so is the output latency in seconds + void latency_reported_with_device_opened_first() { + const double deviceRate = 48000.0; + const int reportedOut = 2 * 4096; + const int reportedIn = 4096; + FakeAudioIO::Config config; + config.sampleRate = int(deviceRate); + config.playbackLatency = reportedOut; + config.recordLatency = reportedIn; + config.input = tone(highHz, 2.0); + makeWindow(config); + m_window->recreateAudioIO(); + QVERIFY(m_window->fake()); + openReference(writeWav(tone(lowHz, 1.0))); + if (QTest::currentTestFailed()) return; + m_window->setPlayReferenceWhileRecording(true); + + take(800); + if (QTest::currentTestFailed()) return; + + TakeLatency used = m_window->takeLatency(); + QCOMPARE(used.recordingRate, deviceRate); + QCOMPARE(used.reportedOutput, reportedOut / deviceRate); + QVERIFY2(std::llabs(used.roundTrip - (reportedOut + reportedIn)) <= 2, + qPrintable(QString("placed with %1 frames at %2 Hz; the " + "device reports %3 + %4") + .arg(used.roundTrip).arg(used.recordingRate) + .arg(reportedOut).arg(reportedIn))); + } + + void latency_zero_when_toggle_off() { + FakeAudioIO::Config config; + config.playbackLatency = 4096; + config.recordLatency = 4096; + config.input = tone(highHz, 3.0); + makeWindow(config); + m_window->setPlayReferenceWhileRecording(false); + openReference(writeWav(tone(lowHz, 1.0))); + if (QTest::currentTestFailed()) return; + + take(800); + if (QTest::currentTestFailed()) return; + + QCOMPARE(m_window->recordingLatencyFrames(), sv::sv_frame_t(0)); + auto wave = takeAudio(); + QVERIFY(wave); + QCOMPARE(wave->getStartFrame(), sv::sv_frame_t(0)); + QCOMPARE(m_window->fake()->getPlayStartFrame(), -1L); + // Nothing to compensate for, so the take is where it was recorded + auto ranges = m_window->takes()->getCoverage().getRanges(); + QCOMPARE(int(ranges.size()), 1); + QCOMPARE(ranges[0].start, sv::sv_frame_t(0)); + } + + // Record starts the take at the playback position: what is sung lands + // there on the reference's timeline, and the take's audio file is + // silence up to it + // The cursor, which the view follows, runs from where the take is being + // recorded and not from frame 0 + void take_cursor_runs_from_position() { + FakeAudioIO::Config config; + config.input = tone(highHz, 3.0); + makeWindow(config); + openReference(writeWav(tone(lowHz, 4.0))); + if (QTest::currentTestFailed()) return; + + const sv::sv_frame_t P = sv::sv_frame_t(2.0 * rate); + m_window->seekTo(P); + startTake(); + if (QTest::currentTestFailed()) return; + QTest::qWait(500); + sv::sv_frame_t during = m_window->playbackFrame(); + stopTake(); + if (QTest::currentTestFailed()) return; + + QVERIFY2(during > P && during < P + sv::sv_frame_t(2.0 * rate), + qPrintable(QString("half a second into a take recorded from " + "frame %1 the cursor was at frame %2") + .arg(P).arg(during))); + QCOMPARE(m_window->playbackFrame(), P); + } + + void take_at_playback_position() { + FakeAudioIO::Config config; + config.input = tone(highHz, 3.0); + makeWindow(config); + openReference(writeWav(tone(lowHz, 4.0))); + if (QTest::currentTestFailed()) return; + + const sv::sv_frame_t P = sv::sv_frame_t(2.0 * rate); + m_window->seekTo(P); + take(1000); + if (QTest::currentTestFailed()) return; + QCOMPARE(m_window->takePosition(), P); + QCOMPARE(m_window->recordOverQuestions(), 0); + + auto wave = takeAudio(); + QVERIFY(wave); + QCOMPARE(wave->getStartFrame(), sv::sv_frame_t(0)); + QVERIFY2(wave->getFrameCount() > P + sv::sv_frame_t(0.7 * rate) && + wave->getFrameCount() < P + sv::sv_frame_t(1.6 * rate), + qPrintable(QString("the take's audio is %1 frames long; a " + "second recorded at frame %2 should make " + "it about %3") + .arg(wave->getFrameCount()).arg(P) + .arg(P + sv::sv_frame_t(rate)))); + + auto ranges = m_window->takes()->getCoverage().getRanges(); + QCOMPARE(int(ranges.size()), 1); + QCOMPARE(ranges[0].start, P); + QCOMPARE(ranges[0].end, wave->getFrameCount()); + + // The file holds the singing where it was sung and nothing before + const sv::sv_frame_t margin = sv::sv_frame_t(0.1 * rate); + QVERIFY2(takeAudioRms(0, P - margin) < 0.001, + "the take's audio is not silent before the position it was " + "recorded at"); + QVERIFY(takeAudioRms(P + margin, ranges[0].end - margin) > 0.05); + + // and so does its pitch track + auto events = pitchEvents(m_window->analyser2()); + QVERIFY(!events.empty()); + QVERIFY2(std::llabs(events.front().getFrame() - P) < margin, + qPrintable(QString("the take's pitch track starts at frame " + "%1; it was recorded from frame %2") + .arg(events.front().getFrame()).arg(P))); + QVERIFY(std::fabs(TestSignals::centsBetween + (medianHz(events), highHz)) < 10.0); + + // and the reference is where it was + QVERIFY(std::fabs(TestSignals::centsBetween + (medianHz(pitchEvents(m_window->analyser())), + lowHz)) < 10.0); + } + + // The latency is taken off the front of the recording as it is + // spliced in, rather than carried by the model's start frame. The + // device reports a round trip of K frames and delivers a singer who + // is exactly that late, singing a low note and then a high one; the + // step between them must land 0.75 s after the take's position. + void take_latency_removed_by_splice() { + const int K = 3 * 4096; + FakeAudioIO::Config config; + config.playbackLatency = 2 * 4096; + config.recordLatency = 4096; + config.input = melody(0.75); + config.inputDelay = K; + config.inputFollowsPlayback = true; + makeWindow(config); + m_window->setPlayReferenceWhileRecording(true); + openReference(writeWav(tone(lowHz, 5.0))); + if (QTest::currentTestFailed()) return; + + const sv::sv_frame_t P = sv::sv_frame_t(1.5 * rate); + m_window->seekTo(P); + take(2200); + if (QTest::currentTestFailed()) return; + + QVERIFY2(m_window->fake()->getPlayStartFrame() >= 0, + "the reference was never played"); + QVERIFY(m_window->recordingLatencyFrames() >= K); + + auto wave = takeAudio(); + QVERIFY(wave); + QVERIFY2(wave->getStartFrame() == sv::sv_frame_t(0), + "the take's audio is shifted for the latency instead of " + "being spliced with it taken off"); + + auto ranges = m_window->takes()->getCoverage().getRanges(); + QCOMPARE(int(ranges.size()), 1); + QCOMPARE(ranges[0].start, P); + + auto events = pitchEvents(m_window->analyser2()); + sv::sv_frame_t step = stepFrame(events); + sv::sv_frame_t want = P + sv::sv_frame_t(0.75 * rate); + QVERIFY2(step > 0, "the take never reached the second note"); + sv::sv_frame_t error = step - want; + QVERIFY2(std::llabs(error) <= 4 * hop, + qPrintable(QString("the sung step is at frame %1, %2 frames " + "(%3 ms) from where it was sung (%4); the " + "round trip is %5 frames and the " + "application compensated %6") + .arg(step).arg(error) + .arg(1000.0 * double(error) / rate, 0, 'f', 1) + .arg(want).arg(K) + .arg(m_window->recordingLatencyFrames()))); + + QVERIFY(!events.empty()); + QVERIFY2(events.front().getFrame() >= P - 4 * hop, + qPrintable(QString("the take's pitch track starts at frame " + "%1, before the position %2 it was " + "recorded from") + .arg(events.front().getFrame()).arg(P))); + } + + // Recording into the middle of a take replaces what is there from + // that point on and leaves the rest. The singer sings a low note and + // then a high one; recording the melody again from inside the high + // note leaves the high note only where the second recording did not + // reach. + void take_over_existing_replaces_it() { + FakeAudioIO::Config config; + config.input = melody(0.75); + makeWindow(config); + openReference(writeWav(tone(lowHz, 4.0))); + if (QTest::currentTestFailed()) return; + + take(1600); + if (QTest::currentTestFailed()) return; + sv::sv_frame_t step = stepFrame(pitchEvents(m_window->analyser2())); + QVERIFY2(step > 0 && std::llabs(step - sv::sv_frame_t(0.75 * rate)) < + sv::sv_frame_t(0.1 * rate), + qPrintable(QString("the first recording's step to the high " + "note is at frame %1, not about %2") + .arg(step).arg(sv::sv_frame_t(0.75 * rate)))); + QString before = m_window->takes()->getAudioPath(); + sv::sv_frame_t firstEnd = + m_window->takes()->getCoverage().getEndFrame(); + + const sv::sv_frame_t P = sv::sv_frame_t(1.1 * rate); + m_window->seekTo(P); + m_window->clearRecordOverQuestions(); + take(700); + if (QTest::currentTestFailed()) return; + + // The playhead was inside the singing, so the user was asked + QCOMPARE(m_window->recordOverQuestions(), 1); + + // One range still, and it reaches further than the first take did + auto ranges = m_window->takes()->getCoverage().getRanges(); + QCOMPARE(int(ranges.size()), 1); + QCOMPARE(ranges[0].start, sv::sv_frame_t(0)); + QVERIFY(ranges[0].end >= P + sv::sv_frame_t(0.6 * rate)); + QVERIFY(ranges[0].end > firstEnd); + + // A new file, with the one before kept for undo + QVERIFY(m_window->takes()->getAudioPath() != before); + QCOMPARE(m_window->takes()->getSupersededPaths(), + QStringList { before }); + QVERIFY2(QFileInfo::exists(before), + "the audio file the take had before was not kept"); + + auto events = pitchEvents(m_window->analyser2()); + double mid = std::sqrt(lowHz * highHz); + + auto opening = eventsBetween(events, sv::sv_frame_t(0.1 * rate), + sv::sv_frame_t(0.6 * rate)); + QVERIFY(!opening.empty()); + QVERIFY2(medianHz(opening) < mid, + "the low note the first recording opened with is gone"); + + auto kept = eventsBetween(events, sv::sv_frame_t(0.85 * rate), + sv::sv_frame_t(1.05 * rate)); + QVERIFY(!kept.empty()); + QVERIFY2(medianHz(kept) > mid, + "the high note is gone from before the second recording, " + "which should not have touched it"); + + auto replaced = eventsBetween(events, P + sv::sv_frame_t(0.1 * rate), + P + sv::sv_frame_t(0.6 * rate)); + QVERIFY(!replaced.empty()); + QVERIFY2(medianHz(replaced) < mid, + "the high note is still there where the second recording " + "sang a low one over it"); + } + + // A recording in a gap leaves the gap a gap: the file grows to hold + // it, with silence in between + void take_in_a_gap_grows_the_file() { + FakeAudioIO::Config config; + config.input = tone(highHz, 3.0); + makeWindow(config); + openReference(writeWav(tone(lowHz, 5.0))); + if (QTest::currentTestFailed()) return; + + take(800); + if (QTest::currentTestFailed()) return; + sv::sv_frame_t firstEnd = + m_window->takes()->getCoverage().getEndFrame(); + QVERIFY(firstEnd > sv::sv_frame_t(0.6 * rate)); + + const sv::sv_frame_t P = sv::sv_frame_t(2.5 * rate); + m_window->seekTo(P); + m_window->clearRecordOverQuestions(); + take(800); + if (QTest::currentTestFailed()) return; + + // Recording in a gap takes nothing away, so nothing is asked + QCOMPARE(m_window->recordOverQuestions(), 0); + + auto ranges = m_window->takes()->getCoverage().getRanges(); + QCOMPARE(int(ranges.size()), 2); + QCOMPARE(ranges[0], Coverage::Range(0, firstEnd)); + QCOMPARE(ranges[1].start, P); + + auto wave = takeAudio(); + QVERIFY(wave); + QCOMPARE(wave->getFrameCount(), ranges[1].end); + + const sv::sv_frame_t margin = sv::sv_frame_t(0.1 * rate); + QVERIFY(takeAudioRms(margin, firstEnd - margin) > 0.05); + QVERIFY2(takeAudioRms(firstEnd + margin, P - margin) < 0.001, + "the gap between the two recordings is not silent"); + QVERIFY(takeAudioRms(P + margin, ranges[1].end - margin) > 0.05); + + auto events = pitchEvents(m_window->analyser2()); + QVERIFY(!eventsBetween(events, 0, firstEnd).empty()); + QVERIFY(!eventsBetween(events, P, ranges[1].end).empty()); + QVERIFY2(eventsBetween(events, firstEnd + margin, P - margin).empty(), + "the pitch track has something in the gap between the two " + "recordings"); + } + + // A device at 48 kHz, as phones are, against a reference that is a + // 44.1 kHz model: the recording is made at the device's rate and the + // take is on the reference's timeline. Two recordings, the second in + // the gap after the first, must each land where and be as long as + // they were sung. The same at 44.1 kHz, where nothing is converted + void takes_placed_from_a_device_at_44100() { + verifyTakesPlacedFromDeviceAt(44100); + } + + void takes_placed_from_a_device_at_48000() { + verifyTakesPlacedFromDeviceAt(otherDeviceRate); + } + + // The singer of take_latency_removed_by_splice, exactly on time, on a + // device at 48 kHz: the round trip and the start gap are counted in + // the device's frames, the reference in its own, and the step the + // singer sings has to land where the reference steps all the same, + // in the live dots and in the pitch track + void latency_with_a_device_at_48000() { + const int K = 3 * 4096; + FakeAudioIO::Config config; + config.sampleRate = otherDeviceRate; + config.playbackLatency = 2 * 4096; + config.recordLatency = 4096; + config.input = twoTonesAt(lowHz, 0.75, highHz, 0.75, + otherDeviceRate); + config.inputDelay = K; + config.inputFollowsPlayback = true; + // The play source hands the device the reference in blocks counted + // at the reference's rate, and a block of the device's is 8% more + // of them at 48 kHz: big enough a block for that to show in the + // start gap + config.blockSize = 2048; + makeWindow(config); + m_window->setPlayReferenceWhileRecording(true); + + // The reference steps 0.75 s after the take's position, as the + // singer does 0.75 s after hearing it there + const sv::sv_frame_t P = sv::sv_frame_t(1.5 * rate); + openReference(writeWav(twoNotes(1.5 + 0.75, 0.75))); + if (QTest::currentTestFailed()) return; + + m_window->seekTo(P); + startTake(); + if (QTest::currentTestFailed()) return; + QTest::qWait(2200); + auto model = sv::ModelById::getAs + (m_window->realtimeModelId()); + QVERIFY(model); + // The dots are on the reference's timeline, and at its rate + QCOMPARE(model->getSampleRate(), sv::sv_samplerate_t(rate)); + auto dots = model->getAllEvents(); + stopTake(); + if (QTest::currentTestFailed()) return; + + QVERIFY2(m_window->fake()->getPlayStartFrame() >= 0, + "the reference was never played"); + + // The compensation is in the device's frames: the round trip it + // reports, and the start gap it knows the real length of. Not to + // within a few frames, as at 44.1 kHz: bqaudioio's ResamplerWrapper, + // which brings the reference to the device's rate, holds it back by + // some tens of frames of its own that nothing reports (about 25 by + // the first audible sample, half a millisecond) + sv::sv_frame_t gap = m_window->fake()->getFramesBeforePlayStart(); + sv::sv_frame_t assumedGap = m_window->recordingLatencyFrames() - K; + QVERIFY2(std::llabs(assumedGap - gap) <= 64, + qPrintable(QString("the application took the start gap to " + "be %1 frames; it was %2") + .arg(assumedGap).arg(gap))); + + sv::sv_frame_t refStep = stepFrame(pitchEvents(m_window->analyser())); + sv::sv_frame_t dotStep = stepFrame(dots); + sv::sv_frame_t sungStep = + stepFrame(pitchEvents(m_window->analyser2())); + QVERIFY(refStep > P); + QVERIFY2(dotStep > 0, "the live dots never reached the second note"); + QVERIFY2(sungStep > 0, "the take never reached the second note"); + + // The dots are coarser than pYIN: allow them a YIN window + QVERIFY2(std::llabs(dotStep - refStep) <= 2048, + qPrintable(QString("live step at %1, reference step at %2: " + "%3 frames apart") + .arg(dotStep).arg(refStep) + .arg(dotStep - refStep))); + QVERIFY2(std::llabs(sungStep - refStep) <= 2 * hop, + qPrintable(QString("sung step at %1, reference step at %2: " + "%3 frames (%4 ms) apart") + .arg(sungStep).arg(refStep) + .arg(sungStep - refStep) + .arg(1000.0 * double(sungStep - refStep) / rate, + 0, 'f', 1))); + + auto ranges = m_window->takes()->getCoverage().getRanges(); + QCOMPARE(int(ranges.size()), 1); + QCOMPARE(ranges[0].start, P); + auto events = pitchEvents(m_window->analyser2()); + QVERIFY(!events.empty()); + QVERIFY(events.front().getFrame() >= P - 4 * hop); + } + + // Record into Selection with a pre-roll, on a device at 48 kHz: the + // lead-in and the selection are on the reference's timeline and the + // frames the device delivers are at its own rate. The take has to + // wait for the whole of the selection before it stops itself, and + // keep exactly the selection + void preroll_and_punch_out_with_a_device_at_48000() { + const double leadIn = 0.5; + FakeAudioIO::Config config; + config.sampleRate = otherDeviceRate; + // High during the lead-in, low for a little longer than the + // selection, then high again: only the low note belongs in the take + config.input = twoTonesAt(highHz, leadIn, lowHz, 0.85, + otherDeviceRate); + auto after = toneAt(highHz, 0.8, otherDeviceRate); + config.input.insert(config.input.end(), after.begin(), after.end()); + makeWindow(config); + m_window->setPlayReferenceWhileRecording(false); + m_window->setRecordIntoSelection(true); + setPreRollSeconds(leadIn); + m_window->setPreRoll(true); + openReference(writeWav(tone(lowHz, 4.0))); + if (QTest::currentTestFailed()) return; + + const sv::sv_frame_t P = sv::sv_frame_t(1.0 * rate); + const sv::sv_frame_t E = sv::sv_frame_t(1.8 * rate); + m_window->selectRange(P, E); + m_window->seekTo(P); + startTake(); + if (QTest::currentTestFailed()) return; + QCOMPARE(m_window->takePosition(), P); + QCOMPARE(m_window->takeEnd(), E); + QCOMPARE(m_window->takePreRoll(), sv::sv_frame_t(leadIn * rate)); + QString path; + { + auto recording = sv::ModelById::getAs + (m_window->currentRecordingModelId()); + QVERIFY(recording); + path = recording->getLocation(); + } + + // Once the lead-in is over, the cursor has run through it from + // its start at the reference's pace, not at the device's faster + // one. The record target's duration is the GUI thread's, as the + // cursor's is, so the two are read at the same point + QTRY_VERIFY_WITH_TIMEOUT(m_window->recordTarget()->getRecordDuration() + > sv::sv_frame_t(leadIn * otherDeviceRate), + 5000); + QVERIFY(m_window->recordTarget()->isRecording()); + sv::sv_frame_t during = m_window->playbackFrame(); + sv::sv_frame_t recordedSoFar = + m_window->recordTarget()->getRecordDuration(); + sv::sv_frame_t cursorWant = P - m_window->takePreRoll() + + sv::sv_frame_t(std::llround(double(recordedSoFar) * rate / + otherDeviceRate)); + QVERIFY2(std::llabs(during - cursorWant) <= 1, + qPrintable(QString("%1 frames at %2 Hz into a take whose " + "lead-in starts at frame %3, the cursor " + "is at %4, not %5") + .arg(recordedSoFar).arg(otherDeviceRate) + .arg(P - m_window->takePreRoll()) + .arg(during).arg(cursorWant))); + QVERIFY(during >= P); + + QTRY_VERIFY_WITH_TIMEOUT(!m_window->recordTarget()->isRecording(), 5000); + QTRY_VERIFY_WITH_TIMEOUT(analysed(m_window->analyser2()), 30000); + + // It stopped once the lead-in, the selection and most of the + // margin had been recorded, counted at the device's rate + sv::WavFileReader reader { sv::FileSource(path) }; + QVERIFY(reader.isOK()); + sv::sv_frame_t needed = sv::sv_frame_t + ((leadIn + double(E - P) / rate + + 0.8 * TakeTiming::autoStopMarginSeconds()) * otherDeviceRate); + QVERIFY2(reader.getFrameCount() >= needed, + qPrintable(QString("the take stopped itself after %1 frames " + "at %2 Hz; the lead-in, the selection " + "and the margin are %3") + .arg(reader.getFrameCount()).arg(otherDeviceRate) + .arg(needed))); + + auto ranges = m_window->takes()->getCoverage().getRanges(); + QCOMPARE(int(ranges.size()), 1); + QCOMPARE(ranges[0], Coverage::Range(P, E)); + + auto wave = takeAudio(); + QVERIFY(wave); + QTRY_VERIFY(wave->isReady()); + QCOMPARE(wave->getFrameCount(), E); + sv::sv_frame_t first = -1, last = -1; + soundInTake(0, E + sv::sv_frame_t(0.1 * rate), first, last); + const sv::sv_frame_t close = sv::sv_frame_t(0.01 * rate); + QVERIFY2(std::llabs(first - P) <= close && + std::llabs(last - E) <= close, + qPrintable(QString("the take's audio is loud over [%1,%2]; " + "the selection is [%3,%4)") + .arg(first).arg(last).arg(P).arg(E))); + + // What it kept is the note sung inside the selection, not the one + // of the lead-in or the one after its end + auto events = pitchEvents(m_window->analyser2()); + QVERIFY(!events.empty()); + QVERIFY2(std::fabs(TestSignals::centsBetween + (medianHz(events), lowHz)) < 10.0, + qPrintable(QString("the take's median pitch is %1 Hz; the " + "selection was sung at %2, the lead-in " + "and what came after at %3") + .arg(medianHz(events)).arg(lowHz).arg(highHz))); + } + + // Stop no longer analyses the whole of the take's audio: the new + // audio goes under the pitch and notes layers that are there and only + // the range the recording went into is analysed and merged into them. + // So a second take elsewhere leaves the first recording's events + // exactly as they were, in the very same layers and models. + void take_analyses_only_the_new_range() { + FakeAudioIO::Config config; + config.input = tone(highHz, 4.0); + makeWindow(config); + openReference(writeWav(tone(lowHz, 5.0))); + if (QTest::currentTestFailed()) return; + + take(900); + if (QTest::currentTestFailed()) return; + + Analyser *a2 = m_window->analyser2(); + QVERIFY(a2); + sv::Layer *pitch = a2->getLayer(Analyser::PitchTrack); + sv::Layer *notes = a2->getLayer(Analyser::Notes); + QVERIFY(pitch && notes); + sv::ModelId pitchModel = pitch->getModel(); + sv::ModelId notesModel = notes->getModel(); + sv::ModelId audio = a2->getMainModelId(); + auto before = pitchEvents(pitch); + auto notesBefore = noteEvents(notes); + QVERIFY(before.size() > 50); + QVERIFY(!notesBefore.empty()); + + // Two seconds on: further than the half second of context the + // analysis of the second recording takes for itself + const sv::sv_frame_t P = sv::sv_frame_t(2.5 * rate); + m_window->seekTo(P); + startTake(); + if (QTest::currentTestFailed()) return; + QTest::qWait(700); + + // Stop splices the recording in and asks for the analysis of the + // range it went into. The range is read here, while that run is + // held: it is remembered only until the merge + m_window->holdRangedMerges(true); + m_window->doRecord(); + QVERIFY(!m_window->recordTarget()->isRecording()); + QVERIFY(m_window->analysingRange()); + QCOMPARE(m_window->analysedRangeStart(), P); + m_window->holdRangedMerges(false); + QTRY_VERIFY_WITH_TIMEOUT(analysed(m_window->analyser2()), 30000); + + // The analysis of the take was not thrown away and run again: the + // layers, and the models under them, are the same objects + Analyser *after = m_window->analyser2(); + QVERIFY(after); + QCOMPARE(after->getLayer(Analyser::PitchTrack), pitch); + QCOMPARE(after->getLayer(Analyser::Notes), notes); + QCOMPARE(pitch->getModel(), pitchModel); + QCOMPARE(notes->getModel(), notesModel); + QVERIFY2(after->getMainModelId() != audio, + "the take's audio is not the file the splice wrote"); + QVERIFY2(!sv::ModelById::get(audio), + "the audio the take had before was not released"); + + // and every event before the second recording is the very same + // event, frame and value: nothing there was analysed again + auto keptPitch = eventsBetween(pitchEvents(pitch), 0, P); + auto wasPitch = eventsBetween(before, 0, P); + QCOMPARE(keptPitch.size(), wasPitch.size()); + for (size_t i = 0; i < wasPitch.size(); ++i) { + QCOMPARE(keptPitch[i].getFrame(), wasPitch[i].getFrame()); + QCOMPARE(keptPitch[i].getValue(), wasPitch[i].getValue()); + } + + auto keptNotes = eventsBetween(noteEvents(notes), 0, P); + auto wasNotes = eventsBetween(notesBefore, 0, P); + QCOMPARE(keptNotes.size(), wasNotes.size()); + for (size_t i = 0; i < wasNotes.size(); ++i) { + QCOMPARE(keptNotes[i].getFrame(), wasNotes[i].getFrame()); + QCOMPARE(keptNotes[i].getDuration(), wasNotes[i].getDuration()); + QCOMPARE(keptNotes[i].getValue(), wasNotes[i].getValue()); + } + + // The second recording was analysed, and in the right place + QVERIFY(!eventsBetween(pitchEvents(pitch), P, + P + sv::sv_frame_t(0.6 * rate)).empty()); + verifyPlaySourceClean(); + } + + // Two punch-ins that meet at J inside a note held through both. The + // first's note ends at J, where its recording stopped; the analysis + // of the second starts half a second before J, inside the note, so + // the note it finds begins before the window the merge replaces. It + // is the same note going on, and the take has one note through J. + // Undoing the second punch-in gives the first's note back exactly + void join_inside_a_held_note_keeps_one_note() { + FakeAudioIO::Config config; + config.input = tone(lowHz, 3.0); + makeWindow(config); + m_window->setPlayReferenceWhileRecording(false); + m_window->setRecordIntoSelection(true); + openReference(writeWav(tone(highHz, 3.5))); + if (QTest::currentTestFailed()) return; + + const sv::sv_frame_t P = sv::sv_frame_t(0.5 * rate); + const sv::sv_frame_t J = sv::sv_frame_t(1.5 * rate); + const sv::sv_frame_t E = sv::sv_frame_t(2.5 * rate); + + // Record into Selection, so that each punch-in stops exactly at + // the end of its selection and the two meet at J + auto punchIn = [this](sv::sv_frame_t from, sv::sv_frame_t to) { + m_window->clearSelections(); + m_window->selectRange(from, to); + m_window->seekTo(from); + startTake(); + if (QTest::currentTestFailed()) return; + QTRY_VERIFY_WITH_TIMEOUT + (!m_window->recordTarget()->isRecording(), 5000); + QTRY_VERIFY_WITH_TIMEOUT(analysed(m_window->analyser2()), 30000); + }; + auto describe = [](const sv::EventVector ¬es) { + QStringList out; + for (const auto &e : notes) { + out << QString("%1 to %2 s at %3 Hz") + .arg(double(e.getFrame()) / rate, 0, 'f', 3) + .arg(double(e.getFrame() + e.getDuration()) / rate, + 0, 'f', 3) + .arg(double(e.getValue()), 0, 'f', 1); + } + return "[" + out.join(", ") + "]"; + }; + + punchIn(P, J); + if (QTest::currentTestFailed()) return; + TakeSnapshot first = snapshotTake(); + QCOMPARE(int(first.coverage.size()), 1); + QCOMPARE(first.coverage[0], Coverage::Range(P, J)); + + // The first punch-in's note runs to the join + QVERIFY2(first.notes.size() == 1, qPrintable(describe(first.notes))); + const sv::Event held = first.notes[0]; + QVERIFY2(std::llabs(held.getFrame() - P) <= 4 * hop && + std::llabs(held.getFrame() + held.getDuration() - J) + <= 4 * hop, qPrintable(describe(first.notes))); + + punchIn(J, E); + if (QTest::currentTestFailed()) return; + TakeSnapshot second = snapshotTake(); + QCOMPARE(int(second.coverage.size()), 1); + QCOMPARE(second.coverage[0], Coverage::Range(P, E)); + + // One note, the first's carried on through J to the end of the + // second punch-in: its onset, pitch and label as they were + QVERIFY2(second.notes.size() == 1 && + second.notes[0].getFrame() == held.getFrame() && + second.notes[0].getValue() == held.getValue() && + second.notes[0].getLabel() == held.getLabel() && + std::llabs(second.notes[0].getFrame() + + second.notes[0].getDuration() - E) <= 4 * hop, + qPrintable(QString("the notes after the second punch-in " + "are %1; the first's was %2, the join " + "is at %3 s") + .arg(describe(second.notes)) + .arg(describe(first.notes)) + .arg(double(J) / rate, 0, 'f', 3))); + + // Undo gives the first punch-in's note back, exactly as it was, + // and redo the one note again + QCOMPARE(undoOnce(), QString("Record Singing")); + verifyTakeMatches(first); + if (QTest::currentTestFailed()) return; + QCOMPARE(redoOnce(), QString("Record Singing")); + verifyTakeMatches(second); + } + + // Recording again while the analysis of the range just recorded is + // still running. That analysis is lost -- the swap releases the models + // it was to be merged into -- so the analysis that follows has to + // cover both ranges, or the first recording would have no pitch track + // at all (the first recording of a take starts from empty models). + void take_analysis_covers_the_range_it_lost() { + FakeAudioIO::Config config; + config.input = tone(highHz, 4.0); + makeWindow(config); + openReference(writeWav(tone(lowHz, 5.0))); + if (QTest::currentTestFailed()) return; + + // The merges are held until both takes have stopped: pYIN may + // analyse the first range before its Stop returns, and then there + // is nothing left to lose + m_window->holdRangedMerges(true); + + startTake(); + if (QTest::currentTestFailed()) return; + QTest::qWait(900); + + // Stop splices the recording in and starts the analysis of the + // range it went into there and then + m_window->doRecord(); + QVERIFY(!m_window->recordTarget()->isRecording()); + QVERIFY2(m_window->analysingRange(), + "the race was not set up: no range was being analysed when " + "the second take started"); + QCOMPARE(m_window->analysedRangeStart(), sv::sv_frame_t(0)); + sv::sv_frame_t firstEnd = m_window->analysedRangeEnd(); + QVERIFY(firstEnd > sv::sv_frame_t(0.7 * rate)); + + // A second take in a gap, recorded without letting the event + // loop run, as it was before the merges could be held. (The + // device records from a thread of its own, and the record + // target's ring buffer holds ten seconds.) + const sv::sv_frame_t P = sv::sv_frame_t(3.0 * rate); + m_window->seekTo(P); + startTake(); + if (QTest::currentTestFailed()) return; + QThread::msleep(250); + QVERIFY2(m_window->analysingRange(), + "the first range's analysis finished before the second take " + "stopped"); + m_window->doRecord(); + QVERIFY(!m_window->recordTarget()->isRecording()); + + // The analysis now running covers both recordings + QVERIFY(m_window->analysingRange()); + QCOMPARE(m_window->analysedRangeStart(), sv::sv_frame_t(0)); + QVERIFY(m_window->analysedRangeEnd() > P); + + m_window->holdRangedMerges(false); + QTRY_VERIFY_WITH_TIMEOUT(analysed(m_window->analyser2()), 30000); + auto events = pitchEvents(m_window->analyser2()); + QVERIFY2(!eventsBetween(events, 0, firstEnd).empty(), + "the first recording was left without a pitch track when " + "the second one interrupted its analysis"); + QVERIFY(!eventsBetween(events, P, + P + sv::sv_frame_t(0.2 * rate)).empty()); + verifyPlaySourceClean(); + } + + // The models the analysis of a recorded range is to be merged into, + // torn down while it is still running: by another singing track, and + // by the session going. A regression guard for the area this fork has + // crashed in before -- a crash is the failure. + void range_analysis_torn_down_while_running() { + FakeAudioIO::Config config; + config.input = tone(highHz, 3.0); + makeWindow(config); + openReference(writeWav(tone(lowHz, 3.0))); + if (QTest::currentTestFailed()) return; + + // Held, so that each merge is still to come when its models go, + // however quickly pYIN analyses the range. Never let go: it is + // torn down each time + m_window->holdRangedMerges(true); + + startTake(); + if (QTest::currentTestFailed()) return; + QTest::qWait(900); + m_window->doRecord(); + QVERIFY2(m_window->analysingRange(), + "the race was not set up: nothing was being analysed after " + "Stop"); + + // Another singing track over it + m_window->loadSingingTrack(writeWav(tone(lowHz, 1.0))); + QTRY_VERIFY_WITH_TIMEOUT(analysed(m_window->analyser2()), 30000); + QVERIFY(!m_window->analysingRange()); + verifyPlaySourceClean(); + if (QTest::currentTestFailed()) return; + + // and the same again, with the session closed under it + m_window->seekTo(0); + startTake(); + if (QTest::currentTestFailed()) return; + QTest::qWait(700); + m_window->doRecord(); + QVERIFY(m_window->analysingRange()); + + m_window->doCloseSession(); + QVERIFY(!m_window->analyser2()); + QVERIFY(!m_window->takes()->haveTake()); + QCOMPARE(m_window->paneStack()->getPaneCount(), 0); + QCOMPARE(m_window->paneStack()->getHiddenPaneCount(), 0); + QTest::qWait(300); // anything still queued arrives here + + // and the window still works + openReference(writeWav(tone(highHz, 1.0))); + } + + // The question asked before recording over singing that is there, and + // what the answer does. The dialog itself is not shown here: + // TestMainWindow answers it (the real one has "Don't ask again"). + void record_over_existing_question() { + FakeAudioIO::Config config; + config.input = tone(highHz, 3.0); + makeWindow(config); + openReference(writeWav(tone(lowHz, 4.0))); + if (QTest::currentTestFailed()) return; + + take(700); + if (QTest::currentTestFailed()) return; + // There was nothing to record over + QCOMPARE(m_window->recordOverQuestions(), 0); + sv::sv_frame_t end = m_window->takes()->getCoverage().getEndFrame(); + + // In a gap after it: nothing asked + m_window->seekTo(end + sv::sv_frame_t(1.0 * rate)); + take(400); + if (QTest::currentTestFailed()) return; + QCOMPARE(m_window->recordOverQuestions(), 0); + + // Inside it, answered no: no recording, and nothing changed + QString audio = m_window->takes()->getAudioPath(); + m_window->setRecordOverAnswer(false); + m_window->seekTo(sv::sv_frame_t(0.2 * rate)); + m_window->doRecord(); + QCOMPARE(m_window->recordOverQuestions(), 1); + QVERIFY2(!m_window->recordTarget()->isRecording(), + "the recording started although the question was answered " + "with no"); + QVERIFY(!m_window->recordingAsSingingTrack()); + QVERIFY(!m_window->realtimeLayer()); + QCOMPARE(m_window->takes()->getAudioPath(), audio); + + // Answered yes: it records + m_window->setRecordOverAnswer(true); + take(400); + if (QTest::currentTestFailed()) return; + QCOMPARE(m_window->recordOverQuestions(), 2); + QVERIFY(m_window->takes()->getAudioPath() != audio); + + // "Don't ask again", and it is not asked + SingingTakes::setOverwriteConfirmationWanted(false); + m_window->seekTo(sv::sv_frame_t(0.2 * rate)); + take(400); + if (QTest::currentTestFailed()) return; + QCOMPARE(m_window->recordOverQuestions(), 2); + } + + // Pre-roll: the reference is played from before the take's position + // and the singer comes in with it, but nothing sung during that + // lead-in goes into the take. The singer here sings a low note + // through the lead-in and a high one after it: the take is to hold + // the high note only, and to be the lead-in shorter than what the + // device recorded. + void preroll_lead_is_not_recorded() { + const double leadIn = 0.5; + FakeAudioIO::Config config; + config.input = twoNotes(0.6, 1.0); + makeWindow(config); + // Nothing to hear, so the round trip is zero and the lead-in is + // the whole of what the splice skips (spec 5.1) + m_window->setPlayReferenceWhileRecording(false); + setPreRollSeconds(leadIn); + m_window->setPreRoll(true); + openReference(writeWav(tone(lowHz, 4.0))); + if (QTest::currentTestFailed()) return; + + const sv::sv_frame_t P = sv::sv_frame_t(2.0 * rate); + const sv::sv_frame_t R = sv::sv_frame_t(leadIn * rate); + m_window->seekTo(P); + take(1300); + if (QTest::currentTestFailed()) return; + + QCOMPARE(m_window->takePosition(), P); + QCOMPARE(m_window->takePreRoll(), R); + QCOMPARE(m_window->recordOverQuestions(), 0); + + // The take starts where it was told to, whatever came before + auto ranges = m_window->takes()->getCoverage().getRanges(); + QCOMPARE(int(ranges.size()), 1); + QCOMPARE(ranges[0].start, P); + + // and holds the lead-in less than the 1.3 s that was recorded + sv::sv_frame_t length = ranges[0].length(); + QVERIFY2(length > sv::sv_frame_t(0.45 * rate) && + length < sv::sv_frame_t(0.95 * rate), + qPrintable(QString("1.3 s was recorded with a lead-in of " + "%1 frames, and %2 frames of it went " + "into the take") + .arg(R).arg(length))); + + const sv::sv_frame_t margin = sv::sv_frame_t(0.1 * rate); + QVERIFY2(takeAudioRms(0, P - margin) < 0.001, + "the take's audio is not silent before the position it was " + "recorded at"); + + // The note sung during the lead-in is not in the take: what is at + // its position is the note that came after the lead-in + auto events = pitchEvents(m_window->analyser2()); + QVERIFY(!events.empty()); + auto sung = eventsBetween(events, P + margin, ranges[0].end - margin); + QVERIFY(!sung.empty()); + QVERIFY2(std::fabs(TestSignals::centsBetween + (medianHz(sung), highHz)) < 10.0, + qPrintable(QString("the take's median pitch at its position " + "is %1 Hz; the lead-in was sung at %2 and " + "the take itself at %3") + .arg(medianHz(sung)).arg(lowHz).arg(highHz))); + } + + // What the lead-in looks and sounds like while it runs: the reference + // is played from S, the cursor runs with it from there, the status bar + // counts down, and no dot is drawn until the take's position + void preroll_counts_down_before_the_position() { + const double leadIn = 1.2; + FakeAudioIO::Config config; + config.input = tone(highHz, 3.0); + makeWindow(config); + m_window->setPlayReferenceWhileRecording(true); + setPreRollSeconds(leadIn); + m_window->setPreRoll(true); + openReference(writeWav(tone(lowHz, 4.0))); + if (QTest::currentTestFailed()) return; + + const sv::sv_frame_t P = sv::sv_frame_t(2.0 * rate); + const sv::sv_frame_t R = sv::sv_frame_t(leadIn * rate); + m_window->seekTo(P); + startTake(); + if (QTest::currentTestFailed()) return; + QCOMPARE(m_window->takePreRoll(), R); + QTest::qWait(400); + + QVERIFY2(m_window->fake()->getPlayStartFrame() >= 0, + "the reference was never played"); + + sv::sv_frame_t during = m_window->playbackFrame(); + QVERIFY2(during >= P - R && during < P, + qPrintable(QString("0.4 s into a take at frame %1 with a " + "lead-in of %2 frames, the cursor is at " + "frame %3; it should be running through " + "the lead-in") + .arg(P).arg(R).arg(during))); + + QVERIFY2(m_window->statusText().startsWith("Recording in "), + qPrintable(QString("the status bar says \"%1\" during the " + "lead-in, rather than counting down") + .arg(m_window->statusText()))); + + auto model = sv::ModelById::getAs + (m_window->realtimeModelId()); + QVERIFY(model); + QVERIFY2(model->getEventCount() == 0, + qPrintable(QString("%1 live dots were drawn during the " + "lead-in, before the take's position") + .arg(model->getEventCount()))); + + // Past the lead-in the dots come, none of them before the take's + // position, and there is nothing left to count down + QTest::qWait(1400); + QVERIFY(model->getEventCount() > 10); + for (const auto &e : model->getAllEvents()) { + QVERIFY2(e.getFrame() >= P, + qPrintable(QString("a live dot at frame %1, before the " + "take's position %2") + .arg(e.getFrame()).arg(P))); + } + QVERIFY(!m_window->statusText().startsWith("Recording in ")); + + stopTake(); + if (QTest::currentTestFailed()) return; + auto ranges = m_window->takes()->getCoverage().getRanges(); + QCOMPARE(int(ranges.size()), 1); + QCOMPARE(ranges[0].start, P); + } + + // Record into Selection: the selection says where the take starts and + // where it ends, the take stops by itself there, and nothing sung + // afterwards is used. Then a second one over the first, which is not + // asked about, into the selection the playhead is in, and with a + // pre-roll as well. + void punch_out_stops_at_the_end_of_the_selection() { + FakeAudioIO::Config config; + // Low note for a little longer than the selection, then high: the + // high note is what must not reach the take + config.input = twoNotes(0.85, 0.8); + makeWindow(config); + m_window->setPlayReferenceWhileRecording(false); + m_window->setRecordIntoSelection(true); + openReference(writeWav(tone(highHz, 4.0))); + if (QTest::currentTestFailed()) return; + + const sv::sv_frame_t P = sv::sv_frame_t(1.0 * rate); + const sv::sv_frame_t E = sv::sv_frame_t(1.8 * rate); + m_window->selectRange(P, E); + m_window->seekTo(sv::sv_frame_t(0.3 * rate)); // outside the selection + startTake(); + if (QTest::currentTestFailed()) return; + + // The selection is recorded into whatever the playhead says + QCOMPARE(m_window->takePosition(), P); + QCOMPARE(m_window->takeEnd(), E); + QVERIFY(m_window->takeTimerRunning()); + + // Nobody presses Stop: the take ends itself once the singing for + // the end of the selection has arrived + QTRY_VERIFY_WITH_TIMEOUT(!m_window->recordTarget()->isRecording(), 5000); + QVERIFY2(!m_window->takeTimerRunning(), + "the timer that watches the take is still running after it"); + QTRY_VERIFY_WITH_TIMEOUT(analysed(m_window->analyser2()), 30000); + + // What it kept is exactly the selection, margin and all discarded + auto ranges = m_window->takes()->getCoverage().getRanges(); + QCOMPARE(int(ranges.size()), 1); + QCOMPARE(ranges[0], Coverage::Range(P, E)); + + auto wave = takeAudio(); + QVERIFY(wave); + QCOMPARE(wave->getFrameCount(), E); + + // and what it kept is the note sung inside the selection, not the + // one that came after its end + auto events = pitchEvents(m_window->analyser2()); + QVERIFY(!events.empty()); + QVERIFY2(std::fabs(TestSignals::centsBetween + (medianHz(events), lowHz)) < 10.0, + qPrintable(QString("the take's median pitch is %1 Hz; the " + "selection was sung at %2 and what came " + "after its end at %3") + .arg(medianHz(events)).arg(lowHz).arg(highHz))); + + // A second punch, over the singing that is now there: the + // selection is the consent, so nothing is asked (spec 5.1). It is + // the selection the playhead is in that is recorded into, and the + // lead-in of the pre-roll is not taken out of it. + const sv::sv_frame_t P2 = sv::sv_frame_t(1.2 * rate); + const sv::sv_frame_t E2 = sv::sv_frame_t(1.9 * rate); + setPreRollSeconds(0.5); + m_window->setPreRoll(true); + m_window->clearSelections(); + m_window->selectRange(sv::sv_frame_t(0.2 * rate), + sv::sv_frame_t(0.5 * rate)); + m_window->selectRange(P2, E2); + m_window->seekTo(sv::sv_frame_t(1.5 * rate)); // in the second one + m_window->clearRecordOverQuestions(); + startTake(); + if (QTest::currentTestFailed()) return; + + QCOMPARE(m_window->recordOverQuestions(), 0); + QCOMPARE(m_window->takePosition(), P2); + QCOMPARE(m_window->takeEnd(), E2); + QCOMPARE(m_window->takePreRoll(), sv::sv_frame_t(0.5 * rate)); + + QTRY_VERIFY_WITH_TIMEOUT(!m_window->recordTarget()->isRecording(), 5000); + QTRY_VERIFY_WITH_TIMEOUT(analysed(m_window->analyser2()), 30000); + + // The two ranges join, and the second one reached its end: had the + // lead-in been counted as part of what was recorded, the take + // would have stopped short of it + ranges = m_window->takes()->getCoverage().getRanges(); + QCOMPARE(int(ranges.size()), 1); + QCOMPARE(ranges[0], Coverage::Range(P, E2)); + } + + // Stop can still be pressed before the end of the selection; the take + // is then as short as what was sung, and nothing is left watching it + void punch_early_stop_is_shorter() { + FakeAudioIO::Config config; + config.input = tone(highHz, 3.0); + makeWindow(config); + m_window->setPlayReferenceWhileRecording(false); + m_window->setRecordIntoSelection(true); + openReference(writeWav(tone(lowHz, 4.0))); + if (QTest::currentTestFailed()) return; + + const sv::sv_frame_t P = sv::sv_frame_t(1.0 * rate); + const sv::sv_frame_t E = sv::sv_frame_t(3.0 * rate); + m_window->selectRange(P, E); + m_window->seekTo(P + sv::sv_frame_t(0.5 * rate)); // inside it + startTake(); + if (QTest::currentTestFailed()) return; + QCOMPARE(m_window->takePosition(), P); + QCOMPARE(m_window->takeEnd(), E); + + QTest::qWait(700); + QVERIFY2(m_window->recordTarget()->isRecording(), + "the take stopped by itself well before the end of the " + "selection"); + stopTake(); + if (QTest::currentTestFailed()) return; + QVERIFY(!m_window->takeTimerRunning()); + + auto ranges = m_window->takes()->getCoverage().getRanges(); + QCOMPARE(int(ranges.size()), 1); + QCOMPARE(ranges[0].start, P); + QVERIFY2(ranges[0].end > P + sv::sv_frame_t(0.4 * rate) && + ranges[0].end < E, + qPrintable(QString("0.7 s was recorded into the selection " + "[%1,%2) before Stop, and the take covers " + "[%3,%4)") + .arg(P).arg(E) + .arg(ranges[0].start).arg(ranges[0].end))); + + auto wave = takeAudio(); + QVERIFY(wave); + QCOMPARE(wave->getFrameCount(), ranges[0].end); + } + + // The take and the live pitch model are both in the play source + // while the reference plays (review finding 3), and neither is to be + // heard. This test holds that in place for the whole path, from the + // input to the device output. It does not by itself show that the + // take is muted: the play source fills its buffers seconds ahead of + // the playback position, the take has no audio that far ahead yet, + // and so a take this short is silent even unmuted. For the mute see + // take_muted_while_recording and take_silent_in_output_after_reseek. + // + // Pure tones here, so that the reference has nothing at the + // frequency of the input. 26400 samples is a whole number of + // periods of both. + void no_self_monitoring() { + FakeAudioIO::Config config; + config.input = TestSignals::sine(highHz, rate, int(3 * rate), 0.5); + makeWindow(config); + m_window->setPlayReferenceWhileRecording(true); + openReference(writeWav(TestSignals::sine(lowHz, rate, + int(3 * rate), 0.5))); + if (QTest::currentTestFailed()) return; + + take(1500); + if (QTest::currentTestFailed()) return; + + auto output = m_window->fake()->getCapturedOutput(); + long start = m_window->fake()->getPlayStartFrame(); + QVERIFY2(start >= 0, "the reference was never played"); + size_t from = size_t(start) + size_t(0.5 * rate); + double reference = amplitudeAt(output, from, 26400, lowHz); + double input = amplitudeAt(output, from, 26400, highHz); + QVERIFY2(reference > 0.1, + qPrintable(QString("reference amplitude in the output is %1") + .arg(reference))); + QVERIFY2(input < 0.005, + qPrintable(QString("the output has the input's frequency " + "in it, at amplitude %1").arg(input))); + } + + // Review finding 3. The test above finds the take silent in the + // output, but that much is true even unmuted, because the play + // source reads ahead of what has been recorded. Nothing of the take + // is to be audible while it is being recorded, whatever the buffers + // do: neither the recording, nor the live pitch model, nor the + // singing that is already there. This test checks the play + // parameters, and the next one the output. + void take_muted_while_recording() { + FakeAudioIO::Config config; + config.input = tone(highHz, 4.0); + makeWindow(config); + m_window->setPlayReferenceWhileRecording(true); + openReference(writeWav(tone(lowHz, 2.0))); + if (QTest::currentTestFailed()) return; + + auto takeParams = [this]() { + return m_window->analyser2()->getLayer(Analyser::Audio) + ->getPlayParameters(); + }; + + // The first recording: there is no singing track yet, only the + // recording itself and the dots + startTake(); + if (QTest::currentTestFailed()) return; + QVERIFY2(m_window->recordingLayer(), + "the recording has no layer to hold it in the document"); + auto recordingParams = + m_window->recordingLayer()->getPlayParameters(); + QVERIFY(recordingParams); + QVERIFY2(!recordingParams->isPlayAudible(), + "the recording is audible while it is being made"); + QTRY_VERIFY_WITH_TIMEOUT(m_window->realtimeLayer(), 2000); + auto liveParams = m_window->realtimeLayer()->getPlayParameters(); + QVERIFY(liveParams); + QVERIFY2(!liveParams->isPlayAudible(), + "the live pitch model is audible during the take"); + + // The button goes on saying what the user asked for + QVERIFY(m_window->playSingingAudioAction()->isChecked()); + + QTest::qWait(800); + stopTake(); + if (QTest::currentTestFailed()) return; + QVERIFY2(!m_window->recordingLayer(), + "the recording was still held after it had been spliced in"); + QVERIFY2(takeParams()->isPlayAudible(), + "the take was left muted after recording"); + QVERIFY(m_window->playSingingAudioAction()->isChecked()); + + // Recording into the take that is now there: its pitch and notes + // are kept (put out of sight, see the next test), and its audio + // is kept out of the mix + auto events = pitchEvents(m_window->analyser2()); + QVERIFY(!events.empty()); + + startTake(); + if (QTest::currentTestFailed()) return; + QVERIFY2(m_window->analyser2(), + "the singing track was torn down for the take"); + QVERIFY2(pitchEvents(m_window->analyser2()).size() == events.size(), + "the singing pitch track lost its events during the take"); + QVERIFY2(!takeParams()->isPlayAudible(), + "the singing that is there is audible while it is being " + "recorded into"); + + // Switched off during a take, it stays muted afterwards + m_window->playSingingAudioAction()->trigger(); + QVERIFY(!m_window->playSingingAudioAction()->isChecked()); + QVERIFY(!takeParams()->isPlayAudible()); + QTest::qWait(800); + stopTake(); + if (QTest::currentTestFailed()) return; + QVERIFY(!takeParams()->isPlayAudible()); + QVERIFY(!m_window->playSingingAudioAction()->isChecked()); + + // The reference was not touched by any of this + QVERIFY(m_window->analyser()->isAudible(Analyser::Audio)); + } + + // The take's stored pitch track and notes sit over the same part of + // the pane as what is being sung now, the pitch in the same orange + // as the live dots. While a take is being recorded they are out of + // sight, so that the only pitch the singer sees beside the track + // they are following is the one they are singing now; they come back + // when the take stops. + void singing_track_hidden_while_recording() { + FakeAudioIO::Config config; + config.input = tone(highHz, 4.0); + makeWindow(config); + openReference(writeWav(tone(lowHz, 2.0))); + if (QTest::currentTestFailed()) return; + + take(800); + if (QTest::currentTestFailed()) return; + + Analyser *a2 = m_window->analyser2(); + QVERIFY(a2); + sv::Layer *pitch = a2->getLayer(Analyser::PitchTrack); + sv::Layer *notes = a2->getLayer(Analyser::Notes); + sv::Pane *pane = a2->getPane(); + QVERIFY(pitch && notes && pane); + QVERIFY2(!pitch->isLayerDormant(pane), + "the take's pitch track is not shown after the take"); + QVERIFY2(!notes->isLayerDormant(pane), + "the take's notes are not shown after the take"); + QVERIFY(!noteEvents(notes).empty()); + + m_window->setRecordOverAnswer(true); + m_window->seekTo(0); + + startTake(); + if (QTest::currentTestFailed()) return; + QCOMPARE(m_window->analyser2()->getLayer(Analyser::PitchTrack), pitch); + QCOMPARE(m_window->analyser2()->getLayer(Analyser::Notes), notes); + QVERIFY2(pitch->isLayerDormant(pane), + "the take's own orange pitch track is still shown while it " + "is being recorded over"); + QVERIFY2(notes->isLayerDormant(pane), + "the take's own notes are still shown while it is being " + "recorded over"); + // The live dots are the pitch the singer does see + QTRY_VERIFY_WITH_TIMEOUT(m_window->realtimeLayer(), 2000); + QVERIFY(!m_window->realtimeLayer()->isLayerDormant(pane)); + + QTest::qWait(800); + stopTake(); + if (QTest::currentTestFailed()) return; + + QVERIFY(m_window->analyser2()); + QCOMPARE(m_window->analyser2()->getLayer(Analyser::PitchTrack), pitch); + QCOMPARE(m_window->analyser2()->getLayer(Analyser::Notes), notes); + QVERIFY2(!pitch->isLayerDormant(pane), + "the take's pitch track did not come back when the take " + "stopped"); + QVERIFY2(!notes->isLayerDormant(pane), + "the take's notes did not come back when the take stopped"); + } + + // The recording is held in the pane by a hidden waveform layer + // (setupRecordingLayer), because the document needs a layer to hold + // the model. A hidden layer must not be the pane's work model: the + // pane blocks off everything past that model's end frame with a pale + // wash and a vertical line, and a recording's end frame crawls along + // behind the playback cursor as it is written, so the pane would be + // greyed out from there to the right for the whole take. + void recording_does_not_grey_out_the_pane() { + FakeAudioIO::Config config; + config.input = tone(highHz, 4.0); + makeWindow(config); + openReference(writeWav(tone(lowHz, 2.0))); + if (QTest::currentTestFailed()) return; + + sv::Pane *pane = m_window->paneStack()->getPane(0); + QVERIFY(pane); + QCOMPARE(pane->getWorkModel(), m_window->mainModelId()); + + startTake(); + if (QTest::currentTestFailed()) return; + QVERIFY(m_window->recordingLayer()); + QVERIFY(!m_window->currentRecordingModelId().isNone()); + QVERIFY2(pane->getWorkModel() != m_window->currentRecordingModelId(), + "the pane blocks itself off at the end of the recording"); + QCOMPARE(pane->getWorkModel(), m_window->mainModelId()); + + QTest::qWait(600); + stopTake(); + if (QTest::currentTestFailed()) return; + QCOMPARE(pane->getWorkModel(), m_window->mainModelId()); + + // And again with a take already there, whose own audio layer is + // in the pane as well + m_window->setRecordOverAnswer(true); + m_window->seekTo(0); + startTake(); + if (QTest::currentTestFailed()) return; + QVERIFY2(pane->getWorkModel() != m_window->currentRecordingModelId(), + "the pane blocks itself off at the end of the recording"); + + // Even with the pin taken off, the hidden layer that holds the + // recording must not be the one the pane chooses: a layer that + // is not shown is no part of what the pane is showing + pane->setWorkModel({}); + QVERIFY2(pane->getWorkModel() != m_window->currentRecordingModelId(), + "the model of a hidden layer was chosen as the work model"); + pane->setWorkModel(m_window->mainModelId()); + + QTest::qWait(600); + stopTake(); + if (QTest::currentTestFailed()) return; + QCOMPARE(pane->getWorkModel(), m_window->mainModelId()); + } + + // Review finding 3, at the device. The read-ahead that keeps the + // take out of the output in no_self_monitoring is taken away here: + // once more has been recorded than the play source buffers, playback + // is sent back to the start, so that everything the fill thread now + // reads is audio the take already has, and pitches the live model + // already has. Only their being muted keeps them out. + void take_silent_in_output_after_reseek() { + FakeAudioIO::Config config; + config.input = TestSignals::sine(highHz, rate, int(8 * rate), 0.5); + makeWindow(config); + m_window->setPlayReferenceWhileRecording(true); + openReference(writeWav(TestSignals::sine(lowHz, rate, + int(6 * rate), 0.5))); + if (QTest::currentTestFailed()) return; + + startTake(); + if (QTest::currentTestFailed()) return; + QTest::qWait(3600); + + auto wave = sv::ModelById::getAs + (m_window->currentRecordingModelId()); + QVERIFY2(wave, "there is no take being recorded"); + sv::sv_frame_t recorded = wave->getFrameCount(); + QVERIFY2(recorded > sv::sv_frame_t(3.2 * rate), + qPrintable(QString("only %1 frames of the take exist, not " + "enough to outrun the read-ahead") + .arg(recorded))); + + size_t reseek = m_window->fake()->getCapturedOutput().size(); + m_window->playSource()->play(0); + QTest::qWait(1500); + stopTake(); + if (QTest::currentTestFailed()) return; + + auto output = m_window->fake()->getCapturedOutput(); + size_t from = reseek + size_t(0.3 * rate); + double reference = amplitudeAt(output, from, 26400, lowHz); + double input = amplitudeAt(output, from, 26400, highHz); + QVERIFY2(reference > 0.1, + qPrintable(QString("reference amplitude in the output after " + "the reseek is %1").arg(reference))); + QVERIFY2(input >= 0.0 && input < 0.005, + qPrintable(QString("the output has the input's frequency in " + "it, at amplitude %1 (reference %2): the " + "take, or a tone at the live pitch, is " + "played while it is being recorded") + .arg(input).arg(reference))); + } + + // Recording again before the analysis of the take just made has + // finished. The second take's splice tears that analyser down in the + // middle of its pYIN, which is the area this fork has crashed in + // before; cancelAnalyses() is what keeps it safe. A regression guard, + // not a new behaviour: run it under load, a crash is the failure. + // The analysis is held, so that it is unmerged when the second take + // stops however quick the machine; whether its thread is still going + // then as well depends on the machine and its load. + void rerecord_during_analysis() { + FakeAudioIO::Config config; + config.input = tone(highHz, 4.0); + makeWindow(config); + openReference(writeWav(tone(lowHz, 3.0))); + if (QTest::currentTestFailed()) return; + + startTake(); + if (QTest::currentTestFailed()) return; + QTest::qWait(700); + + // Stop splices the recording in and starts the analysis of the + // result there and then + m_window->holdRangedMerges(true); + m_window->doRecord(); + QVERIFY(!m_window->recordTarget()->isRecording()); + QVERIFY2(sv::ModelTransformerFactory::getInstance() + ->haveRunningTransformers(), + "the race was not set up: no analysis was running when the " + "second take started"); + QVERIFY(m_window->analysingRange()); + QString first = m_window->takes()->getAudioPath(); + QVERIFY(!first.isEmpty()); + + // In a gap, so nothing is asked + m_window->seekTo(sv::sv_frame_t(1.5 * rate)); + startTake(); + if (QTest::currentTestFailed()) return; + QTest::qWait(500); + QVERIFY2(m_window->analysingRange(), + "the first take's analysis was over before the second one " + "stopped"); + m_window->doRecord(); + QVERIFY(!m_window->recordTarget()->isRecording()); + m_window->holdRangedMerges(false); + QTRY_VERIFY_WITH_TIMEOUT(analysed(m_window->analyser2()), 30000); + QCOMPARE(m_window->recordOverQuestions(), 0); + + QVERIFY(m_window->analyser2()); + QVERIFY(!pitchEvents(m_window->analyser2()).empty()); + QVERIFY(m_window->takes()->getAudioPath() != first); + auto ranges = m_window->takes()->getCoverage().getRanges(); + QCOMPARE(int(ranges.size()), 2); + QCOMPARE(ranges[1].start, sv::sv_frame_t(1.5 * rate)); + + QVERIFY(!m_window->recordingLayer()); + QCOMPARE(m_window->pendingExtraPaneCount(), 0); + QCOMPARE(m_window->paneStack()->getHiddenPaneCount(), 0); + verifyPlaySourceClean(); + } + + void rerecord_cleans_up() { + FakeAudioIO::Config config; + config.input = tone(highHz, 3.0); + makeWindow(config); + openReference(writeWav(tone(lowHz, 1.0))); + if (QTest::currentTestFailed()) return; + int panes = m_window->paneStack()->getPaneCount(); + + startTake(); + if (QTest::currentTestFailed()) return; + QTRY_VERIFY_WITH_TIMEOUT(m_window->realtimeLayer(), 2000); + sv::ModelId firstLive = m_window->realtimeLayer()->getModel(); + QTest::qWait(800); + stopTake(); + if (QTest::currentTestFailed()) return; + Analyser *first = m_window->analyser2(); + sv::ModelId firstModel = first->getMainModelId(); + QVERIFY(sv::ModelById::get(firstModel)); + + startTake(); + if (QTest::currentTestFailed()) return; + QTRY_VERIFY_WITH_TIMEOUT(m_window->realtimeLayer(), 2000); + sv::ModelId secondLive = m_window->realtimeLayer()->getModel(); + QVERIFY(secondLive != firstLive); + QTest::qWait(800); + stopTake(); + if (QTest::currentTestFailed()) return; + + QVERIFY(m_window->analyser2()); + sv::ModelId secondModel = m_window->analyser2()->getMainModelId(); + QVERIFY(secondModel != firstModel); + QVERIFY2(!sv::ModelById::get(firstModel), + "the first take's model was not released"); + QCOMPARE(layersOnModel(firstModel), 0); + QCOMPARE(layersOnModel(secondModel), 1); + + QCOMPARE(m_window->paneStack()->getPaneCount(), panes); + QVERIFY(m_window->paneStack()->getHiddenPaneCount() <= 1); + QCOMPARE(m_window->pendingExtraPaneCount(), 0); + + auto events = pitchEvents(m_window->analyser2()); + QVERIFY(events.size() > 50); + + // Nothing of the first take is left in the play source: not + // its audio and not its live pitch model (review finding 6) + QTRY_VERIFY_WITH_TIMEOUT(!m_window->realtimeLayer(), 5000); + auto playing = m_window->playSource()->getModels(); + QVERIFY(!playing.count(firstModel)); + QVERIFY(!playing.count(firstLive)); + QVERIFY(!playing.count(secondLive)); + for (sv::ModelId id : playing) { + QVERIFY2(sv::ModelById::get(id), + qPrintable(QString("the play source holds model %1, " + "which no longer exists") + .arg(id.untyped))); + QVERIFY2(layersOnModel(id) > 0, + qPrintable(QString("the play source holds model %1, " + "which no layer uses") + .arg(id.untyped))); + } + // audio, pitch track and notes, of the reference and of the take + QCOMPARE(int(playing.size()), 6); + } + + // With nothing loaded the take is not a singing track: it becomes + // the main model and the primary analyser gets it + void record_without_reference() { + FakeAudioIO::Config config; + config.input = tone(highHz, 3.0); + makeWindow(config); + + startTake(); + if (QTest::currentTestFailed()) return; + QVERIFY(!m_window->recordingAsSingingTrack()); + QTRY_VERIFY_WITH_TIMEOUT(m_window->realtimeLayer(), 2000); + QTRY_VERIFY_WITH_TIMEOUT + (!pitchEvents(m_window->realtimeLayer()).empty(), 3000); + QTest::qWait(800); + + m_window->doRecord(); + QVERIFY(!m_window->recordTarget()->isRecording()); + QTRY_VERIFY_WITH_TIMEOUT(analysed(m_window->analyser()), 30000); + + QVERIFY(!m_window->analyser2()); + QVERIFY(!m_window->mainModelId().isNone()); + QCOMPARE(m_window->analyser()->getMainModelId(), + m_window->mainModelId()); + QVERIFY(std::fabs(TestSignals::centsBetween + (medianHz(pitchEvents(m_window->analyser())), + highHz)) < 10.0); + + QTRY_VERIFY_WITH_TIMEOUT(!m_window->realtimeLayer(), 5000); + QVERIFY(!m_window->realtimeTracker()); + QVERIFY(!m_window->recordingInProgress()); + QCOMPARE(m_window->pendingExtraPaneCount(), 0); + verifyPlaySourceClean(); + } + + // The latency of a compensated take must not outlive it. A take + // with nothing loaded has no reference to line up with: its dots + // belong where they were heard, the first of them at the centre + // of the first YIN window + void standalone_take_after_compensated_take() { + FakeAudioIO::Config config; + config.playbackLatency = 4096; + config.recordLatency = 4096; + config.input = tone(highHz, 6.0); + makeWindow(config); + m_window->setPlayReferenceWhileRecording(true); + openReference(writeWav(tone(lowHz, 1.0))); + if (QTest::currentTestFailed()) return; + + take(800); + if (QTest::currentTestFailed()) return; + QVERIFY2(m_window->recordingLatencyFrames() >= 8192, + qPrintable(QString("the first take was compensated by %1 " + "frames only; the device reports 8192") + .arg(m_window->recordingLatencyFrames()))); + QTRY_VERIFY_WITH_TIMEOUT(!m_window->realtimeLayer(), 5000); + m_window->doCloseSession(); + + startTake(); + if (QTest::currentTestFailed()) return; + QVERIFY(!m_window->recordingAsSingingTrack()); + QTRY_VERIFY_WITH_TIMEOUT(m_window->realtimeLayer(), 2000); + QTRY_VERIFY_WITH_TIMEOUT + (!pitchEvents(m_window->realtimeLayer()).empty(), 3000); + QTest::qWait(800); + + sv::sv_frame_t latency = m_window->recordingLatencyFrames(); + auto dots = pitchEvents(m_window->realtimeLayer()); + QVERIFY(!dots.empty()); + sv::sv_frame_t firstDot = dots.front().getFrame(); + + m_window->doRecord(); + QVERIFY(!m_window->recordTarget()->isRecording()); + QTRY_VERIFY_WITH_TIMEOUT(analysed(m_window->analyser()), 30000); + + QVERIFY2(latency == 0 && + firstDot >= sv::sv_frame_t(RealtimePitchTracker::kWindowSize / 2), + qPrintable(QString("the standalone take ran with a latency " + "of %1 frames and its first dot at frame " + "%2; there is nothing to compensate for, " + "and no dot can come before frame %3") + .arg(latency).arg(firstDot) + .arg(RealtimePitchTracker::kWindowSize / 2))); + } + + // No device at all: the base class record() gives up quietly + void record_failure_resets_flags() { + makeWindow(FakeAudioIO::Config(), false); + openReference(writeWav(tone(lowHz, 1.0))); + if (QTest::currentTestFailed()) return; + + m_window->doRecord(); + QVERIFY(!m_window->recordTarget()->isRecording()); + + QVERIFY(!m_window->recordingAsSingingTrack()); + QVERIFY(!m_window->recordingInProgress()); + + // The reference is still there, and Analyse Now is about it. + // With the flag left set it would be routed to a singing track + // that does not exist, and the reference left as it was. (Not + // "the next file opened": opening one closes the session, which + // clears the flag whatever record() did.) + QVERIFY(analysed(m_window->analyser())); + sv::ModelId pitchBefore = + m_window->analyser()->getLayer(Analyser::PitchTrack)->getModel(); + QSignalSpy relayered(m_window->analyser(), SIGNAL(layersChanged())); + + m_window->doAnalyseNow(); + + // The misrouted Analyse Now waits 200 ms for the singing track's + // analyser before it gives up. Wait that out before failing, so + // that it does not fire after this test has ended + if (relayered.isEmpty()) QTest::qWait(300); + + sv::Layer *pitchNow = + m_window->analyser()->getLayer(Analyser::PitchTrack); + QVERIFY2(!relayered.isEmpty() && + pitchNow && pitchNow->getModel() != pitchBefore, + "Analyse Now after a failed record did not re-analyse " + "the reference"); + QTRY_VERIFY_WITH_TIMEOUT(analysed(m_window->analyser()), 30000); + QVERIFY(std::fabs(TestSignals::centsBetween + (medianHz(pitchEvents(m_window->analyser())), + lowHz)) < 10.0); + } + + void analyse_now_during_take() { + FakeAudioIO::Config config; + config.input = tone(highHz, 3.0); + makeWindow(config); + openReference(writeWav(tone(lowHz, 1.0))); + if (QTest::currentTestFailed()) return; + + sv::Layer *refLayer = + m_window->analyser()->getLayer(Analyser::PitchTrack); + sv::ModelId refModel = refLayer->getModel(); + + startTake(); + if (QTest::currentTestFailed()) return; + QTest::qWait(600); + m_window->doAnalyseNow(); + QTest::qWait(600); + QVERIFY(m_window->recordTarget()->isRecording()); + + // The take carries on as if nothing had been asked + QVERIFY(m_window->recordingAsSingingTrack()); + QVERIFY(m_window->recordingInProgress()); + QVERIFY(m_window->realtimeTracker()); + QVERIFY(m_window->realtimeLayer()); + QVERIFY(pitchEvents(m_window->analyser2()).empty()); + + m_window->doRecord(); + QTRY_VERIFY_WITH_TIMEOUT(analysed(m_window->analyser()), 30000); + QTRY_VERIFY_WITH_TIMEOUT(analysed(m_window->analyser2()), 30000); + + sv::Layer *refLayerNow = + m_window->analyser()->getLayer(Analyser::PitchTrack); + QVERIFY2(refLayerNow == refLayer && refLayerNow->getModel() == refModel, + "the reference pitch track was replaced"); + } + + // Analyse Now takes in the singing of the take as well (spec 7): all + // of its coverage is analysed again and merged into the pitch track + // and notes it has, which are not thrown away and made afresh + void analyse_now_reanalyses_the_take() { + FakeAudioIO::Config config; + config.input = tone(highHz, 3.0); + makeWindow(config); + openReference(writeWav(tone(lowHz, 4.0))); + if (QTest::currentTestFailed()) return; + + take(700); + if (QTest::currentTestFailed()) return; + const sv::sv_frame_t P = sv::sv_frame_t(2.0 * rate); + m_window->seekTo(P); + take(600); + if (QTest::currentTestFailed()) return; + + auto ranges = m_window->takes()->getCoverage().getRanges(); + QCOMPARE(int(ranges.size()), 2); + + Analyser *a2 = m_window->analyser2(); + sv::Layer *pitch = a2->getLayer(Analyser::PitchTrack); + QVERIFY(pitch); + auto model = sv::ModelById::getAs + (pitch->getModel()); + QVERIFY(model); + + // Emptied by hand, so that what comes back can only have come + // from the analysis Analyse Now asks for + for (const auto &e : model->getAllEvents()) model->remove(e); + QVERIFY(pitchEvents(pitch).empty()); + + // Held, so that the range is still there to be read however quick + // the run: it is remembered only until the merge + m_window->holdRangedMerges(true); + m_window->doAnalyseNow(); + + // One run over the span of the coverage, not one per range + QVERIFY(m_window->analysingRange()); + QCOMPARE(m_window->analysedRangeStart(), ranges[0].start); + QCOMPARE(m_window->analysedRangeEnd(), ranges[1].end); + + m_window->holdRangedMerges(false); + QTRY_VERIFY_WITH_TIMEOUT(analysed(m_window->analyser()), 30000); + QTRY_VERIFY_WITH_TIMEOUT(analysed(m_window->analyser2()), 30000); + + QCOMPARE(m_window->analyser2()->getLayer(Analyser::PitchTrack), pitch); + QCOMPARE(noteLayersInPane0(), 2); + + auto events = pitchEvents(pitch); + QVERIFY2(!eventsBetween(events, ranges[0].start, ranges[0].end).empty(), + "the first range of the coverage was not analysed again"); + QVERIFY2(!eventsBetween(events, ranges[1].start, ranges[1].end).empty(), + "the second range of the coverage was not analysed again"); + QVERIFY(std::fabs(TestSignals::centsBetween + (medianHz(events), highHz)) < 10.0); + } + + void load_singing_track() { + makeWindow(FakeAudioIO::Config()); + openReference(writeWav(tone(lowHz, 1.0))); + if (QTest::currentTestFailed()) return; + int panes = m_window->paneStack()->getPaneCount(); + sv::Layer *refLayer = + m_window->analyser()->getLayer(Analyser::PitchTrack); + sv::ModelId refModel = refLayer->getModel(); + QSignalSpy refSetUp(m_window->analyser(), SIGNAL(layersChanged())); + QVERIFY(refSetUp.isValid()); + + m_window->loadSingingTrack(writeWav(tone(highHz, 1.0))); + + // Set up exactly once: there and then, and not again when the + // queued calls run + Analyser *a2 = m_window->analyser2(); + QVERIFY(a2); + sv::ModelId singing = a2->getMainModelId(); + QVERIFY(m_window->pendingSingingModelId().isNone()); + QCoreApplication::processEvents(); + QCOMPARE(m_window->analyser2(), a2); + QVERIFY(sv::ModelById::get(singing)); + QCOMPARE(layersOnModel(singing), 1); + QCOMPARE(m_window->paneStack()->getPaneCount(), panes); + + QTRY_VERIFY_WITH_TIMEOUT(analysed(m_window->analyser2()), 30000); + QCOMPARE(m_window->analyser2(), a2); + QVERIFY(std::fabs(TestSignals::centsBetween + (medianHz(pitchEvents(a2)), highHz)) < 10.0); + + // The other half of finding 7: the reference is not handed to + // its analyser a second time. That would keep its layers, but + // forget its pitch candidates and leave them in the pane + QCOMPARE(int(refSetUp.count()), 0); + QCOMPARE(m_window->analyser()->getLayer(Analyser::PitchTrack), + refLayer); + QCOMPARE(refLayer->getModel(), refModel); + + // The second pass used to end by marking the document unmodified + QVERIFY(m_window->isDocumentModified()); + + // A track loaded whole is a take whose singing is all of it + QVERIFY(m_window->takes()->haveTake()); + auto ranges = m_window->takes()->getCoverage().getRanges(); + QCOMPARE(int(ranges.size()), 1); + QCOMPARE(ranges[0], + Coverage::Range(0, sv::ModelById::getAs + (singing)->getFrameCount())); + } + + // Loading a singing track over one that is already there. Since + // phase 7a each load is a take of its own (spec 5.3): the first + // track's audio is released, but its pitch and notes are kept as the + // layers of the take that has been put away + void reload_singing_track() { + makeWindow(FakeAudioIO::Config()); + openReference(writeWav(tone(lowHz, 1.0))); + if (QTest::currentTestFailed()) return; + int panes = m_window->paneStack()->getPaneCount(); + + m_window->loadSingingTrack(writeWav(tone(highHz, 2.0))); + QTRY_VERIFY_WITH_TIMEOUT(analysed(m_window->analyser2()), 30000); + sv::ModelId first = m_window->analyser2()->getMainModelId(); + sv::ModelId firstPitch = m_window->analyser2() + ->getLayer(Analyser::PitchTrack)->getModel(); + + m_window->loadSingingTrack(writeWav(tone(highHz, 1.0))); + QTRY_VERIFY_WITH_TIMEOUT(analysed(m_window->analyser2()), 30000); + sv::ModelId second = m_window->analyser2()->getMainModelId(); + QVERIFY(second != first); + + // Two takes, and the first one's audio is gone: only the active + // take has an audio model (spec 6.4) + QCOMPARE(m_window->takes()->getTakeNames(), + QStringList({ "Take 1", "Take 2" })); + QVERIFY2(!sv::ModelById::get(first), + "the first singing track's model was not released"); + QVERIFY2(sv::ModelById::get(firstPitch), + "the first take's pitch track went with its audio"); + QCOMPARE(layersOnModel(second), 1); + QCOMPARE(m_window->paneStack()->getPaneCount(), panes); + + verifyTakeIsPutAway("Take 1"); + if (QTest::currentTestFailed()) return; + + auto playing = m_window->playSource()->getModels(); + QVERIFY(!playing.count(first)); + QVERIFY(!playing.count(firstPitch)); + verifyPlaySourceClean(); + if (QTest::currentTestFailed()) return; + // audio, pitch track and notes, of the reference and of the take + // that is on show + QCOMPARE(int(playing.size()), 6); + + // A stale id used to keep the end of playback where the longest + // model ever loaded had ended, and the pitch track of the take + // that has been put away -- of the 2 s file -- would do the same + QVERIFY(m_window->playSource()->getPlayEndFrame() < + sv::sv_frame_t(1.2 * rate)); + } + + // Another audio file under the take's pitch and notes layers: the + // layers, their models and everything in them are the same objects + // afterwards, and no analysis is run + void swap_keeps_layers_and_events() { + makeWindow(FakeAudioIO::Config()); + openReference(writeWav(tone(lowHz, 1.0))); + if (QTest::currentTestFailed()) return; + int panes = m_window->paneStack()->getPaneCount(); + + m_window->loadSingingTrack(writeWav(tone(highHz, 1.0))); + QTRY_VERIFY_WITH_TIMEOUT(analysed(m_window->analyser2()), 30000); + Analyser *before = m_window->analyser2(); + sv::Layer *pitch = before->getLayer(Analyser::PitchTrack); + sv::Layer *notes = before->getLayer(Analyser::Notes); + sv::ModelId oldAudio = before->getMainModelId(); + sv::ModelId pitchModel = pitch->getModel(); + sv::ModelId notesModel = notes->getModel(); + auto events = pitchEvents(pitch); + QVERIFY(!events.empty()); + auto coverage = m_window->takes()->getCoverage().getRanges(); + QCOMPARE(int(coverage.size()), 1); + + // Twice as long as the file it replaces, so that a coverage reset + // to "the whole of this file" would show + QString error = m_window->doSwapSingingAudio(writeWav(tone(lowHz, 2.0))); + QVERIFY2(error.isEmpty(), qPrintable(error)); + + Analyser *after = m_window->analyser2(); + QVERIFY(after); + // Not "after != before": the old analyser has been deleted, and a + // new one may legitimately be built at the same address (which it + // is, once another suite has churned the heap first). That the + // analyser was rebuilt shows in the model it is on, below + QCOMPARE(after->getLayer(Analyser::PitchTrack), pitch); + QCOMPARE(after->getLayer(Analyser::Notes), notes); + QCOMPARE(pitch->getModel(), pitchModel); + QCOMPARE(notes->getModel(), notesModel); + auto kept = pitchEvents(pitch); + QCOMPARE(kept.size(), events.size()); + for (size_t i = 0; i < events.size(); ++i) { + QCOMPARE(kept[i].getFrame(), events[i].getFrame()); + QCOMPARE(kept[i].getValue(), events[i].getValue()); + } + + // The new audio is the analyser's, and the old one is released + sv::ModelId newAudio = after->getMainModelId(); + QVERIFY(newAudio != oldAudio); + auto wfm = sv::ModelById::getAs(newAudio); + QVERIFY(wfm); + // svcore reads a file this small on a thread of its own, and its + // length grows from 0 until it is ready: nothing here has waited, + // as no analysis follows the swap (CI's macOS found 0 frames) + QTRY_VERIFY(wfm->isReady()); + QCOMPARE(wfm->getFrameCount(), sv::sv_frame_t(2.0 * rate)); + QVERIFY2(!sv::ModelById::get(oldAudio), + "the audio that was swapped out was not released"); + QCOMPARE(layersOnModel(newAudio), 1); + QCOMPARE(m_window->paneStack()->getPaneCount(), panes); + QCOMPARE(m_window->paneStack()->getHiddenPaneCount(), 0); + auto playing = m_window->playSource()->getModels(); + QVERIFY(!playing.count(oldAudio)); + QVERIFY(playing.count(newAudio)); + verifyPlaySourceClean(); + if (QTest::currentTestFailed()) return; + + // The two layers' models come from the new audio now: that is what + // let the new analyser claim them + QCOMPARE(sv::ModelById::get(pitchModel)->getSourceModel(), newAudio); + QCOMPARE(sv::ModelById::get(notesModel)->getSourceModel(), newAudio); + + // Nothing was analysed, then or when the queued calls ran + QVERIFY(!sv::ModelTransformerFactory::getInstance() + ->haveRunningTransformers()); + QCoreApplication::processEvents(); + QCOMPARE(m_window->analyser2(), after); + QVERIFY(!sv::ModelTransformerFactory::getInstance() + ->haveRunningTransformers()); + QCOMPARE(pitchEvents(pitch).size(), events.size()); + + // Colours, the toggles and the take's coverage as they were + QCOMPARE(colourOf(pitch), colourNamed("Orange")); + QCOMPARE(colourOf(notes), colourNamed("Bright Purple")); + QVERIFY(after->isVisible(Analyser::Audio)); + QVERIFY(after->isVisible(Analyser::PitchTrack)); + QVERIFY(after->isVisible(Analyser::Notes)); + QVERIFY(after->isAudible(Analyser::Audio)); + QVERIFY(m_window->playSingingAudioAction()->isChecked()); + auto ranges = m_window->takes()->getCoverage().getRanges(); + QCOMPARE(int(ranges.size()), 1); + QCOMPARE(ranges[0], coverage[0]); + } + + // The swapped-in audio is what is heard afterwards, and the audio it + // replaced is not + void swap_plays_the_new_audio() { + makeWindow(FakeAudioIO::Config()); + openReference(writeWav(TestSignals::sine(lowHz, rate, + int(2 * rate), 0.5))); + if (QTest::currentTestFailed()) return; + + m_window->loadSingingTrack + (writeWav(TestSignals::sine(highHz, rate, int(2 * rate), 0.5))); + QTRY_VERIFY_WITH_TIMEOUT(analysed(m_window->analyser2()), 30000); + + QString error = m_window->doSwapSingingAudio + (writeWav(TestSignals::sine(swapHz, rate, int(2 * rate), 0.5))); + QVERIFY2(error.isEmpty(), qPrintable(error)); + + m_window->seekTo(0); + m_window->doPlay(); + QTest::qWait(1500); + m_window->doPlay(); + + auto output = m_window->fake()->getCapturedOutput(); + long start = m_window->fake()->getPlayStartFrame(); + QVERIFY2(start >= 0, "nothing was played"); + size_t from = size_t(start) + size_t(0.3 * rate); + double swapped = amplitudeAt(output, from, 22050, swapHz); + double replaced = amplitudeAt(output, from, 22050, highHz); + QVERIFY2(swapped > 0.05, + qPrintable(QString("the audio swapped in is not in the " + "output: amplitude %1").arg(swapped))); + QVERIFY2(replaced >= 0.0 && replaced < 0.005, + qPrintable(QString("the audio swapped out is still in the " + "output, at amplitude %1 (the new audio " + "is at %2)").arg(replaced).arg(swapped))); + } + + // Closing the session after a swap: the layers the released analyser + // used to own are deleted by the analyser that claimed them + void swap_then_close_session() { + makeWindow(FakeAudioIO::Config()); + openReference(writeWav(tone(lowHz, 1.0))); + if (QTest::currentTestFailed()) return; + + m_window->loadSingingTrack(writeWav(tone(highHz, 1.0))); + QTRY_VERIFY_WITH_TIMEOUT(analysed(m_window->analyser2()), 30000); + sv::ModelId pitchModel = m_window->analyser2() + ->getLayer(Analyser::PitchTrack)->getModel(); + + QString error = m_window->doSwapSingingAudio(writeWav(tone(lowHz, 1.0))); + QVERIFY2(error.isEmpty(), qPrintable(error)); + sv::ModelId audio = m_window->analyser2()->getMainModelId(); + + m_window->doCloseSession(); + + QVERIFY(!m_window->analyser2()); + QVERIFY2(!sv::ModelById::get(audio), + "the swapped-in audio outlived the session"); + QVERIFY2(!sv::ModelById::get(pitchModel), + "the swapped-over pitch track outlived the session"); + QCOMPARE(m_window->paneStack()->getPaneCount(), 0); + QCOMPARE(m_window->paneStack()->getHiddenPaneCount(), 0); + QCOMPARE(m_window->pendingExtraPaneCount(), 0); + + // and the window still works + openReference(writeWav(tone(highHz, 1.0))); + } + + // Recording again after a swap. The layers the first analyser + // released are deleted while the one that claimed them is alive: what + // the next take does with them is not the released analyser's business + void swap_then_record_again() { + FakeAudioIO::Config config; + config.input = tone(highHz, 4.0); + makeWindow(config); + openReference(writeWav(tone(lowHz, 2.0))); + if (QTest::currentTestFailed()) return; + int panes = m_window->paneStack()->getPaneCount(); + + take(600); + if (QTest::currentTestFailed()) return; + QString first = m_window->takes()->getAudioPath(); + QVERIFY(!first.isEmpty()); + + // Standing in for the file a splice will write in phase 4c + QString error = m_window->doSwapSingingAudio(writeWav(tone(lowHz, 2.0))); + QVERIFY2(error.isEmpty(), qPrintable(error)); + QVERIFY(!pitchEvents(m_window->analyser2()).empty()); + + m_window->seekTo(0); + take(600); + if (QTest::currentTestFailed()) return; + + QVERIFY(m_window->analyser2()); + QVERIFY(!pitchEvents(m_window->analyser2()).empty()); + QVERIFY(m_window->takes()->getAudioPath() != first); + QVERIFY(!m_window->recordingLayer()); + QCOMPARE(m_window->paneStack()->getPaneCount(), panes); + QCOMPARE(m_window->pendingExtraPaneCount(), 0); + verifyPlaySourceClean(); + } + + // The swap while pYIN is still running on the audio it replaces: the + // transforms are cancelled before any model changes hands. A + // regression guard, a crash is the failure + void swap_during_analysis() { + makeWindow(FakeAudioIO::Config()); + openReference(writeWav(tone(lowHz, 1.0))); + if (QTest::currentTestFailed()) return; + + m_window->loadSingingTrack(writeWav(tone(highHz, 4.0))); + QVERIFY2(sv::ModelTransformerFactory::getInstance() + ->haveRunningTransformers(), + "the race was not set up: no analysis was running when the " + "swap started"); + Analyser *before = m_window->analyser2(); + QVERIFY(before); + sv::Layer *pitch = before->getLayer(Analyser::PitchTrack); + sv::Layer *notes = before->getLayer(Analyser::Notes); + QVERIFY(pitch && notes); + sv::ModelId oldAudio = before->getMainModelId(); + + QString error = m_window->doSwapSingingAudio(writeWav(tone(lowHz, 1.0))); + QVERIFY2(error.isEmpty(), qPrintable(error)); + + // The transform threads are gone by the time the swap returns, so + // nothing writes into the pitch track any more -- whatever pYIN + // had got to is what stays. (The factory only strikes a + // transformer off its list when the event loop next runs) + auto events = pitchEvents(pitch); + QTRY_VERIFY_WITH_TIMEOUT(!sv::ModelTransformerFactory::getInstance() + ->haveRunningTransformers(), 10000); + QTest::qWait(300); + QCOMPARE(pitchEvents(pitch).size(), events.size()); + + Analyser *after = m_window->analyser2(); + QVERIFY(after); + // Not "after != before": the old analyser has been deleted, and a + // new one may legitimately be built at the same address (which it + // is, once another suite has churned the heap first). That the + // analyser was rebuilt shows in the model it is on, below + QCOMPARE(after->getLayer(Analyser::PitchTrack), pitch); + QCOMPARE(after->getLayer(Analyser::Notes), notes); + QVERIFY(after->getMainModelId() != oldAudio); + QVERIFY2(!sv::ModelById::get(oldAudio), + "the audio that was swapped out was not released"); + QCoreApplication::processEvents(); + verifyPlaySourceClean(); + } + + // Finding 7, the scenario itself: pitch candidates on the reference + // are still the analyser's after another track has been loaded + void reference_candidates_survive_load() { + makeWindow(FakeAudioIO::Config()); + openReference(writeWav(tone(lowHz, 2.0))); + if (QTest::currentTestFailed()) return; + Analyser *a = m_window->analyser(); + + QString error = a->reAnalyseSelection + (sv::Selection(sv::sv_frame_t(0.5 * rate), + sv::sv_frame_t(1.5 * rate)), + Analyser::FrequencyRange()); + QVERIFY2(error.isEmpty(), qPrintable(error)); + QTRY_VERIFY_WITH_TIMEOUT(a->haveHigherPitchCandidate(), 30000); + + m_window->loadSingingTrack(writeWav(tone(highHz, 1.0))); + QTRY_VERIFY_WITH_TIMEOUT(analysed(m_window->analyser2()), 30000); + QVERIFY2(a->haveHigherPitchCandidate(), + "the reference analyser forgot its pitch candidates"); + + m_window->doLoadBackgroundMusic(writeWav(tone(highHz, 1.0))); + QCoreApplication::processEvents(); + QVERIFY2(a->haveHigherPitchCandidate(), + "the reference analyser forgot its pitch candidates"); + } + + // Bug 1: the analyser was never told of a layer deleted by someone + // else, and went on listing deleted pitch candidates + void candidates_deleted_from_outside() { + makeWindow(FakeAudioIO::Config()); + openReference(writeWav(tone(lowHz, 2.0))); + if (QTest::currentTestFailed()) return; + Analyser *a = m_window->analyser(); + + QString error = a->reAnalyseSelection + (sv::Selection(sv::sv_frame_t(0.5 * rate), + sv::sv_frame_t(1.5 * rate)), + Analyser::FrequencyRange()); + QVERIFY2(error.isEmpty(), qPrintable(error)); + QTRY_VERIFY_WITH_TIMEOUT(a->haveHigherPitchCandidate(), 30000); + + std::vector candidates; + sv::Pane *pane = a->getPane(); + for (int i = 0; i < pane->getLayerCount(); ++i) { + sv::Layer *layer = pane->getLayer(i); + if (layer->getLayerPresentationName() == "candidate") { + candidates.push_back(layer); + } + } + QVERIFY(!candidates.empty()); + + for (sv::Layer *layer : candidates) { + m_window->document()->deleteLayer(layer, true); + } + QVERIFY2(!a->haveHigherPitchCandidate() && + !a->haveLowerPitchCandidate(), + "the analyser still lists deleted pitch candidates"); + + // The same for one of its own layers + sv::Layer *notes = a->getLayer(Analyser::Notes); + QVERIFY(notes); + m_window->document()->deleteLayer(notes, true); + QVERIFY2(!a->getLayer(Analyser::Notes), + "the analyser still points at its deleted note layer"); + } + + // Pitch candidates are the analyser's own layers: they come and go + // with no entry in the undo history, an undo leaves them alone, and + // the next re-analysis deletes them. As undoable layers they could be + // undone out of the pane while the analyser went on listing them, and + // the next re-analysis then made a command of each that a later undo + // crashed on + void candidates_make_no_undo_entries() { + makeWindow(FakeAudioIO::Config()); + openReference(writeWav(tone(lowHz, 2.0))); + if (QTest::currentTestFailed()) return; + Analyser *a = m_window->analyser(); + sv::Pane *pane = a->getPane(); + auto candidates = [&]() { + std::vector> found; + for (int i = 0; i < pane->getLayerCount(); ++i) { + sv::Layer *layer = pane->getLayer(i); + if (layer->getLayerPresentationName() == "candidate") { + found.push_back(layer); + } + } + return found; + }; + sv::CommandHistory::getInstance()->clear(); + + std::vector> earlier; + for (double start : { 0.5, 0.8 }) { + QString error = a->reAnalyseSelection + (sv::Selection(sv::sv_frame_t(start * rate), + sv::sv_frame_t((start + 0.7) * rate)), + Analyser::FrequencyRange()); + QVERIFY2(error.isEmpty(), qPrintable(error)); + QTRY_VERIFY_WITH_TIMEOUT(a->haveHigherPitchCandidate(), 30000); + QVERIFY(!candidates().empty()); + for (const QPointer &layer : earlier) { + QVERIFY2(!layer, "a re-analysis left the candidates of the " + "one before it alive"); + } + QString undone = undoOnce(); + QVERIFY2(undone.isEmpty(), + qPrintable("the re-analysis left \"" + undone + + "\" in the undo history")); + QVERIFY2(a->haveHigherPitchCandidate() && !candidates().empty(), + "an undo took the pitch candidates away"); + earlier = candidates(); + } + + a->clearReAnalysis(); + QVERIFY2(candidates().empty(), + "clearing the re-analysis left candidates in the pane"); + for (const QPointer &layer : earlier) { + QVERIFY2(!layer, "clearing the re-analysis left its candidates " + "alive"); + } + } + + void load_background_music() { + makeWindow(FakeAudioIO::Config()); + openReference(writeWav(tone(lowHz, 1.0))); + if (QTest::currentTestFailed()) return; + int panes = m_window->paneStack()->getPaneCount(); + int rulerPaneLayers = m_window->paneStack()->getPane(1)->getLayerCount(); + QSignalSpy refSetUp(m_window->analyser(), SIGNAL(layersChanged())); + QVERIFY(refSetUp.isValid()); + + m_window->doLoadBackgroundMusic(writeWav(tone(highHz, 1.0))); + QCoreApplication::processEvents(); + QCOMPARE(int(refSetUp.count()), 0); + QVERIFY(m_window->isDocumentModified()); + + sv::ModelId music = m_window->backgroundMusicModelId(); + QVERIFY(!music.isNone()); + QVERIFY(sv::ModelById::get(music)); + QVERIFY(m_window->backgroundMusicLayer()); + QVERIFY(paneHasLayer(0, m_window->backgroundMusicLayer())); + QCOMPARE(layersOnModel(music), 1); + auto params = m_window->backgroundMusicLayer()->getPlayParameters(); + QVERIFY(params && params->isPlayAudible()); + + QVERIFY2(!m_window->analyser2(), + "the background music was analysed as a singing track"); + QCOMPARE(m_window->paneStack()->getPaneCount(), panes); + QCOMPARE(m_window->paneStack()->getPane(1)->getLayerCount(), + rulerPaneLayers); + } + + // Only after a session load is the shared ruler put into the extra + // pane, so only here does pruning that pane touch it. The take + // afterwards is what used to read the freed ruler. + void load_singing_track_after_session() { + FakeAudioIO::Config config; + config.input = tone(highHz, 3.0); + makeWindow(config); + reopenAsSession(); + if (QTest::currentTestFailed()) return; + + m_window->loadSingingTrack(writeWav(tone(highHz, 1.0))); + verifyRulerIntact(); + if (QTest::currentTestFailed()) return; + QTRY_VERIFY_WITH_TIMEOUT(analysed(m_window->analyser2()), 30000); + + take(600); + if (QTest::currentTestFailed()) return; + verifyRulerIntact(); + } + + void load_background_music_after_session() { + FakeAudioIO::Config config; + config.input = tone(highHz, 3.0); + makeWindow(config); + reopenAsSession(); + if (QTest::currentTestFailed()) return; + + m_window->doLoadBackgroundMusic(writeWav(tone(highHz, 1.0))); + verifyRulerIntact(); + if (QTest::currentTestFailed()) return; + QVERIFY(m_window->backgroundMusicLayer()); + + take(600); + if (QTest::currentTestFailed()) return; + verifyRulerIntact(); + } + + // Saved with the session and found again when it is opened, beside + // the session's take and not taken for its audio: hidden, heard at the + // level and pan it was saved at, and muted if it was muted + void background_music_session_round_trip() { + FakeAudioIO::Config config; + config.input = tone(highHz, 3.0); + makeWindow(config); + openReference(writeWav(tone(lowHz, 1.5))); + if (QTest::currentTestFailed()) return; + + take(600); + if (QTest::currentTestFailed()) return; + + QString music = writeWav(tone(highHz, 1.0)); + m_window->doLoadBackgroundMusic(music); + QVERIFY(m_window->backgroundMusicLayer()); + + // A notch of the level control, as a click leaves it, and the pan + // half left + sv::LevelPanToolButton *levelPan = m_window->backgroundMusicLevelPan(); + levelPan->setLevel(0.3f); + emit levelPan->panChanged(-0.5f); + auto params = m_window->backgroundMusicLayer()->getPlayParameters(); + QVERIFY(params); + float gain = params->getPlayGain(); + QVERIFY2(gain > 0.f && gain < 0.9f, qPrintable(QString::number(gain))); + QCOMPARE(params->getPlayPan(), -0.5f); + QVERIFY(params->isPlayAudible()); + + QString session = m_dir.filePath("background-music.ton"); + QVERIFY(m_window->saveSessionFile(session)); + QString takePath = m_window->takes()->getAudioPath(); + reopenSession(session); + if (QTest::currentTestFailed()) return; + + // The same file, with the one layer that holds it, in pane 0 and + // hidden, and in the play source + sv::WaveformLayer *layer = m_window->backgroundMusicLayer(); + QVERIFY2(layer, "the background music of the session was not adopted"); + sv::ModelId model = m_window->backgroundMusicModelId(); + QCOMPARE(layer->getModel(), model); + QVERIFY(model != m_window->mainModelId()); + auto wave = sv::ModelById::getAs(model); + QVERIFY(wave); + QCOMPARE(QFileInfo(wave->getLocation()).canonicalFilePath(), + QFileInfo(music).canonicalFilePath()); + QVERIFY(paneHasLayer(0, layer)); + QVERIFY(layer->isLayerDormant(m_window->paneStack()->getPane(0))); + QCOMPARE(layersOnModel(model), 1); + QCOMPARE(backgroundMusicLayersInDocument(), 1); + QVERIFY(m_window->playSource()->getModels().count(model)); + verifyPlaySourceClean(); + + params = layer->getPlayParameters(); + QVERIFY(params); + QVERIFY(params->isPlayAudible()); + QVERIFY(std::fabs(params->getPlayGain() - gain) < 1e-5); + QCOMPARE(params->getPlayPan(), -0.5f); + QVERIFY(m_window->playBackgroundMusicAction()->isEnabled()); + QVERIFY(m_window->playBackgroundMusicAction()->isChecked()); + QVERIFY(levelPan->isEnabled()); + QVERIFY(std::fabs(levelPan->getLevel() - gain) < 1e-5); + QCOMPARE(levelPan->getPan(), -0.5f); + + // The take is the take, on its own audio, and the music is not a + // singing track of its own + QCOMPARE(m_window->takes()->getTakeNames(), QStringList { "Take 1" }); + QCOMPARE(m_window->takes()->getAudioPath(), takePath); + QVERIFY(m_window->analyser2()); + QVERIFY(takeAudio()); + QVERIFY(m_window->analyser2()->getMainModelId() != model); + QCOMPARE(QFileInfo(takeAudio()->getLocation()).canonicalFilePath(), + QFileInfo(takePath).canonicalFilePath()); + + // Opening a session is not a change to it + QVERIFY(!m_window->isDocumentModified()); + + // Muted, saved and opened again: muted, and the toggle says so + m_window->playBackgroundMusicAction()->trigger(); + QVERIFY(!params->isPlayAudible()); + session = m_dir.filePath("background-music-muted.ton"); + QVERIFY(m_window->saveSessionFile(session)); + reopenSession(session); + if (QTest::currentTestFailed()) return; + + layer = m_window->backgroundMusicLayer(); + QVERIFY(layer); + params = layer->getPlayParameters(); + QVERIFY(params); + QVERIFY2(!params->isPlayAudible(), "the background music was saved " + "muted and is heard"); + QVERIFY(std::fabs(params->getPlayGain() - gain) < 1e-5); + QVERIFY(m_window->playBackgroundMusicAction()->isEnabled()); + QVERIFY(!m_window->playBackgroundMusicAction()->isChecked()); + QCOMPARE(backgroundMusicLayersInDocument(), 1); + } + + // The music has gone since the session was saved: the session reader + // asks where it is, and without an answer the session opens without + // it, as it does without any audio it names. Nothing of it is left, + // and music loaded again is the session's + void background_music_missing_from_session() { + makeWindow(FakeAudioIO::Config()); + openReference(writeWav(tone(lowHz, 1.0))); + if (QTest::currentTestFailed()) return; + + QString music = writeWav(tone(highHz, 1.0)); + m_window->doLoadBackgroundMusic(music); + QVERIFY(m_window->backgroundMusicLayer()); + QString session = m_dir.filePath("background-music-gone.ton"); + QVERIFY(m_window->saveSessionFile(session)); + m_window->doCloseSession(); + QVERIFY(QFile::remove(music)); + takeDialogs(); + + reopenSession(session); + if (QTest::currentTestFailed()) return; + QStringList dialogs = takeDialogs(); + QStringList asked = dialogs.filter("Do you want to locate it?"); + QCOMPARE(asked.size(), 1); + QVERIFY2(asked[0].contains(QFileInfo(music).fileName()), + qPrintable(asked[0])); + QCOMPARE(dialogs.filter("referred to by the original session " + "file could not be loaded").size(), 1); + QVERIFY(m_window->isSessionIncomplete()); + + QVERIFY(!m_window->backgroundMusicLayer()); + QVERIFY(m_window->backgroundMusicModelId().isNone()); + QCOMPARE(backgroundMusicLayersInDocument(), 0); + QVERIFY(!m_window->playBackgroundMusicAction()->isEnabled()); + QVERIFY(!m_window->analyser2()); + verifyPlaySourceClean(); + + m_window->doLoadBackgroundMusic(writeWav(tone(highHz, 1.0))); + QVERIFY(m_window->backgroundMusicLayer()); + QVERIFY(m_window->playBackgroundMusicAction()->isEnabled()); + QCOMPARE(backgroundMusicLayersInDocument(), 1); + } + + void session_round_trip() { + const int K = 8192; + FakeAudioIO::Config config; + config.playbackLatency = 4096; + config.recordLatency = 4096; + config.input = tone(highHz, 3.0); + config.inputDelay = K; + config.inputFollowsPlayback = true; + makeWindow(config); + m_window->setPlayReferenceWhileRecording(true); + openReference(writeWav(tone(lowHz, 2.0))); + if (QTest::currentTestFailed()) return; + + take(1200); + if (QTest::currentTestFailed()) return; + // the round trip plus the start gap, which varies + sv::sv_frame_t shift = m_window->recordingLatencyFrames(); + QVERIFY(shift >= K); + + // The compensation is in the take's audio, so what has to survive + // the round trip is the pitch track, not a start frame + auto before = pitchEvents(m_window->analyser2()); + QVERIFY(!before.empty()); + sv::sv_frame_t firstEventBefore = before.front().getFrame(); + QCOMPARE(noteLayersInPane0(), 2); + + QString session = m_dir.filePath("round-trip.ton"); + QVERIFY(m_window->saveSessionFile(session)); + m_window->doCloseSession(); + QVERIFY(!m_window->analyser2()); + + m_window->discardModifications(); + QCOMPARE(m_window->openPath(session, MainWindow::ReplaceSession), + MainWindow::FileOpenSucceeded); + QTRY_VERIFY_WITH_TIMEOUT(analysed(m_window->analyser()), 30000); + QTRY_VERIFY_WITH_TIMEOUT(analysed(m_window->analyser2()), 30000); + + Analyser *a2 = m_window->analyser2(); + QVERIFY(a2->getMainModelId() != m_window->mainModelId()); + QCOMPARE(colourOf(a2->getLayer(Analyser::PitchTrack)), + colourNamed("Orange")); + QCOMPARE(colourOf(a2->getLayer(Analyser::Notes)), + colourNamed("Bright Purple")); + QVERIFY(std::fabs(TestSignals::centsBetween + (medianHz(pitchEvents(a2)), highHz)) < 10.0); + QVERIFY(std::fabs(TestSignals::centsBetween + (medianHz(pitchEvents(m_window->analyser())), + lowHz)) < 10.0); + + auto wave = sv::ModelById::getAs + (a2->getMainModelId()); + QVERIFY(wave); + QCOMPARE(wave->getStartFrame(), sv::sv_frame_t(0)); + + // The take's layers were restored and claimed, not analysed + // again: a re-analysis would have left the restored pair in the + // pane and put a second one beside it + QCOMPARE(noteLayersInPane0(), 2); + + auto after = pitchEvents(a2); + QCOMPARE(after.size(), before.size()); + QVERIFY(!after.empty()); + QVERIFY2(std::llabs(after.front().getFrame() - firstEventBefore) <= + 2 * hop, + qPrintable(QString("the reloaded take's pitch track starts " + "at frame %1; before saving it started " + "at %2") + .arg(after.front().getFrame()) + .arg(firstEventBefore))); + + // The take is the audio file the session pointed at, and its + // coverage comes from the coverage strip the session kept: one + // recording from frame 0, so all of the file + QVERIFY(m_window->takes()->haveTake()); + auto ranges = m_window->takes()->getCoverage().getRanges(); + QCOMPARE(int(ranges.size()), 1); + QCOMPARE(ranges[0], Coverage::Range(0, wave->getFrameCount())); + } + + void close_session_resets() { + FakeAudioIO::Config config; + config.input = tone(highHz, 3.0); + makeWindow(config); + openReference(writeWav(tone(lowHz, 1.0))); + if (QTest::currentTestFailed()) return; + + take(600); + if (QTest::currentTestFailed()) return; + m_window->doCloseSession(); + + QVERIFY(!m_window->analyser2()); + QVERIFY(!m_window->realtimeTracker()); + QVERIFY(!m_window->realtimeLayer()); + QVERIFY(m_window->realtimeModelId().isNone()); + QVERIFY(m_window->currentRecordingModelId().isNone()); + QVERIFY(!m_window->recordingLayer()); + QVERIFY(m_window->pendingSingingModelId().isNone()); + QVERIFY(!m_window->recordingAsSingingTrack()); + QVERIFY2(!m_window->takes()->haveTake(), + "the take outlived the session it was recorded in"); + QCOMPARE(m_window->pendingExtraPaneCount(), 0); + QCOMPARE(m_window->paneStack()->getPaneCount(), 0); + QCOMPARE(m_window->paneStack()->getHiddenPaneCount(), 0); + + // and the window still works + openReference(writeWav(tone(highHz, 1.0))); + if (QTest::currentTestFailed()) return; + QVERIFY(std::fabs(TestSignals::centsBetween + (medianHz(pitchEvents(m_window->analyser())), + highHz)) < 10.0); + } + + // The coverage strip: one region per range of the take that holds + // recorded singing, drawn in pane 0 and stored in the session + + void coverage_strip_follows_takes() { + FakeAudioIO::Config config; + config.input = tone(highHz, 5.0); + makeWindow(config); + openReference(writeWav(tone(lowHz, 4.0))); + if (QTest::currentTestFailed()) return; + + // Nothing recorded, nothing to show + QVERIFY(!m_window->coverageStrip()->isShown()); + QCOMPARE(stripLayersInPane0(), 0); + + take(700); + if (QTest::currentTestFailed()) return; + QVERIFY(m_window->coverageStrip()->isShown()); + QCOMPARE(int(stripEvents().size()), 1); + verifyStripMatchesTake(); + if (QTest::currentTestFailed()) return; + + // A second recording in a gap: a second bar, and the first + // exactly where it was + sv::sv_frame_t firstEnd = + m_window->takes()->getCoverage().getEndFrame(); + const sv::sv_frame_t P = sv::sv_frame_t(2.0 * rate); + m_window->seekTo(P); + take(700); + if (QTest::currentTestFailed()) return; + QCOMPARE(int(stripEvents().size()), 2); + QCOMPARE(stripEvents()[0].getFrame(), sv::sv_frame_t(0)); + QCOMPARE(stripEvents()[0].getDuration(), firstEnd); + QCOMPARE(stripEvents()[1].getFrame(), P); + verifyStripMatchesTake(); + if (QTest::currentTestFailed()) return; + + // What it looks like: a filled band along the bottom of the pane + // where there is singing, and nothing in the gap (the svgui + // fork's PlotStrip style). The layer is painted on its own: what + // else is in the pane is not the point here + { + sv::Pane *pane = m_window->paneStack()->getPane(0); + QVERIFY(pane); + pane->resize(400, 200); + pane->setZoomLevel(sv::ZoomLevel + (sv::ZoomLevel::FramesPerPixel, 512)); + pane->setCentreFrame(sv::sv_frame_t(2.0 * rate)); + + QImage image(pane->width(), pane->height(), QImage::Format_RGB32); + image.fill(Qt::black); + { + QPainter painter(&image); + stripLayer()->paint(pane, painter, image.rect()); + } + + QRgb strip = sv::ColourDatabase::getInstance()->getColour + (stripLayer()->getBaseColour()).rgb(); + QRgb black = QColor(Qt::black).rgb(); + int y = image.height() - 3; + int inFirst = pane->getXForFrame(firstEnd / 2); + int inGap = pane->getXForFrame((firstEnd + P) / 2); + int inSecond = pane->getXForFrame(P + sv::sv_frame_t(0.3 * rate)); + QVERIFY(inFirst >= 0 && inSecond < image.width()); + QCOMPARE(image.pixel(inFirst, y), strip); + QCOMPARE(image.pixel(inSecond, y), strip); + QCOMPARE(image.pixel(inGap, y), black); + // a band, not a block, and nothing else of a region + for (int yy = 0; yy < image.height() - 12; ++yy) { + QCOMPARE(image.pixel(inFirst, yy), black); + } + } + + // A third that runs from inside the first range into the second: + // the two become one bar + m_window->seekTo(sv::sv_frame_t(0.5 * rate)); + take(2000); + if (QTest::currentTestFailed()) return; + QCOMPARE(int(stripEvents().size()), 1); + QCOMPARE(stripEvents()[0].getFrame(), sv::sv_frame_t(0)); + QVERIFY2(stripEvents()[0].getDuration() > P, + "the recording that joined the two ranges did not reach " + "the second of them"); + verifyStripMatchesTake(); + } + + // Coverage has no file format of its own: the strip's regions are + // where it is stored, and where it comes from when a session is + // opened again. Before this a restored take covered all of its file + void coverage_strip_survives_a_session() { + FakeAudioIO::Config config; + config.input = tone(highHz, 3.0); + makeWindow(config); + openReference(writeWav(tone(lowHz, 4.0))); + if (QTest::currentTestFailed()) return; + + take(700); + if (QTest::currentTestFailed()) return; + m_window->seekTo(sv::sv_frame_t(2.0 * rate)); + take(700); + if (QTest::currentTestFailed()) return; + + Coverage::Ranges before = m_window->takes()->getCoverage().getRanges(); + QCOMPARE(int(before.size()), 2); + QVERIFY(before[0].end < before[1].start); + + QString session = m_dir.filePath("coverage.ton"); + QVERIFY(m_window->saveSessionFile(session)); + m_window->doCloseSession(); + QVERIFY2(!m_window->coverageStrip()->isShown(), + "the coverage strip outlived the session it was made in"); + + m_window->discardModifications(); + QCOMPARE(m_window->openPath(session, MainWindow::ReplaceSession), + MainWindow::FileOpenSucceeded); + QTRY_VERIFY_WITH_TIMEOUT(analysed(m_window->analyser()), 30000); + QTRY_VERIFY_WITH_TIMEOUT(analysed(m_window->analyser2()), 30000); + + // The gap survived: a take restored before this phase covered all + // of its file, which would have been one range and no gap + QVERIFY(m_window->takes()->haveTake()); + QCOMPARE(m_window->takes()->getCoverage().getRanges(), before); + + // ... and the layer showing it is the one the session restored, + // not a second one made beside it + QVERIFY(m_window->coverageStrip()->isShown()); + verifyStripMatchesTake(); + } + + // Nothing of a take's strip is left over for the next singing track + void coverage_strip_replaced_by_load_singing_track() { + FakeAudioIO::Config config; + config.input = tone(highHz, 3.0); + makeWindow(config); + openReference(writeWav(tone(lowHz, 4.0))); + if (QTest::currentTestFailed()) return; + + m_window->seekTo(sv::sv_frame_t(2.0 * rate)); + take(700); + if (QTest::currentTestFailed()) return; + QCOMPARE(int(stripEvents().size()), 1); + QVERIFY(stripEvents()[0].getFrame() > 0); + + // A track loaded whole is a take whose singing is all of it: one + // bar, from frame 0, and no second strip beside the first + m_window->loadSingingTrack(writeWav(tone(highHz, 1.0))); + QTRY_VERIFY_WITH_TIMEOUT(analysed(m_window->analyser2()), 30000); + + auto wave = takeAudio(); + QVERIFY(wave); + QCOMPARE(int(stripEvents().size()), 1); + QCOMPARE(stripEvents()[0].getFrame(), sv::sv_frame_t(0)); + QCOMPARE(stripEvents()[0].getDuration(), wave->getFrameCount()); + verifyStripMatchesTake(); + if (QTest::currentTestFailed()) return; + + // And the session closes without leaving the layer behind + m_window->doCloseSession(); + QVERIFY(!m_window->coverageStrip()->isShown()); + openReference(writeWav(tone(lowHz, 1.0))); + if (QTest::currentTestFailed()) return; + QCOMPARE(stripLayersInPane0(), 0); + } + + // Erase Singing in Selection and Select Recording at Playhead + // (spec 5.2). An erase takes the singing out of the take's audio, + // out of its coverage and strip, and out of the pitch and the notes + // that were analysed there. Nothing is analysed again: the layers + // on screen are the very ones that were there before + + void erase_a_whole_recording() { + FakeAudioIO::Config config; + config.input = tone(highHz, 3.0); + makeWindow(config); + openReference(writeWav(tone(lowHz, 4.0))); + if (QTest::currentTestFailed()) return; + + m_window->seekTo(sv::sv_frame_t(1.0 * rate)); + take(700); + if (QTest::currentTestFailed()) return; + + auto ranges = m_window->takes()->getCoverage().getRanges(); + QCOMPARE(int(ranges.size()), 1); + Coverage::Range recorded = ranges[0]; + QString audioBefore = m_window->takes()->getAudioPath(); + sv::Layer *pitch = m_window->analyser2()->getLayer(Analyser::PitchTrack); + sv::Layer *notes = m_window->analyser2()->getLayer(Analyser::Notes); + QVERIFY(pitch && notes); + QVERIFY(!pitchEvents(pitch).empty()); + QVERIFY(!noteEvents(notes).empty()); + QVERIFY(takeAudioRms(recorded.start, recorded.end) > 0.01); + QVERIFY(takeAudio()); + sv::sv_frame_t frames = takeAudio()->getFrameCount(); + + // The range to erase is what the other new action selects + m_window->seekTo(recorded.start + recorded.length() / 2); + m_window->doSelectRecordingAtPlayhead(); + QCOMPARE(int(m_window->selections().size()), 1); + QCOMPARE(m_window->selections().begin()->getStartFrame(), + recorded.start); + QCOMPARE(m_window->selections().begin()->getEndFrame(), recorded.end); + + m_window->doEraseSingingInSelection(); + + // Nothing is covered any more and the strip has gone with it. + // The take stays, in a file of its own, as long as the one it + // replaces and silent where the singing was + QVERIFY(m_window->takes()->haveTake()); + QVERIFY(m_window->takes()->getCoverage().isEmpty()); + QVERIFY(m_window->takes()->getAudioPath() != audioBefore); + QCOMPARE(m_window->takes()->getSupersededPaths().last(), audioBefore); + QVERIFY(!m_window->coverageStrip()->isShown()); + QCOMPARE(stripLayersInPane0(), 0); + QVERIFY(takeAudioRms(recorded.start + 1000, recorded.end - 1000) + < 1e-6); + + // The same two layers, with nothing left in them where the + // singing was + QCOMPARE(m_window->analyser2()->getLayer(Analyser::PitchTrack), pitch); + QCOMPARE(m_window->analyser2()->getLayer(Analyser::Notes), notes); + QVERIFY(eventsBetween(pitchEvents(pitch), + recorded.start, recorded.end).empty()); + QVERIFY(eventsBetween(noteEvents(notes), + recorded.start, recorded.end).empty()); + + // and the erase started no analysis of the take. (A transformer + // may well be running: making a selection sets Tony's own + // re-analysis of the *reference* going, which has nothing to do + // with the take. Wait for it, and see that the take's pitch and + // notes are still empty when everything has settled) + QVERIFY(!m_window->analysingRange()); + QTRY_VERIFY_WITH_TIMEOUT(analysed(m_window->analyser()), 30000); + QCOMPARE(m_window->analyser2()->getLayer(Analyser::PitchTrack), pitch); + QCOMPARE(m_window->analyser2()->getLayer(Analyser::Notes), notes); + QVERIFY(eventsBetween(pitchEvents(pitch), + recorded.start, recorded.end).empty()); + QVERIFY(eventsBetween(noteEvents(notes), + recorded.start, recorded.end).empty()); + + // The audio under them is the new file, as long as the one it + // replaced (a model of a file just opened takes a moment to know + // how long it is) + QTRY_VERIFY(takeAudio() && takeAudio()->getFrameCount() == frames); + verifyPlaySourceClean(); + } + + // One end of a recording erased: what is left is the rest of it, and + // a note whose onset went with the audio begins where the audio does + void erase_trims_one_end() { + FakeAudioIO::Config config; + config.input = tone(highHz, 3.0); + makeWindow(config); + openReference(writeWav(tone(lowHz, 4.0))); + if (QTest::currentTestFailed()) return; + + m_window->seekTo(sv::sv_frame_t(1.0 * rate)); + take(1000); + if (QTest::currentTestFailed()) return; + + auto ranges = m_window->takes()->getCoverage().getRanges(); + QCOMPARE(int(ranges.size()), 1); + Coverage::Range recorded = ranges[0]; + sv::sv_frame_t cut = recorded.start + sv::sv_frame_t(0.4 * rate); + QVERIFY(cut < recorded.end); + sv::Layer *pitch = m_window->analyser2()->getLayer(Analyser::PitchTrack); + sv::Layer *notes = m_window->analyser2()->getLayer(Analyser::Notes); + QVERIFY(!eventsBetween(pitchEvents(pitch), recorded.start, + cut).empty()); + + m_window->selectRange(recorded.start, cut); + m_window->doEraseSingingInSelection(); + + auto left = m_window->takes()->getCoverage().getRanges(); + QCOMPARE(int(left.size()), 1); + QCOMPARE(left[0], Coverage::Range(cut, recorded.end)); + QCOMPARE(int(stripEvents().size()), 1); + verifyStripMatchesTake(); + if (QTest::currentTestFailed()) return; + + // Silent where the erase went, and as it was after that + QVERIFY(takeAudioRms(recorded.start + 1000, cut - 1000) < 1e-6); + QVERIFY(takeAudioRms(cut + 1000, recorded.end - 1000) > 0.01); + + QCOMPARE(m_window->analyser2()->getLayer(Analyser::PitchTrack), pitch); + QCOMPARE(m_window->analyser2()->getLayer(Analyser::Notes), notes); + QVERIFY(eventsBetween(pitchEvents(pitch), recorded.start, cut).empty()); + QVERIFY(!eventsBetween(pitchEvents(pitch), cut, recorded.end).empty()); + + auto notesLeft = noteEvents(notes); + QVERIFY(!notesLeft.empty()); + for (const auto &e : notesLeft) { + QVERIFY2(e.getFrame() >= cut, + "a note was left starting inside the erased range"); + } + QVERIFY(!m_window->analysingRange()); + } + + // A range erased from the middle: the coverage, the strip and the + // note that ran through it are each in two parts afterwards + void erase_splits_a_recording() { + FakeAudioIO::Config config; + config.input = tone(highHz, 3.0); + makeWindow(config); + openReference(writeWav(tone(lowHz, 4.0))); + if (QTest::currentTestFailed()) return; + + m_window->seekTo(sv::sv_frame_t(1.0 * rate)); + take(1200); + if (QTest::currentTestFailed()) return; + + auto ranges = m_window->takes()->getCoverage().getRanges(); + QCOMPARE(int(ranges.size()), 1); + Coverage::Range recorded = ranges[0]; + sv::sv_frame_t from = recorded.start + sv::sv_frame_t(0.4 * rate); + sv::sv_frame_t to = recorded.start + sv::sv_frame_t(0.8 * rate); + QVERIFY(to < recorded.end); + sv::Layer *pitch = m_window->analyser2()->getLayer(Analyser::PitchTrack); + sv::Layer *notes = m_window->analyser2()->getLayer(Analyser::Notes); + + m_window->selectRange(from, to); + m_window->doEraseSingingInSelection(); + + auto left = m_window->takes()->getCoverage().getRanges(); + QCOMPARE(int(left.size()), 2); + QCOMPARE(left[0], Coverage::Range(recorded.start, from)); + QCOMPARE(left[1], Coverage::Range(to, recorded.end)); + QCOMPARE(int(stripEvents().size()), 2); + verifyStripMatchesTake(); + if (QTest::currentTestFailed()) return; + + QVERIFY(takeAudioRms(recorded.start + 1000, from - 1000) > 0.01); + QVERIFY(takeAudioRms(from + 1000, to - 1000) < 1e-6); + QVERIFY(takeAudioRms(to + 1000, recorded.end - 1000) > 0.01); + + QCOMPARE(m_window->analyser2()->getLayer(Analyser::PitchTrack), pitch); + QCOMPARE(m_window->analyser2()->getLayer(Analyser::Notes), notes); + QVERIFY(!eventsBetween(pitchEvents(pitch), recorded.start, + from).empty()); + QVERIFY(eventsBetween(pitchEvents(pitch), from, to).empty()); + QVERIFY(!eventsBetween(pitchEvents(pitch), to, recorded.end).empty()); + + // Nothing of a note is left over the erased audio + auto notesLeft = noteEvents(notes); + QVERIFY(!notesLeft.empty()); + for (const auto &e : notesLeft) { + QVERIFY2(e.getFrame() + e.getDuration() <= from || + e.getFrame() >= to, + "a note still runs through the erased range"); + } + QVERIFY(!m_window->analysingRange()); + } + + // The coverage range the playhead is in becomes the selection; in a + // gap there is nothing to select, and what is selected is left alone + void select_recording_at_playhead() { + FakeAudioIO::Config config; + config.input = tone(highHz, 3.0); + makeWindow(config); + openReference(writeWav(tone(lowHz, 4.0))); + if (QTest::currentTestFailed()) return; + + take(700); + if (QTest::currentTestFailed()) return; + m_window->seekTo(sv::sv_frame_t(2.0 * rate)); + take(700); + if (QTest::currentTestFailed()) return; + + auto ranges = m_window->takes()->getCoverage().getRanges(); + QCOMPARE(int(ranges.size()), 2); + m_window->clearSelections(); + + m_window->seekTo(ranges[0].start + ranges[0].length() / 2); + m_window->doSelectRecordingAtPlayhead(); + QCOMPARE(int(m_window->selections().size()), 1); + QCOMPARE(m_window->selections().begin()->getStartFrame(), + ranges[0].start); + QCOMPARE(m_window->selections().begin()->getEndFrame(), ranges[0].end); + + // In the gap between the two recordings + m_window->seekTo((ranges[0].end + ranges[1].start) / 2); + m_window->doSelectRecordingAtPlayhead(); + QCOMPARE(int(m_window->selections().size()), 1); + QCOMPARE(m_window->selections().begin()->getStartFrame(), + ranges[0].start); + + m_window->seekTo(ranges[1].start + ranges[1].length() / 2); + m_window->doSelectRecordingAtPlayhead(); + QCOMPARE(int(m_window->selections().size()), 1); + QCOMPARE(m_window->selections().begin()->getStartFrame(), + ranges[1].start); + QCOMPARE(m_window->selections().begin()->getEndFrame(), ranges[1].end); + } + + // Neither action is to be had without a take with singing in it, nor + // while one is being recorded, and erasing needs a selection as well + void erase_actions_enabled_when_they_apply() { + FakeAudioIO::Config config; + config.input = tone(highHz, 3.0); + makeWindow(config); + openReference(writeWav(tone(lowHz, 4.0))); + if (QTest::currentTestFailed()) return; + + m_window->selectRange(0, sv::sv_frame_t(0.5 * rate)); + QVERIFY(!m_window->eraseSingingAction()->isEnabled()); + QVERIFY(!m_window->selectRecordingAction()->isEnabled()); + + m_window->clearSelections(); + take(700); + if (QTest::currentTestFailed()) return; + + // A take, but nothing selected to erase from it + m_window->clearSelections(); + QVERIFY(m_window->selectRecordingAction()->isEnabled()); + QVERIFY(!m_window->eraseSingingAction()->isEnabled()); + + m_window->selectRange(0, sv::sv_frame_t(0.5 * rate)); + QVERIFY(m_window->selectRecordingAction()->isEnabled()); + QVERIFY(m_window->eraseSingingAction()->isEnabled()); + + // and neither of them while the next take is being recorded + startTake(); + if (QTest::currentTestFailed()) return; + QVERIFY(!m_window->eraseSingingAction()->isEnabled()); + QVERIFY(!m_window->selectRecordingAction()->isEnabled()); + QTest::qWait(300); + stopTake(); + } + + // Undo and redo of the singing of a take (spec 5.4) + + // A recording over material that is already there, undone and redone: + // the audio file, the coverage, the strip and every pitch event and + // note come back exactly as they were + void undo_redo_a_take() { + FakeAudioIO::Config config; + config.input = tone(highHz, 8.0); + makeWindow(config); + openReference(writeWav(tone(lowHz, 4.0))); + if (QTest::currentTestFailed()) return; + + // The material the second recording is undone back to + take(1000); + if (QTest::currentTestFailed()) return; + TakeSnapshot before = snapshotTake(); + QVERIFY(!before.pitch.empty()); + QVERIFY(!before.notes.empty()); + QVERIFY(before.frames > 0); + + // A second recording, into the silence after the first + m_window->seekTo(sv::sv_frame_t(2.0 * rate)); + take(700); + if (QTest::currentTestFailed()) return; + + TakeSnapshot after = snapshotTake(); + QVERIFY(after.path != before.path); + QCOMPARE(int(after.coverage.size()), 2); + QVERIFY(after.frames > before.frames); + Coverage::Range recorded = after.coverage[1]; + QVERIFY(takeAudioRms(recorded.start + 1000, recorded.end - 1000) + > 0.01); + + QCOMPARE(undoOnce(), QString("Record Singing")); + verifyTakeMatches(before); + if (QTest::currentTestFailed()) return; + + // The file the take plays is the one from before, in which the + // second recording's range was never anything but silence + QVERIFY(takeAudioRms(recorded.start + 1000, + std::min(recorded.end, before.frames) - 1000) + < 1e-6); + QVERIFY(!m_window->analysingRange()); + verifyPlaySourceClean(); + + QCOMPARE(redoOnce(), QString("Record Singing")); + verifyTakeMatches(after); + if (QTest::currentTestFailed()) return; + QVERIFY(takeAudioRms(recorded.start + 1000, recorded.end - 1000) + > 0.01); + + // The redo restored the analysis from the command; it did not run + // pYIN again + QVERIFY(!m_window->analysingRange()); + verifyPlaySourceClean(); + } + + // An erase undone and redone, the same way + void undo_redo_an_erase() { + FakeAudioIO::Config config; + config.input = tone(highHz, 4.0); + makeWindow(config); + openReference(writeWav(tone(lowHz, 4.0))); + if (QTest::currentTestFailed()) return; + + m_window->seekTo(sv::sv_frame_t(1.0 * rate)); + take(1200); + if (QTest::currentTestFailed()) return; + + TakeSnapshot before = snapshotTake(); + QCOMPARE(int(before.coverage.size()), 1); + QVERIFY(!before.pitch.empty()); + QVERIFY(!before.notes.empty()); + + Coverage::Range recorded = before.coverage[0]; + sv::sv_frame_t from = recorded.start + sv::sv_frame_t(0.4 * rate); + sv::sv_frame_t to = recorded.start + sv::sv_frame_t(0.8 * rate); + m_window->selectRange(from, to); + m_window->doEraseSingingInSelection(); + + TakeSnapshot after = snapshotTake(); + QVERIFY(after.path != before.path); + QCOMPARE(int(after.coverage.size()), 2); + QVERIFY(eventsBetween(after.pitch, from, to).empty()); + QVERIFY(takeAudioRms(from + 1000, to - 1000) < 1e-6); + + QCOMPARE(undoOnce(), QString("Erase Singing")); + verifyTakeMatches(before); + if (QTest::currentTestFailed()) return; + QVERIFY(takeAudioRms(from + 1000, to - 1000) > 0.01); + + QCOMPARE(redoOnce(), QString("Erase Singing")); + verifyTakeMatches(after); + if (QTest::currentTestFailed()) return; + QVERIFY(takeAudioRms(from + 1000, to - 1000) < 1e-6); + + // The selection is still there and the reference's own + // re-analysis of it may still be running: let it finish before + // the window goes + QTRY_VERIFY_WITH_TIMEOUT(analysed(m_window->analyser()), 30000); + verifyPlaySourceClean(); + } + + // Undo pressed while the analysis of the recorded range is still + // running: the run is abandoned, so nothing of it lands on the take + // that has been put back. The redo has to run it again -- that + // result never existed to be restored + void undo_during_analysis_then_redo() { + FakeAudioIO::Config config; + config.input = tone(highHz, 8.0); + makeWindow(config); + openReference(writeWav(tone(lowHz, 4.0))); + if (QTest::currentTestFailed()) return; + + take(1000); + if (QTest::currentTestFailed()) return; + TakeSnapshot before = snapshotTake(); + QVERIFY(!before.pitch.empty()); + + // Stop splices the recording in and starts the analysis of it + // there and then. Held, so that it is still running when Undo is + // pressed, and the redo's run when its range is read + m_window->seekTo(sv::sv_frame_t(2.0 * rate)); + startTake(); + if (QTest::currentTestFailed()) return; + QTest::qWait(700); + m_window->holdRangedMerges(true); + m_window->doRecord(); + QVERIFY(!m_window->recordTarget()->isRecording()); + QVERIFY2(m_window->analysingRange(), + "the race was not set up: no analysis was running after Stop"); + sv::sv_frame_t analysedStart = m_window->analysedRangeStart(); + sv::sv_frame_t analysedEnd = m_window->analysedRangeEnd(); + QVERIFY(analysedEnd > analysedStart); + + QCOMPARE(undoOnce(), QString("Record Singing")); + QVERIFY2(!m_window->analysingRange(), + "the analysis was left running over the undone take"); + verifyTakeMatches(before); + if (QTest::currentTestFailed()) return; + + // The live dots were waiting for that analysis; nothing is + QVERIFY(!m_window->realtimeLayer()); + QVERIFY(m_window->eraseSingingAction()->isEnabled() || + m_window->selections().empty()); + + // Redo: the range is analysed again, and this time the result + // reaches the take's pitch track + QCOMPARE(redoOnce(), QString("Record Singing")); + QVERIFY(m_window->analysingRange()); + QCOMPARE(m_window->analysedRangeStart(), analysedStart); + QCOMPARE(m_window->analysedRangeEnd(), analysedEnd); + m_window->holdRangedMerges(false); + QTRY_VERIFY_WITH_TIMEOUT(analysed(m_window->analyser2()), 30000); + + auto ranges = m_window->takes()->getCoverage().getRanges(); + QCOMPARE(int(ranges.size()), 2); + QVERIFY2(!eventsBetween(pitchEvents(m_window->analyser2()), + ranges[1].start, ranges[1].end).empty(), + "the redone recording was never analysed"); + verifyPlaySourceClean(); + } + + // The first recording of a take undone: there is no take and no + // singing track at all, as before it, and Record still works + void undo_a_first_take_then_record_again() { + FakeAudioIO::Config config; + config.input = tone(highHz, 8.0); + makeWindow(config); + openReference(writeWav(tone(lowHz, 3.0))); + if (QTest::currentTestFailed()) return; + + int panes = m_window->paneStack()->getPaneCount(); + take(700); + if (QTest::currentTestFailed()) return; + QVERIFY(m_window->takes()->haveTake()); + + QCOMPARE(undoOnce(), QString("Record Singing")); + QVERIFY(!m_window->takes()->haveTake()); + QVERIFY(m_window->takes()->getCoverage().isEmpty()); + QVERIFY2(!m_window->analyser2(), + "the singing track was left behind with no audio"); + QVERIFY(!m_window->coverageStrip()->isShown()); + QCOMPARE(stripLayersInPane0(), 0); + QCOMPARE(noteLayersInPane0(), 1); // the reference's + QCOMPARE(m_window->paneStack()->getPaneCount(), panes); + verifyPlaySourceClean(); + + // and the reference is untouched + QVERIFY(std::fabs(TestSignals::centsBetween + (medianHz(pitchEvents(m_window->analyser())), + lowHz)) < 10.0); + + // Recording again makes a take from nothing, as the first + // recording did + m_window->seekTo(sv::sv_frame_t(1.0 * rate)); + take(700); + if (QTest::currentTestFailed()) return; + + QVERIFY(m_window->takes()->haveTake()); + auto ranges = m_window->takes()->getCoverage().getRanges(); + QCOMPARE(int(ranges.size()), 1); + QCOMPARE(ranges[0].start, sv::sv_frame_t(1.0 * rate)); + QVERIFY(m_window->analyser2()); + QVERIFY(!pitchEvents(m_window->analyser2()).empty()); + QVERIFY(!noteEvents(m_window->analyser2() + ->getLayer(Analyser::Notes)).empty()); + verifyStripMatchesTake(); + verifyPlaySourceClean(); + } + + // What one take leaves on the undo stack: the take, and nothing of + // the layers and panes Tony makes for itself along the way + void undo_stack_top_after_a_take() { + FakeAudioIO::Config config; + config.input = tone(highHz, 3.0); + makeWindow(config); + openReference(writeWav(tone(lowHz, 1.0))); + if (QTest::currentTestFailed()) return; + + // Nothing of loading and analysing the reference is undoable + // either: those layers are Tony's, not the user's + QCOMPARE(undoOnce(), QString()); + + take(700); + if (QTest::currentTestFailed()) return; + QVERIFY(m_window->isDocumentModified()); + + QCOMPARE(undoOnce(), QString("Record Singing")); + QVERIFY(!m_window->takes()->haveTake()); + QCOMPARE(undoOnce(), QString()); + + QCOMPARE(redoOnce(), QString("Record Singing")); + QVERIFY(m_window->takes()->haveTake()); + QCOMPARE(redoOnce(), QString()); + } + + // A take does not cost the takes before it their undo: each is an + // entry of its own, undone and redone in order + void undo_two_takes_in_order() { + FakeAudioIO::Config config; + config.input = tone(highHz, 5.0); + makeWindow(config); + openReference(writeWav(tone(lowHz, 4.0))); + if (QTest::currentTestFailed()) return; + + take(700); + if (QTest::currentTestFailed()) return; + QTRY_VERIFY_WITH_TIMEOUT(analysed(m_window->analyser2()), 30000); + if (QTest::currentTestFailed()) return; + QString firstPath = m_window->takes()->getAudioPath(); + Coverage firstCoverage = m_window->takes()->getCoverage(); + + m_window->seekTo(sv::sv_frame_t(2.0 * rate)); + take(700); + if (QTest::currentTestFailed()) return; + QTRY_VERIFY_WITH_TIMEOUT(analysed(m_window->analyser2()), 30000); + if (QTest::currentTestFailed()) return; + QString secondPath = m_window->takes()->getAudioPath(); + QCOMPARE(int(m_window->takes()->getCoverage().getRanges().size()), 2); + + QCOMPARE(undoOnce(), QString("Record Singing")); + QCOMPARE(m_window->takes()->getAudioPath(), firstPath); + QVERIFY(m_window->takes()->getCoverage() == firstCoverage); + + QCOMPARE(undoOnce(), QString("Record Singing")); + QVERIFY(!m_window->takes()->haveTake()); + QCOMPARE(undoOnce(), QString()); + + QCOMPARE(redoOnce(), QString("Record Singing")); + QCOMPARE(m_window->takes()->getAudioPath(), firstPath); + QCOMPARE(redoOnce(), QString("Record Singing")); + QCOMPARE(m_window->takes()->getAudioPath(), secondPath); + QCOMPARE(int(m_window->takes()->getCoverage().getRanges().size()), 2); + } + + // The audio files a take has been through are kept until the session + // closes, and then only the ones nothing refers to any more go + void take_files_deleted_on_close() { + FakeAudioIO::Config config; + config.input = tone(highHz, 6.0); + makeWindow(config); + openReference(writeWav(tone(lowHz, 4.0))); + if (QTest::currentTestFailed()) return; + + take(700); + if (QTest::currentTestFailed()) return; + m_window->seekTo(sv::sv_frame_t(2.0 * rate)); + take(700); + if (QTest::currentTestFailed()) return; + + QStringList written = m_window->takes()->getWrittenPaths(); + QCOMPARE(int(written.size()), 2); + QCOMPARE(written.last(), m_window->takes()->getAudioPath()); + for (const QString &path : written) { + QVERIFY2(QFileInfo::exists(path), qPrintable(path)); + } + + // Only the superseded one is unused: the take's own file is kept + // even though this session was never saved + QCOMPARE(m_window->takes()->unusedWrittenFiles(), + QStringList { written.first() }); + + m_window->doCloseSession(); + + QVERIFY2(!QFileInfo::exists(written.first()), + "a superseded take audio file was left behind"); + QVERIFY2(QFileInfo::exists(written.last()), + "the take's own audio file was deleted"); + + // The commands that held those paths went with the session + QCOMPARE(undoOnce(), QString()); + } + + // Several takes (spec 5.3). Every take of the session has its three + // layers in pane 0, named after it; only the active take has an audio + // model and the singing analyser, and the rest are hidden and silent + + void first_recording_makes_take_1() { + FakeAudioIO::Config config; + config.input = tone(highHz, 3.0); + makeWindow(config); + openReference(writeWav(tone(lowHz, 2.0))); + if (QTest::currentTestFailed()) return; + + // No take, nothing in the combo box, and nothing to act on + QCOMPARE(m_window->takes()->getTakeCount(), 0); + QCOMPARE(m_window->takeCombo()->count(), 0); + m_window->doUpdateMenuStates(); + QVERIFY(m_window->newTakeAction()->isEnabled()); + QVERIFY(!m_window->duplicateTakeAction()->isEnabled()); + QVERIFY(!m_window->deleteTakeAction()->isEnabled()); + + take(600); + if (QTest::currentTestFailed()) return; + + QCOMPARE(m_window->takes()->getTakeNames(), QStringList { "Take 1" }); + QCOMPARE(m_window->takes()->getActiveIndex(), 0); + + // The layers of the take carry its name, and they are the ones the + // analyser holds + Analyser *a2 = m_window->analyser2(); + QVERIFY(a2); + TakeLayers::Found found = takeLayers("Take 1"); + QCOMPARE(static_cast(found.pitch), + a2->getLayer(Analyser::PitchTrack)); + QCOMPARE(static_cast(found.notes), + a2->getLayer(Analyser::Notes)); + QCOMPARE(static_cast(found.coverage), + static_cast(stripLayer())); + + QCOMPARE(m_window->takeCombo()->count(), 1); + QCOMPARE(m_window->takeCombo()->currentText(), QString("Take 1")); + m_window->doUpdateMenuStates(); + QVERIFY(m_window->duplicateTakeAction()->isEnabled()); + QVERIFY(m_window->deleteTakeAction()->isEnabled()); + } + + // New Empty Take leaves the take that was on show as it is, and the + // next recording goes into the new one + void new_empty_take_then_record() { + FakeAudioIO::Config config; + config.input = tone(highHz, 6.0); + makeWindow(config); + openReference(writeWav(tone(lowHz, 4.0))); + if (QTest::currentTestFailed()) return; + + take(700); + if (QTest::currentTestFailed()) return; + TakeSnapshot first = snapshotTake(); + QVERIFY(!first.pitch.empty()); + QVERIFY(!first.notes.empty()); + TakeLayers::Found one = takeLayers("Take 1"); + QVERIFY(one.pitch && one.notes && one.coverage); + + m_window->doNewEmptyTake(); + + // Two takes, the new one active with nothing in it and nothing to + // show it with + QCOMPARE(m_window->takes()->getTakeNames(), + QStringList({ "Take 1", "Take 2" })); + QCOMPARE(m_window->takes()->getActiveIndex(), 1); + QVERIFY(!m_window->takes()->haveTake()); + QVERIFY2(!m_window->analyser2(), + "the empty take was given the singing analyser"); + QVERIFY(!m_window->coverageStrip()->isShown()); + QCOMPARE(m_window->takeCombo()->currentText(), QString("Take 2")); + + // The first take's layers are the same objects, with their events, + // and they are put away + QCOMPARE(takeLayers("Take 1").pitch, one.pitch); + QCOMPARE(pitchEvents(one.pitch), first.pitch); + QCOMPARE(noteEvents(one.notes), first.notes); + verifyTakeIsPutAway("Take 1"); + if (QTest::currentTestFailed()) return; + + // Recording into the new take: its own audio file, its own layers + m_window->seekTo(sv::sv_frame_t(2.0 * rate)); + take(700); + if (QTest::currentTestFailed()) return; + + QCOMPARE(m_window->takes()->getActiveIndex(), 1); + QVERIFY(m_window->takes()->getAudioPath() != first.path); + auto ranges = m_window->takes()->getCoverage().getRanges(); + QCOMPARE(int(ranges.size()), 1); + QVERIFY(ranges[0].start >= sv::sv_frame_t(2.0 * rate)); + + TakeLayers::Found two = takeLayers("Take 2"); + QVERIFY(two.pitch && two.notes && two.coverage); + QVERIFY(two.pitch != one.pitch && two.notes != one.notes); + QCOMPARE(static_cast(two.notes), + m_window->analyser2()->getLayer(Analyser::Notes)); + QVERIFY(!pitchEvents(two.pitch).empty()); + + // The first take is untouched by all of it + QCOMPARE(pitchEvents(one.pitch), first.pitch); + QCOMPARE(noteEvents(one.notes), first.notes); + QCOMPARE(m_window->takes()->getTake(0)->audioPath, first.path); + verifyTakeIsPutAway("Take 1"); + if (QTest::currentTestFailed()) return; + + // Each take has its own strip, and one of them is on show + QCOMPARE(allStripLayersInPane0(), 2); + verifyStripMatchesTake(); + verifyPlaySourceClean(); + } + + // Switching back and forth: the take's audio, coverage, strip, pitch + // and notes come back exactly, the layers are the very same objects + // and nothing is analysed + void switch_between_takes() { + FakeAudioIO::Config config; + config.input = tone(highHz, 6.0); + makeWindow(config); + openReference(writeWav(tone(lowHz, 4.0))); + if (QTest::currentTestFailed()) return; + + take(700); + if (QTest::currentTestFailed()) return; + TakeSnapshot first = snapshotTake(); + TakeLayers::Found one = takeLayers("Take 1"); + + m_window->doNewEmptyTake(); + m_window->seekTo(sv::sv_frame_t(2.0 * rate)); + take(700); + if (QTest::currentTestFailed()) return; + TakeSnapshot second = snapshotTake(); + TakeLayers::Found two = takeLayers("Take 2"); + QVERIFY(second.path != first.path); + QVERIFY(second.frames > 0 && first.frames > 0); + + // Back to the first take, through the combo box as the user does + m_window->doChooseTakeInCombo(0); + QCOMPARE(m_window->takes()->getActiveIndex(), 0); + + verifyTakeMatches(first); + if (QTest::currentTestFailed()) return; + QCOMPARE(takeLayers("Take 1").pitch, one.pitch); + QCOMPARE(takeLayers("Take 1").notes, one.notes); + QCOMPARE(static_cast(one.pitch), + m_window->analyser2()->getLayer(Analyser::PitchTrack)); + verifyTakeIsPutAway("Take 2"); + if (QTest::currentTestFailed()) return; + + // Nothing was analysed, then or when the queued calls ran + QVERIFY(!m_window->analysingRange()); + QVERIFY(!sv::ModelTransformerFactory::getInstance() + ->haveRunningTransformers()); + QCoreApplication::processEvents(); + QVERIFY(!sv::ModelTransformerFactory::getInstance() + ->haveRunningTransformers()); + + // The note tool acts on the pane's topmost note layer: it has to + // be the take that is on show, not the one put away + QCOMPARE(topNoteLayerInPane0(), static_cast(one.notes)); + verifyStripMatchesTake(); + if (QTest::currentTestFailed()) return; + + // The undo history goes with a switch (spec 5.4) + QCOMPARE(undoOnce(), QString()); + + // ... and back to the second + m_window->doChooseTakeInCombo(1); + QCOMPARE(m_window->takes()->getActiveIndex(), 1); + verifyTakeMatches(second); + if (QTest::currentTestFailed()) return; + QCOMPARE(takeLayers("Take 2").notes, two.notes); + QCOMPARE(topNoteLayerInPane0(), static_cast(two.notes)); + verifyTakeIsPutAway("Take 1"); + if (QTest::currentTestFailed()) return; + QVERIFY(!m_window->analysingRange()); + verifyStripMatchesTake(); + verifyPlaySourceClean(); + } + + // A take that is not on show is silent and cannot hold playback open + // past the end of the audio that is + void inactive_take_does_not_play() { + FakeAudioIO::Config config; + config.input = tone(highHz, 4.0); + makeWindow(config); + openReference(writeWav(tone(lowHz, 1.0))); + if (QTest::currentTestFailed()) return; + + // A take that reaches well past the end of the reference + take(1500); + if (QTest::currentTestFailed()) return; + sv::sv_frame_t longest = m_window->takes()->getCoverage().getEndFrame(); + QVERIFY(longest > sv::sv_frame_t(1.2 * rate)); + QVERIFY(m_window->playSource()->getPlayEndFrame() >= longest); + + // ... and a short one beside it + m_window->doNewEmptyTake(); + m_window->seekTo(0); + take(400); + if (QTest::currentTestFailed()) return; + + verifyTakeIsPutAway("Take 1"); + if (QTest::currentTestFailed()) return; + + // Playback ends with the longest of what can be heard: the + // reference and the take on show, not the take put away + sv::sv_frame_t end = m_window->playSource()->getPlayEndFrame(); + QVERIFY2(end < longest, + qPrintable(QString("playback still runs to frame %1, the end " + "of the take that was put away (%2)") + .arg(end).arg(longest))); + QVERIFY(end >= sv::sv_frame_t(1.0 * rate)); + verifyPlaySourceClean(); + } + + // Duplicate Take: a copy of the take, sharing its audio file, and + // recording into the copy leaves the original alone + void duplicate_take_then_record() { + FakeAudioIO::Config config; + config.input = tone(highHz, 6.0); + makeWindow(config); + openReference(writeWav(tone(lowHz, 4.0))); + if (QTest::currentTestFailed()) return; + + take(700); + if (QTest::currentTestFailed()) return; + TakeSnapshot first = snapshotTake(); + QVERIFY(!first.pitch.empty()); + QVERIFY(!first.notes.empty()); + TakeLayers::Found one = takeLayers("Take 1"); + + m_window->doDuplicateTake(); + + QCOMPARE(m_window->takes()->getTakeNames(), + QStringList({ "Take 1", "Take 2" })); + QCOMPARE(m_window->takes()->getActiveIndex(), 1); + + // The same audio file and coverage, and the same events in layers + // of its own + QCOMPARE(m_window->takes()->getAudioPath(), first.path); + QCOMPARE(m_window->takes()->getCoverage().getRanges(), first.coverage); + TakeLayers::Found two = takeLayers("Take 2"); + QVERIFY(two.pitch && two.notes && two.coverage); + QVERIFY(two.pitch != one.pitch && two.notes != one.notes); + QCOMPARE(pitchEvents(two.pitch), first.pitch); + QCOMPARE(noteEvents(two.notes), first.notes); + verifyStripMatchesTake(); + if (QTest::currentTestFailed()) return; + verifyTakeIsPutAway("Take 1"); + if (QTest::currentTestFailed()) return; + + // No audio file was written, and the shared one is not up for + // deletion however thoroughly either take supersedes it + QCOMPARE(m_window->takes()->getWrittenPaths(), + QStringList { first.path }); + + // Recording into the copy: its own file from now on, and the take + // it was copied from is exactly as it was + m_window->seekTo(sv::sv_frame_t(2.0 * rate)); + take(700); + if (QTest::currentTestFailed()) return; + + QVERIFY(m_window->takes()->getAudioPath() != first.path); + QCOMPARE(int(m_window->takes()->getCoverage().getRanges().size()), 2); + QCOMPARE(m_window->takes()->getTake(0)->audioPath, first.path); + QCOMPARE(m_window->takes()->getTake(0)->coverage.getRanges(), + first.coverage); + QCOMPARE(pitchEvents(one.pitch), first.pitch); + QCOMPARE(noteEvents(one.notes), first.notes); + QVERIFY2(m_window->takes()->unusedWrittenFiles().isEmpty(), + "the file the first take still plays was up for deletion"); + QVERIFY(QFileInfo::exists(first.path)); + verifyPlaySourceClean(); + } + + // Delete Take asks first, and deletes the take's layers and nothing + // else: never an audio file + void delete_the_active_take() { + FakeAudioIO::Config config; + config.input = tone(highHz, 6.0); + makeWindow(config); + openReference(writeWav(tone(lowHz, 4.0))); + if (QTest::currentTestFailed()) return; + + take(700); + if (QTest::currentTestFailed()) return; + TakeSnapshot first = snapshotTake(); + TakeLayers::Found one = takeLayers("Take 1"); + + m_window->doNewEmptyTake(); + m_window->seekTo(sv::sv_frame_t(2.0 * rate)); + take(700); + if (QTest::currentTestFailed()) return; + QString secondPath = m_window->takes()->getAudioPath(); + TakeLayers::Found two = takeLayers("Take 2"); + + // Asked, and answered no: nothing happens + m_window->setDeleteTakeAnswer(false); + m_window->doDeleteTake(); + QCOMPARE(m_window->deleteTakeQuestions(), 1); + QCOMPARE(m_window->takes()->getTakeCount(), 2); + + m_window->setDeleteTakeAnswer(true); + m_window->doDeleteTake(); + QCOMPARE(m_window->deleteTakeQuestions(), 2); + + // The take before it is the active one, as it was + QCOMPARE(m_window->takes()->getTakeNames(), QStringList { "Take 1" }); + QCOMPARE(m_window->takes()->getActiveIndex(), 0); + verifyTakeMatches(first); + if (QTest::currentTestFailed()) return; + QCOMPARE(takeLayers("Take 1").pitch, one.pitch); + QCOMPARE(static_cast(one.notes), + m_window->analyser2()->getLayer(Analyser::Notes)); + + // The deleted take's layers are gone from the pane and from the + // document, and its audio file is still on disk + QVERIFY(!takeLayers("Take 2").pitch); + QVERIFY(!takeLayers("Take 2").notes); + QVERIFY(!takeLayers("Take 2").coverage); + QVERIFY(!documentHasLayer(two.pitch)); + QVERIFY(!documentHasLayer(two.notes)); + QVERIFY(!documentHasLayer(two.coverage)); + QCOMPARE(noteLayersInPane0(), 2); // the reference's and Take 1's + QVERIFY2(QFileInfo::exists(secondPath), + "deleting a take deleted its audio file"); + verifyStripMatchesTake(); + verifyPlaySourceClean(); + if (QTest::currentTestFailed()) return; + + // And the last take can go too, leaving no take at all + m_window->doDeleteTake(); + QCOMPARE(m_window->takes()->getTakeCount(), 0); + QCOMPARE(m_window->takes()->getActiveIndex(), -1); + QVERIFY(!m_window->analyser2()); + QCOMPARE(allStripLayersInPane0(), 0); + QCOMPARE(noteLayersInPane0(), 1); // the reference's + QCOMPARE(m_window->takeCombo()->count(), 0); + QVERIFY(QFileInfo::exists(first.path)); + + // Recording again makes a take, as the first recording did + m_window->seekTo(0); + take(600); + if (QTest::currentTestFailed()) return; + QCOMPARE(m_window->takes()->getTakeNames(), QStringList { "Take 3" }); + QVERIFY(!pitchEvents(m_window->analyser2()).empty()); + verifyStripMatchesTake(); + } + + // Deleting a take that is not the active one: the one on show does not + // move + void delete_an_inactive_take() { + FakeAudioIO::Config config; + config.input = tone(highHz, 6.0); + makeWindow(config); + openReference(writeWav(tone(lowHz, 4.0))); + if (QTest::currentTestFailed()) return; + + take(700); + if (QTest::currentTestFailed()) return; + TakeLayers::Found one = takeLayers("Take 1"); + + m_window->doNewEmptyTake(); + m_window->seekTo(sv::sv_frame_t(2.0 * rate)); + take(700); + if (QTest::currentTestFailed()) return; + TakeSnapshot second = snapshotTake(); + + QVERIFY(m_window->doDeleteTakeAt(0)); + + QCOMPARE(m_window->takes()->getTakeNames(), QStringList { "Take 2" }); + QCOMPARE(m_window->takes()->getActiveIndex(), 0); + verifyTakeMatches(second); + if (QTest::currentTestFailed()) return; + QVERIFY(!takeLayers("Take 1").pitch); + QVERIFY(!documentHasLayer(one.pitch)); + QCOMPARE(noteLayersInPane0(), 2); + verifyStripMatchesTake(); + verifyPlaySourceClean(); + } + + // Rename Take: the take and its layers, and not the undo history + void rename_take() { + FakeAudioIO::Config config; + config.input = tone(highHz, 3.0); + makeWindow(config); + openReference(writeWav(tone(lowHz, 2.0))); + if (QTest::currentTestFailed()) return; + + take(700); + if (QTest::currentTestFailed()) return; + TakeLayers::Found before = takeLayers("Take 1"); + QVERIFY(before.pitch && before.notes && before.coverage); + + m_window->setTakeNameAnswer("Chorus"); + m_window->doRenameTake(); + + QCOMPARE(m_window->takes()->getTakeNames(), QStringList { "Chorus" }); + QCOMPARE(m_window->takeCombo()->currentText(), QString("Chorus")); + + // The same layers, named after the take again + TakeLayers::Found after = takeLayers("Chorus"); + QCOMPARE(after.pitch, before.pitch); + QCOMPARE(after.notes, before.notes); + QCOMPARE(after.coverage, before.coverage); + QVERIFY(!takeLayers("Take 1").pitch); + QCOMPARE(m_window->coverageStrip()->getTakeName(), QString("Chorus")); + verifyStripMatchesTake(); + if (QTest::currentTestFailed()) return; + + // A rename is not a change to the singing, so the recording can + // still be undone (spec 5.4) + QCOMPARE(undoOnce(), QString("Record Singing")); + QCOMPARE(redoOnce(), QString("Record Singing")); + + // The take's audio is still under its layers after all that. The + // layers are looked up again: an undo of the first recording of a + // take takes them away, and the redo makes them afresh + QVERIFY(m_window->analyser2()); + QCOMPARE(static_cast(takeLayers("Chorus").pitch), + m_window->analyser2()->getLayer(Analyser::PitchTrack)); + QCOMPARE(m_window->takes()->getTakeNames(), QStringList { "Chorus" }); + } + + // The take operations are not to be had while a take is being + // recorded, and a new take then record is not the same as a switch + void take_actions_disabled_while_recording() { + FakeAudioIO::Config config; + config.input = tone(highHz, 4.0); + makeWindow(config); + openReference(writeWav(tone(lowHz, 2.0))); + if (QTest::currentTestFailed()) return; + + take(600); + if (QTest::currentTestFailed()) return; + + m_window->doUpdateMenuStates(); + QVERIFY(m_window->takeCombo()->isEnabled()); + QVERIFY(m_window->newTakeAction()->isEnabled()); + + startTake(); + if (QTest::currentTestFailed()) return; + m_window->doUpdateMenuStates(); + QVERIFY2(!m_window->takeCombo()->isEnabled(), + "takes could be switched while one was being recorded"); + QVERIFY(!m_window->newTakeAction()->isEnabled()); + QVERIFY(!m_window->duplicateTakeAction()->isEnabled()); + QVERIFY(!m_window->deleteTakeAction()->isEnabled()); + + // And the calls themselves refuse, in case a script reaches them + QVERIFY(!m_window->doSwitchToTake(0)); + m_window->doNewEmptyTake(); + QCOMPARE(m_window->takes()->getTakeCount(), 1); + + waitForSomethingRecorded(); + stopTake(); + if (QTest::currentTestFailed()) return; + m_window->doUpdateMenuStates(); + QVERIFY(m_window->takeCombo()->isEnabled()); + } + + // The takes of a session in the .ton (spec 6.4): the element + // says which takes there are, where their audio is and which was on + // show; their pitch, notes and coverage come back as the layers they + // are stored in, and nothing is analysed + + // Two takes, the second of them active, saved and opened again + void two_takes_survive_a_session_opening() { + FakeAudioIO::Config config; + config.input = tone(highHz, 6.0); + makeWindow(config); + openReference(writeWav(tone(lowHz, 4.0))); + if (QTest::currentTestFailed()) return; + + // A take with a gap in its coverage, and a second take beside it + take(700); + if (QTest::currentTestFailed()) return; + m_window->seekTo(sv::sv_frame_t(2.0 * rate)); + take(700); + if (QTest::currentTestFailed()) return; + TakeSnapshot first = snapshotTake(); + QCOMPARE(int(first.coverage.size()), 2); + QVERIFY(!first.pitch.empty() && !first.notes.empty()); + + m_window->doNewEmptyTake(); + m_window->seekTo(sv::sv_frame_t(1.0 * rate)); + take(700); + if (QTest::currentTestFailed()) return; + TakeSnapshot second = snapshotTake(); + QVERIFY(second.path != first.path); + QVERIFY(!second.pitch.empty() && !second.notes.empty()); + + QString session = m_dir.filePath("two-takes.ton"); + QVERIFY(m_window->saveSessionFile(session)); + + // The save copied both takes' audio into the session's own folder + // and the file names it there (spec 6.4), so that is where the + // takes come back from + first.path = m_window->takes()->getTake(0)->audioPath; + second.path = m_window->takes()->getTake(1)->audioPath; + QVERIFY2(TakesFile::isInFolder(TakesFile::takesFolder(session), + first.path), qPrintable(first.path)); + + reopenSession(session); + if (QTest::currentTestFailed()) return; + + // Both takes, under their own names, with the one that was on show + // active again + QCOMPARE(m_window->takes()->getTakeNames(), + QStringList({ "Take 1", "Take 2" })); + QCOMPARE(m_window->takes()->getActiveIndex(), 1); + QCOMPARE(m_window->takes()->getAudioPath(), second.path); + QCOMPARE(m_window->takes()->getCoverage().getRanges(), second.coverage); + QCOMPARE(m_window->takes()->getTake(0)->audioPath, first.path); + QCOMPARE(m_window->takes()->getTake(0)->coverage.getRanges(), + first.coverage); + + // The active take's own layers are the ones the analyser holds, and + // its audio is under them + Analyser *a2 = m_window->analyser2(); + QVERIFY(a2); + QCOMPARE(static_cast(takeLayers("Take 2").pitch), + a2->getLayer(Analyser::PitchTrack)); + QVERIFY(takeAudio()); + QCOMPARE(takeAudio()->getStartFrame(), sv::sv_frame_t(0)); + + // The pitch and the notes of both takes came back as they were: a + // session file keeps a value to six figures, nothing else changes + verifyEventsSurvived(second.pitch, pitchEvents(a2), "the active take's pitch"); + verifyEventsSurvived(second.notes, + noteEvents(a2->getLayer(Analyser::Notes)), + "the active take's notes"); + verifyEventsSurvived(first.pitch, pitchEvents(takeLayers("Take 1").pitch), + "the stored take's pitch"); + verifyEventsSurvived(first.notes, noteEvents(takeLayers("Take 1").notes), + "the stored take's notes"); + if (QTest::currentTestFailed()) return; + + // Nothing was analysed on the way in, then or when the queued + // calls of the load ran + QVERIFY(!m_window->analysingRange()); + QCoreApplication::processEvents(); + QTest::qWait(100); + QVERIFY2(!sv::ModelTransformerFactory::getInstance() + ->haveRunningTransformers(), + "the session load started an analysis of a take"); + QCOMPARE(noteLayersInPane0(), 3); // the reference's and the two takes' + + // The take that is not on show is hidden, silent and owned by + // nobody; the one that is can be heard, and playback runs to the + // end of it + verifyTakeIsPutAway("Take 1"); + if (QTest::currentTestFailed()) return; + QVERIFY(a2->isAudible(Analyser::Audio)); + QVERIFY(m_window->playSource()->getModels() + .count(a2->getMainModelId())); + QVERIFY(m_window->playSource()->getPlayEndFrame() >= + m_window->takes()->getCoverage().getEndFrame()); + + // Each take has its strip; the active one's is on show and is the + // coverage the take came back with + QCOMPARE(allStripLayersInPane0(), 2); + verifyStripMatchesTake(); + if (QTest::currentTestFailed()) return; + + // One copy of the take's audio and no leftovers of the load: the + // waveform layer and model that Document::toXml() wrote for the + // active take were dropped (spec 6.4) + QCOMPARE(m_window->paneStack()->getPaneCount(), 2); + QCOMPARE(m_window->paneStack()->getHiddenPaneCount(), 0); + QCOMPARE(audioModelsBesidesReference(), 1); + QCOMPARE(waveformLayersInPane0(), 2); // the reference's and the take's + verifyPlaySourceClean(); + QVERIFY2(!m_window->isDocumentModified(), + "opening a session left it looking modified"); + QCOMPARE(undoOnce(), QString()); // and nothing on the undo stack + if (QTest::currentTestFailed()) return; + + // Switching takes works after a load like any other time + QVERIFY(m_window->doSwitchToTake(0)); + QCOMPARE(m_window->takes()->getAudioPath(), first.path); + verifyEventsSurvived(first.pitch, pitchEvents(m_window->analyser2()), + "the take switched to after a load"); + if (QTest::currentTestFailed()) return; + verifyTakeIsPutAway("Take 2"); + if (QTest::currentTestFailed()) return; + verifyStripMatchesTake(); + QVERIFY(!m_window->analysingRange()); + QVERIFY2(m_window->isDocumentModified(), + "switching take did not mark the session modified"); + + // A take made now carries the numbering on from the session + m_window->doNewEmptyTake(); + QCOMPARE(m_window->takes()->getActiveName(), QString("Take 3")); + } + + // A saved session names the audio file of every take, so none of them + // is deleted on close, however thoroughly it has been superseded since + void saved_session_protects_every_take_audio() { + FakeAudioIO::Config config; + config.input = tone(highHz, 6.0); + makeWindow(config); + openReference(writeWav(tone(lowHz, 4.0))); + if (QTest::currentTestFailed()) return; + + take(600); + if (QTest::currentTestFailed()) return; + QString firstRecorded = m_window->takes()->getAudioPath(); + + m_window->doNewEmptyTake(); + m_window->seekTo(sv::sv_frame_t(2.0 * rate)); + take(600); + if (QTest::currentTestFailed()) return; + + QString session = m_dir.filePath("protected.ton"); + QVERIFY(m_window->doSaveSessionAs(session)); + + // What the saved session names is the copy of each take's audio in + // its own folder (spec 6.4) + QString firstSaved = m_window->takes()->getTake(0)->audioPath; + QString secondSaved = m_window->takes()->getTake(1)->audioPath; + QVERIFY(firstSaved != firstRecorded); + + // Recording into the first take again supersedes the file the saved + // session names for it + QVERIFY(m_window->doSwitchToTake(0)); + m_window->seekTo(sv::sv_frame_t(1.0 * rate)); + take(600); + if (QTest::currentTestFailed()) return; + QVERIFY(m_window->takes()->getAudioPath() != firstSaved); + + QStringList unused = m_window->takes()->unusedWrittenFiles(); + QVERIFY2(!unused.contains(firstSaved) && !unused.contains(secondSaved), + "an audio file the saved session names was up for deletion"); + QVERIFY2(unused.contains(firstRecorded), + "the recording the save copied into the folder is still " + "referred to by something"); + + m_window->doCloseSession(); + + QVERIFY2(QFileInfo::exists(firstSaved), + "the audio the saved session names for the first take was " + "deleted when the session closed"); + QVERIFY2(QFileInfo::exists(secondSaved), + "the audio the saved session names for the second take was " + "deleted when the session closed"); + + // The recording the save copied into the folder is nobody's now: + // that copy is the take's audio, and no undo is left to want this + QVERIFY2(!QFileInfo::exists(firstRecorded), + "the recording that the save copied into the session's " + "folder was left behind in the record directory"); + } + + // A take name with characters that XML cares about + void take_name_with_entities_survives_a_session() { + FakeAudioIO::Config config; + config.input = tone(highHz, 3.0); + makeWindow(config); + openReference(writeWav(tone(lowHz, 2.0))); + if (QTest::currentTestFailed()) return; + + take(600); + if (QTest::currentTestFailed()) return; + + const QString name = "Rock & \"Roll\" <2>"; + m_window->setTakeNameAnswer(name); + m_window->doRenameTake(); + QCOMPARE(m_window->takes()->getActiveName(), name); + TakeSnapshot before = snapshotTake(); + + QString session = m_dir.filePath("entities.ton"); + QVERIFY(m_window->saveSessionFile(session)); + // The take's audio is in the session's folder now (spec 6.4) + before.path = m_window->takes()->getAudioPath(); + reopenSession(session); + if (QTest::currentTestFailed()) return; + + QCOMPARE(m_window->takes()->getTakeNames(), QStringList { name }); + QCOMPARE(m_window->takes()->getAudioPath(), before.path); + QVERIFY(m_window->analyser2()); + QCOMPARE(static_cast(takeLayers(name).pitch), + m_window->analyser2()->getLayer(Analyser::PitchTrack)); + verifyEventsSurvived(before.pitch, pitchEvents(m_window->analyser2()), + "the pitch of a take with an escaped name"); + if (QTest::currentTestFailed()) return; + verifyStripMatchesTake(); + } + + // A session from before the takes were stored opens without its + // singing track, and nothing of one is left in the pane (spec 3) + void session_without_takes_opens_without_singing() { + FakeAudioIO::Config config; + config.input = tone(highHz, 3.0); + makeWindow(config); + openReference(writeWav(tone(lowHz, 2.0))); + if (QTest::currentTestFailed()) return; + + take(600); + if (QTest::currentTestFailed()) return; + + QString session = m_dir.filePath("old.ton"); + QVERIFY(m_window->saveSessionFile(session)); + // The save copied the take's audio into the session's folder; that + // copy is what the file names, and what must be left alone below + QString takePath = m_window->takes()->getAudioPath(); + + // As a .ton written before this phase: the same document without + // the element Tony's own pass reads + QString stripped = removeTakesElement(session); + QVERIFY2(stripped == "", qPrintable(stripped)); + + reopenSession(session); + if (QTest::currentTestFailed()) return; + + // No take, and no analyser or layers of one + QCOMPARE(m_window->takes()->getTakeCount(), 0); + QVERIFY(!m_window->takes()->haveTake()); + QVERIFY2(!m_window->analyser2(), + "the singing track of an old session was set up as a take"); + QVERIFY(!m_window->coverageStrip()->isShown()); + QCOMPARE(allStripLayersInPane0(), 0); + QCOMPARE(noteLayersInPane0(), 1); // the reference's + QCOMPARE(waveformLayersInPane0(), 1); // the reference's + QCOMPARE(audioModelsBesidesReference(), 0); + QCOMPARE(m_window->paneStack()->getPaneCount(), 2); + verifyPlaySourceClean(); + QVERIFY(!m_window->isDocumentModified()); + + // The audio file was not touched, and the reference is intact + QVERIFY(QFileInfo::exists(takePath)); + QVERIFY(std::fabs(TestSignals::centsBetween + (medianHz(pitchEvents(m_window->analyser())), + lowHz)) < 10.0); + + // Recording makes the session's first take, as in a new session + m_window->seekTo(0); + take(600); + if (QTest::currentTestFailed()) return; + QCOMPARE(m_window->takes()->getTakeNames(), QStringList { "Take 1" }); + verifyStripMatchesTake(); + } + + // The audio file of a take is gone when the session is opened: one + // warning, and the take is shown without its sound (spec 6.4) + void session_take_with_missing_audio() { + FakeAudioIO::Config config; + config.input = tone(highHz, 3.0); + makeWindow(config); + openReference(writeWav(tone(lowHz, 2.0))); + if (QTest::currentTestFailed()) return; + + take(600); + if (QTest::currentTestFailed()) return; + TakeSnapshot before = snapshotTake(); + QVERIFY(!before.pitch.empty()); + + QString session = m_dir.filePath("missing.ton"); + QVERIFY(m_window->saveSessionFile(session)); + // The save copied the take's audio into the session's folder, and + // that copy is the file the session names (spec 6.4) + before.path = m_window->takes()->getAudioPath(); + + // The session names the take's audio once, in the takes element: + // the document does not carry the audio model as well (the take's + // waveform layer is not saved), which the session reader would + // ask the user to locate when the file has gone + { + sv::BZipFileDevice file(session); + QVERIFY(file.open(QIODevice::ReadOnly)); + QByteArray document = file.readAll(); + file.close(); + // (one wave file model: the reference) + QCOMPARE(int(document.count("type=\"wavefile\"")), 1); + QVERIFY(document.contains("doCloseSession(); + QVERIFY(QFile::remove(before.path)); + + m_window->discardModifications(); + QCOMPARE(m_window->openPath(session, MainWindow::ReplaceSession), + MainWindow::FileOpenSucceeded); + QTRY_VERIFY_WITH_TIMEOUT(analysed(m_window->analyser()), 30000); + + // One warning of ours for the session, naming the folder the takes' + // audio was expected in rather than one warning per take (spec 6.4) + QStringList ours = dialogsMatching("without their audio"); + QCOMPARE(ours.size(), 1); + QVERIFY2(ours[0].contains(QFileInfo(TakesFile::takesFolder(session)) + .fileName()), + qPrintable(ours[0])); + + // The take is there, with its pitch, its notes and its coverage, + // and no audio + QCOMPARE(m_window->takes()->getTakeNames(), QStringList { "Take 1" }); + QCOMPARE(m_window->takes()->getAudioPath(), before.path); + QCOMPARE(m_window->takes()->getCoverage().getRanges(), before.coverage); + QVERIFY(!m_window->analyser2()); + QVERIFY(!takeAudio()); + TakeLayers::Found found = takeLayers("Take 1"); + QVERIFY(found.pitch && found.notes && found.coverage); + verifyEventsSurvived(before.pitch, pitchEvents(found.pitch), + "the pitch of a take whose audio is missing"); + if (QTest::currentTestFailed()) return; + QVERIFY(m_window->coverageStrip()->isShown()); + QCOMPARE(stripEvents(), before.strip); + verifyPlaySourceClean(); + + // Recording into it is refused, with one warning, and the take is + // left exactly as it was: splicing needs the file it adds to + m_window->seekTo(0); + startTake(); + if (QTest::currentTestFailed()) return; + QTest::qWait(400); + m_window->doRecord(); + QVERIFY(!m_window->recordTarget()->isRecording()); + + QStringList refusals = dialogsMatching("could not be added"); + QCOMPARE(refusals.size(), 1); + QVERIFY2(refusals[0].contains(QFileInfo(before.path).fileName()), + qPrintable(refusals[0])); + QVERIFY(takeDialogs().isEmpty()); + QCOMPARE(m_window->takes()->getAudioPath(), before.path); + QCOMPARE(m_window->takes()->getCoverage().getRanges(), before.coverage); + QCOMPARE(stripEvents(), before.strip); + } + + // The reference of a session cannot be read when it is opened (on the + // phone: a format it has no decoder for), and svapp says the session + // loaded incomplete. Saved over, its file would lose the reference: it + // is saved only when the user asks and then says yes, and a save no + // one asked for (Android's on suspend) passes it by + void incomplete_session_not_saved_unasked() { + FakeAudioIO::Config config; + makeWindow(config); + QString reference = writeWav(tone(lowHz, 1.0)); + openReference(reference); + if (QTest::currentTestFailed()) return; + + QString session = m_dir.filePath("no-reference.ton"); + QVERIFY(m_window->doSaveSessionAs(session)); + QVERIFY(!m_window->isSessionIncomplete()); + m_window->markModified(); + QVERIFY2(m_window->doMaySaveUnasked(), + "a whole session, changed, with a file of its own, may not " + "be saved unasked"); + m_window->doCloseSession(); + QByteArray saved = fileContents(session); + QVERIFY(!saved.isEmpty()); + + QVERIFY(QFile::remove(reference)); + m_window->discardModifications(); + QCOMPARE(m_window->openPath(session, MainWindow::ReplaceSession), + MainWindow::FileOpenSucceeded); + QCOMPARE(messagesMatching("Incomplete session loaded", + "referred to by the original session " + "file could not be loaded").size(), 1); + QVERIFY2(m_window->isSessionIncomplete(), + "the session is not known to have loaded incomplete"); + + // svapp gives such a session no file of its own; nor is it saved + // unasked if it has one + QCOMPARE(m_window->sessionFile(), QString()); + m_window->markModified(); + QVERIFY(!m_window->doMaySaveUnasked()); + m_window->setSessionFile(session); + QVERIFY2(!m_window->doMaySaveUnasked(), + "an incomplete session may be saved over its file unasked"); + + // Save, over that file: asked, and "no" saves nothing + m_window->setSaveIncompleteAnswer(false); + m_window->doSaveSession(); + QCOMPARE(m_window->saveIncompleteQuestions(), 1); + QCOMPARE(fileContents(session), saved); + QVERIFY(m_window->isDocumentModified()); + + // Save with no file of its own is Save As, which asks before the + // file is picked (on Android the picker makes the file) + m_window->setSessionFile(""); + QString other = m_dir.filePath("no-reference-saved.ton"); + m_window->setSaveFileNameAnswer(other); + m_window->doSaveSession(); + QCOMPARE(m_window->saveIncompleteQuestions(), 2); + m_window->doSaveSessionAsAsked(); + QCOMPARE(m_window->saveIncompleteQuestions(), 3); + QCOMPARE(m_window->saveFileNameQuestions(), 0); + QVERIFY(!QFileInfo::exists(other)); + QVERIFY(m_window->isSessionIncomplete()); + QVERIFY(m_window->isDocumentModified()); + QVERIFY(takeDialogs().isEmpty()); + } + + // The same, with the reference there and other audio the session names + // missing, so that it can be saved: once the user says yes, the session + // is what its new file says, and is neither asked about nor passed by + // again + void incomplete_session_saved_when_the_user_says_so() { + FakeAudioIO::Config config; + makeWindow(config); + openReference(writeWav(tone(lowHz, 1.0))); + if (QTest::currentTestFailed()) return; + + QString session = m_dir.filePath("missing-audio.ton"); + QVERIFY(m_window->doSaveSessionAs(session)); + m_window->doCloseSession(); + QString missing = m_dir.filePath("missing-audio.wav"); + QString error = insertIntoSession + (session, "", + QString("\n").arg(missing) + .toUtf8()); + QVERIFY2(error == "", qPrintable(error)); + QByteArray saved = fileContents(session); + + reopenSession(session); + if (QTest::currentTestFailed()) return; + QCOMPARE(messagesMatching("Incomplete session loaded", + "referred to by the original session " + "file could not be loaded").size(), 1); + QVERIFY(m_window->isSessionIncomplete()); + QCOMPARE(m_window->sessionFile(), QString()); + m_window->markModified(); + QVERIFY(!m_window->doMaySaveUnasked()); + + // Saved over the file it came from, as the user may choose: which + // then no longer names the audio that was missing + m_window->setSaveIncompleteAnswer(true); + m_window->setSaveFileNameAnswer(session); + m_window->doSaveSession(); + QCOMPARE(m_window->saveIncompleteQuestions(), 1); + QCOMPARE(m_window->saveFileNameQuestions(), 1); + QVERIFY2(fileContents(session) != saved, "the session was not saved"); + QCOMPARE(m_window->sessionFile(), session); + QVERIFY(!m_window->isDocumentModified()); + QVERIFY(!m_window->isSessionIncomplete()); + { + sv::BZipFileDevice file(session); + QVERIFY(file.open(QIODevice::ReadOnly)); + QByteArray document = file.readAll(); + file.close(); + QVERIFY(!document.contains("missing-audio.wav")); + QCOMPARE(int(document.count("type=\"wavefile\"")), 1); + } + + m_window->markModified(); + QVERIFY(m_window->doMaySaveUnasked()); + m_window->doSaveSession(); + QCOMPARE(m_window->saveIncompleteQuestions(), 1); + QCOMPARE(m_window->saveFileNameQuestions(), 1); + QVERIFY(!m_window->isDocumentModified()); + QVERIFY(takeDialogs().isEmpty()); + } + + // --- The takes folder of a session (spec 6.4) --- + // + // A take's combined audio belongs to the song: it lives in + // ".takes" beside the .ton, which names it relative to + // itself, so that the two can be moved together. Before the first + // save there is no folder, so the files are written among the raw + // recordings and the save copies them across. + + // The first save copies the take's audio into the session's folder and + // names it there, relative to the .ton. The recording it was copied + // from belongs to nobody afterwards, and goes when the session closes + void first_save_copies_the_take_audio() { + FakeAudioIO::Config config; + config.input = tone(highHz, 3.0); + makeWindow(config); + openReference(writeWav(tone(lowHz, 2.0))); + if (QTest::currentTestFailed()) return; + + take(600); + if (QTest::currentTestFailed()) return; + QString recorded = m_window->takes()->getAudioPath(); + + QString session = m_dir.filePath("copied.ton"); + QString folder = TakesFile::takesFolder(session); + QVERIFY(!QFileInfo::exists(folder)); + + QVERIFY(m_window->doSaveSessionAs(session)); + QCOMPARE(m_window->sessionFile(), session); + + // The take's audio is the copy in the folder, under the name it had + QString copied = m_window->takes()->getAudioPath(); + QVERIFY2(TakesFile::isInFolder(folder, copied), qPrintable(copied)); + QCOMPARE(QFileInfo(copied).fileName(), QFileInfo(recorded).fileName()); + QVERIFY(QFileInfo::exists(copied)); + + // Copied and not moved: the audio model has the old file open and + // goes on reading it + QVERIFY2(QFileInfo::exists(recorded), + "the recording was moved out from under the open model"); + QVERIFY(takeAudio()); + + // And the file names it relative to itself + { + sv::BZipFileDevice file(session); + QVERIFY(file.open(QIODevice::ReadOnly)); + QByteArray document = file.readAll(); + file.close(); + QByteArray wanted = "audio=\"copied.takes/" + + QFileInfo(copied).fileName().toUtf8() + "\""; + QVERIFY2(document.contains(wanted), qPrintable(document)); + } + + // Nothing refers to the recording now, and it is up for deletion + // when the session closes + QCOMPARE(m_window->takes()->unusedWrittenFiles(), + QStringList { recorded }); + + // An undo and a redo after the save still find the files the + // commands hold: the recordings are there until the session closes. + // The redo leaves the take pointing outside the folder again, so the + // next save copies it in again -- beside the copy that is there + // already, never over it + QCOMPARE(undoOnce(), QString("Record Singing")); + QVERIFY(!m_window->takes()->haveTake()); + QCOMPARE(redoOnce(), QString("Record Singing")); + QCOMPARE(m_window->takes()->getAudioPath(), recorded); + + QVERIFY(m_window->doSaveSessionAs(session)); + QString again = m_window->takes()->getAudioPath(); + QVERIFY2(TakesFile::isInFolder(folder, again), qPrintable(again)); + QVERIFY2(again != copied, "the second copy was written over the first"); + QVERIFY(QFileInfo::exists(copied)); + + m_window->doCloseSession(); + QVERIFY2(!QFileInfo::exists(recorded), + "the recording the save copied was left behind"); + QVERIFY2(QFileInfo::exists(copied) && QFileInfo::exists(again), + "audio that a saved session named was deleted on close"); + } + + // Once the session has a file, the next recording is written straight + // into its folder + void record_after_saving_writes_into_the_folder() { + FakeAudioIO::Config config; + config.input = tone(highHz, 6.0); + makeWindow(config); + openReference(writeWav(tone(lowHz, 4.0))); + if (QTest::currentTestFailed()) return; + + take(600); + if (QTest::currentTestFailed()) return; + + QString session = m_dir.filePath("recorded-into.ton"); + QVERIFY(m_window->doSaveSessionAs(session)); + QString folder = TakesFile::takesFolder(session); + + m_window->seekTo(sv::sv_frame_t(2.0 * rate)); + take(600); + if (QTest::currentTestFailed()) return; + + QString path = m_window->takes()->getAudioPath(); + QVERIFY2(TakesFile::isInFolder(folder, path), qPrintable(path)); + verifyTakeHasSound(); + if (QTest::currentTestFailed()) return; + + // The raw recordings are still where they were: only the combined + // files moved house + QVERIFY(QDir(sv::RecordDirectory::getRecordDirectory()) + .entryList(QStringList { "*.wav" }, QDir::Files).size() > 0); + } + + // Save As to another place copies the takes into the new session's + // folder and leaves the old one alone: the old .ton is still good + void save_as_copies_into_the_new_folder() { + FakeAudioIO::Config config; + config.input = tone(highHz, 3.0); + makeWindow(config); + openReference(writeWav(tone(lowHz, 2.0))); + if (QTest::currentTestFailed()) return; + + take(600); + if (QTest::currentTestFailed()) return; + + QString first = m_dir.filePath("first.ton"); + QVERIFY(m_window->doSaveSessionAs(first)); + QString firstAudio = m_window->takes()->getAudioPath(); + + QVERIFY(QDir().mkpath(m_dir.filePath("other"))); + QString second = QDir(m_dir.filePath("other")).filePath("second.ton"); + QVERIFY(m_window->doSaveSessionAs(second)); + QString secondAudio = m_window->takes()->getAudioPath(); + + QVERIFY2(TakesFile::isInFolder(TakesFile::takesFolder(second), + secondAudio), qPrintable(secondAudio)); + QVERIFY2(QFileInfo::exists(firstAudio), + "Save As took the audio of the session it was saved from"); + + // Both sessions open, with their own copy of the singing + for (QString session : { first, second }) { + reopenSession(session); + if (QTest::currentTestFailed()) return; + verifyTakeHasSound(); + if (QTest::currentTestFailed()) return; + QVERIFY2(TakesFile::isInFolder(TakesFile::takesFolder(session), + m_window->takes()->getAudioPath()), + qPrintable(m_window->takes()->getAudioPath())); + } + } + + // Two takes sharing one audio file (a duplicate) share the copy of it + void duplicated_take_copies_its_audio_once() { + FakeAudioIO::Config config; + config.input = tone(highHz, 3.0); + makeWindow(config); + openReference(writeWav(tone(lowHz, 2.0))); + if (QTest::currentTestFailed()) return; + + take(600); + if (QTest::currentTestFailed()) return; + m_window->doDuplicateTake(); + QCOMPARE(m_window->takes()->getTakeCount(), 2); + QCOMPARE(m_window->takes()->getTake(1)->audioPath, + m_window->takes()->getTake(0)->audioPath); + + QString session = m_dir.filePath("shared.ton"); + QVERIFY(m_window->doSaveSessionAs(session)); + + QString folder = TakesFile::takesFolder(session); + QString copied = m_window->takes()->getTake(0)->audioPath; + QCOMPARE(m_window->takes()->getTake(1)->audioPath, copied); + QVERIFY2(TakesFile::isInFolder(folder, copied), qPrintable(copied)); + QCOMPARE(QDir(folder).entryList(QStringList { "*.wav" }, + QDir::Files).size(), 1); + } + + // The .ton and its folder moved together: the paths in the file are + // relative, so the takes are found in the new place + void session_folder_moved_as_a_whole() { + FakeAudioIO::Config config; + config.input = tone(highHz, 3.0); + makeWindow(config); + openReference(writeWav(tone(lowHz, 2.0))); + if (QTest::currentTestFailed()) return; + + take(600); + if (QTest::currentTestFailed()) return; + + QString home = m_dir.filePath("in-place"); + QVERIFY(QDir().mkpath(home)); + QString session = QDir(home).filePath("song.ton"); + QVERIFY(m_window->doSaveSessionAs(session)); + m_window->doCloseSession(); + + // The .ton and its "song.takes" folder, moved as one + QString moved = m_dir.filePath("moved-house"); + QVERIFY2(QDir().rename(home, moved), "could not move the session"); + QString movedSession = QDir(moved).filePath("song.ton"); + + m_window->discardModifications(); + QCOMPARE(m_window->openPath(movedSession, MainWindow::ReplaceSession), + MainWindow::FileOpenSucceeded); + QTRY_VERIFY_WITH_TIMEOUT(analysed(m_window->analyser()), 30000); + + verifyTakeHasSound(); + if (QTest::currentTestFailed()) return; + QVERIFY2(TakesFile::isInFolder(TakesFile::takesFolder(movedSession), + m_window->takes()->getAudioPath()), + qPrintable(m_window->takes()->getAudioPath())); + QVERIFY(!pitchEvents(m_window->analyser2()).empty()); + } + + // The .ton moved without its folder: one warning for the session, + // naming the folder, and the take shows its pitch and notes with no + // sound + void session_moved_without_its_folder() { + FakeAudioIO::Config config; + config.input = tone(highHz, 3.0); + makeWindow(config); + openReference(writeWav(tone(lowHz, 2.0))); + if (QTest::currentTestFailed()) return; + + take(600); + if (QTest::currentTestFailed()) return; + TakeSnapshot before = snapshotTake(); + + QString session = m_dir.filePath("left-behind.ton"); + QVERIFY(m_window->doSaveSessionAs(session)); + m_window->doCloseSession(); + + // The file on its own, in a directory with no takes folder + QString elsewhere = m_dir.filePath("without-folder"); + QVERIFY(QDir().mkpath(elsewhere)); + QString moved = QDir(elsewhere).filePath("left-behind.ton"); + QVERIFY(QFile::copy(session, moved)); + + m_window->discardModifications(); + QCOMPARE(m_window->openPath(moved, MainWindow::ReplaceSession), + MainWindow::FileOpenSucceeded); + QTRY_VERIFY_WITH_TIMEOUT(analysed(m_window->analyser()), 30000); + + // One warning, for the session and not for the take, naming the + // folder the audio was expected in + QStringList warnings = dialogsMatching("without their audio"); + QCOMPARE(warnings.size(), 1); + QVERIFY2(warnings[0].contains("left-behind.takes"), + qPrintable(warnings[0])); + QVERIFY(takeDialogs().isEmpty()); + + // The take is there with everything but its sound + QCOMPARE(m_window->takes()->getTakeNames(), QStringList { "Take 1" }); + QCOMPARE(m_window->takes()->getCoverage().getRanges(), before.coverage); + QVERIFY(!m_window->analyser2()); + QVERIFY(!takeAudio()); + TakeLayers::Found found = takeLayers("Take 1"); + QVERIFY(found.pitch && found.notes && found.coverage); + verifyEventsSurvived(before.pitch, pitchEvents(found.pitch), + "the pitch of a take whose folder was left " + "behind"); + if (QTest::currentTestFailed()) return; + QVERIFY(m_window->coverageStrip()->isShown()); + } + + // A copy that cannot be made fails the save: the session is not written + // at all, and nothing about the takes changes + void a_failed_copy_fails_the_save() { + FakeAudioIO::Config config; + config.input = tone(highHz, 6.0); + makeWindow(config); + openReference(writeWav(tone(lowHz, 4.0))); + if (QTest::currentTestFailed()) return; + + take(600); + if (QTest::currentTestFailed()) return; + QString firstAudio = m_window->takes()->getAudioPath(); + + m_window->doNewEmptyTake(); + m_window->seekTo(sv::sv_frame_t(2.0 * rate)); + take(600); + if (QTest::currentTestFailed()) return; + QString secondAudio = m_window->takes()->getAudioPath(); + + QString session = m_dir.filePath("blocked.ton"); + QString folder = TakesFile::takesFolder(session); + + // There is a file where the folder would go, so it cannot be made + { + QFile blocker(folder); + QVERIFY(blocker.open(QIODevice::WriteOnly)); + blocker.write("not a folder"); + blocker.close(); + } + + QVERIFY2(!m_window->saveSessionFile(session), + "the session was saved although its takes' audio could not " + "be put beside it"); + QCOMPARE(dialogsMatching("was not saved").size(), 1); + QVERIFY2(!QFileInfo::exists(session), + "a session file was written that names audio which is not " + "in its folder"); + QCOMPARE(m_window->takes()->getTake(0)->audioPath, firstAudio); + QCOMPARE(m_window->takes()->getTake(1)->audioPath, secondAudio); + + // Now the folder can be made, but the audio of the take that is not + // on show has gone from under us: the copy of the first take, which + // was made before the failure, is taken back again and the folder + // made for them is left empty + QVERIFY(QFile::remove(folder)); + QVERIFY(m_window->doSwitchToTake(0)); + QVERIFY2(QFile::remove(secondAudio), + "the audio of the take that was put away is still open"); + + QVERIFY2(!m_window->saveSessionFile(session), + "the session was saved although one take's audio was gone"); + QCOMPARE(dialogsMatching("was not saved").size(), 1); + QVERIFY(!QFileInfo::exists(session)); + QCOMPARE(m_window->takes()->getTake(0)->audioPath, firstAudio); + QCOMPARE(m_window->takes()->getTake(1)->audioPath, secondAudio); + QVERIFY(QDir(folder).exists()); + QVERIFY2(QDir(folder).isEmpty(), + "the copy made before the failure was left in the folder"); + + // An empty folder this run made goes with the session + m_window->doCloseSession(); + QVERIFY2(!QFileInfo::exists(folder), + "the empty takes folder was left behind"); + } + + // Saving while the analysis of a recorded range runs: the save waits + // for the merge, so the session holds the take's own pitch and notes + // and neither the state before the merge nor the run's two temporary + // models + void save_during_ranged_analysis() { + FakeAudioIO::Config config; + config.input = tone(highHz, 3.0); + makeWindow(config); + openReference(writeWav(tone(lowHz, 2.0))); + if (QTest::currentTestFailed()) return; + + // Held, however quickly pYIN analyses the range, until the save + // runs the event loop + m_window->holdRangedMerges(true); + + startTake(); + if (QTest::currentTestFailed()) return; + QTest::qWait(700); + + // Stop splices the recording in and starts the analysis of it there + // and then, so it is running when the session is saved + m_window->doRecord(); + QVERIFY(!m_window->recordTarget()->isRecording()); + QVERIFY2(m_window->analysingRange(), + "the test shows nothing: no analysis was running when the " + "session was saved"); + + // Let go from the event loop, which only a save that waits runs + QTimer::singleShot(0, m_window, [this]() { + m_window->holdRangedMerges(false); + }); + QString session = m_dir.filePath("mid-analysis.ton"); + QVERIFY(m_window->saveSessionFile(session)); + + QVERIFY2(!m_window->analysingRange(), + "the save did not wait for the analysis of the recording"); + TakeSnapshot before = snapshotTake(); + QVERIFY(!before.pitch.empty()); + + reopenSession(session); + if (QTest::currentTestFailed()) return; + + // The merged pitch and notes are what the session holds, in the + // take's own layers and no others + QVERIFY(m_window->analyser2()); + verifyEventsSurvived(before.pitch, pitchEvents(m_window->analyser2()), + "the pitch saved during an analysis"); + verifyEventsSurvived(before.notes, + noteEvents(m_window->analyser2() + ->getLayer(Analyser::Notes)), + "the notes saved during an analysis"); + if (QTest::currentTestFailed()) return; + QCOMPARE(noteLayersInPane0(), 2); + QCOMPARE(audioModelsBesidesReference(), 1); + verifyStripMatchesTake(); + verifyPlaySourceClean(); + } + + // The alternate pitch track: the reference pitch track moved by + // whole octaves, as a layer of its own + + void alternate_pitch_layer_names() { + int octaves = 99; + QVERIFY(AlternatePitchTrack::octavesFromLayerName + (AlternatePitchTrack::layerNameFor(-2), octaves)); + QCOMPARE(octaves, -2); + QVERIFY(AlternatePitchTrack::octavesFromLayerName + (AlternatePitchTrack::layerNameFor(3), octaves)); + QCOMPARE(octaves, 3); + // zero is the reference itself, and the rest are not ours + for (QString name : { AlternatePitchTrack::layerNameFor(0), + AlternatePitchTrack::layerNameFor(4), + QString("Alternate Pitch Track"), + QString("Alternate Pitch Track x"), + QString("Pitch Track -1") }) { + QVERIFY2(!AlternatePitchTrack::octavesFromLayerName(name, octaves), + qPrintable(name)); + } + QCOMPARE(octaves, 3); + QCOMPARE(AlternatePitchTrack::shifted(220.0, -1), 110.0); + QCOMPARE(AlternatePitchTrack::shifted(220.0, 2), 880.0); + } + + void alternate_pitch_track() { + makeWindow(FakeAudioIO::Config()); + QVERIFY(!m_window->alternatePitchAction()->isEnabled()); + openReference(writeWav(tone(lowHz, 1.0))); + if (QTest::currentTestFailed()) return; + + AlternatePitchTrack *alt = m_window->alternatePitch(); + QVERIFY(m_window->alternatePitchAction()->isEnabled()); + QVERIFY(!m_window->alternatePitchAction()->isChecked()); + QVERIFY(!m_window->alternatePitchUpAction()->isEnabled()); + QVERIFY(!alt->isShown()); + m_window->discardModifications(); + + m_window->doToggleAlternatePitch(); + QVERIFY(alt->isShown()); + QVERIFY(m_window->alternatePitchAction()->isChecked()); + QVERIFY(m_window->alternatePitchUpAction()->isEnabled()); + QVERIFY(m_window->isDocumentModified()); + QCOMPARE(alt->getOctaves(), -1); + QVERIFY(paneHasLayer(0, alt->getLayer())); + QCOMPARE(colourOf(alt->getLayer()), colourNamed("Faded Brown")); + QVERIFY(alternateMatchesReference()); + QVERIFY(std::fabs(TestSignals::centsBetween + (medianHz(pitchEvents(alt->getLayer())), + lowHz / 2)) < 10.0); + + // never heard, and never the layer that gets edited + QVERIFY(!alt->getLayer()->getPlayParameters()->isPlayAudible()); + QVERIFY(m_window->paneStack()->getPane(0)->getSelectedLayer() + != alt->getLayer()); + + // up from one below is one above: none is the reference itself + m_window->discardModifications(); + m_window->doStepAlternatePitch(true); + QCOMPARE(alt->getOctaves(), 1); + QVERIFY(m_window->isDocumentModified()); + QVERIFY(alternateMatchesReference()); + QVERIFY(std::fabs(TestSignals::centsBetween + (medianHz(pitchEvents(alt->getLayer())), + lowHz * 2)) < 10.0); + + m_window->doStepAlternatePitch(true); + m_window->doStepAlternatePitch(true); + m_window->doStepAlternatePitch(true); + QCOMPARE(alt->getOctaves(), int(AlternatePitchTrack::maxOctaves)); + QVERIFY(!m_window->alternatePitchUpAction()->isEnabled()); + QVERIFY(m_window->alternatePitchDownAction()->isEnabled()); + + // the reference was left alone + QVERIFY(std::fabs(TestSignals::centsBetween + (medianHz(pitchEvents(m_window->analyser())), + lowHz)) < 10.0); + + sv::Layer *layer = alt->getLayer(); + m_window->doToggleAlternatePitch(); + QVERIFY(!alt->isShown()); + QVERIFY(!documentHasLayer(layer)); + QCOMPARE(alternateLayersInDocument(), 0); + verifyPlaySourceClean(); + + // and it comes back where it was + m_window->doToggleAlternatePitch(); + QCOMPARE(alt->getOctaves(), int(AlternatePitchTrack::maxOctaves)); + QVERIFY(alternateMatchesReference()); + } + + // Analyse Now gives the reference a new pitch layer and model + void alternate_pitch_follows_reanalysis() { + makeWindow(FakeAudioIO::Config()); + openReference(writeWav(tone(lowHz, 1.0))); + if (QTest::currentTestFailed()) return; + + AlternatePitchTrack *alt = m_window->alternatePitch(); + m_window->doToggleAlternatePitch(); + sv::ModelId before = alt->getSource(); + QVERIFY(!before.isNone()); + + m_window->doAnalyseNow(); + QTRY_VERIFY_WITH_TIMEOUT(analysed(m_window->analyser()), 30000); + + QVERIFY(alt->getSource() != before); + QCOMPARE(alt->getSource(), + m_window->analyser()->getLayer(Analyser::PitchTrack) + ->getModel()); + QTRY_VERIFY_WITH_TIMEOUT(alternateMatchesReference(), 5000); + + // and an edit to the reference reaches it + auto ref = pitchEvents(m_window->analyser()); + m_window->analyser()->shiftOctave + (sv::Selection(ref.front().getFrame(), + ref.back().getFrame() + 1), true); + QTRY_VERIFY_WITH_TIMEOUT + (std::fabs(TestSignals::centsBetween + (medianHz(pitchEvents(alt->getLayer())), lowHz)) < 10.0, + 5000); + QVERIFY(alternateMatchesReference()); + } + + void alternate_pitch_followed_during_take() { + FakeAudioIO::Config config; + config.input = tone(highHz, 3.0); + makeWindow(config); + openReference(writeWav(tone(lowHz, 1.0))); + if (QTest::currentTestFailed()) return; + + AlternatePitchTrack *alt = m_window->alternatePitch(); + m_window->doToggleAlternatePitch(); + sv::Pane *pane = m_window->paneStack()->getPane(0); + sv::Layer *reference = + m_window->analyser()->getLayer(Analyser::PitchTrack); + QVERIFY(!reference->isLayerDormant(pane)); + + startTake(); + if (QTest::currentTestFailed()) return; + QCOMPARE(colourOf(alt->getLayer()), colourNamed("Dark Brown")); + QVERIFY(!alt->getLayer()->isLayerDormant(pane)); + QVERIFY(reference->isLayerDormant(pane)); + QVERIFY(!m_window->alternatePitchAction()->isEnabled()); + QVERIFY(!m_window->alternatePitchDownAction()->isEnabled()); + QTest::qWait(600); + QVERIFY(reference->isLayerDormant(pane)); + + stopTake(); + if (QTest::currentTestFailed()) return; + QCOMPARE(colourOf(alt->getLayer()), colourNamed("Faded Brown")); + QVERIFY(!reference->isLayerDormant(pane)); + QVERIFY(m_window->alternatePitchAction()->isEnabled()); + QVERIFY(alternateMatchesReference()); + + // The setting that Show Pitch Track keeps was not touched + QVERIFY(m_window->analyser()->isVisible(Analyser::PitchTrack)); + QSettings settings; + settings.beginGroup("Analyser"); + QVERIFY(settings.value + (QString("visible-%1").arg(int(Analyser::PitchTrack)), + true).toBool()); + settings.endGroup(); + + // A take with no alternate track hides nothing + m_window->doToggleAlternatePitch(); + startTake(); + if (QTest::currentTestFailed()) return; + QVERIFY(!reference->isLayerDormant(pane)); + waitForSomethingRecorded(); + stopTake(); + } + + void alternate_pitch_session_round_trip() { + makeWindow(FakeAudioIO::Config()); + openReference(writeWav(tone(lowHz, 1.0))); + if (QTest::currentTestFailed()) return; + + AlternatePitchTrack *alt = m_window->alternatePitch(); + m_window->doToggleAlternatePitch(); + m_window->doStepAlternatePitch(false); + QCOMPARE(alt->getOctaves(), -2); + + QString session = m_dir.filePath("alternate.ton"); + QVERIFY(m_window->saveSessionFile(session)); + m_window->doCloseSession(); + QVERIFY(!alt->isShown()); + + // A session without one must not be given the last one's + openReference(writeWav(tone(highHz, 1.0))); + if (QTest::currentTestFailed()) return; + QVERIFY(!alt->isShown()); + QCOMPARE(alternateLayersInDocument(), 0); + + openReference(session); + if (QTest::currentTestFailed()) return; + QVERIFY(alt->isShown()); + QCOMPARE(alt->getOctaves(), -2); + QVERIFY(m_window->alternatePitchAction()->isChecked()); + QCOMPARE(alternateLayersInDocument(), 1); + QVERIFY(paneHasLayer(0, alt->getLayer())); + QCOMPARE(colourOf(alt->getLayer()), colourNamed("Faded Brown")); + QVERIFY(!alt->getLayer()->getPlayParameters()->isPlayAudible()); + QTRY_VERIFY_WITH_TIMEOUT(alternateMatchesReference(), 5000); + QVERIFY(std::fabs(TestSignals::centsBetween + (medianHz(pitchEvents(alt->getLayer())), + lowHz / 4)) < 10.0); + + // The reference is still the black one, and still the one edited + QCOMPARE(colourOf(m_window->analyser()->getLayer(Analyser::PitchTrack)), + colourNamed("Black")); + QVERIFY(std::fabs(TestSignals::centsBetween + (medianHz(pitchEvents(m_window->analyser())), + lowHz)) < 10.0); + QVERIFY(m_window->paneStack()->getPane(0)->getSelectedLayer() + != alt->getLayer()); + } + + // Timed lyrics: an LRC file imported onto the reference's timeline, + // drawn along the bottom of pane 0 by LyricsTrack's layer + + void lyrics_import_shows_words() { + makeWindow(FakeAudioIO::Config()); + openReference(writeWav(tone(lowHz, 1.0))); + if (QTest::currentTestFailed()) return; + + QString path = lyricsFixture("moises-exporter-words.lrc"); + QVERIFY(m_window->doImportLyricsFrom(path)); + + LyricsTrack *lyrics = m_window->lyrics(); + QVERIFY(lyrics->isShown()); + QVERIFY(lyrics->isVisible()); + QCOMPARE(lyricsLayersInDocument(), 1); + QCOMPARE(lyricsLayersInPane0(), 1); + sv::RegionLayer *layer = lyrics->getLayer(); + QVERIFY(paneHasLayer(0, layer)); + QCOMPARE(int(layer->getPlotStyle()), int(sv::RegionLayer::PlotLyrics)); + QCOMPARE(int(layer->getVerticalScale()), + int(sv::RegionLayer::EqualSpaced)); + QCOMPARE(colourOf(layer), colourNamed("Grey")); + QVERIFY(!layer->isLayerEditable()); + QCOMPARE(layer->getLayerPresentationName(), + QString("Kesäyön testilaulu")); + + // Every word, at its frame, with its label + sv::EventVector expected = expectedLyricsEvents(path); + QCOMPARE(int(expected.size()), 13); + QCOMPARE(lyricsEvents(), expected); + + Lyrics parsed = lyricsIn(path); + QString counts = QString("Imported %1 words in %2 lines.") + .arg(parsed.words.size()).arg(parsed.lineCount()); + QVERIFY2(m_window->statusText().startsWith(counts), + qPrintable(m_window->statusText())); + + QVERIFY(m_window->removeLyricsAction()->isEnabled()); + QVERIFY(m_window->showLyricsAction()->isEnabled()); + QVERIFY(m_window->showLyricsAction()->isChecked()); + } + + // Like loading background music: not undoable, and nothing goes onto + // the undo stack or comes off it, but the session has changed + void lyrics_import_leaves_history_alone() { + makeWindow(FakeAudioIO::Config()); + openReference(writeWav(tone(lowHz, 1.0))); + if (QTest::currentTestFailed()) return; + + auto *history = sv::CommandHistory::getInstance(); + history->addCommand(new sv::GenericCommand + ("Earlier Edit", []() {}, []() {}), false); + m_window->discardModifications(); + QSignalSpy commands(history, qOverload<> + (&sv::CommandHistory::commandExecuted)); + + QVERIFY(m_window->doImportLyricsFrom + (lyricsFixture("moises-exporter-words.lrc"))); + QCOMPARE(int(commands.count()), 0); + QVERIFY(m_window->isDocumentModified()); + + sv::Layer *layer = m_window->lyrics()->getLayer(); + QCOMPARE(undoOnce(), QString("Earlier Edit")); + QCOMPARE(undoOnce(), QString()); + QVERIFY(m_window->lyrics()->isShown()); + QVERIFY(m_window->lyrics()->getLayer() == layer); + QVERIFY(paneHasLayer(0, layer)); + } + + // The play source takes in the model of every layer in a view, and + // the last end of them is where playback ends + void lyrics_do_not_extend_playback() { + makeWindow(FakeAudioIO::Config()); + openReference(writeWav(tone(lowHz, 1.0))); + if (QTest::currentTestFailed()) return; + + sv::AudioCallbackPlaySource *playSource = m_window->playSource(); + sv::sv_frame_t playEnd = playSource->getPlayEndFrame(); + QVERIFY(playEnd > 0); + + // Two words within the second of reference, and one well after it + QVERIFY(m_window->doImportLyricsFrom + (writeLrc("[00:00.20]<00:00.20>Yksi <00:00.60>kaksi\n" + "[00:03.00]<00:03.00>Kolme\n"))); + sv::ModelId model = m_window->lyrics()->getModelId(); + QVERIFY(!model.isNone()); + QVERIFY2(sv::ModelById::get(model)->getEndFrame() > playEnd, + "the lyrics end within the reference: this shows nothing"); + + QVERIFY2(playSource->getModels().count(model) == 0, + "the play source holds the lyrics"); + // Not equal: taking a model out works the end out again from the + // models' ends as they are now, and one of pYIN's may be shorter + // than it was when it came in + QVERIFY2(playSource->getPlayEndFrame() <= playEnd, + qPrintable(QString("playback ends at frame %1, later than " + "%2 before the import") + .arg(playSource->getPlayEndFrame()).arg(playEnd))); + verifyPlaySourceClean(); + + QVERIFY2(m_window->statusText().contains + ("1 word starts after the end of the reference."), + qPrintable(m_window->statusText())); + + // The same once the session is opened again: the load puts the + // model of every layer it adds to a view into the play source + QString session = m_dir.filePath("lyrics-past-the-end.ton"); + QVERIFY(m_window->saveSessionFile(session)); + reopenSession(session); + if (QTest::currentTestFailed()) return; + + QVERIFY(m_window->lyrics()->isShown()); + model = m_window->lyrics()->getModelId(); + QVERIFY(!model.isNone()); + sv::sv_frame_t lyricsEnd = sv::ModelById::get(model)->getEndFrame(); + playSource = m_window->playSource(); + QVERIFY2(playSource->getModels().count(model) == 0, + "the play source holds the lyrics of the session"); + QVERIFY2(playSource->getPlayEndFrame() < lyricsEnd, + qPrintable(QString("playback ends at frame %1, with the " + "lyrics, which end at %2") + .arg(playSource->getPlayEndFrame()) + .arg(lyricsEnd))); + verifyPlaySourceClean(); + } + + void lyrics_reimport_replaces() { + makeWindow(FakeAudioIO::Config()); + openReference(writeWav(tone(lowHz, 1.0))); + if (QTest::currentTestFailed()) return; + + LyricsTrack *lyrics = m_window->lyrics(); + QVERIFY(m_window->doImportLyricsFrom + (lyricsFixture("moises-exporter-words.lrc"))); + sv::ModelId first = lyrics->getModelId(); + + QString path = lyricsFixture("lrc-with-ends.lrc"); + QVERIFY(m_window->doImportLyricsFrom(path)); + QCOMPARE(lyricsLayersInDocument(), 1); + QCOMPARE(lyricsLayersInPane0(), 1); + QVERIFY(lyrics->getModelId() != first); + QCOMPARE(lyricsEvents(), expectedLyricsEvents(path)); + QCOMPARE(lyrics->getLayer()->getLayerPresentationName(), + QString("Päivä [Live]")); + + // A model id is never used twice, unlike the address of a layer + QVERIFY2(!sv::ModelById::get(first), + "the first lyrics' model was not released"); + QVERIFY(m_window->document()->getModels().count(first) == 0); + verifyPlaySourceClean(); + } + + void lyrics_remove() { + makeWindow(FakeAudioIO::Config()); + openReference(writeWav(tone(lowHz, 1.0))); + if (QTest::currentTestFailed()) return; + + LyricsTrack *lyrics = m_window->lyrics(); + QVERIFY(m_window->doImportLyricsFrom + (lyricsFixture("moises-exporter-words.lrc"))); + sv::ModelId model = lyrics->getModelId(); + m_window->discardModifications(); + + m_window->removeLyricsAction()->trigger(); + QVERIFY(!lyrics->isShown()); + QCOMPARE(lyricsLayersInDocument(), 0); + QVERIFY2(!sv::ModelById::get(model), "the lyrics model was not released"); + QVERIFY(m_window->document()->getModels().count(model) == 0); + QVERIFY(m_window->isDocumentModified()); + QCOMPARE(undoOnce(), QString()); + verifyPlaySourceClean(); + + QVERIFY(!m_window->removeLyricsAction()->isEnabled()); + QVERIFY(!m_window->showLyricsAction()->isEnabled()); + QVERIFY(!m_window->showLyricsAction()->isChecked()); + QVERIFY(m_window->importLyricsAction()->isEnabled()); + } + + // Hidden, not removed; not in the settings, and no command + void lyrics_show_toggle() { + makeWindow(FakeAudioIO::Config()); + openReference(writeWav(tone(lowHz, 1.0))); + if (QTest::currentTestFailed()) return; + + LyricsTrack *lyrics = m_window->lyrics(); + QVERIFY(m_window->doImportLyricsFrom + (lyricsFixture("moises-exporter-words.lrc"))); + sv::Layer *layer = lyrics->getLayer(); + sv::Pane *pane = m_window->paneStack()->getPane(0); + m_window->discardModifications(); + auto *history = sv::CommandHistory::getInstance(); + QSignalSpy commands(history, qOverload<> + (&sv::CommandHistory::commandExecuted)); + + QAction *show = m_window->showLyricsAction(); + show->trigger(); + QVERIFY(layer->isLayerDormant(pane)); + QVERIFY(!lyrics->isVisible()); + QVERIFY(!show->isChecked()); + QVERIFY(show->isEnabled()); + QVERIFY(lyrics->getLayer() == layer); + QVERIFY(paneHasLayer(0, layer)); + QVERIFY(m_window->isDocumentModified()); + + show->trigger(); + QVERIFY(!layer->isLayerDormant(pane)); + QVERIFY(lyrics->isVisible()); + QVERIFY(show->isChecked()); + + QCOMPARE(int(commands.count()), 0); + QCOMPARE(undoOnce(), QString()); + for (const QString &key : QSettings().allKeys()) { + QVERIFY2(!key.contains("lyrics", Qt::CaseInsensitive), + qPrintable("in the settings: " + key)); + } + } + + // The pane takes its hover readout and its vertical scale from its + // top layer, and the lyrics have neither + void lyrics_keep_top_layer() { + makeWindow(FakeAudioIO::Config()); + openReference(writeWav(tone(lowHz, 1.0))); + if (QTest::currentTestFailed()) return; + + sv::Pane *pane = m_window->paneStack()->getPane(0); + sv::Layer *top = pane->getTopLayer(); + QVERIFY(top); + sv::Layer *reference = + m_window->analyser()->getLayer(Analyser::PitchTrack); + double r0 = 0, r1 = 0; + bool referenceScale = reference->getDisplayExtents(r0, r1); + + QVERIFY(m_window->doImportLyricsFrom + (lyricsFixture("moises-exporter-words.lrc"))); + sv::Layer *layer = m_window->lyrics()->getLayer(); + QVERIFY2(pane->getTopLayer() == top, + "the lyrics are the pane's top layer"); + QVERIFY(pane->getLayer(pane->getLayerCount() - 2) == layer); + + double s0 = 0, s1 = 0; + QCOMPARE(reference->getDisplayExtents(s0, s1), referenceScale); + QCOMPARE(s0, r0); + QCOMPARE(s1, r1); + } + + // The layer over the lyrics can go while they stay: the alternate + // pitch track when it is turned off, a take's layers when the take + // is deleted. The lyrics must not be left on top then either + void lyrics_stay_under_top_when_the_layer_above_goes() { + makeWindow(FakeAudioIO::Config()); + openReference(writeWav(tone(lowHz, 1.0))); + if (QTest::currentTestFailed()) return; + + m_window->doToggleAlternatePitch(); + QVERIFY(m_window->alternatePitch()->isShown()); + sv::Pane *pane = m_window->paneStack()->getPane(0); + QVERIFY(pane->getTopLayer() == m_window->alternatePitch()->getLayer()); + sv::Layer *under = pane->getLayer(pane->getLayerCount() - 2); + + QVERIFY(m_window->doImportLyricsFrom + (lyricsFixture("moises-exporter-words.lrc"))); + sv::Layer *layer = m_window->lyrics()->getLayer(); + QVERIFY(pane->getLayer(pane->getLayerCount() - 2) == layer); + + // The alternate track's layer is deleted, and the lyrics were + // just under it + m_window->doToggleAlternatePitch(); + QVERIFY(!m_window->alternatePitch()->isShown()); + QTRY_VERIFY2(pane->getTopLayer() != layer, + "the lyrics were left the pane's top layer"); + QVERIFY2(pane->getTopLayer() == under, + "the layer that was under the lyrics is not on top"); + QVERIFY(pane->getLayer(pane->getLayerCount() - 2) == layer); + QVERIFY(m_window->lyrics()->isShown()); + QVERIFY(m_window->lyrics()->isVisible()); + } + + // One dialog each, and nothing changes: not even lyrics that are there + void lyrics_import_failure() { + makeWindow(FakeAudioIO::Config()); + openReference(writeWav(tone(lowHz, 1.0))); + if (QTest::currentTestFailed()) return; + m_window->discardModifications(); + + LyricsTrack *lyrics = m_window->lyrics(); + QString untimed = writeLrc("Just some words\nwith no times at all\n"); + QVector> failures { + { untimed, "No timed lyrics were found" }, + { m_dir.filePath("no-such-lyrics.lrc"), "could not be found" }, + { writeLrc(QByteArray(int(Lyrics::maxFileBytes) + 1, 'a')), + "over 1 MB, too big to be a lyrics file" }, + { writeLrc("\n

Sana\n\n", + "ttml"), + "not well-formed XML" }, + }; + for (const auto &f : failures) { + QVERIFY2(!m_window->doImportLyricsFrom(f.first), + qPrintable(f.first)); + QStringList dialogs = + messagesMatching("Could not import lyrics", f.second); + QCOMPARE(dialogs.size(), 1); + QVERIFY2(dialogs[0].contains(f.second), qPrintable(dialogs[0])); + QVERIFY(!lyrics->isShown()); + QCOMPARE(lyricsLayersInDocument(), 0); + QVERIFY(!m_window->isDocumentModified()); + } + + QVERIFY(m_window->doImportLyricsFrom + (lyricsFixture("moises-exporter-words.lrc"))); + sv::ModelId model = lyrics->getModelId(); + sv::EventVector events = lyricsEvents(); + m_window->discardModifications(); + + QVERIFY(!m_window->doImportLyricsFrom(untimed)); + QCOMPARE(messagesMatching("Could not import lyrics", + "No timed lyrics were found").size(), 1); + QVERIFY(lyrics->getModelId() == model); + QCOMPARE(lyricsEvents(), events); + QCOMPARE(lyricsLayersInDocument(), 1); + QVERIFY(!m_window->isDocumentModified()); + } + + // File > Import Lyrics..., with the file dialog answered from here + void lyrics_import_through_the_menu() { + makeWindow(FakeAudioIO::Config()); + QAction *import = m_window->importLyricsAction(); + QVERIFY2(!import->isEnabled(), "there is no reference yet"); + QVERIFY(!m_window->removeLyricsAction()->isEnabled()); + QVERIFY(!m_window->showLyricsAction()->isEnabled()); + + openReference(writeWav(tone(lowHz, 1.0))); + if (QTest::currentTestFailed()) return; + QVERIFY(import->isEnabled()); + + // Line timing, as the exporter writes it + QString path = lyricsFixture("moises-exporter-lines.lrc"); + m_window->setLyricsFileAnswer(path); + import->trigger(); + QCOMPARE(m_window->lyricsFileQuestions(), 1); + QVERIFY(m_window->lyrics()->isShown()); + QCOMPARE(lyricsEvents(), expectedLyricsEvents(path)); + QVERIFY(m_window->isDocumentModified()); + } + + void lyrics_import_cancelled() { + makeWindow(FakeAudioIO::Config()); + openReference(writeWav(tone(lowHz, 1.0))); + if (QTest::currentTestFailed()) return; + + LyricsTrack *lyrics = m_window->lyrics(); + QVERIFY(m_window->doImportLyricsFrom + (lyricsFixture("moises-exporter-words.lrc"))); + sv::ModelId model = lyrics->getModelId(); + sv::EventVector events = lyricsEvents(); + m_window->discardModifications(); + + m_window->setLyricsFileAnswer(""); + m_window->importLyricsAction()->trigger(); + QCOMPARE(m_window->lyricsFileQuestions(), 1); + QVERIFY(lyrics->getModelId() == model); + QCOMPARE(lyricsEvents(), events); + QCOMPARE(lyricsLayersInDocument(), 1); + QVERIFY(!m_window->isDocumentModified()); + } + + // The singer is reading the lyrics there are + void lyrics_import_disabled_while_recording() { + FakeAudioIO::Config config; + config.input = tone(highHz, 3.0); + makeWindow(config); + openReference(writeWav(tone(lowHz, 2.0))); + if (QTest::currentTestFailed()) return; + + QAction *import = m_window->importLyricsAction(); + QVERIFY(import->isEnabled()); + QString path = lyricsFixture("moises-exporter-words.lrc"); + m_window->setLyricsFileAnswer(path); + + startTake(); + if (QTest::currentTestFailed()) return; + QTRY_VERIFY_WITH_TIMEOUT(!import->isEnabled(), 2000); + import->trigger(); + QCOMPARE(m_window->lyricsFileQuestions(), 0); + QVERIFY(!m_window->doImportLyricsFrom(path)); + QVERIFY(!m_window->lyrics()->isShown()); + + waitForSomethingRecorded(); + stopTake(); + if (QTest::currentTestFailed()) return; + QTRY_VERIFY_WITH_TIMEOUT(import->isEnabled(), 2000); + } + + // TTML as the exporter writes it word by word: every word ends where + // the file says, not where the next one starts + void lyrics_import_ttml() { + makeWindow(FakeAudioIO::Config()); + openReference(writeWav(tone(lowHz, 1.0))); + if (QTest::currentTestFailed()) return; + m_window->discardModifications(); + + QString path = lyricsFixture("moises-exporter-words.ttml"); + QVERIFY(m_window->doImportLyricsFrom(path)); + QVERIFY(m_window->lyrics()->isShown()); + QVERIFY(m_window->isDocumentModified()); + QCOMPARE(m_window->lyrics()->getLayer()->getLayerPresentationName(), + QString("Lyrics")); + + sv::EventVector expected = expectedLyricsEvents(path); + QCOMPARE(int(expected.size()), 12); + sv::EventVector events = lyricsEvents(); + QCOMPARE(events, expected); + + // "Tämä" is sung from 0.52 s to 0.80 s and "on" starts at 0.84 s, + // where an inferred end would be. The syllables of "keksitty" are + // one word, and the second

is the second line + auto frameAt = [](double seconds) { + return sv::sv_frame_t(std::llround(seconds * rate)); + }; + QCOMPARE(events[0].getLabel(), QString("Tämä")); + QCOMPARE(events[0].getFrame(), frameAt(0.52)); + QCOMPARE(events[0].getFrame() + events[0].getDuration(), frameAt(0.80)); + QCOMPARE(events[1].getFrame(), frameAt(0.84)); + QCOMPARE(events[2].getLabel(), QString("keksitty")); + QCOMPARE(events[2].getFrame(), frameAt(1.10)); + QCOMPARE(events[2].getFrame() + events[2].getDuration(), frameAt(1.90)); + QCOMPARE(events[3].getValue(), 0.f); + QCOMPARE(events[4].getLabel(), QString("Yö")); + QCOMPARE(events[4].getValue(), 1.f); + + QString status = m_window->statusText(); + QVERIFY2(status.startsWith("Imported 12 words in 3 lines."), + qPrintable(status)); + } + + // File > Export Lyrics... writes the words as the model holds them + // now, not as the file they came from had them, as TTML that reads + // back to the same words. Not a change to the session + void lyrics_export() { + makeWindow(FakeAudioIO::Config()); + QString reference = writeWav(tone(lowHz, 1.0)); + openReference(reference); + if (QTest::currentTestFailed()) return; + + QVERIFY(m_window->doImportLyricsFrom + (lyricsFixture("moises-exporter-words.lrc"))); + auto model = sv::ModelById::getAs + (m_window->lyrics()->getModelId()); + QVERIFY(model); + + // One word changed in the model since: its text, and its end + sv::EventVector imported = lyricsEvents(); + auto word = std::find_if(imported.begin(), imported.end(), + [](const sv::Event &e) { + return e.getLabel() == "hämärä"; + }); + QVERIFY(word != imported.end()); + model->remove(*word); + model->add(word->withLabel("hämärämpi") + .withDuration(word->getDuration() / 2)); + sv::EventVector events = lyricsEvents(); + QVERIFY(events != imported); + + m_window->discardModifications(); + QSignalSpy commands(sv::CommandHistory::getInstance(), qOverload<> + (&sv::CommandHistory::commandExecuted)); + + QString path = m_dir.filePath("exported-lyrics.ttml"); + m_window->setLyricsExportAnswer(path); + QVERIFY(m_window->exportLyricsAction()->isEnabled()); + m_window->exportLyricsAction()->trigger(); + QCOMPARE(m_window->lyricsExportQuestions(), 1); + + // The name offered is the reference's, beside it + QFileInfo info(reference); + QCOMPARE(m_window->lyricsExportSuggestion(), + QDir(info.absolutePath()) + .filePath(info.completeBaseName() + ".ttml")); + + QFile file(path); + QVERIFY2(file.open(QIODevice::ReadOnly), "nothing was written"); + LyricsParseResult parsed = parseTtml(file.readAll()); + QVERIFY2(parsed.error == "", qPrintable(parsed.error)); + + // The same words, texts and lines, at the same times to the + // nearest millisecond, and the title the layer was named after + Lyrics now = lyricsFromEvents(events, rate); + const QVector &back = parsed.lyrics.words; + QCOMPARE(back.size(), now.words.size()); + for (int i = 0; i < back.size(); ++i) { + QString what = QString("word %1, \"%2\"").arg(i) + .arg(now.words[i].text); + QVERIFY2(back[i].text == now.words[i].text, + qPrintable(what + " came back as " + back[i].text)); + QVERIFY2(back[i].line == now.words[i].line, + qPrintable(what + ": another line")); + QVERIFY2(std::fabs(back[i].start - now.words[i].start) < 0.0005001, + qPrintable(what + ": another start")); + QVERIFY2(std::fabs(back[i].end - now.words[i].end) < 0.0005001, + qPrintable(what + ": another end")); + } + QStringList texts; + for (const LyricWord &w : back) texts << w.text; + QVERIFY2(texts.contains("hämärämpi") && !texts.contains("hämärä"), + qPrintable(texts.join(" "))); + QCOMPARE(parsed.lyrics.title, QString("Kesäyön testilaulu")); + + QCOMPARE(m_window->statusText(), + QString("Exported 13 words in %1 lines.") + .arg(now.lineCount())); + QCOMPARE(int(commands.count()), 0); + QVERIFY(!m_window->isDocumentModified()); + QCOMPARE(lyricsEvents(), events); + } + + // Only when there are lyrics, shown or hidden + void lyrics_export_needs_lyrics() { + makeWindow(FakeAudioIO::Config()); + QAction *exportAction = m_window->exportLyricsAction(); + QVERIFY2(!exportAction->isEnabled(), "there is no reference yet"); + + openReference(writeWav(tone(lowHz, 1.0))); + if (QTest::currentTestFailed()) return; + QVERIFY2(!exportAction->isEnabled(), "there are no lyrics yet"); + + QString lrc = lyricsFixture("moises-exporter-words.lrc"); + QVERIFY(m_window->doImportLyricsFrom(lrc)); + QVERIFY(exportAction->isEnabled()); + m_window->showLyricsAction()->trigger(); + QVERIFY(!m_window->lyrics()->isVisible()); + QVERIFY2(exportAction->isEnabled(), "hidden lyrics are lyrics too"); + m_window->showLyricsAction()->trigger(); + + m_window->removeLyricsAction()->trigger(); + QVERIFY(!m_window->lyrics()->isShown()); + QVERIFY(!exportAction->isEnabled()); + QString path = m_dir.filePath("removed-lyrics.ttml"); + m_window->setLyricsExportAnswer(path); + exportAction->trigger(); + QCOMPARE(m_window->lyricsExportQuestions(), 0); + QVERIFY(!m_window->doExportLyricsTo(path)); + QVERIFY(!QFileInfo::exists(path)); + + QVERIFY(m_window->doImportLyricsFrom(lrc)); + QVERIFY(exportAction->isEnabled()); + m_window->doCloseSession(); + QVERIFY2(!exportAction->isEnabled(), "the session has gone"); + } + + void lyrics_export_cancelled() { + makeWindow(FakeAudioIO::Config()); + openReference(writeWav(tone(lowHz, 1.0))); + if (QTest::currentTestFailed()) return; + QVERIFY(m_window->doImportLyricsFrom + (lyricsFixture("moises-exporter-words.lrc"))); + QString status = m_window->statusText(); + + const QStringList ttml { "*.ttml" }; + QStringList before = QDir(m_dir.path()).entryList(ttml, QDir::Files); + m_window->setLyricsExportAnswer(""); + m_window->exportLyricsAction()->trigger(); + QCOMPARE(m_window->lyricsExportQuestions(), 1); + QVERIFY(m_window->lyricsExportSuggestion() != ""); + QVERIFY2(!QFileInfo::exists(m_window->lyricsExportSuggestion()), + "written where the dialog would have suggested"); + QCOMPARE(QDir(m_dir.path()).entryList(ttml, QDir::Files), before); + QCOMPARE(m_window->statusText(), status); + } + + // One dialog each, and nothing written or changed + void lyrics_export_failure() { + makeWindow(FakeAudioIO::Config()); + openReference(writeWav(tone(lowHz, 1.0))); + if (QTest::currentTestFailed()) return; + QVERIFY(m_window->doImportLyricsFrom + (lyricsFixture("moises-exporter-words.lrc"))); + sv::EventVector events = lyricsEvents(); + QString status = m_window->statusText(); + m_window->discardModifications(); + + // Not a file made read-only: root can write to that. A folder + // that is not there, and the name of a folder + QString noFolder = m_dir.filePath("no-such-folder/lyrics.ttml"); + QString folder = m_dir.filePath("a-folder.ttml"); + QVERIFY(QDir().mkpath(folder)); + + for (QString path : { noFolder, folder }) { + m_window->setLyricsExportAnswer(path); + m_window->exportLyricsAction()->trigger(); + QStringList dialogs = + messagesMatching("Could not export lyrics", path); + QCOMPARE(dialogs.size(), 1); + QVERIFY2(dialogs[0].contains(path), qPrintable(dialogs[0])); + QCOMPARE(m_window->statusText(), status); + QVERIFY(!m_window->isDocumentModified()); + QCOMPARE(lyricsEvents(), events); + } + QCOMPARE(m_window->lyricsExportQuestions(), 2); + QVERIFY(!QFileInfo::exists(m_dir.filePath("no-such-folder"))); + QVERIFY(QFileInfo(folder).isDir()); + QVERIFY(QDir(folder).isEmpty()); + } + + // Saved with the session and found again by name when it is opened, + // as it was: hidden if it was hidden + void lyrics_session_round_trip() { + makeWindow(FakeAudioIO::Config()); + openReference(writeWav(tone(lowHz, 1.0))); + if (QTest::currentTestFailed()) return; + + LyricsTrack *lyrics = m_window->lyrics(); + QVERIFY(m_window->doImportLyricsFrom + (lyricsFixture("moises-exporter-words.lrc"))); + sv::EventVector before = lyricsEvents(); + QCOMPARE(int(before.size()), 13); + m_window->showLyricsAction()->trigger(); + QVERIFY(!lyrics->isVisible()); + + QString session = m_dir.filePath("lyrics-hidden.ton"); + QVERIFY(m_window->saveSessionFile(session)); + reopenSession(session); + if (QTest::currentTestFailed()) return; + + // One layer, and LyricsTrack has it + QVERIFY2(lyrics->isShown(), "the lyrics of the session were not adopted"); + QCOMPARE(lyricsLayersInDocument(), 1); + QCOMPARE(lyricsLayersInPane0(), 1); + sv::RegionLayer *layer = lyrics->getLayer(); + QCOMPARE(static_cast(layer), lyricsLayerInPane0()); + + // Every word at its frame and for as long, and its label letter for + // letter: the file is UTF-8, and a label is escaped in it + sv::EventVector after = lyricsEvents(); + verifyEventsSurvived(before, after, "the lyrics"); + if (QTest::currentTestFailed()) return; + QStringList labels; + for (size_t i = 0; i < after.size(); ++i) { + QCOMPARE(after[i].getLabel(), before[i].getLabel()); + labels << after[i].getLabel(); + } + QVERIFY2(labels.contains(QString("Tämä")) && + labels.contains(QString("hämärä")) && + labels.contains(QString("Yö")), qPrintable(labels.join(" "))); + + // Still hidden, and the menu says so + sv::Pane *pane = m_window->paneStack()->getPane(0); + QVERIFY(layer->isLayerDormant(pane)); + QVERIFY(!lyrics->isVisible()); + QVERIFY(m_window->showLyricsAction()->isEnabled()); + QVERIFY(!m_window->showLyricsAction()->isChecked()); + QVERIFY(m_window->removeLyricsAction()->isEnabled()); + + // Called what the file's [ti:] tag says, and drawn as before + QCOMPARE(layer->getLayerPresentationName(), + QString("Kesäyön testilaulu")); + QCOMPARE(int(layer->getPlotStyle()), int(sv::RegionLayer::PlotLyrics)); + QCOMPARE(colourOf(layer), colourNamed("Grey")); + QVERIFY2(pane->getTopLayer() != layer, + "the lyrics are the pane's top layer"); + + // Opening a session is not a change to it + QVERIFY(!m_window->isDocumentModified()); + + // Shown again, saved and opened again: shown + m_window->showLyricsAction()->trigger(); + QVERIFY(lyrics->isVisible()); + session = m_dir.filePath("lyrics-shown.ton"); + QVERIFY(m_window->saveSessionFile(session)); + reopenSession(session); + if (QTest::currentTestFailed()) return; + + QVERIFY(lyrics->isShown()); + QCOMPARE(lyricsLayersInDocument(), 1); + pane = m_window->paneStack()->getPane(0); + QVERIFY(!lyrics->getLayer()->isLayerDormant(pane)); + QVERIFY(lyrics->isVisible()); + QVERIFY(m_window->showLyricsAction()->isChecked()); + verifyEventsSurvived(before, lyricsEvents(), "the lyrics, saved twice"); + } + + // A session can have the lyrics on top of pane 0, if the layer that + // was above them went before it was saved. They do not stay on top + // once it is opened + void lyrics_not_top_after_reopen() { + makeWindow(FakeAudioIO::Config()); + openReference(writeWav(tone(lowHz, 1.0))); + if (QTest::currentTestFailed()) return; + + QVERIFY(m_window->doImportLyricsFrom + (lyricsFixture("moises-exporter-words.lrc"))); + sv::Pane *pane = m_window->paneStack()->getPane(0); + sv::Layer *layer = m_window->lyrics()->getLayer(); + TakeLayers::raise(pane, layer); + QVERIFY(pane->getTopLayer() == layer); + int count = pane->getLayerCount(); + QString below = pane->getLayer(count - 2)->objectName(); + QVERIFY(below != ""); + + QString session = m_dir.filePath("lyrics-on-top.ton"); + QVERIFY(m_window->saveSessionFile(session)); + reopenSession(session); + if (QTest::currentTestFailed()) return; + + // The layer that was just under them is over them, and nothing + // else has moved + pane = m_window->paneStack()->getPane(0); + layer = m_window->lyrics()->getLayer(); + QVERIFY(layer); + QCOMPARE(pane->getLayerCount(), count); + QVERIFY2(pane->getTopLayer() != layer, + "the lyrics are the pane's top layer"); + QCOMPARE(pane->getTopLayer()->objectName(), below); + QVERIFY(pane->getLayer(count - 2) == layer); + } + + // The lyrics belong to the song, not to a take: nothing a take does + // touches them, and the singer reads them while recording + void lyrics_survive_takes() { + FakeAudioIO::Config config; + config.input = tone(highHz, 4.0); + makeWindow(config); + openReference(writeWav(tone(lowHz, 2.0))); + if (QTest::currentTestFailed()) return; + + QVERIFY(m_window->doImportLyricsFrom + (lyricsFixture("moises-exporter-words.lrc"))); + sv::Layer *layer = lyricsLayerInPane0(); + QVERIFY(layer); + sv::ModelId model = m_window->lyrics()->getModelId(); + sv::EventVector events = lyricsEvents(); + QVERIFY(!events.empty()); + + startTake(); + if (QTest::currentTestFailed()) return; + verifyLyricsUntouched(layer, model, events, "at the start of the take"); + if (QTest::currentTestFailed()) return; + QTest::qWait(600); + verifyLyricsUntouched(layer, model, events, "while recording"); + if (QTest::currentTestFailed()) return; + stopTake(); + if (QTest::currentTestFailed()) return; + QVERIFY(m_window->takes()->haveTake()); + verifyLyricsUntouched(layer, model, events, "after the take"); + if (QTest::currentTestFailed()) return; + + QCOMPARE(undoOnce(), QString("Record Singing")); + verifyLyricsUntouched(layer, model, events, "after the undo"); + if (QTest::currentTestFailed()) return; + QCOMPARE(redoOnce(), QString("Record Singing")); + QTRY_VERIFY_WITH_TIMEOUT(analysed(m_window->analyser2()), 30000); + verifyLyricsUntouched(layer, model, events, "after the redo"); + if (QTest::currentTestFailed()) return; + + // The first take's layers are put away for the new one, and it has + // its audio swapped in under them on the way back + m_window->doNewEmptyTake(); + QCOMPARE(m_window->takes()->getActiveIndex(), 1); + verifyLyricsUntouched(layer, model, events, "in a new take"); + if (QTest::currentTestFailed()) return; + m_window->doChooseTakeInCombo(0); + QCOMPARE(m_window->takes()->getActiveIndex(), 0); + verifyLyricsUntouched(layer, model, events, "back in the first take"); + if (QTest::currentTestFailed()) return; + verifyPlaySourceClean(); + + // A session with takes is restored after its lyrics are found, + // and that leaves them alone as well + QString session = m_dir.filePath("lyrics-takes.ton"); + QVERIFY(m_window->saveSessionFile(session)); + reopenSession(session); + if (QTest::currentTestFailed()) return; + QCOMPARE(m_window->takes()->getTakeCount(), 2); + QCOMPARE(m_window->takes()->getActiveIndex(), 0); + sv::EventVector reopened = lyricsEvents(); + verifyEventsSurvived(events, reopened, "the lyrics"); + if (QTest::currentTestFailed()) return; + verifyLyricsUntouched(lyricsLayerInPane0(), + m_window->lyrics()->getModelId(), reopened, + "after the session was opened"); + if (QTest::currentTestFailed()) return; + verifyPlaySourceClean(); + } + + // Load Singing Track opens its file with openPath(), prunes the pane + // that makes and clears the history: none of it is the lyrics' business + void lyrics_survive_load_singing_track() { + makeWindow(FakeAudioIO::Config()); + openReference(writeWav(tone(lowHz, 1.0))); + if (QTest::currentTestFailed()) return; + + QVERIFY(m_window->doImportLyricsFrom + (lyricsFixture("moises-exporter-words.lrc"))); + sv::Layer *layer = lyricsLayerInPane0(); + sv::ModelId model = m_window->lyrics()->getModelId(); + sv::EventVector events = lyricsEvents(); + + m_window->loadSingingTrack(writeWav(tone(highHz, 1.0))); + QTRY_VERIFY_WITH_TIMEOUT(analysed(m_window->analyser2()), 30000); + verifyLyricsUntouched(layer, model, events, "after Load Singing Track"); + if (QTest::currentTestFailed()) return; + verifyPlaySourceClean(); + } + + // The lyrics go with their session, and no other session is given them + void lyrics_gone_with_session() { + makeWindow(FakeAudioIO::Config()); + openReference(writeWav(tone(lowHz, 1.0))); + if (QTest::currentTestFailed()) return; + QString plain = m_dir.filePath("no-lyrics.ton"); + QVERIFY(m_window->saveSessionFile(plain)); + + LyricsTrack *lyrics = m_window->lyrics(); + QVERIFY(m_window->doImportLyricsFrom + (lyricsFixture("moises-exporter-words.lrc"))); + sv::ModelId model = lyrics->getModelId(); + QString withLyrics = m_dir.filePath("with-lyrics.ton"); + QVERIFY(m_window->saveSessionFile(withLyrics)); + + m_window->doCloseSession(); + QVERIFY(!lyrics->isShown()); + QVERIFY(!lyrics->getLayer()); + QVERIFY2(!sv::ModelById::get(model), + "the lyrics model outlived its session"); + + auto noLyrics = [&](const char *what) { + QVERIFY2(!lyrics->isShown(), what); + QVERIFY2(lyricsLayersInDocument() == 0, what); + QVERIFY2(!m_window->removeLyricsAction()->isEnabled(), what); + QVERIFY2(!m_window->showLyricsAction()->isEnabled(), what); + QVERIFY2(!m_window->showLyricsAction()->isChecked(), what); + QVERIFY2(m_window->importLyricsAction()->isEnabled(), what); + }; + + openReference(writeWav(tone(highHz, 1.0))); + if (QTest::currentTestFailed()) return; + noLyrics("a new reference was given the lyrics"); + if (QTest::currentTestFailed()) return; + + // A session without lyrics, opened after one with them + openReference(withLyrics); + if (QTest::currentTestFailed()) return; + QVERIFY(lyrics->isShown()); + openReference(plain); + if (QTest::currentTestFailed()) return; + noLyrics("a session without lyrics was given the last one's"); + } + + // The word at the playback position is highlighted with playback + // stopped as well: at once when the lyrics are imported with the + // cursor in a word, and wherever a seek puts the cursor + void lyrics_highlight_follows_seek() { + makeWindow(FakeAudioIO::Config()); + openReference(writeWav(tone(lowHz, 2.0))); + if (QTest::currentTestFailed()) return; + + m_window->seekTo(sv::sv_frame_t(0.7 * rate)); + QVERIFY(m_window->doImportLyricsFrom(writeLrc(gappedLyrics()))); + QCOMPARE(highlightedWord(), QString("kaksi")); + + // Before the first word, in each word, and in the gaps after them + const struct { double seconds; const char *word; } seeks[] = { + { 0.30, "Yksi" }, { 1.10, "" }, { 1.50, "kolme" }, + { 0.10, "" }, { 0.80, "kaksi" }, { 1.90, "" }, + }; + for (const auto &s : seeks) { + m_window->seekTo(sv::sv_frame_t(s.seconds * rate)); + QTRY_COMPARE_WITH_TIMEOUT(highlightedWord(), QString(s.word), 1000); + } + + // A highlight is not a change to the session + m_window->discardModifications(); + m_window->seekTo(sv::sv_frame_t(0.3 * rate)); + QTRY_COMPARE_WITH_TIMEOUT(highlightedWord(), QString("Yksi"), 1000); + QVERIFY(!m_window->isDocumentModified()); + } + + // While the reference plays, the highlight moves on from word to word + void lyrics_highlight_follows_playback() { + makeWindow(FakeAudioIO::Config()); + openReference(writeWav(tone(lowHz, 2.0))); + if (QTest::currentTestFailed()) return; + + QVERIFY(m_window->doImportLyricsFrom + (writeLrc("[00:00.00]<00:00.00>Yksi <00:00.25>kaksi " + "<00:00.50>kolme <00:00.75>neljä <00:01.00>viisi " + "<00:01.25>kuusi <00:01.50>\n"))); + QCOMPARE(m_window->playbackFrame(), sv::sv_frame_t(0)); + QCOMPARE(highlightedWord(), QString("Yksi")); + + m_window->doPlay(); + QTRY_VERIFY_WITH_TIMEOUT(highlightedWord() == "neljä", 2000); + m_window->doPlay(); + QVERIFY2(m_window->fake()->getPlayStartFrame() >= 0, + "nothing was played"); + } + + // During a take the highlight follows the cursor, which runs with the + // reference: through the lead-in of a pre-roll, while the status bar + // counts down, and from the take's position on the reference's + // timeline, not the recording's own. The take's waveform is faded + // under the lyrics like the reference's + void lyrics_highlight_and_fade_in_a_take() { + const double leadIn = 0.6; + FakeAudioIO::Config config; + config.input = tone(highHz, 3.0); + makeWindow(config); + setPreRollSeconds(leadIn); + m_window->setPreRoll(true); + openReference(writeWav(tone(lowHz, 3.0))); + if (QTest::currentTestFailed()) return; + + // A word in the lead-in, a gap at the take's position, and two + // words after it + QVERIFY(m_window->doImportLyricsFrom + (writeLrc("[00:01.50]<00:01.50>Alku <00:01.90>\n" + "[00:02.10]<00:02.10>kaksi <00:02.40>kolme " + "<00:03.00>\n"))); + const sv::sv_frame_t P = sv::sv_frame_t(2.0 * rate); + m_window->seekTo(P); + QTRY_COMPARE_WITH_TIMEOUT(highlightedWord(), QString(), 1000); + + startTake(); + if (QTest::currentTestFailed()) return; + QCOMPARE(m_window->takePreRoll(), sv::sv_frame_t(leadIn * rate)); + QTRY_VERIFY_WITH_TIMEOUT(highlightedWord() == "Alku", 1000); + QVERIFY2(m_window->statusText().startsWith("Recording in "), + qPrintable(QString("the word in the lead-in is lit, but the " + "status bar says \"%1\" rather than " + "counting down") + .arg(m_window->statusText()))); + QTRY_VERIFY_WITH_TIMEOUT(highlightedWord() == "kolme", 1500); + stopTake(); + if (QTest::currentTestFailed()) return; + + // Back at the take's position, in the gap + QCOMPARE(m_window->playbackFrame(), P); + QTRY_COMPARE_WITH_TIMEOUT(highlightedWord(), QString(), 1000); + + // The take's analyser was made when the take stopped, with a + // waveform of its own, and that is faded too + Analyser *a2 = m_window->analyser2(); + QVERIFY(a2 && a2->getLayer(Analyser::Audio)); + QCOMPARE(waveformColour(a2), QString("Pale Grey")); + QCOMPARE(waveformColour(m_window->analyser()), QString("Pale Grey")); + } + + // The waveform is faded while the lyrics are on show over it, and + // only then. It is no setting of the user's: nothing goes into the + // settings, as Analyser::setVisible() and setAudible() would put it, + // no command is made, and the fade marks nothing modified + void lyrics_fade_the_waveform() { + makeWindow(FakeAudioIO::Config()); + openReference(writeWav(tone(lowHz, 1.0))); + if (QTest::currentTestFailed()) return; + Analyser *a = m_window->analyser(); + QCOMPARE(waveformColour(a), QString("Grey")); + + // Values the waveform does not have, which a write of its state to + // the settings would put right + QSettings settings; + settings.beginGroup("Analyser"); + settings.setValue(QString("visible-%1").arg(int(Analyser::Audio)), + !a->isVisible(Analyser::Audio)); + settings.setValue(QString("audible-%1").arg(int(Analyser::Audio)), + !a->isAudible(Analyser::Audio)); + settings.endGroup(); + settings.sync(); + auto before = allSettings(); + auto *history = sv::CommandHistory::getInstance(); + QSignalSpy commands(history, qOverload<> + (&sv::CommandHistory::commandExecuted)); + + QVERIFY(m_window->doImportLyricsFrom + (lyricsFixture("moises-exporter-words.lrc"))); + QCOMPARE(waveformColour(a), QString("Pale Grey")); + + QAction *show = m_window->showLyricsAction(); + show->trigger(); + QVERIFY(!m_window->lyrics()->isVisible()); + QCOMPARE(waveformColour(a), QString("Grey")); + show->trigger(); + QVERIFY(m_window->lyrics()->isVisible()); + QCOMPARE(waveformColour(a), QString("Pale Grey")); + + m_window->removeLyricsAction()->trigger(); + QVERIFY(!m_window->lyrics()->isShown()); + QCOMPARE(waveformColour(a), QString("Grey")); + + QCOMPARE(int(commands.count()), 0); + QStringList changed = settingsChanged(before, allSettings()); + QVERIFY2(changed.isEmpty(), + qPrintable("changed in the settings: " + changed.join(", "))); + + // The import, Show Lyrics and Remove Lyrics change the session; + // the fade on its own does not + m_window->discardModifications(); + a->setWaveformFaded(true); + QCOMPARE(waveformColour(a), QString("Pale Grey")); + a->setWaveformFaded(false); + QCOMPARE(waveformColour(a), QString("Grey")); + QVERIFY(!m_window->isDocumentModified()); + QCOMPARE(int(commands.count()), 0); + } + + // The session saves the waveform's colour with its layer, faded or + // not. Opened, it is faded under lyrics on show and grey otherwise, + // whatever it was saved as, and the word at the cursor is lit at once + void lyrics_fade_survives_a_session() { + makeWindow(FakeAudioIO::Config()); + openReference(writeWav(tone(lowHz, 1.0))); + if (QTest::currentTestFailed()) return; + + // Saved faded with no lyrics, as a session whose lyrics have gone + // since would be + m_window->analyser()->setWaveformFaded(true); + QString fadedWithout = m_dir.filePath("faded-without-lyrics.ton"); + QVERIFY(m_window->saveSessionFile(fadedWithout)); + m_window->analyser()->setWaveformFaded(false); + + QVERIFY(m_window->doImportLyricsFrom + (lyricsFixture("moises-exporter-words.lrc"))); + m_window->seekTo(sv::sv_frame_t(0.5 * rate)); + QTRY_COMPARE_WITH_TIMEOUT(highlightedWord(), QString("nollaa"), 1000); + QString shown = m_dir.filePath("lyrics-shown-faded.ton"); + QVERIFY(m_window->saveSessionFile(shown)); + m_window->showLyricsAction()->trigger(); + QCOMPARE(waveformColour(m_window->analyser()), QString("Grey")); + QString hidden = m_dir.filePath("lyrics-hidden-grey.ton"); + QVERIFY(m_window->saveSessionFile(hidden)); + + reopenSession(shown); + if (QTest::currentTestFailed()) return; + QVERIFY(m_window->lyrics()->isVisible()); + QCOMPARE(waveformColour(m_window->analyser()), QString("Pale Grey")); + sv::RegionLayer *layer = m_window->lyrics()->getLayer(); + QCOMPARE(layer->getHighlightFrame(), m_window->playbackFrame()); + + reopenSession(hidden); + if (QTest::currentTestFailed()) return; + QVERIFY(m_window->lyrics()->isShown()); + QVERIFY(!m_window->lyrics()->isVisible()); + QCOMPARE(waveformColour(m_window->analyser()), QString("Grey")); + + // Opened after one with lyrics on show, and saved faded itself. + // The close in between takes the fade away: the analyser of the + // reference stays for the next file + reopenSession(shown); + if (QTest::currentTestFailed()) return; + QCOMPARE(waveformColour(m_window->analyser()), QString("Pale Grey")); + QVERIFY(m_window->analyser()->isWaveformFaded()); + m_window->doCloseSession(); + QVERIFY2(!m_window->analyser()->isWaveformFaded(), + "the fade outlived the session"); + openReference(fadedWithout); + if (QTest::currentTestFailed()) return; + QVERIFY(!m_window->lyrics()->isShown()); + QCOMPARE(waveformColour(m_window->analyser()), QString("Grey")); + } + + // Without Edit Lyrics the mouse is the pane's everywhere: drags from + // either side of the shared edge move no word + void lyrics_edit_off_leaves_words_alone() { + makeWindow(FakeAudioIO::Config()); + showEditableLyrics(); + if (QTest::currentTestFailed()) return; + sv::EventVector before = lyricsEvents(); + + QAction *edit = m_window->editLyricsAction(); + QVERIFY(edit->isEnabled()); + QVERIFY(!edit->isChecked()); + QVERIFY(!m_window->lyricsEditor()->isEnabled()); + + // The pane's navigate drags, which move the view + int edge = columnOf(lyricsWord("kaksi").getFrame()); + dragFromTo(inRow(edge - 1), inRow(edge - 30)); + QVERIFY2(columnOf(lyricsWord("kaksi").getFrame()) != edge, + "the pane did not get the drag"); + edge = columnOf(lyricsWord("kaksi").getFrame()); + dragFromTo(inRow(edge), inRow(edge + 30)); + QCOMPARE(lyricsEvents(), before); + QVERIFY(!m_window->isDocumentModified()); + QCOMPARE(undoOnce(), QString()); + + // and a double-click on a word asks nothing. The pane's own may + // open the edit dialog of the pitch point there, which the + // watchdog closes + doubleClickAt(inRow(columnOf(lyricsWord("kaksi").getFrame()) + 40)); + takeDialogs(); + QCOMPARE(m_window->wordTextQuestions(), 0); + QCOMPARE(lyricsEvents(), before); + } + + // Yksi's end and kaksi's start are one edge on screen. The column + // left of it is Yksi's, the one right of it kaksi's, and the side the + // pointer is on picks the one word that moves (decision 3) + void lyrics_edit_shared_edge_each_side() { + lyricsEditFixture(); + if (QTest::currentTestFailed()) return; + sv::Event yksi = lyricsWord("Yksi"); + sv::Event kaksi = lyricsWord("kaksi"); + QCOMPARE(endOf(yksi), kaksi.getFrame()); + int edge = columnOf(kaksi.getFrame()); + + dragFromTo(inRow(edge - 1), inRow(edge - 21)); + sv::Event moved = lyricsWord("Yksi"); + QCOMPARE(endOf(moved), endOf(yksi) + framesBetween(edge - 1, edge - 21)); + QCOMPARE(moved.getFrame(), yksi.getFrame()); + QCOMPARE(moved.getValue(), yksi.getValue()); + QCOMPARE(moved.getLabel(), yksi.getLabel()); + QCOMPARE(lyricsWord("kaksi"), kaksi); + QCOMPARE(int(lyricsEvents().size()), 3); + QCOMPARE(undoOnce(), QString("Move Word End")); + QCOMPARE(lyricsWord("Yksi"), yksi); + + dragFromTo(inRow(edge), inRow(edge + 20)); + moved = lyricsWord("kaksi"); + QCOMPARE(moved.getFrame(), kaksi.getFrame() + framesBetween(edge, edge + 20)); + QCOMPARE(endOf(moved), endOf(kaksi)); + QCOMPARE(moved.getValue(), kaksi.getValue()); + QCOMPARE(lyricsWord("Yksi"), yksi); + QCOMPARE(int(lyricsEvents().size()), 3); + QCOMPARE(undoOnce(), QString("Move Word Start")); + QCOMPARE(lyricsWord("kaksi"), kaksi); + } + + // An edge stops at the neighbouring word and 20 ms from the word's + // other edge, however far the pointer goes (decision 6) + void lyrics_edit_clamps() { + lyricsEditFixture(); + if (QTest::currentTestFailed()) return; + sv::Event yksi = lyricsWord("Yksi"); + sv::Event kaksi = lyricsWord("kaksi"); + sv::Event kolme = lyricsWord("kolme"); + sv::EventVector before = lyricsEvents(); + + // kaksi's end, over the gap and past the whole of kolme + int kaksiEnd = columnOf(endOf(kaksi)) - 1; + dragFromTo(inRow(kaksiEnd), inRow(columnOf(endOf(kolme)) + 30)); + QCOMPARE(endOf(lyricsWord("kaksi")), kolme.getFrame()); + QCOMPARE(lyricsWord("kolme"), kolme); + QCOMPARE(undoOnce(), QString("Move Word End")); + + // kolme's start, back over the gap and past Yksi + int kolmeStart = columnOf(kolme.getFrame()); + dragFromTo(inRow(kolmeStart), inRow(columnOf(yksi.getFrame()) - 30)); + QCOMPARE(lyricsWord("kolme").getFrame(), endOf(kaksi)); + QCOMPARE(endOf(lyricsWord("kolme")), endOf(kolme)); + QCOMPARE(lyricsWord("kaksi"), kaksi); + QCOMPARE(undoOnce(), QString("Move Word Start")); + + // Yksi's end, back past its own start: 20 ms after it + int yksiEnd = columnOf(endOf(yksi)) - 1; + dragFromTo(inRow(yksiEnd), inRow(columnOf(yksi.getFrame()) - 30)); + QCOMPARE(endOf(lyricsWord("Yksi")), + yksi.getFrame() + LyricsEdit::minWordFrames(rate)); + QCOMPARE(lyricsWord("Yksi").getFrame(), yksi.getFrame()); + QCOMPARE(undoOnce(), QString("Move Word End")); + + // kaksi's start, on past its own end + int kaksiStart = columnOf(kaksi.getFrame()); + dragFromTo(inRow(kaksiStart), inRow(columnOf(endOf(kaksi)) + 30)); + QCOMPARE(lyricsWord("kaksi").getFrame(), + endOf(kaksi) - LyricsEdit::minWordFrames(rate)); + QCOMPARE(undoOnce(), QString("Move Word Start")); + + // Nothing after the last word: kolme's end goes where it is taken + int kolmeEnd = columnOf(endOf(kolme)) - 1; + dragFromTo(inRow(kolmeEnd), inRow(kolmeEnd + 40)); + QCOMPARE(endOf(lyricsWord("kolme")), + endOf(kolme) + framesBetween(kolmeEnd, kolmeEnd + 40)); + QCOMPARE(undoOnce(), QString("Move Word End")); + + // and a drag past the neighbour and back leaves the edge where the + // pointer is: the limit is not where the drag got to + pressAt(inRow(kaksiEnd)); + moveHeldTo(inRow(columnOf(endOf(kolme)) + 30)); + QCOMPARE(endOf(lyricsWord("kaksi")), kolme.getFrame()); + moveHeldTo(inRow(kaksiEnd + 20)); + releaseAt(inRow(kaksiEnd + 20)); + QCOMPARE(endOf(lyricsWord("kaksi")), + endOf(kaksi) + framesBetween(kaksiEnd, kaksiEnd + 20)); + QCOMPARE(undoOnce(), QString("Move Word End")); + QCOMPARE(lyricsEvents(), before); + QCOMPARE(undoOnce(), QString()); + } + + // A drag edits the words as it goes and is one step on the history + // when let go (decision 12); a click, or a drag back to where it + // began, is none + void lyrics_edit_one_step_per_drag() { + lyricsEditFixture(); + if (QTest::currentTestFailed()) return; + LyricsEditor *editor = m_window->lyricsEditor(); + auto *history = sv::CommandHistory::getInstance(); + QSignalSpy commands(history, qOverload<> + (&sv::CommandHistory::commandExecuted)); + sv::EventVector before = lyricsEvents(); + sv::Event kaksi = lyricsWord("kaksi"); + int end = columnOf(endOf(kaksi)) - 1; + + pressAt(inRow(end)); + QVERIFY(editor->isDragging()); + releaseAt(inRow(end)); + QVERIFY(!editor->isDragging()); + + pressAt(inRow(end)); + for (int x = end; x <= end + 40; x += 4) moveHeldTo(inRow(x)); + QVERIFY(endOf(lyricsWord("kaksi")) > endOf(kaksi)); + for (int x = end + 40; x >= end; x -= 4) moveHeldTo(inRow(x)); + releaseAt(inRow(end)); + QCOMPARE(lyricsEvents(), before); + QCOMPARE(int(commands.count()), 0); + QVERIFY(!m_window->isDocumentModified()); + QCOMPARE(undoOnce(), QString()); + + // Many moves, each one seen in the model at once, and nothing on + // the history until the button is let go + pressAt(inRow(end)); + for (int x = end + 2; x <= end + 60; x += 2) { + moveHeldTo(inRow(x)); + QCOMPARE(endOf(lyricsWord("kaksi")), + endOf(kaksi) + framesBetween(end, x)); + } + QCOMPARE(int(commands.count()), 0); + releaseAt(inRow(end + 60)); + QCOMPARE(int(commands.count()), 1); + QVERIFY(m_window->isDocumentModified()); + sv::EventVector afterFirst = lyricsEvents(); + + int yksiStart = columnOf(lyricsWord("Yksi").getFrame()); + dragFromTo(inRow(yksiStart), inRow(yksiStart + 30)); + QCOMPARE(int(commands.count()), 2); + sv::EventVector afterSecond = lyricsEvents(); + QVERIFY(afterSecond != afterFirst); + + QCOMPARE(undoOnce(), QString("Move Word Start")); + QCOMPARE(lyricsEvents(), afterFirst); + QCOMPARE(undoOnce(), QString("Move Word End")); + QCOMPARE(lyricsEvents(), before); + QCOMPARE(undoOnce(), QString()); + QCOMPARE(redoOnce(), QString("Move Word End")); + QCOMPARE(lyricsEvents(), afterFirst); + QCOMPARE(redoOnce(), QString("Move Word Start")); + QCOMPARE(lyricsEvents(), afterSecond); + QCOMPARE(redoOnce(), QString()); + + // Still the lyrics' own model, out of the play source + QVERIFY(m_window->playSource()->getModels().count + (m_window->lyrics()->getModelId()) == 0); + verifyPlaySourceClean(); + } + + // An edit is a change to the session, and the session keeps it + void lyrics_edit_saved_with_the_session() { + lyricsEditFixture(); + if (QTest::currentTestFailed()) return; + sv::EventVector before = lyricsEvents(); + + int end = columnOf(endOf(lyricsWord("kaksi"))) - 1; + dragFromTo(inRow(end), inRow(end + 50)); + int start = columnOf(lyricsWord("kolme").getFrame()); + dragFromTo(inRow(start), inRow(start + 25)); + QVERIFY(m_window->isDocumentModified()); + sv::EventVector edited = lyricsEvents(); + QVERIFY(edited != before); + + QString session = m_dir.filePath("lyrics-edited.ton"); + QVERIFY(m_window->saveSessionFile(session)); + reopenSession(session); + if (QTest::currentTestFailed()) return; + + QVERIFY(m_window->lyrics()->isShown()); + verifyEventsSurvived(edited, lyricsEvents(), "the edited lyrics"); + if (QTest::currentTestFailed()) return; + QCOMPARE(lyricsWord("kaksi").getLabel(), QString("kaksi")); + + // Edit mode is not part of the session + QVERIFY(!m_window->lyricsEditor()->isEnabled()); + QVERIFY(m_window->editLyricsAction()->isEnabled()); + QVERIFY(!m_window->editLyricsAction()->isChecked()); + QVERIFY(m_window->playSource()->getModels().count + (m_window->lyrics()->getModelId()) == 0); + verifyPlaySourceClean(); + } + + // The word at the cursor is found again as its edge moves, while the + // button is held as well + void lyrics_edit_highlight_follows() { + lyricsEditFixture(); + if (QTest::currentTestFailed()) return; + sv::Event kaksi = lyricsWord("kaksi"); + sv::Event kolme = lyricsWord("kolme"); + sv::sv_frame_t gap = (endOf(kaksi) + kolme.getFrame()) / 2; + m_window->seekTo(gap); + QTRY_COMPARE_WITH_TIMEOUT(highlightedWord(), QString(), 1000); + + int end = columnOf(endOf(kaksi)) - 1; + int past = columnOf(gap) + 10; + pressAt(inRow(end)); + moveHeldTo(inRow(past)); + QTRY_COMPARE_WITH_TIMEOUT(highlightedWord(), QString("kaksi"), 1000); + releaseAt(inRow(past)); + QCOMPARE(highlightedWord(), QString("kaksi")); + + QCOMPARE(undoOnce(), QString("Move Word End")); + QTRY_COMPARE_WITH_TIMEOUT(highlightedWord(), QString(), 1000); + + int start = columnOf(kolme.getFrame()); + dragFromTo(inRow(start), inRow(columnOf(gap) - 10)); + QTRY_COMPARE_WITH_TIMEOUT(highlightedWord(), QString("kolme"), 1000); + } + + // Nothing to edit: the action is there, disabled, and its trigger + // does nothing + void lyrics_edit_needs_lyrics() { + makeWindow(FakeAudioIO::Config()); + QAction *edit = m_window->editLyricsAction(); + QVERIFY(!edit->isEnabled()); + openReference(writeWav(tone(lowHz, 2.0))); + if (QTest::currentTestFailed()) return; + QVERIFY(!edit->isEnabled()); + QVERIFY(!edit->isChecked()); + edit->trigger(); + QVERIFY(!edit->isChecked()); + QVERIFY(!m_window->lyricsEditor()->isEnabled()); + + QVERIFY(m_window->doImportLyricsFrom(writeLrc(gappedLyrics()))); + QVERIFY(edit->isEnabled()); + QVERIFY(!edit->isChecked()); + QVERIFY(!m_window->lyricsEditor()->isEnabled()); + } + + // Turned off, and not to be had, with the lyrics hidden; shown again, + // it is to be had but stays off, and the mouse is the pane's + void lyrics_edit_off_when_hidden() { + lyricsEditFixture(); + if (QTest::currentTestFailed()) return; + QAction *edit = m_window->editLyricsAction(); + QAction *show = m_window->showLyricsAction(); + sv::EventVector before = lyricsEvents(); + + show->trigger(); + QVERIFY(!m_window->lyrics()->isVisible()); + QVERIFY(!m_window->lyricsEditor()->isEnabled()); + QVERIFY(!edit->isEnabled()); + QVERIFY(!edit->isChecked()); + + show->trigger(); + QVERIFY(m_window->lyrics()->isVisible()); + QVERIFY(edit->isEnabled()); + QVERIFY(!edit->isChecked()); + QVERIFY(!m_window->lyricsEditor()->isEnabled()); + dragIsThePanes(before); + } + + void lyrics_edit_off_on_remove() { + lyricsEditFixture(); + if (QTest::currentTestFailed()) return; + QAction *edit = m_window->editLyricsAction(); + + m_window->removeLyricsAction()->trigger(); + QVERIFY(!m_window->lyrics()->isShown()); + QVERIFY(!m_window->lyricsEditor()->isEnabled()); + QVERIFY(!edit->isEnabled()); + QVERIFY(!edit->isChecked()); + } + + // New lyrics are not what edit mode was switched on for + void lyrics_edit_off_on_import() { + lyricsEditFixture(); + if (QTest::currentTestFailed()) return; + QAction *edit = m_window->editLyricsAction(); + + QVERIFY(m_window->doImportLyricsFrom(writeLrc(gappedLyrics()))); + QVERIFY(!m_window->lyricsEditor()->isEnabled()); + QVERIFY(edit->isEnabled()); + QVERIFY(!edit->isChecked()); + m_row = lyricsBoxRow(); + dragIsThePanes(lyricsEvents()); + } + + // Off with the session, and off in the next one until switched on, + // which then edits in the new pane 0 + void lyrics_edit_off_on_close() { + lyricsEditFixture(); + if (QTest::currentTestFailed()) return; + QAction *edit = m_window->editLyricsAction(); + + m_window->doCloseSession(); + QVERIFY(!m_window->lyricsEditor()->isEnabled()); + QVERIFY(!edit->isEnabled()); + QVERIFY(!edit->isChecked()); + + showEditableLyrics(); + if (QTest::currentTestFailed()) return; + QVERIFY(!m_window->lyricsEditor()->isEnabled()); + QVERIFY(!edit->isChecked()); + sv::EventVector before = lyricsEvents(); + dragIsThePanes(before); + if (QTest::currentTestFailed()) return; + + switchLyricsEditingOn(); + int edge = columnOf(lyricsWord("kaksi").getFrame()); + dragFromTo(inRow(edge), inRow(edge + 30)); + QCOMPARE(lyricsWord("kaksi").getFrame(), + before[1].getFrame() + framesBetween(edge, edge + 30)); + } + + // Off while a take is recorded, when the singer is reading the words. + // A drag going on when the take starts is finished first, and so is + // on the history before the take + void lyrics_edit_off_while_recording() { + FakeAudioIO::Config config; + config.input = tone(highHz, 3.0); + lyricsEditFixture(config); + if (QTest::currentTestFailed()) return; + QAction *edit = m_window->editLyricsAction(); + LyricsEditor *editor = m_window->lyricsEditor(); + sv::EventVector before = lyricsEvents(); + + int end = columnOf(endOf(lyricsWord("kaksi"))) - 1; + pressAt(inRow(end)); + moveHeldTo(inRow(end + 30)); + sv::EventVector dragged = lyricsEvents(); + QVERIFY(dragged != before); + + startTake(); + if (QTest::currentTestFailed()) return; + QVERIFY(!editor->isEnabled()); + QVERIFY(!editor->isDragging()); + QVERIFY(!edit->isEnabled()); + QVERIFY(!edit->isChecked()); + + // The rest of that drag moves nothing + moveHeldTo(inRow(end + 60)); + releaseAt(inRow(end + 60)); + QCOMPARE(lyricsEvents(), dragged); + + QTest::qWait(300); + stopTake(); + if (QTest::currentTestFailed()) return; + QTRY_VERIFY_WITH_TIMEOUT(edit->isEnabled(), 2000); + QVERIFY(!edit->isChecked()); + QVERIFY(!editor->isEnabled()); + + QCOMPARE(undoOnce(), QString("Record Singing")); + QCOMPARE(undoOnce(), QString("Move Word End")); + QCOMPARE(lyricsEvents(), before); + } + + // Edit mode switched off in the middle of a drag: the drag ends as a + // release would end it, and the rest of it is not an edit + void lyrics_edit_off_in_the_middle_of_a_drag() { + lyricsEditFixture(); + if (QTest::currentTestFailed()) return; + LyricsEditor *editor = m_window->lyricsEditor(); + sv::EventVector before = lyricsEvents(); + + int end = columnOf(endOf(lyricsWord("kaksi"))) - 1; + pressAt(inRow(end)); + moveHeldTo(inRow(end + 30)); + sv::EventVector dragged = lyricsEvents(); + QVERIFY(dragged != before); + + m_window->editLyricsAction()->trigger(); + QVERIFY(!editor->isEnabled()); + QVERIFY(!editor->isDragging()); + QVERIFY(!m_window->editLyricsAction()->isChecked()); + QVERIFY(m_window->isDocumentModified()); + + moveHeldTo(inRow(end + 60)); + releaseAt(inRow(end + 60)); + QCOMPARE(lyricsEvents(), dragged); + + QCOMPARE(undoOnce(), QString("Move Word End")); + QCOMPARE(lyricsEvents(), before); + QCOMPARE(undoOnce(), QString()); + } + + // The words change under a drag: removed, or an undo (Ctrl+Z with the + // button held) takes away the word being dragged. The drag ends, the + // model is left as that made it, and nothing goes on the history + void lyrics_edit_words_change_under_a_drag() { + lyricsEditFixture(); + if (QTest::currentTestFailed()) return; + LyricsEditor *editor = m_window->lyricsEditor(); + sv::EventVector before = lyricsEvents(); + sv::Event yksi = lyricsWord("Yksi"); + + // An earlier drag of Yksi's end, undone after a press on that end + int end = columnOf(endOf(yksi)) - 1; + dragFromTo(inRow(end), inRow(end - 20)); + sv::EventVector moved = lyricsEvents(); + end = columnOf(endOf(lyricsWord("Yksi"))) - 1; + pressAt(inRow(end)); + QVERIFY(editor->isDragging()); + QCOMPARE(undoOnce(), QString("Move Word End")); + QCOMPARE(lyricsEvents(), before); + moveHeldTo(inRow(end - 10)); + releaseAt(inRow(end - 10)); + QVERIFY(!editor->isDragging()); + QCOMPARE(lyricsEvents(), before); + + // Nothing was pushed over the undone drag, which can be redone + QCOMPARE(redoOnce(), QString("Move Word End")); + QCOMPARE(lyricsEvents(), moved); + QCOMPARE(redoOnce(), QString()); + + // Removed in the middle of a drag + int start = columnOf(lyricsWord("kolme").getFrame()); + pressAt(inRow(start)); + moveHeldTo(inRow(start + 20)); + m_window->removeLyricsAction()->trigger(); + QVERIFY(!editor->isDragging()); + QVERIFY(!editor->isEnabled()); + moveHeldTo(inRow(start + 40)); + releaseAt(inRow(start + 40)); + + // On top of the history is still the redone drag of Yksi, whose + // model has gone with the lyrics (and which does nothing now), not + // a Move Word Start + QCOMPARE(undoOnce(), QString("Move Word End")); + QVERIFY(!m_window->lyrics()->isShown()); + verifyPlaySourceClean(); + } + + // The resize cursor over an edge, from either side of a shared one, + // and the pane's own back wherever else the pointer goes; the status + // bar says what the mouse does in the row + void lyrics_edit_cursor_and_help() { + lyricsEditFixture(); + if (QTest::currentTestFailed()) return; + sv::Pane *pane = pane0(); + Qt::CursorShape own = pane->cursor().shape(); + QVERIFY(own != Qt::SizeHorCursor); + int edge = columnOf(lyricsWord("kaksi").getFrame()); + QPoint above(edge + 40, m_row.top() - 8); + + hoverAt(inRow(edge - 1)); + QCOMPARE(pane->cursor().shape(), Qt::SizeHorCursor); + QCOMPARE(m_window->statusText(), + QString("Drag to move the end of \"Yksi\", " + "Shift-drag to move all the words")); + hoverAt(inRow(edge + 2)); + QCOMPARE(pane->cursor().shape(), Qt::SizeHorCursor); + QCOMPARE(m_window->statusText(), + QString("Drag to move the start of \"kaksi\", " + "Shift-drag to move all the words")); + + hoverAt(inRow(edge + 40)); + QCOMPARE(pane->cursor().shape(), own); + QCOMPARE(m_window->statusText(), + QString("Double-click to change the text of \"kaksi\", " + "right-click to delete it, " + "Shift-drag to move all the words")); + int gap = columnOf(endOf(lyricsWord("kaksi"))) + 20; + hoverAt(inRow(gap)); + QCOMPARE(pane->cursor().shape(), own); + QCOMPARE(m_window->statusText(), + QString("Right-click to add a word, " + "drag a word's start or end to move it, " + "Shift-drag to move all the words")); + + // A closed hand through a drag of all the words (decision 19), + // from a word or from an edge, and the pane's own or the edge's + // back after + shiftPressAt(inRow(edge + 40)); + QCOMPARE(pane->cursor().shape(), Qt::ClosedHandCursor); + shiftMoveHeldTo(inRow(edge + 50)); + QCOMPARE(pane->cursor().shape(), Qt::ClosedHandCursor); + shiftReleaseAt(inRow(edge + 50)); + QCOMPARE(pane->cursor().shape(), own); + QCOMPARE(undoOnce(), QString("Shift Lyrics")); + edge = columnOf(lyricsWord("kaksi").getFrame()); + shiftPressAt(inRow(edge)); + QCOMPARE(pane->cursor().shape(), Qt::ClosedHandCursor); + shiftReleaseAt(inRow(edge)); + QCOMPARE(pane->cursor().shape(), Qt::SizeHorCursor); + hoverAt(inRow(gap)); + QCOMPARE(pane->cursor().shape(), own); + + hoverAt(inRow(edge)); + QCOMPARE(pane->cursor().shape(), Qt::SizeHorCursor); + hoverAt(above); + QCOMPARE(pane->cursor().shape(), own); + QVERIFY2(!m_window->statusText().startsWith("Drag"), + qPrintable(m_window->statusText())); + + // Kept through a drag that leaves the row, and given back after + pressAt(inRow(edge)); + moveHeldTo(above); + QCOMPARE(pane->cursor().shape(), Qt::SizeHorCursor); + releaseAt(above); + QCOMPARE(pane->cursor().shape(), own); + QCOMPARE(undoOnce(), QString("Move Word Start")); + + // Given back when edit mode goes off over an edge, with the help + hoverAt(inRow(edge)); + QCOMPARE(pane->cursor().shape(), Qt::SizeHorCursor); + m_window->editLyricsAction()->trigger(); + QCOMPARE(pane->cursor().shape(), own); + QVERIFY2(!m_window->statusText().startsWith("Drag"), + qPrintable(m_window->statusText())); + hoverAt(inRow(edge - 1)); + QCOMPARE(pane->cursor().shape(), own); + } + + // Everything the editor does not act on is the pane's: a click in a + // word moves the playback cursor as before, a click on an edge does + // not. A double-click on an edge is a press there + void lyrics_edit_clicks_elsewhere_are_the_panes() { + lyricsEditFixture(); + if (QTest::currentTestFailed()) return; + sv::Event kolme = lyricsWord("kolme"); + int edge = columnOf(kolme.getFrame()); + int inside = edge + 50; + sv::sv_frame_t insideFrame = pane0()->getFrameForX(inside); + sv::sv_frame_t start = m_window->playbackFrame(); + QVERIFY(std::abs(start - insideFrame) > 10000); + + // The pane moves the cursor a double-click interval after a + // click, unless a second press comes first and takes its place: + // so the edge's click has its time to show it did nothing + pressAt(inRow(edge)); + releaseAt(inRow(edge)); + QTest::qWait(QApplication::doubleClickInterval() + 200); + QCOMPARE(m_window->playbackFrame(), start); + + pressAt(inRow(inside)); + releaseAt(inRow(inside)); + QTRY_VERIFY_WITH_TIMEOUT + (std::abs(m_window->playbackFrame() - insideFrame) < 1000, 3000); + QCOMPARE(lyricsWord("kolme"), kolme); + QCOMPARE(undoOnce(), QString()); + + pressAt(inRow(edge)); + releaseAt(inRow(edge)); + sendMouse(QEvent::MouseButtonDblClick, inRow(edge), + Qt::LeftButton, Qt::LeftButton); + moveHeldTo(inRow(edge + 20)); + releaseAt(inRow(edge + 20)); + QCOMPARE(lyricsWord("kolme").getFrame(), + kolme.getFrame() + framesBetween(edge, edge + 20)); + QCOMPARE(m_window->wordTextQuestions(), 0); + QCOMPARE(undoOnce(), QString("Move Word Start")); + QCOMPARE(undoOnce(), QString()); + } + + // A double-click on a word, away from its edges, asks for its text + // and changes it, and nothing else of the word: one step on the + // history (decisions 8, 12). A double-click between words is the + // pane's, and asks nothing + void lyrics_edit_text_by_double_click() { + lyricsEditFixture(); + if (QTest::currentTestFailed()) return; + QSignalSpy commands(sv::CommandHistory::getInstance(), qOverload<> + (&sv::CommandHistory::commandExecuted)); + sv::EventVector before = lyricsEvents(); + sv::Event kaksi = lyricsWord("kaksi"); + int inside = columnOf(kaksi.getFrame()) + 40; + + m_window->answerWordText("kaksikko"); + doubleClickAt(inRow(inside)); + QCOMPARE(m_window->wordTextQuestions(), 1); + QCOMPARE(m_window->wordTextOffered(), QString("kaksi")); + QVERIFY(!m_window->wordTextWasNew()); + QCOMPARE(lyricsWord("kaksikko"), kaksi.withLabel("kaksikko")); + QCOMPARE(lyricsWord("kaksi").getFrame(), sv::sv_frame_t(-1)); + QCOMPARE(int(lyricsEvents().size()), 3); + QCOMPARE(int(commands.count()), 1); + QVERIFY(m_window->isDocumentModified()); + sv::EventVector changed = lyricsEvents(); + + QCOMPARE(undoOnce(), QString("Change Word Text")); + QCOMPARE(lyricsEvents(), before); + QCOMPARE(undoOnce(), QString()); + QCOMPARE(redoOnce(), QString("Change Word Text")); + QCOMPARE(lyricsEvents(), changed); + + // The menu's Edit Word Text... is the same edit + m_window->discardModifications(); + m_window->answerWordText("kaksi"); + QVERIFY(chooseAt(inRow(inside), "Edit Word Text...")); + QCOMPARE(m_window->wordTextQuestions(), 2); + QCOMPARE(m_window->wordTextOffered(), QString("kaksikko")); + QCOMPARE(lyricsEvents(), before); + QVERIFY(m_window->isDocumentModified()); + QCOMPARE(undoOnce(), QString("Change Word Text")); + QCOMPARE(lyricsEvents(), changed); + + // Last, as the pane's double-click moves the view. It may open + // the edit dialog of the pitch point there, which the watchdog + // closes + int gap = columnOf(endOf(kaksi)) + 20; + doubleClickAt(inRow(gap)); + takeDialogs(); + QCOMPARE(m_window->wordTextQuestions(), 2); + QCOMPARE(lyricsEvents(), changed); + } + + // Cancelled, nothing left of the text once cleaned, or the same text: + // the word stays as it was, and nothing goes on the history + // (decision 11). A new word likewise is not added + void lyrics_edit_text_refused() { + lyricsEditFixture(); + if (QTest::currentTestFailed()) return; + QSignalSpy commands(sv::CommandHistory::getInstance(), qOverload<> + (&sv::CommandHistory::commandExecuted)); + sv::EventVector before = lyricsEvents(); + int inside = columnOf(lyricsWord("kaksi").getFrame()) + 40; + + m_window->cancelWordText(); + m_window->answerWordText(""); + m_window->answerWordText(" "); + m_window->answerWordText(QString("\t") + QChar(0x07) + " \r\n"); + m_window->answerWordText(" kaksi\t"); + for (int i = 1; i <= 5; ++i) { + doubleClickAt(inRow(inside)); + QCOMPARE(m_window->wordTextQuestions(), i); + QCOMPARE(lyricsEvents(), before); + } + + m_window->cancelWordText(); + QVERIFY(chooseAt(inRow(inside), "Edit Word Text...")); + QCOMPARE(m_window->wordTextQuestions(), 6); + + int gap = columnOf(endOf(lyricsWord("kaksi"))) + 20; + m_window->cancelWordText(); + m_window->answerWordText(QString(" ") + QChar(0x7F) + "\t"); + QVERIFY(chooseAt(inRow(gap), "Add Word...")); + QVERIFY(chooseAt(inRow(gap), "Add Word...")); + QCOMPARE(m_window->wordTextQuestions(), 8); + QVERIFY(m_window->wordTextWasNew()); + QCOMPARE(m_window->wordTextOffered(), QString()); + + QCOMPARE(lyricsEvents(), before); + QCOMPARE(int(commands.count()), 0); + QVERIFY(!m_window->isDocumentModified()); + QCOMPARE(undoOnce(), QString()); + } + + // The text is cleaned as the parsers clean a label: control + // characters out, a tab a space, blanks at the ends trimmed, at most + // 200 characters (decision 11) + void lyrics_edit_text_cleaned() { + lyricsEditFixture(); + if (QTest::currentTestFailed()) return; + sv::Event kaksi = lyricsWord("kaksi"); + int inside = columnOf(kaksi.getFrame()) + 40; + + m_window->answerWordText(QString(" kak") + QChar(0x07) + + "si\tvaan \r\n"); + doubleClickAt(inRow(inside)); + QCOMPARE(lyricsWord("kaksi vaan"), kaksi.withLabel("kaksi vaan")); + + m_window->answerWordText(QString(250, QChar('a'))); + doubleClickAt(inRow(inside)); + QCOMPARE(lyricsWord(QString(200, QChar('a'))), + kaksi.withLabel(QString(200, QChar('a')))); + + int gap = columnOf(endOf(kaksi)) + 20; + m_window->answerWordText(QString(" ja") + QChar(0x7F) + "\t"); + QVERIFY(chooseAt(inRow(gap), "Add Word...")); + QVERIFY(lyricsWord("ja").getFrame() >= 0); + QCOMPARE(int(lyricsEvents().size()), 4); + + QCOMPARE(undoOnce(), QString("Add Word")); + QCOMPARE(undoOnce(), QString("Change Word Text")); + QCOMPARE(undoOnce(), QString("Change Word Text")); + QCOMPARE(lyricsWord("kaksi"), kaksi); + } + + // The menu at a point: on a word, anywhere in its box, its edges as + // well, the word's two entries; between words Add Word..., disabled + // left of frame 0; outside the box row, or with edit mode off, none + // (decision 8) + void lyrics_edit_menu_entries() { + lyricsEditFixture(); + if (QTest::currentTestFailed()) return; + LyricsEditor *editor = m_window->lyricsEditor(); + sv::Event yksi = lyricsWord("Yksi"); + sv::Event kaksi = lyricsWord("kaksi"); + sv::Event kolme = lyricsWord("kolme"); + const QStringList onWord = { "Edit Word Text...", "Delete Word" }; + const QStringList add = { "Add Word..." }; + + int start = columnOf(kaksi.getFrame()); + int last = columnOf(endOf(kaksi)) - 1; + for (int x : { start, start + 2, start + 40, last - 2, last }) { + QCOMPARE(menuAt(inRow(x)), onWord); + auto entries = editor->menuEntriesAt(inRow(x)); + QCOMPARE(entries[0].word, kaksi); + QCOMPARE(entries[1].word, kaksi); + } + QCOMPARE(menuAt(inRow(start - 1)), onWord); + QCOMPARE(editor->menuEntriesAt(inRow(start - 1))[0].word, yksi); + + // Between words. The first column after kaksi's box shows the + // end of kaksi too, and where it starts is inside kaksi: a new word + // goes after it all the same + int after = columnOf(endOf(kaksi)); + int before = columnOf(kolme.getFrame()) - 1; + QVERIFY2(pane0()->getFrameForX(after) < endOf(kaksi), + "the column after kaksi starts after kaksi's end: " + "the case of this test is not set up"); + for (int x : { after, after + 2, (after + before) / 2, before }) { + QCOMPARE(menuAt(inRow(x)), add); + } + QCOMPARE(menuAt(inRow(columnOf(endOf(kolme)) + 50)), add); + QCOMPARE(menuAt(inRow(columnOf(0) - 5)), QStringList{ "-Add Word..." }); + + QVERIFY(menuAt(QPoint(start + 40, m_row.top() - 3)).isEmpty()); + QVERIFY(menuAt(QPoint(start + 40, m_row.bottom() + 3)).isEmpty()); + m_window->editLyricsAction()->trigger(); + QVERIFY(!editor->isEnabled()); + QVERIFY(menuAt(inRow(start + 40)).isEmpty()); + QVERIFY(menuAt(inRow(after)).isEmpty()); + } + + // A right press in the box row pops the words' menu up, and the pane + // never sees it, so Tony's own menu stays shut; the entries of the + // menu shown do what they say. Outside the row, or with edit mode + // off, the press is the pane's, and opens Tony's menu + void lyrics_edit_right_press() { + lyricsEditFixture(); + if (QTest::currentTestFailed()) return; + QSignalSpy panes(pane0(), &sv::Pane::rightButtonMenuRequested); + sv::Event kolme = lyricsWord("kolme"); + int inside = columnOf(kolme.getFrame()) + 40; + + rightPressAt(inRow(inside)); + QCOMPARE(int(panes.count()), 0); + QMenu *menu = wordsMenu(); + QVERIFY2(menu, "the words' menu is not on show"); + QCOMPARE(actionTexts(menu), + (QStringList{ "Edit Word Text...", "Delete Word" })); + menu->actions()[1]->trigger(); + QCOMPARE(lyricsWord("kolme").getFrame(), sv::sv_frame_t(-1)); + QCOMPARE(int(lyricsEvents().size()), 2); + QCOMPARE(closeMenus(), 1); + QCOMPARE(undoOnce(), QString("Delete Word")); + QCOMPARE(lyricsWord("kolme"), kolme); + + int gap = columnOf(kolme.getFrame()) - 20; + rightPressAt(inRow(gap)); + QCOMPARE(int(panes.count()), 0); + menu = wordsMenu(); + QVERIFY2(menu, "the words' menu is not on show"); + QCOMPARE(actionTexts(menu), QStringList{ "Add Word..." }); + QVERIFY(menu->actions()[0]->isEnabled()); + m_window->answerWordText("ja"); + menu->actions()[0]->trigger(); + QCOMPARE(m_window->wordTextQuestions(), 1); + QVERIFY(lyricsWord("ja").getFrame() >= 0); + QCOMPARE(closeMenus(), 1); + QCOMPARE(undoOnce(), QString("Add Word")); + + // Above the row, where the pane's menu is + rightPressAt(QPoint(inside, m_row.top() - 8)); + QCOMPARE(int(panes.count()), 1); + QVERIFY(!wordsMenu()); + QCOMPARE(closeMenus(), 1); + + m_window->editLyricsAction()->trigger(); + QVERIFY(!m_window->lyricsEditor()->isEnabled()); + rightPressAt(inRow(inside)); + QCOMPARE(int(panes.count()), 2); + QVERIFY(!wordsMenu()); + QCOMPARE(closeMenus(), 1); + QCOMPARE(lyricsWord("kolme"), kolme); + } + + // Add Word... puts the word at the click: 0.5 s long, or up to the + // next word, back into the gap as far as that makes it longer + // (decision 9), and on the line of the nearer neighbour (decision 10) + void lyrics_edit_add_word() { + lyricsEditFixture(); + if (QTest::currentTestFailed()) return; + QSignalSpy commands(sv::CommandHistory::getInstance(), qOverload<> + (&sv::CommandHistory::commandExecuted)); + sv::EventVector before = lyricsEvents(); + sv::Event kaksi = lyricsWord("kaksi"); + sv::Event kolme = lyricsWord("kolme"); + QVERIFY(kaksi.getValue() != kolme.getValue()); + QCOMPARE(kolme.getFrame() - endOf(kaksi), LyricsEdit::newWordFrames(rate)); + + // The gap is 0.5 s, and the word fills it wherever the click is. + // Its neighbours are as near as each other, and the word before + // gives it its line + m_window->answerWordText("ja"); + QVERIFY(chooseAt(inRow(columnOf(kolme.getFrame()) - 20), "Add Word...")); + QCOMPARE(m_window->wordTextQuestions(), 1); + QVERIFY(m_window->wordTextWasNew()); + QCOMPARE(m_window->wordTextOffered(), QString()); + QCOMPARE(lyricsWord("ja"), + sv::Event(endOf(kaksi), kaksi.getValue(), + kolme.getFrame() - endOf(kaksi), "ja")); + QCOMPARE(int(lyricsEvents().size()), 4); + QCOMPARE(int(commands.count()), 1); + QVERIFY(m_window->isDocumentModified()); + sv::EventVector added = lyricsEvents(); + + QCOMPARE(undoOnce(), QString("Add Word")); + QCOMPARE(lyricsEvents(), before); + QCOMPARE(redoOnce(), QString("Add Word")); + QCOMPARE(lyricsEvents(), added); + QCOMPARE(undoOnce(), QString("Add Word")); + + // kaksi 0.2 s shorter, for a wider gap. Near kolme, the word is + // moved back from it to be 0.5 s long, takes its line and is the + // line's first word now, which the layer draws bold + sv::Event shorter = kaksi.withDuration + (kaksi.getDuration() - sv::sv_frame_t(0.2 * rate)); + replaceWord(kaksi, shorter); + if (QTest::currentTestFailed()) return; + m_window->answerWordText("ja"); + QVERIFY(chooseAt(inRow(columnOf(kolme.getFrame()) - 5), "Add Word...")); + sv::Event ja = lyricsWord("ja"); + QCOMPARE(endOf(ja), kolme.getFrame()); + QCOMPARE(ja.getDuration(), LyricsEdit::newWordFrames(rate)); + QCOMPARE(ja.getValue(), kolme.getValue()); + Lyrics now = lyricsFromEvents(lyricsEvents(), rate); + QCOMPARE(now.words.size(), 4); + QCOMPARE(now.words[1].text, QString("kaksi")); + QCOMPARE(now.words[2].text, QString("ja")); + QVERIFY(now.words[2].line != now.words[1].line); + QCOMPARE(now.words[3].line, now.words[2].line); + QCOMPARE(undoOnce(), QString("Add Word")); + + // Near kaksi: at the click, 0.5 s long, on kaksi's line + int x = columnOf(endOf(shorter)) + 10; + m_window->answerWordText("ja"); + QVERIFY(chooseAt(inRow(x), "Add Word...")); + ja = lyricsWord("ja"); + QCOMPARE(columnOf(ja.getFrame()), x); + QCOMPARE(ja.getDuration(), LyricsEdit::newWordFrames(rate)); + QCOMPARE(ja.getValue(), kaksi.getValue()); + QCOMPARE(undoOnce(), QString("Add Word")); + QCOMPARE(m_window->wordTextQuestions(), 3); + } + + // Before the first word the gap runs from frame 0, and a word that + // would be too long for it is cut to fit; after the last word there + // is no limit. Each takes the line of its one neighbour + void lyrics_edit_add_word_at_either_end() { + lyricsEditFixture(); + if (QTest::currentTestFailed()) return; + sv::EventVector before = lyricsEvents(); + sv::Event yksi = lyricsWord("Yksi"); + sv::Event kolme = lyricsWord("kolme"); + QVERIFY(yksi.getFrame() < LyricsEdit::newWordFrames(rate)); + + m_window->answerWordText("Nyt"); + QVERIFY(chooseAt(inRow(columnOf(yksi.getFrame() / 2)), "Add Word...")); + QCOMPARE(lyricsWord("Nyt"), + sv::Event(0, yksi.getValue(), yksi.getFrame(), "Nyt")); + QCOMPARE(lyricsFromEvents(lyricsEvents(), rate).words[0].text, + QString("Nyt")); + + int after = columnOf(endOf(kolme)) + 20; + m_window->answerWordText("loppu"); + QVERIFY(chooseAt(inRow(after), "Add Word...")); + sv::Event loppu = lyricsWord("loppu"); + QCOMPARE(columnOf(loppu.getFrame()), after); + QCOMPARE(loppu.getDuration(), LyricsEdit::newWordFrames(rate)); + QCOMPARE(loppu.getValue(), kolme.getValue()); + QCOMPARE(int(lyricsEvents().size()), 5); + + QCOMPARE(undoOnce(), QString("Add Word")); + QCOMPARE(undoOnce(), QString("Add Word")); + QCOMPARE(lyricsEvents(), before); + verifyPlaySourceClean(); + } + + // No room: a gap under 20 ms. The entry is there, disabled, and + // chosen asks nothing; a gap of 20 ms is room. Room taken after the + // menu was made, or while the text is asked for: nothing is added + void lyrics_edit_add_word_needs_room() { + lyricsEditFixture(); + if (QTest::currentTestFailed()) return; + LyricsEditor *editor = m_window->lyricsEditor(); + sv::Event kaksi = lyricsWord("kaksi"); + sv::Event kolme = lyricsWord("kolme"); + sv::sv_frame_t minimum = LyricsEdit::minWordFrames(rate); + + sv::Event narrow = kaksi.withDuration + (kolme.getFrame() - (minimum - 1) - kaksi.getFrame()); + replaceWord(kaksi, narrow); + if (QTest::currentTestFailed()) return; + int x = columnOf(endOf(narrow)) + 1; + QVERIFY(x < columnOf(kolme.getFrame())); + sv::EventVector before = lyricsEvents(); + QCOMPARE(menuAt(inRow(x)), QStringList{ "-Add Word..." }); + m_window->answerWordText("ei"); + QVERIFY(chooseAt(inRow(x), "Add Word...")); + QCOMPARE(m_window->wordTextQuestions(), 0); + QCOMPARE(lyricsEvents(), before); + + sv::Event fits = narrow.withDuration(narrow.getDuration() - 1); + replaceWord(narrow, fits); + if (QTest::currentTestFailed()) return; + QCOMPARE(menuAt(inRow(x)), QStringList{ "Add Word..." }); + QVERIFY(chooseAt(inRow(x), "Add Word...")); + QCOMPARE(m_window->wordTextQuestions(), 1); + QCOMPARE(lyricsWord("ei"), + sv::Event(endOf(fits), kaksi.getValue(), minimum, "ei")); + QCOMPARE(undoOnce(), QString("Add Word")); + + // The menu made, and the gap closed before its entry is chosen + auto entries = editor->menuEntriesAt(inRow(x)); + QCOMPARE(int(entries.size()), 1); + QVERIFY(entries[0].enabled); + sv::Event closed = fits.withDuration(kolme.getFrame() - fits.getFrame()); + replaceWord(fits, closed); + if (QTest::currentTestFailed()) return; + before = lyricsEvents(); + m_window->answerWordText("ei"); + editor->choose(entries[0]); + QCOMPARE(m_window->wordTextQuestions(), 1); + QCOMPARE(lyricsEvents(), before); + + // and closed while the text is asked for + replaceWord(closed, fits); + if (QTest::currentTestFailed()) return; + m_window->whileAskingWordText([this, fits, closed]() { + replaceWord(fits, closed); + }); + editor->choose(entries[0]); + QCOMPARE(m_window->wordTextQuestions(), 2); + QCOMPARE(m_window->wordTextAnswersLeft(), 0); + QCOMPARE(lyricsEvents(), before); + QCOMPARE(undoOnce(), QString()); + } + + // Delete Word takes the word away: the only word of its line, and so + // the line; then every word, after which Add Word still adds one. + // Undone, each comes back as it was + void lyrics_edit_delete_word() { + lyricsEditFixture(); + if (QTest::currentTestFailed()) return; + QSignalSpy commands(sv::CommandHistory::getInstance(), qOverload<> + (&sv::CommandHistory::commandExecuted)); + sv::EventVector before = lyricsEvents(); + sv::Event kolme = lyricsWord("kolme"); + QCOMPARE(lyricsFromEvents(before, rate).lineCount(), 2); + int inside = columnOf(kolme.getFrame()) + 40; + + QVERIFY(chooseAt(inRow(inside), "Delete Word")); + QCOMPARE(m_window->wordTextQuestions(), 0); + QCOMPARE(lyricsWord("kolme").getFrame(), sv::sv_frame_t(-1)); + QCOMPARE(int(lyricsEvents().size()), 2); + QCOMPARE(lyricsFromEvents(lyricsEvents(), rate).lineCount(), 1); + QCOMPARE(int(commands.count()), 1); + QVERIFY(m_window->isDocumentModified()); + sv::EventVector deleted = lyricsEvents(); + + QCOMPARE(undoOnce(), QString("Delete Word")); + QCOMPARE(lyricsEvents(), before); + QCOMPARE(redoOnce(), QString("Delete Word")); + QCOMPARE(lyricsEvents(), deleted); + + QVERIFY(chooseAt(inRow(columnOf(lyricsWord("Yksi").getFrame()) + 30), + "Delete Word")); + QVERIFY(chooseAt(inRow(columnOf(lyricsWord("kaksi").getFrame()) + 30), + "Delete Word")); + QVERIFY(lyricsEvents().empty()); + QVERIFY(m_window->lyrics()->isShown()); + QVERIFY(m_window->lyricsEditor()->isEnabled()); + m_row = lyricsBoxRow(); + QCOMPARE(menuAt(inRow(inside)), QStringList{ "Add Word..." }); + m_window->answerWordText("uusi"); + QVERIFY(chooseAt(inRow(inside), "Add Word...")); + QCOMPARE(lyricsWord("uusi").getValue(), 0.f); + QCOMPARE(lyricsWord("uusi").getDuration(), LyricsEdit::newWordFrames(rate)); + + QCOMPARE(undoOnce(), QString("Add Word")); + QCOMPARE(undoOnce(), QString("Delete Word")); + QCOMPARE(undoOnce(), QString("Delete Word")); + QCOMPARE(undoOnce(), QString("Delete Word")); + QCOMPARE(lyricsEvents(), before); + } + + // The question runs an event loop of its own, and anything can + // happen while it is open: the word changed by an undo, edit mode + // switched off, the lyrics removed. The answer then changes nothing, + // and nothing is pushed over what is on the history + void lyrics_edit_words_change_during_question() { + lyricsEditFixture(); + if (QTest::currentTestFailed()) return; + LyricsEditor *editor = m_window->lyricsEditor(); + sv::EventVector before = lyricsEvents(); + int inside = columnOf(lyricsWord("kaksi").getFrame()) + 40; + + m_window->answerWordText("kaksikko"); + doubleClickAt(inRow(inside)); + sv::EventVector changed = lyricsEvents(); + QVERIFY(changed != before); + + m_window->whileAskingWordText([this]() { undoOnce(); }); + m_window->answerWordText("kaksikkoko"); + doubleClickAt(inRow(inside)); + QCOMPARE(m_window->wordTextQuestions(), 2); + QCOMPARE(lyricsEvents(), before); + QCOMPARE(redoOnce(), QString("Change Word Text")); + QCOMPARE(lyricsEvents(), changed); + QCOMPARE(redoOnce(), QString()); + + // Deleted while its menu entry asks + m_window->whileAskingWordText([this, inside]() { + chooseAt(inRow(inside), "Delete Word"); + }); + m_window->answerWordText("kaksi"); + QVERIFY(chooseAt(inRow(inside), "Edit Word Text...")); + QCOMPARE(m_window->wordTextQuestions(), 3); + QCOMPARE(lyricsWord("kaksikko").getFrame(), sv::sv_frame_t(-1)); + QCOMPARE(int(lyricsEvents().size()), 2); + QCOMPARE(undoOnce(), QString("Delete Word")); + QCOMPARE(lyricsEvents(), changed); + + m_window->whileAskingWordText([this]() { + m_window->editLyricsAction()->trigger(); + }); + m_window->answerWordText("kaksi"); + doubleClickAt(inRow(inside)); + QCOMPARE(m_window->wordTextQuestions(), 4); + QVERIFY(!editor->isEnabled()); + QCOMPARE(lyricsEvents(), changed); + switchLyricsEditingOn(); + if (QTest::currentTestFailed()) return; + + int gap = columnOf(endOf(lyricsWord("kaksikko"))) + 20; + m_window->whileAskingWordText([this]() { + m_window->removeLyricsAction()->trigger(); + }); + m_window->answerWordText("ja"); + QVERIFY(chooseAt(inRow(gap), "Add Word...")); + QCOMPARE(m_window->wordTextQuestions(), 5); + QVERIFY(!m_window->lyrics()->isShown()); + QVERIFY(!editor->isEnabled()); + + // On top of the history, the first change, whose model has gone + QCOMPARE(undoOnce(), QString("Change Word Text")); + verifyPlaySourceClean(); + } + + // Export Lyrics after edits writes the words as edited: a text + // changed, an end moved, a word added on the line of the word after + // it, and a word deleted + void lyrics_edit_export_writes_edits() { + lyricsEditFixture(); + if (QTest::currentTestFailed()) return; + sv::Event yksi = lyricsWord("Yksi"); + sv::Event kolme = lyricsWord("kolme"); + + m_window->answerWordText("Yksin"); + doubleClickAt(inRow(columnOf(yksi.getFrame()) + 30)); + int end = columnOf(endOf(lyricsWord("kaksi"))) - 1; + dragFromTo(inRow(end), inRow(end - 30)); + m_window->answerWordText("ja"); + QVERIFY(chooseAt(inRow(columnOf(kolme.getFrame()) - 5), "Add Word...")); + QVERIFY(chooseAt(inRow(columnOf(kolme.getFrame()) + 40), "Delete Word")); + QCOMPARE(undoOnce(), QString("Delete Word")); + QCOMPARE(redoOnce(), QString("Delete Word")); + + sv::EventVector events = lyricsEvents(); + Lyrics now = lyricsFromEvents(events, rate); + QStringList texts; + for (const LyricWord &w : now.words) texts << w.text; + QCOMPARE(texts, (QStringList{ "Yksin", "kaksi", "ja" })); + QCOMPARE(now.words[2].line, 1); + + m_window->discardModifications(); + QString path = m_dir.filePath("edited-lyrics.ttml"); + m_window->setLyricsExportAnswer(path); + m_window->exportLyricsAction()->trigger(); + QFile file(path); + QVERIFY2(file.open(QIODevice::ReadOnly), "nothing was written"); + LyricsParseResult parsed = parseTtml(file.readAll()); + QVERIFY2(parsed.error == "", qPrintable(parsed.error)); + + const QVector &back = parsed.lyrics.words; + QCOMPARE(back.size(), now.words.size()); + for (int i = 0; i < back.size(); ++i) { + QString what = QString("word %1, \"%2\"").arg(i) + .arg(now.words[i].text); + QVERIFY2(back[i].text == now.words[i].text, + qPrintable(what + " came back as " + back[i].text)); + QVERIFY2(back[i].line == now.words[i].line, + qPrintable(what + ": another line")); + QVERIFY2(std::fabs(back[i].start - now.words[i].start) < 0.0005001, + qPrintable(what + ": another start")); + QVERIFY2(std::fabs(back[i].end - now.words[i].end) < 0.0005001, + qPrintable(what + ": another end")); + } + QVERIFY(std::fabs(back[1].end - 0.9) > 0.05); + QVERIFY(!m_window->isDocumentModified()); + QCOMPARE(lyricsEvents(), events); + } + + // Shift held at the press, anywhere in the box row (in a word, in a + // gap, on an edge), a drag moves all the words together as it goes, + // starts and ends alike, and is one "Shift Lyrics" step when let go + // (decisions 17, 18a) + void lyrics_edit_shift_drag() { + lyricsEditFixture(); + if (QTest::currentTestFailed()) return; + LyricsEditor *editor = m_window->lyricsEditor(); + QSignalSpy commands(sv::CommandHistory::getInstance(), qOverload<> + (&sv::CommandHistory::commandExecuted)); + sv::EventVector before = lyricsEvents(); + QCOMPARE(int(before.size()), 3); + + // Later, from inside a word, each move seen in the model at once + int inside = columnOf(lyricsWord("kaksi").getFrame()) + 40; + shiftPressAt(inRow(inside)); + QVERIFY(editor->isShifting()); + for (int x = inside + 3; x <= inside + 30; x += 3) { + shiftMoveHeldTo(inRow(x)); + QCOMPARE(lyricsEvents(), shiftedBy(before, framesBetween(inside, x))); + } + QCOMPARE(int(commands.count()), 0); + shiftReleaseAt(inRow(inside + 30)); + QVERIFY(!editor->isDragging()); + sv::EventVector later = + shiftedBy(before, framesBetween(inside, inside + 30)); + QCOMPARE(lyricsEvents(), later); + QCOMPARE(int(commands.count()), 1); + QVERIFY(m_window->isDocumentModified()); + + // Earlier, from the gap between kaksi and kolme + int gap = columnOf(endOf(lyricsWord("kaksi"))) + 20; + shiftDragFromTo(inRow(gap), inRow(gap - 20)); + sv::EventVector earlier = shiftedBy(later, framesBetween(gap, gap - 20)); + QCOMPARE(lyricsEvents(), earlier); + + // From the shared edge: all the words, not the one edge + int edge = columnOf(lyricsWord("kaksi").getFrame()); + shiftDragFromTo(inRow(edge), inRow(edge + 25)); + sv::EventVector fromEdge = + shiftedBy(earlier, framesBetween(edge, edge + 25)); + QCOMPARE(lyricsEvents(), fromEdge); + QCOMPARE(int(commands.count()), 3); + + QCOMPARE(undoOnce(), QString("Shift Lyrics")); + QCOMPARE(lyricsEvents(), earlier); + QCOMPARE(undoOnce(), QString("Shift Lyrics")); + QCOMPARE(lyricsEvents(), later); + QCOMPARE(undoOnce(), QString("Shift Lyrics")); + QCOMPARE(lyricsEvents(), before); + QCOMPARE(undoOnce(), QString()); + QCOMPARE(redoOnce(), QString("Shift Lyrics")); + QCOMPARE(lyricsEvents(), later); + QCOMPARE(redoOnce(), QString("Shift Lyrics")); + QCOMPARE(redoOnce(), QString("Shift Lyrics")); + QCOMPARE(lyricsEvents(), fromEdge); + QCOMPARE(redoOnce(), QString()); + + QVERIFY(m_window->playSource()->getModels().count + (m_window->lyrics()->getModelId()) == 0); + verifyPlaySourceClean(); + } + + // The first word stops at 0, however far the pointer goes, and the + // rest keep their places behind it; a drag past and back puts the + // words where the pointer is. Already at 0, a drag earlier is no + // edit at all (decision 17) + void lyrics_edit_shift_drag_stops_at_zero() { + lyricsEditFixture(); + if (QTest::currentTestFailed()) return; + QSignalSpy commands(sv::CommandHistory::getInstance(), qOverload<> + (&sv::CommandHistory::commandExecuted)); + sv::EventVector before = lyricsEvents(); + sv::sv_frame_t first = lyricsWord("Yksi").getFrame(); + QVERIFY(first > 0); + int inside = columnOf(first) + 40; + QVERIFY2(framesBetween(inside, 20) < -first - 1000, + "the pointer cannot go far enough left"); + + shiftPressAt(inRow(inside)); + shiftMoveHeldTo(inRow(20)); + QCOMPARE(lyricsEvents(), shiftedBy(before, -first)); + shiftMoveHeldTo(inRow(inside - 10)); + shiftReleaseAt(inRow(inside - 10)); + QCOMPARE(lyricsEvents(), + shiftedBy(before, framesBetween(inside, inside - 10))); + QCOMPARE(undoOnce(), QString("Shift Lyrics")); + QCOMPARE(lyricsEvents(), before); + + shiftDragFromTo(inRow(inside), inRow(20)); + sv::EventVector atZero = shiftedBy(before, -first); + QCOMPARE(lyricsEvents(), atZero); + QCOMPARE(lyricsWord("Yksi").getFrame(), sv::sv_frame_t(0)); + + // (An undo and a redo are commands executed as well) + m_window->discardModifications(); + int pushed = int(commands.count()); + int again = columnOf(0) + 40; + shiftDragFromTo(inRow(again), inRow(again - 30)); + QCOMPARE(lyricsEvents(), atZero); + QCOMPARE(int(commands.count()), pushed); + QVERIFY(!m_window->isDocumentModified()); + + QCOMPARE(undoOnce(), QString("Shift Lyrics")); + QCOMPARE(lyricsEvents(), before); + QCOMPARE(undoOnce(), QString()); + } + + // The one command of a long drag holds the words as they were at the + // press and as they are at the release, and nothing of the moves + // between. CommandHistory does not show what a command holds, but + // the model says what is done to it: a word taken out is one signal, + // a word put in two, so the undo and the redo of a command of N words + // out and N in each give 3N at most, where a command that kept every + // move would replay all of them + void lyrics_edit_shift_drag_one_command() { + lyricsEditFixture(); + if (QTest::currentTestFailed()) return; + auto model = lyricsModel(); + QVERIFY(model); + sv::EventVector before = lyricsEvents(); + int words = int(before.size()); + + int inside = columnOf(lyricsWord("kaksi").getFrame()) + 40; + shiftPressAt(inRow(inside)); + for (int i = 0; i < 40; ++i) { + shiftMoveHeldTo(inRow(inside + (i % 2 ? 30 : -30) + i / 4)); + } + shiftMoveHeldTo(inRow(inside + 17)); + shiftReleaseAt(inRow(inside + 17)); + sv::EventVector after = + shiftedBy(before, framesBetween(inside, inside + 17)); + QCOMPARE(lyricsEvents(), after); + + int changes = 0; + auto counted = [&changes]() { ++changes; }; + auto within = connect(model.get(), &sv::Model::modelChangedWithin, + this, counted); + auto whole = connect(model.get(), &sv::Model::modelChanged, + this, counted); + + QCOMPARE(undoOnce(), QString("Shift Lyrics")); + QCOMPARE(lyricsEvents(), before); + QVERIFY2(changes >= words && changes <= 3 * words, + qPrintable(QString("the undo changed the model %1 times " + "for %2 words").arg(changes).arg(words))); + changes = 0; + QCOMPARE(redoOnce(), QString("Shift Lyrics")); + QCOMPARE(lyricsEvents(), after); + QVERIFY2(changes >= words && changes <= 3 * words, + qPrintable(QString("the redo changed the model %1 times " + "for %2 words").arg(changes).arg(words))); + disconnect(within); + disconnect(whole); + } + + // What a press is, an edge's or all the words', is decided at the + // press: a plain press on an edge moves that edge alone, Shift held + // or not after, and a Shift press moves all the words, Shift let go + // or not. Pressed and let go, or dragged away and back: no edit + void lyrics_edit_shift_decided_at_the_press() { + lyricsEditFixture(); + if (QTest::currentTestFailed()) return; + LyricsEditor *editor = m_window->lyricsEditor(); + QSignalSpy commands(sv::CommandHistory::getInstance(), qOverload<> + (&sv::CommandHistory::commandExecuted)); + sv::EventVector before = lyricsEvents(); + sv::Event yksi = lyricsWord("Yksi"); + sv::Event kaksi = lyricsWord("kaksi"); + sv::Event kolme = lyricsWord("kolme"); + int edge = columnOf(kaksi.getFrame()); + + pressAt(inRow(edge)); + QVERIFY(editor->isDragging()); + QVERIFY(!editor->isShifting()); + shiftMoveHeldTo(inRow(edge + 20)); + shiftReleaseAt(inRow(edge + 20)); + QCOMPARE(lyricsWord("kaksi").getFrame(), + kaksi.getFrame() + framesBetween(edge, edge + 20)); + QCOMPARE(endOf(lyricsWord("kaksi")), endOf(kaksi)); + QCOMPARE(lyricsWord("Yksi"), yksi); + QCOMPARE(lyricsWord("kolme"), kolme); + QCOMPARE(undoOnce(), QString("Move Word Start")); + QCOMPARE(lyricsEvents(), before); + + shiftPressAt(inRow(edge)); + QVERIFY(editor->isShifting()); + moveHeldTo(inRow(edge + 20)); + releaseAt(inRow(edge + 20)); + QCOMPARE(lyricsEvents(), + shiftedBy(before, framesBetween(edge, edge + 20))); + QCOMPARE(undoOnce(), QString("Shift Lyrics")); + QCOMPARE(lyricsEvents(), before); + + // (An undo is a command executed as well) + m_window->discardModifications(); + int pushed = int(commands.count()); + int inside = edge + 40; + shiftPressAt(inRow(inside)); + shiftReleaseAt(inRow(inside)); + shiftPressAt(inRow(inside)); + for (int x = inside; x <= inside + 30; x += 5) shiftMoveHeldTo(inRow(x)); + QVERIFY(lyricsEvents() != before); + for (int x = inside + 30; x >= inside; x -= 5) shiftMoveHeldTo(inRow(x)); + shiftReleaseAt(inRow(inside)); + QVERIFY(!editor->isDragging()); + QCOMPARE(lyricsEvents(), before); + QCOMPARE(int(commands.count()), pushed); + QVERIFY(!m_window->isDocumentModified()); + QCOMPARE(undoOnce(), QString()); + } + + // Edit mode off, a Shift press is the pane's as any press is + // (decision 19); edit mode on, so is one outside the box row + void lyrics_edit_shift_press_elsewhere_is_the_panes() { + makeWindow(FakeAudioIO::Config()); + showEditableLyrics(); + if (QTest::currentTestFailed()) return; + LyricsEditor *editor = m_window->lyricsEditor(); + sv::EventVector before = lyricsEvents(); + int inside = columnOf(lyricsWord("kaksi").getFrame()) + 40; + + shiftPressAt(inRow(inside)); + QVERIFY(!editor->isDragging()); + shiftMoveHeldTo(inRow(inside + 30)); + shiftReleaseAt(inRow(inside + 30)); + QCOMPARE(lyricsEvents(), before); + + switchLyricsEditingOn(); + if (QTest::currentTestFailed()) return; + QPoint above(inside, m_row.top() - 8); + shiftPressAt(above); + QVERIFY(!editor->isDragging()); + shiftMoveHeldTo(QPoint(inside + 30, above.y())); + shiftReleaseAt(QPoint(inside + 30, above.y())); + QCOMPARE(lyricsEvents(), before); + + // The pane's Shift-drag outlines a region to analyse again + QTRY_VERIFY_WITH_TIMEOUT(!sv::ModelTransformerFactory::getInstance() + ->haveRunningTransformers(), 30000); + QVERIFY(undoOnce() != QString("Shift Lyrics")); + } + + // Edit mode switched off in the middle of a drag of all the words: + // the drag ends as a release would, one step, and the rest of it is + // not an edit + void lyrics_edit_shift_off_in_the_middle_of_a_drag() { + lyricsEditFixture(); + if (QTest::currentTestFailed()) return; + LyricsEditor *editor = m_window->lyricsEditor(); + sv::EventVector before = lyricsEvents(); + + int inside = columnOf(lyricsWord("kaksi").getFrame()) + 40; + shiftPressAt(inRow(inside)); + shiftMoveHeldTo(inRow(inside + 30)); + sv::EventVector dragged = lyricsEvents(); + QCOMPARE(dragged, shiftedBy(before, framesBetween(inside, inside + 30))); + + m_window->editLyricsAction()->trigger(); + QVERIFY(!editor->isEnabled()); + QVERIFY(!editor->isDragging()); + QVERIFY(m_window->isDocumentModified()); + + shiftMoveHeldTo(inRow(inside + 60)); + shiftReleaseAt(inRow(inside + 60)); + QCOMPARE(lyricsEvents(), dragged); + + QCOMPARE(undoOnce(), QString("Shift Lyrics")); + QCOMPARE(lyricsEvents(), before); + QCOMPARE(undoOnce(), QString()); + QCOMPARE(redoOnce(), QString("Shift Lyrics")); + QCOMPARE(lyricsEvents(), dragged); + } + + // The words changed under a drag of them all, by something other + // than the drag: the drag ends, the model is left as that made it, + // and nothing goes on the history. Removed in the middle likewise + void lyrics_edit_shift_words_change_under_a_drag() { + lyricsEditFixture(); + if (QTest::currentTestFailed()) return; + LyricsEditor *editor = m_window->lyricsEditor(); + sv::EventVector before = lyricsEvents(); + + int inside = columnOf(lyricsWord("kaksi").getFrame()) + 40; + shiftPressAt(inRow(inside)); + shiftMoveHeldTo(inRow(inside + 20)); + sv::Event kolme = lyricsWord("kolme"); + replaceWord(kolme, kolme.withLabel("kolmas")); + sv::EventVector changed = lyricsEvents(); + shiftMoveHeldTo(inRow(inside + 40)); + QCOMPARE(lyricsEvents(), changed); + shiftReleaseAt(inRow(inside + 40)); + QVERIFY(!editor->isDragging()); + QCOMPARE(lyricsEvents(), changed); + QCOMPARE(undoOnce(), QString()); + + shiftPressAt(inRow(inside)); + shiftMoveHeldTo(inRow(inside + 20)); + m_window->removeLyricsAction()->trigger(); + QVERIFY(!editor->isDragging()); + QVERIFY(!editor->isEnabled()); + shiftMoveHeldTo(inRow(inside + 40)); + shiftReleaseAt(inRow(inside + 40)); + QVERIFY(!m_window->lyrics()->isShown()); + QCOMPARE(undoOnce(), QString()); + verifyPlaySourceClean(); + } + + // Edit > Shift Lyrics...: all the words by the seconds typed, later or + // earlier, one step each, the status bar saying how far they went; + // the first word stops at 0, and a shift of 0, or one clamped to 0, + // or a cancel, is no edit at all (decisions 18b, 20). Edit mode need + // not be on + void lyrics_edit_shift_by_number() { + makeWindow(FakeAudioIO::Config()); + showEditableLyrics(); + if (QTest::currentTestFailed()) return; + QAction *shift = m_window->shiftLyricsAction(); + QSignalSpy commands(sv::CommandHistory::getInstance(), qOverload<> + (&sv::CommandHistory::commandExecuted)); + sv::EventVector before = lyricsEvents(); + QVERIFY(!m_window->lyricsEditor()->isEnabled()); + QVERIFY(shift->isEnabled()); + + m_window->answerLyricsShift(0.2); + shift->trigger(); + QCOMPARE(m_window->lyricsShiftQuestions(), 1); + sv::EventVector later = shiftedBy(before, 8820); + QCOMPARE(lyricsEvents(), later); + QCOMPARE(m_window->statusText(), + QString("Shifted the lyrics 0.200 s later.")); + QVERIFY(m_window->isDocumentModified()); + + m_window->answerLyricsShift(-0.05); + shift->trigger(); + sv::EventVector earlier = shiftedBy(later, -2205); + QCOMPARE(lyricsEvents(), earlier); + QCOMPARE(m_window->statusText(), + QString("Shifted the lyrics 0.050 s earlier.")); + QCOMPARE(int(commands.count()), 2); + + QCOMPARE(undoOnce(), QString("Shift Lyrics")); + QCOMPARE(lyricsEvents(), later); + QCOMPARE(undoOnce(), QString("Shift Lyrics")); + QCOMPARE(lyricsEvents(), before); + QCOMPARE(redoOnce(), QString("Shift Lyrics")); + QCOMPARE(redoOnce(), QString("Shift Lyrics")); + QCOMPARE(lyricsEvents(), earlier); + + // Yksi starts at 0.35 s now: that far and no further + QCOMPARE(lyricsWord("Yksi").getFrame(), sv::sv_frame_t(15435)); + m_window->answerLyricsShift(-5.0); + shift->trigger(); + sv::EventVector atZero = shiftedBy(earlier, -15435); + QCOMPARE(lyricsEvents(), atZero); + QCOMPARE(m_window->statusText(), + QString("Shifted the lyrics 0.350 s earlier.")); + + // Nothing to do: no step, and nothing said. (An undo and a redo + // are commands executed as well) + int pushed = int(commands.count()); + m_window->discardModifications(); + m_window->setStatusText("before"); + m_window->answerLyricsShift(-1.0); + m_window->answerLyricsShift(0.0); + m_window->answerLyricsShift(0.00001); + m_window->cancelLyricsShift(); + for (int i = 0; i < 4; ++i) shift->trigger(); + QCOMPARE(m_window->lyricsShiftQuestions(), 7); + QCOMPARE(lyricsEvents(), atZero); + QCOMPARE(int(commands.count()), pushed); + QVERIFY(!m_window->isDocumentModified()); + QCOMPARE(m_window->statusText(), QString("before")); + + // With edit mode on as well, which stays on + switchLyricsEditingOn(); + if (QTest::currentTestFailed()) return; + m_window->answerLyricsShift(1.5); + shift->trigger(); + QCOMPARE(lyricsEvents(), shiftedBy(atZero, 66150)); + QVERIFY(m_window->lyricsEditor()->isEnabled()); + QCOMPARE(undoOnce(), QString("Shift Lyrics")); + QCOMPARE(lyricsEvents(), atZero); + } + + // To be had when editing is (lyricsEditAllowed()), edit mode on or + // not: not without lyrics, hidden ones, or once they are removed; and + // a trigger then asks nothing + void lyrics_edit_shift_needs_lyrics() { + makeWindow(FakeAudioIO::Config()); + QAction *shift = m_window->shiftLyricsAction(); + QVERIFY(shift); + QVERIFY(!shift->isEnabled()); + openReference(writeWav(tone(lowHz, 2.0))); + if (QTest::currentTestFailed()) return; + QVERIFY(!shift->isEnabled()); + m_window->answerLyricsShift(0.5); + shift->trigger(); + QCOMPARE(m_window->lyricsShiftQuestions(), 0); + + QVERIFY(m_window->doImportLyricsFrom(writeLrc(gappedLyrics()))); + QVERIFY(shift->isEnabled()); + m_window->showLyricsAction()->trigger(); + QVERIFY(!shift->isEnabled()); + sv::EventVector before = lyricsEvents(); + shift->trigger(); + QCOMPARE(m_window->lyricsShiftQuestions(), 0); + QCOMPARE(lyricsEvents(), before); + m_window->showLyricsAction()->trigger(); + QVERIFY(shift->isEnabled()); + + m_window->removeLyricsAction()->trigger(); + QVERIFY(!shift->isEnabled()); + shift->trigger(); + QCOMPARE(m_window->lyricsShiftQuestions(), 0); + } + + // Not while a take is recorded. A drag of all the words going on when + // the take starts is finished first, and so is on the history before + // the take + void lyrics_edit_shift_off_while_recording() { + FakeAudioIO::Config config; + config.input = tone(highHz, 3.0); + lyricsEditFixture(config); + if (QTest::currentTestFailed()) return; + QAction *shift = m_window->shiftLyricsAction(); + LyricsEditor *editor = m_window->lyricsEditor(); + sv::EventVector before = lyricsEvents(); + + int inside = columnOf(lyricsWord("kaksi").getFrame()) + 40; + shiftPressAt(inRow(inside)); + shiftMoveHeldTo(inRow(inside + 30)); + sv::EventVector dragged = lyricsEvents(); + QVERIFY(dragged != before); + + startTake(); + if (QTest::currentTestFailed()) return; + QVERIFY(!editor->isDragging()); + QVERIFY(!shift->isEnabled()); + m_window->answerLyricsShift(0.5); + shift->trigger(); + QCOMPARE(m_window->lyricsShiftQuestions(), 0); + + shiftMoveHeldTo(inRow(inside + 60)); + shiftReleaseAt(inRow(inside + 60)); + QCOMPARE(lyricsEvents(), dragged); + + QTest::qWait(300); + stopTake(); + if (QTest::currentTestFailed()) return; + QTRY_VERIFY_WITH_TIMEOUT(shift->isEnabled(), 2000); + + QCOMPARE(undoOnce(), QString("Record Singing")); + QCOMPARE(undoOnce(), QString("Shift Lyrics")); + QCOMPARE(lyricsEvents(), before); + } + + // The question has an event loop of its own: lyrics removed, hidden or + // imported again while it is open are not what the seconds were typed + // for, and nothing is shifted + void lyrics_edit_shift_lyrics_change_during_question() { + makeWindow(FakeAudioIO::Config()); + showEditableLyrics(); + if (QTest::currentTestFailed()) return; + QAction *shift = m_window->shiftLyricsAction(); + QSignalSpy commands(sv::CommandHistory::getInstance(), qOverload<> + (&sv::CommandHistory::commandExecuted)); + QString file = writeLrc(gappedLyrics()); + sv::EventVector before = lyricsEvents(); + + m_window->whileAskingLyricsShift([this, file]() { + QVERIFY(m_window->doImportLyricsFrom(file)); + }); + m_window->answerLyricsShift(0.3); + shift->trigger(); + QCOMPARE(m_window->lyricsShiftQuestions(), 1); + QCOMPARE(lyricsEvents(), before); + + m_window->whileAskingLyricsShift([this]() { + m_window->showLyricsAction()->trigger(); + }); + m_window->answerLyricsShift(0.3); + shift->trigger(); + QCOMPARE(lyricsEvents(), before); + m_window->showLyricsAction()->trigger(); + + m_window->whileAskingLyricsShift([this]() { + m_window->removeLyricsAction()->trigger(); + }); + m_window->answerLyricsShift(0.3); + shift->trigger(); + QCOMPARE(m_window->lyricsShiftQuestions(), 3); + QVERIFY(!m_window->lyrics()->isShown()); + QCOMPARE(int(commands.count()), 0); + QCOMPARE(undoOnce(), QString()); + } + + // The word at the cursor is found again as all the words move, by a + // drag and by a number + void lyrics_edit_shift_highlight_follows() { + lyricsEditFixture(); + if (QTest::currentTestFailed()) return; + sv::Event kaksi = lyricsWord("kaksi"); + sv::Event kolme = lyricsWord("kolme"); + sv::sv_frame_t gap = (endOf(kaksi) + kolme.getFrame()) / 2; + m_window->seekTo(gap); + QTRY_COMPARE_WITH_TIMEOUT(highlightedWord(), QString(), 1000); + + // About 0.29 s later: kaksi, 0.6 to 0.9 s, then covers 1.15 s + int inside = columnOf(kaksi.getFrame()) + 40; + shiftPressAt(inRow(inside)); + shiftMoveHeldTo(inRow(inside + 100)); + QTRY_COMPARE_WITH_TIMEOUT(highlightedWord(), QString("kaksi"), 1000); + shiftReleaseAt(inRow(inside + 100)); + QCOMPARE(highlightedWord(), QString("kaksi")); + + QCOMPARE(undoOnce(), QString("Shift Lyrics")); + QTRY_COMPARE_WITH_TIMEOUT(highlightedWord(), QString(), 1000); + + m_window->answerLyricsShift(0.3); + m_window->shiftLyricsAction()->trigger(); + QTRY_COMPARE_WITH_TIMEOUT(highlightedWord(), QString("kaksi"), 1000); + } + + // Export Lyrics after a shift writes the shifted times + void lyrics_edit_shift_then_export() { + makeWindow(FakeAudioIO::Config()); + showEditableLyrics(); + if (QTest::currentTestFailed()) return; + Lyrics was = lyricsFromEvents(lyricsEvents(), rate); + + m_window->answerLyricsShift(0.25); + m_window->shiftLyricsAction()->trigger(); + QCOMPARE(undoOnce(), QString("Shift Lyrics")); + QCOMPARE(redoOnce(), QString("Shift Lyrics")); + + QString path = m_dir.filePath("shifted-lyrics.ttml"); + m_window->setLyricsExportAnswer(path); + m_window->exportLyricsAction()->trigger(); + QFile file(path); + QVERIFY2(file.open(QIODevice::ReadOnly), "nothing was written"); + LyricsParseResult parsed = parseTtml(file.readAll()); + QVERIFY2(parsed.error == "", qPrintable(parsed.error)); + + const QVector &back = parsed.lyrics.words; + QCOMPARE(back.size(), was.words.size()); + for (int i = 0; i < back.size(); ++i) { + QString what = QString("word %1, \"%2\"").arg(i) + .arg(was.words[i].text); + QVERIFY2(back[i].text == was.words[i].text, + qPrintable(what + " came back as " + back[i].text)); + QVERIFY2(back[i].line == was.words[i].line, + qPrintable(what + ": another line")); + QVERIFY2(std::fabs(back[i].start - (was.words[i].start + 0.25)) + < 0.0005001, qPrintable(what + ": another start")); + QVERIFY2(std::fabs(back[i].end - (was.words[i].end + 0.25)) + < 0.0005001, qPrintable(what + ": another end")); + } + } + + // Closing while pYIN is still running on the take (review finding + // 15). Unless the analysis is cancelled first, about one run in + // three under CPU load destroys the take's model on the transform + // thread ("Timers cannot be stopped from another thread") and the + // process dies with an access violation soon after. A regression + // shows up as a crash of the whole test program, and not reliably: + // run it under load to check. The analysis is held, so that its run + // is there, unmerged, at the close however quick the machine; whether + // its thread is still going then as well depends on the machine and + // its load. + void close_session_during_analysis() { + FakeAudioIO::Config config; + config.input = tone(highHz, 3.0); + makeWindow(config); + openReference(writeWav(tone(lowHz, 1.0))); + if (QTest::currentTestFailed()) return; + + startTake(); + if (QTest::currentTestFailed()) return; + QTest::qWait(600); + + // Stop splices the recording into the take and starts the analysis + // of the result there and then, so it is running when the session + // is closed. Otherwise this test shows nothing + m_window->holdRangedMerges(true); + m_window->doRecord(); + QVERIFY(!m_window->recordTarget()->isRecording()); + QVERIFY2(sv::ModelTransformerFactory::getInstance() + ->haveRunningTransformers(), + "the race was not set up: no analysis was running when " + "the session was about to be closed"); + QVERIFY(m_window->analysingRange()); + m_window->doCloseSession(); + m_window->holdRangedMerges(false); + + QVERIFY(!m_window->analyser2()); + QCOMPARE(m_window->paneStack()->getPaneCount(), 0); + + openReference(writeWav(tone(highHz, 1.0))); + if (QTest::currentTestFailed()) return; + QVERIFY(std::fabs(TestSignals::centsBetween + (medianHz(pitchEvents(m_window->analyser())), + highHz)) < 10.0); + } +}; + +#endif diff --git a/main/test/TestRunSuite.h b/main/test/TestRunSuite.h new file mode 100644 index 00000000..c60cc926 --- /dev/null +++ b/main/test/TestRunSuite.h @@ -0,0 +1,175 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TEST_RUN_SUITE_TEST_H +#define TEST_RUN_SUITE_TEST_H + +// Which test functions a shard of a suite runs (TONY_TEST_SHARD): each +// exactly once over the shards, and nothing QTest would not run as a +// test. And the application name a shard's process runs under: one of +// its own, which is what keeps shards running at once apart. + +#include "RunSuite.h" + +#include +#include +#include + +// A suite as QTest sees one: its test functions, and slots that are not +// test functions. Never run. +class ShardFixture : public QObject +{ + Q_OBJECT + +public slots: + void publicSlot() {} + +private slots: + void initTestCase() {} + void init() {} + void first() {} + void second_data() {} + void second() {} + void withArgument(int) {} + void third() {} + void fourth() {} + void fifth() {} + void cleanup() {} + void cleanupTestCase() {} +}; + +class TestRunSuite : public QObject +{ + Q_OBJECT + +private slots: + // One shard is the whole suite, in the order declared + void one_shard_is_every_test_function() { + ShardFixture fixture; + QCOMPARE(shardFunctions(&fixture, 0, 1), + QStringList({ "first", "second", "third", "fourth", "fifth" })); + } + + // Each count-th function from the shard's number, so that the + // long tests declared together are spread over the shards + void shards_take_every_nth() { + ShardFixture fixture; + QCOMPARE(shardFunctions(&fixture, 0, 2), + QStringList({ "first", "third", "fifth" })); + QCOMPARE(shardFunctions(&fixture, 1, 2), + QStringList({ "second", "fourth" })); + } + + // Over the shards of any count, every test function once + void shards_cover_each_function_once() { + ShardFixture fixture; + QStringList all = shardFunctions(&fixture, 0, 1); + for (int count = 1; count <= 7; ++count) { + QStringList seen; + for (int shard = 0; shard < count; ++shard) { + seen << shardFunctions(&fixture, shard, count); + } + QVERIFY2(seen.size() == all.size() && + QSet(seen.begin(), seen.end()) == + QSet(all.begin(), all.end()), + qPrintable(QString("%1 shards ran %2") + .arg(count).arg(seen.join(", ")))); + } + } + + // More shards than functions: the ones past the end have none, and + // runSuite() then does not run the suite at all + void shards_past_the_end_are_empty() { + ShardFixture fixture; + QCOMPARE(shardFunctions(&fixture, 4, 6), QStringList({ "fifth" })); + QVERIFY(shardFunctions(&fixture, 5, 6).isEmpty()); + } + + // A run that is not sharded keeps the executable's own name, the one + // meson test and named tests have always used + void no_shard_is_the_base_name() { + QCOMPARE(shardApplicationName("test-tony-app", QString()), + QString("test-tony-app")); + QCOMPARE(shardApplicationName("test-tony-app", ""), + QString("test-tony-app")); + } + + // The name says which shard of how many, after the base name + void a_shard_name_says_which_shard() { + QCOMPARE(shardApplicationName("test-tony-app", "3/8"), + QString("test-tony-app-shard3of8")); + } + + // The shards of one run are processes at once: none may share a name + void every_shard_of_a_count_has_its_own_name() { + for (int count = 1; count <= 8; ++count) { + QStringList names; + for (int shard = 0; shard < count; ++shard) { + names << shardName(shard, count); + } + QVERIFY2(QSet(names.begin(), names.end()).size() == count, + qPrintable(QString("%1 shards are named %2") + .arg(count).arg(names.join(", ")))); + } + } + + // Shard i of another count runs other tests, so it is another name + void the_same_shard_of_another_count_has_another_name() { + for (int count = 1; count <= 8; ++count) { + for (int other = count + 1; other <= 8; ++other) { + for (int shard = 0; shard < count; ++shard) { + QString name = shardName(shard, count); + QVERIFY2(shardName(shard, other) != name, + qPrintable(QString("%1/%2 and %1/%3 are both %4") + .arg(shard).arg(count).arg(other) + .arg(name))); + } + } + } + } + + // runSuite() refuses to run with a value that is not a shard, and + // the name is then the base name, as for no value + void an_invalid_shard_is_the_base_name() { + for (QString value : { "x", "2/2", "-1/2", "0/0", "1/2/3", "/" }) { + QString name = shardApplicationName("test-tony-app", value); + QVERIFY2(name == "test-tony-app", + qPrintable(QString("\"%1\" gives %2") + .arg(value).arg(name))); + } + } + + // Both parts must be whole numbers. Otherwise a slip such as "1x/2" + // would be read as shard 0, and run it a second time under its name + void a_shard_is_two_whole_numbers() { + int shard = -1, count = -1; + QVERIFY(parseShard("3/8", shard, count)); + QCOMPARE(shard, 3); + QCOMPARE(count, 8); + for (QString value : { "a/2", "/2", "1x/2", "1/x", "1/2x", "1.0/2" }) { + QVERIFY2(!parseShard(value, shard, count), + qPrintable(QString("\"%1\" taken as %2/%3") + .arg(value).arg(shard).arg(count))); + QCOMPARE(shardApplicationName("test-tony-app", value), + QString("test-tony-app")); + } + } + +private: + static QString shardName(int shard, int count) { + return shardApplicationName + ("test-tony-app", QString("%1/%2").arg(shard).arg(count)); + } +}; + +#endif diff --git a/main/test/TestSignals.h b/main/test/TestSignals.h new file mode 100644 index 00000000..6ea3926e --- /dev/null +++ b/main/test/TestSignals.h @@ -0,0 +1,134 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TEST_SIGNALS_H +#define TEST_SIGNALS_H + +// Synthetic signals and pitch comparison shared by the Tony test suites. + +#include +#include +#include + +namespace TestSignals { + +const double kPi = 3.14159265358979323846; + +inline std::vector +sine(double hz, double sampleRate, int n, double amplitude = 0.5, + double dc = 0.0, int startIndex = 0) +{ + std::vector v(n); + for (int i = 0; i < n; ++i) { + v[i] = float(dc + amplitude * + std::sin(2.0 * kPi * hz * (startIndex + i) / sampleRate)); + } + return v; +} + +/** Naive (aliasing) sawtooth: all harmonics present, fundamental at hz. */ +inline std::vector +sawtooth(double hz, double sampleRate, int n, double amplitude = 0.5) +{ + std::vector v(n); + for (int i = 0; i < n; ++i) { + double phase = std::fmod(hz * i / sampleRate, 1.0); + v[i] = float(amplitude * (2.0 * phase - 1.0)); + } + return v; +} + +/** Deterministic: the same seed always yields the same buffer. */ +inline std::vector +whiteNoise(int n, unsigned seed, double amplitude = 0.5) +{ + std::mt19937 gen(seed); + std::uniform_real_distribution dist(float(-amplitude), + float(amplitude)); + std::vector v(n); + for (int i = 0; i < n; ++i) v[i] = dist(gen); + return v; +} + +inline double +centsBetween(double hz, double referenceHz) +{ + return 1200.0 * std::log2(hz / referenceHz); +} + +/** + * x through a second-order Butterworth high-pass at hz (the Audio EQ + * Cookbook's): 12 dB an octave below it, as a small speaker rolls off + */ +inline std::vector +highPassed(const std::vector &x, double sampleRate, double hz) +{ + const double w = 2.0 * kPi * hz / sampleRate; + const double alpha = std::sin(w) / std::sqrt(2.0); + const double cosw = std::cos(w); + const double b0 = (1.0 + cosw) / 2.0, b1 = -(1.0 + cosw), b2 = b0; + const double a0 = 1.0 + alpha, a1 = -2.0 * cosw, a2 = 1.0 - alpha; + std::vector y(x.size()); + double x1 = 0.0, x2 = 0.0, y1 = 0.0, y2 = 0.0; + for (size_t i = 0; i < x.size(); ++i) { + double in = x[i]; + double out = (b0 * in + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2) / a0; + x2 = x1; x1 = in; + y2 = y1; y1 = out; + y[i] = float(out); + } + return y; +} + +/** + * x with hz taken out: a notch (the Audio EQ Cookbook's), which leaves + * nothing of a steady tone at hz once it has settled, some q periods + * in, and little of anything an octave away. As a small speaker loses + * a voice's fundamental, and more cleanly. + */ +inline std::vector +notched(const std::vector &x, double sampleRate, double hz, + double q = 4.0) +{ + const double w = 2.0 * kPi * hz / sampleRate; + const double alpha = std::sin(w) / (2.0 * q); + const double cosw = std::cos(w); + const double b0 = 1.0, b1 = -2.0 * cosw, b2 = 1.0; + const double a0 = 1.0 + alpha, a1 = -2.0 * cosw, a2 = 1.0 - alpha; + std::vector y(x.size()); + double x1 = 0.0, x2 = 0.0, y1 = 0.0, y2 = 0.0; + for (size_t i = 0; i < x.size(); ++i) { + double in = x[i]; + double out = (b0 * in + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2) / a0; + x2 = x1; x1 = in; + y2 = y1; y1 = out; + y[i] = float(out); + } + return y; +} + +/** + * x with a tone at hz, and anything within a few cents of it, taken out + * as a small speaker loses a voice's fundamental: two notches of q 2, + * which leave a steady tone within 10 cents of hz 60 dB down or more + * once settled, and take 1 dB or so off the octave above. + */ +inline std::vector +withoutFundamental(const std::vector &x, double sampleRate, double hz) +{ + return notched(notched(x, sampleRate, hz, 2.0), sampleRate, hz, 2.0); +} + +} + +#endif diff --git a/main/test/TestSingingAnalysis.h b/main/test/TestSingingAnalysis.h new file mode 100644 index 00000000..8d19e552 --- /dev/null +++ b/main/test/TestSingingAnalysis.h @@ -0,0 +1,1456 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TEST_SINGING_ANALYSIS_H +#define TEST_SINGING_ANALYSIS_H + +// Tier 4: the Analyser running the real pYIN plugin on synthetic +// audio, in primary and secondary mode, and the latency shift that +// the record path applies before analysis. No MainWindow. +// +// The test main points VAMP_PATH at the directory holding the test +// executable, which is where the build leaves pyin.dll. + +#include "TestSignals.h" + +#include "../Analyser.h" +#include "../LatencyCheck.h" + +#include "framework/Document.h" +#include "framework/SVFileReader.h" +#include "view/Pane.h" +#include "view/PaneStack.h" +#include "view/ViewManager.h" +#include "layer/Layer.h" +#include "layer/ColourDatabase.h" +#include "layer/SingleColourLayer.h" +#include "layer/WaveformLayer.h" +#include "data/model/WritableWaveFileModel.h" +#include "data/model/SparseTimeValueModel.h" +#include "data/model/NoteModel.h" +#include "transform/ModelTransformerFactory.h" +#include "base/PlayParameters.h" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +class TestSingingAnalysis : public QObject +{ + Q_OBJECT + + static constexpr double rate = 44100.0; + static constexpr int hop = 256; // as set in Analyser::addAnalyses() + + // The latency shift used throughout: close to half a second, and + // a whole number of hops so that the shifted and unshifted + // analyses land on the same frame grid + static constexpr sv::sv_frame_t shift = 86 * hop; + + QTemporaryDir m_dir; + int m_fileCounter = 0; + + sv::ViewManager *m_viewManager = nullptr; + sv::PaneStack *m_paneStack = nullptr; + sv::Document *m_document = nullptr; + sv::Pane *m_pane = nullptr; + + sv::ModelId makeAudioModel(const std::vector &data) { + QString path = m_dir.filePath + (QString("audio-%1.wav").arg(++m_fileCounter)); + auto model = std::make_shared + (path, rate, 1, + sv::WritableWaveFileModel::Normalisation::None); + const float *ptr = data.data(); + model->addSamples(&ptr, sv::sv_frame_t(data.size())); + model->writeComplete(); + return sv::ModelById::add(model); + } + + // Both tones have a whole number of samples per period (200 and + // 150). A synthetic tone is exactly periodic, so when its period is + // not a whole number of samples the best integer lag is a multiple + // of it, and pYIN reports a subharmonic: 220, 330 and 440 Hz all + // come out as 110 Hz (lag 401), where 150 Hz (lag 294) is right. + // No voice is that periodic, so this is about the test signal and + // not about the application. + static constexpr double referenceHz = 220.5; + static constexpr double singingHz = 294.0; + + static std::vector tone(double hz, double seconds) { + return TestSignals::sawtooth(hz, rate, int(seconds * rate), 0.5f); + } + + static std::vector silenceThenTone(sv::sv_frame_t silence, + double hz, double seconds) { + std::vector data(size_t(silence), 0.f); + auto t = tone(hz, seconds); + data.insert(data.end(), t.begin(), t.end()); + return data; + } + + static void appendSilence(std::vector &data, double seconds) { + data.insert(data.end(), size_t(seconds * rate), 0.f); + } + + // Four notes with 0.6 s of silence between them, 5 s in all: the + // gaps are wide enough that a range with half a second of margin on + // each side can sit over the third note and still leave the second + // and fourth untouched. Each pitch has a whole number of samples per + // period, as above (150, 200, 180 and 140 samples) + static std::vector fourNotes() { + std::vector data; + for (double hz : { 294.0, 220.5, 245.0, 315.0 }) { + appendSilence(data, 0.6); + auto t = tone(hz, 0.5); + data.insert(data.end(), t.begin(), t.end()); + } + appendSilence(data, 0.6); + return data; + } + + // The third note of fourNotes() is at 2.8 to 3.3 s; this range holds + // it whole and is nowhere near frame 0. Widened by half a second it + // runs from 2.25 to 3.85 s, so that both of its edges are in silence + // and the notes on either side are left out of it altogether + static constexpr double thirdNoteFrom = 2.75; + static constexpr double thirdNoteTo = 3.35; + + // The range analyseRange() really covers: half a second either side + // of the range asked for, clipped to the coverage, then out to the + // 256-frame grid + static void widenRange(sv::sv_frame_t start, sv::sv_frame_t end, + sv::sv_frame_t clipStart, sv::sv_frame_t clipEnd, + sv::sv_frame_t &from, sv::sv_frame_t &to) { + sv::sv_frame_t margin = sv::sv_frame_t(rate / 2); + from = std::max(clipStart, start - margin); + to = std::min(clipEnd, end + margin); + from = (from / hop) * hop; + to = ((to + hop - 1) / hop) * hop; + } + + static sv::sv_frame_t frameAt(double seconds) { + return sv::sv_frame_t(seconds * rate); + } + + // Run the analyser on the model and wait for pYIN. If startFrame + // is non-zero, apply it between layer setup and analysis, which + // is what MainWindow::analyseNow() does after a take. + void analyse(Analyser &analyser, sv::ModelId model, + sv::sv_frame_t startFrame = 0) { + QSignalSpy done(&analyser, SIGNAL(initialAnalysisCompleted())); + QString error; + if (startFrame != 0) { + error = analyser.newFileLoaded + (m_document, model, m_paneStack, m_pane, true); + QCOMPARE(error, QString()); + auto wave = sv::ModelById::getAs(model); + QVERIFY(wave); + wave->setStartFrame(startFrame); + error = analyser.analyseExistingFile(); + } else { + error = analyser.newFileLoaded + (m_document, model, m_paneStack, m_pane, false); + } + QCOMPARE(error, QString()); + QVERIFY(analyser.getLayer(Analyser::PitchTrack)); + QVERIFY(analyser.getLayer(Analyser::Notes)); + QVERIFY2(done.count() > 0 || done.wait(30000), + "pYIN did not complete within 30 seconds"); + + // and for the transform to be gone. The first notice at 100 comes + // from one of the two outputs, and the other's can still be + // queued behind it: a test would count that as a second + // initialAnalysisCompleted(), long after pYIN finished. The + // factory lets a transform go only after its notices are in + QTRY_VERIFY_WITH_TIMEOUT(!sv::ModelTransformerFactory::getInstance() + ->haveRunningTransformers(), 30000); + } + + sv::ModelId addSingingModel(const std::vector &data) { + sv::ModelId id = makeAudioModel(data); + m_document->addNonDerivedModel(id); + return id; + } + + static sv::EventVector pitchEvents(Analyser &analyser) { + sv::Layer *layer = analyser.getLayer(Analyser::PitchTrack); + if (!layer) return {}; + auto model = sv::ModelById::getAs + (layer->getModel()); + if (!model) return {}; + return model->getAllEvents(); + } + + static std::shared_ptr notesModel(Analyser &analyser) { + sv::Layer *layer = analyser.getLayer(Analyser::Notes); + if (!layer) return nullptr; + return sv::ModelById::getAs(layer->getModel()); + } + + static sv::EventVector noteEvents(Analyser &analyser) { + auto model = notesModel(analyser); + if (!model) return {}; + return model->getAllEvents(); + } + + // The singing track as MainWindow sets it up for the first recording + // of a take: the analyser takes the audio without analysing any of it + // and makes empty pitch and notes layers for the analysis of the + // recorded range to be merged into + void setUpEmpty(Analyser &analyser, sv::ModelId singing) { + QCOMPARE(analyser.newFileLoaded(m_document, singing, m_paneStack, + m_pane, true), QString()); + QCOMPARE(analyser.addEmptyAnalyses(), QString()); + QVERIFY(analyser.getLayer(Analyser::PitchTrack)); + QVERIFY(analyser.getLayer(Analyser::Notes)); + QVERIFY(pitchEvents(analyser).empty()); + QVERIFY(noteEvents(analyser).empty()); + } + + // Wait for a ranged analysis to be merged. The completion signal + // alone does not say so: the whole-file analysis before it can still + // be delivering its own, late, when the range starts + void waitForRange(Analyser &analyser, QSignalSpy &done) { + QVERIFY2(done.count() > 0 || done.wait(30000), + "the ranged analysis did not complete within 30 seconds"); + QTRY_VERIFY2_WITH_TIMEOUT(!analyser.isAnalysingRange(), + "the analyser still says a range is being " + "analysed", 30000); + } + + // Nothing of a ranged analysis may be left behind in the document + void verifyNothingLeftOver(size_t layersBefore, size_t modelsBefore) { + QCOMPARE(m_document->getLayers().size(), layersBefore); + QCOMPARE(m_document->getModels().size(), modelsBefore); + } + + // Pitch events whose frame is within [from, to), by frame + static std::map pitchIn(const sv::EventVector &events, + sv::sv_frame_t from, + sv::sv_frame_t to) { + std::map m; + for (const auto &e : events) { + if (e.getFrame() >= from && e.getFrame() < to) { + m[e.getFrame()] = e.getValue(); + } + } + return m; + } + + // Notes that start within [from, to) + static sv::EventVector notesIn(const sv::EventVector &events, + sv::sv_frame_t from, sv::sv_frame_t to) { + sv::EventVector out; + for (const auto &e : events) { + if (e.getFrame() >= from && e.getFrame() < to) out.push_back(e); + } + return out; + } + + // The window analyseRange() merges: half the margin on each side of + // the range asked for, inside the run, out to an edge of the run that + // the coverage limit clipped + static void mergeWindow(sv::sv_frame_t start, sv::sv_frame_t end, + sv::sv_frame_t clipStart, sv::sv_frame_t clipEnd, + sv::sv_frame_t &wFrom, sv::sv_frame_t &wTo) { + sv::sv_frame_t margin = sv::sv_frame_t(rate / 2); + sv::sv_frame_t from, to; + widenRange(start, end, clipStart, clipEnd, from, to); + wFrom = (start - margin < clipStart) ? + from : std::max(from, start - margin / 2); + wTo = (end + margin > clipEnd) ? to : std::min(to, end + margin / 2); + } + + static std::set doubledFrames(const sv::EventVector &ev) { + std::set out; + for (size_t i = 1; i < ev.size(); ++i) { + if (ev[i].getFrame() == ev[i-1].getFrame()) { + out.insert(ev[i].getFrame()); + } + } + return out; + } + + static std::map byFrame(const sv::EventVector &ev) { + std::map m; + for (const auto &e : ev) m[e.getFrame()] = e.getValue(); + return m; + } + + // For a failure message: every note as onset+duration@Hz + static QString describeNotes(const sv::EventVector &events) { + QStringList out; + for (const auto &e : events) { + out << QString("%1+%2@%3").arg(e.getFrame()) + .arg(e.getDuration()).arg(double(e.getValue()), 0, 'f', 1); + } + return "[" + out.join(" ") + "]"; + } + + // The pitch track of the whole file before and after a ranged run + // over audio that has not changed: the same frames, no frame twice, + // the same values outside the merge window and near enough inside it + void comparePitchAcrossFile(const sv::EventVector &was, + const sv::EventVector &is, + sv::sv_frame_t wFrom, sv::sv_frame_t wTo) { + // The merge may not leave two events on one frame. pYIN itself + // does, once per run: in fixed-lag mode it stamps the frame 100 + // hops before the end of a run twice, so a frame that was already + // doubled in the whole-file track may still be + QStringList twice; + for (sv::sv_frame_t f : doubledFrames(is)) { + if (!doubledFrames(was).count(f)) { + twice << QString::number(f); + } + } + QVERIFY2(twice.isEmpty(), + qPrintable(QString("two pitch events on one frame after the " + "merge (window %1 to %2): %3") + .arg(wFrom).arg(wTo).arg(twice.join(" ")))); + + auto a = byFrame(was), b = byFrame(is); + QStringList odd; + for (const auto &p : a) if (!b.count(p.first)) odd << QString("-%1").arg(p.first); + for (const auto &p : b) if (!a.count(p.first)) odd << QString("+%1").arg(p.first); + QVERIFY2(odd.isEmpty(), + qPrintable(QString("pitch frames lost (-) or gained (+) by " + "the ranged run, merge window %1 to %2: %3") + .arg(wFrom).arg(wTo).arg(odd.join(" ")))); + double worst = 0.0; + for (const auto &p : a) { + float now = b[p.first]; + if (p.first >= wFrom && p.first < wTo) { + worst = std::max(worst, std::abs + (TestSignals::centsBetween(now, p.second))); + } else { + QVERIFY2(now == p.second, + qPrintable(QString("pitch at %1 changed from %2 to " + "%3, outside the merge window " + "%4 to %5").arg(p.first) + .arg(p.second).arg(now) + .arg(wFrom).arg(wTo))); + } + } + // Measured: 0 cents. The window keeps a quarter of a second of + // the run away from its edges, and that is context enough for + // pYIN to say the same as it said about the whole file + QVERIFY2(worst < 20.0, + qPrintable(QString("worst pitch difference inside the merge " + "window %1 cents").arg(worst))); + } + + static double medianHz(const sv::EventVector &events) { + std::vector values; + for (const auto &e : events) values.push_back(e.getValue()); + if (values.empty()) return 0.0; + std::sort(values.begin(), values.end()); + return values[values.size() / 2]; + } + + static int colourOf(sv::Layer *layer) { + auto scl = qobject_cast(layer); + return scl ? scl->getBaseColour() : -1; + } + + static bool audible(sv::Layer *layer) { + auto params = layer ? layer->getPlayParameters() : nullptr; + return params && params->isPlayAudible(); + } + + struct Callback : public sv::SVFileReaderPaneCallback { + sv::PaneStack *stack; + Callback(sv::PaneStack *s) : stack(s) { } + sv::Pane *addPane() override { return stack->addPane(); } + void setWindowSize(int, int) override { } + void addSelection(sv::sv_frame_t, sv::sv_frame_t) override { } + }; + +private slots: + void initTestCase() { + QVERIFY(m_dir.isValid()); + + // These are the test executable's own settings (see the + // organisation name in the test main). Start from the + // defaults whatever an earlier run left behind. + QSettings().clear(); + + // As the MainWindow and MainWindowBase constructors do. The + // Analyser connects to signals by their unqualified argument + // type names, which only resolve once these are registered. + qRegisterMetaType("sv_frame_t"); + qRegisterMetaType("sv_samplerate_t"); + qRegisterMetaType("ModelId"); + QSettings settings; + settings.beginGroup("Transformer"); + settings.setValue("use-flexi-note-model", true); + settings.endGroup(); + + // As the MainWindow constructor does; only the colours that + // the Analyser asks for by name + auto cdb = sv::ColourDatabase::getInstance(); + cdb->addColour(Qt::black, "Black"); + cdb->addColour(QColor(255, 150, 50), "Orange"); + cdb->addColour(QColor(180, 180, 180), "Grey"); + cdb->setUseDarkBackground + (cdb->addColour(QColor(30, 150, 255), "Bright Blue"), true); + cdb->setUseDarkBackground + (cdb->addColour(QColor(225, 74, 255), "Bright Purple"), true); + } + + void init() { + m_viewManager = new sv::ViewManager; + m_paneStack = new sv::PaneStack(nullptr, m_viewManager); + m_document = new sv::Document; + m_document->setMainModel(makeAudioModel(tone(referenceHz, 2.0))); + m_pane = m_paneStack->addPane(); + } + + void cleanup() { + delete m_document; + delete m_paneStack; + delete m_viewManager; + m_document = nullptr; + m_paneStack = nullptr; + m_viewManager = nullptr; + m_pane = nullptr; + } + + void pitch_track_found() { + Analyser analyser; + analyse(analyser, m_document->getMainModel()); + if (QTest::currentTestFailed()) return; + + auto events = pitchEvents(analyser); + QVERIFY2(events.size() > 100, + qPrintable(QString("only %1 pitch events for 2 s of tone") + .arg(events.size()))); + + double median = medianHz(events); + double cents = TestSignals::centsBetween(median, referenceHz); + QVERIFY2(std::abs(cents) < 10.0, + qPrintable(QString("median %1 Hz is %2 cents from the reference") + .arg(median).arg(cents))); + } + + // Calibrate Audio's reference, its first four events (one tone at + // each pitch): as generated; with the tones' fundamentals taken out, + // quiet and with a room's noise; and as a phone's speaker, or an + // earbud held to the microphone, plays it, nothing much below 1 kHz, + // in a room. pYIN finds each tone's pitch all the same, and not an + // octave or two off, over the tone less a tenth of a second at each + // end + void reference_tones_found_without_their_fundamental_data() { + QTest::addColumn("withoutFundamental"); + QTest::addColumn("highPassHz"); + QTest::addColumn("noise"); + QTest::newRow("as generated") << false << 0.0 << 0.0; + QTest::newRow("no fundamental") << true << 0.0 << 0.0; + QTest::newRow("no fundamental, in a room") << true << 0.0 << 0.001; + QTest::newRow("small speaker, in a room") << false << 1000.0 << 0.003; + } + + void reference_tones_found_without_their_fundamental() { + QFETCH(bool, withoutFundamental); + QFETCH(double, highPassHz); + QFETCH(double, noise); + + LatencyCheck::Layout layout = LatencyCheck::calibrationLayout(rate); + layout.length = layout.events[4].sweepStart; + layout.events.resize(4); + std::vector x = LatencyCheck::generate(layout); + std::set pitches; + for (const LatencyCheck::Event &e : layout.events) { + pitches.insert(e.toneHz); + } + QCOMPARE(int(pitches.size()), 4); + if (withoutFundamental) { + for (double hz : pitches) { + x = TestSignals::withoutFundamental(x, rate, hz); + } + } + if (highPassHz > 0.0) { + // 24 dB an octave: at the tones' pitches 30 to 50 dB down + x = TestSignals::highPassed(TestSignals::highPassed + (x, rate, highPassHz), + rate, highPassHz); + } + if (noise > 0.0) { + // White, at -65 dBFS RMS for 0.001, as the dev checks' fake + // room has it + const std::vector room = + TestSignals::whiteNoise(int(x.size()), 1, noise); + for (size_t i = 0; i < x.size(); ++i) x[i] += room[i]; + } + + Analyser analyser(Analyser::SecondaryColors); + analyse(analyser, addSingingModel(x)); + if (QTest::currentTestFailed()) return; + const sv::EventVector events = pitchEvents(analyser); + + for (const LatencyCheck::Event &e : layout.events) { + const sv::sv_frame_t from = e.toneStart + frameAt(0.1); + const sv::sv_frame_t to = e.toneStart + e.toneLength - frameAt(0.1); + sv::EventVector on; + int close = 0; + for (const sv::Event &p : events) { + if (p.getFrame() < from || p.getFrame() >= to) continue; + on.push_back(p); + if (std::abs(TestSignals::centsBetween(p.getValue(), + e.toneHz)) < 50.0) { + ++close; + } + } + const int hops = int((to - from) / hop); + const double median = medianHz(on); + QVERIFY2(int(on.size()) >= hops * 9 / 10 && + close >= int(on.size()) * 95 / 100 && + std::abs(TestSignals::centsBetween(median, e.toneHz)) < 10.0, + qPrintable(QString("the tone at %1 Hz: %2 pitch events " + "of %3 hops, %4 within 50 cents, the " + "median %5 Hz") + .arg(e.toneHz).arg(on.size()).arg(hops) + .arg(close).arg(median))); + } + } + + void shift_aligns_onset() { + // The core latency-compensation test. The same recording, + // half a second of silence and then a tone, is analysed with + // and without the start-frame shift that analyseNow() applies. + auto data = silenceThenTone(shift, singingHz, 1.5); + + sv::sv_frame_t unshifted = 0, shifted = 0; + + { + Analyser analyser(Analyser::SecondaryColors); + analyse(analyser, addSingingModel(data)); + if (QTest::currentTestFailed()) return; + auto events = pitchEvents(analyser); + QVERIFY(!events.empty()); + unshifted = events.begin()->getFrame(); + analyser.removeAllLayers(); + } + + { + Analyser analyser(Analyser::SecondaryColors); + analyse(analyser, addSingingModel(data), -shift); + if (QTest::currentTestFailed()) return; + auto events = pitchEvents(analyser); + QVERIFY(!events.empty()); + shifted = events.begin()->getFrame(); + analyser.removeAllLayers(); + } + + // Measured: 22016 and 0, the onset exactly. Two hops either + // way allows for pYIN deciding a little later that the tone + // is voiced; the difference between the runs gets one. + QVERIFY2(std::abs(unshifted - shift) <= 2 * hop, + qPrintable(QString("unshifted onset at %1, expected near %2") + .arg(unshifted).arg(shift))); + QVERIFY2(std::abs(shifted) <= 2 * hop, + qPrintable(QString("shifted onset at %1, expected near 0") + .arg(shifted))); + QVERIFY2(std::abs((unshifted - shifted) - shift) <= hop, + qPrintable(QString("onset moved by %1, expected %2") + .arg(unshifted - shifted).arg(shift))); + } + + void shift_survives_session() { + sv::ModelId singing = addSingingModel(tone(singingHz, 0.5)); + auto wave = sv::ModelById::getAs(singing); + QVERIFY(wave); + wave->setStartFrame(-shift); + QString singingPath = wave->getLocation(); + + // The model is only written if a layer in a view uses it + sv::Layer *layer = m_document->createLayer(sv::LayerFactory::Waveform); + QVERIFY(layer); + m_document->setModel(layer, singing); + m_document->addLayerToView(m_pane, layer); + + // As MainWindowBase::toXml() + QString xml; + { + QTextStream out(&xml); + out << "\n" + << "\n\n"; + m_document->toXml(out, "", ""); + out << "\n"; + m_pane->toXml(out, " "); + out << "\n\n"; + } + QVERIFY2(xml.contains(QString("start=\"%1\"").arg(-shift)), + "the shifted start frame was not written to the session"); + + sv::ViewManager viewManager; + sv::PaneStack paneStack(nullptr, &viewManager); + auto document = new sv::Document; + Callback callback(&paneStack); + { + sv::SVFileReader reader(document, callback, m_dir.path()); + reader.parseXml(xml); + QVERIFY2(reader.isOK(), qPrintable(reader.getErrorString())); + } + + std::shared_ptr reloaded; + for (sv::Layer *l : document->getLayers()) { + auto m = sv::ModelById::getAs(l->getModel()); + if (m && m->getLocation() == singingPath) reloaded = m; + } + sv::sv_frame_t start = reloaded ? reloaded->getStartFrame() : 0; + bool found = bool(reloaded); + reloaded.reset(); + delete document; + + QVERIFY2(found, "the singing model was not reloaded"); + QCOMPARE(start, -shift); + } + + void secondary_layers_muted() { + // Guards commit 36d0ce7 + Analyser primary(Analyser::PrimaryColors); + analyse(primary, m_document->getMainModel()); + if (QTest::currentTestFailed()) return; + + Analyser secondary(Analyser::SecondaryColors); + analyse(secondary, addSingingModel(tone(singingHz, 1.0))); + if (QTest::currentTestFailed()) return; + + QVERIFY(primary.getLayer(Analyser::PitchTrack) != + secondary.getLayer(Analyser::PitchTrack)); + QVERIFY(primary.getLayer(Analyser::Notes) != + secondary.getLayer(Analyser::Notes)); + + QVERIFY(!audible(secondary.getLayer(Analyser::PitchTrack))); + QVERIFY(!audible(secondary.getLayer(Analyser::Notes))); + QVERIFY(audible(primary.getLayer(Analyser::PitchTrack))); + QVERIFY(audible(primary.getLayer(Analyser::Notes))); + + // Muting them must not have gone through the settings that + // the two analysers share + QSettings settings; + settings.beginGroup("Analyser"); + QVERIFY(settings.value + (QString("audible-%1").arg(int(Analyser::PitchTrack)), + true).toBool()); + QVERIFY(settings.value + (QString("audible-%1").arg(int(Analyser::Notes)), + true).toBool()); + settings.endGroup(); + } + + void secondary_colours() { + Analyser primary(Analyser::PrimaryColors); + analyse(primary, m_document->getMainModel()); + if (QTest::currentTestFailed()) return; + + Analyser secondary(Analyser::SecondaryColors); + analyse(secondary, addSingingModel(tone(singingHz, 1.0))); + if (QTest::currentTestFailed()) return; + + auto cdb = sv::ColourDatabase::getInstance(); + QCOMPARE(colourOf(primary.getLayer(Analyser::PitchTrack)), + cdb->getColourIndex(QString("Black"))); + QCOMPARE(colourOf(primary.getLayer(Analyser::Notes)), + cdb->getColourIndex(QString("Bright Blue"))); + QCOMPARE(colourOf(secondary.getLayer(Analyser::PitchTrack)), + cdb->getColourIndex(QString("Orange"))); + QCOMPARE(colourOf(secondary.getLayer(Analyser::Notes)), + cdb->getColourIndex(QString("Bright Purple"))); + } + + void secondary_tracks_own_audio() { + // Two analysers in one pane: each pitch track must come from + // its own model, which the two different pitches show + Analyser primary(Analyser::PrimaryColors); + analyse(primary, m_document->getMainModel()); + if (QTest::currentTestFailed()) return; + + Analyser secondary(Analyser::SecondaryColors); + analyse(secondary, addSingingModel(tone(singingHz, 1.0))); + if (QTest::currentTestFailed()) return; + + double p = medianHz(pitchEvents(primary)); + double s = medianHz(pitchEvents(secondary)); + QVERIFY2(std::abs(TestSignals::centsBetween(p, referenceHz)) < 10.0, + qPrintable(QString("primary median %1 Hz").arg(p))); + QVERIFY2(std::abs(TestSignals::centsBetween(s, singingHz)) < 10.0, + qPrintable(QString("secondary median %1 Hz").arg(s))); + } + + void secondary_waveform_is_own_model() { + // The secondary analyser must show the singing audio, not + // make a second layer on the document's main model + sv::ModelId singing = addSingingModel(tone(singingHz, 1.0)); + Analyser secondary(Analyser::SecondaryColors); + QString error = secondary.newFileLoaded + (m_document, singing, m_paneStack, m_pane, true); + QCOMPARE(error, QString()); + + sv::Layer *audio = secondary.getLayer(Analyser::Audio); + QVERIFY(audio); + QVERIFY(audio->getModel() == singing); + + // deferAnalysis: no pYIN yet + QVERIFY(!secondary.getLayer(Analyser::PitchTrack)); + QVERIFY(!secondary.getLayer(Analyser::Notes)); + } + + void remove_all_layers() { + Analyser primary(Analyser::PrimaryColors); + analyse(primary, m_document->getMainModel()); + if (QTest::currentTestFailed()) return; + + sv::ModelId singing = addSingingModel(tone(singingHz, 1.0)); + Analyser secondary(Analyser::SecondaryColors); + analyse(secondary, singing); + if (QTest::currentTestFailed()) return; + + sv::Layer *primaryPitch = primary.getLayer(Analyser::PitchTrack); + int primaryLayers = 0; + for (sv::Layer *l : m_document->getLayers()) { + if (l->getModel() != singing && l->getSourceModel() != singing) { + ++primaryLayers; + } + } + + secondary.removeAllLayers(); + + for (sv::Layer *l : m_document->getLayers()) { + QVERIFY2(l->getModel() != singing, + "a layer still shows the singing model"); + QVERIFY2(l->getSourceModel() != singing, + "a layer derived from the singing model is left"); + } + QVERIFY2(!sv::ModelById::get(singing), + "the singing model was not released"); + QVERIFY(!secondary.getLayer(Analyser::Audio)); + QVERIFY(!secondary.getLayer(Analyser::PitchTrack)); + QVERIFY(!secondary.getLayer(Analyser::Notes)); + + // and the primary analyser's layers were left alone + QCOMPARE(int(m_document->getLayers().size()), primaryLayers); + QVERIFY(primary.getLayer(Analyser::PitchTrack) == primaryPitch); + QVERIFY(m_document->getLayers().count(primaryPitch) > 0); + } + + void ranged_matches_whole_file() { + // The time-stamp question of spec section 11: within its range a + // ranged analysis must give what a whole-file analysis gives. + // Also the "nothing in the models yet" case: the ranged result + // is all there is afterwards + auto data = fourNotes(); + sv::sv_frame_t fileEnd = sv::sv_frame_t(data.size()); + sv::sv_frame_t start = frameAt(thirdNoteFrom); + sv::sv_frame_t end = frameAt(thirdNoteTo); + sv::sv_frame_t from, to; + widenRange(start, end, 0, fileEnd, from, to); + + sv::EventVector wholePitch, wholeNotes; + { + Analyser whole(Analyser::SecondaryColors); + analyse(whole, addSingingModel(data)); + if (QTest::currentTestFailed()) return; + wholePitch = pitchEvents(whole); + wholeNotes = noteEvents(whole); + whole.removeAllLayers(); + } + QCOMPARE(int(wholeNotes.size()), 4); + + Analyser analyser(Analyser::SecondaryColors); + setUpEmpty(analyser, addSingingModel(data)); + if (QTest::currentTestFailed()) return; + + size_t layers = m_document->getLayers().size(); + size_t models = m_document->getModels().size(); + + QSignalSpy done(&analyser, SIGNAL(initialAnalysisCompleted())); + QCOMPARE(analyser.analyseRange(start, end, 0, fileEnd), QString()); + waitForRange(analyser, done); + if (QTest::currentTestFailed()) return; + QCOMPARE(done.count(), 1); + verifyNothingLeftOver(layers, models); + + // Nothing may have landed outside the range that was analysed: + // this is what fails if the time stamps of an output are relative + // to the start of the run rather than to the start of the file + for (const auto &e : pitchEvents(analyser)) { + QVERIFY2(e.getFrame() >= from && e.getFrame() < to + 2 * hop, + qPrintable(QString("pitch event at %1, outside the " + "analysed %2 to %3") + .arg(e.getFrame()).arg(from).arg(to))); + } + for (const auto &e : noteEvents(analyser)) { + QVERIFY2(e.getFrame() >= from && e.getFrame() < to + 2 * hop, + qPrintable(QString("note at %1, outside the analysed " + "%2 to %3") + .arg(e.getFrame()).arg(from).arg(to))); + } + + // Both runs place their events on the grid of the step size: the + // pitch track because its output is at a fixed sample rate of one + // per step, the notes because the range analysed starts on that + // grid (take that away and the notes land between the grid points) + for (const auto &e : wholePitch) QCOMPARE(e.getFrame() % hop, 0); + for (const auto &e : wholeNotes) QCOMPARE(e.getFrame() % hop, 0); + for (const auto &e : pitchEvents(analyser)) { + QCOMPARE(e.getFrame() % hop, 0); + } + for (const auto &e : noteEvents(analyser)) { + QCOMPARE(e.getFrame() % hop, 0); + } + + // The pitch track within the range asked for, frame by frame + auto a = pitchIn(wholePitch, start, end); + auto b = pitchIn(pitchEvents(analyser), start, end); + QVERIFY2(b.size() > 60, + qPrintable(QString("only %1 ranged pitch events in the range") + .arg(b.size()))); + + int odd = 0; + double worst = 0.0; + for (const auto &p : a) { + auto i = b.find(p.first); + if (i == b.end()) { ++odd; continue; } + double cents = std::abs(TestSignals::centsBetween(i->second, + p.second)); + if (cents > worst) worst = cents; + } + for (const auto &p : b) { + if (a.find(p.first) == a.end()) ++odd; + } + // Measured: 0 frames in one run and not the other, 0 cents apart. + // A few frames of slack at the voiced edges, where pYIN's HMM has + // less to go on in the shorter run + QVERIFY2(odd <= 6, + qPrintable(QString("%1 of %2 frames are in one run and not " + "the other").arg(odd).arg(a.size()))); + QVERIFY2(worst < 20.0, + qPrintable(QString("worst pitch difference %1 cents") + .arg(worst))); + + // And the notes that start within it + sv::EventVector an = notesIn(wholeNotes, start, end); + sv::EventVector bn = notesIn(noteEvents(analyser), start, end); + QCOMPARE(int(an.size()), 1); + QCOMPARE(bn.size(), an.size()); + for (size_t i = 0; i < an.size(); ++i) { + QVERIFY2(std::abs(bn[i].getFrame() - an[i].getFrame()) <= 2 * hop, + qPrintable(QString("note starts at %1, whole-file run " + "had %2") + .arg(bn[i].getFrame()).arg(an[i].getFrame()))); + QVERIFY2(std::abs(bn[i].getDuration() - an[i].getDuration()) + <= 2 * hop, + qPrintable(QString("note lasts %1, whole-file run had %2") + .arg(bn[i].getDuration()) + .arg(an[i].getDuration()))); + QVERIFY2(std::abs(TestSignals::centsBetween(bn[i].getValue(), + an[i].getValue())) + < 20.0, + qPrintable(QString("note at %1 Hz, whole-file run had %2") + .arg(bn[i].getValue()).arg(an[i].getValue()))); + } + } + + // A whole-file analysis, then the same audio analysed again over one + // range: the file must come out of it exactly as a whole-file + // analysis left it. No hole where the merge window begins, no frame + // twice, no note split or shortened, nothing moved. Only the middle + // of the run is merged for the sake of this + void verifyRangedRunChangesNothing(sv::sv_frame_t start, + sv::sv_frame_t end) { + auto data = fourNotes(); + sv::sv_frame_t fileEnd = sv::sv_frame_t(data.size()); + sv::sv_frame_t wFrom, wTo; + mergeWindow(start, end, 0, fileEnd, wFrom, wTo); + + Analyser analyser(Analyser::SecondaryColors); + analyse(analyser, addSingingModel(data)); + if (QTest::currentTestFailed()) return; + + sv::EventVector wasPitch = pitchEvents(analyser); + sv::EventVector wasNotes = noteEvents(analyser); + QCOMPARE(int(wasNotes.size()), 4); + QVERIFY2(wasPitch.size() > 200, "too few pitch events to test this"); + + size_t layers = m_document->getLayers().size(); + size_t models = m_document->getModels().size(); + + QSignalSpy done(&analyser, SIGNAL(initialAnalysisCompleted())); + QCOMPARE(analyser.analyseRange(start, end, 0, fileEnd), QString()); + waitForRange(analyser, done); + if (QTest::currentTestFailed()) return; + verifyNothingLeftOver(layers, models); + + comparePitchAcrossFile(wasPitch, pitchEvents(analyser), wFrom, wTo); + if (QTest::currentTestFailed()) return; + + // Still four notes, each where it was. A note whose onset is in + // the window is replaced by the run's own, which may sit a hop or + // two away; one whose onset is outside it is not touched at all + sv::EventVector isNotes = noteEvents(analyser); + QVERIFY2(isNotes.size() == wasNotes.size(), + qPrintable(QString("the notes %1 became %2 (merge window " + "%3 to %4)").arg(describeNotes(wasNotes)) + .arg(describeNotes(isNotes)) + .arg(wFrom).arg(wTo))); + for (size_t i = 0; i < wasNotes.size(); ++i) { + sv::sv_frame_t f = wasNotes[i].getFrame(); + sv::sv_frame_t slack = (f >= wFrom && f < wTo) ? 2 * hop : 0; + QVERIFY2(std::abs(isNotes[i].getFrame() - f) <= slack, + qPrintable(QString("note %1 moved from %2 to %3") + .arg(i).arg(f).arg(isNotes[i].getFrame()))); + QVERIFY2(std::abs(isNotes[i].getDuration() - + wasNotes[i].getDuration()) <= slack, + qPrintable(QString("note %1 lasted %2, now %3").arg(i) + .arg(wasNotes[i].getDuration()) + .arg(isNotes[i].getDuration()))); + QVERIFY(std::abs(TestSignals::centsBetween + (isNotes[i].getValue(), + wasNotes[i].getValue())) < 20.0); + } + } + + void ranged_leaves_the_rest_alone() { + // The third note of fourNotes() analysed again, with both edges of + // the merge window landing in the silence around it + verifyRangedRunChangesNothing(frameAt(thirdNoteFrom), + frameAt(thirdNoteTo)); + } + + void ranged_range_shorter_than_the_margin() { + // One hop asked for, in the middle of the third note: the merge + // window is half a second wide all the same, and its right edge + // now falls inside that note (which the run therefore replaces, + // whole, tail and all -- the note's end is inside the run) + sv::sv_frame_t start = frameAt(3.0); + sv::sv_frame_t wFrom, wTo; + mergeWindow(start, start + hop, 0, frameAt(5.0), wFrom, wTo); + QVERIFY(wFrom >= frameAt(2.75) && wTo > frameAt(3.25) && + wTo < frameAt(3.3)); // the note runs 2.8 to 3.3 s + verifyRangedRunChangesNothing(start, start + hop); + } + + void ranged_keeps_a_note_across_the_window_edge() { + // A note that runs into the merge window from before it: the run + // finds it going on from before the window too, so it has no note + // to add inside the window, and the one note stays one note -- + // where it used to be cut in two + std::vector data; + appendSilence(data, 0.3); + auto lng = tone(singingHz, 2.0); + data.insert(data.end(), lng.begin(), lng.end()); + appendSilence(data, 0.3); + auto second = tone(referenceHz, 0.5); + data.insert(data.end(), second.begin(), second.end()); + appendSilence(data, 0.3); + + sv::sv_frame_t fileEnd = sv::sv_frame_t(data.size()); + sv::sv_frame_t start = frameAt(1.5), end = frameAt(2.0); + sv::sv_frame_t wFrom, wTo; + mergeWindow(start, end, 0, fileEnd, wFrom, wTo); + + Analyser analyser(Analyser::SecondaryColors); + analyse(analyser, addSingingModel(data)); + if (QTest::currentTestFailed()) return; + + // One long note from 0.3 to 2.3 s, and a short one after it + sv::EventVector wasNotes = noteEvents(analyser); + sv::EventVector wasPitch = pitchEvents(analyser); + QCOMPARE(int(wasNotes.size()), 2); + sv::Event crossing = wasNotes[0]; + QVERIFY2(crossing.getFrame() < wFrom && + crossing.getFrame() + crossing.getDuration() > wTo, + qPrintable(QString("the long note (%1 for %2) does not cross " + "the merge window %3 to %4") + .arg(crossing.getFrame()) + .arg(crossing.getDuration()) + .arg(wFrom).arg(wTo))); + + QSignalSpy done(&analyser, SIGNAL(initialAnalysisCompleted())); + QCOMPARE(analyser.analyseRange(start, end, 0, fileEnd), QString()); + waitForRange(analyser, done); + if (QTest::currentTestFailed()) return; + + // The run begins in the middle of the note, where it cannot stamp + // its first two hops: the pitch track of the whole file must come + // through it without a hole all the same + comparePitchAcrossFile(wasPitch, pitchEvents(analyser), wFrom, wTo); + if (QTest::currentTestFailed()) return; + + sv::EventVector now = noteEvents(analyser); + QVERIFY2(now.size() == wasNotes.size() && + now[0].getFrame() == crossing.getFrame() && + now[0].getDuration() == crossing.getDuration(), + qPrintable(QString("the notes %1 became %2 (merge window " + "%3 to %4)").arg(describeNotes(wasNotes)) + .arg(describeNotes(now)).arg(wFrom).arg(wTo))); + + // and the note after the range is untouched + QCOMPARE(now.back().getFrame(), wasNotes[1].getFrame()); + QCOMPARE(now.back().getDuration(), wasNotes[1].getDuration()); + } + + void ranged_cuts_a_new_note_at_the_next_old_note() { + // A new note may not run over an old note that begins at or after + // the window: the old onset stands and the new note is cut back to + // it. The old notes here are put in by hand, as notes that the + // user has edited or that belong to material the swap replaced + std::vector data; + appendSilence(data, 1.4); + auto lng = tone(singingHz, 1.6); // 1.4 to 3.0 s + data.insert(data.end(), lng.begin(), lng.end()); + appendSilence(data, 0.3); + + sv::sv_frame_t fileEnd = sv::sv_frame_t(data.size()); + sv::sv_frame_t start = frameAt(1.5), end = frameAt(2.0); + sv::sv_frame_t wFrom, wTo; + mergeWindow(start, end, 0, fileEnd, wFrom, wTo); + QVERIFY(wFrom < frameAt(1.4) && wTo > frameAt(2.2)); + + Analyser analyser(Analyser::SecondaryColors); + setUpEmpty(analyser, addSingingModel(data)); + if (QTest::currentTestFailed()) return; + auto notes = notesModel(analyser); + QVERIFY(notes); + + // Before the window, inside it, and one beginning after it that + // the run's note will want to run over + sv::Event before(frameAt(0.2), 200.f, frameAt(0.3), "before"); + sv::Event inside(frameAt(1.6), 200.f, frameAt(0.3), "inside"); + sv::Event after(wTo + frameAt(0.05), 200.f, frameAt(0.6), "after"); + for (const auto &e : { before, inside, after }) notes->add(e); + + QSignalSpy done(&analyser, SIGNAL(initialAnalysisCompleted())); + QCOMPARE(analyser.analyseRange(start, end, 0, fileEnd), QString()); + waitForRange(analyser, done); + if (QTest::currentTestFailed()) return; + + sv::EventVector now = noteEvents(analyser); + QVERIFY2(now.size() == 3, + qPrintable("notes after the merge: " + describeNotes(now))); + + // The note before the window is untouched: no new note starts + // inside it + QCOMPARE(now[0].getFrame(), before.getFrame()); + QCOMPARE(now[0].getDuration(), before.getDuration()); + + // The one inside it is gone, replaced by the run's note, which + // starts near the start of the tone and is cut back to the onset + // of the note after the window + QVERIFY2(std::abs(now[1].getFrame() - frameAt(1.4)) <= 4 * hop, + qPrintable(QString("the run's note starts at %1, the tone at " + "%2").arg(now[1].getFrame()) + .arg(frameAt(1.4)))); + QCOMPARE(now[1].getFrame() + now[1].getDuration(), after.getFrame()); + QVERIFY(std::abs(TestSignals::centsBetween(now[1].getValue(), + singingHz)) < 50.0); + + // and the note after the window keeps its onset and its length + QCOMPARE(now[2].getFrame(), after.getFrame()); + QCOMPARE(now[2].getDuration(), after.getDuration()); + } + + void ranged_keeps_the_end_of_a_note_past_the_run() { + // A note that begins inside the merge window and is still going + // when the run ends: the run had to cut it off where it stopped + // listening, but the audio out there has not changed, so the note + // ends where the old note that covered the run's end ended + std::vector data; + appendSilence(data, 1.4); + auto lng = tone(singingHz, 2.4); // 1.4 to 3.8 s + data.insert(data.end(), lng.begin(), lng.end()); + appendSilence(data, 0.3); + + sv::sv_frame_t fileEnd = sv::sv_frame_t(data.size()); + sv::sv_frame_t start = frameAt(1.5), end = frameAt(2.0); + sv::sv_frame_t from, to, wFrom, wTo; + widenRange(start, end, 0, fileEnd, from, to); + mergeWindow(start, end, 0, fileEnd, wFrom, wTo); + // The note begins inside the window and outlasts the run + QVERIFY(wFrom < frameAt(1.4) && wTo > frameAt(1.4)); + QVERIFY(to < frameAt(3.5)); + + Analyser analyser(Analyser::SecondaryColors); + analyse(analyser, addSingingModel(data)); + if (QTest::currentTestFailed()) return; + + sv::EventVector wasNotes = noteEvents(analyser); + QCOMPARE(int(wasNotes.size()), 1); + sv::sv_frame_t wasEnd = + wasNotes[0].getFrame() + wasNotes[0].getDuration(); + QVERIFY2(wasEnd > to, + qPrintable(QString("the long note (%1 for %2) does not " + "outlast the run, which ends at %3") + .arg(wasNotes[0].getFrame()) + .arg(wasNotes[0].getDuration()).arg(to))); + + QSignalSpy done(&analyser, SIGNAL(initialAnalysisCompleted())); + QCOMPARE(analyser.analyseRange(start, end, 0, fileEnd), QString()); + waitForRange(analyser, done); + if (QTest::currentTestFailed()) return; + + sv::EventVector now = noteEvents(analyser); + QVERIFY2(now.size() == 1, + qPrintable("notes after the merge: " + describeNotes(now))); + QVERIFY(std::abs(now[0].getFrame() - wasNotes[0].getFrame()) <= + 4 * hop); + QVERIFY2(now[0].getFrame() + now[0].getDuration() == wasEnd, + qPrintable(QString("the note %1 was cut off at the end of " + "the run (%2); it used to end at %3") + .arg(describeNotes(now)).arg(to).arg(wasEnd))); + } + + void ranged_join_inside_a_note_data() { + QTest::addColumn("sameNote"); + QTest::newRow("the same note going on") << true; + QTest::newRow("a new note from the join") << false; + } + + void ranged_join_inside_a_note() { + // Two punch-ins meeting at J, as MainWindow analyses them: the + // first's range alone, its coverage ending at J, then the second's + // with the coverage grown to take it in. The second's run starts + // half a second before J, inside the first's note, so the note it + // finds there begins before the merge window. When the singer + // holds the note through J, that is the first's note going on, and + // the two are one. When a different note begins at J, they are two: + // the first's note must not be carried on over the new one + QFETCH(bool, sameNote); + + std::vector data; + appendSilence(data, 0.2); + auto held = tone(singingHz, 1.3); // 0.2 to 1.5 s + data.insert(data.end(), held.begin(), held.end()); + auto then = tone(sameNote ? singingHz : referenceHz, 1.3); + data.insert(data.end(), then.begin(), then.end()); // to 2.8 s + appendSilence(data, 0.3); + + const sv::sv_frame_t P = frameAt(0.5), J = frameAt(1.5), + E = frameAt(2.5); + sv::sv_frame_t wFrom, wTo; + mergeWindow(J, E, P, E, wFrom, wTo); + QVERIFY(wFrom > P && wFrom < J); + + Analyser analyser(Analyser::SecondaryColors); + setUpEmpty(analyser, addSingingModel(data)); + if (QTest::currentTestFailed()) return; + + QSignalSpy done(&analyser, SIGNAL(initialAnalysisCompleted())); + QCOMPARE(analyser.analyseRange(P, J, P, J), QString()); + waitForRange(analyser, done); + if (QTest::currentTestFailed()) return; + + // The first punch-in's note, running from near P to J + sv::EventVector first = noteEvents(analyser); + QVERIFY2(first.size() == 1 && + std::abs(first[0].getFrame() - P) <= 4 * hop && + std::abs(first[0].getFrame() + first[0].getDuration() - J) + <= 4 * hop, qPrintable("first: " + describeNotes(first))); + + done.clear(); + QCOMPARE(analyser.analyseRange(J, E, P, E), QString()); + waitForRange(analyser, done); + if (QTest::currentTestFailed()) return; + + sv::EventVector now = noteEvents(analyser); + QString what = QString("the first punch-in's note %1 and the second's " + "(merge window %2 to %3) became %4") + .arg(describeNotes(first)).arg(wFrom).arg(wTo) + .arg(describeNotes(now)); + + // The first's note keeps its onset and pitch either way + QVERIFY2(!now.empty() && now[0].getFrame() == first[0].getFrame() && + now[0].getValue() == first[0].getValue(), qPrintable(what)); + sv::sv_frame_t end = now[0].getFrame() + now[0].getDuration(); + + if (sameNote) { + // and runs on through J to the end of the coverage + QVERIFY2(now.size() == 1 && std::abs(end - E) <= 4 * hop, + qPrintable(what)); + } else { + // and still ends at J, where the new note begins, sung at its + // own pitch + QVERIFY2(now.size() == 2 && std::abs(end - J) <= 4 * hop && + now[1].getFrame() >= end && + std::abs(now[1].getFrame() - J) <= 4 * hop && + std::abs(TestSignals::centsBetween + (now[1].getValue(), referenceHz)) < 50.0, + qPrintable(what)); + } + } + + void ranged_at_the_edge_of_coverage() { + // Where the caller's coverage limit clips an edge of the run there + // is nothing beyond it but silence and no context worth keeping, + // so the window reaches that edge and takes in everything the run + // stamped there. Three cases: both edges clipped (the first + // recording of a take, with nothing in the models yet), the left + // edge, the right edge + std::vector data; + appendSilence(data, 0.15); + auto first = tone(singingHz, 0.5); // 0.15 to 0.65 s + data.insert(data.end(), first.begin(), first.end()); + appendSilence(data, 0.4); + auto second = tone(referenceHz, 0.5); // 1.05 to 1.55 s + data.insert(data.end(), second.begin(), second.end()); + appendSilence(data, 0.45); // silence beyond the coverage + + sv::sv_frame_t fileEnd = sv::sv_frame_t(data.size()); + sv::sv_frame_t coverEnd = frameAt(1.55); + + sv::EventVector wholePitch, wholeNotes; + { + Analyser whole(Analyser::SecondaryColors); + analyse(whole, addSingingModel(data)); + if (QTest::currentTestFailed()) return; + wholePitch = pitchEvents(whole); + wholeNotes = noteEvents(whole); + whole.removeAllLayers(); + } + QCOMPARE(int(wholeNotes.size()), 2); + + // Both edges: empty models, the coverage is the range recorded, + // and the run is the whole of it. Nothing may be dropped at + // either end, so the result is the whole-file result + { + Analyser analyser(Analyser::SecondaryColors); + setUpEmpty(analyser, addSingingModel(data)); + if (QTest::currentTestFailed()) return; + QSignalSpy done(&analyser, SIGNAL(initialAnalysisCompleted())); + QCOMPARE(analyser.analyseRange(0, coverEnd, 0, coverEnd), + QString()); + waitForRange(analyser, done); + if (QTest::currentTestFailed()) return; + + auto a = byFrame(wholePitch), b = byFrame(pitchEvents(analyser)); + QStringList odd; + for (const auto &p : a) if (!b.count(p.first)) odd << QString("-%1").arg(p.first); + for (const auto &p : b) if (!a.count(p.first)) odd << QString("+%1").arg(p.first); + QVERIFY2(odd.isEmpty(), + qPrintable("pitch frames lost (-) or gained (+) by the " + "run over the whole coverage: " + + odd.join(" "))); + sv::EventVector notes = noteEvents(analyser); + QVERIFY2(notes.size() == wholeNotes.size(), + qPrintable("notes " + describeNotes(wholeNotes) + + " became " + describeNotes(notes))); + for (size_t i = 0; i < notes.size(); ++i) { + QVERIFY(std::abs(notes[i].getFrame() - + wholeNotes[i].getFrame()) <= 2 * hop); + } + // or the next analyser would claim these layers as its own + analyser.removeAllLayers(); + } + + // The left edge only: a range at the very start of the coverage + // still gets its first note, and nothing before it is lost + { + Analyser analyser(Analyser::SecondaryColors); + analyse(analyser, addSingingModel(data)); + if (QTest::currentTestFailed()) return; + sv::EventVector wasPitch = pitchEvents(analyser); + sv::sv_frame_t start = frameAt(0.1), end = frameAt(0.7); + sv::sv_frame_t wFrom, wTo; + mergeWindow(start, end, 0, fileEnd, wFrom, wTo); + QCOMPARE(wFrom, sv::sv_frame_t(0)); // clipped to nothing + + QSignalSpy done(&analyser, SIGNAL(initialAnalysisCompleted())); + QCOMPARE(analyser.analyseRange(start, end, 0, fileEnd), QString()); + waitForRange(analyser, done); + if (QTest::currentTestFailed()) return; + + sv::EventVector notes = noteEvents(analyser); + QVERIFY2(notes.size() == 2, + qPrintable("notes after the merge: " + + describeNotes(notes))); + QVERIFY(std::abs(notes[0].getFrame() - + wholeNotes[0].getFrame()) <= 2 * hop); + comparePitchAcrossFile(wasPitch, pitchEvents(analyser), wFrom, wTo); + if (QTest::currentTestFailed()) return; + analyser.removeAllLayers(); + } + + // The right edge only: the run stops at the end of the coverage, + // and what it stamped past that end (pYIN stamps a block two hops + // in) is kept, so the last of the pitch track is not lost + { + Analyser analyser(Analyser::SecondaryColors); + analyse(analyser, addSingingModel(data)); + if (QTest::currentTestFailed()) return; + sv::EventVector wasPitch = pitchEvents(analyser); + sv::sv_frame_t start = frameAt(1.2), end = coverEnd; + sv::sv_frame_t wFrom, wTo; + mergeWindow(start, end, 0, coverEnd, wFrom, wTo); + QVERIFY(wTo >= coverEnd); + + QSignalSpy done(&analyser, SIGNAL(initialAnalysisCompleted())); + QCOMPARE(analyser.analyseRange(start, end, 0, coverEnd), QString()); + waitForRange(analyser, done); + if (QTest::currentTestFailed()) return; + + sv::EventVector notes = noteEvents(analyser); + QVERIFY2(notes.size() == 2, + qPrintable("notes after the merge: " + + describeNotes(notes))); + QVERIFY(std::abs(notes[1].getFrame() - + wholeNotes[1].getFrame()) <= 2 * hop); + // The window reaches past the end of the run here, so the + // comparison allows for the run's own last events + comparePitchAcrossFile(wasPitch, pitchEvents(analyser), + wFrom, wTo + 4 * hop); + } + } + + void ranged_cancelled() { + // Closing, re-recording or switching take while a ranged + // analysis runs goes through cancelAnalyses() + auto data = fourNotes(); + sv::sv_frame_t fileEnd = sv::sv_frame_t(data.size()); + + Analyser analyser(Analyser::SecondaryColors); + analyse(analyser, addSingingModel(data)); + if (QTest::currentTestFailed()) return; + + sv::EventVector wasPitch = pitchEvents(analyser); + sv::EventVector wasNotes = noteEvents(analyser); + size_t layers = m_document->getLayers().size(); + size_t models = m_document->getModels().size(); + + int inPane = m_pane->getLayerCount(); + sv::Layer *current = m_pane->getSelectedLayer(); + + QSignalSpy done(&analyser, SIGNAL(initialAnalysisCompleted())); + QCOMPARE(analyser.analyseRange(0, fileEnd, 0, fileEnd), QString()); + QVERIFY2(analyser.isAnalysingRange(), + "the whole file was analysed before we could cancel it"); + QVERIFY(m_document->getLayers().size() > layers); + + // The temporary layers are in the document but in no view, so + // nothing shows them, nothing selects them, and the scan for + // existing analyses (which looks in the pane) cannot take them + // for the analyser's own + QCOMPARE(m_pane->getLayerCount(), inPane); + QVERIFY(m_pane->getSelectedLayer() == current); + + analyser.cancelAnalyses(); + + QVERIFY(!analyser.isAnalysingRange()); + verifyNothingLeftOver(layers, models); + + // and no late merge from the transform we abandoned + QTest::qWait(500); + QCOMPARE(done.count(), 0); + verifyNothingLeftOver(layers, models); + QCOMPARE(pitchEvents(analyser).size(), wasPitch.size()); + QCOMPARE(noteEvents(analyser).size(), wasNotes.size()); + for (size_t i = 0; i < wasPitch.size(); ++i) { + QCOMPARE(pitchEvents(analyser)[i].getFrame(), + wasPitch[i].getFrame()); + } + } + + void ranged_released_while_running() { + // releaseLayers() (the model swap of 4a) and the destructor must + // take the temporaries with them too + auto data = fourNotes(); + sv::sv_frame_t fileEnd = sv::sv_frame_t(data.size()); + + size_t layers = 0, models = 0; + { + Analyser analyser(Analyser::SecondaryColors); + analyse(analyser, addSingingModel(data)); + if (QTest::currentTestFailed()) return; + layers = m_document->getLayers().size(); + models = m_document->getModels().size(); + + QCOMPARE(analyser.analyseRange(0, fileEnd, 0, fileEnd), QString()); + QVERIFY(analyser.isAnalysingRange()); + analyser.releaseLayers(); + QVERIFY(!analyser.isAnalysingRange()); + + // releaseLayers() deletes the waveform layer and its model + QCOMPARE(m_document->getLayers().size(), layers - 1); + } + + // A second run, abandoned by the destructor this time + { + Analyser analyser(Analyser::SecondaryColors); + analyse(analyser, addSingingModel(data)); + if (QTest::currentTestFailed()) return; + layers = m_document->getLayers().size(); + models = m_document->getModels().size(); + + QCOMPARE(analyser.analyseRange(0, fileEnd, 0, fileEnd), QString()); + QVERIFY(analyser.isAnalysingRange()); + } + verifyNothingLeftOver(layers, models); + } + + void ranged_restarted_while_running() { + // A second call abandons the first: its material is presumed to + // have changed under us + auto data = fourNotes(); + sv::sv_frame_t fileEnd = sv::sv_frame_t(data.size()); + sv::sv_frame_t firstStart = frameAt(0.5), firstEnd = frameAt(1.2); + sv::sv_frame_t start = frameAt(thirdNoteFrom); + sv::sv_frame_t end = frameAt(thirdNoteTo); + sv::sv_frame_t abandoned0, abandoned1, from, to; + widenRange(firstStart, firstEnd, 0, fileEnd, abandoned0, abandoned1); + widenRange(start, end, 0, fileEnd, from, to); + QVERIFY(abandoned1 < from); // the two ranges do not meet + + Analyser analyser(Analyser::SecondaryColors); + setUpEmpty(analyser, addSingingModel(data)); + if (QTest::currentTestFailed()) return; + + size_t layers = m_document->getLayers().size(); + size_t models = m_document->getModels().size(); + + QSignalSpy done(&analyser, SIGNAL(initialAnalysisCompleted())); + QCOMPARE(analyser.analyseRange(firstStart, firstEnd, 0, fileEnd), + QString()); + QVERIFY(analyser.isAnalysingRange()); + QCOMPARE(analyser.analyseRange(start, end, 0, fileEnd), QString()); + QVERIFY(analyser.isAnalysingRange()); + + waitForRange(analyser, done); + if (QTest::currentTestFailed()) return; + QCOMPARE(done.count(), 1); + verifyNothingLeftOver(layers, models); + + // Only the second range was merged + auto events = pitchEvents(analyser); + QVERIFY(events.size() > 60); + for (const auto &e : events) { + QVERIFY2(e.getFrame() >= from && e.getFrame() < to + 2 * hop, + qPrintable(QString("pitch event at %1, from the range " + "that was abandoned").arg(e.getFrame()))); + } + QCOMPARE(int(notesIn(noteEvents(analyser), abandoned0, abandoned1).size()), + 0); + QVERIFY(notesIn(noteEvents(analyser), from, to + 2 * hop).size() >= 1); + } +}; + +#endif diff --git a/main/test/TestSingingDocument.h b/main/test/TestSingingDocument.h new file mode 100644 index 00000000..b24605d9 --- /dev/null +++ b/main/test/TestSingingDocument.h @@ -0,0 +1,325 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TEST_SINGING_DOCUMENT_H +#define TEST_SINGING_DOCUMENT_H + +// Tier 3: the Document ownership rules that the singing-track flows +// depend on, and the pane pruning helper built on them. No MainWindow. +// +// Each test starts from the state the application is in after a +// session load: a main model, pane 0, and a time ruler in pane 0 that +// the document's other panes share. + +#include "../PaneUtils.h" + +#include "framework/Document.h" +#include "view/Pane.h" +#include "view/PaneStack.h" +#include "view/ViewManager.h" +#include "layer/Layer.h" +#include "layer/LayerFactory.h" +#include "widgets/CommandHistory.h" +#include "data/model/WritableWaveFileModel.h" + +#include +#include +#include +#include + +#include +#include + +class TestSingingDocument : public QObject +{ + Q_OBJECT + + QTemporaryDir m_dir; + int m_fileCounter = 0; + + sv::ViewManager *m_viewManager = nullptr; + sv::PaneStack *m_paneStack = nullptr; + sv::Document *m_document = nullptr; + sv::Pane *m_mainPane = nullptr; + sv::Layer *m_ruler = nullptr; + + sv::ModelId makeAudioModel() { + QString path = m_dir.filePath + (QString("audio-%1.wav").arg(++m_fileCounter)); + auto model = std::make_shared + (path, 44100.0, 1, + sv::WritableWaveFileModel::Normalisation::None); + std::vector data(4410, 0.25f); + const float *ptr = data.data(); + model->addSamples(&ptr, sv::sv_frame_t(data.size())); + model->writeComplete(); + return sv::ModelById::add(model); + } + + // What openAudio(CreateAdditionalModel) and record() leave behind: + // a new pane holding an imported waveform layer that is the only + // reference to the new model and, optionally, the shared ruler. + struct Extra { + sv::ModelId model; + sv::Layer *waveform = nullptr; + sv::Pane *pane = nullptr; + }; + + Extra addExtraPane(bool withRuler) { + Extra extra; + extra.model = makeAudioModel(); + extra.waveform = m_document->createImportedLayer(extra.model); + extra.pane = m_paneStack->addPane(); + if (withRuler) { + m_document->addLayerToView(extra.pane, m_ruler); + } + if (extra.waveform) { + m_document->addLayerToView(extra.pane, extra.waveform); + } + return extra; + } + + // As Analyser::addWaveform() and loadBackgroundMusic() do + sv::Layer *addSecondReference(sv::ModelId model) { + sv::Layer *layer = m_document->createLayer(sv::LayerFactory::Waveform); + if (!layer) return nullptr; + m_document->setModel(layer, model); + m_document->addLayerToView(m_mainPane, layer); + return layer; + } + + static bool paneHasLayer(sv::Pane *pane, sv::Layer *layer) { + for (int i = 0; i < pane->getLayerCount(); ++i) { + if (pane->getLayer(i) == layer) return true; + } + return false; + } + + bool documentHasLayer(sv::Layer *layer) const { + return m_document->getLayers().count(layer) > 0; + } + + static bool modelAlive(sv::ModelId id) { + return bool(sv::ModelById::get(id)); + } + +private slots: + void initTestCase() { + QVERIFY(m_dir.isValid()); + qRegisterMetaType("Layer*"); + } + + void init() { + m_viewManager = new sv::ViewManager; + m_paneStack = new sv::PaneStack(nullptr, m_viewManager); + m_document = new sv::Document; + m_document->setMainModel(makeAudioModel()); + m_mainPane = m_paneStack->addPane(); + m_ruler = m_document->createMainModelLayer(sv::LayerFactory::TimeRuler); + QVERIFY(m_ruler); + m_document->addLayerToView(m_mainPane, m_ruler); + } + + void cleanup() { + // The document force-deletes its layers from their views, so + // it has to go while the panes still exist + delete m_document; + delete m_paneStack; + delete m_viewManager; + m_document = nullptr; + m_paneStack = nullptr; + m_viewManager = nullptr; + m_mainPane = nullptr; + m_ruler = nullptr; + } + + // --- The rules ------------------------------------------------------- + + void force_delete_releases_sole_model() { + // The mechanism behind review finding 1 + Extra extra = addExtraPane(false); + QVERIFY(extra.waveform); + QVERIFY(modelAlive(extra.model)); + + m_document->deleteLayer(extra.waveform, true); + + QVERIFY(!documentHasLayer(extra.waveform)); + QVERIFY(!modelAlive(extra.model)); + } + + void second_layer_keeps_model_alive() { + // The background-music workaround + Extra extra = addExtraPane(false); + QVERIFY(extra.waveform); + QVERIFY(addSecondReference(extra.model)); + + m_document->deleteLayer(extra.waveform, true); + + QVERIFY(modelAlive(extra.model)); + } + + void detach_keeps_layer_and_model() { + // Document::detachLayerFromView is an addition in the svapp fork + Extra extra = addExtraPane(false); + QVERIFY(extra.waveform); + QSignalSpy commands(sv::CommandHistory::getInstance(), + SIGNAL(commandExecuted())); + + m_document->detachLayerFromView(extra.pane, extra.waveform); + + QVERIFY(!paneHasLayer(extra.pane, extra.waveform)); + QVERIFY(documentHasLayer(extra.waveform)); + QVERIFY(modelAlive(extra.model)); + QCOMPARE(int(commands.count()), 0); + } + + void force_delete_skips_play_source() { + // layerInAView(layer, false) is what usually takes a model out + // of the play source. A forced delete never emits it (review + // finding 6); what the main window goes by then is + // modelAboutToBeReleased. + Extra viaCommand = addExtraPane(false); + Extra viaForce = addExtraPane(false); + QVERIFY(viaCommand.waveform && viaForce.waveform); + QSignalSpy inAView(m_document, + SIGNAL(layerInAView(Layer *, bool))); + QVERIFY(inAView.isValid()); + QSignalSpy released(m_document, + SIGNAL(modelAboutToBeReleased(ModelId))); + QVERIFY(released.isValid()); + + m_document->removeLayerFromView(viaCommand.pane, viaCommand.waveform); + QCOMPARE(int(inAView.count()), 1); + QCOMPARE(inAView.at(0).at(1).toBool(), false); + QCOMPARE(int(released.count()), 0); + + m_document->deleteLayer(viaForce.waveform, true); + QCOMPARE(int(inAView.count()), 1); + QCOMPARE(int(released.count()), 1); + QVERIFY(!modelAlive(viaForce.model)); + } + + // --- The pruning helper ---------------------------------------------- + + void shared_ruler_survives_prune() { + // Review finding 2 + Extra extra = addExtraPane(true); + QVERIFY(extra.waveform); + QVERIFY(addSecondReference(extra.model)); + QVERIFY(paneHasLayer(extra.pane, m_ruler)); + + pruneExtraPane(m_document, m_paneStack, extra.pane, extra.model); + + QVERIFY2(documentHasLayer(m_ruler), + "the shared time ruler was deleted from the document"); + QVERIFY(paneHasLayer(m_mainPane, m_ruler)); + } + + void prune_deletes_orphan_and_pane() { + Extra extra = addExtraPane(true); + QVERIFY(extra.waveform); + sv::Layer *kept = addSecondReference(extra.model); + QVERIFY(kept); + QCOMPARE(m_paneStack->getPaneCount(), 2); + QSignalSpy commands(sv::CommandHistory::getInstance(), + SIGNAL(commandExecuted())); + + pruneExtraPane(m_document, m_paneStack, extra.pane, extra.model); + + QCOMPARE(m_paneStack->getPaneCount(), 1); + QCOMPARE(m_paneStack->getHiddenPaneCount(), 0); + QVERIFY(!documentHasLayer(extra.waveform)); + QVERIFY(documentHasLayer(kept)); + QVERIFY(modelAlive(extra.model)); + // Nothing undoable may be left pointing at the dead pane + QCOMPARE(int(commands.count()), 0); + } + + void prune_without_second_ref_releases_model() { + // What loadSingingTrack() relies on when the analyser could not + // be set up: pruning is then what disposes of the model + Extra extra = addExtraPane(true); + QVERIFY(extra.waveform); + + pruneExtraPane(m_document, m_paneStack, extra.pane, extra.model); + + QVERIFY(!modelAlive(extra.model)); + QCOMPARE(m_paneStack->getPaneCount(), 1); + } + + void prune_none_id_deletes_nothing() { + // A layer that was never given a model has the "none" id. An + // empty owned id must not be taken to match it. (Not the ruler: + // createMainModelLayer() gives that the main model's id.) + Extra extra = addExtraPane(true); + QVERIFY(extra.waveform); + sv::Layer *noModel = + m_document->createLayer(sv::LayerFactory::TimeValues); + QVERIFY(noModel); + QVERIFY2(noModel->getModel().isNone(), + "a layer made without a model does not have the none id"); + m_document->addLayerToView(extra.pane, noModel); + QVERIFY2(paneHasLayer(extra.pane, noModel), + "the layer without a model was not added to the pane"); + + pruneExtraPane(m_document, m_paneStack, extra.pane, sv::ModelId()); + + QVERIFY2(documentHasLayer(noModel), + "the layer without a model was deleted from the document"); + QVERIFY2(documentHasLayer(m_ruler), + "the shared time ruler was deleted from the document"); + QVERIFY(paneHasLayer(m_mainPane, m_ruler)); + QVERIFY(documentHasLayer(extra.waveform)); + QVERIFY(modelAlive(extra.model)); + QCOMPARE(m_paneStack->getPaneCount(), 1); + } + + void prune_hidden_pane() { + // The record flow hides the pane first and prunes it later + Extra extra = addExtraPane(true); + QVERIFY(extra.waveform); + QVERIFY(addSecondReference(extra.model)); + m_paneStack->hidePane(extra.pane); + QCOMPARE(m_paneStack->getPaneCount(), 1); + QCOMPARE(m_paneStack->getHiddenPaneCount(), 1); + + pruneExtraPane(m_document, m_paneStack, extra.pane, extra.model); + + QCOMPARE(m_paneStack->getHiddenPaneCount(), 0); + QVERIFY2(documentHasLayer(m_ruler), + "the shared time ruler was deleted from the document"); + QVERIFY(paneHasLayer(m_mainPane, m_ruler)); + QVERIFY(!documentHasLayer(extra.waveform)); + QVERIFY(modelAlive(extra.model)); + } + + void layer_view_map_clean_after_prune() { + // If pruning left the dead pane in the document's layer-to-view + // map, force-deleting the ruler would call into the freed + // widget. Only a memory checker makes that failure certain; + // without one this at least exercises the path. + Extra extra = addExtraPane(true); + QVERIFY(extra.waveform); + QVERIFY(addSecondReference(extra.model)); + + pruneExtraPane(m_document, m_paneStack, extra.pane, extra.model); + + QVERIFY2(documentHasLayer(m_ruler), + "the shared time ruler was deleted from the document"); + m_document->deleteLayer(m_ruler, true); + QVERIFY(!paneHasLayer(m_mainPane, m_ruler)); + m_ruler = nullptr; + } +}; + +#endif diff --git a/main/test/TestSingingTakes.h b/main/test/TestSingingTakes.h new file mode 100644 index 00000000..dd5363ad --- /dev/null +++ b/main/test/TestSingingTakes.h @@ -0,0 +1,764 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TEST_SINGING_TAKES_H +#define TEST_SINGING_TAKES_H + +// Tier 2: the state of a singing take — which audio file it is in, what +// of it holds recorded material, and what recording into it does to +// both. No window and no audio device: the files are written here and +// read back. + +#include "../SingingTakes.h" +#include "../TakeAudio.h" + +#include "data/fileio/FileSource.h" +#include "data/fileio/WavFileReader.h" +#include "data/fileio/WavFileWriter.h" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +class TestSingingTakes : public QObject +{ + Q_OBJECT + + typedef sv::sv_frame_t frame_t; + + static constexpr double kRate = 44100.0; + + QTemporaryDir m_dir; + int m_fileCounter = 0; + + // A recording of the given length, at a level that says which one it is + QString writeRecording(frame_t frames, float level, double rate = kRate) { + QString path = m_dir.filePath + (QString("recorded-%1.wav").arg(++m_fileCounter)); + sv::WavFileWriter writer(path, rate, 1, + sv::WavFileWriter::WriteToTarget); + sv::floatvec_t data(frames, level); + if (!writer.isOK() || !writer.putInterleavedFrames(data) || + !writer.close()) { + return ""; + } + return path; + } + + QString takeDirectory() { + QDir dir(m_dir.path()); + dir.mkpath("takes"); + return dir.filePath("takes"); + } + + static frame_t framesIn(QString path) { + sv::WavFileReader reader { sv::FileSource(path) }; + return reader.isOK() ? reader.getFrameCount() : -1; + } + + static float sampleAt(QString path, frame_t frame) { + sv::WavFileReader reader { sv::FileSource(path) }; + if (!reader.isOK()) return 0.f; + auto data = reader.getInterleavedFrames(frame, 1); + return data.empty() ? 0.f : data[0]; + } + +private slots: + void initTestCase() { + QVERIFY(m_dir.isValid()); + } + + void init() { + SingingTakes::setOverwriteConfirmationWanted(true); + } + + void nothing_to_start_with() { + SingingTakes takes; + QVERIFY(!takes.haveTake()); + QCOMPARE(takes.getAudioPath(), QString()); + QVERIFY(takes.getCoverage().isEmpty()); + QVERIFY(takes.getSupersededPaths().isEmpty()); + // Nothing is recorded, so nothing can be recorded over + QVERIFY(!takes.coversPosition(0)); + QVERIFY(!takes.shouldConfirmRecordingAt(0)); + } + + void whole_file_take() { + SingingTakes takes; + takes.setWholeFileTake("/somewhere/sung.wav", 1000); + QVERIFY(takes.haveTake()); + QCOMPARE(takes.getAudioPath(), QString("/somewhere/sung.wav")); + QCOMPARE(int(takes.getCoverage().getRanges().size()), 1); + QCOMPARE(takes.getCoverage().getRanges()[0], Coverage::Range(0, 1000)); + QVERIFY(takes.coversPosition(999)); + QVERIFY(!takes.coversPosition(1000)); + + // Another file loaded in its place: the one before is kept, since + // it may be wanted again by undo + takes.setWholeFileTake("/somewhere/else.wav", 10); + QCOMPARE(takes.getAudioPath(), QString("/somewhere/else.wav")); + QCOMPARE(int(takes.getCoverage().getRanges().size()), 1); + QCOMPARE(takes.getCoverage().getRanges()[0], Coverage::Range(0, 10)); + QCOMPARE(takes.getSupersededPaths(), + QStringList { "/somewhere/sung.wav" }); + + takes.clear(); + QVERIFY(!takes.haveTake()); + QVERIFY(takes.getCoverage().isEmpty()); + QVERIFY(takes.getSupersededPaths().isEmpty()); + } + + // Several takes (spec 5.3): a list, with one of them the active one, + // which is the take every single-take call is about + + void takes_are_a_list() { + SingingTakes takes; + QCOMPARE(takes.getTakeCount(), 0); + QCOMPARE(takes.getActiveIndex(), -1); + QCOMPARE(takes.getActiveName(), QString()); + QVERIFY(takes.getTakeNames().isEmpty()); + QVERIFY(!takes.getTake(0)); + + // An empty take: there, active, and with nothing in it + QCOMPARE(takes.addTake(), QString("Take 1")); + QCOMPARE(takes.getTakeCount(), 1); + QCOMPARE(takes.getActiveIndex(), 0); + QVERIFY(!takes.haveTake()); + QVERIFY(takes.getCoverage().isEmpty()); + + takes.setWholeFileTake("/somewhere/one.wav", 1000); + QVERIFY(takes.haveTake()); + + // A second take of its own: the first is left as it is + QCOMPARE(takes.addTake(), QString("Take 2")); + QCOMPARE(takes.getActiveIndex(), 1); + QVERIFY(!takes.haveTake()); + QVERIFY(takes.getCoverage().isEmpty()); + QCOMPARE(takes.getTakeNames(), QStringList({ "Take 1", "Take 2" })); + + takes.setWholeFileTake("/somewhere/two.wav", 2000); + + // Switching is the active index, and nothing else + QVERIFY(takes.setActiveIndex(0)); + QCOMPARE(takes.getAudioPath(), QString("/somewhere/one.wav")); + QCOMPARE(takes.getCoverage().getRanges()[0], Coverage::Range(0, 1000)); + QVERIFY(takes.setActiveIndex(1)); + QCOMPARE(takes.getAudioPath(), QString("/somewhere/two.wav")); + QCOMPARE(takes.getCoverage().getRanges()[0], Coverage::Range(0, 2000)); + + QVERIFY(!takes.setActiveIndex(2)); + QVERIFY(!takes.setActiveIndex(-1)); + QCOMPARE(takes.getActiveIndex(), 1); + + QCOMPARE(takes.indexOf("Take 1"), 0); + QCOMPARE(takes.indexOf("Take 3"), -1); + + takes.clear(); + QCOMPARE(takes.getTakeCount(), 0); + QCOMPARE(takes.getActiveIndex(), -1); + } + + // The first recording of a session records into a take of its own, + // without anyone having to ask for one + void first_recording_makes_a_take() { + SingingTakes takes; + QString recording = writeRecording(1000, 0.5f); + QVERIFY(takes.spliceRecording(recording, 0, 0, -1, + takeDirectory()).isEmpty()); + QCOMPARE(takes.getTakeCount(), 1); + QCOMPARE(takes.getActiveName(), QString("Take 1")); + QVERIFY(takes.haveTake()); + } + + // A name is never used twice in a session, however many takes have + // been deleted since: the layers of a take that has gone may still be + // in the document, under the name it had + void take_names_are_not_reused() { + SingingTakes takes; + QCOMPARE(takes.addTake(), QString("Take 1")); + QCOMPARE(takes.addTake(), QString("Take 2")); + QVERIFY(takes.removeTake(1)); + QCOMPARE(takes.addTake(), QString("Take 3")); + + // A name of the caller's own, and one that is taken already + QCOMPARE(takes.addTake("Chorus"), QString("Chorus")); + QCOMPARE(takes.addTake("Chorus"), QString("Take 4")); + + takes.clear(); + QCOMPARE(takes.addTake(), QString("Take 1")); + } + + void rename_a_take() { + SingingTakes takes; + takes.addTake(); + takes.addTake(); + + QVERIFY(takes.renameTake(0, "Chorus")); + QCOMPARE(takes.getTakeNames(), QStringList({ "Chorus", "Take 2" })); + QCOMPARE(takes.indexOf("Chorus"), 0); + + // The name it has, which is not a change at all + QVERIFY(takes.renameTake(0, "Chorus")); + + // Empty, whitespace only, another take's, and no such take + QVERIFY(!takes.renameTake(0, "")); + QVERIFY(!takes.renameTake(0, " ")); + QVERIFY(!takes.renameTake(0, "Take 2")); + QVERIFY(!takes.renameTake(2, "Verse")); + QCOMPARE(takes.getTakeNames(), QStringList({ "Chorus", "Take 2" })); + + QVERIFY(takes.renameTake(1, " Verse ")); + QCOMPARE(takes.getTakeNames(), QStringList({ "Chorus", "Verse" })); + } + + // Deleting the active take leaves a neighbour active, and deleting the + // last of them leaves no take at all + void remove_take_activates_a_neighbour() { + SingingTakes takes; + takes.addTake(); + takes.addTake(); + takes.addTake(); + QCOMPARE(takes.getActiveIndex(), 2); + + QVERIFY(takes.removeTake(2)); + QCOMPARE(takes.getActiveIndex(), 1); + + // One before the active take: the same take stays active + QVERIFY(takes.setActiveIndex(1)); + QVERIFY(takes.removeTake(0)); + QCOMPARE(takes.getActiveIndex(), 0); + QCOMPARE(takes.getTakeNames(), QStringList { "Take 2" }); + + QVERIFY(takes.removeTake(0)); + QCOMPARE(takes.getTakeCount(), 0); + QCOMPARE(takes.getActiveIndex(), -1); + QVERIFY(!takes.haveTake()); + + QVERIFY(!takes.removeTake(0)); + } + + // A duplicate shares the audio file of the take it was made from + // until one of them is recorded into or erased from, which writes a + // new file anyway (spec 5.4). So a file is in use while any take + // refers to it + void duplicate_shares_the_audio_file() { + SingingTakes takes; + QString recording = writeRecording(1000, 0.5f); + QVERIFY(takes.spliceRecording(recording, 0, 0, -1, + takeDirectory()).isEmpty()); + QString shared = takes.getAudioPath(); + Coverage coverage = takes.getCoverage(); + + QCOMPARE(takes.duplicateActiveTake(), QString("Take 2")); + QCOMPARE(takes.getTakeCount(), 2); + QCOMPARE(takes.getActiveIndex(), 1); + QCOMPARE(takes.getAudioPath(), shared); + QVERIFY(takes.getCoverage() == coverage); + + // No file was written and none superseded + QCOMPARE(takes.getWrittenPaths(), QStringList { shared }); + QVERIFY(takes.getSupersededPaths().isEmpty()); + QVERIFY(takes.unusedWrittenFiles().isEmpty()); + + // Recording into the copy writes a file of its own; the take it + // was copied from is untouched, and still uses the shared file + QVERIFY(takes.spliceRecording(recording, 0, 5000, -1, + takeDirectory()).isEmpty()); + QVERIFY(takes.getAudioPath() != shared); + QCOMPARE(takes.getTake(0)->audioPath, shared); + QVERIFY(takes.getTake(0)->coverage == coverage); + + // The shared file has been superseded for the copy, but the first + // take still plays it, so it is not ours to delete + QVERIFY(takes.getSupersededPaths().contains(shared)); + QVERIFY2(takes.unusedWrittenFiles().isEmpty(), + "a file another take is using was up for deletion"); + + // ... and once that take has gone, it is + QVERIFY(takes.removeTake(0)); + QCOMPARE(takes.unusedWrittenFiles(), QStringList { shared }); + } + + // A take with no audio yet: its name is there, and the calls about the + // active take say there is nothing + void a_take_with_no_audio() { + SingingTakes takes; + takes.addTake(); + QVERIFY(!takes.haveTake()); + QCOMPARE(takes.getAudioPath(), QString()); + QVERIFY(takes.getCoverage().isEmpty()); + QVERIFY(!takes.coversPosition(0)); + QVERIFY(!takes.shouldConfirmRecordingAt(0)); + QVERIFY(takes.duplicateActiveTake() != ""); + QVERIFY(!takes.haveTake()); + + // Nothing to erase, and no file written trying + Coverage::Ranges erased { Coverage::Range(1, 2) }; + QCOMPARE(takes.eraseRanges(Coverage::Ranges { Coverage::Range(0, 100) }, + takeDirectory(), &erased), QString()); + QVERIFY(erased.empty()); + QVERIFY(takes.getWrittenPaths().isEmpty()); + } + + // Every audio file of a take gets a name of its own, in the directory + // asked for, and never one that is taken: TakeAudio refuses to write + // over a file, so a name that collides would lose a recording + void every_file_has_its_own_name() { + QString dir = takeDirectory(); + QStringList made; + + for (int i = 0; i < 4; ++i) { + QString path = SingingTakes::nextAudioPath(dir); + QVERIFY(!path.isEmpty()); + QCOMPARE(QFileInfo(path).absolutePath(), + QFileInfo(dir).absoluteFilePath()); + QVERIFY2(!QFileInfo::exists(path), qPrintable(path)); + QVERIFY2(!made.contains(path), qPrintable(path)); + QFile file(path); + QVERIFY(file.open(QIODevice::WriteOnly)); + file.close(); + made << path; + } + } + + // The first recording of a take, made from part of the way in + void splice_first_recording() { + SingingTakes takes; + QString recording = writeRecording(4000, 0.5f); + QVERIFY(!recording.isEmpty()); + + Coverage::Range placed; + QString error = takes.spliceRecording(recording, 1000, 2000, -1, + takeDirectory(), &placed); + QCOMPARE(error, QString()); + + // The first 1000 frames of the recording are the latency: what was + // recorded before the singer could have heard frame 2000 + QCOMPARE(placed, Coverage::Range(2000, 5000)); + QVERIFY(takes.haveTake()); + QCOMPARE(int(takes.getCoverage().getRanges().size()), 1); + QCOMPARE(takes.getCoverage().getRanges()[0], Coverage::Range(2000, 5000)); + QVERIFY(takes.getSupersededPaths().isEmpty()); + + QString path = takes.getAudioPath(); + QCOMPARE(framesIn(path), frame_t(5000)); + QCOMPARE(sampleAt(path, 500), 0.f); + QVERIFY(std::fabs(sampleAt(path, 3500) - 0.5f) < 1e-3f); + } + + // A second recording, into the silence after the first: the file grows, + // the gap stays as it was and the coverage has two ranges + void splice_into_a_gap() { + SingingTakes takes; + QString first = writeRecording(1000, 0.5f); + QString second = writeRecording(1000, 0.25f); + QVERIFY(takes.spliceRecording(first, 0, 0, -1, + takeDirectory()).isEmpty()); + QString firstPath = takes.getAudioPath(); + + Coverage::Range placed; + QVERIFY(takes.spliceRecording(second, 0, 5000, -1, takeDirectory(), + &placed).isEmpty()); + QCOMPARE(placed, Coverage::Range(5000, 6000)); + + QCOMPARE(int(takes.getCoverage().getRanges().size()), 2); + QCOMPARE(takes.getCoverage().getRanges()[0], Coverage::Range(0, 1000)); + QCOMPARE(takes.getCoverage().getRanges()[1], Coverage::Range(5000, 6000)); + + QVERIFY(takes.getAudioPath() != firstPath); + QCOMPARE(takes.getSupersededPaths(), QStringList { firstPath }); + QVERIFY2(QFileInfo::exists(firstPath), + "the file the take had before was not kept"); + + QString path = takes.getAudioPath(); + QCOMPARE(framesIn(path), frame_t(6000)); + QVERIFY(std::fabs(sampleAt(path, 500) - 0.5f) < 1e-3f); + QCOMPARE(sampleAt(path, 3000), 0.f); + QVERIFY(std::fabs(sampleAt(path, 5500) - 0.25f) < 1e-3f); + } + + // Recording over material that is there: the ranges join, and the new + // material is what is heard where it landed + void splice_over_existing() { + SingingTakes takes; + QString first = writeRecording(4000, 0.5f); + QString second = writeRecording(1000, 0.25f); + QVERIFY(takes.spliceRecording(first, 0, 0, -1, + takeDirectory()).isEmpty()); + QVERIFY(takes.spliceRecording(second, 0, 2000, -1, + takeDirectory()).isEmpty()); + + QCOMPARE(int(takes.getCoverage().getRanges().size()), 1); + QCOMPARE(takes.getCoverage().getRanges()[0], Coverage::Range(0, 4000)); + + QString path = takes.getAudioPath(); + QCOMPARE(framesIn(path), frame_t(4000)); + QVERIFY(std::fabs(sampleAt(path, 1000) - 0.5f) < 1e-3f); // kept + QVERIFY(std::fabs(sampleAt(path, 2500) - 0.25f) < 1e-3f); // replaced + QVERIFY(std::fabs(sampleAt(path, 3500) - 0.5f) < 1e-3f); // kept + } + + void splice_failure_leaves_the_take_alone() { + SingingTakes takes; + QString recording = writeRecording(1000, 0.5f); + QVERIFY(takes.spliceRecording(recording, 0, 0, -1, + takeDirectory()).isEmpty()); + QString path = takes.getAudioPath(); + Coverage before = takes.getCoverage(); + + // Nothing of the recording is left once the latency is taken off + QString error = takes.spliceRecording(recording, 2000, 0, -1, + takeDirectory()); + QVERIFY(!error.isEmpty()); + QCOMPARE(takes.getAudioPath(), path); + QVERIFY(takes.getCoverage() == before); + QVERIFY(takes.getSupersededPaths().isEmpty()); + + // and a recording that is not there at all + error = takes.spliceRecording(m_dir.filePath("not-a-file.wav"), 0, 0, + -1, takeDirectory()); + QVERIFY(!error.isEmpty()); + QCOMPARE(takes.getAudioPath(), path); + QVERIFY(takes.getCoverage() == before); + + // nor when it has to be converted first + error = takes.spliceRecording(m_dir.filePath("not-a-file.wav"), 0, 0, + -1, takeDirectory(), nullptr, 48000.0); + QVERIFY(!error.isEmpty()); + QCOMPARE(takes.getAudioPath(), path); + QVERIFY(takes.getCoverage() == before); + } + + // A recording from a device that does not run at the take's rate, the + // reference's, goes in at the take's rate: a second of it is a second + // of the take, and the offset, the position and the length are all + // frames at that rate. Twice, the second time into a take that is at + // the take's rate already + void splice_converts_a_recording_at_another_rate() { + SingingTakes takes; + QString recording = writeRecording(48000, 0.5f, 48000.0); + QString second = writeRecording(24000, 0.25f, 48000.0); + QVERIFY(!recording.isEmpty() && !second.isEmpty()); + + Coverage::Range placed; + QCOMPARE(takes.spliceRecording(recording, 4410, 2000, -1, + takeDirectory(), &placed, kRate), + QString()); + QCOMPARE(placed, Coverage::Range(2000, 2000 + 44100 - 4410)); + + QString path = takes.getAudioPath(); + QCOMPARE(TakeAudio::sampleRate(path), kRate); + QCOMPARE(framesIn(path), placed.end); + QVERIFY(std::fabs(sampleAt(path, 20000) - 0.5f) < 1e-3f); + + QCOMPARE(takes.spliceRecording(second, 0, 60000, 11025, + takeDirectory(), &placed, kRate), + QString()); + QCOMPARE(placed, Coverage::Range(60000, 71025)); + QCOMPARE(int(takes.getCoverage().getRanges().size()), 2); + + path = takes.getAudioPath(); + QCOMPARE(TakeAudio::sampleRate(path), kRate); + QCOMPARE(framesIn(path), frame_t(71025)); + QVERIFY(std::fabs(sampleAt(path, 20000) - 0.5f) < 1e-3f); + QVERIFY(std::fabs(sampleAt(path, 65000) - 0.25f) < 1e-3f); + + // The recordings are as they were, and nothing of the conversion + // is left beside the take's files + QCOMPARE(TakeAudio::sampleRate(recording), 48000.0); + QCOMPARE(framesIn(recording), frame_t(48000)); + QStringList left = QDir(takeDirectory()).entryList + (QDir::AllEntries | QDir::NoDotAndDotDot); + for (const QString &name : left) { + QVERIFY2(name.startsWith("take-") && name.endsWith(".wav"), + qPrintable(QString("\"%1\" was left in the takes folder") + .arg(name))); + } + } + + // Erasing from the middle of a recording: the file is as long as it + // was, silent where the range was, and the coverage is split in two + void erase_the_middle() { + SingingTakes takes; + QString recording = writeRecording(4000, 0.5f); + QVERIFY(takes.spliceRecording(recording, 0, 0, -1, + takeDirectory()).isEmpty()); + QString before = takes.getAudioPath(); + + Coverage::Ranges erased; + QCOMPARE(takes.eraseRanges(Coverage::Ranges { Coverage::Range(1000, 2000) }, + takeDirectory(), &erased), QString()); + + QCOMPARE(int(erased.size()), 1); + QCOMPARE(erased[0], Coverage::Range(1000, 2000)); + + QCOMPARE(int(takes.getCoverage().getRanges().size()), 2); + QCOMPARE(takes.getCoverage().getRanges()[0], Coverage::Range(0, 1000)); + QCOMPARE(takes.getCoverage().getRanges()[1], Coverage::Range(2000, 4000)); + + QString path = takes.getAudioPath(); + QVERIFY(path != before); + QCOMPARE(takes.getSupersededPaths(), QStringList { before }); + QVERIFY2(QFileInfo::exists(before), + "the file the take had before was not kept"); + + // As long as it was, and silent only where the erase went. The + // samples looked at are clear of the 5 ms fades at the edges + QCOMPARE(framesIn(path), frame_t(4000)); + QVERIFY(std::fabs(sampleAt(path, 500) - 0.5f) < 1e-3f); + QCOMPARE(sampleAt(path, 1500), 0.f); + QVERIFY(std::fabs(sampleAt(path, 3000) - 0.5f) < 1e-3f); + } + + // Several ranges at once, out of order, and one of them trimming the + // end of the recording + void erase_several_ranges() { + SingingTakes takes; + QString recording = writeRecording(4000, 0.5f); + QVERIFY(takes.spliceRecording(recording, 0, 0, -1, + takeDirectory()).isEmpty()); + + Coverage::Ranges erased; + QCOMPARE(takes.eraseRanges(Coverage::Ranges { Coverage::Range(3000, 4000), + Coverage::Range(1000, 2000) }, + takeDirectory(), &erased), QString()); + + QCOMPARE(int(erased.size()), 2); + QCOMPARE(erased[0], Coverage::Range(1000, 2000)); + QCOMPARE(erased[1], Coverage::Range(3000, 4000)); + + QCOMPARE(int(takes.getCoverage().getRanges().size()), 2); + QCOMPARE(takes.getCoverage().getRanges()[0], Coverage::Range(0, 1000)); + QCOMPARE(takes.getCoverage().getRanges()[1], Coverage::Range(2000, 3000)); + + QString path = takes.getAudioPath(); + QCOMPARE(framesIn(path), frame_t(4000)); + QCOMPARE(sampleAt(path, 1500), 0.f); + QVERIFY(std::fabs(sampleAt(path, 2500) - 0.5f) < 1e-3f); + QCOMPARE(sampleAt(path, 3500), 0.f); + } + + // What is asked for is clipped to the coverage: silence that was + // never recorded holds nothing to erase, and a selection running + // past the singing must not make the file any longer + void erase_is_clipped_to_the_coverage() { + SingingTakes takes; + QString recording = writeRecording(2000, 0.5f); + // coverage is [2000, 4000) + QVERIFY(takes.spliceRecording(recording, 0, 2000, -1, + takeDirectory()).isEmpty()); + + Coverage::Ranges erased; + QCOMPARE(takes.eraseRanges(Coverage::Ranges { Coverage::Range(0, 3000) }, + takeDirectory(), &erased), QString()); + + QCOMPARE(int(erased.size()), 1); + QCOMPARE(erased[0], Coverage::Range(2000, 3000)); + QCOMPARE(int(takes.getCoverage().getRanges().size()), 1); + QCOMPARE(takes.getCoverage().getRanges()[0], Coverage::Range(3000, 4000)); + QCOMPARE(framesIn(takes.getAudioPath()), frame_t(4000)); + } + + // A selection with no recorded singing in it: nothing is written and + // nothing changes, and it is not an error + void erase_where_nothing_was_recorded() { + SingingTakes takes; + QString recording = writeRecording(1000, 0.5f); + QVERIFY(takes.spliceRecording(recording, 0, 1000, -1, + takeDirectory()).isEmpty()); + QString path = takes.getAudioPath(); + Coverage before = takes.getCoverage(); + + Coverage::Ranges erased { Coverage::Range(1, 2) }; + QCOMPARE(takes.eraseRanges(Coverage::Ranges { Coverage::Range(0, 1000) }, + takeDirectory(), &erased), QString()); + QVERIFY(erased.empty()); + QCOMPARE(takes.getAudioPath(), path); + QVERIFY(takes.getCoverage() == before); + QVERIFY(takes.getSupersededPaths().isEmpty()); + + // and no ranges at all + QCOMPARE(takes.eraseRanges(Coverage::Ranges {}, takeDirectory(), + &erased), QString()); + QVERIFY(erased.empty()); + QCOMPARE(takes.getAudioPath(), path); + } + + // Erasing all there is: the take stays, with an audio file that is + // silent throughout and nothing covered + void erase_the_whole_take() { + SingingTakes takes; + QString recording = writeRecording(2000, 0.5f); + QVERIFY(takes.spliceRecording(recording, 0, 0, -1, + takeDirectory()).isEmpty()); + + QCOMPARE(takes.eraseRanges(Coverage::Ranges { Coverage::Range(0, 2000) }, + takeDirectory()), QString()); + + QVERIFY(takes.haveTake()); + QVERIFY(takes.getCoverage().isEmpty()); + QCOMPARE(framesIn(takes.getAudioPath()), frame_t(2000)); + QCOMPARE(sampleAt(takes.getAudioPath(), 1000), 0.f); + } + + void erase_failure_leaves_the_take_alone() { + SingingTakes takes; + takes.setWholeFileTake(m_dir.filePath("not-a-file.wav"), 1000); + Coverage before = takes.getCoverage(); + + Coverage::Ranges erased { Coverage::Range(1, 2) }; + QString error = takes.eraseRanges + (Coverage::Ranges { Coverage::Range(0, 500) }, takeDirectory(), + &erased); + QVERIFY(!error.isEmpty()); + QVERIFY(erased.empty()); + QCOMPARE(takes.getAudioPath(), m_dir.filePath("not-a-file.wav")); + QVERIFY(takes.getCoverage() == before); + QVERIFY(takes.getSupersededPaths().isEmpty()); + } + + // Undo and redo swap the take between two files it has already had: + // neither is superseded by the other again, however often they change + // places, and neither becomes a file this run wrote + void restore_take_supersedes_nothing() { + SingingTakes takes; + QString recording = writeRecording(1000, 0.5f); + QVERIFY(takes.spliceRecording(recording, 0, 0, -1, + takeDirectory()).isEmpty()); + QString first = takes.getAudioPath(); + QVERIFY(takes.spliceRecording(recording, 0, 5000, -1, + takeDirectory()).isEmpty()); + QString second = takes.getAudioPath(); + Coverage after = takes.getCoverage(); + QCOMPARE(takes.getSupersededPaths(), QStringList { first }); + + Coverage before; + before.add(0, 1000); + + takes.restoreTake(first, before); + QCOMPARE(takes.getAudioPath(), first); + QVERIFY(takes.getCoverage() == before); + QCOMPARE(takes.getSupersededPaths(), QStringList { first }); + QCOMPARE(takes.getWrittenPaths(), QStringList({ first, second })); + + takes.restoreTake(second, after); + QCOMPARE(takes.getAudioPath(), second); + QVERIFY(takes.getCoverage() == after); + QCOMPARE(takes.getSupersededPaths(), QStringList { first }); + QCOMPARE(takes.getWrittenPaths(), QStringList({ first, second })); + + // Undo of the very first recording of a take: no take at all + takes.restoreTake("", Coverage()); + QVERIFY(!takes.haveTake()); + QVERIFY(takes.getCoverage().isEmpty()); + } + + // What a session close may delete: the files this run wrote that + // nothing refers to any more. Never the file the take is in, and + // never a file the user brought, however thoroughly superseded + void only_unused_files_we_wrote_are_deleted() { + SingingTakes takes; + + // The user's own singing track, recorded over twice + QString loaded = writeRecording(1000, 0.5f); + takes.setWholeFileTake(loaded, 1000); + + QString recording = writeRecording(1000, 0.25f); + QVERIFY(takes.spliceRecording(recording, 0, 2000, -1, + takeDirectory()).isEmpty()); + QString first = takes.getAudioPath(); + QVERIFY(takes.spliceRecording(recording, 0, 5000, -1, + takeDirectory()).isEmpty()); + QString second = takes.getAudioPath(); + + QCOMPARE(takes.getWrittenPaths(), QStringList({ first, second })); + QVERIFY(takes.getSupersededPaths().contains(loaded)); + + QCOMPARE(takes.unusedWrittenFiles(), QStringList { first }); + QCOMPARE(takes.removeUnusedFiles(), QStringList { first }); + + QVERIFY2(!QFileInfo::exists(first), "a superseded file was kept"); + QVERIFY2(QFileInfo::exists(second), "the take's own file was deleted"); + QVERIFY2(QFileInfo::exists(loaded), "the user's own file was deleted"); + + // and nothing is deleted twice + QVERIFY(takes.unusedWrittenFiles().isEmpty()); + QVERIFY(takes.removeUnusedFiles().isEmpty()); + } + + // The file a saved session names must be there when that session is + // opened again, however many recordings have superseded it since + void a_saved_session_keeps_its_file() { + SingingTakes takes; + QString recording = writeRecording(1000, 0.5f); + QVERIFY(takes.spliceRecording(recording, 0, 0, -1, + takeDirectory()).isEmpty()); + QString saved = takes.getAudioPath(); + takes.protectPath(saved); + + QVERIFY(takes.spliceRecording(recording, 0, 5000, -1, + takeDirectory()).isEmpty()); + QString second = takes.getAudioPath(); + QVERIFY(takes.unusedWrittenFiles().isEmpty()); + + QVERIFY(takes.spliceRecording(recording, 0, 9000, -1, + takeDirectory()).isEmpty()); + QCOMPARE(takes.unusedWrittenFiles(), QStringList { second }); + + QCOMPARE(takes.removeUnusedFiles(), QStringList { second }); + QVERIFY(QFileInfo::exists(saved)); + QVERIFY(QFileInfo::exists(takes.getAudioPath())); + + // A new session starts with nothing to remember or protect + takes.clear(); + QVERIFY(takes.getWrittenPaths().isEmpty()); + QVERIFY(takes.unusedWrittenFiles().isEmpty()); + } + + // The question before recording over something: asked inside the + // covered ranges only, and not at all once the user has said so + void overwrite_question() { + SingingTakes takes; + QString recording = writeRecording(1000, 0.5f); + QVERIFY(takes.spliceRecording(recording, 0, 1000, -1, + takeDirectory()).isEmpty()); + // coverage is [1000, 2000) + + QVERIFY(SingingTakes::isOverwriteConfirmationWanted()); + QVERIFY(takes.shouldConfirmRecordingAt(1000)); + QVERIFY(takes.shouldConfirmRecordingAt(1999)); + + // In a gap, before or after: recording there takes nothing away, + // even if it runs on into what is there + QVERIFY(!takes.shouldConfirmRecordingAt(0)); + QVERIFY(!takes.shouldConfirmRecordingAt(999)); + QVERIFY(!takes.shouldConfirmRecordingAt(2000)); + + SingingTakes::setOverwriteConfirmationWanted(false); + QVERIFY(!SingingTakes::isOverwriteConfirmationWanted()); + QVERIFY(takes.coversPosition(1500)); + QVERIFY(!takes.shouldConfirmRecordingAt(1500)); + + SingingTakes::setOverwriteConfirmationWanted(true); + QVERIFY(takes.shouldConfirmRecordingAt(1500)); + } +}; + +#endif diff --git a/main/test/TestSongScroll.h b/main/test/TestSongScroll.h new file mode 100644 index 00000000..00f20874 --- /dev/null +++ b/main/test/TestSongScroll.h @@ -0,0 +1,291 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TEST_SONG_SCROLL_H +#define TEST_SONG_SCROLL_H + +// Tier 2: the arithmetic of the song scroll bar. Frames, pixels and +// pitches in, frames, pixels and columns out; no widget. + +#include "../SongScroll.h" + +#include +#include + +#include +#include +#include + +class TestSongScroll : public QObject +{ + Q_OBJECT + + typedef sv::sv_frame_t frame_t; + typedef std::vector> Events; + + // Ten seconds on a strip 882 pixels wide: 500 frames to a pixel + static constexpr frame_t song = 441000; + static constexpr double width = 882.0; + static constexpr double minWidth = 16.0; + + static QString text(const SongScroll::Thumb &t) { + return QString("[%1, %2]").arg(t.x0).arg(t.x1); + } + + static void verifyThumb(const SongScroll::Thumb &t, double x0, double x1) { + QVERIFY2(std::fabs(t.x0 - x0) < 1e-9 && std::fabs(t.x1 - x1) < 1e-9, + qPrintable(QString("the thumb is %1, not [%2, %3]") + .arg(text(t)).arg(x0).arg(x1))); + } + + // Voiced frames every step from first up to (not including) end + static void addEvents(Events &events, frame_t first, frame_t end, + frame_t step, double value) { + for (frame_t f = first; f < end; f += step) { + events.push_back({ f, value }); + } + } + + static QString voiced(const std::vector &columns) { + QString s; + for (const auto &c : columns) s += (c.isEmpty() ? "." : "#"); + return s; + } + +private slots: + void frame_to_x_and_back() { + QCOMPARE(SongScroll::xForFrame(0, song, width), 0.0); + QCOMPARE(SongScroll::xForFrame(song, song, width), width); + QCOMPARE(SongScroll::xForFrame(song / 2, song, width), width / 2); + QCOMPARE(SongScroll::frameForX(width / 2, song, width), song / 2); + + for (frame_t f : { frame_t(0), frame_t(1), frame_t(499), + frame_t(12345), frame_t(220500), song - 1 }) { + QCOMPARE(SongScroll::frameForX + (SongScroll::xForFrame(f, song, width), song, width), f); + } + + // Back from a frame to within the half a frame it was rounded by + double halfFrame = 0.5 * width / double(song); + for (double x = 0.0; x <= width; x += 37.3) { + double back = SongScroll::xForFrame + (SongScroll::frameForX(x, song, width), song, width); + QVERIFY2(std::fabs(back - x) <= halfFrame, + qPrintable(QString("%1 came back as %2").arg(x).arg(back))); + } + + // No song, or no strip: nothing to map + QCOMPARE(SongScroll::xForFrame(1000, 0, width), 0.0); + QCOMPARE(SongScroll::frameForX(100.0, 0, width), frame_t(0)); + QCOMPARE(SongScroll::frameForX(100.0, song, 0.0), frame_t(0)); + } + + void frames_are_kept_within_the_song() { + QCOMPARE(SongScroll::clampToSong(-5, song), frame_t(0)); + QCOMPARE(SongScroll::clampToSong(0, song), frame_t(0)); + QCOMPARE(SongScroll::clampToSong(1234, song), frame_t(1234)); + QCOMPARE(SongScroll::clampToSong(song - 1, song), song - 1); + QCOMPARE(SongScroll::clampToSong(song, song), song - 1); + QCOMPARE(SongScroll::clampToSong(song + 100, song), song - 1); + QCOMPARE(SongScroll::clampToSong(100, 0), frame_t(0)); + } + + void thumb_covers_what_the_panes_show() { + verifyThumb(SongScroll::thumb(100000, 150000, song, width, minWidth), + 200.0, 300.0); + } + + // A pane scrolled to the song's start shows some of what is before + // it, which the strip does not have: the thumb stops at the edge + void thumb_at_the_start() { + verifyThumb(SongScroll::thumb(-20000, 30000, song, width, minWidth), + 0.0, 60.0); + + // and one narrower than the least is widened into the strip + verifyThumb(SongScroll::thumb(-1000, 2000, song, width, minWidth), + 0.0, minWidth); + verifyThumb(SongScroll::thumb(0, 500, song, width, minWidth), + 0.0, minWidth); + } + + void thumb_at_the_end() { + verifyThumb(SongScroll::thumb(song - 30000, song + 20000, + song, width, minWidth), + width - 60.0, width); + + // 10 pixels of song, widened to 16 about their middle and moved + // back from the edge + verifyThumb(SongScroll::thumb(song - 5000, song + 5000, + song, width, minWidth), + width - minWidth, width); + + // Nothing of the song on show at all: at the end still + verifyThumb(SongScroll::thumb(song + 1000, song + 50000, + song, width, minWidth), + width - minWidth, width); + } + + // Narrow in the middle: the least width, about the same middle + void thumb_has_a_least_width() { + verifyThumb(SongScroll::thumb(220000, 221000, song, width, minWidth), + 441.0 - minWidth / 2, 441.0 + minWidth / 2); + } + + // Zoomed out beyond the song: the whole strip + void thumb_wider_than_the_song() { + verifyThumb(SongScroll::thumb(-100000, song + 100000, + song, width, minWidth), + 0.0, width); + verifyThumb(SongScroll::thumb(0, song, song, width, minWidth), + 0.0, width); + + // and a strip narrower than the least thumb is all thumb + verifyThumb(SongScroll::thumb(1000, 2000, song, 10.0, minWidth), + 0.0, 10.0); + + // No song: no thumb + QCOMPARE(SongScroll::thumb(0, 1000, 0, width, minWidth).width(), 0.0); + } + + void press_on_the_thumb_or_beside_it() { + SongScroll::Thumb t; + t.x0 = 200.0; + t.x1 = 300.0; + QVERIFY(!SongScroll::hitsThumb(t, 199.9)); + QVERIFY(SongScroll::hitsThumb(t, 200.0)); + QVERIFY(SongScroll::hitsThumb(t, 250.0)); + QVERIFY(SongScroll::hitsThumb(t, 300.0)); + QVERIFY(!SongScroll::hitsThumb(t, 300.1)); + } + + // A drag moves the centre by what the strip shows in the distance, + // from wherever it was grabbed, and not out of the song + void drag_moves_the_centre_by_the_distance() { + QCOMPARE(SongScroll::dragCentre(125000, 250.0, 250.0, song, width), + frame_t(125000)); + QCOMPARE(SongScroll::dragCentre(125000, 250.0, 300.0, song, width), + frame_t(150000)); + QCOMPARE(SongScroll::dragCentre(125000, 250.0, 200.0, song, width), + frame_t(100000)); + QCOMPARE(SongScroll::dragCentre(125000, 250.0, 250.5, song, width), + frame_t(125250)); + + // The same distance from another grab is the same movement + QCOMPARE(SongScroll::dragCentre(125000, 20.0, 70.0, song, width), + frame_t(150000)); + + QCOMPARE(SongScroll::dragCentre(125000, 250.0, -500.0, song, width), + frame_t(0)); + QCOMPARE(SongScroll::dragCentre(125000, 250.0, 5000.0, song, width), + song - 1); + } + + void jump_centres_on_the_frame_there() { + QCOMPARE(SongScroll::jumpCentre(441.0, song, width), frame_t(220500)); + QCOMPARE(SongScroll::jumpCentre(1.0, song, width), frame_t(500)); + QCOMPARE(SongScroll::jumpCentre(-10.0, song, width), frame_t(0)); + QCOMPARE(SongScroll::jumpCentre(width + 10.0, song, width), song - 1); + } + + // Each column has the lowest and highest pitch of its frames + void pitch_columns_have_their_range() { + Events events; + for (int i = 0; i < 10; ++i) events.push_back({ i * 10, 100.0 + i }); + events.push_back({ 150, 220.0 }); + events.push_back({ 160, 210.0 }); + + std::vector columns = + SongScroll::pitchColumns(events, 10, 1000, 10); + QCOMPARE(int(columns.size()), 10); + QCOMPARE(columns[0].low, 100.0); + QCOMPARE(columns[0].high, 109.0); + QCOMPARE(columns[1].low, 210.0); + QCOMPARE(columns[1].high, 220.0); + QCOMPARE(voiced(columns), QString("##........")); + + double low = 0, high = 0; + QVERIFY(SongScroll::pitchRange(columns, low, high)); + QCOMPARE(low, 100.0); + QCOMPARE(high, 220.0); + } + + // Where the pitch track has no events, as in a rest, and where it + // has events of no pitch, the columns are empty + void pitch_columns_empty_for_a_gap() { + Events events; + addEvents(events, 0, 300, 10, 200.0); + addEvents(events, 400, 500, 10, 0.0); + addEvents(events, 500, 550, 10, -1.0); + addEvents(events, 600, 1000, 10, 300.0); + + std::vector columns = + SongScroll::pitchColumns(events, 10, 1000, 10); + QCOMPARE(voiced(columns), QString("###...####")); + + // and nothing at all is all empty + columns = SongScroll::pitchColumns(Events(), 10, 1000, 10); + QCOMPARE(voiced(columns), QString("..........")); + double low = 0, high = 0; + QVERIFY(!SongScroll::pitchRange(columns, low, high)); + } + + // A pitch track has an event for each hop: in columns narrower than + // that the voiced part has no holes + void pitch_columns_narrower_than_an_event() { + Events events; + addEvents(events, 0, 500, 50, 200.0); + std::vector columns = + SongScroll::pitchColumns(events, 50, 1000, 100); + QCOMPARE(int(columns.size()), 100); + QCOMPARE(voiced(columns), + QString(50, '#') + QString(50, '.')); + } + + // What is outside the song is left out, and what overlaps its ends + // is cut there + void pitch_columns_within_the_song() { + Events events; + events.push_back({ -100, 150.0 }); // ends before the song + events.push_back({ -20, 160.0 }); // frames -20 to 29 + events.push_back({ 990, 170.0 }); // to 1039, the song ends at 999 + events.push_back({ 1000, 180.0 }); // after the song + std::vector columns = + SongScroll::pitchColumns(events, 50, 1000, 100); + QCOMPARE(voiced(columns), + QString("###") + QString(96, '.') + QString("#")); + QCOMPARE(columns[0].low, 160.0); + QCOMPARE(columns[99].high, 170.0); + + // No columns, no song + QVERIFY(SongScroll::pitchColumns(events, 50, 1000, 0).empty()); + QCOMPARE(voiced(SongScroll::pitchColumns(events, 50, 0, 4)), + QString("....")); + } + + // Octaves are equal steps; low at the bottom, high at the top + void pitch_on_a_log_scale() { + QCOMPARE(SongScroll::yForPitch(100.0, 100.0, 400.0, 16.0), 16.0); + QCOMPARE(SongScroll::yForPitch(400.0, 100.0, 400.0, 16.0), 0.0); + QVERIFY(std::fabs(SongScroll::yForPitch(200.0, 100.0, 400.0, 16.0) + - 8.0) < 1e-9); + + // Out of the range: at its edge + QCOMPARE(SongScroll::yForPitch(50.0, 100.0, 400.0, 16.0), 16.0); + QCOMPARE(SongScroll::yForPitch(800.0, 100.0, 400.0, 16.0), 0.0); + + // A song of one pitch: in the middle + QCOMPARE(SongScroll::yForPitch(220.0, 220.0, 220.0, 16.0), 8.0); + } +}; + +#endif diff --git a/main/test/TestStreamLatency.h b/main/test/TestStreamLatency.h new file mode 100644 index 00000000..f8b3999d --- /dev/null +++ b/main/test/TestStreamLatency.h @@ -0,0 +1,246 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TEST_STREAM_LATENCY_H +#define TEST_STREAM_LATENCY_H + +// Tier 2: a device's latency from its streams' timestamps, as the +// Android backend works it out. Counters and timestamps in, frames out; +// no device: a simulated one whose true latencies are known. + +#include "../StreamLatency.h" + +#include +#include + +#include +#include + +class TestStreamLatency : public QObject +{ + Q_OBJECT + + // A duplex device at 48 kHz with callbacks of 96 frames, every 2 ms + // from t0. Each callback reads 96 frames of input and writes 96 of + // output. The frame written in callback k is heard trueOutput frames + // after the callback; the frame read in callback k came in trueInput + // frames before it. The hardware's timestamps are exact. + struct Device { + static constexpr double rate = 48000.0; + static constexpr int burst = 96; + static constexpr int64_t t0 = 5000000000; // ns + double trueOutput = 250.0; + double trueInput = 130.0; + + static int64_t nanos(double frames) { + return int64_t(std::llround(frames * 1.0e9 / rate)); + } + // When output frame f is heard, and input frame f came in + int64_t heard(int64_t f) const { + return t0 + nanos(double(f) + trueOutput); + } + int64_t cameIn(int64_t f) const { + return t0 + nanos(double(f) - trueInput); + } + // What the streams report at a moment after callback k has run + // and before k + 1 does: every frame of callback k is written + // and read, and the timestamps are of frames some time back + StreamLatency::Position output(int k) const { + StreamLatency::Position p; + p.appFrames = int64_t(k + 1) * burst; + p.hardwareFrame = p.appFrames - 400; + p.hardwareNanos = heard(p.hardwareFrame); + return p; + } + StreamLatency::Position input(int k) const { + StreamLatency::Position p; + p.appFrames = int64_t(k + 1) * burst; + p.hardwareFrame = p.appFrames - 700; + p.hardwareNanos = cameIn(p.hardwareFrame); + return p; + } + // The moment a fraction of the way from callback k to k + 1 + int64_t between(int k, double fraction) const { + return t0 + nanos((double(k) + fraction) * burst); + } + }; + + static StreamLatency::Estimate est(int output, int input) { + StreamLatency::Estimate e; + e.output = output; + e.input = input; + return e; + } + +private slots: + void output_latency_is_what_is_written_and_not_yet_heard() { + // 1000 frames written beyond the timestamp's frame, which went + // out 10 ms (480 frames) ago + StreamLatency::Position p; + p.appFrames = 10000; + p.hardwareFrame = 9000; + p.hardwareNanos = 1000000000; + QCOMPARE(StreamLatency::outputLatency(p, 1010000000, 48000.0), 520.0); + } + + void input_latency_is_the_age_of_the_next_frame_read() { + // The next frame to read came in 1000 frames after the + // timestamp's, and 25 ms (1200 frames) have gone by since it + StreamLatency::Position p; + p.appFrames = 10000; + p.hardwareFrame = 9000; + p.hardwareNanos = 1000000000; + QCOMPARE(StreamLatency::inputLatency(p, 1025000000, 48000.0), 200.0); + // Read so eagerly that the next frame is not in yet + QCOMPARE(StreamLatency::inputLatency(p, 1015000000, 48000.0), -280.0); + } + + // Read at any moment between two callbacks, each part is off by the + // time to the next callback, and their sum is the true round trip + void round_trip_does_not_depend_on_when_it_is_read() { + Device d; + for (int k : { 10, 11, 500 }) { + for (double fraction : { 0.05, 0.3, 0.6, 0.95 }) { + int64_t now = d.between(k, fraction); + double out = StreamLatency::outputLatency + (d.output(k), now, Device::rate); + double in = StreamLatency::inputLatency + (d.input(k), now, Device::rate); + QVERIFY(std::fabs(out + in - (d.trueOutput + d.trueInput)) + < 0.01); + // the time to the next callback, one way and the other + double toNext = (1.0 - fraction) * Device::burst; + QVERIFY(std::fabs(out - (d.trueOutput + toNext)) < 0.01); + QVERIFY(std::fabs(in - (d.trueInput - toNext)) < 0.01); + } + } + } + + // What the figure is for: the singer answers output frame W the + // moment it is heard, and the answer comes in at input frame R plus + // the round trip, R and W being where the two streams stand at the + // same callback (recording.md, "Latency") + void round_trip_is_where_an_answer_to_the_output_lands() { + Device d; + d.trueOutput = 1234.0; + d.trueInput = 321.0; + int k = 40; + int64_t now = d.between(k, 0.7); + StreamLatency::Estimate e = StreamLatency::fromReading + (StreamLatency::outputLatency(d.output(k), now, Device::rate), + StreamLatency::inputLatency(d.input(k), now, Device::rate)); + + int64_t w = d.output(k).appFrames; // the next frame to be written + int64_t r = d.input(k).appFrames; // and to be read + // The input frame that came in when output frame w was heard + int64_t answer = r; + while (d.cameIn(answer) < d.heard(w)) ++answer; + QCOMPARE(r + e.roundTrip(), answer); + } + + void reading_is_rounded_once() { + StreamLatency::Estimate e = StreamLatency::fromReading(100.4, 50.4); + QCOMPARE(e.roundTrip(), 151); + QCOMPARE(e.output, 100); + QCOMPARE(e.input, 51); + } + + // Bqaudioio's users take a negative latency as none (LatencyUtils.h + // does), which would lose it from the sum + void negative_part_is_taken_off_the_other() { + StreamLatency::Estimate e = StreamLatency::fromReading(300.4, -50.2); + QCOMPARE(e.output, 250); + QCOMPARE(e.input, 0); + + e = StreamLatency::fromReading(-20.0, 500.0); + QCOMPARE(e.output, 0); + QCOMPARE(e.input, 480); + + e = StreamLatency::fromReading(-20.0, -30.0); + QCOMPARE(e.output, 0); + QCOMPARE(e.input, 0); + } + + void plausible_is_more_than_nothing_and_at_most_a_second() { + QVERIFY(StreamLatency::isPlausible(est(200, 100), 48000.0)); + QVERIFY(StreamLatency::isPlausible(est(48000, 0), 48000.0)); + QVERIFY(!StreamLatency::isPlausible(est(48000, 1), 48000.0)); + QVERIFY(!StreamLatency::isPlausible(est(0, 0), 48000.0)); + } + + void median_takes_the_middle_round_trip() { + StreamLatency::Estimate chosen; + // One reading spoilt by a callback, one burst low; one absurd + std::vector readings { + est(260, 120), est(250, 130), est(160, 124), + est(900000, 0), est(270, 110), est(255, 127), + }; + QVERIFY(StreamLatency::median(readings, 48000.0, chosen)); + QCOMPARE(chosen.roundTrip(), 380); + + readings = { est(100, 0) }; + QVERIFY(StreamLatency::median(readings, 48000.0, chosen)); + QCOMPARE(chosen.output, 100); + + readings = { est(0, 0), est(-5, 0) }; + QVERIFY(!StreamLatency::median(readings, 48000.0, chosen)); + QVERIFY(!StreamLatency::median({}, 48000.0, chosen)); + } + + void guess_is_the_output_buffer_and_a_burst_of_input() { + StreamLatency::Estimate e = StreamLatency::guess(192, 96); + QCOMPARE(e.output, 192); + QCOMPARE(e.input, 96); + e = StreamLatency::guess(-1, -1); + QCOMPARE(e.roundTrip(), 0); + } + + // A callback reads all the input there is, up to its room, and at + // least what the output asks for; after a stall of 244 ms, as on the + // phone, all of it at once, where FullDuplexStream would read 96 + // frames a callback and never catch up + void a_callback_reads_all_the_input_there_is() { + QCOMPARE(StreamLatency::inputFramesToRead(96, 11424, 11520), 11424); + QCOMPARE(StreamLatency::inputFramesToRead(96, 20000, 11520), 11520); + QCOMPARE(StreamLatency::inputFramesToRead(96, 192, 11520), 192); + QCOMPARE(StreamLatency::inputFramesToRead(96, 0, 11520), 96); + QCOMPARE(StreamLatency::inputFramesToRead(96, 40, 11520), 96); + QCOMPARE(StreamLatency::inputFramesToRead(96, 500, 0), 0); + } + + // What a callback that keeps up leaves waiting is the output's buffer + // and two input bursts at most, on the phone's streams 192 and 96: a + // reading with the input's whole buffer waiting is refused + void a_reading_is_used_only_while_the_input_keeps_up() { + QVERIFY(StreamLatency::inputKeptUp(0, 192, 96)); + QVERIFY(StreamLatency::inputKeptUp(170, 192, 96)); + QVERIFY(StreamLatency::inputKeptUp(384, 192, 96)); + QVERIFY(!StreamLatency::inputKeptUp(385, 192, 96)); + QVERIFY(!StreamLatency::inputKeptUp(11424, 192, 96)); + QVERIFY(StreamLatency::inputKeptUp(0, -1, -1)); + QVERIFY(!StreamLatency::inputKeptUp(1, 0, 0)); + } + + // The phone's readings after input was lost: one and two buffers' + // worth over what it read, more than its buffer of 11520 holds + void an_input_latency_over_the_buffer_is_input_lost() { + QVERIFY(StreamLatency::inputLatencyPossible(154.0, 11520)); + QVERIFY(StreamLatency::inputLatencyPossible(11519.0, 11520)); + QVERIFY(!StreamLatency::inputLatencyPossible(11520.0, 11520)); + QVERIFY(!StreamLatency::inputLatencyPossible(11691.0, 11520)); + QVERIFY(!StreamLatency::inputLatencyPossible(23219.0, 11520)); + QVERIFY(StreamLatency::inputLatencyPossible(23219.0, 0)); + } +}; + +#endif diff --git a/main/test/TestTakeAudio.h b/main/test/TestTakeAudio.h new file mode 100644 index 00000000..b79c020d --- /dev/null +++ b/main/test/TestTakeAudio.h @@ -0,0 +1,575 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TEST_TAKE_AUDIO_H +#define TEST_TAKE_AUDIO_H + +// Tier 2: putting a recording into the audio file of a singing take, +// and taking parts of one out again. The signals are constants and +// ramps so that every sample of the result says where it came from. + +#include "../TakeAudio.h" + +#include "TestSignals.h" + +#include "data/fileio/FileSource.h" +#include "data/fileio/WavFileReader.h" +#include "data/fileio/WavFileWriter.h" + +#include +#include +#include +#include + +#include +#include + +class TestTakeAudio : public QObject +{ + Q_OBJECT + + typedef sv::sv_frame_t frame_t; + typedef std::vector Signal; // interleaved + + static constexpr double kRate = 44100.0; + static constexpr float kTolerance = 1e-3f; // the files may be 16-bit + static constexpr frame_t kFade = 100; + + QTemporaryDir m_dir; + int m_fileCounter = 0; + + QString newPath(QString stem = "audio") { + return m_dir.filePath(QString("%1-%2.wav").arg(stem).arg(++m_fileCounter)); + } + + QString writeWav(const Signal &interleaved, int channels = 1, + double rate = kRate) { + QString path = newPath("source"); + sv::WavFileWriter writer(path, rate, channels, + sv::WavFileWriter::WriteToTarget); + sv::floatvec_t data(interleaved.begin(), interleaved.end()); + if (!writer.isOK() || !writer.putInterleavedFrames(data) || + !writer.close()) { + return ""; + } + return path; + } + + static Signal constant(frame_t frames, float value) { + return Signal(frames, value); + } + + // Rises from 0.1 to 0.6, so that no frame of it is silent and each + // differs from the next by more than the tolerance only slowly: + // value says which frame, to within a few + static Signal ramp(frame_t frames) { + Signal s(frames); + for (frame_t i = 0; i < frames; ++i) s[i] = rampValue(i, frames); + return s; + } + static float rampValue(frame_t i, frame_t frames) { + return 0.1f + 0.5f * float(i) / float(frames); + } + + struct Audio { + bool ok = false; + int channels = 0; + frame_t frames = 0; + Signal samples; + float at(frame_t i, int c = 0) const { return samples[i * channels + c]; } + }; + + Audio read(QString path) { + Audio a; + sv::WavFileReader reader { sv::FileSource(path) }; + if (!reader.isOK()) return a; + a.ok = true; + a.channels = reader.getChannelCount(); + a.frames = reader.getFrameCount(); + auto data = reader.getInterleavedFrames(0, a.frames); + a.samples.assign(data.begin(), data.end()); + return a; + } + + static bool nearly(float a, float b) { return std::fabs(a - b) <= kTolerance; } + + // Every frame of [from, to) in channel c is value + static bool allNearly(const Audio &a, frame_t from, frame_t to, float value, + int c = 0) { + for (frame_t i = from; i < to; ++i) { + if (!nearly(a.at(i, c), value)) { + qWarning("frame %lld is %f, expected %f", + (long long)i, a.at(i, c), value); + return false; + } + } + return true; + } + + // [from, to) goes from one value to the other without ever + // leaving the span between them or turning back + static bool monotonicBetween(const Audio &a, frame_t from, frame_t to, + float first, float last) { + float lo = std::min(first, last) - kTolerance; + float hi = std::max(first, last) + kTolerance; + float direction = (last > first ? 1.f : -1.f); + for (frame_t i = from; i < to; ++i) { + float v = a.at(i); + if (v < lo || v > hi) { + qWarning("frame %lld is %f, outside %f..%f", + (long long)i, v, lo, hi); + return false; + } + if (i > from && (v - a.at(i - 1)) * direction < -kTolerance) { + qWarning("frame %lld turns back", (long long)i); + return false; + } + } + return true; + } + +private slots: + void initTestCase() { + QVERIFY(m_dir.isValid()); + } + + void fade_length() { + QCOMPARE(TakeAudio::defaultFadeFrames(44100.0), frame_t(220)); + QCOMPARE(TakeAudio::defaultFadeFrames(48000.0), frame_t(240)); + } + + // The first recording of a take, started part of the way in + void splice_into_nothing() { + QString recording = writeWav(constant(4000, 0.5f)); + QString out = newPath(); + Coverage::Range placed; + QString error = TakeAudio::splice("", recording, 0, 1000, -1, out, + &placed, kFade); + QCOMPARE(error, QString()); + QCOMPARE(placed, Coverage::Range(1000, 5000)); + + Audio a = read(out); + QVERIFY(a.ok); + QCOMPARE(a.channels, 1); + QCOMPARE(a.frames, frame_t(5000)); + QVERIFY(allNearly(a, 0, 1000, 0.f)); + QVERIFY(monotonicBetween(a, 1000, 1000 + kFade, 0.f, 0.5f)); + QVERIFY(allNearly(a, 1000 + kFade, 5000 - kFade, 0.5f)); + QVERIFY(monotonicBetween(a, 5000 - kFade, 5000, 0.5f, 0.f)); + // and it does fade: the first frame is nearly silent + QVERIFY(a.at(1000) < 0.05f); + QVERIFY(a.at(4999) < 0.05f); + } + + // What was recorded before the singer could have heard the + // reference is left out: frame "offset" of the recording is what + // lands on the position + void splice_removes_latency() { + const frame_t n = 8000, offset = 1500, position = 3000; + QString recording = writeWav(ramp(n)); + QString out = newPath(); + Coverage::Range placed; + QCOMPARE(TakeAudio::splice("", recording, offset, position, -1, out, + &placed, kFade), QString()); + QCOMPARE(placed, Coverage::Range(position, position + n - offset)); + + Audio a = read(out); + QVERIFY(a.ok); + QCOMPARE(a.frames, position + n - offset); + for (frame_t k : { frame_t(kFade), frame_t(2000), n - offset - kFade - 1 }) { + QVERIFY2(nearly(a.at(position + k), rampValue(offset + k, n)), + qPrintable(QString::number(k))); + } + } + + void splice_length_limits_what_is_used() { + QString recording = writeWav(constant(4000, 0.5f)); + QString out = newPath(); + Coverage::Range placed; + QCOMPARE(TakeAudio::splice("", recording, 500, 0, 1000, out, + &placed, kFade), QString()); + QCOMPARE(placed, Coverage::Range(0, 1000)); + QCOMPARE(read(out).frames, frame_t(1000)); + + // more than there is: what there is + out = newPath(); + QCOMPARE(TakeAudio::splice("", recording, 500, 0, 99999, out, + &placed, kFade), QString()); + QCOMPARE(placed, Coverage::Range(0, 3500)); + } + + // Singing one part again + void splice_replaces_the_middle() { + QString old = writeWav(constant(10000, 0.25f)); + QString recording = writeWav(constant(2000, -0.5f)); + QString out = newPath(); + QCOMPARE(TakeAudio::splice(old, recording, 0, 3000, -1, out, + nullptr, kFade), QString()); + + Audio a = read(out); + QVERIFY(a.ok); + QCOMPARE(a.frames, frame_t(10000)); + QVERIFY(allNearly(a, 0, 3000, 0.25f)); + // crossfaded: straight from the one to the other, with no dip + // to silence on the way and no jump + QVERIFY(monotonicBetween(a, 3000, 3000 + kFade, 0.25f, -0.5f)); + QVERIFY(allNearly(a, 3000 + kFade, 5000 - kFade, -0.5f)); + QVERIFY(monotonicBetween(a, 5000 - kFade, 5000, -0.5f, 0.25f)); + QVERIFY(allNearly(a, 5000, 10000, 0.25f)); + for (frame_t i = 2999; i < 5001; ++i) { + QVERIFY2(std::fabs(a.at(i + 1) - a.at(i)) < 0.8f / float(kFade), + qPrintable(QString("jump at %1").arg(i))); + } + + // and the take it was made from is as it was + Audio before = read(old); + QCOMPARE(before.frames, frame_t(10000)); + QVERIFY(allNearly(before, 0, 10000, 0.25f)); + } + + void splice_past_the_end_lengthens() { + QString old = writeWav(constant(3000, 0.25f)); + QString recording = writeWav(constant(2000, 0.5f)); + + // running off the end + QString out = newPath(); + QCOMPARE(TakeAudio::splice(old, recording, 0, 2000, -1, out, + nullptr, kFade), QString()); + Audio a = read(out); + QCOMPARE(a.frames, frame_t(4000)); + QVERIFY(allNearly(a, 0, 2000, 0.25f)); + QVERIFY(allNearly(a, 2000 + kFade, 4000 - kFade, 0.5f)); + + // starting beyond it, after an intermission + out = newPath(); + QCOMPARE(TakeAudio::splice(old, recording, 0, 6000, -1, out, + nullptr, kFade), QString()); + a = read(out); + QCOMPARE(a.frames, frame_t(8000)); + QVERIFY(allNearly(a, 0, 3000, 0.25f)); + QVERIFY(allNearly(a, 3000, 6000, 0.f)); + QVERIFY(allNearly(a, 6000 + kFade, 8000 - kFade, 0.5f)); + } + + void splice_before_zero_is_clipped() { + const frame_t n = 4000; + QString recording = writeWav(ramp(n)); + QString out = newPath(); + Coverage::Range placed; + QCOMPARE(TakeAudio::splice("", recording, 0, -1000, -1, out, + &placed, kFade), QString()); + QCOMPARE(placed, Coverage::Range(0, 3000)); + Audio a = read(out); + QCOMPARE(a.frames, frame_t(3000)); + QVERIFY(nearly(a.at(1500), rampValue(2500, n))); + } + + void splice_with_no_fade() { + QString old = writeWav(constant(3000, 0.25f)); + QString recording = writeWav(constant(1000, 0.5f)); + QString out = newPath(); + QCOMPARE(TakeAudio::splice(old, recording, 0, 1000, -1, out, + nullptr, 0), QString()); + Audio a = read(out); + QVERIFY(allNearly(a, 0, 1000, 0.25f)); + QVERIFY(allNearly(a, 1000, 2000, 0.5f)); + QVERIFY(allNearly(a, 2000, 3000, 0.25f)); + } + + // Shorter than two fades: they are shortened, and do not run into + // each other or outside the recording + void splice_shorter_than_the_fades() { + QString old = writeWav(constant(3000, 0.25f)); + QString recording = writeWav(constant(50, 0.5f)); + QString out = newPath(); + QCOMPARE(TakeAudio::splice(old, recording, 0, 1000, -1, out, + nullptr, kFade), QString()); + Audio a = read(out); + QVERIFY(allNearly(a, 0, 1000, 0.25f)); + QVERIFY(allNearly(a, 1050, 3000, 0.25f)); + QVERIFY(monotonicBetween(a, 1000, 1025, 0.25f, 0.5f)); + QVERIFY(monotonicBetween(a, 1025, 1050, 0.5f, 0.25f)); + QVERIFY(a.at(1025) > 0.45f); + } + + // The take keeps its channels whatever the device gives later + void splice_mono_into_stereo() { + Signal stereo; + for (int i = 0; i < 3000; ++i) { + stereo.push_back(0.2f); + stereo.push_back(-0.2f); + } + QString old = writeWav(stereo, 2); + QString recording = writeWav(constant(1000, 0.5f)); + QString out = newPath(); + QCOMPARE(TakeAudio::splice(old, recording, 0, 1000, -1, out, + nullptr, 0), QString()); + Audio a = read(out); + QCOMPARE(a.channels, 2); + QCOMPARE(a.frames, frame_t(3000)); + QVERIFY(allNearly(a, 0, 1000, 0.2f, 0)); + QVERIFY(allNearly(a, 0, 1000, -0.2f, 1)); + QVERIFY(allNearly(a, 1000, 2000, 0.5f, 0)); + QVERIFY(allNearly(a, 1000, 2000, 0.5f, 1)); + QVERIFY(allNearly(a, 2000, 3000, -0.2f, 1)); + } + + void splice_stereo_into_mono() { + Signal stereo; + for (int i = 0; i < 1000; ++i) { + stereo.push_back(0.6f); + stereo.push_back(0.2f); + } + QString old = writeWav(constant(3000, 0.1f)); + QString recording = writeWav(stereo, 2); + QString out = newPath(); + QCOMPARE(TakeAudio::splice(old, recording, 0, 1000, -1, out, + nullptr, 0), QString()); + Audio a = read(out); + QCOMPARE(a.channels, 1); + QVERIFY(allNearly(a, 1000, 2000, 0.4f)); + QVERIFY(allNearly(a, 2000, 3000, 0.1f)); + } + + // Longer than the block the work is done in, with the recording + // across a block boundary + void splice_long_files() { + const frame_t oldLength = 100000, n = 40000, position = 30000; + QString old = writeWav(constant(oldLength, 0.25f)); + QString recording = writeWav(ramp(n)); + QString out = newPath(); + QCOMPARE(TakeAudio::splice(old, recording, 0, position, -1, out, + nullptr, kFade), QString()); + Audio a = read(out); + QCOMPARE(a.frames, oldLength); + QVERIFY(allNearly(a, 0, position, 0.25f)); + for (frame_t k = kFade; k < n - kFade; k += 97) { + QVERIFY2(nearly(a.at(position + k), rampValue(k, n)), + qPrintable(QString::number(k))); + } + QVERIFY(allNearly(a, position + n, oldLength, 0.25f)); + } + + void splice_errors() { + QString old = writeWav(constant(3000, 0.25f)); + QString recording = writeWav(constant(1000, 0.5f)); + QString other = writeWav(constant(1000, 0.5f), 1, 48000.0); + + QString out = newPath(); + QVERIFY(TakeAudio::splice(old, m_dir.filePath("absent.wav"), + 0, 0, -1, out) != ""); + QVERIFY(!QFile::exists(out)); + + // The take's own file gone missing: the message says so, and does + // not claim that the file being written exists already + QString missing = m_dir.filePath("absent.wav"); + QString error = TakeAudio::splice(missing, recording, 0, 0, -1, out); + QVERIFY2(error.contains("absent.wav") && !error.contains(out), + qPrintable(error)); + QVERIFY(!QFile::exists(out)); + + QVERIFY(TakeAudio::splice(old, other, 0, 0, -1, out) != ""); + QVERIFY(!QFile::exists(out)); + + // nothing left of it after the latency, or before frame 0 + QVERIFY(TakeAudio::splice(old, recording, 1000, 0, -1, out) != ""); + QVERIFY(TakeAudio::splice(old, recording, 0, -1000, -1, out) != ""); + QVERIFY(TakeAudio::splice(old, recording, 0, 0, 0, out) != ""); + QVERIFY(!QFile::exists(out)); + + // never over a file that is there, the take's own least of all + QVERIFY(TakeAudio::splice(old, recording, 0, 0, -1, old) != ""); + QVERIFY(TakeAudio::splice(old, recording, 0, 0, -1, recording) != ""); + QVERIFY(TakeAudio::splice(old, recording, 0, 0, -1, "") != ""); + QVERIFY(allNearly(read(old), 0, 3000, 0.25f)); + QVERIFY(allNearly(read(recording), 0, 1000, 0.5f)); + } + + void erase_the_middle() { + QString old = writeWav(constant(10000, 0.5f)); + QString out = newPath(); + QCOMPARE(TakeAudio::erase(old, { Coverage::Range(3000, 5000) }, out, + kFade), QString()); + Audio a = read(out); + QVERIFY(a.ok); + QCOMPARE(a.frames, frame_t(10000)); + QVERIFY(allNearly(a, 0, 3000, 0.5f)); + QVERIFY(monotonicBetween(a, 3000, 3000 + kFade, 0.5f, 0.f)); + QVERIFY(allNearly(a, 3000 + kFade, 5000 - kFade, 0.f)); + QVERIFY(monotonicBetween(a, 5000 - kFade, 5000, 0.f, 0.5f)); + QVERIFY(allNearly(a, 5000, 10000, 0.5f)); + + QVERIFY(allNearly(read(old), 0, 10000, 0.5f)); + } + + // Out of order, overlapping, and off both ends of the file + void erase_several() { + QString old = writeWav(constant(10000, 0.5f)); + QString out = newPath(); + Coverage::Ranges ranges { + Coverage::Range(8000, 20000), + Coverage::Range(2000, 3000), + Coverage::Range(2500, 4000), + Coverage::Range(-500, 1000), + Coverage::Range(6000, 6000), + }; + QCOMPARE(TakeAudio::erase(old, ranges, out, 0), QString()); + Audio a = read(out); + QCOMPARE(a.frames, frame_t(10000)); + QVERIFY(allNearly(a, 0, 1000, 0.f)); + QVERIFY(allNearly(a, 1000, 2000, 0.5f)); + QVERIFY(allNearly(a, 2000, 4000, 0.f)); + QVERIFY(allNearly(a, 4000, 8000, 0.5f)); + QVERIFY(allNearly(a, 8000, 10000, 0.f)); + } + + void erase_nothing_is_a_copy() { + QString old = writeWav(ramp(5000)); + QString out = newPath(); + QCOMPARE(TakeAudio::erase(old, {}, out), QString()); + Audio a = read(out); + QCOMPARE(a.frames, frame_t(5000)); + for (frame_t i = 0; i < 5000; i += 50) { + QVERIFY(nearly(a.at(i), rampValue(i, 5000))); + } + } + + void erase_errors() { + QString old = writeWav(constant(3000, 0.5f)); + QString out = newPath(); + Coverage::Ranges ranges { Coverage::Range(1000, 2000) }; + QVERIFY(TakeAudio::erase(m_dir.filePath("absent.wav"), ranges, out) != ""); + QVERIFY(TakeAudio::erase("", ranges, out) != ""); + QVERIFY(!QFile::exists(out)); + QVERIFY(TakeAudio::erase(old, ranges, old) != ""); + QVERIFY(allNearly(read(old), 0, 3000, 0.5f)); + } + + // Converting a recording from a device that does not run at the + // reference's rate. As long as it was in seconds, to the frame, in + // as many channels, kept apart + void resample_keeps_the_length_and_the_channels() { + const frame_t n = 12345; + Signal stereo; + for (frame_t i = 0; i < n; ++i) { + stereo.push_back(0.25f); + stereo.push_back(-0.25f); + } + QString in = writeWav(stereo, 2, 48000.0); + QString out = newPath(); + QCOMPARE(TakeAudio::resample(in, 44100.0, out), QString()); + QCOMPARE(TakeAudio::sampleRate(out), 44100.0); + Audio a = read(out); + QVERIFY(a.ok); + QCOMPARE(a.channels, 2); + QCOMPARE(a.frames, frame_t(std::llround(n * 44100.0 / 48000.0))); + QVERIFY(allNearly(a, 1000, a.frames - 1000, 0.25f, 0)); + QVERIFY(allNearly(a, 1000, a.frames - 1000, -0.25f, 1)); + + // Upwards, and longer than the blocks the work is done in + const frame_t m = 100000; + in = writeWav(ramp(m), 1, 44100.0); + out = newPath(); + QCOMPARE(TakeAudio::resample(in, 48000.0, out), QString()); + QCOMPARE(TakeAudio::sampleRate(out), 48000.0); + a = read(out); + QCOMPARE(a.frames, frame_t(std::llround(m * 48000.0 / 44100.0))); + + // The original is as it was + QCOMPARE(TakeAudio::sampleRate(in), 44100.0); + QCOMPARE(read(in).frames, m); + } + + // A click is where it was in time, to within a frame: near the + // start, across the boundary of two of the blocks the work is done + // in, and near the end, which the resampler holds back until + // something follows it + void resample_keeps_clicks_where_they_were() { + const frame_t n = 50000; + const std::vector clicks { 1000, 16390, 33000, n - 200 }; + for (double from : { 48000.0, 44100.0 }) { + double to = (from == 48000.0 ? 44100.0 : 48000.0); + Signal s(n, 0.f); + for (frame_t c : clicks) s[c] = 0.9f; + QString in = writeWav(s, 1, from); + QString out = newPath(); + QCOMPARE(TakeAudio::resample(in, to, out), QString()); + Audio a = read(out); + QVERIFY(a.ok); + for (frame_t c : clicks) { + double want = double(c) * to / from; + frame_t peak = -1; + float loudest = 0.f; + for (frame_t i = frame_t(want) - 50; i <= frame_t(want) + 50; + ++i) { + if (i < 0 || i >= a.frames) continue; + if (std::fabs(a.at(i)) > loudest) { + loudest = std::fabs(a.at(i)); + peak = i; + } + } + QVERIFY2(loudest > 0.3f && std::fabs(double(peak) - want) <= 1.0, + qPrintable(QString("a click at frame %1 at %2 Hz " + "belongs at %3 at %4 Hz; the " + "loudest frame near it is %5, " + "at %6") + .arg(c).arg(from).arg(want).arg(to) + .arg(peak).arg(loudest))); + } + } + } + + // A tone keeps its pitch, its phase and its level: frame for frame, + // the same sine at the new rate + void resample_keeps_a_tone() { + const double hz = 1000.0, from = 48000.0, to = 44100.0; + QString in = writeWav(TestSignals::sine(hz, from, int(from)), 1, from); + QString out = newPath(); + QCOMPARE(TakeAudio::resample(in, to, out), QString()); + Audio a = read(out); + QCOMPARE(a.frames, frame_t(to)); + // Away from the ends, where the tone starts and stops abruptly + for (frame_t i = 1000; i < a.frames - 1000; ++i) { + float want = float(0.5 * std::sin(2.0 * TestSignals::kPi * hz * + double(i) / to)); + QVERIFY2(std::fabs(a.at(i) - want) < 0.005f, + qPrintable(QString("frame %1 is %2; a %3 Hz sine at " + "%4 Hz is %5 there") + .arg(i).arg(a.at(i)).arg(hz).arg(to) + .arg(want))); + } + } + + void resample_errors() { + QString in = writeWav(constant(1000, 0.5f), 1, 48000.0); + QString other = writeWav(constant(1000, 0.25f)); + + QString out = newPath(); + QVERIFY(TakeAudio::resample(m_dir.filePath("absent.wav"), 44100.0, + out) != ""); + QVERIFY(TakeAudio::resample(in, 0.0, out) != ""); + QVERIFY(TakeAudio::resample(in, 44100.0, "") != ""); + QVERIFY(!QFile::exists(out)); + + // never over a file that is there, the one read from least of all + QVERIFY(TakeAudio::resample(in, 44100.0, in) != ""); + QVERIFY(TakeAudio::resample(in, 44100.0, other) != ""); + QVERIFY(allNearly(read(in), 0, 1000, 0.5f)); + QVERIFY(allNearly(read(other), 0, 1000, 0.25f)); + + QCOMPARE(TakeAudio::sampleRate(in), 48000.0); + QCOMPARE(TakeAudio::sampleRate(m_dir.filePath("absent.wav")), 0.0); + } +}; + +#endif diff --git a/main/test/TestTakeDiff.h b/main/test/TestTakeDiff.h new file mode 100644 index 00000000..71445f1d --- /dev/null +++ b/main/test/TestTakeDiff.h @@ -0,0 +1,858 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TEST_TAKE_DIFF_H +#define TEST_TAKE_DIFF_H + +// Tier 2: the comparisons the development checks make on a real take, +// on synthetic samples and events: each passing, and failing on +// purpose. Two tests run the real splice through files, since where +// its fades fall is what the audio comparison relies on. And where +// live dots lie among the dev reference's sounds. + +#include "../TakeDiff.h" +#include "../TakeAudio.h" +#include "../LatencyCheck.h" +#include "../RealtimePitchTracker.h" +#include "TestSignals.h" + +#include "data/fileio/FileSource.h" +#include "data/fileio/WavFileReader.h" +#include "data/fileio/WavFileWriter.h" + +#include +#include +#include + +#include +#include +#include +#include + +class TestTakeDiff : public QObject +{ + Q_OBJECT + + typedef sv::sv_frame_t frame_t; + typedef Coverage::Range Range; + typedef std::vector Signal; // interleaved + + static constexpr double kRate = 44100.0; + static constexpr float kAmplitude = 0.25f; // -12 dBFS, as the reference + + // 220.5 Hz: exactly 200 samples a period at 44.1 kHz, so a phase + // is a frame and nothing drifts + static constexpr double kToneHz = 220.5; + + QTemporaryDir m_dir; + int m_fileCounter = 0; + + static frame_t framesOf(double seconds) { + return frame_t(std::llround(seconds * kRate)); + } + + // A sine whose phase at frame 0 is "phase" radians + static Signal sine(frame_t frames, double phase = 0.0) { + Signal s(frames); + for (frame_t i = 0; i < frames; ++i) { + s[i] = kAmplitude * float(std::sin(2.0 * TestSignals::kPi * kToneHz + * double(i) / kRate + phase)); + } + return s; + } + + // Distinct and nowhere silent, so that any change shows + static Signal ramp(frame_t frames, int channels = 1) { + Signal s(frames * channels); + for (frame_t i = 0; i < frames * channels; ++i) { + s[i] = 0.1f + 0.5f * float(i) / float(frames * channels); + } + return s; + } + + // Gaussian, from a generator of our own so that every platform + // makes the same noise + static Signal noise(frame_t frames, float sd) { + Signal s(frames); + uint32_t state = 12345; + auto uniform = [&]() { + state = state * 1664525u + 1013904223u; + return (double(state) + 1.0) / 4294967297.0; + }; + for (frame_t i = 0; i < frames; ++i) { + double u = uniform(), v = uniform(); + s[i] = sd * float(std::sqrt(-2.0 * std::log(u)) * + std::cos(2.0 * TestSignals::kPi * v)); + } + return s; + } + + static sv::Event pitchAt(frame_t frame, float hz = 220.f) { + return sv::Event(frame, hz, QString()); + } + + static sv::Event noteAt(frame_t frame, frame_t duration, + float hz = 220.f) { + return sv::Event(frame, hz, duration, "sung"); + } + + // An event every hop from "from" to "to", as pYIN leaves over a + // held tone + static sv::EventVector pitchTrack(frame_t from, frame_t to) { + sv::EventVector events; + for (frame_t f = from; f < to; f += TakeDiff::kHopFrames) { + events.push_back(pitchAt(f)); + } + return events; + } + + static QString placeName(TakeDiff::DotPlace place) { + switch (place) { + case TakeDiff::DotPlace::OnPitch: return "on pitch"; + case TakeDiff::DotPlace::OffPitch: return "off pitch"; + case TakeDiff::DotPlace::AtEdge: return "at an edge"; + case TakeDiff::DotPlace::OnSweep: return "on a sweep"; + case TakeDiff::DotPlace::OnNothing: return "on nothing"; + } + return "?"; + } + + // Where a dot lies, and a message saying where it was and what it + // was taken for + static bool placedAs(double punchIn, double seconds, double hz, + TakeDiff::DotPlace expected, QString &message) { + const TakeDiff::LiveDot dot = TakeDiff::placeLiveDot + (LatencyCheck::devLayout(), punchIn, seconds, hz); + message = QString("a dot at %1 s, %2 Hz, in a punch-in from %3 s: " + "%4, %5 cents from %6 Hz, not %7") + .arg(seconds, 0, 'f', 4).arg(hz).arg(punchIn) + .arg(placeName(dot.place)).arg(dot.cents, 0, 'f', 1) + .arg(dot.toneHz).arg(placeName(expected)); + return dot.place == expected; + } + + static void removeFrame(sv::EventVector &events, frame_t frame) { + events.erase(std::remove_if(events.begin(), events.end(), + [&](const sv::Event &e) { + return e.getFrame() == frame; + }), events.end()); + } + + QString writeWav(const Signal &samples) { + QString path = m_dir.filePath + (QString("source-%1.wav").arg(++m_fileCounter)); + sv::WavFileWriter writer(path, kRate, 1, + sv::WavFileWriter::WriteToTarget); + sv::floatvec_t data(samples.begin(), samples.end()); + if (!writer.isOK() || !writer.putInterleavedFrames(data) || + !writer.close()) { + return ""; + } + return path; + } + + QString newPath() { + return m_dir.filePath(QString("take-%1.wav").arg(++m_fileCounter)); + } + + // All of a mono file, as a check reads a take's file + static Signal read(QString path) { + sv::WavFileReader reader { sv::FileSource(path) }; + if (!reader.isOK() || reader.getChannelCount() != 1) return {}; + auto data = reader.getInterleavedFrames(0, reader.getFrameCount()); + return Signal(data.begin(), data.end()); + } + + static TakeDiff::AudioDiff compare(const Signal &before, + const Signal &after, Range range, + int channels = 1) { + return TakeDiff::audioOutside + (before.data(), frame_t(before.size()) / channels, + after.data(), frame_t(after.size()) / channels, + channels, range); + } + + static TakeDiff::SampleStep stepAt(const Signal &samples, frame_t join, + int channels = 1) { + return TakeDiff::stepAt(samples.data(), + frame_t(samples.size()) / channels, + channels, kRate, join); + } + +private slots: + void initTestCase() { + QVERIFY(m_dir.isValid()); + } + + // ---- Audio outside a range ---- + + // Everything in the range may change, its first and last frames too + void audio_changed_only_inside_passes() { + Signal before = ramp(10000); + Signal after = before; + for (frame_t i = 4000; i < 6000; ++i) after[i] = 0.9f; + + TakeDiff::AudioDiff d = compare(before, after, Range(4000, 6000)); + QVERIFY(d.pass); + QCOMPARE(d.firstDifference, frame_t(-1)); + QCOMPARE(d.differences, frame_t(0)); + } + + // Bit for bit: the smallest change a float can make, one frame + // outside the range at either end, or in one channel only + void audio_changed_just_outside_fails() { + Signal before = ramp(10000); + + Signal after = before; + after[6000] = std::nextafter(after[6000], 1.f); + TakeDiff::AudioDiff d = compare(before, after, Range(4000, 6000)); + QVERIFY(!d.pass); + QCOMPARE(d.firstDifference, frame_t(6000)); + QCOMPARE(d.differences, frame_t(1)); + QVERIFY(d.largestDifference > 0.0); + QVERIFY(d.largestDifference < 1e-6); + + after = before; + after[3999] = 0.f; + d = compare(before, after, Range(4000, 6000)); + QVERIFY(!d.pass); + QCOMPARE(d.firstDifference, frame_t(3999)); + + Signal stereo = ramp(10000, 2); + after = stereo; + after[6000 * 2 + 1] = 0.f; + d = compare(stereo, after, Range(4000, 6000), 2); + QVERIFY(!d.pass); + QCOMPARE(d.firstDifference, frame_t(6000)); + QCOMPARE(d.differences, frame_t(1)); + } + + // A take is silent wherever nothing was recorded, in the file or + // past its end + void audio_past_the_end_is_silence() { + Signal before = ramp(5000); + + // A punch-in after the old end makes the file longer + Signal after = before; + after.resize(8000, 0.f); + for (frame_t i = 6000; i < 8000; ++i) after[i] = 0.9f; + QVERIFY(compare(before, after, Range(6000, 8000)).pass); + + after[5500] = 0.1f; + TakeDiff::AudioDiff d = compare(before, after, Range(6000, 8000)); + QVERIFY(!d.pass); + QCOMPARE(d.firstDifference, frame_t(5500)); + + // Sound lost off the end is a difference too + after = before; + after.resize(4500); + d = compare(before, after, Range(1000, 2000)); + QVERIFY(!d.pass); + QCOMPARE(d.firstDifference, frame_t(4500)); + QCOMPARE(d.differences, frame_t(500)); + } + + // The real splice and erase, through their files: bit-identical + // outside the range they fill, and their fades reach right to its + // ends, so that the range less one frame at either end differs + // exactly there. Were a fade to fall outside the range, the first + // comparison would fail + void audio_splice_and_erase_fades_lie_inside_the_range() { + QString oldPath = writeWav(sine(20000)); + QString recording = writeWav(Signal(6000, -0.2f)); + QVERIFY(oldPath != "" && recording != ""); + + QString spliced = newPath(); + Range placed; + QCOMPARE(TakeAudio::splice(oldPath, recording, 0, 5000, 4000, + spliced, &placed), QString()); + QCOMPARE(placed.start, frame_t(5000)); + QCOMPARE(placed.end, frame_t(9000)); + + Signal before = read(oldPath), after = read(spliced); + QCOMPARE(frame_t(before.size()), frame_t(20000)); + QCOMPARE(frame_t(after.size()), frame_t(20000)); + + TakeDiff::AudioDiff d = compare(before, after, placed); + QVERIFY2(d.pass, qPrintable(QString("differs from frame %1") + .arg(d.firstDifference))); + + d = compare(before, after, Range(placed.start + 1, placed.end)); + QVERIFY(!d.pass); + QCOMPARE(d.firstDifference, placed.start); + QCOMPARE(d.differences, frame_t(1)); + + d = compare(before, after, Range(placed.start, placed.end - 1)); + QVERIFY(!d.pass); + QCOMPARE(d.firstDifference, placed.end - 1); + + QString erased = newPath(); + Range gone(12050, 13050); // from a peak: a fade there changes it + QCOMPARE(TakeAudio::erase(spliced, Coverage::Ranges { gone }, erased), + QString()); + Signal later = read(erased); + QVERIFY(compare(after, later, gone).pass); + d = compare(after, later, Range(gone.start + 1, gone.end)); + QCOMPARE(d.firstDifference, gone.start); + } + + // ---- Events outside a range, give or take the margin ---- + + void events_changed_inside_the_margin_pass() { + Range range(100000, 200000); + frame_t margin = framesOf(TakeDiff::kEventMarginSeconds); + + sv::EventVector before = pitchTrack(0, 300000); + sv::EventVector after; + for (const sv::Event &e : before) { + bool inside = e.getFrame() >= range.start - margin && + e.getFrame() < range.end + margin; + after.push_back(inside ? e.withValue(330.f) : e); + } + after.push_back(pitchAt(range.start + 7)); // one more, inside + + TakeDiff::EventDiff d = + TakeDiff::eventsOutside(before, after, range, kRate); + QVERIFY(d.pass); + QCOMPARE(d.firstDifference, frame_t(-1)); + QCOMPARE(d.window.start, range.start - margin); + QCOMPARE(d.window.end, range.end + margin); + + // A note that crosses the window's edge counts as inside: cut + // back, as the merge may cut an old note a new one overlaps + sv::EventVector notes { noteAt(50000, 30000), noteAt(80000, 20000), + noteAt(150000, 10000), noteAt(230000, 10000) }; + sv::EventVector cut { noteAt(50000, 30000), noteAt(80000, 10000), + noteAt(160000, 5000), noteAt(230000, 10000) }; + QVERIFY(TakeDiff::eventsOutside(notes, cut, range, kRate).pass); + } + + // The window is [start - margin, end + margin), to the frame + void pitch_changed_just_outside_the_margin_fails() { + Range range(100000, 200000); + frame_t margin = framesOf(TakeDiff::kEventMarginSeconds); + frame_t first = range.start - margin, last = range.end + margin; + + sv::EventVector before { pitchAt(first - 1), pitchAt(first), + pitchAt(last - 1), pitchAt(last) }; + + sv::EventVector after { pitchAt(first - 1, 221.f), pitchAt(first, 300.f), + pitchAt(last - 1, 300.f), pitchAt(last) }; + TakeDiff::EventDiff d = + TakeDiff::eventsOutside(before, after, range, kRate); + QVERIFY(!d.pass); + QCOMPARE(int(d.changed.size()), 1); + QCOMPARE(d.changed[0].first.getFrame(), first - 1); + QCOMPARE(d.changed[0].second.getValue(), 221.f); + QCOMPARE(d.firstDifference, first - 1); + + after = { pitchAt(first - 1), pitchAt(first), pitchAt(last - 1), + pitchAt(last, 221.f) }; + d = TakeDiff::eventsOutside(before, after, range, kRate); + QVERIFY(!d.pass); + QCOMPARE(d.firstDifference, last); + } + + void events_added_removed_and_changed_are_told_apart() { + Range range(100000, 110000); + sv::EventVector before { pitchAt(1000), pitchAt(2000), pitchAt(3000) }; + sv::EventVector after { pitchAt(4000), pitchAt(2000, 230.f), + pitchAt(1000) }; // and out of order + + TakeDiff::EventDiff d = + TakeDiff::eventsOutside(before, after, range, kRate); + QVERIFY(!d.pass); + QCOMPARE(int(d.removed.size()), 1); + QCOMPARE(d.removed[0].getFrame(), frame_t(3000)); + QCOMPARE(int(d.added.size()), 1); + QCOMPARE(d.added[0].getFrame(), frame_t(4000)); + QCOMPARE(int(d.changed.size()), 1); + QCOMPARE(d.changed[0].first.getValue(), 220.f); + QCOMPARE(d.changed[0].second.getValue(), 230.f); + QCOMPARE(d.firstDifference, frame_t(2000)); + + // The same event twice on one side is one more event there + before = { pitchAt(1000), pitchAt(1000) }; + after = { pitchAt(1000) }; + d = TakeDiff::eventsOutside(before, after, range, kRate); + QVERIFY(!d.pass); + QCOMPARE(int(d.removed.size()), 1); + QVERIFY(d.changed.empty()); + } + + void note_changed_outside_the_margin_fails() { + Range range(100000, 200000); + + // Shorter, wholly outside + sv::EventVector before { noteAt(20000, 5000) }; + sv::EventVector after { noteAt(20000, 4000) }; + TakeDiff::EventDiff d = + TakeDiff::eventsOutside(before, after, range, kRate); + QVERIFY(!d.pass); + QCOMPARE(int(d.changed.size()), 1); + QCOMPARE(d.changed[0].second.getDuration(), frame_t(4000)); + + // Grown into the window: the note that was outside is gone + before = { noteAt(80000, 8000) }; + after = { noteAt(80000, 10000) }; + d = TakeDiff::eventsOutside(before, after, range, kRate); + QVERIFY(!d.pass); + QCOMPARE(int(d.removed.size()), 1); + QVERIFY(d.added.empty()); + } + + // ---- Pitch across a join ---- + + void pitch_continuous_across_a_join_passes() { + frame_t join = framesOf(2.0) + 100; + sv::EventVector pitch = pitchTrack(0, framesOf(5.0)); + + TakeDiff::PitchJoin p = TakeDiff::pitchAcross(pitch, join, kRate); + QVERIFY(p.pass); + QCOMPARE(p.largestGap, TakeDiff::kHopFrames); + QCOMPARE(p.doubled, 0); + QCOMPARE(p.outOfOrder, 0); + + frame_t reach = framesOf(TakeDiff::kPitchWindowSeconds); + QCOMPARE(p.window.start, join - reach); + QCOMPARE(p.window.end, join + reach); + int expected = 0; + for (const sv::Event &e : pitch) { + if (e.getFrame() >= join - reach && e.getFrame() < join + reach) { + ++expected; + } + } + QCOMPARE(p.events, expected); + } + + void pitch_event_dropped_at_the_join_fails() { + frame_t join = framesOf(2.0) + 100; + frame_t atJoin = (join / TakeDiff::kHopFrames) * TakeDiff::kHopFrames; + sv::EventVector pitch = pitchTrack(0, framesOf(5.0)); + removeFrame(pitch, atJoin); + + TakeDiff::PitchJoin p = TakeDiff::pitchAcross(pitch, join, kRate); + QVERIFY(!p.pass); + QCOMPARE(p.largestGap, 2 * TakeDiff::kHopFrames); + QCOMPARE(p.largestGapFrom, atJoin - TakeDiff::kHopFrames); + + // Two hops are allowed when asked for + QVERIFY(TakeDiff::pitchAcross(pitch, join, kRate, + TakeDiff::kPitchWindowSeconds, 2).pass); + } + + void pitch_doubled_frame_fails() { + frame_t join = framesOf(2.0) + 100; + frame_t atJoin = (join / TakeDiff::kHopFrames) * TakeDiff::kHopFrames; + sv::EventVector pitch = pitchTrack(0, framesOf(5.0)); + + sv::EventVector doubled = pitch; + auto at = std::find(doubled.begin(), doubled.end(), pitchAt(atJoin)); + QVERIFY(at != doubled.end()); + doubled.insert(at + 1, pitchAt(atJoin, 221.f)); + + TakeDiff::PitchJoin p = TakeDiff::pitchAcross(doubled, join, kRate); + QVERIFY(!p.pass); + QCOMPARE(p.doubled, 1); + QCOMPARE(p.firstDoubled, atJoin); + QCOMPARE(p.outOfOrder, 0); + QCOMPARE(p.largestGap, TakeDiff::kHopFrames); + + // Two neighbours the wrong way round + sv::EventVector swapped = pitch; + at = std::find(swapped.begin(), swapped.end(), pitchAt(atJoin)); + std::iter_swap(at, at + 1); + p = TakeDiff::pitchAcross(swapped, join, kRate); + QVERIFY(!p.pass); + QCOMPARE(p.outOfOrder, 1); + QCOMPARE(p.firstOutOfOrder, atJoin); + QCOMPARE(p.doubled, 0); + } + + // Only the window counts, but a hole that reaches into it does + void pitch_looks_only_around_the_join() { + const frame_t hop = TakeDiff::kHopFrames; + frame_t join = framesOf(2.0) + 100; + frame_t atJoin = (join / hop) * hop; + sv::EventVector pitch = pitchTrack(0, framesOf(5.0)); + + // A doubled frame and a hole a second away: pYIN's own doubled + // frame near the end of a run is like this + sv::EventVector distant = pitch; + distant.push_back(pitchAt(atJoin + 172 * hop)); + for (int k = 1; k <= 10; ++k) removeFrame(distant, atJoin - 172 * hop + k * hop); + QVERIFY(TakeDiff::pitchAcross(distant, join, kRate).pass); + + // The merge's seam, a quarter of a second before the join + sv::EventVector seam = pitch; + frame_t seamFrame = ((join - framesOf(0.25)) / hop) * hop; + removeFrame(seam, seamFrame); + QVERIFY(!TakeDiff::pitchAcross(seam, join, kRate).pass); + + // A hole from before the window into it + frame_t windowStart = join - framesOf(TakeDiff::kPitchWindowSeconds); + sv::EventVector into = pitch; + into.erase(std::remove_if(into.begin(), into.end(), + [&](const sv::Event &e) { + return e.getFrame() > windowStart - 2000 && + e.getFrame() < windowStart + 1000; + }), into.end()); + TakeDiff::PitchJoin p = TakeDiff::pitchAcross(into, join, kRate); + QVERIFY(!p.pass); + QVERIFY(p.largestGapFrom < windowStart); + + // The track stops inside the window + sv::EventVector stops = pitchTrack(0, join + framesOf(0.2)); + p = TakeDiff::pitchAcross(stops, join, kRate); + QVERIFY(!p.pass); + QVERIFY(p.largestGap > framesOf(0.2)); + + // Nothing at all + p = TakeDiff::pitchAcross(sv::EventVector(), join, kRate); + QVERIFY(!p.pass); + QCOMPARE(p.events, 0); + QCOMPARE(p.largestGap, 2 * framesOf(TakeDiff::kPitchWindowSeconds)); + } + + // ---- One note across a join ---- + + void one_note_across_a_join_passes() { + frame_t join = framesOf(10.0); + sv::EventVector notes { noteAt(framesOf(5.0), framesOf(1.0)), + noteAt(join - framesOf(1.5), framesOf(3.0)), + noteAt(framesOf(13.0), framesOf(1.0)) }; + + TakeDiff::NoteJoin n = TakeDiff::notesAcross(notes, join, kRate); + QVERIFY(n.pass); + QCOMPARE(int(n.spanning.size()), 1); + QCOMPARE(n.spanning[0].getFrame(), join - framesOf(1.5)); + QVERIFY(n.edgesNear.empty()); + QCOMPARE(n.nearestEdge, -framesOf(1.5)); + } + + void note_split_at_the_join_fails() { + frame_t join = framesOf(10.0); + frame_t a = join - framesOf(1.5), b = join + framesOf(1.5); + frame_t clearance = framesOf(TakeDiff::kNoteClearanceSeconds); + + // Split at the join: the first ends there, so only the second + // holds it + sv::EventVector notes { noteAt(a, join - a), noteAt(join, b - join) }; + TakeDiff::NoteJoin n = TakeDiff::notesAcross(notes, join, kRate); + QVERIFY(!n.pass); + QCOMPARE(int(n.spanning.size()), 1); + QCOMPARE(n.spanning[0].getFrame(), join); + QCOMPARE(int(n.edgesNear.size()), 2); + QCOMPARE(n.nearestEdge, frame_t(0)); + + // At the merge's seam + frame_t seam = join - framesOf(0.25); + notes = { noteAt(a, seam - a), noteAt(seam, b - seam) }; + n = TakeDiff::notesAcross(notes, join, kRate); + QVERIFY(!n.pass); + QCOMPARE(n.nearestEdge, -framesOf(0.25)); + + // At the clearance, and one frame beyond it + frame_t split = join - clearance; + notes = { noteAt(a, split - a), noteAt(split, b - split) }; + QVERIFY(!TakeDiff::notesAcross(notes, join, kRate).pass); + split = join - clearance - 1; + notes = { noteAt(a, split - a), noteAt(split, b - split) }; + QVERIFY(TakeDiff::notesAcross(notes, join, kRate).pass); + + // None, or two at once + QVERIFY(!TakeDiff::notesAcross(sv::EventVector(), join, kRate).pass); + notes = { noteAt(a, b - a), noteAt(a, b - a, 330.f) }; + n = TakeDiff::notesAcross(notes, join, kRate); + QVERIFY(!n.pass); + QCOMPARE(int(n.spanning.size()), 2); + } + + // ---- No step in the samples at a join ---- + + void a_steady_tone_has_no_step() { + Signal tone = sine(framesOf(0.2)); + TakeDiff::SampleStep s = stepAt(tone, framesOf(0.1)); + QVERIFY2(s.pass && std::fabs(s.stepDb) < 0.1, + qPrintable(QString("%1 dB").arg(s.stepDb))); + + // Silence has no step either + s = stepAt(Signal(framesOf(0.2), 0.f), framesOf(0.1)); + QVERIFY(s.pass); + QCOMPARE(s.stepDb, 0.0); + } + + // The threshold's own reasoning: noise reads a few dB, well under it + void white_noise_has_no_step() { + Signal hiss = noise(framesOf(0.2), 0.05f); + TakeDiff::SampleStep s = stepAt(hiss, framesOf(0.1)); + QVERIFY2(s.pass && s.stepDb > 0.0 && s.stepDb < 7.0, + qPrintable(QString("%1 dB").arg(s.stepDb))); + } + + // The tone at its peak is cut to the same tone at its trough + void a_hard_cut_in_a_sine_is_a_step() { + frame_t join = 4050; // a quarter period past a whole one: the peak + Signal cut = sine(framesOf(0.2)); + Signal opposite = sine(framesOf(0.2), TestSignals::kPi); + std::copy(opposite.begin() + join, opposite.end(), cut.begin() + join); + + TakeDiff::SampleStep s = stepAt(cut, join); + QVERIFY(!s.pass); + QVERIFY2(s.stepDb > 30.0, qPrintable(QString("%1 dB").arg(s.stepDb))); + QCOMPARE(s.largestAt, join); + QVERIFY(s.largest > 1.9 * kAmplitude); + + // Seen in the one channel that has it + Signal stereo(cut.size() * 2); + Signal tone = sine(framesOf(0.2)); + for (size_t i = 0; i < cut.size(); ++i) { + stereo[i * 2] = tone[i]; + stereo[i * 2 + 1] = cut[i]; + } + s = stepAt(stereo, join, 2); + QVERIFY(!s.pass); + QCOMPARE(s.channel, 1); + } + + // The real splice, through its files: a recording of the opposite + // phase crossfaded in reads no step, and cut in without its fade + // reads one + void the_splice_crossfade_leaves_no_step() { + QString oldPath = writeWav(sine(20000)); + QString recording = writeWav(sine(6000, -TestSignals::kPi / 2)); + QVERIFY(oldPath != "" && recording != ""); + + frame_t position = 10050; // the old take at its peak + QString faded = newPath(), hard = newPath(); + QCOMPARE(TakeAudio::splice(oldPath, recording, 0, position, 4000, + faded), QString()); + QCOMPARE(TakeAudio::splice(oldPath, recording, 0, position, 4000, + hard, nullptr, 0), QString()); + + TakeDiff::SampleStep s = stepAt(read(faded), position); + QVERIFY2(s.pass && s.stepDb < 1.0, + qPrintable(QString("%1 dB").arg(s.stepDb))); + s = stepAt(read(faded), position + 4000); + QVERIFY2(s.pass, qPrintable(QString("%1 dB").arg(s.stepDb))); + + s = stepAt(read(hard), position); + QVERIFY2(!s.pass && s.stepDb > 30.0, + qPrintable(QString("%1 dB").arg(s.stepDb))); + QCOMPARE(s.largestAt, position); + } + + void a_join_outside_the_audio_does_not_pass() { + Signal tone = sine(framesOf(0.2)); + QVERIFY(!stepAt(tone, framesOf(0.2) + 1000).pass); + QVERIFY(!stepAt(Signal(), 0).pass); + } + + // Item 3 of the dev checks, at the tone of 245 Hz from 7.5 s in the + // first punch-in, where the user's runs had dots 50 to 75 cents off + // from 7.516 to 7.528 s on every driver. Those dots, in the first + // window of the tracker from the tone's start, are not judged, and + // the item passes; the same dots one window later are off pitch, and + // make it fail. In tune there, they are on pitch + void live_dots_off_pitch_at_a_tones_onset_are_not_judged() { + const LatencyCheck::Layout layout = LatencyCheck::devLayout(); + const LatencyCheck::Event &e = layout.events[3]; + QCOMPARE(e.toneHz, 245.0); + const double onset = double(e.toneStart) / layout.rate; + QVERIFY(std::fabs(onset - 7.5) < 1e-9); + const double window = + double(RealtimePitchTracker::kWindowSize) / layout.rate; + const double punchIn = 6.3; + const double sharp = 254.6; + + const TakeDiff::LiveDot first = + TakeDiff::placeLiveDot(layout, punchIn, 7.519, sharp); + QCOMPARE(first.toneHz, 245.0); + QVERIFY2(std::fabs(first.cents - 67.0) < 1.0, + qPrintable(QString::number(first.cents))); + + QString message; + for (double at : { 7.516, 7.519, 7.528, onset + window - 0.0005 }) { + QVERIFY2(placedAs(punchIn, at, sharp, + TakeDiff::DotPlace::AtEdge, message), + qPrintable(message)); + QVERIFY2(placedAs(punchIn, at + window, sharp, + TakeDiff::DotPlace::OffPitch, message), + qPrintable(message)); + QVERIFY2(placedAs(punchIn, at + window, 245.0, + TakeDiff::DotPlace::OnPitch, message), + qPrintable(message)); + } + + // Within kDotCents either way, and no further + const double cents = TakeDiff::kDotCents; + const double at = onset + 0.3; + QVERIFY2(placedAs(punchIn, at, 245.0 * std::pow(2.0, (cents - 1) / 1200), + TakeDiff::DotPlace::OnPitch, message), + qPrintable(message)); + QVERIFY2(placedAs(punchIn, at, 245.0 * std::pow(2.0, -(cents - 1) / 1200), + TakeDiff::DotPlace::OnPitch, message), + qPrintable(message)); + QVERIFY2(placedAs(punchIn, at, 245.0 * std::pow(2.0, (cents + 1) / 1200), + TakeDiff::DotPlace::OffPitch, message), + qPrintable(message)); + QVERIFY2(placedAs(punchIn, at, 0.0, TakeDiff::DotPlace::OffPitch, + message), qPrintable(message)); + } + + // The punch-in from 16.8 s, where the tone of 245 Hz before it ends: + // its first window straddles the start of what is kept, and the + // user's runs had dots off pitch there too, from 16.803 to 16.822 s. + // They are not judged; nor, since they lie within half a window of + // that tone's end, in a punch-in that began before the tone. The + // same dots at a punch-in starting inside a tone, at 16.4 s, are not + // judged, and in a punch-in that began before the tone they are. The + // same at a punch-in starting in silence, where one window later a + // dot is on nothing + void live_dots_at_a_punch_ins_start_are_not_judged() { + const LatencyCheck::Layout layout = LatencyCheck::devLayout(); + const double window = + double(RealtimePitchTracker::kWindowSize) / layout.rate; + const double sharp = 254.6; + QString message; + for (double at : { 16.803, 16.822 }) { + QVERIFY2(placedAs(16.8, at, sharp, TakeDiff::DotPlace::AtEdge, + message), qPrintable(message)); + QVERIFY2(placedAs(15.0, at, sharp, TakeDiff::DotPlace::AtEdge, + message), qPrintable(message)); + const double inside = at - 0.4; + QVERIFY2(placedAs(16.4, inside, sharp, TakeDiff::DotPlace::AtEdge, + message), qPrintable(message)); + QVERIFY2(placedAs(15.0, inside, sharp, + TakeDiff::DotPlace::OffPitch, message), + qPrintable(message)); + } + + // Between the tone that ends at 11.9 s and the sweep at 13.1 s + QVERIFY2(placedAs(12.3, 12.31, sharp, TakeDiff::DotPlace::AtEdge, + message), qPrintable(message)); + QVERIFY2(placedAs(12.3, 12.31 + window, sharp, + TakeDiff::DotPlace::OnNothing, message), + qPrintable(message)); + } + + // Item 3 at a tone's end, where the user's run at WASAPI 20 ms had + // dots 71 cents sharp at 8.302 s, as the tone of 245 Hz ends at + // 8.3 s, and 62 cents sharp at 18.797 s, as the tone of 220.5 Hz + // ends at 18.8 s. Within half a window either side of the end, the + // window straddles it, and the dots are not judged; the same dots a + // window earlier, inside the tone, are off pitch, and in tune there + // they are on pitch + void live_dots_off_pitch_at_a_tones_end_are_not_judged() { + const LatencyCheck::Layout layout = LatencyCheck::devLayout(); + const TakeDiff::DotReach reach = TakeDiff::dotReach(layout.rate); + const double window = + double(RealtimePitchTracker::kWindowSize) / layout.rate; + + struct UserDot { + int event; + double toneHz; + double end; + double punchIn; + double seconds; + double hz; + double cents; + }; + const UserDot userDots[] = { + { 3, 245.0, 8.3, 6.3, 8.302, 255.3, 71.0 }, + { 8, 220.5, 18.8, 16.8, 18.797, 228.5, 62.0 }, + }; + + QString message; + for (const UserDot &u : userDots) { + const LatencyCheck::Event &e = layout.events[u.event]; + QCOMPARE(e.toneHz, u.toneHz); + const double end = double(e.toneStart + e.toneLength) / layout.rate; + QVERIFY(std::fabs(end - u.end) < 1e-9); + + const TakeDiff::LiveDot dot = + TakeDiff::placeLiveDot(layout, u.punchIn, u.seconds, u.hz); + QCOMPARE(dot.toneHz, u.toneHz); + QVERIFY2(std::fabs(dot.cents - u.cents) < 1.0, + qPrintable(QString::number(dot.cents))); + + QVERIFY2(placedAs(u.punchIn, u.seconds, u.hz, + TakeDiff::DotPlace::AtEdge, message), + qPrintable(message)); + QVERIFY2(placedAs(u.punchIn, u.seconds, 0.0, + TakeDiff::DotPlace::AtEdge, message), + qPrintable(message)); + QVERIFY2(placedAs(u.punchIn, u.seconds - window, u.hz, + TakeDiff::DotPlace::OffPitch, message), + qPrintable(message)); + QVERIFY2(placedAs(u.punchIn, u.seconds - window, u.toneHz, + TakeDiff::DotPlace::OnPitch, message), + qPrintable(message)); + + // Half a window either side, and no further: past it, still + // within the tone's reach, the dot is judged again + const double margin = 0.0005; + for (double at : { end - reach.end + margin, + end + reach.end - margin }) { + QVERIFY2(placedAs(u.punchIn, at, u.hz, + TakeDiff::DotPlace::AtEdge, message), + qPrintable(message)); + } + for (double at : { end - reach.end - margin, + end + reach.end + margin }) { + QVERIFY(at < end + reach.after); + QVERIFY2(placedAs(u.punchIn, at, u.hz, + TakeDiff::DotPlace::OffPitch, message), + qPrintable(message)); + QVERIFY2(placedAs(u.punchIn, at, u.toneHz, + TakeDiff::DotPlace::OnPitch, message), + qPrintable(message)); + } + } + } + + // How far a sound's dots reach: a hop before its start, half a + // window and a hop past its end; a sweep's at any pitch + void live_dots_reach_of_a_sound() { + const LatencyCheck::Layout layout = LatencyCheck::devLayout(); + const TakeDiff::DotReach reach = TakeDiff::dotReach(layout.rate); + QVERIFY(std::fabs(reach.before - 256 / 44100.0) < 1e-12); + QVERIFY(std::fabs(reach.after - (256 + 1024) / 44100.0) < 1e-12); + QVERIFY(std::fabs(reach.onset - 2048 / 44100.0) < 1e-12); + QVERIFY(std::fabs(reach.end - 1024 / 44100.0) < 1e-12); + + // The event at 7.2 s: its sweep, then its tone from 7.5 to 8.3 s + const double punchIn = 6.3; + const double e = 0.0005; + QString message; + for (double at : { 7.2 - reach.before + e, 7.3, + 7.4 + reach.after - e }) { + QVERIFY2(placedAs(punchIn, at, 900.0, TakeDiff::DotPlace::OnSweep, + message), qPrintable(message)); + } + for (double at : { 7.2 - reach.before - e, 7.4 + reach.after + e, + 7.5 - reach.before - e, 8.3 + reach.after + e }) { + QVERIFY2(placedAs(punchIn, at, 245.0, + TakeDiff::DotPlace::OnNothing, message), + qPrintable(message)); + } + QVERIFY2(placedAs(punchIn, 7.5 - reach.before + e, 245.0, + TakeDiff::DotPlace::AtEdge, message), + qPrintable(message)); + QVERIFY2(placedAs(punchIn, 8.3 + reach.after - e, 245.0, + TakeDiff::DotPlace::OnPitch, message), + qPrintable(message)); + } +}; + +#endif diff --git a/main/test/TestTakeEvents.h b/main/test/TestTakeEvents.h new file mode 100644 index 00000000..97b988da --- /dev/null +++ b/main/test/TestTakeEvents.h @@ -0,0 +1,217 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TEST_TAKE_EVENTS_H +#define TEST_TAKE_EVENTS_H + +// Tier 2: what erasing part of a take does to the pitch events and the +// notes that were analysed there. Event lists in, changes out; no +// models and no window. + +#include "../TakeEvents.h" + +#include +#include + +class TestTakeEvents : public QObject +{ + Q_OBJECT + + typedef sv::sv_frame_t frame_t; + typedef Coverage::Range Range; + typedef Coverage::Ranges Ranges; + + // A pitch event: a frequency at a frame, no duration + static sv::Event pitchAt(frame_t frame, float hz = 220.f) { + return sv::Event(frame, hz, QString()); + } + + static sv::Event noteAt(frame_t frame, frame_t duration, + float hz = 220.f) { + return sv::Event(frame, hz, duration, "sung"); + } + + static sv::EventVector pitchTrack(frame_t from, frame_t to, + frame_t step) { + sv::EventVector events; + for (frame_t f = from; f < to; f += step) events.push_back(pitchAt(f)); + return events; + } + +private slots: + // The pitch events in the erased ranges go, and nothing is added in + // their place: there is nothing there to hear any more + void pitch_in_the_erased_ranges_goes() { + sv::EventVector events = pitchTrack(0, 1000, 100); + TakeEvents::Change change = + TakeEvents::erasePitch(events, Ranges { Range(300, 600) }); + + QVERIFY(change.added.empty()); + QCOMPARE(int(change.removed.size()), 3); + QCOMPARE(change.removed[0].getFrame(), frame_t(300)); + QCOMPARE(change.removed[1].getFrame(), frame_t(400)); + QCOMPARE(change.removed[2].getFrame(), frame_t(500)); + } + + // A range is [start, end): an event at the end of it is outside + void pitch_at_the_edges_of_a_range() { + sv::EventVector events { pitchAt(299), pitchAt(300), pitchAt(599), + pitchAt(600) }; + TakeEvents::Change change = + TakeEvents::erasePitch(events, Ranges { Range(300, 600) }); + + QCOMPARE(int(change.removed.size()), 2); + QCOMPARE(change.removed[0].getFrame(), frame_t(300)); + QCOMPARE(change.removed[1].getFrame(), frame_t(599)); + } + + void pitch_in_several_ranges() { + sv::EventVector events = pitchTrack(0, 1000, 100); + // Out of order, and two of them overlapping + TakeEvents::Change change = TakeEvents::erasePitch + (events, Ranges { Range(800, 900), Range(100, 250), + Range(200, 300) }); + + QCOMPARE(int(change.removed.size()), 3); + QCOMPARE(change.removed[0].getFrame(), frame_t(100)); + QCOMPARE(change.removed[1].getFrame(), frame_t(200)); + QCOMPARE(change.removed[2].getFrame(), frame_t(800)); + } + + void nothing_erased_changes_nothing() { + sv::EventVector events = pitchTrack(0, 1000, 100); + QVERIFY(TakeEvents::erasePitch(events, Ranges {}).isEmpty()); + QVERIFY(TakeEvents::eraseNotes + (sv::EventVector { noteAt(0, 1000) }, Ranges {}).isEmpty()); + + // A range that does not reach the events + QVERIFY(TakeEvents::erasePitch + (events, Ranges { Range(2000, 3000) }).isEmpty()); + QVERIFY(TakeEvents::eraseNotes + (sv::EventVector { noteAt(0, 1000) }, + Ranges { Range(2000, 3000) }).isEmpty()); + } + + // A note the erased range takes in altogether goes + void note_wholly_inside_goes() { + sv::EventVector notes { noteAt(400, 100) }; + TakeEvents::Change change = + TakeEvents::eraseNotes(notes, Ranges { Range(300, 600) }); + + QCOMPARE(int(change.removed.size()), 1); + QCOMPARE(change.removed[0].getFrame(), frame_t(400)); + QVERIFY(change.added.empty()); + + // and one that fills it exactly + change = TakeEvents::eraseNotes(sv::EventVector { noteAt(300, 300) }, + Ranges { Range(300, 600) }); + QCOMPARE(int(change.removed.size()), 1); + QVERIFY(change.added.empty()); + } + + // A note that runs into the range from before it keeps its onset and + // is cut back to where the erased audio begins + void note_cut_back_at_the_start_of_a_range() { + sv::EventVector notes { noteAt(100, 300, 330.f) }; + TakeEvents::Change change = + TakeEvents::eraseNotes(notes, Ranges { Range(300, 600) }); + + QCOMPARE(int(change.removed.size()), 1); + QCOMPARE(int(change.added.size()), 1); + QCOMPARE(change.added[0].getFrame(), frame_t(100)); + QCOMPARE(change.added[0].getDuration(), frame_t(200)); + // and it is the same note otherwise + QCOMPARE(change.added[0].getValue(), 330.f); + QCOMPARE(change.added[0].getLabel(), QString("sung")); + } + + // A note whose own start was erased has lost the singing its onset + // was: it begins again where the audio does, and is shorter by what + // was taken from it + void note_moved_on_from_the_end_of_a_range() { + sv::EventVector notes { noteAt(400, 500) }; + TakeEvents::Change change = + TakeEvents::eraseNotes(notes, Ranges { Range(300, 600) }); + + QCOMPARE(int(change.removed.size()), 1); + QCOMPARE(int(change.added.size()), 1); + QCOMPARE(change.added[0].getFrame(), frame_t(600)); + QCOMPARE(change.added[0].getDuration(), frame_t(300)); + } + + // A range erased from the middle of a note leaves two notes, as it + // leaves two stretches of audio + void note_split_by_a_range_in_the_middle() { + sv::EventVector notes { noteAt(100, 800) }; + TakeEvents::Change change = + TakeEvents::eraseNotes(notes, Ranges { Range(300, 600) }); + + QCOMPARE(int(change.removed.size()), 1); + QCOMPARE(int(change.added.size()), 2); + QCOMPARE(change.added[0].getFrame(), frame_t(100)); + QCOMPARE(change.added[0].getDuration(), frame_t(200)); + QCOMPARE(change.added[1].getFrame(), frame_t(600)); + QCOMPARE(change.added[1].getDuration(), frame_t(300)); + } + + // Several ranges through one note, given out of order + void note_through_several_ranges() { + sv::EventVector notes { noteAt(0, 1000) }; + TakeEvents::Change change = TakeEvents::eraseNotes + (notes, Ranges { Range(600, 700), Range(250, 400), + Range(200, 300) }); + + QCOMPARE(int(change.removed.size()), 1); + QCOMPARE(int(change.added.size()), 3); + QCOMPARE(change.added[0].getFrame(), frame_t(0)); + QCOMPARE(change.added[0].getDuration(), frame_t(200)); + QCOMPARE(change.added[1].getFrame(), frame_t(400)); + QCOMPARE(change.added[1].getDuration(), frame_t(200)); + QCOMPARE(change.added[2].getFrame(), frame_t(700)); + QCOMPARE(change.added[2].getDuration(), frame_t(300)); + } + + // Only the notes the ranges reach are touched + void notes_outside_are_left_alone() { + sv::EventVector notes { noteAt(0, 100), noteAt(400, 100), + noteAt(900, 100) }; + TakeEvents::Change change = + TakeEvents::eraseNotes(notes, Ranges { Range(300, 600) }); + + QCOMPARE(int(change.removed.size()), 1); + QCOMPARE(change.removed[0].getFrame(), frame_t(400)); + QVERIFY(change.added.empty()); + } + + // A note that ends where the range starts, and one that starts where + // it ends, are outside it + void notes_at_the_edges_of_a_range() { + sv::EventVector notes { noteAt(200, 100), noteAt(600, 100) }; + QVERIFY(TakeEvents::eraseNotes + (notes, Ranges { Range(300, 600) }).isEmpty()); + } + + // A note of no length: nothing pYIN makes, but the rule is the same + // as for a pitch event + void a_note_of_no_length() { + sv::EventVector notes { noteAt(400, 0), noteAt(700, 0) }; + TakeEvents::Change change = + TakeEvents::eraseNotes(notes, Ranges { Range(300, 600) }); + + QCOMPARE(int(change.removed.size()), 1); + QCOMPARE(change.removed[0].getFrame(), frame_t(400)); + QVERIFY(change.added.empty()); + } +}; + +#endif diff --git a/main/test/TestTakeTiming.h b/main/test/TestTakeTiming.h new file mode 100644 index 00000000..0c4fcdac --- /dev/null +++ b/main/test/TestTakeTiming.h @@ -0,0 +1,258 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TEST_TAKE_TIMING_H +#define TEST_TAKE_TIMING_H + +// Tier 2: the frame arithmetic of a take with a pre-roll and a +// punch-out. No window, no device and no files: what MainWindow does +// with the answers is Tier 5's business (TestRecordWorkflow). + +#include "../TakeTiming.h" + +#include +#include + +class TestTakeTiming : public QObject +{ + Q_OBJECT + + typedef sv::sv_frame_t frame_t; + + static constexpr double kRate = 44100.0; + + // A take at P with a pre-roll of R and a round trip of L, running + // until Stop + static TakeTiming take(frame_t position, frame_t preRoll = 0, + frame_t latency = 0) { + TakeTiming t; + t.rate = kRate; + t.position = position; + t.preRoll = preRoll; + t.latency = latency; + return t; + } + +private slots: + // The pre-roll is as long as it was asked for, except near the + // start of the song, where there is less room for it + void preroll_fits_before_the_position() { + const frame_t wanted = frame_t(3 * kRate); + QCOMPARE(TakeTiming::preRollBefore(frame_t(10 * kRate), wanted), wanted); + QCOMPARE(TakeTiming::preRollBefore(wanted, wanted), wanted); + + // Half a second in, the lead-in is half a second + QCOMPARE(TakeTiming::preRollBefore(frame_t(0.5 * kRate), wanted), + frame_t(0.5 * kRate)); + // and at the start of the song there is none + QCOMPARE(TakeTiming::preRollBefore(0, wanted), frame_t(0)); + // nor is there with the pre-roll switched off + QCOMPARE(TakeTiming::preRollBefore(frame_t(10 * kRate), 0), frame_t(0)); + } + + // Playback starts R frames before the take's position, so that the + // reference reaches the position as the recording starts to count + void playback_starts_before_the_position() { + QCOMPARE(take(1000).playbackStart(), frame_t(1000)); + QCOMPARE(take(5000, 3000).playbackStart(), frame_t(2000)); + QCOMPARE(take(3000, 3000).playbackStart(), frame_t(0)); + // Nothing before frame 0, whatever it is asked for + QCOMPARE(take(1000, 4000).playbackStart(), frame_t(0)); + } + + // The recording is used from the latency plus the lead-in on: both + // were recorded before the singer could hear the reference at P + void splice_offset_is_the_latency_and_the_lead_in() { + QCOMPARE(take(5000).spliceOffset(), frame_t(0)); + QCOMPARE(take(5000, 0, 1500).spliceOffset(), frame_t(1500)); + QCOMPARE(take(5000, 3000).spliceOffset(), frame_t(3000)); + QCOMPARE(take(5000, 3000, 1500).spliceOffset(), frame_t(4500)); + // A latency that was never measured is no latency + QCOMPARE(take(5000, 3000, -100).spliceOffset(), frame_t(2900)); + } + + // Without a punch-out the whole of the recording is used; with one, + // only as much of it as reaches the end of the selection + void splice_length_stops_at_the_end() { + QCOMPARE(take(5000).spliceLength(), frame_t(-1)); + + TakeTiming t = take(5000, 3000, 1500); + t.end = 9000; + QVERIFY(t.havePunchOut()); + QCOMPARE(t.spliceLength(), frame_t(4000)); + // ... which the lead-in and the latency do not change + QCOMPARE(t.spliceOffset(), frame_t(4500)); + + // A selection that ends where it starts, or before it, is no + // punch-out at all + t.end = 5000; + QVERIFY(!t.havePunchOut()); + QCOMPARE(t.spliceLength(), frame_t(-1)); + } + + // The take stops itself once the singing for the end of the + // selection must have arrived: the offset, the length, and a margin + void auto_stop_waits_for_the_end_and_a_margin() { + TakeTiming t = take(5000, 3000, 1500); + QVERIFY(!t.havePunchOut()); + QCOMPARE(t.autoStopFrames(), frame_t(0)); + QVERIFY(!t.shouldStopAt(frame_t(100 * kRate))); + + t.end = 5000 + frame_t(2 * kRate); + frame_t margin = frame_t(kRate * TakeTiming::autoStopMarginSeconds()); + frame_t want = 4500 + frame_t(2 * kRate) + margin; + QCOMPARE(t.autoStopFrames(), want); + QVERIFY(!t.shouldStopAt(want - 1)); + QVERIFY(t.shouldStopAt(want)); + QVERIFY(t.shouldStopAt(want + 10000)); + + // A longer lead-in is a later stop: the lead-in is not part of + // what the take keeps + TakeTiming longer = t; + longer.preRoll = 3000 + frame_t(kRate); + QCOMPARE(longer.autoStopFrames(), want + frame_t(kRate)); + } + + // The live dots go where the finished pitch track will put them, + // and sound from the lead-in gets no dot at all + void live_dots_start_at_the_position() { + TakeTiming t = take(5000, 3000, 1500); + QCOMPARE(t.liveFrameIntoTake(4500), frame_t(0)); + QCOMPARE(t.liveFrameIntoTake(9500), frame_t(5000)); + QVERIFY(t.liveFrameIntoTake(4499) < 0); + QVERIFY(t.liveFrameIntoTake(0) < 0); + + // With no lead-in and no latency a dot is where it was sung + QCOMPARE(take(5000).liveFrameIntoTake(1234), frame_t(1234)); + } + + // The status bar counts the lead-in down in whole seconds, and says + // nothing once the take's own material has begun + void countdown_runs_out_with_the_lead_in() { + TakeTiming t = take(frame_t(10 * kRate), frame_t(3 * kRate)); + QVERIFY(t.isInLeadIn(0)); + QCOMPARE(t.countdownSeconds(0), 3); + QCOMPARE(t.countdownText(0), QString("Recording in 3…")); + QCOMPARE(t.countdownSeconds(frame_t(1.5 * kRate)), 2); + QCOMPARE(t.countdownText(frame_t(1.5 * kRate)), + QString("Recording in 2…")); + QCOMPARE(t.countdownSeconds(frame_t(3 * kRate) - 1), 1); + + // Once the lead-in is over there is nothing to say + QVERIFY(!t.isInLeadIn(frame_t(3 * kRate))); + QCOMPARE(t.countdownSeconds(frame_t(3 * kRate)), 0); + QCOMPARE(t.countdownText(frame_t(3 * kRate)), QString()); + QCOMPARE(t.countdownText(frame_t(10 * kRate)), QString()); + + // The latency is part of the wait: the singer's answer to the + // reference at P arrives that much later + t.latency = frame_t(0.5 * kRate); + QCOMPARE(t.countdownSeconds(frame_t(3 * kRate)), 1); + + // With the pre-roll off there is no lead-in and no countdown, + // however long the round trip is + TakeTiming plain = take(frame_t(10 * kRate), 0, frame_t(0.5 * kRate)); + QVERIFY(!plain.isInLeadIn(0)); + QCOMPARE(plain.countdownText(0), QString()); + } + + // A device at 48 kHz, as phones are, against a reference at 44.1: + // P, E and R are the reference's frames, L and every count off the + // record target the recording's, and each answer is in the frames it + // is used in + void device_at_another_rate() { + const double deviceRate = 48000.0; + TakeTiming t = take(frame_t(10 * kRate), frame_t(0.5 * kRate), + frame_t(0.1 * deviceRate)); + t.recordRate = deviceRate; + + QCOMPARE(t.recordedToReference(frame_t(deviceRate)), frame_t(kRate)); + QCOMPARE(t.referenceToRecorded(frame_t(kRate)), frame_t(deviceRate)); + QCOMPARE(t.recordedToReference(12345), frame_t(11342)); + // The same as a ratio, for the cursor while the take records + QCOMPARE(t.referenceFramesPerRecordedFrame(), kRate / deviceRate); + + // The splice reads the recording once it is at the reference's + // rate: the latency is converted, the lead-in is not + QCOMPARE(t.spliceOffset(), frame_t(0.1 * kRate) + frame_t(0.5 * kRate)); + QCOMPARE(t.playbackStart(), frame_t(9.5 * kRate)); + + // The record target counts the device's frames: the take waits + // for the latency, the lead-in, two seconds of selection and the + // margin, all at the device's rate + t.end = t.position + frame_t(2 * kRate); + QCOMPARE(t.spliceLength(), frame_t(2 * kRate)); + frame_t want = frame_t(0.1 * deviceRate) + frame_t(0.5 * deviceRate) + + frame_t(2 * deviceRate) + + frame_t(TakeTiming::autoStopMarginSeconds() * deviceRate); + QCOMPARE(t.autoStopFrames(), want); + QVERIFY(!t.shouldStopAt(want - 1)); + QVERIFY(t.shouldStopAt(want)); + + // A dot for what the device recorded one second after the lead-in + // is one second into the take, at the reference's rate + frame_t lead = frame_t(0.1 * deviceRate) + frame_t(0.5 * deviceRate); + QCOMPARE(t.liveFrameIntoTake(lead), frame_t(0)); + QCOMPARE(t.liveFrameIntoTake(lead + frame_t(deviceRate)), frame_t(kRate)); + QVERIFY(t.liveFrameIntoTake(lead - 100) < 0); + + // The countdown goes by the device's frames too + TakeTiming c = take(frame_t(10 * kRate), frame_t(3 * kRate)); + c.recordRate = deviceRate; + QCOMPARE(c.countdownSeconds(0), 3); + QCOMPARE(c.countdownSeconds(frame_t(1.5 * deviceRate)), 2); + QCOMPARE(c.countdownSeconds(frame_t(3 * deviceRate) - 100), 1); + QVERIFY(c.isInLeadIn(frame_t(3 * deviceRate) - 100)); + QVERIFY(!c.isInLeadIn(frame_t(3 * deviceRate))); + QCOMPARE(c.countdownSeconds(frame_t(3 * deviceRate)), 0); + + // At the reference's rate, or with none given, nothing changes + TakeTiming same = take(5000, 3000, 1500); + same.recordRate = kRate; + QCOMPARE(same.recordedToReference(12345), frame_t(12345)); + QCOMPARE(same.spliceOffset(), frame_t(4500)); + QCOMPARE(take(5000, 3000, 1500).recordedToReference(12345), + frame_t(12345)); + QCOMPARE(same.referenceFramesPerRecordedFrame(), 1.0); + QCOMPARE(take(5000, 3000, 1500).referenceFramesPerRecordedFrame(), 1.0); + } + + // Recording into a selection: the one the playhead is in, else the + // first of them + void selection_at_the_playhead_is_the_one_recorded_into() { + Coverage::Ranges ranges; + Coverage::Range chosen; + + QVERIFY(!TakeTiming::chooseRange(ranges, 100, chosen)); + + ranges.push_back(Coverage::Range(1000, 2000)); + ranges.push_back(Coverage::Range(5000, 7000)); + + QVERIFY(TakeTiming::chooseRange(ranges, 6000, chosen)); + QCOMPARE(chosen, Coverage::Range(5000, 7000)); + QVERIFY(TakeTiming::chooseRange(ranges, 5000, chosen)); + QCOMPARE(chosen, Coverage::Range(5000, 7000)); + + // The end of a range is not in it + QVERIFY(TakeTiming::chooseRange(ranges, 2000, chosen)); + QCOMPARE(chosen, Coverage::Range(1000, 2000)); + + // The playhead outside all of them: the first + QVERIFY(TakeTiming::chooseRange(ranges, 0, chosen)); + QCOMPARE(chosen, Coverage::Range(1000, 2000)); + QVERIFY(TakeTiming::chooseRange(ranges, 100000, chosen)); + QCOMPARE(chosen, Coverage::Range(1000, 2000)); + } +}; + +#endif diff --git a/main/test/TestTakesFile.h b/main/test/TestTakesFile.h new file mode 100644 index 00000000..da2ff465 --- /dev/null +++ b/main/test/TestTakesFile.h @@ -0,0 +1,493 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TEST_TAKES_FILE_H +#define TEST_TAKES_FILE_H + +// Tier 2: the element of a session file, written from the takes of +// a session and read back. No window: the element is written to a string +// and to a file here, as a session's own reader would find it. + +#include "../TakesFile.h" +#include "../SingingTakes.h" + +#include "data/fileio/BZipFileDevice.h" + +#include +#include +#include +#include +#include +#include +#include + +class TestTakesFile : public QObject +{ + Q_OBJECT + + QTemporaryDir m_dir; + int m_fileCounter = 0; + + // The element as it appears in a session file: inside an + // document, which is what the reading pass has to pick it out of + static QString inDocument(QString element) { + return "\n" + "\n\n\n" + "\n" + "\n\n\n" + "\n" + element + "\n"; + } + + static TakesFile::Takes readString(QString xml) { + QXmlStreamReader reader(xml); + return TakesFile::readFrom(reader); + } + + // A file for the copying to work on; nothing here reads what is in it + bool writeAudio(QString path) { + QFile file(path); + if (!file.open(QIODevice::WriteOnly)) return false; + bool ok = (file.write(QByteArray(64, 'x')) == 64); + file.close(); + return ok; + } + + QString writeFile(QString content, bool compressed) { + QString path = m_dir.filePath + (QString("session-%1.ton").arg(++m_fileCounter)); + if (compressed) { + sv::BZipFileDevice file(path); + if (!file.open(QIODevice::WriteOnly)) return ""; + file.write(content.toUtf8()); + file.close(); + } else { + QFile file(path); + if (!file.open(QIODevice::WriteOnly)) return ""; + file.write(content.toUtf8()); + file.close(); + } + return path; + } + +private slots: + void initTestCase() { + QVERIFY(m_dir.isValid()); + } + + // Two takes and the active one, through a string and back + void round_trip() { + SingingTakes takes; + QCOMPARE(takes.addTake(), QString("Take 1")); + takes.restoreTake("C:/songs/one.wav", Coverage()); + QCOMPARE(takes.addTake(), QString("Take 2")); + takes.restoreTake("C:/songs/two.wav", Coverage()); + QVERIFY(takes.setActiveIndex(0)); + + QString element = TakesFile::toXml(takes); + TakesFile::Takes read = readString(inDocument(element)); + + QVERIFY(read.found); + QCOMPARE(read.active, QString("Take 1")); + QCOMPARE(int(read.takes.size()), 2); + QCOMPARE(read.takes[0].name, QString("Take 1")); + QCOMPARE(read.takes[0].audioPath, QString("C:/songs/one.wav")); + QCOMPARE(read.takes[1].name, QString("Take 2")); + QCOMPARE(read.takes[1].audioPath, QString("C:/songs/two.wav")); + } + + // A take with no recording in it yet has no audio file + void empty_take() { + SingingTakes takes; + takes.addTake(); + + TakesFile::Takes read = readString(inDocument(TakesFile::toXml(takes))); + + QVERIFY(read.found); + QCOMPARE(int(read.takes.size()), 1); + QCOMPARE(read.takes[0].audioPath, QString()); + QCOMPARE(read.active, QString("Take 1")); + } + + // A session with no take at all still says so, which is what tells it + // from a session saved before the takes were stored + void no_takes() { + SingingTakes takes; + + TakesFile::Takes read = readString(inDocument(TakesFile::toXml(takes))); + QVERIFY(read.found); + QVERIFY(read.takes.empty()); + QCOMPARE(read.active, QString()); + + TakesFile::Takes none = readString(inDocument("")); + QVERIFY2(!none.found, "a document with no takes element was read as " + "having one"); + } + + // A name the user gave with characters that XML cares about + void escaped_name() { + SingingTakes takes; + takes.addTake("Rock & \"Roll\" <2>"); + takes.restoreTake("C:/songs/a & b/take's.wav", Coverage()); + + QString element = TakesFile::toXml(takes); + QVERIFY2(!element.contains("& \""), qPrintable(element)); + + TakesFile::Takes read = readString(inDocument(element)); + QCOMPARE(int(read.takes.size()), 1); + QCOMPARE(read.takes[0].name, QString("Rock & \"Roll\" <2>")); + QCOMPARE(read.takes[0].audioPath, + QString("C:/songs/a & b/take's.wav")); + QCOMPARE(read.active, QString("Rock & \"Roll\" <2>")); + } + + // A name the user gave, and a file name, holding what QString::arg() + // takes for its placeholders: written as they are, and neither taken + // for the other + void names_like_placeholders() { + SingingTakes takes; + takes.addTake("Verse %1 %3"); + takes.restoreTake("C:/songs/100%2 take.wav", Coverage()); + + TakesFile::Takes read = readString(inDocument(TakesFile::toXml(takes))); + QCOMPARE(int(read.takes.size()), 1); + QCOMPARE(read.takes[0].name, QString("Verse %1 %3")); + QCOMPARE(read.takes[0].audioPath, QString("C:/songs/100%2 take.wav")); + QCOMPARE(read.active, QString("Verse %1 %3")); + } + + // From a file: bzip2, as a .ton is, and plain XML, which the session + // reader also accepts + void from_a_file() { + SingingTakes takes; + takes.addTake("Chorus"); + takes.restoreTake("C:/songs/chorus.wav", Coverage()); + + QString document = inDocument(TakesFile::toXml(takes)); + + for (bool compressed : { true, false }) { + QString path = writeFile(document, compressed); + QVERIFY(!path.isEmpty()); + TakesFile::Takes read = TakesFile::read(path); + QVERIFY2(read.found, compressed ? "bzip2" : "plain"); + QCOMPARE(int(read.takes.size()), 1); + QCOMPARE(read.takes[0].name, QString("Chorus")); + QCOMPARE(read.takes[0].audioPath, QString("C:/songs/chorus.wav")); + } + + // Nothing there to read + QVERIFY(!TakesFile::read(m_dir.filePath("nothing.ton")).found); + QVERIFY(!TakesFile::read("").found); + } + + // The element names a take's audio relative to the session file when + // it is in the session's own folder (spec 6.4), and where it is + // otherwise -- before the first save, the take is in the record + // directory + void paths_relative_to_the_session() { + SingingTakes takes; + takes.addTake("Take 1"); + takes.restoreTake("C:/songs/My Song.takes/take-1.wav", Coverage()); + takes.addTake("Take 2"); + takes.restoreTake("C:/recorded/take-2.wav", Coverage()); + + QString element = TakesFile::toXml(takes, "C:/songs/My Song.ton"); + TakesFile::Takes read = readString(inDocument(element)); + + QCOMPARE(read.takes[0].audioPath, + QString("My Song.takes/take-1.wav")); + QCOMPARE(read.takes[1].audioPath, QString("C:/recorded/take-2.wav")); + + // What a session load makes of them + QCOMPARE(TakesFile::resolveAudioPath("C:/songs/My Song.ton", + read.takes[0].audioPath), + QString("C:/songs/My Song.takes/take-1.wav")); + + // With no session path the paths are written as they are, which is + // what a session saved before the folder holds + QString absolute = TakesFile::toXml(takes); + QCOMPARE(readString(inDocument(absolute)).takes[0].audioPath, + QString("C:/songs/My Song.takes/take-1.wav")); + } + + // --- The audio folder of a session (spec 6.4) --- + + // ".takes" beside the session file, whatever the session is + // called and whichever slashes its path is written with + void takes_folder() { + QCOMPARE(TakesFile::takesFolder("C:/songs/My Song.ton"), + QString("C:/songs/My Song.takes")); +#ifdef Q_OS_WIN + // Back slashes separate the parts of a path on Windows only; + // elsewhere they are part of a name + QCOMPARE(TakesFile::takesFolder("C:\\songs\\My Song.ton"), + QString("C:/songs/My Song.takes")); +#endif + QCOMPARE(TakesFile::takesFolder("C:/Käännös/Säkeistö 2.ton"), + QString("C:/Käännös/Säkeistö 2.takes")); + // Only the extension goes, so a name with dots of its own keeps them + QCOMPARE(TakesFile::takesFolder("C:/songs/take.2.ton"), + QString("C:/songs/take.2.takes")); + // A session file at the root of a drive, and one with no directory + QCOMPARE(TakesFile::takesFolder("C:/song.ton"), + QString("C:/song.takes")); + QCOMPARE(TakesFile::takesFolder("song.ton"), QString("song.takes")); + QCOMPARE(TakesFile::takesFolder(""), QString()); + } + + // What goes in the file: relative to the session file when the audio is + // inside its directory, and the path as it is when it is not + void relative_audio_path() { + QCOMPARE(TakesFile::relativeAudioPath + ("C:/songs/My Song.ton", + "C:/songs/My Song.takes/take-1.wav"), + QString("My Song.takes/take-1.wav")); +#ifdef Q_OS_WIN + QCOMPARE(TakesFile::relativeAudioPath + ("C:/songs/My Song.ton", + "C:\\songs\\My Song.takes\\take-1.wav"), + QString("My Song.takes/take-1.wav")); +#endif + QCOMPARE(TakesFile::relativeAudioPath + ("C:/Käännös/Säkeistö.ton", + "C:/Käännös/Säkeistö.takes/take-1.wav"), + QString("Säkeistö.takes/take-1.wav")); + + // Not below the session: the record directory before the first + // save, or another drive altogether + QCOMPARE(TakesFile::relativeAudioPath + ("C:/songs/My Song.ton", "C:/recorded/take-1.wav"), + QString("C:/recorded/take-1.wav")); + QCOMPARE(TakesFile::relativeAudioPath + ("C:/songs/My Song.ton", "D:/songs/My Song.takes/take-1.wav"), + QString("D:/songs/My Song.takes/take-1.wav")); + + // A take with no audio in it yet, and no session to be relative to + QCOMPARE(TakesFile::relativeAudioPath("C:/songs/My Song.ton", ""), + QString()); + QCOMPARE(TakesFile::relativeAudioPath("", "C:/recorded/take-1.wav"), + QString("C:/recorded/take-1.wav")); + + // A session file at the root of a drive + QCOMPARE(TakesFile::relativeAudioPath + ("C:/song.ton", "C:/song.takes/take-1.wav"), + QString("song.takes/take-1.wav")); + } + + // And back: a relative path against the session's directory, an + // absolute one -- as a session saved before the folder has -- as it is + void resolve_audio_path() { + QCOMPARE(TakesFile::resolveAudioPath + ("C:/songs/My Song.ton", "My Song.takes/take-1.wav"), + QString("C:/songs/My Song.takes/take-1.wav")); +#ifdef Q_OS_WIN + QCOMPARE(TakesFile::resolveAudioPath + ("C:\\songs\\My Song.ton", "My Song.takes\\take-1.wav"), + QString("C:/songs/My Song.takes/take-1.wav")); +#endif + QCOMPARE(TakesFile::resolveAudioPath + ("C:/Käännös/Säkeistö.ton", "Säkeistö.takes/take-1.wav"), + QString("C:/Käännös/Säkeistö.takes/take-1.wav")); + // An absolute path is one in the form of the system the session + // is read on: a drive letter makes one only on Windows +#ifdef Q_OS_WIN + QString elsewhere = "C:/recorded/take-1.wav"; +#else + QString elsewhere = "/recorded/take-1.wav"; +#endif + QCOMPARE(TakesFile::resolveAudioPath("C:/songs/My Song.ton", elsewhere), + elsewhere); + QCOMPARE(TakesFile::resolveAudioPath("C:/songs/My Song.ton", ""), + QString()); + + // The session moved, with its folder: the same relative path finds + // the audio in the new place + QCOMPARE(TakesFile::resolveAudioPath + ("D:/backup/songs/My Song.ton", "My Song.takes/take-1.wav"), + QString("D:/backup/songs/My Song.takes/take-1.wav")); + + // Written and read back, for every shape of path + for (QString session : { "C:/songs/My Song.ton", "C:/song.ton", + "C:/Käännös/Säkeistö 2.ton" }) { + for (QString audio : { "take-1.wav", "deeper/take-2.wav" }) { + QString path = QDir::cleanPath + (TakesFile::takesFolder(session) + "/" + audio); + QString stored = TakesFile::relativeAudioPath(session, path); + QVERIFY2(!QFileInfo(stored).isAbsolute(), qPrintable(stored)); + QCOMPARE(TakesFile::resolveAudioPath(session, stored), path); + } + } + } + + void in_folder() { + QVERIFY(TakesFile::isInFolder("C:/songs/My Song.takes", + "C:/songs/My Song.takes/take-1.wav")); +#ifdef Q_OS_WIN + // Windows tells no two names apart by their case + QVERIFY(TakesFile::isInFolder("C:/songs/my song.takes", + "C:\\Songs\\My Song.takes\\take-1.wav")); +#else + // Other systems do + QVERIFY(!TakesFile::isInFolder("/songs/my song.takes", + "/Songs/My Song.takes/take-1.wav")); +#endif + QVERIFY(TakesFile::isInFolder("C:/songs/My Song.takes/", + "C:/songs/My Song.takes/in/take-1.wav")); + QVERIFY(!TakesFile::isInFolder("C:/songs/My Song.takes", + "C:/songs/My Song.takes")); + QVERIFY(!TakesFile::isInFolder("C:/songs/My Song.takes", + "C:/songs/My Song.takes-2/take-1.wav")); + QVERIFY(!TakesFile::isInFolder("C:/songs/My Song.takes", + "C:/recorded/take-1.wav")); + QVERIFY(!TakesFile::isInFolder("", "C:/recorded/take-1.wav")); + QVERIFY(!TakesFile::isInFolder("C:/songs/My Song.takes", "")); + } + + // A copy is never written over a file that is there already + void free_copy_path() { + QString folder = m_dir.filePath("free"); + QVERIFY(QDir().mkpath(folder)); + + QString first = TakesFile::freeCopyPath(folder, "take-1.wav"); + QCOMPARE(first, QDir::cleanPath(folder + "/take-1.wav")); + + QVERIFY(writeAudio(first)); + QString second = TakesFile::freeCopyPath(folder, "take-1.wav"); + QCOMPARE(second, QDir::cleanPath(folder + "/take-1-2.wav")); + + QVERIFY(writeAudio(second)); + QCOMPARE(TakesFile::freeCopyPath(folder, "take-1.wav"), + QDir::cleanPath(folder + "/take-1-3.wav")); + + QCOMPARE(TakesFile::freeCopyPath("", "take-1.wav"), QString()); + QCOMPARE(TakesFile::freeCopyPath(folder, ""), QString()); + + // A loaded file's own name, which may hold a '%' + QString percent = TakesFile::freeCopyPath(folder, "100%2.wav"); + QCOMPARE(percent, QDir::cleanPath(folder + "/100%2.wav")); + QVERIFY(writeAudio(percent)); + QCOMPARE(TakesFile::freeCopyPath(folder, "100%2.wav"), + QDir::cleanPath(folder + "/100%2-2.wav")); + } + + // The copy a save makes: one per file, the takes pointing at the + // copies, and the copies counted as files this run wrote + void copy_take_audio_into_the_folder() { + QString record = m_dir.filePath("rec"); + QString folder = m_dir.filePath("copied.takes"); + QVERIFY(QDir().mkpath(record)); + QVERIFY(QDir().mkpath(folder)); + + QString one = QDir::cleanPath(record + "/take-1.wav"); + QString two = QDir::cleanPath(record + "/take-2.wav"); + QString inside = QDir::cleanPath(folder + "/take-3.wav"); + QVERIFY(writeAudio(one)); + QVERIFY(writeAudio(two)); + QVERIFY(writeAudio(inside)); + + SingingTakes takes; + takes.addTake("A"); + takes.restoreTake(one, Coverage()); + // Two takes sharing one file, as a duplicated take does + takes.addTake("B"); + takes.restoreTake(one, Coverage()); + takes.addTake("C"); + takes.restoreTake(two, Coverage()); + // And one whose audio is in the folder already + takes.addTake("D"); + takes.restoreTake(inside, Coverage()); + + QCOMPARE(TakesFile::copyTakeAudioInto(takes, folder), QString()); + + QString copyOfOne = QDir::cleanPath(folder + "/take-1.wav"); + QString copyOfTwo = QDir::cleanPath(folder + "/take-2.wav"); + QCOMPARE(takes.getTake(0)->audioPath, copyOfOne); + QCOMPARE(takes.getTake(1)->audioPath, copyOfOne); + QCOMPARE(takes.getTake(2)->audioPath, copyOfTwo); + QCOMPARE(takes.getTake(3)->audioPath, inside); + QVERIFY(QFileInfo::exists(copyOfOne)); + QVERIFY(QFileInfo::exists(copyOfTwo)); + + // The shared file was copied once: a second copy would have been + // "take-1-2.wav" + QVERIFY2(!QFileInfo::exists(QDir::cleanPath(folder + "/take-1-2.wav")), + "a file two takes share was copied twice"); + + // The originals are still there, for the models that have them open + QVERIFY(QFileInfo::exists(one)); + QVERIFY(QFileInfo::exists(two)); + + // The copies are ours, so the cleanup on close may take them away + // again if no saved session names them + QCOMPARE(takes.getWrittenPaths(), QStringList({ copyOfOne, copyOfTwo })); + + // Saving again copies nothing: everything is in the folder + QCOMPARE(TakesFile::copyTakeAudioInto(takes, folder), QString()); + QCOMPARE(takes.getTake(0)->audioPath, copyOfOne); + QCOMPARE(takes.getWrittenPaths(), QStringList({ copyOfOne, copyOfTwo })); + } + + // A copy that fails leaves the takes exactly as they were, and takes + // the copies that were made with it: better no saved session than one + // that names files which are not there + void a_failed_copy_changes_nothing() { + QString record = m_dir.filePath("rec-failing"); + QVERIFY(QDir().mkpath(record)); + + QString one = QDir::cleanPath(record + "/take-1.wav"); + QVERIFY(writeAudio(one)); + QString missing = QDir::cleanPath(record + "/not-there.wav"); + + SingingTakes takes; + takes.addTake("A"); + takes.restoreTake(one, Coverage()); + takes.addTake("B"); + takes.restoreTake(missing, Coverage()); + + // No such folder to copy into + QString folder = m_dir.filePath("nowhere.takes"); + QString error = TakesFile::copyTakeAudioInto(takes, folder); + QVERIFY2(error != "", "copying into a folder that is not there " + "reported no error"); + QCOMPARE(takes.getTake(0)->audioPath, one); + QCOMPARE(takes.getTake(1)->audioPath, missing); + QVERIFY(takes.getWrittenPaths().isEmpty()); + + // The folder is there, but the second take's audio is not: the + // first take's copy is removed again + QVERIFY(QDir().mkpath(folder)); + error = TakesFile::copyTakeAudioInto(takes, folder); + QVERIFY2(error != "", "copying a file that is not there reported no " + "error"); + QCOMPARE(takes.getTake(0)->audioPath, one); + QCOMPARE(takes.getTake(1)->audioPath, missing); + QVERIFY(takes.getWrittenPaths().isEmpty()); + QVERIFY2(!QFileInfo::exists(QDir::cleanPath(folder + "/take-1.wav")), + "a copy made before the failure was left behind"); + + QVERIFY(TakesFile::copyTakeAudioInto(takes, "") != ""); + } + + // The numbering carries on from the takes a session had, whatever has + // happened to them since + void reserved_names_carry_the_numbering_on() { + SingingTakes takes; + takes.reserveTakeName("Take 1"); + takes.reserveTakeName("Take 7"); + takes.reserveTakeName("Chorus"); + + QCOMPARE(takes.addTake("Take 7"), QString("Take 7")); + QCOMPARE(takes.addTake(), QString("Take 8")); + } +}; + +#endif diff --git a/main/test/TestTouchGestures.h b/main/test/TestTouchGestures.h new file mode 100644 index 00000000..5dbae94d --- /dev/null +++ b/main/test/TestTouchGestures.h @@ -0,0 +1,1147 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TEST_TOUCH_GESTURES_H +#define TEST_TOUCH_GESTURES_H + +// Tier 5: touch on the panes of the real MainWindow (TouchGestures). +// Pinch, two fingers dragged and a long press; one finger and the +// mouse as they were. Up and down, two fingers zoom and scroll the +// analyser's frequency range, which is checked through svgui's own +// mapping of the pane. +// +// The touch goes in where a platform's does (QTest::touchEvent goes +// through QWindowSystemInterface), so Qt makes mouse events of it as it +// does on a phone, and a mouse button left pressed shows in +// QGuiApplication::mouseButtons(). Qt finds the widget under a touch +// only among widgets on show, so the window is shown here. + +#include "TestSignals.h" + +#include "../MainWindow.h" +#include "../Analyser.h" +#include "../AlternatePitchTrack.h" +#include "../PinchZoom.h" +#include "../TouchGestures.h" +#include "../VerticalZoom.h" + +#include "version.h" + +#include "view/Pane.h" +#include "view/PaneStack.h" +#include "view/ViewManager.h" +#include "layer/CoordinateScale.h" +#include "layer/Layer.h" +#include "layer/TimeValueLayer.h" +#include "widgets/CommandHistory.h" +#include "widgets/InteractiveFileFinder.h" +#include "data/fileio/WavFileWriter.h" +#include "data/model/RelativelyFineZoomConstraint.h" +#include "transform/ModelTransformerFactory.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +/** + * MainWindow without an audio device, and with the pane's right-button + * menu counted instead of shown: a menu on show would take the touch + * events that come after it. A test that wants a menu there gives one. + */ +class TouchTestWindow : public MainWindow +{ +public: + TouchTestWindow() : MainWindow(AUDIO_PLAYBACK_AND_RECORD, true, false) { } + + sv::PaneStack *paneStack() { return m_paneStack; } + sv::ViewManager *viewManager() { return m_viewManager; } + Analyser *analyser() { return m_analyser; } + Analyser *analyser2() { return m_analyser2; } + AlternatePitchTrack *alternatePitch() { return m_alternatePitch; } + void toggleAlternatePitch() { alternatePitchToggled(); } + + void discardModifications() { m_documentModified = false; } + void doCloseSession() { discardModifications(); closeSession(); } + + int menuRequests = 0; + QPoint menuPosition; + QMenu *menu = nullptr; + +protected: + void createAudioIO() override { } + + // As MainWindow's own does, with the menu given + void paneRightButtonMenuRequested(sv::Pane *, QPoint position) override { + ++menuRequests; + menuPosition = position; + if (menu) menu->popup(position); + } +}; + +class TestTouchGestures : public QObject +{ + Q_OBJECT + + static constexpr double rate = 44100.0; + + // The reference's pitch, and a low voice's (D2) + static constexpr double referenceHz = 220.5; + static constexpr double lowHz = 73.5; + + // Where each test starts: frames per pixel, and the centre frame + static constexpr int startLevel = 64; + static constexpr sv::sv_frame_t startCentre = 3 * 44100; + + QTemporaryDir m_dir; + TouchTestWindow *m_window = nullptr; + QPointingDevice *m_touch = nullptr; + QTimer m_watchdog; + QStringList m_dialogs; + + typedef QTest::QTouchEventWidgetSequence Touch; + + // Points are given in the coordinates of the pane they are on + Touch touch() { return QTest::touchEvent(m_window, m_touch, false); } + + sv::Pane *pane() { return m_window->paneStack()->getPane(0); } + sv::Pane *strip() { return m_window->paneStack()->getPane(1); } + + double framesPerPixel() { + return PinchZoom::framesPerPixel(pane()->getZoomLevel()); + } + + sv::MultiSelection::SelectionList selections() { + return m_window->viewManager()->getSelections(); + } + + // The analyser's frequency range, which the pane draws pitch on + VerticalZoom::Range frequencyRange() { + VerticalZoom::Range r; + r.log = true; + m_window->analyser()->getDisplayFrequencyExtents(r.min, r.max); + return r; + } + + static double octaves(const VerticalZoom::Range &r) { + return std::log2(r.max / r.min); + } + + static QString text(const VerticalZoom::Range &r) { + return QString("%1 to %2 Hz").arg(r.min).arg(r.max); + } + + // Where the pane draws a frequency, and what it draws at y: svgui's + // mapping, not the one under test + double yForFrequency(double f) { + return pane()->getEffectiveVerticalExtents("Hz") + .getCoordForValue(pane(), f); + } + + double frequencyAtY(double y) { + return pane()->getEffectiveVerticalExtents("Hz") + .getValueForCoord(pane(), y); + } + + // How far the fingers go up or down before that counts + static int deadZone() { + return 2 * QGuiApplication::styleHints()->startDragDistance(); + } + + // Two fingers down together, moved in ten even steps, and lifted + // together + void twoFingers(sv::Pane *p, QPoint a0, QPoint b0, QPoint a1, QPoint b1) { + Touch t = touch(); + t.press(0, a0, p).press(1, b0, p).commit(); + for (int i = 1; i <= 10; ++i) { + t.move(0, a0 + (a1 - a0) * i / 10, p) + .move(1, b0 + (b1 - b0) * i / 10, p).commit(); + } + t.release(0, a1, p).release(1, b1, p).commit(); + } + + bool analysed(Analyser *a) { + return a && a->getLayer(Analyser::PitchTrack) && + a->getLayer(Analyser::Notes) && + a->getInitialAnalysisCompletion() >= 100 && + !a->isAnalysingRange() && + !sv::ModelTransformerFactory::getInstance() + ->haveRunningTransformers(); + } + + bool analysed() { return analysed(m_window->analyser()); } + + // A wav file in the test's directory, written the first time it is + // asked for; "" if it could not be + QString wavFile(QString name, const std::vector &data) { + QString path = m_dir.filePath(name); + if (QFile::exists(path)) return path; + sv::WavFileWriter writer(path, rate, 1, + sv::WavFileWriter::WriteToTarget); + const float *ptr = data.data(); + if (!writer.isOK() || + !writer.writeSamples(&ptr, sv::sv_frame_t(data.size())) || + !writer.close()) { + return ""; + } + return path; + } + + // Six seconds of a sine + QString sineFile(QString name, double hz) { + return wavFile(name, TestSignals::sine(hz, rate, int(6 * rate), 0.5)); + } + + // The view at startLevel about startCentre + void resetView() { + pane()->setZoomLevel + (sv::ZoomLevel(sv::ZoomLevel::FramesPerPixel, startLevel)); + pane()->setCentreFrame(startCentre); + } + + // The window on show with six seconds of reference, a sine at + // referenceHz unless another file is given, analysed, and the view at + // startLevel about startCentre + void openWindow(QString path = "") { + m_window = new TouchTestWindow; + m_window->resize(1000, 700); + m_window->show(); + QVERIFY(QTest::qWaitForWindowExposed(m_window)); + +#if QT_VERSION < QT_VERSION_CHECK(6, 5, 0) + // Qt 6.4 takes a finger's press for the second click of a double + // click whenever the press before it, on any device, was with the + // same button and nothing has moved since: it compares the press's + // time with its own. A test that ended on a tap made the next + // one's first touch a double click, and the pane opened an item's + // edit dialog. A mouse move far from where the mouse was pressed + // clears it; one of two moves far apart always is + QWindow *handle = m_window->windowHandle(); + QTest::mouseMove(handle, QPoint(1, 1)); + QTest::mouseMove(handle, QPoint(handle->width() - 2, + handle->height() - 2)); +#endif + + if (path == "") path = sineFile("reference.wav", referenceHz); + QVERIFY(path != ""); + + m_window->discardModifications(); + QCOMPARE(m_window->openPath(path, MainWindow::ReplaceSession), + MainWindow::FileOpenSucceeded); + QTRY_VERIFY_WITH_TIMEOUT(analysed(), 30000); + + QVERIFY(pane()); + QVERIFY(strip()); + resetView(); + QCOMPARE(framesPerPixel(), double(startLevel)); + } + + // An analyser's pitch and notes hidden or shown again, straight to + // the layers as the app does for a while: Analyser::setVisible() + // writes a setting that both analysers share + void showPitch(Analyser *a, bool shown) { + a->getLayer(Analyser::PitchTrack)->showLayer(pane(), shown); + a->getLayer(Analyser::Notes)->showLayer(pane(), shown); + } + + // Where a zoom by factor about a value at y puts it: towards the + // middle of the pane, its distance from there divided by the factor + double pulledTowardsMiddle(double y, double factor) { + double middle = pane()->height() / 2.0; + return middle + (y - middle) / factor; + } + + // How far, in pixels, a frequency on r may be from where the fingers + // put it: the range is whole Hz (the spectrogram's), so each end may + // be half a hertz from theirs, which low down is a few pixels + double slack(const VerticalZoom::Range &r) { + return 1.0 + pane()->height() * + (std::log2((r.min + 0.5) / r.min) + + std::log2((r.max + 0.5) / r.max)) / octaves(r); + } + + void dismissDialog() { + QWidget *modal = QApplication::activeModalWidget(); + if (!modal) return; + QString description = modal->windowTitle(); + if (auto box = qobject_cast(modal)) { + description += ": " + box->text(); + m_dialogs.push_back(description); + QList buttons = box->buttons(); + if (!buttons.isEmpty()) { + buttons.last()->click(); + return; + } + } else { + m_dialogs.push_back(description); + } + if (auto dialog = qobject_cast(modal)) { + dialog->reject(); + } else { + modal->close(); + } + } + +private slots: + void initTestCase() { + QVERIFY(m_dir.isValid()); + + // Otherwise the MainWindow constructor asks, in a dialog + QSettings settings; + settings.beginGroup("Preferences"); + settings.setValue(QString("network-permission-%1").arg(TONY_VERSION), + false); + settings.endGroup(); + + // As main() does; without it a .ton file is not a session + sv::InteractiveFileFinder::getInstance() + ->setApplicationSessionExtension("ton"); + + connect(&m_watchdog, &QTimer::timeout, + this, [this]() { dismissDialog(); }); + m_watchdog.start(50); + } + + void init() { + m_dialogs.clear(); + + // A device of its own for each test: fingers that a failed test + // left down stay on the last one + m_touch = QTest::createTouchDevice(); + } + + void cleanup() { + if (m_window) { + // A test that failed with Qt's button pressed must not leave + // it so for the next, which may make no mouse events to clear it + if (QGuiApplication::mouseButtons() != Qt::NoButton) { + QTest::mouseRelease(m_window->windowHandle(), Qt::LeftButton); + } + QTRY_VERIFY_WITH_TIMEOUT + (!sv::ModelTransformerFactory::getInstance() + ->haveRunningTransformers(), 30000); + m_window->doCloseSession(); + delete m_window; + m_window = nullptr; + } + QVERIFY2(m_dialogs.isEmpty(), + qPrintable("unexpected dialog: " + m_dialogs.join(" | "))); + } + + void cleanupTestCase() { + m_watchdog.stop(); + } + + // Fingers twice as far apart: twice as far in, with the frame that was + // between them still there. The second finger comes down after the + // first, and the first is lifted first + void pinch_out_zooms_in_about_the_fingers() { + openWindow(); + if (QTest::currentTestFailed()) return; + + sv::Pane *p = pane(); + int x = p->width() / 2 - 100; // off centre: not just the centre kept + int y = p->height() / 2; + sv::sv_frame_t before = p->getFrameForX(x); + + Touch t = touch(); + t.press(0, QPoint(x - 50, y), p).commit(); + t.stationary(0).press(1, QPoint(x + 50, y), p).commit(); + for (int i = 1; i <= 10; ++i) { + t.move(0, QPoint(x - 50 - 5 * i, y), p) + .move(1, QPoint(x + 50 + 5 * i, y), p).commit(); + } + t.release(0, QPoint(x - 100, y), p).stationary(1).commit(); + t.release(1, QPoint(x + 100, y), p).commit(); + + QCOMPARE(framesPerPixel(), startLevel / 2.0); + sv::sv_frame_t after = p->getFrameForX(x); + QVERIFY2(std::abs(after - before) <= startLevel / 2, + qPrintable(QString("frame %1 under the fingers, then %2") + .arg(before).arg(after))); + + QCOMPARE(m_window->menuRequests, 0); + QCOMPARE(QGuiApplication::mouseButtons(), Qt::NoButton); + } + + // Fingers half as far apart: twice as far out. Both come down, and + // go up, together + void pinch_in_zooms_out_about_the_fingers() { + openWindow(); + if (QTest::currentTestFailed()) return; + + sv::Pane *p = pane(); + int x = p->width() / 2 + 150; + int y = p->height() / 2; + sv::sv_frame_t before = p->getFrameForX(x); + + Touch t = touch(); + t.press(0, QPoint(x - 100, y), p).press(1, QPoint(x + 100, y), p) + .commit(); + for (int i = 1; i <= 10; ++i) { + t.move(0, QPoint(x - 100 + 5 * i, y), p) + .move(1, QPoint(x + 100 - 5 * i, y), p).commit(); + } + t.release(0, QPoint(x - 50, y), p).release(1, QPoint(x + 50, y), p) + .commit(); + + QCOMPARE(framesPerPixel(), startLevel * 2.0); + sv::sv_frame_t after = p->getFrameForX(x); + QVERIFY2(std::abs(after - before) <= startLevel * 2, + qPrintable(QString("frame %1 under the fingers, then %2") + .arg(before).arg(after))); + + QCOMPARE(m_window->menuRequests, 0); + QCOMPARE(QGuiApplication::mouseButtons(), Qt::NoButton); + } + + // Two fingers dragged left by 100 pixels bring 100 pixels' worth of + // what follows into view, and do not zoom. They come down together: + // Qt then makes no mouse events at all, so what moves the view can + // only be the two of them (one finger dragged the same way would + // move it as far) + void two_finger_drag_scrolls() { + openWindow(); + if (QTest::currentTestFailed()) return; + + sv::Pane *p = pane(); + int x = p->width() / 2; + int y = p->height() / 2; + sv::sv_frame_t before = p->getCentreFrame(); + + Touch t = touch(); + t.press(0, QPoint(x - 50, y), p).press(1, QPoint(x + 50, y), p) + .commit(); + for (int i = 1; i <= 10; ++i) { + t.move(0, QPoint(x - 50 - 10 * i, y), p) + .move(1, QPoint(x + 50 - 10 * i, y), p).commit(); + } + t.release(0, QPoint(x - 150, y), p).release(1, QPoint(x - 50, y), p) + .commit(); + + QCOMPARE(framesPerPixel(), double(startLevel)); + sv::sv_frame_t expected = before + 100 * startLevel; + QVERIFY2(std::abs(p->getCentreFrame() - expected) <= startLevel, + qPrintable(QString("centre %1, expected %2") + .arg(p->getCentreFrame()).arg(expected))); + + QCOMPARE(m_window->menuRequests, 0); + QCOMPARE(QGuiApplication::mouseButtons(), Qt::NoButton); + } + + // One finger held still asks for the menu where it is, as a right + // click there does. It does not drag afterwards, and the pane never + // had the press: no move of the playhead is scheduled + void long_press_asks_for_the_menu() { + openWindow(); + if (QTest::currentTestFailed()) return; + + sv::Pane *p = pane(); + QPoint at(p->width() / 2 + 120, p->height() / 2); + sv::sv_frame_t centre = p->getCentreFrame(); + sv::sv_frame_t playhead = m_window->viewManager()->getPlaybackFrame(); + QVERIFY(std::abs(p->getFrameForX(at.x()) - playhead) > 10000); + + Touch t = touch(); + t.press(0, at, p).commit(); + QTest::qWait(TouchGestures::longPressMs / 2); + QCOMPARE(m_window->menuRequests, 0); + QTest::qWait(TouchGestures::longPressMs / 2 + 200); + QCOMPARE(m_window->menuRequests, 1); + QCOMPARE(m_window->menuPosition, p->mapToGlobal(at)); + + for (int i = 1; i <= 5; ++i) { + t.move(0, at - QPoint(20 * i, 0), p).commit(); + } + t.release(0, at - QPoint(100, 0), p).commit(); + QTest::qWait(QApplication::doubleClickInterval() + 100); + + QCOMPARE(p->getCentreFrame(), centre); + QCOMPARE(m_window->viewManager()->getPlaybackFrame(), playhead); + QCOMPARE(m_window->menuRequests, 1); + QCOMPARE(QGuiApplication::mouseButtons(), Qt::NoButton); + } + + // In the selection strip, where a press starts a selection + void long_press_leaves_no_selection() { + openWindow(); + if (QTest::currentTestFailed()) return; + + sv::Pane *s = strip(); + QPoint at(s->width() / 2, s->height() / 2); + QVERIFY(selections().empty()); + + Touch t = touch(); + t.press(0, at, s).commit(); + QTest::qWait(TouchGestures::longPressMs + 200); + QCOMPARE(m_window->menuRequests, 1); + + for (int i = 1; i <= 5; ++i) { + t.move(0, at + QPoint(20 * i, 0), s).commit(); + } + t.release(0, at + QPoint(100, 0), s).commit(); + + QVERIFY(selections().empty()); + QVERIFY(!m_window->viewManager()->haveInProgressSelection()); + QCOMPARE(QGuiApplication::mouseButtons(), Qt::NoButton); + } + + // The menu comes up under the finger. The finger wandering onto its + // first item (Undo, in Tony's) and lifted there does not choose it; + // a tap on it afterwards does + void long_press_menu_waits_for_a_tap() { + openWindow(); + if (QTest::currentTestFailed()) return; + + QMenu *menu = new QMenu(m_window); + QAction *first = menu->addAction("First"); + menu->addAction("Second"); + int chosen = 0; + connect(first, &QAction::triggered, this, [&chosen]() { ++chosen; }); + m_window->menu = menu; + + sv::Pane *p = pane(); + QPoint at(p->width() / 2, p->height() / 2); + + Touch t = touch(); + t.press(0, at, p).commit(); + QTest::qWait(TouchGestures::longPressMs + 200); + QCOMPARE(m_window->menuRequests, 1); + QVERIFY(menu->isVisible()); + + QPoint item = p->mapFromGlobal + (menu->mapToGlobal(menu->actionGeometry(first).center())); + for (int i = 1; i <= 10; ++i) { + t.move(0, at + (item - at) * i / 10, p).commit(); + } + t.release(0, item, p).commit(); + + QCOMPARE(chosen, 0); + QVERIFY(menu->isVisible()); + + QTest::qWait(QApplication::doubleClickInterval() + 100); + Touch tap = touch(); + tap.press(0, item, p).commit(); + tap.release(0, item, p).commit(); + + QTRY_COMPARE(chosen, 1); + QVERIFY(!menu->isVisible()); + QCOMPARE(QGuiApplication::mouseButtons(), Qt::NoButton); + } + + // One finger dragged in Navigate mode moves the view exactly as the + // mouse dragged the same way does + void one_finger_drag_pans_as_the_mouse_does() { + openWindow(); + if (QTest::currentTestFailed()) return; + + sv::Pane *p = pane(); + int y = p->height() / 2; + QPoint from(p->width() / 2 + 100, y); + QPoint to(p->width() / 2, y); + sv::sv_frame_t start = p->getCentreFrame(); + + QTest::mousePress(p, Qt::LeftButton, Qt::NoModifier, from); + for (int i = 1; i <= 10; ++i) { + QTest::mouseMove(p, from + (to - from) * i / 10); + } + QTest::mouseRelease(p, Qt::LeftButton, Qt::NoModifier, to); + sv::sv_frame_t byMouse = p->getCentreFrame() - start; + QVERIFY2(byMouse > 90 * startLevel, + qPrintable(QString("the mouse moved it by %1") + .arg(byMouse))); + + p->setCentreFrame(start); + QCOMPARE(p->getCentreFrame(), start); + + Touch t = touch(); + t.press(0, from, p).commit(); + for (int i = 1; i <= 10; ++i) { + t.move(0, from + (to - from) * i / 10, p).commit(); + } + t.release(0, to, p).commit(); + sv::sv_frame_t byTouch = p->getCentreFrame() - start; + + QCOMPARE(byTouch, byMouse); + QCOMPARE(m_window->menuRequests, 0); + QCOMPARE(QGuiApplication::mouseButtons(), Qt::NoButton); + } + + // A tap is a click: in Navigate mode the playhead goes there + void one_finger_tap_moves_the_playhead() { + openWindow(); + if (QTest::currentTestFailed()) return; + + sv::Pane *p = pane(); + QPoint at(p->width() / 2 + 150, p->height() / 2); + sv::sv_frame_t target = p->getFrameForX(at.x()); + QVERIFY(std::abs(m_window->viewManager()->getPlaybackFrame() - + target) > 10000); + + Touch t = touch(); + t.press(0, at, p).commit(); + t.release(0, at, p).commit(); + + QTRY_VERIFY_WITH_TIMEOUT + (std::abs(m_window->viewManager()->getPlaybackFrame() - target) + <= startLevel, 2000); + QCOMPARE(m_window->menuRequests, 0); + QCOMPARE(QGuiApplication::mouseButtons(), Qt::NoButton); + } + + // A finger dragging out a selection, and a second finger coming down: + // the selection ends where the first finger was, and nothing either + // finger does after that changes it or leaves a button pressed + void second_finger_ends_a_selection_drag() { + openWindow(); + if (QTest::currentTestFailed()) return; + + sv::Pane *s = strip(); + sv::ViewManager *vm = m_window->viewManager(); + int y = s->height() / 2; + int x0 = s->width() / 2 - 100; + int x1 = x0 + 100; + sv::sv_frame_t f0 = s->getFrameForX(x0); + sv::sv_frame_t f1 = s->getFrameForX(x1); + + Touch t = touch(); + t.press(0, QPoint(x0, y), s).commit(); + for (int i = 1; i <= 10; ++i) { + t.move(0, QPoint(x0 + 10 * i, y), s).commit(); + } + QVERIFY(vm->haveInProgressSelection()); + + t.stationary(0).press(1, QPoint(x1 + 80, y), s).commit(); + QVERIFY(!vm->haveInProgressSelection()); + QCOMPARE(int(selections().size()), 1); + sv::Selection made = *selections().begin(); + QVERIFY2(std::abs(made.getStartFrame() - f0) <= startLevel && + std::abs(made.getEndFrame() - f1) <= startLevel, + qPrintable(QString("selected %1 to %2, dragged %3 to %4") + .arg(made.getStartFrame()) + .arg(made.getEndFrame()).arg(f0).arg(f1))); + + for (int i = 1; i <= 5; ++i) { + t.move(0, QPoint(x1 + 20 * i, y), s) + .move(1, QPoint(x1 + 80 + 20 * i, y), s).commit(); + } + t.release(0, QPoint(x1 + 100, y), s) + .release(1, QPoint(x1 + 180, y), s).commit(); + + QCOMPARE(int(selections().size()), 1); + QVERIFY(*selections().begin() == made); + QVERIFY(!vm->haveInProgressSelection()); + QCOMPARE(m_window->menuRequests, 0); + QCOMPARE(QGuiApplication::mouseButtons(), Qt::NoButton); + } + + // The wheel zooms in one step about the centre, as svgui has it + void mouse_wheel_zoom_is_unchanged() { + openWindow(); + if (QTest::currentTestFailed()) return; + + sv::Pane *p = pane(); + sv::sv_frame_t centre = p->getCentreFrame(); + QPointF at(p->width() / 2 - 100, p->height() / 2); + + QWheelEvent wheel(at, p->mapToGlobal(at), QPoint(), QPoint(0, 120), + Qt::NoButton, Qt::NoModifier, Qt::NoScrollPhase, + false); + QCoreApplication::sendEvent(p, &wheel); + + sv::ZoomLevel expected = sv::RelativelyFineZoomConstraint() + .getNearestZoomLevel + (sv::ZoomLevel(sv::ZoomLevel::FramesPerPixel, + startLevel).decremented(), + sv::ZoomConstraint::RoundDown); + QVERIFY(framesPerPixel() < startLevel); + QVERIFY(p->getZoomLevel() == expected); + QCOMPARE(p->getCentreFrame(), centre); + } + + // Fingers spread up the pane: the frequency range narrows by as + // much as they spread, less what the dead zone took, about the + // pitch on show, the reference's sine, low in the pane and well + // below the fingers, which comes towards the middle of the pane by + // as much. The time axis stays as it was, the pitch layers in the + // pane are drawn on the new range, and nothing goes into the undo + // history + void vertical_pinch_narrows_the_frequency_range() { + openWindow(); + if (QTest::currentTestFailed()) return; + + m_window->toggleAlternatePitch(); + QVERIFY(m_window->alternatePitch()->isShown()); + + sv::Pane *p = pane(); + QVERIFY2(p->height() >= 300, + qPrintable(QString("the pane is %1 high").arg(p->height()))); + int x = p->width() / 2 + 100; + int y = p->height() / 2 - 60; + sv::sv_frame_t centre = p->getCentreFrame(); + VerticalZoom::Range before = frequencyRange(); + QCOMPARE(before.min, 40.0); + QCOMPARE(before.max, 1500.0); + QVERIFY(m_window->analyser()->setDisplayFrequencyExtents(150, 1200)); + before = frequencyRange(); + double pitchY = yForFrequency(referenceHz); + QVERIFY2(pitchY > 0.75 * p->height(), + qPrintable(QString("the pitch is at %1").arg(pitchY))); + + int commands = 0; + auto counted = connect(sv::CommandHistory::getInstance(), + qOverload<>(&sv::CommandHistory::commandExecuted), + this, [&commands]() { ++commands; }); + twoFingers(p, QPoint(x, y - 50), QPoint(x, y + 50), + QPoint(x, y - 100), QPoint(x, y + 100)); + disconnect(counted); + QCOMPARE(commands, 0); + + VerticalZoom::Range after = frequencyRange(); + double factor = (200.0 - deadZone()) / 100.0; + double narrowed = octaves(before) / octaves(after); + QVERIFY2(std::fabs(narrowed - factor) < 0.01 * factor, + qPrintable(QString("%1, then %2: %3 times narrower, not %4") + .arg(text(before)).arg(text(after)) + .arg(narrowed).arg(factor))); + double expected = pulledTowardsMiddle(pitchY, factor); + QVERIFY2(std::fabs(yForFrequency(referenceHz) - expected) <= + slack(after), + qPrintable(QString("the pitch was at %1, then at %2, not %3") + .arg(pitchY).arg(yForFrequency(referenceHz)) + .arg(expected))); + + // As far as the centre goes, to the whole pixel the fingers hold + QCOMPARE(framesPerPixel(), double(startLevel)); + QVERIFY(std::abs(p->getCentreFrame() - centre) < startLevel); + + // The reference's pitch and notes, and the alternate pitch track + // that follows them + Analyser *a = m_window->analyser(); + std::vector layers { + a->getLayer(Analyser::PitchTrack), + a->getLayer(Analyser::Notes), + m_window->alternatePitch()->getLayer() + }; + for (sv::Layer *layer : layers) { + QVERIFY(layer); + sv::CoordinateScale scale = + p->getEffectiveVerticalExtentsForLayer(layer); + QVERIFY2(scale.isLogarithmic() && + scale.getDisplayMinimum() == after.min && + scale.getDisplayMaximum() == after.max, + qPrintable(QString("%1 is drawn from %2 to %3") + .arg(layer->objectName()) + .arg(scale.getDisplayMinimum()) + .arg(scale.getDisplayMaximum()))); + } + + QCOMPARE(m_window->menuRequests, 0); + QCOMPARE(QGuiApplication::mouseButtons(), Qt::NoButton); + } + + // Fingers brought together up the pane: the range widens, about + // the pitch on show, which stays where it was + void vertical_pinch_widens_the_frequency_range() { + openWindow(); + if (QTest::currentTestFailed()) return; + + QVERIFY(m_window->analyser()->setDisplayFrequencyExtents(100, 800)); + + sv::Pane *p = pane(); + int x = p->width() / 2 - 100; + int y = p->height() / 2 - 40; + VerticalZoom::Range before = frequencyRange(); + QCOMPARE(before.min, 100.0); + double pitchY = yForFrequency(referenceHz); + QVERIFY(pitchY > y + 20); + + twoFingers(p, QPoint(x, y - 100), QPoint(x, y + 100), + QPoint(x, y - 50), QPoint(x, y + 50)); + + VerticalZoom::Range after = frequencyRange(); + double factor = (100.0 + deadZone()) / 200.0; + double narrowed = octaves(before) / octaves(after); + QVERIFY2(std::fabs(narrowed - factor) < 0.01 * factor, + qPrintable(QString("%1, then %2: %3 times narrower, not %4") + .arg(text(before)).arg(text(after)) + .arg(narrowed).arg(factor))); + QVERIFY2(std::fabs(yForFrequency(referenceHz) - pitchY) <= 1.0, + qPrintable(QString("the pitch was at %1, then at %2") + .arg(pitchY).arg(yForFrequency(referenceHz)))); + QCOMPARE(framesPerPixel(), double(startLevel)); + } + + // Fingers spread across the pane, not quite level, and drifting down + // a little: the time axis zooms, and the frequency range is left + // exactly as it was. Their spread up the pane grows by more than the + // dead zone, but by less than half as much as across + void horizontal_pinch_leaves_the_frequency_range() { + openWindow(); + if (QTest::currentTestFailed()) return; + + sv::Pane *p = pane(); + int x = p->width() / 2; + int y = p->height() / 2; + int drift = deadZone() / 2; + VerticalZoom::Range before = frequencyRange(); + + twoFingers(p, QPoint(x - 50, y - 15), QPoint(x + 50, y + 15), + QPoint(x - 150, y - 35 + drift), + QPoint(x + 150, y + 35 + drift)); + + QVERIFY(framesPerPixel() < startLevel / 2.0); + VerticalZoom::Range after = frequencyRange(); + QCOMPARE(after.min, before.min); + QCOMPARE(after.max, before.max); + } + + // Spread along both: both zoom, time about the fingers and the + // frequency range about the pitch + void diagonal_pinch_zooms_time_and_frequency() { + openWindow(); + if (QTest::currentTestFailed()) return; + + QVERIFY(m_window->analyser()->setDisplayFrequencyExtents(100, 800)); + + sv::Pane *p = pane(); + int x = p->width() / 2 - 100; + int y = p->height() / 2 - 30; + sv::sv_frame_t frame = p->getFrameForX(x); + VerticalZoom::Range before = frequencyRange(); + double pitchY = yForFrequency(referenceHz); + + twoFingers(p, QPoint(x - 50, y - 50), QPoint(x + 50, y + 50), + QPoint(x - 100, y - 100), QPoint(x + 100, y + 100)); + + QCOMPARE(framesPerPixel(), startLevel / 2.0); + QVERIFY2(std::abs(p->getFrameForX(x) - frame) <= startLevel / 2, + qPrintable(QString("frame %1 under the fingers, then %2") + .arg(frame).arg(p->getFrameForX(x)))); + + VerticalZoom::Range after = frequencyRange(); + double factor = (200.0 - deadZone()) / 100.0; + double narrowed = octaves(before) / octaves(after); + QVERIFY2(std::fabs(narrowed - factor) < 0.01 * factor, + qPrintable(QString("%1, then %2: %3 times narrower, not %4") + .arg(text(before)).arg(text(after)) + .arg(narrowed).arg(factor))); + double expected = pulledTowardsMiddle(pitchY, factor); + QVERIFY2(std::fabs(yForFrequency(referenceHz) - expected) <= + slack(after), + qPrintable(QString("the pitch was at %1, then at %2, not %3") + .arg(pitchY).arg(yForFrequency(referenceHz)) + .arg(expected))); + } + + // With no pitch on show, the range zooms about the frequency between + // the fingers, which stays there: the reference's pitch and notes + // hidden, and then shown but below the range + void vertical_pinch_with_no_pitch_on_show_zooms_about_the_fingers() { + openWindow(); + if (QTest::currentTestFailed()) return; + + sv::Pane *p = pane(); + int x = p->width() / 2 + 50; + int y = p->height() / 2 - 60; + Analyser *a = m_window->analyser(); + + for (bool hidden : { true, false }) { + showPitch(a, !hidden); + QVERIFY(a->setDisplayFrequencyExtents(hidden ? 100 : 300, 1200)); + VerticalZoom::Range before = frequencyRange(); + double held = frequencyAtY(y); + + twoFingers(p, QPoint(x, y - 50), QPoint(x, y + 50), + QPoint(x, y - 100), QPoint(x, y + 100)); + + QString what = QString(hidden ? "hidden" : "below the range"); + VerticalZoom::Range after = frequencyRange(); + double factor = (200.0 - deadZone()) / 100.0; + double narrowed = octaves(before) / octaves(after); + QVERIFY2(std::fabs(narrowed - factor) < 0.01 * factor, + qPrintable(what + QString(": %1 times narrower, not %2") + .arg(narrowed).arg(factor))); + QVERIFY2(std::fabs(yForFrequency(held) - y) <= 1.0, + qPrintable(what + QString(": %1 Hz was at %2, then at %3") + .arg(held).arg(y).arg(yForFrequency(held)))); + } + } + + // The fifth phone test: a low voice, C2 and G2 by turns, near the + // bottom of the range as it opens, and the fingers spread up the + // middle of the pane, far above it. At every step, as the range + // narrows, both notes stay in view, and they end nearer the middle. + // Zoomed about the fingers, G2 went below the pane half way through + void low_pitch_stays_in_view_as_the_range_narrows() { + std::vector voice; + for (int i = 0; i < 12; ++i) { + std::vector note = TestSignals::sine + (i % 2 ? 98.0 : 65.4, rate, int(rate / 2), 0.5); + voice.insert(voice.end(), note.begin(), note.end()); + } + QString path = wavFile("low-voice.wav", voice); + QVERIFY(path != ""); + openWindow(path); + if (QTest::currentTestFailed()) return; + + sv::Pane *p = pane(); + int height = p->height(); + int x = p->width() / 2; + int y = height / 2; + VerticalZoom::Range before = frequencyRange(); + QCOMPARE(before.min, 40.0); + QCOMPARE(before.max, 1500.0); + double lowY = yForFrequency(65.4); + double highY = yForFrequency(98.0); + QVERIFY2(highY > 0.7 * height, + qPrintable(QString("G2 at %1 of %2").arg(highY).arg(height))); + + int from = 30; + int to = height / 2 - 20; + Touch t = touch(); + t.press(0, QPoint(x, y - from), p).press(1, QPoint(x, y + from), p) + .commit(); + for (int i = 1; i <= 10; ++i) { + int d = from + (to - from) * i / 10; + t.move(0, QPoint(x, y - d), p).move(1, QPoint(x, y + d), p) + .commit(); + double low = yForFrequency(65.4); + double high = yForFrequency(98.0); + QVERIFY2(low <= height && high >= 0, + qPrintable(QString("step %1, %2: C2 at %3, G2 at %4 " + "of %5") + .arg(i).arg(text(frequencyRange())) + .arg(low).arg(high).arg(height))); + } + t.release(0, QPoint(x, y - to), p).release(1, QPoint(x, y + to), p) + .commit(); + + VerticalZoom::Range after = frequencyRange(); + QVERIFY2(octaves(after) < octaves(before) / 4, + qPrintable(text(before) + ", then " + text(after))); + double middle = height / 2.0; + double lowAfter = yForFrequency(65.4); + double highAfter = yForFrequency(98.0); + QVERIFY2(std::fabs((lowAfter + highAfter) / 2 - middle) < + std::fabs((lowY + highY) / 2 - middle) / 4, + qPrintable(QString("C2 and G2 at %1 and %2, then at %3 and " + "%4, of %5") + .arg(lowY).arg(highY).arg(lowAfter) + .arg(highAfter).arg(height))); + QCOMPARE(QGuiApplication::mouseButtons(), Qt::NoButton); + } + + // The singing's pitch counts as the reference's does. A low voice + // sung to a high reference: with the reference's pitch and notes + // hidden the zoom keeps the singing's in view, and with both on + // show it is about the middle between the two + void singing_pitch_on_show_is_kept_in_view() { + openWindow(); + if (QTest::currentTestFailed()) return; + + QString singing = sineFile("singing.wav", lowHz); + QVERIFY(singing != ""); + m_window->loadSingingTrack(singing); + QTRY_VERIFY_WITH_TIMEOUT(analysed(m_window->analyser2()), 30000); + QVERIFY(m_window->analyser2()->getPane() == pane()); + resetView(); + + sv::Pane *p = pane(); + int x = p->width() / 2; + int y = p->height() / 4; + Analyser *a = m_window->analyser(); + + for (bool reference : { false, true }) { + showPitch(a, reference); + QVERIFY(a->setDisplayFrequencyExtents(40, 1500)); + double kept = reference ? std::sqrt(lowHz * referenceHz) : lowHz; + double keptY = yForFrequency(kept); + + twoFingers(p, QPoint(x, y - 40), QPoint(x, y + 40), + QPoint(x, y - 80), QPoint(x, y + 80)); + + double factor = (160.0 - deadZone()) / 80.0; + double expected = pulledTowardsMiddle(keptY, factor); + VerticalZoom::Range after = frequencyRange(); + QVERIFY2(std::fabs(yForFrequency(kept) - expected) <= + slack(after), + qPrintable(QString("%1 Hz was at %2, then at %3, not " + "%4, on %5") + .arg(kept).arg(keptY) + .arg(yForFrequency(kept)).arg(expected) + .arg(text(after)))); + } + } + + // Two fingers side by side dragged down the pane: what was between + // them goes down with them, as far as they went beyond the dead + // zone, and the range keeps its height. Time stays where it was + void vertical_two_finger_drag_scrolls_the_frequency_range() { + openWindow(); + if (QTest::currentTestFailed()) return; + + sv::Pane *p = pane(); + int x = p->width() / 2; + int y = p->height() / 2 - 80; + sv::sv_frame_t centre = p->getCentreFrame(); + VerticalZoom::Range before = frequencyRange(); + double held = frequencyAtY(y); + + twoFingers(p, QPoint(x - 50, y), QPoint(x + 50, y), + QPoint(x - 50, y + 120), QPoint(x + 50, y + 120)); + + VerticalZoom::Range after = frequencyRange(); + double expected = y + 120 - deadZone(); + QVERIFY2(std::fabs(yForFrequency(held) - expected) <= 1.0, + qPrintable(QString("%1 Hz was at %2, then at %3, not %4") + .arg(held).arg(y).arg(yForFrequency(held)) + .arg(expected))); + QVERIFY2(after.max > before.max && + std::fabs(octaves(after) - octaves(before)) < 0.01, + qPrintable(QString("%1, then %2") + .arg(text(before)).arg(text(after)))); + + // As far as the centre goes, to the whole pixel the fingers hold + QCOMPARE(framesPerPixel(), double(startLevel)); + QVERIFY(std::abs(p->getCentreFrame() - centre) < startLevel); + QCOMPARE(QGuiApplication::mouseButtons(), Qt::NoButton); + } + + // No narrower than a major third, no wider than the piano, and no + // further up or down: held there, the range stays while the fingers + // go on, and goes back with them as soon as they turn. The range is + // whole Hz (the spectrogram's), hence the half hertz allowed + void frequency_range_stays_within_its_limits() { + openWindow(); + if (QTest::currentTestFailed()) return; + + sv::Pane *p = pane(); + int x = p->width() / 2; + int y = p->height() / 2; + VerticalZoom::Limits limits = VerticalZoom::pitchLimits(); + + QVERIFY(m_window->analyser()->setDisplayFrequencyExtents(200, 260)); + twoFingers(p, QPoint(x, y - 50), QPoint(x, y + 50), + QPoint(x, y - 120), QPoint(x, y + 120)); + VerticalZoom::Range r = frequencyRange(); + double semitones = 12.0 * octaves(r); + QVERIFY2(semitones > 3.9 && semitones < 4.1, + qPrintable(text(r) + QString(": %1 semitones") + .arg(semitones))); + + QVERIFY(m_window->analyser()->setDisplayFrequencyExtents(40, 1500)); + twoFingers(p, QPoint(x, y - 120), QPoint(x, y + 120), + QPoint(x, y - 20), QPoint(x, y + 20)); + r = frequencyRange(); + QVERIFY2(std::fabs(r.min - limits.lowest) <= 0.5 && + std::fabs(r.max - limits.highest) <= 0.5, + qPrintable(text(r))); + + QVERIFY(m_window->analyser()->setDisplayFrequencyExtents(40, 1500)); + VerticalZoom::Range start = frequencyRange(); + int top = 20; + int bottom = p->height() - 40; + Touch t = touch(); + t.press(0, QPoint(x - 50, top), p).press(1, QPoint(x + 50, top), p) + .commit(); + for (int i = 1; i <= 10; ++i) { + int at = top + (bottom - top) * i / 10; + t.move(0, QPoint(x - 50, at), p).move(1, QPoint(x + 50, at), p) + .commit(); + } + r = frequencyRange(); + QVERIFY2(std::fabs(r.max - limits.highest) <= 0.5 && + std::fabs(octaves(r) - octaves(start)) < 0.01, + qPrintable(text(start) + ", then " + text(r))); + + double atTurn = frequencyAtY(bottom); + for (int i = 1; i <= 5; ++i) { + int at = bottom - 10 * i; + t.move(0, QPoint(x - 50, at), p).move(1, QPoint(x + 50, at), p) + .commit(); + } + t.release(0, QPoint(x - 50, bottom - 50), p) + .release(1, QPoint(x + 50, bottom - 50), p).commit(); + QVERIFY2(std::fabs(yForFrequency(atTurn) - (bottom - 50)) <= 1.0, + qPrintable(QString("%1 Hz was at %2, then at %3") + .arg(atTurn).arg(bottom) + .arg(yForFrequency(atTurn)))); + QCOMPARE(QGuiApplication::mouseButtons(), Qt::NoButton); + } + + // The range the fingers leave is the session's: saved with it, and + // there again when it is opened + void frequency_range_is_saved_with_the_session() { + openWindow(); + if (QTest::currentTestFailed()) return; + + sv::Pane *p = pane(); + int x = p->width() / 2; + int y = p->height() / 2; + twoFingers(p, QPoint(x, y - 50), QPoint(x, y + 50), + QPoint(x, y - 100), QPoint(x, y + 120)); + VerticalZoom::Range zoomed = frequencyRange(); + QVERIFY(zoomed.min > 40.0 && zoomed.max < 1500.0); + + QString session = m_dir.filePath("zoomed.ton"); + QVERIFY(m_window->saveSessionFile(session)); + m_window->doCloseSession(); + QCOMPARE(m_window->openPath(session, MainWindow::ReplaceSession), + MainWindow::FileOpenSucceeded); + QTRY_VERIFY_WITH_TIMEOUT(analysed(), 30000); + + VerticalZoom::Range restored = frequencyRange(); + QCOMPARE(restored.min, zoomed.min); + QCOMPARE(restored.max, zoomed.max); + sv::CoordinateScale scale = pane()->getEffectiveVerticalExtentsForLayer + (m_window->analyser()->getLayer(Analyser::PitchTrack)); + QCOMPARE(scale.getDisplayMinimum(), zoomed.min); + QCOMPARE(scale.getDisplayMaximum(), zoomed.max); + } + + // The selection strip shows no frequencies: two fingers dragged up + // it leave the range alone + void frequency_range_is_the_pitch_pane_s_only() { + openWindow(); + if (QTest::currentTestFailed()) return; + + sv::Pane *s = strip(); + int x = s->width() / 2; + int y = s->height() - 5; + VerticalZoom::Range before = frequencyRange(); + + twoFingers(s, QPoint(x - 50, y), QPoint(x + 50, y), + QPoint(x - 50, y - 60), QPoint(x + 50, y - 60)); + + VerticalZoom::Range after = frequencyRange(); + QCOMPARE(after.min, before.min); + QCOMPARE(after.max, before.max); + QCOMPARE(QGuiApplication::mouseButtons(), Qt::NoButton); + } +}; + +#endif diff --git a/main/test/TestTouchMenuStyle.h b/main/test/TestTouchMenuStyle.h new file mode 100644 index 00000000..2e24ce43 --- /dev/null +++ b/main/test/TestTouchMenuStyle.h @@ -0,0 +1,325 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TEST_TOUCH_MENU_STYLE_H +#define TEST_TOUCH_MENU_STYLE_H + +// Tier 5: menus taller than the screen, as a phone has them +// (TouchMenuStyle): on the screen in one column, scrolled by a finger +// dragged over them, every item reachable, and a tap still a choice; +// menus and combo box lists clear of the system bars and of the top and +// bottom of the screen. The menus are real popups on the offscreen +// platform's screen; the mouse events are what Qt makes of a finger on +// Android. + +#include "../TouchMenuStyle.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +class TestTouchMenuStyle : public QObject +{ + Q_OBJECT + + // Taller than the screen by some way: the phone's Edit menu is about + // two screens high in landscape + static constexpr int itemCount = 80; + + std::unique_ptr m_style; + std::unique_ptr m_menu; + + QRect screen() const { + return QGuiApplication::primaryScreen()->availableGeometry(); + } + + // A menu of itemCount items with the touch style, popped up at the + // top of the screen. The items are as wide as Tony's longest: laid + // out in columns, as Qt's styles lay out a menu taller than the + // screen, the columns would not fit across it either + QMenu *longMenu() { + m_menu.reset(new QMenu); + m_menu->setStyle(m_style.get()); + for (int i = 0; i < itemCount; ++i) { + m_menu->addAction(QString("Item %1: Save Session to Audio File " + "Path").arg(i)); + } + m_menu->popup(screen().topLeft()); + return m_menu.get(); + } + + QAction *item(int i) { return m_menu->actions().at(i); } + + int top(int i) { return m_menu->actionGeometry(item(i)).top(); } + + // On show: inside the menu, and across the screen, not off its side + // as columns that do not fit are. (The menu itself fits the screen's + // height, or nearly: the offscreen platform puts a popup two pixels + // in from where Qt asks) + bool shows(int i) { + QRect r = m_menu->actionGeometry(item(i)); + QRect global(m_menu->mapToGlobal(r.topLeft()), r.size()); + return r.top() >= 0 && r.bottom() < m_menu->height() && + global.left() >= screen().left() && + global.right() <= screen().right(); + } + + // A finger put down at from and moved to to, a few pixels at a time, + // then lifted there + void drag(QPoint from, QPoint to) { + QTest::mousePress(m_menu.get(), Qt::LeftButton, {}, from); + QPoint step = (to - from) / 20; + for (int i = 1; i <= 20; ++i) { + QTest::mouseMove(m_menu.get(), from + step * i); + } + QTest::mouseRelease(m_menu.get(), Qt::LeftButton, {}, from + step * 20); + } + +private slots: + void init() { + m_style.reset(new TouchMenuStyle); + } + + void cleanup() { + m_menu.reset(); + m_style.reset(); + } + + void a_long_menu_is_on_the_screen_in_one_column() { + QMenu *menu = longMenu(); + QVERIFY(QTest::qWaitForWindowExposed(menu)); + + QVERIFY2(menu->height() <= screen().height(), + qPrintable(QString("%1 high on a screen %2 high") + .arg(menu->height()).arg(screen().height()))); + + // One column: every item at the same place across, and the ones + // that do not fit below, to be scrolled to + for (int i = 1; i < itemCount; ++i) { + QCOMPARE(menu->actionGeometry(item(i)).left(), + menu->actionGeometry(item(0)).left()); + } + QVERIFY(shows(0)); + QVERIFY(!shows(itemCount - 1)); + QVERIFY(top(itemCount - 1) >= menu->height()); + + // Arrows a finger can hit + QVERIFY(m_style->pixelMetric(QStyle::PM_MenuScrollerHeight) >= 30); + } + + void a_finger_dragged_up_scrolls_the_menu_and_chooses_nothing() { + QMenu *menu = longMenu(); + QVERIFY(QTest::qWaitForWindowExposed(menu)); + QSignalSpy triggered(menu, &QMenu::triggered); + + int row = menu->actionGeometry(item(0)).height(); + QVERIFY(row > 0); + int before = top(10); + + QPoint centre = menu->rect().center(); + drag(centre, centre - QPoint(0, 10 * row)); + + // Ten rows' drag, ten rows further on (give or take the arrow + // strip that appears at the top once it has scrolled) + int moved = before - top(10); + QVERIFY2(moved >= 8 * row && moved <= 11 * row, + qPrintable(QString("moved %1 for rows of %2") + .arg(moved).arg(row))); + QCOMPARE(triggered.count(), 0); + QVERIFY(menu->isVisible()); + QVERIFY(!shows(0)); + } + + void every_item_can_be_reached_and_the_way_back_too() { + QMenu *menu = longMenu(); + QVERIFY(QTest::qWaitForWindowExposed(menu)); + QSignalSpy triggered(menu, &QMenu::triggered); + + QPoint low(menu->width() / 2, menu->height() * 3 / 4); + QPoint high(menu->width() / 2, menu->height() / 4); + + for (int i = 0; i < 10 && !shows(itemCount - 1); ++i) { + drag(low, high); + } + QVERIFY(shows(itemCount - 1)); + + for (int i = 0; i < 10 && !shows(0); ++i) { + drag(high, low); + } + QVERIFY(shows(0)); + + QCOMPARE(triggered.count(), 0); + QVERIFY(menu->isVisible()); + } + + void a_tap_chooses_an_item_as_before() { + QMenu *menu = longMenu(); + QVERIFY(QTest::qWaitForWindowExposed(menu)); + QSignalSpy triggered(menu, &QMenu::triggered); + + QPoint at = menu->actionGeometry(item(3)).center(); + QTest::mousePress(menu, Qt::LeftButton, {}, at); + QTest::mouseRelease(menu, Qt::LeftButton, {}, at); + + QCOMPARE(triggered.count(), 1); + QCOMPARE(triggered.at(0).at(0).value(), item(3)); + } + + void a_tap_after_scrolling_chooses_the_item_shown_there() { + QMenu *menu = longMenu(); + QVERIFY(QTest::qWaitForWindowExposed(menu)); + QSignalSpy triggered(menu, &QMenu::triggered); + + QPoint centre = menu->rect().center(); + int row = menu->actionGeometry(item(0)).height(); + int before = menu->actions().indexOf(menu->actionAt(centre)); + drag(centre, centre - QPoint(0, 20 * row)); + + QAction *shown = menu->actionAt(centre); + QVERIFY(shown); + QVERIFY2(menu->actions().indexOf(shown) >= before + 18, + qPrintable(QString("item %1 there before, %2 after") + .arg(before) + .arg(menu->actions().indexOf(shown)))); + + QTest::mousePress(menu, Qt::LeftButton, {}, centre); + QTest::mouseRelease(menu, Qt::LeftButton, {}, centre); + + QCOMPARE(triggered.count(), 1); + QCOMPARE(triggered.at(0).at(0).value(), shown); + } + + // --- Clear of the system bars and the screen's edges --- + + static QRect onScreen(QWidget *w) { + return QRect(w->mapToGlobal(QPoint(0, 0)), w->size()); + } + + // Inside area, give or take the two pixels the offscreen platform + // moves a popup by + static bool within(QRect r, QRect area) { + return area.adjusted(-2, -2, 2, 2).contains(r); + } + + static QString describe(QRect r, QRect area) { + return QString("%1,%2 %3x%4 in %5,%6 %7x%8") + .arg(r.x()).arg(r.y()).arg(r.width()).arg(r.height()) + .arg(area.x()).arg(area.y()).arg(area.width()).arg(area.height()); + } + + void a_long_menu_keeps_clear_of_the_top_and_bottom_and_still_scrolls() { + m_style->setEdgeMargin(60); + QMenu *menu = longMenu(); + QVERIFY(QTest::qWaitForWindowExposed(menu)); + + QRect usable = m_style->usableArea(menu); + QCOMPARE(usable, screen().adjusted(0, 60, 0, -60)); + QVERIFY2(within(onScreen(menu), usable), + qPrintable(describe(onScreen(menu), usable))); + + QPoint low(menu->width() / 2, menu->height() * 3 / 4); + QPoint high(menu->width() / 2, menu->height() / 4); + for (int i = 0; i < 10 && !shows(itemCount - 1); ++i) { + drag(low, high); + } + QVERIFY(shows(itemCount - 1)); + QVERIFY2(within(onScreen(menu), usable), + qPrintable(describe(onScreen(menu), usable))); + } + + void a_short_menu_at_the_very_top_is_moved_down() { + m_style->setEdgeMargin(60); + m_menu.reset(new QMenu); + m_menu->setStyle(m_style.get()); + m_menu->addAction("Open..."); + m_menu->addAction("Open Recent"); + m_menu->addAction("Save Session"); + m_menu->popup(screen().topLeft()); + QVERIFY(QTest::qWaitForWindowExposed(m_menu.get())); + + QRect usable = m_style->usableArea(m_menu.get()); + QVERIFY2(within(onScreen(m_menu.get()), usable), + qPrintable(describe(onScreen(m_menu.get()), usable))); + QVERIFY(onScreen(m_menu.get()).top() >= screen().top() + 58); + } + + void menus_keep_inside_the_main_windows_safe_area() { + // The offscreen platform has no system bars and no safe area + // margins: a main window short of the screen on every side + // stands for the part the bars leave + QWidget window; + window.setGeometry(screen().adjusted(40, 80, -40, -100)); + window.show(); + QVERIFY(QTest::qWaitForWindowExposed(&window)); + m_style->setSafeAreaWindow(&window); + + QMenu *menu = longMenu(); + QVERIFY(QTest::qWaitForWindowExposed(menu)); + + QRect usable = m_style->usableArea(menu); + QCOMPARE(usable, onScreen(&window) & screen()); + QVERIFY2(within(onScreen(menu), usable), + qPrintable(describe(onScreen(menu), usable))); + + m_menu.reset(); + } + + void a_combo_boxs_list_keeps_clear_too() { + m_style->setEdgeMargin(60); + + // At the top of the screen, as the take box is in the compact + // layout's toolbar, its list longer than the screen is high + QComboBox combo; + combo.setStyle(m_style.get()); + for (int i = 0; i < itemCount; ++i) { + combo.addItem(QString("Take %1").arg(i)); + } + combo.setCurrentIndex(itemCount / 2); + QWidget *list = combo.view()->parentWidget(); + list->setStyle(m_style.get()); + combo.move(screen().topLeft()); + combo.show(); + QVERIFY(QTest::qWaitForWindowExposed(&combo)); + + combo.showPopup(); + QVERIFY(QTest::qWaitForWindowExposed(list)); + + QRect usable = m_style->usableArea(list); + QVERIFY2(within(onScreen(list), usable), + qPrintable(describe(onScreen(list), usable))); + + combo.hidePopup(); + } + + void without_margins_menus_are_placed_as_before() { + // As the desktop has it: the style's own margin, and the screen + TouchMenuStyle plain; + m_menu.reset(new QMenu); + m_menu->addAction("Item"); + QCOMPARE(plain.usableArea(m_menu.get()), screen()); + QCOMPARE(plain.pixelMetric(QStyle::PM_MenuDesktopFrameWidth, nullptr, + m_menu.get()), + plain.baseStyle()->pixelMetric + (QStyle::PM_MenuDesktopFrameWidth, nullptr, m_menu.get())); + } +}; + +#endif diff --git a/main/test/TestUiChecks.h b/main/test/TestUiChecks.h new file mode 100644 index 00000000..8b8f2cbf --- /dev/null +++ b/main/test/TestUiChecks.h @@ -0,0 +1,1819 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TEST_UI_CHECKS_H +#define TEST_UI_CHECKS_H + +// Tier 6: the window as the user sees and handles it. The same +// MainWindow and fake device as TestRecordWorkflow, but shown, driven +// with key presses, mouse gestures and its own dialogs, and judged by +// the pixels of pane 0 as it is drawn -- the items of the manual +// checklist that are about what is on the screen. +// +// With TONY_TEST_SHOT_DIR set, the suite also saves what it looked at, +// as PNG files, for a person to look over. + +#include "TestSignals.h" +#include "TestMainWindow.h" + +#include "../AlternatePitchTrack.h" +#include "../TakeLayers.h" + +#include "version.h" + +#include "framework/Document.h" +#include "view/Pane.h" +#include "view/PaneStack.h" +#include "layer/Layer.h" +#include "layer/ColourDatabase.h" +#include "layer/CoordinateScale.h" +#include "data/model/SparseTimeValueModel.h" +#include "data/model/NoteModel.h" +#include "data/model/RegionModel.h" +#include "data/fileio/WavFileReader.h" +#include "data/fileio/WavFileWriter.h" +#include "data/fileio/FileSource.h" +#include "base/RecordDirectory.h" +#include "transform/ModelTransformerFactory.h" +#include "widgets/CommandHistory.h" +#include "widgets/InteractiveFileFinder.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +class TestUiChecks : public QObject +{ + Q_OBJECT + + static constexpr double rate = 44100.0; + + // Whole numbers of samples per period: see TestSingingAnalysis.h + static constexpr double lowHz = 220.5; + static constexpr double highHz = 294.0; + + QTemporaryDir m_dir; + int m_fileCounter = 0; + TestMainWindow *m_window = nullptr; + QTimer m_watchdog; + QStringList m_dialogs; + QString m_shotDir; + + // Given each modal dialog before the watchdog dismisses it: true if + // it answered the dialog itself + std::function m_answerDialog; + + static std::vector tone(double hz, double seconds) { + return TestSignals::sawtooth(hz, rate, int(seconds * rate), 0.5f); + } + + static sv::sv_frame_t frames(double seconds) { + return sv::sv_frame_t(seconds * rate); + } + + QString writeWav(const std::vector &data) { + QString path = m_dir.filePath + (QString("audio-%1.wav").arg(++m_fileCounter)); + sv::WavFileWriter writer(path, rate, 1, + sv::WavFileWriter::WriteToTarget); + const float *ptr = data.data(); + if (!writer.isOK() || + !writer.writeSamples(&ptr, sv::sv_frame_t(data.size())) || + !writer.close()) { + return {}; + } + return path; + } + + // Shown at a fixed size and active, as the user has it: key presses + // reach the window's shortcuts only when it is the active window + void makeWindow(FakeAudioIO::Config config) { + delete m_window; + m_window = new TestMainWindow(config); + m_window->resize(1200, 800); + m_window->show(); + QVERIFY(QTest::qWaitForWindowExposed(m_window)); + m_window->activateWindow(); + QVERIFY(QTest::qWaitForWindowActive(m_window)); + } + + static bool analysed(Analyser *a) { + return a && a->getLayer(Analyser::PitchTrack) && + a->getLayer(Analyser::Notes) && + a->getInitialAnalysisCompletion() >= 100 && + !a->isAnalysingRange() && + !sv::ModelTransformerFactory::getInstance() + ->haveRunningTransformers(); + } + + void openReference(QString path) { + QVERIFY(!path.isEmpty()); + m_window->discardModifications(); + QCOMPARE(m_window->openPath(path, MainWindow::ReplaceSession), + MainWindow::FileOpenSucceeded); + QTRY_VERIFY_WITH_TIMEOUT(analysed(m_window->analyser()), 30000); + } + + void startTake() { + QVERIFY(!m_window->recordTarget()->isRecording()); + m_window->doRecord(); + QVERIFY(m_window->recordTarget()->isRecording()); + } + + void stopTake() { + QVERIFY(m_window->recordTarget()->isRecording()); + m_window->doRecord(); + QVERIFY(!m_window->recordTarget()->isRecording()); + QTRY_VERIFY_WITH_TIMEOUT(analysed(m_window->analyser2()), 30000); + } + + void take(int ms) { + startTake(); + if (QTest::currentTestFailed()) return; + QTest::qWait(ms); + stopTake(); + } + + sv::Pane *pane0() { return m_window->paneStack()->getPane(0); } + + // Whatever is seen of the page from start to end, and a little room + // either side + void showSeconds(double start, double end) { + sv::Pane *pane = pane0(); + int width = pane->width(); + int perPixel = int(std::ceil((end - start) * rate / (width * 0.8))); + pane->setZoomLevel(sv::ZoomLevel(sv::ZoomLevel::FramesPerPixel, + std::max(1, perPixel))); + pane->setCentreFrame(frames((start + end) / 2.0)); + } + + // Pane 0 as it is on the screen just now: the window's backing store, + // which holds what the pane's own paint events put there. Not + // QWidget::grab(), which has the pane paint itself once more for the + // occasion and so can show what the screen does not. The pane is + // painted first, as it would be at the next update: a pane that has + // just turned a page is still the old page on the screen until then, + // while every position asked of it is on the new one + QImage grabPane() { + QCoreApplication::processEvents(); + pane0()->repaint(); + QPixmap window = m_window->screen()->grabWindow(m_window->winId()); + QRect rect(pane0()->mapTo(m_window, QPoint(0, 0)), pane0()->size()); + return window.copy(rect).toImage() + .convertToFormat(QImage::Format_RGB32); + } + + // ... and drawn afresh, all of it. After playback the pane's own + // cache of what it drew holds the translucent note boxes painted a + // second time over themselves, until the next zoom or scroll; that + // is how the view composes itself, not what a layer draws, and would + // otherwise be taken for a change to the layers + QImage grabPaneRedrawn() { + sv::Pane *pane = pane0(); + sv::ZoomLevel zoom = pane->getZoomLevel(); + sv::sv_frame_t centre = pane->getCentreFrame(); + // (a zoom level only one step away snaps back to this one) + pane->setZoomLevel(sv::ZoomLevel(zoom.zone, zoom.level * 2)); + grabPane(); + pane->setZoomLevel(zoom); + pane->setCentreFrame(centre); + return grabPane(); + } + + void saveShot(QString name, const QImage &image) { + if (m_shotDir == "") return; + QDir().mkpath(m_shotDir); + QString test = QTest::currentTestFunction(); + image.save(QDir(m_shotDir).filePath(test + "-" + name + ".png")); + } + + // The whole window, as grabPane() takes the pane + void saveWindowShot(QString name) { + if (m_shotDir == "") return; + QCoreApplication::processEvents(); + saveShot(name, m_window->screen()->grabWindow(m_window->winId()) + .toImage()); + } + + static bool closeTo(QRgb pixel, QColor colour, int tolerance) { + return std::abs(qRed(pixel) - colour.red()) <= tolerance && + std::abs(qGreen(pixel) - colour.green()) <= tolerance && + std::abs(qBlue(pixel) - colour.blue()) <= tolerance; + } + + static QColor named(QString name) { + auto cdb = sv::ColourDatabase::getInstance(); + return cdb->getColour(cdb->getColourIndex(name)); + } + + // The orange of the live dots, the take's pitch track and the + // coverage strip; the purple of the take's notes + static bool isOrange(QRgb pixel) { + return closeTo(pixel, named("Orange"), 30); + } + static bool isPurple(QRgb pixel) { + return closeTo(pixel, named("Bright Purple"), 30); + } + + // Orange, or orange seen through the translucent fill of the take's + // notes, which are drawn over its pitch track: what is sung, as it is + // drawn. Not the brighter orange of the pitch candidates + static bool isSinging(QRgb pixel) { + return qRed(pixel) >= 200 && qGreen(pixel) >= 90 && + qGreen(pixel) <= 170 && qBlue(pixel) <= 140; + } + + // Pixels of a colour in the columns [x0, x1), above the band of the + // coverage strip at the bottom of the pane + static int countAbove(const QImage &image, int x0, int x1, + std::function is) { + int n = 0; + x0 = std::max(0, x0); + x1 = std::min(image.width(), x1); + for (int x = x0; x < x1; ++x) { + for (int y = 0; y < image.height() - 10; ++y) { + if (is(image.pixel(x, y))) ++n; + } + } + return n; + } + + // Leftmost and rightmost column holding such a pixel, or -1 + static std::pair extentAbove(const QImage &image, + std::function is) { + int left = -1, right = -1; + for (int x = 0; x < image.width(); ++x) { + for (int y = 0; y < image.height() - 10; ++y) { + if (is(image.pixel(x, y))) { + if (left < 0) left = x; + right = x; + break; + } + } + } + return { left, right }; + } + + // The play pointer as View::drawPlayPointer() draws it: a line of the + // background colour between two of the foreground, the whole height + // of the pane. -1 if there is none + static int pointerX(const QImage &image) { + auto dark = [](QRgb p) { return qGray(p) < 80; }; + auto light = [](QRgb p) { return qGray(p) > 200; }; + int h = image.height(); + for (int x = 1; x + 1 < image.width(); ++x) { + int n = 0; + for (int y = 1; y + 1 < h; ++y) { + if (dark(image.pixel(x - 1, y)) && light(image.pixel(x, y)) && + dark(image.pixel(x + 1, y))) ++n; + } + if (n > (h * 9) / 10) return x; + } + return -1; + } + + QMenu *menuTitled(QString title) { + for (QAction *a : m_window->menuBar()->actions()) { + if (a->menu() && a->text() == title) return a->menu(); + } + return nullptr; + } + + // The Undo item of the Edit menu, as the user reads it + QString undoText() { + QMenu *edit = menuTitled(tr("&Edit")); + if (!edit) return "(no Edit menu)"; + for (QAction *a : edit->actions()) { + if (a->shortcut() == QKeySequence(tr("Ctrl+Z"))) return a->text(); + } + return "(no Undo item)"; + } + + void press(QKeySequence keys) { + QVERIFY(QTest::qWaitForWindowActive(m_window)); + QTest::keySequence(m_window, keys); + QCoreApplication::processEvents(); + } + + // The pointer over pane 0 with no button held. Where it was last over + // a note decides what the Edit tool does to that note: near its top a + // drag moves it, near its bottom a click splits it, and with the + // pointer never over a note a drag moves it. Sent to the pane itself: + // QTest::mouseMove() with no button held moves the platform's cursor + // instead, which the offscreen platform need not pass on + void hover(QPoint pos) { + sv::Pane *pane = pane0(); + QMouseEvent move(QEvent::MouseMove, QPointF(pos), + QPointF(pane->mapToGlobal(pos)), + Qt::NoButton, Qt::NoButton, Qt::NoModifier); + QApplication::sendEvent(pane, &move); + } + + // A drag across pane 0 with the left button, in ten steps + void drag(QPoint from, QPoint to) { + sv::Pane *pane = pane0(); + QTest::mousePress(pane, Qt::LeftButton, Qt::NoModifier, from); + for (int i = 1; i <= 10; ++i) { + QTest::mouseMove(pane, from + (to - from) * i / 10); + QTest::qWait(10); + } + QTest::mouseRelease(pane, Qt::LeftButton, Qt::NoModifier, to); + } + + Coverage::Ranges coverage() { + return m_window->takes()->getCoverage().getRanges(); + } + + // Everything in pane 0 a gesture could change: the events of every + // layer's model, how each layer scales itself, the zoom, the + // selection and the undo history. The centre frame is left out: + // dragging with the navigate tool is meant to scroll + struct PaneState { + std::map events; + std::map> extents; + std::vector layers; + sv::ZoomLevel zoom; + sv::MultiSelection::SelectionList selections; + QString undo; + // Those of the layers that are pitch candidates of a + // re-analysis. Not compared: layers has them + std::set candidates; + + bool operator==(const PaneState &s) const { + return events == s.events && extents == s.extents && + layers == s.layers && zoom == s.zoom && + selections == s.selections && undo == s.undo; + } + }; + + PaneState paneState() { + PaneState s; + sv::Pane *pane = pane0(); + for (int i = 0; i < pane->getLayerCount(); ++i) { + sv::Layer *layer = pane->getLayer(i); + QString key = QString("%1 %2").arg(i).arg(layer->objectName()); + s.layers.push_back(key); + if (layer->getLayerPresentationName() == "candidate") { + s.candidates.insert(key); + } + sv::ModelId id = layer->getModel(); + if (auto m = sv::ModelById::getAs(id)) { + s.events[key] = m->getAllEvents(); + } else if (auto m = sv::ModelById::getAs(id)) { + s.events[key] = m->getAllEvents(); + } else if (auto m = sv::ModelById::getAs(id)) { + s.events[key] = m->getAllEvents(); + } + double min = 0.0, max = 0.0; + if (layer->getDisplayExtents(min, max)) { + s.extents[key] = { min, max }; + } + } + s.zoom = pane->getZoomLevel(); + s.selections = m_window->selections(); + s.undo = undoText(); + return s; + } + + static QString describe(const PaneState &a, const PaneState &b) { + QStringList what; + if (a.layers != b.layers) what << "the layers of the pane"; + for (const auto &e : a.events) { + auto i = b.events.find(e.first); + if (i == b.events.end() || i->second != e.second) { + what << QString("the events of \"%1\"").arg(e.first); + } + } + for (const auto &e : a.extents) { + auto i = b.extents.find(e.first); + if (i == b.extents.end() || i->second != e.second) { + what << QString("the scale of \"%1\"").arg(e.first); + } + } + if (!(a.zoom == b.zoom)) what << "the zoom"; + if (a.selections != b.selections) what << "the selection"; + if (a.undo != b.undo) what << "the undo history (" + b.undo + ")"; + return what.join(", "); + } + + // Not a slot: QtTest would run it as a test + void dismissDialog() { + QWidget *modal = QApplication::activeModalWidget(); + if (!modal) return; + if (m_answerDialog && m_answerDialog(modal)) return; + QString description = modal->windowTitle(); + if (auto box = qobject_cast(modal)) { + description += ": " + box->text(); + m_dialogs.push_back(description); + // The last button is Cancel (or No) in every question Tony + // asks; see TestRecordWorkflow::dismissDialog() + QList buttons = box->buttons(); + if (!buttons.isEmpty()) { + buttons.last()->click(); + return; + } + } else { + m_dialogs.push_back(description); + } + if (auto dialog = qobject_cast(modal)) { + dialog->reject(); + } else { + modal->close(); + } + } + + // Press the button of this role in a message box, ticking its check + // box first if asked to; the text of the box goes into asked + static std::function + answerWith(QMessageBox::StandardButton button, bool tick, + QStringList *asked) { + return [=](QWidget *modal) { + auto box = qobject_cast(modal); + if (!box || !box->button(button)) return false; + // The text, not the title: macOS shows no title on a message + // box, and Qt keeps none there + if (asked) asked->push_back(box->text()); + if (tick && box->checkBox()) box->checkBox()->setChecked(true); + box->button(button)->click(); + return true; + }; + } + +private slots: + void initTestCase() { + QVERIFY(m_dir.isValid()); + m_shotDir = qEnvironmentVariable("TONY_TEST_SHOT_DIR"); + + QSettings().clear(); + + // As TestRecordWorkflow: no network question, .ton is a session, + // takes go into the temporary directory + QSettings settings; + settings.beginGroup("Preferences"); + settings.setValue(QString("network-permission-%1").arg(TONY_VERSION), + false); + settings.endGroup(); + + sv::InteractiveFileFinder::getInstance() + ->setApplicationSessionExtension("ton"); + + sv::RecordDirectory::setRecordContainerDirectory + (m_dir.filePath("recorded")); + + connect(&m_watchdog, &QTimer::timeout, + this, [this]() { dismissDialog(); }); + m_watchdog.start(50); + } + + void init() { + m_dialogs.clear(); + m_answerDialog = nullptr; + QSettings settings; + settings.beginGroup("MainWindow"); + settings.setValue("playrefwhilerecording", false); + settings.setValue("preroll", false); + settings.setValue("recordintoselection", false); + settings.remove("prerollseconds"); + settings.endGroup(); + settings.beginGroup("Analyser"); + settings.remove(""); + settings.endGroup(); + SingingTakes::setOverwriteConfirmationWanted(true); + } + + void cleanup() { + m_answerDialog = nullptr; + if (m_window) { + if (m_window->recordTarget()->isRecording()) { + m_window->doRecord(); + } + QTRY_VERIFY_WITH_TIMEOUT + (!sv::ModelTransformerFactory::getInstance() + ->haveRunningTransformers(), 30000); + m_window->doCloseSession(); + delete m_window; + m_window = nullptr; + } + QVERIFY2(m_dialogs.isEmpty(), + qPrintable("unexpected dialog: " + m_dialogs.join(" | "))); + } + + void cleanupTestCase() { + m_watchdog.stop(); + sv::RecordDirectory::setRecordContainerDirectory(""); + } + + // The pane draws again only where new dots go, not all of itself for + // each batch of them: at a phone's pixel ratio a whole pane costs + // several times as much, 25 times a second + void live_dots_draw_only_where_they_are() { + FakeAudioIO::Config config; + config.input = tone(highHz, 4.0); + makeWindow(config); + if (QTest::currentTestFailed()) return; + openReference(writeWav(tone(lowHz, 12.0))); + if (QTest::currentTestFailed()) return; + + // A take that stays on its page, and away from the start of the + // song, where the recording's own frames would be + const sv::sv_frame_t P = frames(6.0); + showSeconds(5.0, 11.0); + m_window->seekTo(P); + startTake(); + if (QTest::currentTestFailed()) return; + QTest::qWait(500); + + // Seen before the paint event reaches anything else + struct PaintWatch : public QObject { + int whole = 0, parts = 0; + bool eventFilter(QObject *object, QEvent *e) override { + if (e->type() == QEvent::Paint) { + auto *widget = static_cast(object); + QRect r = static_cast(e)->rect(); + if (r.width() >= widget->width() - 2) ++whole; + else ++parts; + } + return false; + } + } watch; + sv::Pane *pane = pane0(); + sv::sv_frame_t pageStart = pane->getStartFrame(); + auto model = sv::ModelById::getAs + (m_window->realtimeModelId()); + QVERIFY(model); + int dotsBefore = model->getEventCount(); + pane->installEventFilter(&watch); + QTest::qWait(1500); + pane->removeEventFilter(&watch); + + QCOMPARE(pane->getStartFrame(), pageStart); + QVERIFY2(model->getEventCount() > dotsBefore + 100, + "hardly any dots came"); + QVERIFY2(watch.parts > 10, "the pane was hardly drawn at all"); + // (a dot that widens the model's pitch range has it drawn whole) + QVERIFY2(watch.whole <= 5, + qPrintable(QString("the pane was drawn whole %1 times in " + "1.5 s, and in part %2 times") + .arg(watch.whole).arg(watch.parts))); + stopTake(); + } + + // Checklist: live dots appear under the playback cursor, not behind + // it; during a take at P > 0 the cursor starts at P, the pane follows + // it, and cursor and dots are in the same place. Singing exactly in + // time with the reference, through a device with latency + void live_dots_under_the_cursor() { + FakeAudioIO::Config config; + config.recordLatency = 512; + config.playbackLatency = 1024; + config.inputDelay = 1536; + config.inputFollowsPlayback = true; + config.input = tone(highHz, 6.0); + makeWindow(config); + if (QTest::currentTestFailed()) return; + m_window->setPlayReferenceWhileRecording(true); + openReference(writeWav(tone(lowHz, 12.0))); + if (QTest::currentTestFailed()) return; + + // A page of about two seconds, so that the take runs off it + const sv::sv_frame_t P = frames(4.0); + showSeconds(3.5, 5.5); + m_window->seekTo(P); + startTake(); + if (QTest::currentTestFailed()) return; + + // The most the newest dot may trail the cursor by: the latency of + // the device, YIN's window and the delivery of the dots, with room + // to spare. A dot a whole latency out, or one at the take's own + // frame rather than the song's, is far more than this + const sv::sv_frame_t lag = frames(0.3); + sv::Pane *pane = pane0(); + QElapsedTimer timer; + timer.start(); + + // A 20 ms timer of the test's own, ticking as svgui's pointer + // timer does: how late this machine runs one (three times and + // more on CI's macOS) is how late the pointer may be + int ticks = 0; + QTimer ticker; + ticker.setInterval(20); + connect(&ticker, &QTimer::timeout, this, [&ticks]() { ++ticks; }); + ticker.start(); + int pages = 0, looked = 0; + int firstPageStart = -1; + bool sawFirstDots = false; + sv::sv_frame_t worstLag = 0; + QString worst; + + while (timer.elapsed() < 3000) { + QTest::qWait(150); + sv::sv_frame_t cursorBefore = m_window->playbackFrame(); + QImage image = grabPane(); + sv::sv_frame_t cursor = m_window->playbackFrame(); + int x = pointerX(image); + QVERIFY2(x >= 0, + qPrintable(QString("%1 ms into the take there is no play " + "pointer on the pane: it has not " + "followed the cursor (frame %2)") + .arg(timer.elapsed()).arg(cursor))); + // The view moves its pointer on a timer of its own (20 ms in + // the svgui fork), so it may be one tick behind, and a tick is + // as late as this machine's timers make it + double late = (ticks > 0 ? + std::max(1.0, double(timer.elapsed()) / + (20.0 * ticks)) : 1.0); + int earliest = pane->getXForFrame + (cursorBefore - frames(0.05 * late)); + int latest = pane->getXForFrame(cursor); + QVERIFY2(x >= earliest - 2 && x <= latest + 2, + qPrintable(QString("the pointer is drawn at x = %1, the " + "playback frame is between x = %2 " + "and %3 (timers %4 times late)") + .arg(x).arg(earliest).arg(latest) + .arg(late, 0, 'f', 1))); + + int start = int(pane->getStartFrame()); + if (firstPageStart < 0) firstPageStart = start; + else if (start != firstPageStart) ++pages; + + auto dots = extentAbove(image, isSinging); + if (dots.second < 0) continue; + ++looked; + + // Never ahead of the cursor; how far behind it, and the + // newest dot the model has, for the worst moment + sv::sv_frame_t newest = pane->getFrameForX(dots.second); + QVERIFY2(newest <= cursor + pane->getZoomLevel().level * 3, + qPrintable(QString("%1 ms into the take a dot is drawn at " + "frame %2, ahead of the cursor at %3") + .arg(timer.elapsed()).arg(newest) + .arg(cursor))); + if (cursor - newest > worstLag) { + worstLag = cursor - newest; + sv::sv_frame_t inModel = -1; + if (auto model = sv::ModelById::getAs + (m_window->realtimeModelId())) { + auto all = model->getAllEvents(); + if (!all.empty()) inModel = all.back().getFrame(); + } + worst = QString("%1 ms into the take the newest dot drawn is " + "%2 ms behind the cursor; the newest in the " + "model is %3 ms behind it") + .arg(timer.elapsed()) + .arg(double(cursor - newest) * 1000.0 / rate, 0, 'f', 0) + .arg(double(cursor - inModel) * 1000.0 / rate, 0, 'f', 0); + } + + // The first dots of the take are at P, where the singing began + if (!sawFirstDots && pane->getStartFrame() < P) { + sawFirstDots = true; + sv::sv_frame_t oldest = pane->getFrameForX(dots.first); + QVERIFY2(std::llabs(oldest - P) < frames(0.1), + qPrintable(QString("the first dot of a take at frame " + "%1 is at frame %2") + .arg(P).arg(oldest))); + saveShot("first-dots", image); + qInfo("newest dot %.0f ms behind the cursor", + double(cursor - newest) * 1000.0 / rate); + } + } + QVERIFY2(looked >= 5, "hardly any live dots were drawn"); + QVERIFY(sawFirstDots); + QVERIFY2(pages >= 1, "the take never ran off the first page, so " + "whether the pane follows it was not seen"); + saveWindowShot("during"); + qInfo("%s", qPrintable(worst)); + QVERIFY2(worstLag <= lag, qPrintable(worst)); + + // The dots stay until the take's pitch track is there, which is + // drawn over the same place in the same orange: at no moment is + // what was sung missing from the pane + const sv::sv_frame_t end = m_window->playbackFrame(); + m_window->doRecord(); + QVERIFY(!m_window->recordTarget()->isRecording()); + showSeconds(4.0, 4.0 + double(end - P) / rate); + int sungFrom = pane->getXForFrame(P + frames(0.2)); + int sungTo = pane->getXForFrame(end - frames(0.5)); + QElapsedTimer sinceStop; + sinceStop.start(); + while (!analysed(m_window->analyser2())) { + QVERIFY2(countAbove(grabPane(), sungFrom, sungTo, isSinging) > 0, + qPrintable(QString("%1 ms after Stop nothing of what was " + "sung is drawn") + .arg(sinceStop.elapsed()))); + QVERIFY(sinceStop.elapsed() < 30000); + QTest::qWait(20); + } + QImage after = grabPane(); + QVERIFY(countAbove(after, sungFrom, sungTo, isSinging) > 0); + saveShot("analysed", after); + + // The status bar is still once nothing is recorded or played + QTest::qWait(100); + QString status = m_window->statusText(); + QTest::qWait(400); + QCOMPARE(m_window->statusText(), status); + } + + // Checklist: recording over singing that is there, that take's own + // pitch track and notes are out of sight for the take, so only the + // dots are drawn, and they are back when the take stops + void own_pitch_out_of_sight_while_recording_over_it() { + FakeAudioIO::Config config; + config.input = tone(highHz, 8.0); + makeWindow(config); + if (QTest::currentTestFailed()) return; + openReference(writeWav(tone(lowHz, 4.0))); + if (QTest::currentTestFailed()) return; + + take(2200); + if (QTest::currentTestFailed()) return; + showSeconds(0.0, 3.0); + sv::Pane *pane = pane0(); + + const sv::sv_frame_t P = frames(0.4); + int ahead0 = pane->getXForFrame(frames(1.3)); + int ahead1 = pane->getXForFrame(frames(1.9)); + QImage before = grabPane(); + QVERIFY2(countAbove(before, ahead0, ahead1, isSinging) > 0, + "the first take's pitch track is not drawn"); + QVERIFY2(countAbove(before, 0, before.width(), isPurple) > 0, + "the first take's notes are not drawn"); + saveShot("before", before); + + m_window->seekTo(P); + m_window->setRecordOverAnswer(true); + startTake(); + if (QTest::currentTestFailed()) return; + QTest::qWait(500); + + // Ahead of the cursor, where the old singing is and nothing new + // has been sung yet, nothing of the take is drawn. (Record puts + // the view back on P, so where things are is asked again) + QImage during = grabPane(); + ahead0 = pane->getXForFrame(frames(1.3)); + ahead1 = pane->getXForFrame(frames(1.9)); + int x = pointerX(during); + QVERIFY2(x >= 0 && x < ahead0, + qPrintable(QString("the pointer is at x = %1, the old " + "singing to look at from x = %2") + .arg(x).arg(ahead0))); + QVERIFY2(countAbove(during, ahead0, ahead1, isSinging) == 0, + "the take's own pitch track is drawn during a take over it"); + QVERIFY2(countAbove(during, 0, during.width(), isPurple) == 0, + "the take's own notes are drawn during a take over it"); + QVERIFY2(countAbove(during, pane->getXForFrame(P), x, isSinging) > 0, + "no live dots behind the cursor"); + saveShot("during", during); + + stopTake(); + if (QTest::currentTestFailed()) return; + QImage after = grabPane(); + ahead0 = pane->getXForFrame(frames(1.3)); + ahead1 = pane->getXForFrame(frames(1.9)); + QVERIFY2(countAbove(after, ahead0, ahead1, isSinging) > 0, + "the rest of the old pitch track did not come back"); + QVERIFY2(countAbove(after, 0, after.width(), isPurple) > 0, + "the notes did not come back"); + saveShot("after", after); + } + + // Checklist: the coverage strip cannot be touched. Clicking, double- + // clicking and dragging on it with either tool creates, moves, selects + // and edits nothing of its own and does not change the pane's scale. + // The Edit tool acts on the take's note at the time in the song under + // the pointer, at any height in the pane -- the band too, and that is + // meant -- so an edit of that note is what it may do. A drag moves + // the note and re-analyses the pitch under it, which may leave more + // than one entry in the undo history; undoing them takes it all back + // exactly, but for the pitch candidates, which are the analyser's + void strip_ignores_the_mouse() { + FakeAudioIO::Config config; + config.input = tone(highHz, 6.0); + makeWindow(config); + if (QTest::currentTestFailed()) return; + openReference(writeWav(tone(lowHz, 4.0))); + if (QTest::currentTestFailed()) return; + + // One recording, so that the band has an end to drag off + m_window->seekTo(frames(0.5)); + take(1500); + if (QTest::currentTestFailed()) return; + m_window->clearSelections(); + QTRY_VERIFY_WITH_TIMEOUT + (!sv::ModelTransformerFactory::getInstance() + ->haveRunningTransformers(), 30000); + showSeconds(0.0, 3.0); + + sv::Pane *pane = pane0(); + auto range = coverage()[0]; + QImage image = grabPaneRedrawn(); + const int y = image.height() - 3; + const int inBand = pane->getXForFrame(range.start + frames(0.3)); + const int atEnd = pane->getXForFrame(range.end) - 1; + const int beyond = pane->getXForFrame(range.end + frames(0.4)); + QVERIFY2(isOrange(image.pixel(inBand, y)) && + isOrange(image.pixel(atEnd - 2, y)) && + !isOrange(image.pixel(beyond, y)), + "the strip is not where the gestures are going to be made"); + saveShot("strip", image); + + struct Gesture { QString name; std::function act; }; + std::vector gestures { + { "a click in the band", [&]() { + QTest::mouseClick(pane, Qt::LeftButton, Qt::NoModifier, + QPoint(inBand, y)); } }, + { "a double-click in the band", [&]() { + QTest::mouseDClick(pane, Qt::LeftButton, Qt::NoModifier, + QPoint(inBand, y)); } }, + { "a click at its end", [&]() { + QTest::mouseClick(pane, Qt::LeftButton, Qt::NoModifier, + QPoint(atEnd, y)); } }, + { "a drag along it", [&]() { + drag(QPoint(inBand, y), QPoint(atEnd, y)); } }, + { "a drag off its end", [&]() { + drag(QPoint(atEnd, y), QPoint(beyond, y)); } }, + { "a drag onto it", [&]() { + drag(QPoint(beyond, y), QPoint(inBand, y)); } }, + }; + + // A drag of the take's note also re-analyses the pitch under it. + // That puts pitch candidates into the pane, which stay until the + // next re-analysis whatever is undone: they are the analyser's + // own layers. Everything but them, the other layers numbered as + // if they were not there + auto withoutCandidates = [&](const PaneState &s) { + PaneState r; + int n = 0; + for (const QString &key : s.layers) { + if (s.candidates.count(key)) continue; + QString renumbered = + QString("%1 %2").arg(n++).arg(key.section(' ', 1)); + r.layers.push_back(renumbered); + auto e = s.events.find(key); + if (e != s.events.end()) r.events[renumbered] = e->second; + auto x = s.extents.find(key); + if (x != s.extents.end()) r.extents[renumbered] = x->second; + } + r.zoom = s.zoom; + r.selections = s.selections; + r.undo = s.undo; + return r; + }; + + // ... and but what an edit of the take's note may change: the + // note, the undo history, and the take's pitch track, into which + // the drag may put one of the candidates + auto apartFromNoteEdit = [&](const PaneState &s) { + QString take = m_window->takes()->getActiveName(); + QStringList edited { + TakeLayers::nameFor(take, TakeLayers::Notes), + TakeLayers::nameFor(take, TakeLayers::Pitch) }; + PaneState r = withoutCandidates(s); + for (auto i = r.events.begin(); i != r.events.end(); ) { + if (edited.contains(i->first.section(' ', 1))) { + i = r.events.erase(i); + } else { + ++i; + } + } + r.undo = ""; + return r; + }; + + int noteEdits = 0; + for (QString tool : { QString("navigate"), QString("edit") }) { + press(QKeySequence(tool == "navigate" ? "1" : "2")); + for (const Gesture &g : gestures) { + sv::sv_frame_t centre = pane->getCentreFrame(); + const PaneState before = paneState(); + g.act(); + QTest::qWait(300); // outlast the double-click interval + // and any re-analysis the gesture started: its candidates + // arrive when it finishes (Analyser::layersCreated()) + QTRY_VERIFY_WITH_TIMEOUT + (!sv::ModelTransformerFactory::getInstance() + ->haveRunningTransformers(), 30000); + // The navigate tool scrolls when dragged, as anywhere + pane->setCentreFrame(centre); + PaneState after = paneState(); + if (tool == "navigate") { + QVERIFY2(after == before, + qPrintable(QString("with the navigate tool, %1 " + "changed %2") + .arg(g.name) + .arg(describe(before, after)))); + continue; + } + QVERIFY2(apartFromNoteEdit(after) == apartFromNoteEdit(before), + qPrintable(QString("with the edit tool, %1 changed " + "%2") + .arg(g.name) + .arg(describe(apartFromNoteEdit(before), + apartFromNoteEdit(after))))); + if (after.undo != before.undo) { + ++noteEdits; + for (int i = 0; i < 20 && undoText() != before.undo; ++i) { + press(QKeySequence(tr("Ctrl+Z"))); + } + PaneState undone = paneState(); + QVERIFY2(withoutCandidates(undone) == + withoutCandidates(before), + qPrintable(QString("undoing what %1 did with " + "the edit tool left %2") + .arg(g.name) + .arg(describe(withoutCandidates(before), + withoutCandidates(undone))))); + } + } + } + qInfo("%d of the gestures with the edit tool edited the take's note", + noteEdits); + press(QKeySequence("1")); + } + + // The Edit tool gives a take's note it has changed the pitch of the + // take's own pitch track there, not that of the first pitch track in + // the pane, which is the reference's: a split gives each half the + // pitch sung in it, a drag the note where it is let go + void edited_take_notes_keep_the_take_pitch() { + FakeAudioIO::Config config; + config.input = tone(highHz, 6.0); + makeWindow(config); + if (QTest::currentTestFailed()) return; + openReference(writeWav(tone(lowHz, 4.0))); + if (QTest::currentTestFailed()) return; + + m_window->seekTo(frames(0.5)); + take(1500); + if (QTest::currentTestFailed()) return; + m_window->clearSelections(); + QTRY_VERIFY_WITH_TIMEOUT + (!sv::ModelTransformerFactory::getInstance() + ->haveRunningTransformers(), 30000); + showSeconds(0.0, 3.0); + + sv::Pane *pane = pane0(); + sv::Layer *layer = m_window->analyser2()->getLayer(Analyser::Notes); + QVERIFY(layer); + auto notes = [&]() { + auto model = sv::ModelById::getAs(layer->getModel()); + return model ? model->getAllEvents() : sv::EventVector(); + }; + auto verifySung = [&](QString what) { + sv::EventVector events = notes(); + QVERIFY2(!events.empty(), + qPrintable(what + " left the take no notes")); + for (const sv::Event &e : events) { + double cents = 1200.0 * std::log2(e.getValue() / highHz); + QVERIFY2(std::fabs(cents) < 50.0, + qPrintable(QString("after %1 a note of the take is " + "at %2 Hz, not at the %3 Hz sung " + "(the reference is at %4 Hz)") + .arg(what).arg(e.getValue()) + .arg(highHz).arg(lowHz))); + } + }; + + const sv::EventVector original = notes(); + QCOMPARE(int(original.size()), 1); + const sv::Event note = original[0]; + const int x = pane->getXForFrame(note.getFrame() + frames(0.3)); + const int noteY = pane->getEffectiveVerticalExtentsForLayer(layer) + .getCoordForValueRounded(pane, note.getValue()); + + press(QKeySequence("2")); + + hover(QPoint(x, noteY + 4)); + QTest::mouseClick(pane, Qt::LeftButton, Qt::NoModifier, + QPoint(x, noteY + 4)); + QCOMPARE(int(notes().size()), 2); + verifySung("a split"); + if (QTest::currentTestFailed()) return; + press(QKeySequence(tr("Ctrl+Z"))); + QVERIFY2(notes() == original, "undo did not take the split back"); + + hover(QPoint(x, noteY - 4)); + drag(QPoint(x, noteY - 4), QPoint(x + 60, noteY - 4)); + // The drag re-analyses the pitch under the note + QTRY_VERIFY_WITH_TIMEOUT + (!sv::ModelTransformerFactory::getInstance() + ->haveRunningTransformers(), 30000); + QVERIFY2(notes() != original, "the drag did not move the note"); + verifySung("a drag"); + press(QKeySequence("1")); + } + + // Checklist: the band is readable over waveform and dots at every + // zoom: where there is singing the band is drawn over whatever else + // is there, band high, and nowhere else + void strip_band_at_every_zoom() { + FakeAudioIO::Config config; + config.input = tone(highHz, 6.0); + makeWindow(config); + if (QTest::currentTestFailed()) return; + openReference(writeWav(tone(lowHz, 6.0))); + if (QTest::currentTestFailed()) return; + + m_window->seekTo(frames(1.0)); + take(1500); + if (QTest::currentTestFailed()) return; + auto ranges = coverage(); + QCOMPARE(int(ranges.size()), 1); + const auto range = ranges[0]; + + sv::Pane *pane = pane0(); + for (int perPixel : { 16, 128, 1024, 8192 }) { + // Centred on the end of the singing: some of the band, and + // some of the pane with nothing recorded, at every zoom + pane->setZoomLevel(sv::ZoomLevel(sv::ZoomLevel::FramesPerPixel, + perPixel)); + pane->setCentreFrame(range.end); + QImage image = grabPaneRedrawn(); + saveShot(QString("zoom-%1").arg(perPixel), image); + const int h = image.height(); + + // Not under the vertical scale at the left, nor where the play + // pointer is drawn over everything + int pointer = pointerX(image); + int inRange = 0, outside = 0; + for (int x = pane->getVerticalScaleWidth() + 1; + x < image.width(); ++x) { + if (pointer >= 0 && std::abs(x - pointer) <= 2) continue; + sv::sv_frame_t f0 = pane->getFrameForX(x); + sv::sv_frame_t f1 = pane->getFrameForX(x + 1); + if (f0 < 0) continue; + if (f0 >= range.start && f1 <= range.end) { + ++inRange; + for (int yy = h - 6; yy < h; ++yy) { + QVERIFY2(isOrange(image.pixel(x, yy)), + qPrintable(QString("at %1 frames per pixel " + "the band has a hole at " + "x = %2, y = %3") + .arg(perPixel).arg(x).arg(yy))); + } + QVERIFY2(!isOrange(image.pixel(x, h - 9)), + qPrintable(QString("at %1 frames per pixel the " + "band is more than a band " + "high at x = %2") + .arg(perPixel).arg(x))); + } else if (f1 <= range.start || f0 >= range.end) { + ++outside; + QVERIFY2(!isOrange(image.pixel(x, h - 3)), + qPrintable(QString("at %1 frames per pixel the " + "band is drawn at x = %2, " + "where nothing was recorded") + .arg(perPixel).arg(x))); + } + } + QVERIFY2(inRange >= 3 && outside >= 10, + qPrintable(QString("at %1 frames per pixel %2 columns " + "of the band and %3 without it are in " + "view") + .arg(perPixel).arg(inRange).arg(outside))); + } + saveWindowShot("window"); + } + + // The band after a second recording, in a gap of the first: the take's + // audio is swapped for a file holding both, and the take's waveform + // layer is made again. The band has to stay on top of it, then and + // after everything else that swaps the audio or makes the layer again: + // undo, redo, and opening the session + void strip_on_top_after_another_recording() { + FakeAudioIO::Config config; + config.input = tone(highHz, 6.0); + makeWindow(config); + if (QTest::currentTestFailed()) return; + openReference(writeWav(tone(lowHz, 4.0))); + if (QTest::currentTestFailed()) return; + + const sv::sv_frame_t inFirst = frames(0.4); + const sv::sv_frame_t inSecond = frames(2.2); + + // Whether the band is drawn at a frame: a few columns either side + // of it along the bottom row, all orange + auto band = [&](sv::sv_frame_t frame, QString shot) { + showSeconds(0.0, 3.0); + QImage image = grabPaneRedrawn(); + saveShot(shot, image); + int x = pane0()->getXForFrame(frame); + for (int dx = -3; dx <= 3; ++dx) { + if (!isOrange(image.pixel(x + dx, image.height() - 3))) { + return false; + } + } + return true; + }; + + take(1000); + if (QTest::currentTestFailed()) return; + QVERIFY(band(inFirst, "first")); + + m_window->seekTo(frames(2.0)); + take(700); + if (QTest::currentTestFailed()) return; + QVERIFY2(band(inFirst, "second") && band(inSecond, "second"), + "after a second recording the band is not drawn"); + + // Raised, and still a picture, not sound: out of the play source + auto stripIsSilent = [this]() { + return !m_window->playSource()->getModels().count + (m_window->coverageStrip()->getModelId()); + }; + QVERIFY2(stripIsSilent(), "the strip's model is in the play source"); + + QCOMPARE(undoText(), tr("&Undo %1").arg(tr("Record Singing"))); + press(QKeySequence(tr("Ctrl+Z"))); + QTRY_COMPARE(int(coverage().size()), 1); + QVERIFY2(band(inFirst, "undone") && !band(inSecond, "undone"), + "after an undo the band is not what is left of the take"); + + press(QKeySequence(tr("Ctrl+Shift+Z"))); + QTRY_COMPARE(int(coverage().size()), 2); + QVERIFY2(band(inFirst, "redone") && band(inSecond, "redone"), + "after a redo the band is not drawn"); + + QString session = m_dir.filePath + (QString("session-%1.ton").arg(++m_fileCounter)); + QVERIFY(m_window->saveSessionFile(session)); + m_window->doCloseSession(); + QCOMPARE(m_window->openPath(session, MainWindow::ReplaceSession), + MainWindow::FileOpenSucceeded); + QTRY_VERIFY_WITH_TIMEOUT(analysed(m_window->analyser()), 30000); + QTRY_VERIFY(m_window->takes()->haveTake() && + int(coverage().size()) == 2); + QVERIFY2(band(inFirst, "reopened") && band(inSecond, "reopened"), + "in the session opened again the band is not drawn"); + QVERIFY2(stripIsSilent(), "the strip's model is in the play source"); + } + + // Checklist: Erase and Select Recording are greyed out with no take, + // no selection, while recording and during the analysis after Stop; + // the Takes menu and combo during a take -- and all of them come + // back by themselves, with nothing but the take's own events to + // update them + void menus_follow_the_take_by_themselves() { + FakeAudioIO::Config config; + config.input = tone(highHz, 6.0); + makeWindow(config); + if (QTest::currentTestFailed()) return; + openReference(writeWav(tone(lowHz, 4.0))); + if (QTest::currentTestFailed()) return; + + QAction *erase = m_window->eraseSingingAction(); + QAction *select = m_window->selectRecordingAction(); + QMenu *takes = m_window->takesMenu(); + QVERIFY(takes); + QVERIFY(menuTitled(tr("&Edit"))->actions().contains(erase)); + QVERIFY(menuTitled(tr("&Edit"))->actions().contains(select)); + + auto takeItemsEnabled = [&]() { + int n = 0; + for (QAction *a : takes->actions()) { + if (!a->isSeparator() && a->isEnabled()) ++n; + } + return n; + }; + + // No take + m_window->selectRange(0, frames(0.5)); + QVERIFY(!erase->isEnabled()); + QVERIFY(!select->isEnabled()); + m_window->clearSelections(); + + // The merge of the take's analysis is held until the menus have + // been looked at: pYIN may analyse the range before Stop returns + m_window->holdRangedMerges(true); + + startTake(); + if (QTest::currentTestFailed()) return; + QTest::qWait(200); + QVERIFY2(!m_window->takeCombo()->isEnabled(), + "the take combo can be used during a take"); + QVERIFY2(takeItemsEnabled() == 0, + "items of the Takes menu can be used during a take"); + m_window->selectRange(0, frames(0.3)); + QVERIFY(!erase->isEnabled()); + QVERIFY(!select->isEnabled()); + QTest::qWait(600); + + // Stop, and the moment after it: the recorded range is being + // analysed, and erasing would throw that away + m_window->doRecord(); + QVERIFY(!m_window->recordTarget()->isRecording()); + QVERIFY(m_window->analysingRange()); + QVERIFY2(!erase->isEnabled(), + "Erase can be used while the take is being analysed"); + + // ... and everything back, by itself + m_window->holdRangedMerges(false); + QTRY_VERIFY_WITH_TIMEOUT(analysed(m_window->analyser2()), 30000); + QTRY_VERIFY2(erase->isEnabled(), + "Erase did not come back after the analysis"); + QVERIFY(select->isEnabled()); + QVERIFY(m_window->takeCombo()->isEnabled()); + QVERIFY(takeItemsEnabled() > 0); + + // No selection, nothing to erase + m_window->clearSelections(); + QVERIFY(!erase->isEnabled()); + QVERIFY(select->isEnabled()); + } + + // Checklist: three recordings, Ctrl+Z three times, each takes back + // exactly one; the menu says "Record Singing" / "Erase Singing" and + // never anything about a layer or pane. With the keys, as the user + // does it + void ctrl_z_takes_back_one_recording_at_a_time() { + FakeAudioIO::Config config; + config.input = tone(highHz, 8.0); + makeWindow(config); + if (QTest::currentTestFailed()) return; + openReference(writeWav(tone(lowHz, 5.0))); + if (QTest::currentTestFailed()) return; + QCOMPARE(undoText(), tr("Nothing to undo")); + + for (double at : { 0.0, 1.5, 3.0 }) { + m_window->seekTo(frames(at)); + take(600); + if (QTest::currentTestFailed()) return; + } + QCOMPARE(int(coverage().size()), 3); + + // Every name the user can see in the Edit menu and in the undo + // and redo menus of the toolbar + auto verifyNames = [this]() { + QStringList seen; + for (QAction *a : menuTitled(tr("&Edit"))->actions()) { + seen << a->text(); + } + for (QToolBar *bar : m_window->findChildren()) { + for (QAction *a : bar->actions()) { + if (!a->menu()) continue; + for (QAction *b : a->menu()->actions()) seen << b->text(); + } + } + for (QString text : seen) { + QVERIFY2(!text.contains("Layer", Qt::CaseInsensitive) && + !text.contains("Pane", Qt::CaseInsensitive), + qPrintable("the undo history shows \"" + text + "\"")); + } + }; + + for (int left : { 2, 1, 0 }) { + QCOMPARE(undoText(), tr("&Undo %1").arg(tr("Record Singing"))); + verifyNames(); + if (QTest::currentTestFailed()) return; + press(QKeySequence(tr("Ctrl+Z"))); + QTRY_VERIFY(m_window->takes()->haveTake() ? + int(coverage().size()) == left : left == 0); + } + QCOMPARE(undoText(), tr("Nothing to undo")); + + // ... and all three back + for (int back : { 1, 2, 3 }) { + press(QKeySequence(tr("Ctrl+Shift+Z"))); + QTRY_VERIFY(m_window->takes()->haveTake() && + int(coverage().size()) == back); + } + QVERIFY(!m_window->analysingRange()); + QCOMPARE(undoText(), tr("&Undo %1").arg(tr("Record Singing"))); + + // Erase the middle of the second, with its shortcut. A selection + // has the reference's pitch analysed again in it, and that is an + // entry of the undo history of its own; it is waited for here, so + // that it is not what the next Ctrl+Z takes back + auto second = coverage()[1]; + m_window->selectRange(second.start + frames(0.1), + second.start + frames(0.3)); + QTRY_VERIFY_WITH_TIMEOUT + (!sv::ModelTransformerFactory::getInstance() + ->haveRunningTransformers(), 30000); + QTest::qWait(200); + qInfo("after making a selection the Edit menu says \"%s\"", + qPrintable(undoText())); + QTRY_VERIFY(m_window->eraseSingingAction()->isEnabled()); + press(QKeySequence(tr("Ctrl+D"))); + QTRY_COMPARE(int(coverage().size()), 4); + QCOMPARE(undoText(), tr("&Undo %1").arg(tr("Erase Singing"))); + verifyNames(); + if (QTest::currentTestFailed()) return; + + press(QKeySequence(tr("Ctrl+Z"))); + QTRY_COMPARE(int(coverage().size()), 3); + QCOMPARE(coverage()[1], second); + } + + // Checklist: recording again inside singing that is there asks first; + // No records nothing; "Don't ask again" with Yes holds across + // sessions. The dialog MainWindow shows, answered with its buttons + void record_over_question_in_its_dialog() { + FakeAudioIO::Config config; + config.input = tone(highHz, 8.0); + makeWindow(config); + if (QTest::currentTestFailed()) return; + m_window->setRecordOverAskedInDialog(true); + openReference(writeWav(tone(lowHz, 4.0))); + if (QTest::currentTestFailed()) return; + + take(1500); + if (QTest::currentTestFailed()) return; + const Coverage::Ranges first = coverage(); + const QString firstPath = m_window->takes()->getAudioPath(); + + // No: nothing recorded, and nothing left running + QStringList asked; + m_answerDialog = answerWith(QMessageBox::No, false, &asked); + m_window->seekTo(frames(0.5)); + m_window->doRecord(); + QCOMPARE(asked.size(), 1); + QVERIFY(!m_window->recordTarget()->isRecording()); + QVERIFY(!m_window->recordingInProgress()); + QTest::qWait(300); + QCOMPARE(coverage(), first); + QCOMPARE(m_window->takes()->getAudioPath(), firstPath); + + // Yes, and don't ask again + m_answerDialog = answerWith(QMessageBox::Yes, true, &asked); + m_window->seekTo(frames(0.5)); + startTake(); + if (QTest::currentTestFailed()) return; + QCOMPARE(asked.size(), 2); + QTest::qWait(500); + stopTake(); + if (QTest::currentTestFailed()) return; + QVERIFY(m_window->takes()->getAudioPath() != firstPath); + + // A session of its own, in a window of its own: not asked + m_answerDialog = answerWith(QMessageBox::Yes, false, &asked); + m_window->doCloseSession(); + makeWindow(config); + if (QTest::currentTestFailed()) return; + m_window->setRecordOverAskedInDialog(true); + openReference(writeWav(tone(lowHz, 4.0))); + if (QTest::currentTestFailed()) return; + take(1200); + if (QTest::currentTestFailed()) return; + m_window->seekTo(frames(0.3)); + take(400); + if (QTest::currentTestFailed()) return; + QCOMPARE(asked.size(), 2); + QVERIFY(!SingingTakes::isOverwriteConfirmationWanted()); + } + + // Checklist: pitch and notes outside the recorded range (± about + // 0.25 s) must not flicker or move at all. The pane before and after a + // recording into the middle of a take, compared pixel for pixel + // outside it, and the take's layers the same objects throughout + void nothing_outside_the_range_moves() { + FakeAudioIO::Config config; + config.input = tone(highHz, 8.0); + makeWindow(config); + if (QTest::currentTestFailed()) return; + openReference(writeWav(tone(lowHz, 5.0))); + if (QTest::currentTestFailed()) return; + + take(3500); + if (QTest::currentTestFailed()) return; + showSeconds(0.0, 3.8); + const sv::sv_frame_t P = frames(1.4); + m_window->seekTo(P); + QImage before = grabPaneRedrawn(); + saveShot("before", before); + sv::Layer *pitch = m_window->analyser2()->getLayer(Analyser::PitchTrack); + sv::Layer *notes = m_window->analyser2()->getLayer(Analyser::Notes); + + QSignalSpy relayered(m_window->analyser2(), SIGNAL(layersChanged())); + m_window->setRecordOverAnswer(true); + take(700); + if (QTest::currentTestFailed()) return; + QCOMPARE(m_window->analyser2()->getLayer(Analyser::PitchTrack), pitch); + QCOMPARE(m_window->analyser2()->getLayer(Analyser::Notes), notes); + QVERIFY2(relayered.isEmpty(), + "the take's layers were replaced: they would have vanished " + "from the pane for a moment"); + + m_window->seekTo(P); + showSeconds(0.0, 3.8); + QImage after = grabPaneRedrawn(); + saveShot("after", after); + + // What was recorded is at most as long as the wait for it; the + // analysis of it reaches a quarter of a second either side + sv::Pane *pane = pane0(); + int left = pane->getXForFrame(P - frames(0.3)); + int right = pane->getXForFrame(P + frames(0.8) + frames(0.3)); + int pointer = pane->getXForFrame(P); + QVERIFY(left > 20 && right < before.width() - 20); + + int differing = 0; + QString first; + for (int x = 0; x < before.width(); ++x) { + if (x >= left && x <= right) continue; + if (std::abs(x - pointer) <= 3) continue; + for (int y = 0; y < before.height(); ++y) { + if (before.pixel(x, y) != after.pixel(x, y)) { + if (first == "") { + first = QString("x = %1, y = %2").arg(x).arg(y); + } + ++differing; + } + } + } + QVERIFY2(differing == 0, + qPrintable(QString("%1 pixels outside the recorded range " + "and its margin changed, the first at %2 " + "(range drawn from x = %3 to %4)") + .arg(differing).arg(first).arg(left).arg(right))); + } + + // Checklist: open a .ton, Load Singing Track, then Record: closing + // afterwards asks whether to save. The window's own close, as the + // title bar's button does it + void closing_after_a_take_asks_to_save() { + FakeAudioIO::Config config; + config.input = tone(highHz, 4.0); + makeWindow(config); + if (QTest::currentTestFailed()) return; + openReference(writeWav(tone(lowHz, 2.0))); + if (QTest::currentTestFailed()) return; + QString session = m_dir.filePath + (QString("session-%1.ton").arg(++m_fileCounter)); + QVERIFY(m_window->saveSessionFile(session)); + openReference(session); + if (QTest::currentTestFailed()) return; + + m_window->loadSingingTrack(writeWav(tone(highHz, 1.0))); + QTRY_VERIFY_WITH_TIMEOUT(analysed(m_window->analyser2()), 30000); + m_window->seekTo(frames(1.2)); + take(500); + if (QTest::currentTestFailed()) return; + + QStringList asked; + m_answerDialog = answerWith(QMessageBox::Cancel, false, &asked); + QVERIFY(!m_window->close()); + QCOMPARE(asked.size(), 1); + QVERIFY2(asked[0].startsWith(tr("The current session has been modified.")), + qPrintable(asked[0])); + QVERIFY2(m_window->isVisible(), "Cancel did not keep the window open"); + QVERIFY(m_window->takes()->haveTake()); + } + + // Checklist: stop a take and close the window at once: no crash. + // Closed and deleted while the take is still being analysed + void stop_then_close_the_window_at_once() { + FakeAudioIO::Config config; + config.input = tone(highHz, 4.0); + makeWindow(config); + if (QTest::currentTestFailed()) return; + openReference(writeWav(tone(lowHz, 3.0))); + if (QTest::currentTestFailed()) return; + + // Held, so that the merge is still to come when the window goes, + // however quickly pYIN analyses the range + m_window->holdRangedMerges(true); + + startTake(); + if (QTest::currentTestFailed()) return; + QTest::qWait(1000); + m_window->doRecord(); + QVERIFY(m_window->analysingRange()); + + QStringList asked; + m_answerDialog = answerWith(QMessageBox::No, false, &asked); + QVERIFY(m_window->close()); + QCOMPARE(asked.size(), 1); + QVERIFY2(asked[0].startsWith(tr("The current session has been modified.")), + qPrintable(asked[0])); + delete m_window; + m_window = nullptr; + + // Whatever was still running finishes with no window to go to + QTRY_VERIFY_WITH_TIMEOUT + (!sv::ModelTransformerFactory::getInstance() + ->haveRunningTransformers(), 30000); + QTest::qWait(500); + } + + // Checklist: log out with unsaved takes: what commitData writes into + // ~/.sv1 is playable. Only where the home directory can be moved + // for the test: on Windows it is the profile's, whatever HOME says + void commit_data_writes_a_playable_session() { +#ifdef Q_OS_WIN + QSKIP("commitData() writes into the real profile's .sv1 on Windows"); +#else + QByteArray home = qgetenv("HOME"); + QString fakeHome = m_dir.filePath("home"); + QVERIFY(QDir().mkpath(fakeHome)); + qputenv("HOME", fakeHome.toLocal8Bit()); + QCOMPARE(QDir::homePath(), fakeHome); + + FakeAudioIO::Config config; + config.input = tone(highHz, 4.0); + makeWindow(config); + if (QTest::currentTestFailed()) return; + openReference(writeWav(tone(lowHz, 3.0))); + if (QTest::currentTestFailed()) return; + take(1000); + if (QTest::currentTestFailed()) return; + const Coverage::Ranges recorded = coverage(); + + bool committed = m_window->commitData(false); + qputenv("HOME", home); + QVERIFY(committed); + + QStringList written = QDir(fakeHome + "/.sv1") + .entryList({ "tmp-*" }, QDir::Files); + QCOMPARE(written.size(), 1); + QString path = fakeHome + "/.sv1/" + written[0]; + QVERIFY2(path.endsWith(".ton"), + qPrintable("the session is written as " + path)); + + // It goes on the Recent Files list, which opens it with openPath() + m_window->doCloseSession(); + QCOMPARE(m_window->openPath(path, MainWindow::ReplaceSession), + MainWindow::FileOpenSucceeded); + QTRY_VERIFY_WITH_TIMEOUT(analysed(m_window->analyser()), 30000); + QVERIFY2(m_window->takes()->haveTake(), + "the session written at logout has no take in it"); + QCOMPARE(coverage(), recorded); + + QString audio = m_window->takes()->getAudioPath(); + QVERIFY2(QFileInfo(audio).exists(), + qPrintable("the take's audio is not there: " + audio)); + sv::WavFileReader reader { sv::FileSource(audio) }; + QVERIFY(reader.isOK()); + auto data = reader.getInterleavedFrames + (recorded[0].start + 2000, recorded[0].end - recorded[0].start - 4000); + double sum = 0.0; + for (float v : data) sum += double(v) * double(v); + QVERIFY2(!data.empty() && std::sqrt(sum / double(data.size())) > 0.01, + "the take in the session written at logout is silent"); +#endif + } + + // Checklist: the alternate pitch track is faded brown, dark brown + // during a take, and stays in view after an octave step + void alternate_pitch_track_colours() { + FakeAudioIO::Config config; + config.input = tone(highHz, 4.0); + makeWindow(config); + if (QTest::currentTestFailed()) return; + openReference(writeWav(tone(lowHz, 3.0))); + if (QTest::currentTestFailed()) return; + showSeconds(0.0, 3.0); + + QColor faded(164, 146, 136), dark(74, 37, 17); + auto isFaded = [&](QRgb p) { return closeTo(p, faded, 12); }; + auto isDark = [&](QRgb p) { return closeTo(p, dark, 12); }; + + m_window->doToggleAlternatePitch(); + QTRY_VERIFY(m_window->alternatePitch()->getLayer()); + QTest::qWait(200); + QImage image = grabPane(); + QVERIFY2(countAbove(image, 0, image.width(), isFaded) > 0, + "no faded brown track is drawn"); + saveShot("faded", image); + + // Wide enough for the whole of every toolbar, 8vb and 8va included + QSize size = m_window->size(); + m_window->resize(1920, 1000); + saveWindowShot("window"); + m_window->resize(size); + showSeconds(0.0, 3.0); + + // Two octaves either way stay in view of a 220 Hz reference. The + // third is only reported: 28 Hz and 1.8 kHz are past the range the + // pane shows, and nothing scrolls to them + auto inView = [&]() { + QTest::qWait(300); + image = grabPane(); + int octaves = m_window->alternatePitch()->getOctaves(); + saveShot(QString("octaves%1").arg(octaves), image); + return countAbove(image, 0, image.width(), isFaded) > 0; + }; + QCOMPARE(m_window->alternatePitch()->getOctaves(), -1); + for (bool up : { false, true, true, true }) { + m_window->doStepAlternatePitch(up); + QVERIFY2(inView(), + qPrintable(QString("the track is out of view at %1 " + "octaves") + .arg(m_window->alternatePitch()->getOctaves()))); + } + QCOMPARE(m_window->alternatePitch()->getOctaves(), 2); + m_window->doStepAlternatePitch(true); + qInfo("at +3 octaves the track is %s", inView() ? "in view" : "out of view"); + for (int i = 0; i < 5; ++i) m_window->doStepAlternatePitch(false); + QCOMPARE(m_window->alternatePitch()->getOctaves(), -3); + qInfo("at -3 octaves the track is %s", inView() ? "in view" : "out of view"); + m_window->doStepAlternatePitch(true); + m_window->doStepAlternatePitch(true); + QCOMPARE(m_window->alternatePitch()->getOctaves(), -1); + + startTake(); + if (QTest::currentTestFailed()) return; + QTest::qWait(400); + image = grabPane(); + QVERIFY2(countAbove(image, 0, image.width(), isDark) > 0, + "the track followed during a take is not dark brown"); + QVERIFY2(countAbove(image, 0, image.width(), isFaded) == 0, + "the track is still faded during a take"); + saveShot("dark", image); + stopTake(); + if (QTest::currentTestFailed()) return; + QVERIFY(countAbove(grabPane(), 0, image.width(), isFaded) > 0); + } + + // Checklist: pre-roll less than 3 s from the start of the song: a + // shorter countdown, and no attempt to run from before frame 0 + void preroll_near_the_start_of_the_song() { + FakeAudioIO::Config config; + config.input = tone(highHz, 4.0); + makeWindow(config); + if (QTest::currentTestFailed()) return; + m_window->setPlayReferenceWhileRecording(true); + m_window->setPreRoll(true); + openReference(writeWav(tone(lowHz, 4.0))); + if (QTest::currentTestFailed()) return; + + m_window->seekTo(frames(1.0)); + startTake(); + if (QTest::currentTestFailed()) return; + QCOMPARE(m_window->takePreRoll(), frames(1.0)); + QStringList counted; + QElapsedTimer timer; + timer.start(); + while (timer.elapsed() < 1500) { + QVERIFY2(m_window->playbackFrame() >= 0, + qPrintable(QString("the cursor is at frame %1") + .arg(m_window->playbackFrame()))); + QString text = m_window->statusText(); + if (text.startsWith(tr("Recording in ")) && + !counted.contains(text)) { + counted << text; + } + QTest::qWait(20); + } + QCOMPARE(counted, QStringList({ tr("Recording in %1…").arg(1) })); + stopTake(); + if (QTest::currentTestFailed()) return; + QCOMPARE(coverage()[0].start, frames(1.0)); + } + + // Checklist: Constrain Playback to Selection together with a pre-roll. + // The device hears its own output here, so the "singer" sings exactly + // what is played: a take that is in time holds the reference's high + // note where the reference has it + void preroll_with_playback_constrained_to_the_selection() { + for (bool constrained : { false, true }) { + FakeAudioIO::Config config; + config.loopback = true; + config.recordLatency = 512; + config.playbackLatency = 512; + config.inputDelay = 1024; + makeWindow(config); + if (QTest::currentTestFailed()) return; + m_window->setPlayReferenceWhileRecording(true); + m_window->setPreRoll(true); + m_window->setRecordIntoSelection(true); + QSettings settings; + settings.beginGroup("MainWindow"); + settings.setValue("prerollseconds", 1.0); + settings.endGroup(); + + // Low, then high from 2 to 2.75 s, then low again + auto reference = tone(lowHz, 2.0); + auto high = tone(highHz, 0.75); + auto low = tone(lowHz, 1.25); + reference.insert(reference.end(), high.begin(), high.end()); + reference.insert(reference.end(), low.begin(), low.end()); + openReference(writeWav(reference)); + if (QTest::currentTestFailed()) return; + + m_window->selectRange(frames(2.0), frames(3.5)); + QAction *constrain = nullptr; + for (QAction *a : m_window->findChildren()) { + if (a->text() == tr("Constrain Playback to Selection")) { + constrain = a; + } + } + QVERIFY(constrain); + if (constrain->isChecked() != constrained) constrain->trigger(); + QCOMPARE(constrain->isChecked(), constrained); + QTRY_VERIFY_WITH_TIMEOUT + (!sv::ModelTransformerFactory::getInstance() + ->haveRunningTransformers(), 30000); + + startTake(); + if (QTest::currentTestFailed()) return; + + // Lifted for the take, and not to be put back during it + QTest::qWait(300); + QVERIFY2(!constrain->isChecked(), + "playback is constrained to the selection during a take"); + QVERIFY2(!constrain->isEnabled(), + "playback can be constrained to the selection during a " + "take"); + + QTRY_VERIFY_WITH_TIMEOUT + (!m_window->recordTarget()->isRecording(), 8000); + QTRY_VERIFY_WITH_TIMEOUT(analysed(m_window->analyser2()), 30000); + + // ... and as it was afterwards + QCOMPARE(constrain->isChecked(), constrained); + QVERIFY(constrain->isEnabled()); + + double from = -1.0, to = -1.0; + for (const auto &e : sv::ModelById::getAs + (m_window->analyser2()->getLayer(Analyser::PitchTrack) + ->getModel())->getAllEvents()) { + if (e.getValue() < std::sqrt(lowHz * highHz)) continue; + if (from < 0.0) from = double(e.getFrame()) / rate; + to = double(e.getFrame()) / rate; + } + qInfo("playback %s: the high note is in the take from %.3f to " + "%.3f s", constrained ? "constrained" : "not constrained", + from, to); + QVERIFY2(std::fabs(from - 2.0) < 0.05 && std::fabs(to - 2.75) < 0.05, + qPrintable(QString("the high note of the reference, " + "2.000 to 2.750 s, is in the take " + "from %1 to %2 s") + .arg(from, 0, 'f', 3).arg(to, 0, 'f', 3))); + + if (constrain->isChecked()) constrain->trigger(); + m_window->clearSelections(); + QTRY_VERIFY_WITH_TIMEOUT + (!sv::ModelTransformerFactory::getInstance() + ->haveRunningTransformers(), 30000); + m_window->doCloseSession(); + } + } + + // Checklist: switching between takes is fast and analyses nothing + void switching_takes_is_quick() { + FakeAudioIO::Config config; + config.input = tone(highHz, 6.0); + makeWindow(config); + if (QTest::currentTestFailed()) return; + openReference(writeWav(tone(lowHz, 30.0))); + if (QTest::currentTestFailed()) return; + + take(800); + if (QTest::currentTestFailed()) return; + m_window->doNewEmptyTake(); + m_window->seekTo(frames(2.0)); + take(800); + if (QTest::currentTestFailed()) return; + + for (int to : { 0, 1, 0, 1 }) { + QElapsedTimer timer; + timer.start(); + m_window->doChooseTakeInCombo(to); + QCoreApplication::processEvents(); + grabPane(); + qint64 ms = timer.elapsed(); + qInfo("switch to take %d took %lld ms", to + 1, ms); + QVERIFY2(ms < 1000, + qPrintable(QString("switching takes took %1 ms").arg(ms))); + QCOMPARE(m_window->takes()->getActiveIndex(), to); + QVERIFY(!m_window->analysingRange()); + QVERIFY(!sv::ModelTransformerFactory::getInstance() + ->haveRunningTransformers()); + } + } +}; + +#endif diff --git a/main/test/TestVerticalZoom.h b/main/test/TestVerticalZoom.h new file mode 100644 index 00000000..e0e2dded --- /dev/null +++ b/main/test/TestVerticalZoom.h @@ -0,0 +1,352 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TEST_VERTICAL_ZOOM_H +#define TEST_VERTICAL_ZOOM_H + +// Tier 2: the arithmetic of two fingers on a pane's vertical axis, and +// when a movement of the fingers along an axis counts. Ranges and +// pixels in, ranges and pixels out; no view. That the mapping is the +// pane's own is checked against svgui in TestTouchGestures. + +#include "../PinchZoom.h" +#include "../VerticalZoom.h" + +#include +#include + +#include +#include + +class TestVerticalZoom : public QObject +{ + Q_OBJECT + + typedef VerticalZoom::Range Range; + + static Range range(double min, double max, bool log) { + Range r; + r.min = min; + r.max = max; + r.log = log; + return r; + } + + static QString text(const Range &r) { + return QString("%1 to %2 (%3)").arg(r.min).arg(r.max) + .arg(r.log ? "log" : "linear"); + } + + // The height of the range on its own scale: octaves on a log one + static double span(const Range &r) { + return r.log ? std::log2(r.max / r.min) : r.max - r.min; + } + + static bool within(double a, double b, double tolerance) { + return std::fabs(a - b) <= tolerance; + } + +private slots: + // Bottom edge the minimum, top edge the maximum; half way up a log + // scale the geometric mean, a linear one the arithmetic + void value_at_y_on_both_scales() { + Range log = range(40.0, 1500.0, true); + QCOMPARE(VerticalZoom::valueAtY(log, 400.0, 400.0), 40.0); + QCOMPARE(VerticalZoom::valueAtY(log, 400.0, 0.0), 1500.0); + QVERIFY(within(VerticalZoom::valueAtY(log, 400.0, 200.0), + std::sqrt(40.0 * 1500.0), 1.0e-9)); + + Range linear = range(100.0, 300.0, false); + QCOMPARE(VerticalZoom::valueAtY(linear, 200.0, 50.0), 250.0); + QCOMPARE(VerticalZoom::valueAtY(linear, 200.0, 200.0), 100.0); + } + + void y_for_value_is_the_inverse() { + for (bool log : { true, false }) { + Range r = range(55.0, 880.0, log); + for (double y : { 0.0, 13.5, 200.0, 377.0, 400.0 }) { + double value = VerticalZoom::valueAtY(r, 400.0, y); + double back = VerticalZoom::yForValue(r, 400.0, value); + QVERIFY2(within(back, y, 1.0e-9), + qPrintable(QString("%1: y %2 came back as %3") + .arg(text(r)).arg(y).arg(back))); + } + } + } + + // Twice as far in shows half as many octaves, with the value asked + // for where it was asked for + void zoom_keeps_the_value_at_y() { + for (bool log : { true, false }) { + Range start = range(40.0, 1500.0, log); + for (double factor : { 0.5, 1.0, 1.7, 2.0, 8.0 }) { + for (double y : { 0.0, 120.0, 250.0, 400.0 }) { + double value = 220.0; + Range r = VerticalZoom::zoomedAbout + (start, factor, value, 400.0, y); + QString what = QString("%1 by %2 about %3 at %4: %5") + .arg(text(start)).arg(factor).arg(value).arg(y) + .arg(text(r)); + QVERIFY2(r.log == log, qPrintable(what)); + QVERIFY2(within(VerticalZoom::yForValue(r, 400.0, value), + y, 1.0e-6), qPrintable(what)); + QVERIFY2(within(span(r), span(start) / factor, + 1.0e-9 * span(start)), qPrintable(what)); + } + } + } + } + + // A factor of one moves the range: what was at 300 is at 200, and + // the range is as tall as it was. A quarter of the pane is half an + // octave, which the range goes down by + void factor_one_scrolls() { + Range start = range(100.0, 400.0, true); + double value = VerticalZoom::valueAtY(start, 400.0, 300.0); + Range r = VerticalZoom::zoomedAbout(start, 1.0, value, 400.0, 200.0); + QVERIFY(within(VerticalZoom::yForValue(r, 400.0, value), 200.0, 1.0e-6)); + QVERIFY(within(span(r), 2.0, 1.0e-9)); + QVERIFY(within(r.min, 100.0 / std::sqrt(2.0), 1.0e-9)); + } + + void pitch_limits_are_the_piano_and_a_major_third() { + VerticalZoom::Limits limits = VerticalZoom::pitchLimits(); + QCOMPARE(limits.lowest, 27.5); + QVERIFY(within(limits.highest, 4186.009, 0.001)); + QVERIFY(within(12.0 * std::log2(limits.narrowest), 4.0, 1.0e-9)); + } + + void a_range_within_the_limits_is_left_as_it_is() { + VerticalZoom::Limits limits = VerticalZoom::pitchLimits(); + for (bool log : { true, false }) { + for (Range r : { range(40.0, 1500.0, log), + range(27.5, 4186.009, log), + range(200.0, 252.0, log) }) { + r.log = log; + Range l = VerticalZoom::limited(r, limits, 400.0, 100.0); + QVERIFY2(l == r, qPrintable(text(r) + " became " + text(l))); + } + } + } + + // Too narrow: widened to a major third about the value at y, which + // stays there + void a_narrow_range_is_widened_about_y() { + VerticalZoom::Limits limits = VerticalZoom::pitchLimits(); + for (bool log : { true, false }) { + for (double y : { 0.0, 100.0, 400.0 }) { + Range r = range(200.0, 210.0, log); + double value = VerticalZoom::valueAtY(r, 400.0, y); + Range l = VerticalZoom::limited(r, limits, 400.0, y); + QString what = text(r) + " became " + text(l); + QVERIFY2(within(l.max / l.min, limits.narrowest, 1.0e-9), + qPrintable(what)); + QVERIFY2(within(VerticalZoom::valueAtY(l, 400.0, y), value, + 1.0e-9), qPrintable(what)); + } + } + } + + // Past an end: moved back inside, as tall as it was + void a_range_past_an_end_is_moved_inside() { + VerticalZoom::Limits limits = VerticalZoom::pitchLimits(); + + Range low = VerticalZoom::limited(range(20.0, 100.0, true), + limits, 400.0, 200.0); + QCOMPARE(low.min, 27.5); + QVERIFY(within(low.max, 137.5, 1.0e-6)); + + Range high = VerticalZoom::limited(range(3000.0, 6000.0, true), + limits, 400.0, 200.0); + QCOMPARE(high.max, limits.highest); + QVERIFY(within(high.min, limits.highest / 2.0, 1.0e-6)); + + Range linear = VerticalZoom::limited(range(10.0, 110.0, false), + limits, 400.0, 200.0); + QCOMPARE(linear.min, 27.5); + QVERIFY(within(linear.max, 127.5, 1.0e-6)); + } + + // Wider than the limits, or showing nothing: all of them + void a_wide_or_empty_range_is_all_of_the_limits() { + VerticalZoom::Limits limits = VerticalZoom::pitchLimits(); + double nan = std::numeric_limits::quiet_NaN(); + for (Range r : { range(10.0, 20000.0, true), + range(0.0, 1500.0, true), + range(-5.0, 1500.0, true), + range(500.0, 500.0, true), + range(600.0, 500.0, true), + range(nan, 500.0, true), + range(10.0, 20000.0, false), + range(40.0, nan, false) }) { + Range l = VerticalZoom::limited(r, limits, 400.0, 200.0); + QVERIFY2(l.min == limits.lowest && l.max == limits.highest && + l.log == r.log, + qPrintable(text(r) + " became " + text(l))); + } + } + + // Half way between the lowest and the highest on the range's scale: + // the geometric mean on a log one, however many are in between + void middle_is_half_way_between_lowest_and_highest() { + std::vector values { 110.0, 400.0, 100.0, 105.0, 120.0 }; + double middle = 0.0; + QVERIFY(VerticalZoom::middleShown(values, range(40.0, 1500.0, true), + middle)); + QVERIFY(within(middle, 200.0, 1.0e-9)); + QVERIFY(VerticalZoom::middleShown(values, range(40.0, 1500.0, false), + middle)); + QVERIFY(within(middle, 250.0, 1.0e-9)); + + QVERIFY(VerticalZoom::middleShown({ 73.5 }, range(40.0, 1500.0, true), + middle)); + QVERIFY(within(middle, 73.5, 1.0e-9)); + } + + // Only what the range shows: values above or below it, and not + // values at all, are not on show. None on show: no middle + void middle_is_of_what_is_on_show() { + double nan = std::numeric_limits::quiet_NaN(); + std::vector values { 20.0, 100.0, nan, 400.0, 3000.0, -1.0 }; + double middle = 0.0; + QVERIFY(VerticalZoom::middleShown(values, range(50.0, 1000.0, true), + middle)); + QVERIFY(within(middle, 200.0, 1.0e-9)); + QVERIFY(VerticalZoom::middleShown(values, range(100.0, 400.0, true), + middle)); + QVERIFY(within(middle, 200.0, 1.0e-9)); + + middle = -5.0; + QVERIFY(!VerticalZoom::middleShown(values, range(500.0, 1000.0, true), + middle)); + QVERIFY(!VerticalZoom::middleShown({}, range(40.0, 1500.0, true), + middle)); + QVERIFY(!VerticalZoom::middleShown(values, range(0.0, 1500.0, true), + middle)); + QVERIFY(!VerticalZoom::middleShown(values, range(500.0, 50.0, false), + middle)); + QCOMPARE(middle, -5.0); + } + + // Two strays in forty, an octave jump or a breath, leave the middle + // where the rest put it; five are more than strays + void a_few_strays_do_not_move_the_middle() { + std::vector values; + for (int i = 0; i < 19; ++i) values.push_back(70.0); + for (int i = 0; i < 19; ++i) values.push_back(90.0); + values.push_back(700.0); + values.push_back(35.0); + double middle = 0.0; + QVERIFY(VerticalZoom::middleShown(values, range(30.0, 1500.0, true), + middle)); + QVERIFY2(within(middle, std::sqrt(70.0 * 90.0), 1.0e-9), + qPrintable(QString("%1").arg(middle))); + + values.clear(); + for (int i = 0; i < 17; ++i) values.push_back(70.0); + for (int i = 0; i < 18; ++i) values.push_back(90.0); + for (int i = 0; i < 5; ++i) values.push_back(700.0); + QVERIFY(VerticalZoom::middleShown(values, range(30.0, 1500.0, true), + middle)); + QVERIFY2(within(middle, std::sqrt(70.0 * 700.0), 1.0e-9), + qPrintable(QString("%1").arg(middle))); + } + + // Zoomed in, a value comes towards the middle of the pane, its + // distance from there divided by the factor; zoomed out it stays + void towards_the_middle_by_the_factor() { + QCOMPARE(VerticalZoom::towardsMiddle(380.0, 400.0, 2.0), 290.0); + QCOMPARE(VerticalZoom::towardsMiddle(380.0, 400.0, 4.0), 245.0); + QCOMPARE(VerticalZoom::towardsMiddle(0.0, 400.0, 2.0), 100.0); + QCOMPARE(VerticalZoom::towardsMiddle(200.0, 400.0, 8.0), 200.0); + QCOMPARE(VerticalZoom::towardsMiddle(380.0, 400.0, 1.0), 380.0); + QCOMPARE(VerticalZoom::towardsMiddle(380.0, 400.0, 0.5), 380.0); + } + + // A low voice, B1 to G2, near the bottom of the default range: zoomed + // about its middle, which comes towards the middle of the pane, all + // of it stays in view until it is nearly as tall as the pane. Zoomed + // about the middle of the pane, it is gone below by three times + void a_low_pitch_stays_in_view_as_the_range_narrows() { + const double height = 400.0; + Range start = range(40.0, 1500.0, true); + std::vector pitch { 61.7, 73.4, 82.4, 98.0, 65.4, 87.3 }; + double middle = 0.0; + QVERIFY(VerticalZoom::middleShown(pitch, start, middle)); + double y = VerticalZoom::yForValue(start, height, middle); + QVERIFY(y > 0.8 * height); + + for (double factor : { 1.0, 1.5, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0 }) { + Range r = VerticalZoom::zoomedAbout + (start, factor, middle, height, + VerticalZoom::towardsMiddle(y, height, factor)); + double bottom = VerticalZoom::yForValue(r, height, 61.7); + double top = VerticalZoom::yForValue(r, height, 98.0); + QVERIFY2(bottom <= height && top >= 0.0, + qPrintable(QString("by %1: %2, the pitch from %3 to %4") + .arg(factor).arg(text(r)).arg(bottom) + .arg(top))); + } + + double centre = VerticalZoom::valueAtY(start, height, height / 2); + Range r = VerticalZoom::zoomedAbout(start, 3.0, centre, height, + height / 2); + QVERIFY(VerticalZoom::yForValue(r, height, 98.0) > height); + } + + // Nothing within the dead zone; beyond it, all but the dead zone, + // and then all the way back through it + void movement_counts_beyond_the_dead_zone() { + PinchZoom::AxisMovement m(20.0); + QCOMPARE(m.update(10.0, 0.0), 0.0); + QCOMPARE(m.update(-20.0, 0.0), 0.0); + QVERIFY(!m.isCounting()); + QCOMPARE(m.update(25.0, 0.0), 5.0); + QVERIFY(m.isCounting()); + QCOMPARE(m.update(40.0, 0.0), 20.0); + QCOMPARE(m.update(0.0, 0.0), -20.0); + QCOMPARE(m.update(-30.0, 500.0), -50.0); + + PinchZoom::AxisMovement down(20.0); + QCOMPARE(down.update(-50.0, 0.0), -30.0); + } + + // Less than half as much as across: not meant + void movement_across_the_axis_does_not_count() { + PinchZoom::AxisMovement m(20.0); + QCOMPARE(m.update(30.0, 100.0), 0.0); + QCOMPARE(m.update(49.0, -100.0), 0.0); + QVERIFY(!m.isCounting()); + QCOMPARE(m.update(50.0, 100.0), 30.0); + QVERIFY(m.isCounting()); + } + + // Started from outside: it counts from where it is, however far + // that is, so that nothing jumps + void movement_started_counts_from_there() { + PinchZoom::AxisMovement m(20.0); + m.start(8.0); + QVERIFY(m.isCounting()); + QCOMPARE(m.update(8.0, 0.0), 0.0); + QCOMPARE(m.update(18.0, 0.0), 10.0); + m.start(100.0); // already counting: no change + QCOMPARE(m.update(18.0, 0.0), 10.0); + + PinchZoom::AxisMovement distant(20.0); + distant.start(-50.0); + QCOMPARE(distant.update(-50.0, 0.0), 0.0); + QCOMPARE(distant.update(-60.0, 500.0), -10.0); + } +}; + +#endif diff --git a/main/test/TestViewCache.h b/main/test/TestViewCache.h new file mode 100644 index 00000000..b40c6941 --- /dev/null +++ b/main/test/TestViewCache.h @@ -0,0 +1,161 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef TEST_VIEW_CACHE_H +#define TEST_VIEW_CACHE_H + +// Tier 3: what a pane draws again from its layers, and what it takes +// from its cache of what they drew (the svgui fork). No MainWindow. +// That the live dots of a take are kept out of the cache is +// TestRecordWorkflow's business (live_dots_appear). + +#include "view/Pane.h" +#include "view/ViewManager.h" +#include "layer/TimeValueLayer.h" +#include "data/model/SparseTimeValueModel.h" + +#include +#include +#include + +#include +#include + +class TestViewCache : public QObject +{ + Q_OBJECT + + // A layer that counts the times the pane has it draw + class CountedLayer : public sv::TimeValueLayer + { + public: + mutable int drawn = 0; + void paint(sv::LayerGeometryProvider *v, QPainter &paint, + QRect rect) const override { + ++drawn; + sv::TimeValueLayer::paint(v, paint, rect); + } + }; + + sv::ViewManager *m_viewManager = nullptr; + sv::Pane *m_pane = nullptr; + + // Back to front + std::vector m_layers; + std::vector m_models; + + void changeModelOf(int layer) { + auto model = sv::ModelById::get(m_models[layer]); + emit model->modelChangedWithin(m_models[layer], 0, 44100); + } + + // Painted until the pane has nothing left to settle, then counted + // from nothing + void settle() { + for (int i = 0; i < 3; ++i) { + QCoreApplication::processEvents(); + m_pane->repaint(); + } + for (auto layer : m_layers) layer->drawn = 0; + } + + std::vector drawn() { + std::vector counts; + for (auto layer : m_layers) counts.push_back(layer->drawn); + return counts; + } + +private slots: + void init() { + m_viewManager = new sv::ViewManager(); + m_pane = new sv::Pane(); + m_pane->setViewManager(m_viewManager); + m_pane->resize(400, 200); + + for (int i = 0; i < 3; ++i) { + auto model = std::make_shared + (44100, 256, false); + for (int j = 0; j < 20; ++j) { + model->add(sv::Event(j * 2048, 220.f + float(i * 20 + j), "")); + } + m_models.push_back(sv::ModelById::add(model)); + auto layer = new CountedLayer(); + layer->setModel(m_models.back()); + m_layers.push_back(layer); + m_pane->addLayer(layer); + } + + m_pane->show(); + QVERIFY(QTest::qWaitForWindowExposed(m_pane)); + } + + void cleanup() { + delete m_pane; + m_pane = nullptr; + for (auto layer : m_layers) delete layer; + m_layers.clear(); + for (auto id : m_models) sv::ModelById::release(id); + m_models.clear(); + delete m_viewManager; + m_viewManager = nullptr; + } + + // Painted again with nothing changed, the pane takes what it shows + // from its cache and has none of its layers draw + void nothing_changed_draws_nothing() { + settle(); + m_pane->repaint(); + m_pane->repaint(QRect(100, 0, 20, m_pane->height())); + QCOMPARE(drawn(), std::vector({ 0, 0, 0 })); + } + + // A change to the model of any layer in the cache has every layer + // in it drawn again + void a_change_draws_the_cache_again() { + settle(); + changeModelOf(1); + m_pane->repaint(); + QCOMPARE(drawn(), std::vector({ 1, 1, 1 })); + } + + // A new plot size (View > Plot Size) has every layer drawn again, + // at once, at the new size + void a_plot_scale_change_draws_the_cache_again() { + settle(); + m_viewManager->setPlotScale(1.5); + m_pane->repaint(); + QCOMPARE(drawn(), std::vector({ 1, 1, 1 })); + } + + // Kept out of the cache, a layer is drawn every time the pane is + // painted, and so is every layer in front of it; a change to its + // model leaves the layers behind it in the cache + void a_layer_kept_out_is_drawn_by_itself() { + m_layers[1]->setCachedInView(false); + settle(); + + changeModelOf(1); + m_pane->repaint(); + QCOMPARE(drawn(), std::vector({ 0, 1, 1 })); + + m_pane->repaint(); + QCOMPARE(drawn(), std::vector({ 0, 2, 2 })); + + // What is still in the cache is drawn again as ever + changeModelOf(0); + m_pane->repaint(); + QCOMPARE(drawn(), std::vector({ 1, 3, 3 })); + } +}; + +#endif diff --git a/main/test/tony-app-test.cpp b/main/test/tony-app-test.cpp new file mode 100644 index 00000000..a6915f3a --- /dev/null +++ b/main/test/tony-app-test.cpp @@ -0,0 +1,151 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#include "TestSingingDocument.h" +#include "TestViewCache.h" +#include "TestSingingAnalysis.h" +#include "TestRecordWorkflow.h" +#include "TestTouchGestures.h" +#include "TestCompactLayout.h" +#include "TestTouchMenuStyle.h" +#include "TestLyricsLayer.h" +#include "TestPlotSize.h" +#include "TestUiChecks.h" +#include "TestAudioCheck.h" + +#include "RunSuite.h" + +#include "system/Init.h" + +#include +#include +#include +#include + +#include + +using namespace std; +using namespace sv; + +int main(int argc, char *argv[]) +{ + int good = 0, bad = 0; + + svSystemSpecificInitialisation(); + + // Widgets are created but never shown. meson runs this with + // QT_QPA_PLATFORM=offscreen; default to that when run by hand too. + if (qEnvironmentVariableIsEmpty("QT_QPA_PLATFORM")) { + qputenv("QT_QPA_PLATFORM", "offscreen"); + } + + // Names distinct from the application's, so that nothing here reads + // or writes the user's real Tony settings, and a shard's distinct + // from the other shards', so that shards running at once keep apart + QApplication app(argc, argv); + app.setOrganizationName("tony-tests"); + app.setApplicationName(shardApplicationName + ("test-tony-app", + qEnvironmentVariable("TONY_TEST_SHARD"))); + + // Text in shades of grey, whatever the machine's fontconfig asks for. + // Ubuntu's asks for sub-pixel anti-aliasing, and Qt 6.4 follows it: + // the labels of a scale then have orange and blue fringes, and + // TestUiChecks, which finds the take's dots by their orange, takes + // the fringes for dots + QFont font = QApplication::font(); + font.setStyleStrategy(QFont::NoSubpixelAntialias); + QApplication::setFont(font); + + // Tier 4 runs the real pYIN plugin, which the build leaves next to + // this executable. Replace rather than extend VAMP_PATH, so that a + // pYIN installed elsewhere on this machine is never the one tested. + qputenv("VAMP_PATH", + QDir::toNativeSeparators(app.applicationDirPath()).toLocal8Bit()); + + { + TestSingingDocument t; + if (runSuite(&t, argc, argv)) ++good; + else ++bad; + } + + { + TestViewCache t; + if (runSuite(&t, argc, argv)) ++good; + else ++bad; + } + + { + TestSingingAnalysis t; + if (runSuite(&t, argc, argv)) ++good; + else ++bad; + } + + { + TestLyricsLayer t; + if (runSuite(&t, argc, argv)) ++good; + else ++bad; + } + + { + TestPlotSize t; + if (runSuite(&t, argc, argv)) ++good; + else ++bad; + } + + { + TestRecordWorkflow t; + if (runSuite(&t, argc, argv)) ++good; + else ++bad; + } + + { + TestTouchGestures t; + if (runSuite(&t, argc, argv)) ++good; + else ++bad; + } + + { + TestUiChecks t; + if (runSuite(&t, argc, argv)) ++good; + else ++bad; + } + + { + TestCompactLayout t; + if (runSuite(&t, argc, argv)) ++good; + else ++bad; + } + + { + TestTouchMenuStyle t; + if (runSuite(&t, argc, argv)) ++good; + else ++bad; + } + + { + TestAudioCheck t; + if (runSuite(&t, argc, argv)) ++good; + else ++bad; + } + + (void)good; + + if (bad > 0) { + SVCERR << "\n********* " << bad << " test suite(s) failed!\n" << endl; + return 1; + } else { + SVCERR << "All tests passed" << endl; + return 0; + } +} diff --git a/main/test/tony-core-test.cpp b/main/test/tony-core-test.cpp new file mode 100644 index 00000000..865c36bb --- /dev/null +++ b/main/test/tony-core-test.cpp @@ -0,0 +1,239 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#include "TestAndroidFiles.h" +#include "TestRealtimeYin.h" +#include "TestRealtimePitchTracker.h" +#include "TestOctaveSlips.h" +#include "TestLatencyShift.h" +#include "TestCoverage.h" +#include "TestDecodedPcm.h" +#include "TestLogFile.h" +#include "TestPinchZoom.h" +#include "TestPopupArea.h" +#include "TestStreamLatency.h" +#include "TestTakeAudio.h" +#include "TestTakeEvents.h" +#include "TestSingingTakes.h" +#include "TestTakesFile.h" +#include "TestTakeTiming.h" +#include "TestVerticalZoom.h" +#include "TestSongScroll.h" +#include "TestLyrics.h" +#include "TestLyricsTtml.h" +#include "TestLyricsEdit.h" +#include "TestLatencyCheck.h" +#include "TestLatencyCalibration.h" +#include "TestAudioDriverSettings.h" +#include "TestTakeDiff.h" +#include "TestLiveDotsFeed.h" +#include "TestRunSuite.h" + +#include "RunSuite.h" + +#include "system/Init.h" + +#include + +#include + +using namespace std; +using namespace sv; + +int main(int argc, char *argv[]) +{ + int good = 0, bad = 0; + + svSystemSpecificInitialisation(); + + // Names distinct from the application's, so that nothing here reads + // or writes the user's real Tony settings, and a shard's distinct + // from the other shards', so that shards running at once keep apart + QCoreApplication app(argc, argv); + app.setOrganizationName("tony-tests"); + app.setApplicationName(shardApplicationName + ("test-tony-core", + qEnvironmentVariable("TONY_TEST_SHARD"))); + + { + TestAndroidFiles t; + if (runSuite(&t, argc, argv)) ++good; + else ++bad; + } + + { + TestRealtimeYin t; + if (runSuite(&t, argc, argv)) ++good; + else ++bad; + } + + { + TestRealtimePitchTracker t; + if (runSuite(&t, argc, argv)) ++good; + else ++bad; + } + + { + TestOctaveSlips t; + if (runSuite(&t, argc, argv)) ++good; + else ++bad; + } + + { + TestLatencyShift t; + if (runSuite(&t, argc, argv)) ++good; + else ++bad; + } + + { + TestCoverage t; + if (runSuite(&t, argc, argv)) ++good; + else ++bad; + } + + { + TestDecodedPcm t; + if (runSuite(&t, argc, argv)) ++good; + else ++bad; + } + + { + TestLogFile t; + if (runSuite(&t, argc, argv)) ++good; + else ++bad; + } + + { + TestPinchZoom t; + if (runSuite(&t, argc, argv)) ++good; + else ++bad; + } + + { + TestPopupArea t; + if (runSuite(&t, argc, argv)) ++good; + else ++bad; + } + + { + TestStreamLatency t; + if (runSuite(&t, argc, argv)) ++good; + else ++bad; + } + + { + TestTakeAudio t; + if (runSuite(&t, argc, argv)) ++good; + else ++bad; + } + + { + TestTakeEvents t; + if (runSuite(&t, argc, argv)) ++good; + else ++bad; + } + + { + TestSingingTakes t; + if (runSuite(&t, argc, argv)) ++good; + else ++bad; + } + + { + TestTakesFile t; + if (runSuite(&t, argc, argv)) ++good; + else ++bad; + } + + { + TestTakeTiming t; + if (runSuite(&t, argc, argv)) ++good; + else ++bad; + } + + { + TestVerticalZoom t; + if (runSuite(&t, argc, argv)) ++good; + else ++bad; + } + + { + TestSongScroll t; + if (runSuite(&t, argc, argv)) ++good; + else ++bad; + } + + { + TestLyrics t; + if (runSuite(&t, argc, argv)) ++good; + else ++bad; + } + + { + TestLyricsTtml t; + if (runSuite(&t, argc, argv)) ++good; + else ++bad; + } + + { + TestLyricsEdit t; + if (runSuite(&t, argc, argv)) ++good; + else ++bad; + } + + { + TestLatencyCheck t; + if (runSuite(&t, argc, argv)) ++good; + else ++bad; + } + + { + TestLatencyCalibration t; + if (runSuite(&t, argc, argv)) ++good; + else ++bad; + } + + { + TestAudioDriverSettings t; + if (runSuite(&t, argc, argv)) ++good; + else ++bad; + } + + { + TestTakeDiff t; + if (runSuite(&t, argc, argv)) ++good; + else ++bad; + } + + { + TestLiveDotsFeed t; + if (runSuite(&t, argc, argv)) ++good; + else ++bad; + } + + { + TestRunSuite t; + if (runSuite(&t, argc, argv)) ++good; + else ++bad; + } + + (void)good; + + if (bad > 0) { + SVCERR << "\n********* " << bad << " test suite(s) failed!\n" << endl; + return 1; + } else { + SVCERR << "All tests passed" << endl; + return 0; + } +} diff --git a/main/test/tony-dev-test.cpp b/main/test/tony-dev-test.cpp new file mode 100644 index 00000000..582a7858 --- /dev/null +++ b/main/test/tony-dev-test.cpp @@ -0,0 +1,72 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ +/* + Tony + An intonation analysis and annotation tool + Centre for Digital Music, Queen Mary, University of London. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +// The development checks' own suite, built only where they are +// (TONY_DEV_CHECKS). Kept out of test-tony-app because each of its +// tests records several takes in real time: run it when a change +// touches what the checks drive (see AGENTS.md) + +#include "TestDevChecks.h" + +#include "RunSuite.h" + +#include "system/Init.h" + +#include +#include +#include +#include + +#include + +using namespace std; +using namespace sv; + +int main(int argc, char *argv[]) +{ + svSystemSpecificInitialisation(); + + // Offscreen, as test-tony-app + if (qEnvironmentVariableIsEmpty("QT_QPA_PLATFORM")) { + qputenv("QT_QPA_PLATFORM", "offscreen"); + } + + // Names distinct from the application's, so that nothing here reads + // or writes the user's real Tony settings, and a shard's distinct + // from the other shards', so that shards running at once keep apart + QApplication app(argc, argv); + app.setOrganizationName("tony-tests"); + app.setApplicationName(shardApplicationName + ("test-tony-dev", + qEnvironmentVariable("TONY_TEST_SHARD"))); + + // Text in shades of grey, as in test-tony-app + QFont font = QApplication::font(); + font.setStyleStrategy(QFont::NoSubpixelAntialias); + QApplication::setFont(font); + + // The real pYIN plugin, from next to this executable only + qputenv("VAMP_PATH", + QDir::toNativeSeparators(app.applicationDirPath()).toLocal8Bit()); + + TestDevChecks t; + bool ok = runSuite(&t, argc, argv); + + if (!ok) { + SVCERR << "\n********* the dev checks' suite failed!\n" << endl; + return 1; + } else { + SVCERR << "All tests passed" << endl; + return 0; + } +} diff --git a/meson.build b/meson.build index 41c9392d..4021fd25 100644 --- a/meson.build +++ b/meson.build @@ -71,6 +71,26 @@ elif buildtype.startswith('debug') ] endif # get_option('buildtype') +# Defines that only Tony's own code reads. They go on Tony's targets +# alone: general_defines reaches svcore too, and any change to it +# recompiles the whole of svcore for nothing +tony_defines = [] + +# The development checks (main/dev/, docs/calibrate-audio.md) are in +# every build but a release one: debug, debugoptimized, plain, minsize +# and custom alike. Packages are release builds and have none of it. +# moc has to see the flag too, for what is inside its #ifdefs +dev_checks = not buildtype.startswith('release') +dev_moc_args = [] +if dev_checks + tony_defines += [ + '-DTONY_DEV_CHECKS', + ] + dev_moc_args += [ + '-DTONY_DEV_CHECKS', + ] +endif + svcore_moc_args = [] feature_additional_libs = [] feature_include_dirs = [] @@ -83,7 +103,7 @@ boost_dep = dependency('boost') if system == 'linux' rc = [] - + bzip2_dep = dependency('bzip2', required: false) if not bzip2_dep.found() @@ -109,7 +129,7 @@ if system == 'linux' jack_dep = dependency('jack', version: '>= 0.100') libpulse_dep = dependency('libpulse', version: '>= 0.9') alsa_dep = dependency('alsa') - + portaudio_dep = dependency('portaudio-2.0', version: '>= 19', required: false) feature_dependencies = [ @@ -168,11 +188,11 @@ if system == 'linux' svcore_moc_args = [ '-DHAVE_MAD' ] - + vamp_symbol_args += [ '-Wl,--version-script=' + meson.current_source_dir() / 'vamp-plugin-sdk/vamp-plugin.map' ] - + elif system == 'darwin' svdeps_dir = meson.current_source_dir() / 'sv-dependency-builds/osx' @@ -182,7 +202,7 @@ elif system == 'darwin' else svdeps_libdir = svdeps_dir / 'lib' endif - + rc = [] general_defines += [ @@ -191,7 +211,7 @@ elif system == 'darwin' general_link_args += [ '-mmacosx-version-min=10.15' ] - + feature_defines = [ '-DHAVE_BZ2', '-DHAVE_SNDFILE', @@ -206,7 +226,7 @@ elif system == 'darwin' '-DHAVE_VDSP', '-D__MACOSX_CORE__', # for RtMidi ] - + # vDSP will be used in preference to FFTW most of the time, but # FFTW supports more fft lengths - without it we can end up with a # very slow implementation for unusual lengths. @@ -228,9 +248,9 @@ elif system == 'darwin' svcore_moc_args += [ '-DHAVE_MAD', ] - + endif - + feature_dependencies = [ vamphostsdk_dep, ] @@ -276,18 +296,21 @@ elif system == 'darwin' '-lid3tag', ] endif - + vamp_symbol_args += [ '-exported_symbols_list', meson.current_source_dir() / 'vamp-plugin-sdk/vamp-plugin.list' ] - + elif system == 'windows' if architecture == 'x86' svdeps_dir = meson.current_source_dir() / 'sv-dependency-builds/win32-mingw' - else + elif compiler == 'msvc' svdeps_dir = meson.current_source_dir() / 'sv-dependency-builds/win64-msvc' - endif # architecture + else + # MinGW/GCC x86_64: use system libraries from msys2, no svdeps needed + svdeps_dir = '' + endif # architecture / compiler windows = import('windows') rc = windows.compile_resources('icons/tony.rc') @@ -299,7 +322,20 @@ elif system == 'windows' '-D_USE_MATH_DEFINES', '-D_HAS_STD_BYTE=1', ] - + + if compiler != 'msvc' + # MinGW + C++17: rpcndr.h (via ) unconditionally typedefs + # "unsigned char byte", which collides with the C++17 std::byte enum. + # Fix by force-including a header that pre-includes with + # WIN32_LEAN_AND_MEAN (which skips rpcndr.h), setting the include + # guard so that subsequent #include directives are no-ops + # and rpcndr.h is never processed. + _fix_header = meson.current_source_dir() / 'main/mingw_byte_fix.h' + general_defines += [ + '-include', _fix_header, + ] + endif + feature_defines = [ '-DHAVE_BZ2', '-DHAVE_FFTW3', @@ -327,7 +363,8 @@ elif system == 'windows' 'sv-dependency-builds/win32-mingw/include', 'sv-dependency-builds/win32-mingw/include/opus', ] - else + elif compiler == 'msvc' + # MSVC x86_64: use pre-built dependency tree and MediaFoundation feature_defines += [ '-DHAVE_MEDIAFOUNDATION', ] @@ -336,19 +373,47 @@ elif system == 'windows' 'sv-dependency-builds/win64-msvc/include', 'sv-dependency-builds/win64-msvc/include/opus', ] - endif # architecture - - if buildtype.startswith('release') - feature_additional_libpaths = [ - '-L' + svdeps_dir / 'lib', - ] else - feature_additional_libpaths = [ - '-L' + svdeps_dir / 'lib/debug', - '-L' + svdeps_dir / 'lib', + # MinGW/GCC x86_64: system headers from msys2. + # MediaFoundation IS available in mingw-w64 headers (mfapi.h, mfidl.h, + # mfreadwrite.h, etc. all ship with the mingw-w64 packages in msys2). + # The only header that was missing was shobjidl_core.h — mingw-w64 ships + # the umbrella shobjidl.h instead. MediaFoundationReadStream.cpp now + # includes when built with __MINGW32__, so we can enable + # HAVE_MEDIAFOUNDATION here too and get .m4a / .aac / .mp4 support. + feature_defines += [ + '-DHAVE_MEDIAFOUNDATION', + ] + # Several system headers live in versioned subdirectories that code + # includes without a path prefix, so we add them explicitly. + # Use run_command to resolve the actual Windows path for the mingw prefix. + _mingw_prefix = run_command('sh', '-c', 'echo $MINGW_PREFIX', check: true).stdout().strip() + if _mingw_prefix == '' + _mingw_prefix = 'C:/msys64/mingw64' + endif + feature_include_dirs = [ + _mingw_prefix + '/include/opus', # opusfile.h includes + _mingw_prefix + '/include/sord-0', # dataquay includes + _mingw_prefix + '/include/serd-0', # dataquay includes ] + endif # architecture / compiler + + if architecture == 'x86' or compiler == 'msvc' + if buildtype.startswith('release') + feature_additional_libpaths = [ + '-L' + svdeps_dir / 'lib', + ] + else + feature_additional_libpaths = [ + '-L' + svdeps_dir / 'lib/debug', + '-L' + svdeps_dir / 'lib', + ] + endif + else + # MinGW x86_64: all libs come from the msys2 system prefix + feature_additional_libpaths = [] endif - + feature_additional_libs = [ feature_additional_libpaths, '-lbz2', @@ -377,7 +442,8 @@ elif system == 'windows' '-lwinmm', '-lws2_32', ] - elif architecture == 'x86_64' + elif compiler == 'msvc' + # MSVC x86_64 with MediaFoundation feature_additional_libs += [ # '-NODEFAULTLIB:LIBCMT', '-lsord', @@ -390,23 +456,120 @@ elif system == 'windows' '-lwinmm', '-lws2_32', ] - else + elif architecture == 'x86_64' + # MinGW x86_64: sord/serd have a -0 suffix in the msys2 packages. + # MediaFoundation libraries are available from the mingw-w64 runtime. + feature_additional_libs += [ + '-lsord-0', + '-lserd-0', + '-lmfplat', + '-lmfreadwrite', + '-lmfuuid', + '-lpropsys', + '-ladvapi32', + '-lwinmm', + '-lws2_32', + ] + else error('Build for architecture ' + architecture + ' is not supported on this platform') - endif # architecture - + endif # architecture / compiler + svcore_moc_args = [ '-DHAVE_MAD', '-DQ_OS_WIN', ] + if compiler == 'msvc' + vamp_symbol_args += [ + '-EXPORT:vampGetPluginDescriptor' + ] + else + # MinGW/GCC: use GNU ld syntax to export the Vamp entry point + vamp_symbol_args += [ + '-Wl,--export-dynamic-symbol=vampGetPluginDescriptor' + ] + endif + +elif system == 'android' + + # Cross-compiled with the NDK: see deploy/android/build-tony.sh. The + # libraries are static builds from deploy/android/build-deps.sh, found + # through pkg-config; static: true, because only then does pkg-config + # name their private libraries (ogg, opus, zix, serd) as well. As on + # Linux, less what a phone has no use for: JACK, PulseAudio, ALSA and + # PortAudio (with RtMidi's ALSA defines), and oggz and fishsound (Ogg + # Vorbis). Opus is read-only: there is no libopusenc. + + if architecture == 'aarch64' + android_abi = 'arm64-v8a' + else + error('Build for architecture ' + architecture + ' is not supported on this platform') + endif # architecture + + rc = [] + + bzip2_dep = dependency('bzip2', static: true) + fftw3_dep = dependency('fftw3', version: '>= 3.0.0', static: true) + sndfile_dep = dependency('sndfile', version: '>= 1.0.16', static: true) + samplerate_dep = dependency('samplerate', version: '>= 0.1.2', static: true) + rubberband_dep = dependency('rubberband', version: '>= 3.0.0', static: true) + sord_dep = dependency('sord-0', version: '>= 0.5', static: true) + serd_dep = dependency('serd-0', version: '>= 0.5', static: true) + mad_dep = dependency('mad', version: '>= 0.15.0', static: true) + id3tag_dep = dependency('id3tag', version: '>= 0.15.0', static: true) + opus_dep = dependency('opusfile', static: true) + # The audio device: bqaudioio has no Android backend (main/OboeAudioIO) + oboe_dep = dependency('oboe', static: true) + + feature_dependencies = [ + vamphostsdk_dep, + bzip2_dep, + fftw3_dep, + sndfile_dep, + samplerate_dep, + rubberband_dep, + sord_dep, + serd_dep, + mad_dep, + id3tag_dep, + opus_dep, + oboe_dep, + ] + + feature_defines = [ + '-DHAVE_BZ2', + '-DHAVE_FFTW3', + '-DFFTW_DOUBLE_ONLY', + '-DHAVE_SNDFILE', + '-DHAVE_LIBSAMPLERATE', + '-DHAVE_RUBBERBAND', + '-DHAVE_SORD', '-DUSE_SORD', + '-DHAVE_SERD', + '-DHAVE_MAD', + '-DHAVE_ID3TAG', + '-DHAVE_OPUS', + '-DHAVE_OPUS_READ_ONLY', + ] + + svcore_moc_args = [ + '-DHAVE_MAD' + ] + vamp_symbol_args += [ - '-EXPORT:vampGetPluginDescriptor' + '-Wl,--version-script=' + meson.current_source_dir() / 'vamp-plugin-sdk/vamp-plugin.map' + ] + + # Android's system log, which main.cpp passes stdout and stderr on to; + # and its media extractors and decoders (main/AndroidMediaReadStream) + feature_additional_libs = [ + '-llog', + '-lmediandk', ] else error('This operating system ("' + system + '") is not supported by this build file') endif # system - + all_include_dirs = include_directories( [ 'bqvec', @@ -918,7 +1081,7 @@ svgui_moc_files = qt.preprocess( 'svgui/widgets/WindowShapePreview.h', 'svgui/widgets/WindowTypeSelector.h', ]) - + svapp_files = [ 'svapp/align/Align.cpp', 'svapp/align/ExternalProgramAligner.cpp', @@ -1018,18 +1181,122 @@ chp_plugin = shared_library( install: true, ) -tony_main_files = [ +# main.cpp is kept apart from the rest of main/ so that the rest can be +# built once, as static libraries, and linked into both the application +# and the test executables (see main/test/). +tony_entry_files = [ 'main/main.cpp', +] + +# No GUI dependencies: usable from a QCoreApplication test. +tony_core_files = [ + 'main/AndroidFiles.cpp', + 'main/AudioRoute.cpp', + 'main/AudioDriverSettings.cpp', + 'main/Coverage.cpp', + 'main/DecodedPcm.cpp', + 'main/LogFile.cpp', + 'main/LatencyCalibration.cpp', + 'main/LatencyCheck.cpp', + 'main/LiveDotsFeed.cpp', + 'main/Lyrics.cpp', + 'main/LyricsEdit.cpp', + 'main/LyricsTtml.cpp', + 'main/OctaveSlips.cpp', + 'main/PinchZoom.cpp', + 'main/PopupArea.cpp', + 'main/RealtimePitchTracker.cpp', + 'main/SingingTakes.cpp', + 'main/SongScroll.cpp', + 'main/StreamLatency.cpp', + 'main/TakeAudio.cpp', + 'main/TakeDiff.cpp', + 'main/TakeEvents.cpp', + 'main/TakesFile.cpp', + 'main/TakeTiming.cpp', + 'main/VerticalZoom.cpp', +] + +# Android's decoders for the audio its libraries cannot read (M4A, FLAC, +# Ogg): a bqaudiostream reader that registers itself, which only link_whole +# keeps in the application, nothing referring to it +if system == 'android' + tony_core_files += [ + 'main/AndroidMediaReadStream.cpp', + ] +endif + +tony_app_files = [ + 'main/AlternatePitchTrack.cpp', + 'main/AudioCheckIndicator.cpp', + 'main/AudioCheckRunner.cpp', + 'main/AudioDriverMenus.cpp', + 'main/CalibrateAudioDialog.cpp', + 'main/CompactLayout.cpp', + 'main/CoverageStrip.cpp', + 'main/LyricsTrack.cpp', + 'main/LyricsEditor.cpp', + 'main/LyricsSize.cpp', 'main/Analyser.cpp', 'main/MainWindow.cpp', 'main/NetworkPermissionTester.cpp', + 'main/PaneUtils.cpp', + 'main/PlotSize.cpp', + 'main/SongScrollBar.cpp', + 'main/TakeCommands.cpp', + 'main/TakeLayers.cpp', + 'main/TouchGestures.cpp', + 'main/TouchMenuStyle.cpp', ] -tony_main_moc_files = qt.preprocess( +if system == 'android' + tony_app_files += [ + 'main/AndroidScreen.cpp', + 'main/AndroidStorage.cpp', + 'main/OboeAudioIO.cpp', + ] +endif + +tony_core_moc_files = qt.preprocess( moc_headers: [ + 'main/RealtimePitchTracker.h', + 'main/SingingTakes.h', +]) + +tony_app_moc_headers = [ 'main/MainWindow.h', 'main/Analyser.h', -]) + 'main/AlternatePitchTrack.h', + 'main/AudioCheckIndicator.h', + 'main/AudioCheckRunner.h', + 'main/AudioDriverMenus.h', + 'main/CalibrateAudioDialog.h', + 'main/CompactLayout.h', + 'main/CoverageStrip.h', + 'main/LyricsTrack.h', + 'main/LyricsEditor.h', + 'main/LyricsSize.h', + 'main/PlotSize.h', + 'main/SongScrollBar.h', + 'main/TouchGestures.h', + 'main/TouchMenuStyle.h', +] + +if dev_checks + tony_app_files += [ + 'main/dev/DevChecks.cpp', + 'main/dev/TakeObserver.cpp', + ] + tony_app_moc_headers += [ + 'main/dev/DevChecks.h', + 'main/dev/TakeObserver.h', + ] +endif + +tony_app_moc_files = qt.preprocess( + moc_headers: tony_app_moc_headers, + moc_extra_arguments: dev_moc_args, +) qt_resource_files = qt.preprocess( qresources: [ @@ -1069,6 +1336,10 @@ endif if system == 'linux' tony_main_name = 'tony' +elif system == 'android' + # androiddeployqt looks for lib_.so, and the + # deployment settings name the application-binary Tony + tony_main_name = 'Tony_' + android_abi else tony_main_name = 'Tony' endif # system @@ -1083,129 +1354,380 @@ install_data( install_dir : get_option('datadir') / 'icons/hicolor/scalable/apps', ) -executable( - tony_main_name, - qt_resource_files, - svgui_moc_files, - svapp_moc_files, - tony_main_moc_files, - svgui_files, - svapp_files, - tony_main_files, - rc, +tony_core_lib = static_library( + 'tonycore', + tony_core_moc_files, + tony_core_files, dependencies: [ svcore_dep, qt_dep, feature_dependencies, - os_dep, - dl_dep ], cpp_args: [ feature_defines, general_defines, + tony_defines, ], - link_args: [ - feature_additional_libs, - general_link_args, - ], - win_subsystem: 'windows', - install: true, ) -svcore_base_test_exe = executable( - 'test-svcore-base', - svcore_base_test_moc_files, - 'svcore/base/test/svcore-base-test.cpp', - dependencies: [ - svcore_dep, - qt_dep, - feature_dependencies, - dl_dep, - ], - cpp_args: [ - feature_defines, - general_defines, - ], - link_args: [ - feature_additional_libs, - general_link_args, - ], - win_subsystem: 'console' +tony_core_dep = declare_dependency( + link_with: tony_core_lib, + include_directories: [ 'main' ], ) -svcore_system_test_exe = executable( - 'test-svcore-system', - svcore_system_test_moc_files, - 'svcore/system/test/svcore-system-test.cpp', +tony_app_lib = static_library( + 'tonyapp', + svgui_moc_files, + svapp_moc_files, + tony_app_moc_files, + svgui_files, + svapp_files, + tony_app_files, dependencies: [ svcore_dep, qt_dep, feature_dependencies, - dl_dep, ], cpp_args: [ feature_defines, general_defines, + tony_defines, ], - link_args: [ - feature_additional_libs, - general_link_args, - ], - win_subsystem: 'console' ) -svcore_data_model_test_exe = executable( - 'test-svcore-data-model', - svcore_data_model_test_moc_files, - 'svcore/data/model/test/MockWaveModel.cpp', - 'svcore/data/model/test/svcore-data-model-test.cpp', - dependencies: [ - svcore_dep, - qt_dep, - feature_dependencies, - dl_dep, - ], - cpp_args: [ - feature_defines, - general_defines, - ], - link_args: [ - feature_additional_libs, - general_link_args, - ], - win_subsystem: 'console' +tony_app_dep = declare_dependency( + link_with: tony_app_lib, + include_directories: [ 'main' ], ) -svcore_data_fileio_test_exe = executable( - 'test-svcore-data-fileio', - svcore_data_fileio_test_moc_files, - 'svcore/data/model/test/MockWaveModel.cpp', - 'svcore/data/fileio/test/UnsupportedFormat.cpp', - 'svcore/data/fileio/test/svcore-data-fileio-test.cpp', - dependencies: [ - svcore_dep, - qt_dep, - feature_dependencies, - dl_dep, - ], - cpp_args: [ - feature_defines, - general_defines, - ], - link_args: [ - feature_additional_libs, - general_link_args, - ], - win_subsystem: 'console' -) +# link_whole so that the application contains exactly the objects it did +# when these sources were compiled straight into it. +if system == 'android' + # Qt for Android's Java launcher loads the application as a shared + # library and calls its main(), the one symbol it has to export. + # --exclude-libs keeps whatever comes from a static library (Tony's + # own, svcore, the C libraries) out of its dynamic symbol table. + shared_library( + tony_main_name, + qt_resource_files, + tony_entry_files, + link_whole: [ + tony_app_lib, + tony_core_lib, + ], + dependencies: [ + svcore_dep, + qt_dep, + feature_dependencies, + os_dep, + dl_dep + ], + cpp_args: [ + feature_defines, + general_defines, + tony_defines, + ], + link_args: [ + feature_additional_libs, + general_link_args, + '-Wl,--exclude-libs,ALL', + ], + install: true, + ) +else + executable( + tony_main_name, + qt_resource_files, + tony_entry_files, + rc, + link_whole: [ + tony_app_lib, + tony_core_lib, + ], + dependencies: [ + svcore_dep, + qt_dep, + feature_dependencies, + os_dep, + dl_dep + ], + cpp_args: [ + feature_defines, + general_defines, + tony_defines, + ], + link_args: [ + feature_additional_libs, + general_link_args, + ], + win_subsystem: 'windows', + install: true, + ) +endif # system + +# The tests run on the desktop only: the Android build is the +# application and its plugins. +if system != 'android' + + svcore_base_test_exe = executable( + 'test-svcore-base', + svcore_base_test_moc_files, + 'svcore/base/test/svcore-base-test.cpp', + dependencies: [ + svcore_dep, + qt_dep, + feature_dependencies, + dl_dep, + ], + cpp_args: [ + feature_defines, + general_defines, + ], + link_args: [ + feature_additional_libs, + general_link_args, + ], + win_subsystem: 'console' + ) + + svcore_system_test_exe = executable( + 'test-svcore-system', + svcore_system_test_moc_files, + 'svcore/system/test/svcore-system-test.cpp', + dependencies: [ + svcore_dep, + qt_dep, + feature_dependencies, + dl_dep, + ], + cpp_args: [ + feature_defines, + general_defines, + ], + link_args: [ + feature_additional_libs, + general_link_args, + ], + win_subsystem: 'console' + ) + + svcore_data_model_test_exe = executable( + 'test-svcore-data-model', + svcore_data_model_test_moc_files, + 'svcore/data/model/test/MockWaveModel.cpp', + 'svcore/data/model/test/svcore-data-model-test.cpp', + dependencies: [ + svcore_dep, + qt_dep, + feature_dependencies, + dl_dep, + ], + cpp_args: [ + feature_defines, + general_defines, + ], + link_args: [ + feature_additional_libs, + general_link_args, + ], + win_subsystem: 'console' + ) + + svcore_data_fileio_test_exe = executable( + 'test-svcore-data-fileio', + svcore_data_fileio_test_moc_files, + 'svcore/data/model/test/MockWaveModel.cpp', + 'svcore/data/fileio/test/UnsupportedFormat.cpp', + 'svcore/data/fileio/test/svcore-data-fileio-test.cpp', + dependencies: [ + svcore_dep, + qt_dep, + feature_dependencies, + dl_dep, + ], + cpp_args: [ + feature_defines, + general_defines, + ], + link_args: [ + feature_additional_libs, + general_link_args, + ], + win_subsystem: 'console' + ) -test('svcore-base', svcore_base_test_exe) -test('svcore-system', svcore_system_test_exe) -test('svcore-data-model', svcore_data_model_test_exe) -test('svcore-data-fileio', svcore_data_fileio_test_exe, - args: [ - '--testdir', meson.current_source_dir() / 'svcore/data/fileio/test' - ]) + # Tony's own tests. Two executables here, because they differ in cost + # and in what they link: test-tony-core needs no GUI and no audio + # device; test-tony-app creates widgets (offscreen) and links svgui and + # svapp. The dev checks' test-tony-dev is a third, below. + + tony_core_test_moc_files = qt.preprocess( + moc_headers: [ + 'main/test/TestAndroidFiles.h', + 'main/test/TestRealtimeYin.h', + 'main/test/TestRealtimePitchTracker.h', + 'main/test/TestOctaveSlips.h', + 'main/test/TestLatencyShift.h', + 'main/test/TestCoverage.h', + 'main/test/TestDecodedPcm.h', + 'main/test/TestLogFile.h', + 'main/test/TestPinchZoom.h', + 'main/test/TestPopupArea.h', + 'main/test/TestStreamLatency.h', + 'main/test/TestTakeAudio.h', + 'main/test/TestTakeEvents.h', + 'main/test/TestSingingTakes.h', + 'main/test/TestTakesFile.h', + 'main/test/TestTakeTiming.h', + 'main/test/TestVerticalZoom.h', + 'main/test/TestSongScroll.h', + 'main/test/TestLyrics.h', + 'main/test/TestLyricsTtml.h', + 'main/test/TestLyricsEdit.h', + 'main/test/TestLatencyCheck.h', + 'main/test/TestLatencyCalibration.h', + 'main/test/TestAudioDriverSettings.h', + 'main/test/TestTakeDiff.h', + 'main/test/TestLiveDotsFeed.h', + 'main/test/TestRunSuite.h', + ]) + + # Where the suites find the files in testdata/. Forward slashes: a + # backslash of a Windows path would start an escape in the C string. + tony_test_data_dir = import('fs').as_posix(meson.current_source_dir() / 'testdata') + + tony_core_test_exe = executable( + 'test-tony-core', + tony_core_test_moc_files, + 'main/test/PyinReference.cpp', + 'pyin/YinUtil.cpp', + 'vamp-plugin-sdk/src/vamp-sdk/FFT.cpp', + 'main/test/tony-core-test.cpp', + dependencies: [ + tony_core_dep, + svcore_dep, + qt_dep, + feature_dependencies, + dl_dep, + ], + cpp_args: [ + feature_defines, + general_defines, + tony_defines, + '-DTONY_TEST_DATA_DIR="' + tony_test_data_dir + '"', + ], + link_args: [ + feature_additional_libs, + general_link_args, + ], + win_subsystem: 'console' + ) + + tony_app_test_moc_headers = [ + 'main/test/TestSingingDocument.h', + 'main/test/TestViewCache.h', + 'main/test/TestSingingAnalysis.h', + 'main/test/TestRecordWorkflow.h', + 'main/test/TestLyricsLayer.h', + 'main/test/TestPlotSize.h', + 'main/test/TestUiChecks.h', + 'main/test/TestAudioCheck.h', + 'main/test/TestTouchGestures.h', + 'main/test/TestCompactLayout.h', + 'main/test/TestTouchMenuStyle.h', + ] + + tony_app_test_moc_files = qt.preprocess( + moc_headers: tony_app_test_moc_headers, + moc_extra_arguments: dev_moc_args, + ) + + tony_app_test_exe = executable( + 'test-tony-app', + tony_app_test_moc_files, + 'main/test/tony-app-test.cpp', + # The icons: without them every toolbar button shows its text, and + # is as wide as that (TestCompactLayout needs the buttons' real size) + qt_resource_files, + dependencies: [ + tony_app_dep, + tony_core_dep, + svcore_dep, + qt_dep, + feature_dependencies, + os_dep, + dl_dep, + ], + cpp_args: [ + feature_defines, + general_defines, + tony_defines, + '-DTONY_TEST_DATA_DIR="' + tony_test_data_dir + '"', + ], + link_args: [ + feature_additional_libs, + general_link_args, + ], + win_subsystem: 'console' + ) + + test('tony-core', tony_core_test_exe) + test('tony-app', tony_app_test_exe, + depends: pyin_plugin, + timeout: 1800, + env: [ 'QT_QPA_PLATFORM=offscreen' ]) + + # The development checks' suite, only where the checks are built. Its + # own executable: every test records takes in real time, and it is run + # when a change touches what the checks drive (AGENTS.md) + if dev_checks + tony_dev_test_moc_files = qt.preprocess( + moc_headers: [ + 'main/test/TestDevChecks.h', + ], + moc_extra_arguments: dev_moc_args, + ) + + tony_dev_test_exe = executable( + 'test-tony-dev', + tony_dev_test_moc_files, + 'main/test/tony-dev-test.cpp', + dependencies: [ + tony_app_dep, + tony_core_dep, + svcore_dep, + qt_dep, + feature_dependencies, + os_dep, + dl_dep, + ], + cpp_args: [ + feature_defines, + general_defines, + tony_defines, + ], + link_args: [ + feature_additional_libs, + general_link_args, + ], + win_subsystem: 'console' + ) + + test('tony-dev', tony_dev_test_exe, + depends: pyin_plugin, + timeout: 900, + env: [ 'QT_QPA_PLATFORM=offscreen' ]) + endif + + test('svcore-base', svcore_base_test_exe) + test('svcore-system', svcore_system_test_exe) + test('svcore-data-model', svcore_data_model_test_exe) + test('svcore-data-fileio', svcore_data_fileio_test_exe, + args: [ + '--testdir', meson.current_source_dir() / 'svcore/data/fileio/test' + ]) + +endif # system summary({'prefix': get_option('prefix'), 'bindir': get_option('bindir'), diff --git a/repoint-lock.json b/repoint-lock.json index d3404c23..f83695bd 100644 --- a/repoint-lock.json +++ b/repoint-lock.json @@ -4,13 +4,13 @@ "pin": "d7ceb7d1d490674c93d334e5378108c4328e9e05" }, "svcore": { - "pin": "65eeb62d0c966f4c15db78ba28eab8d4a8ea0274" + "pin": "f9f333084b608a2c5b31ea7522399be10f959ba2" }, "svgui": { - "pin": "d46bd4b46a13be38c56f6704911b53cb1b6e3ceb" + "pin": "ca7162039fc6bd2af3228deab02d1ca16e84df22" }, "svapp": { - "pin": "f51a1690f3f92e2421916d39a3385d49214e60c6" + "pin": "4a7a6f807e6a8f4e9ed7ab167944f00e17c1522e" }, "checker": { "pin": "fae540cf4a79ac5ed5a4d4dc0df680b1acbe8628" @@ -31,10 +31,10 @@ "pin": "38c3e524416a" }, "bqaudioio": { - "pin": "88520ab5b8e5" + "pin": "e7c836fc1199340e55fabb40ce29a8f2a655be6a" }, "bqaudiostream": { - "pin": "5d5950bc4d93" + "pin": "9b59e706f9dda8507c5e1b07ad619c060f65bd29" }, "bqthingfactory": { "pin": "2e4bd170f57f" diff --git a/repoint-project.json b/repoint-project.json index 72812469..5bf8ce9b 100644 --- a/repoint-project.json +++ b/repoint-project.json @@ -11,20 +11,20 @@ "svcore": { "vcs": "git", "service": "github", - "owner": "sonic-visualiser", - "branch": "default" + "owner": "jhhr", + "branch": "tony-customizations" }, "svgui": { "vcs": "git", "service": "github", - "owner": "sonic-visualiser", - "branch": "default" + "owner": "jhhr", + "branch": "tony-customizations" }, "svapp": { "vcs": "git", "service": "github", - "owner": "sonic-visualiser", - "branch": "default" + "owner": "jhhr", + "branch": "tony-customizations" }, "checker": { "vcs": "git", @@ -59,14 +59,16 @@ "owner": "breakfastquay" }, "bqaudioio": { - "vcs": "hg", - "service": "sourcehut", - "owner": "breakfastquay" + "vcs": "git", + "service": "github", + "owner": "jhhr", + "branch": "tony-customizations" }, "bqaudiostream": { - "vcs": "hg", - "service": "sourcehut", - "owner": "breakfastquay" + "vcs": "git", + "service": "github", + "owner": "jhhr", + "branch": "master" }, "bqthingfactory": { "vcs": "hg", @@ -97,5 +99,4 @@ "owner": "c4dm" } } -} - +} \ No newline at end of file diff --git a/testdata/lyrics/amll-style.ttml b/testdata/lyrics/amll-style.ttml new file mode 100644 index 00000000..88e8dacb --- /dev/null +++ b/testdata/lyrics/amll-style.ttml @@ -0,0 +1,5 @@ +

+

Pöllö huhuilee ja kuuAn owl hoots and the moon(taustalla huuto)

+

nousee metsän yllerises over the forest

+

Hiljaa hiljaa

+
diff --git a/testdata/lyrics/lrc-with-ends.lrc b/testdata/lyrics/lrc-with-ends.lrc new file mode 100644 index 00000000..3792903f --- /dev/null +++ b/testdata/lyrics/lrc-with-ends.lrc @@ -0,0 +1,11 @@ +[ar:Testiryhmä] +[ti:Päivä [Live]] +[al:Keksitty levy] +[by:Tonyn testit] +[offset:0] + +[00:01.00]<00:01.00>Päivä <00:01.50>paistaa<00:02.20> +[00:03.00]<00:03.00>Pöllö <00:03.40>huhuilee +[00:04.80] +[00:06.00]Koko rivi yhdellä leimalla +[00:08.50] diff --git a/testdata/lyrics/moises-exporter-lines.lrc b/testdata/lyrics/moises-exporter-lines.lrc new file mode 100644 index 00000000..b97444ab --- /dev/null +++ b/testdata/lyrics/moises-exporter-lines.lrc @@ -0,0 +1,10 @@ +[ti:Kesäyön testilaulu] +[re:Moises Lyrics Exporter Pro] +[ve:2.0] +[co:avg_confidence=1.000; low_confidence_count=0] + +[00:00.00] Alku ennen nollaa +[00:04.64] Tämä on keksittylaulu, +[00:06.00] ♪ +[00:21.50] Yö on hämärä +[00:23.40] Nyt seloppuu! diff --git a/testdata/lyrics/moises-exporter-lines.ttml b/testdata/lyrics/moises-exporter-lines.ttml new file mode 100644 index 00000000..e91685b8 --- /dev/null +++ b/testdata/lyrics/moises-exporter-lines.ttml @@ -0,0 +1,15 @@ + + + + + + + + +
+

Tämä on keksitty laulu,

+

Yö on hämärä, kuu nousee.

+

Nyt se loppuu!

+
+ +
diff --git a/testdata/lyrics/moises-exporter-words.lrc b/testdata/lyrics/moises-exporter-words.lrc new file mode 100644 index 00000000..6a934a84 --- /dev/null +++ b/testdata/lyrics/moises-exporter-words.lrc @@ -0,0 +1,10 @@ +[ti:Kesäyön testilaulu] +[re:Moises Lyrics Exporter Pro] +[ve:2.0] +[co:avg_confidence=1.000; low_confidence_count=0] + +[00:00.000] <00:00.000>Alku <00:00.000>ennen <00:00.310>nollaa +[00:04.640] <00:04.640>Tämä <00:04.920>on <00:05.160>keksitty<00:05.620>laulu, +[00:06.000] ♪ +[00:21.500] <00:21.500>Yö <00:21.800>on <00:22.050>hämärä +[00:23.400] <00:23.400>Nyt <00:23.700>se<00:24.000>loppuu! diff --git a/testdata/lyrics/moises-exporter-words.ttml b/testdata/lyrics/moises-exporter-words.ttml new file mode 100644 index 00000000..3aed05da --- /dev/null +++ b/testdata/lyrics/moises-exporter-words.ttml @@ -0,0 +1,15 @@ + + + + + + + + +
+

Tämä on keksitty laulu,

+

Yö on hämärä, kuu nousee.

+

Nyt se loppuu!

+
+ +