diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5e77c7004..2e6d0bccd 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,5 +1,6 @@ -# This starter workflow is for a CMake project running on multiple platforms. There is a different starter workflow if you just want a single platform. -# See: https://github.com/actions/starter-workflows/blob/main/ci/cmake-single-platform.yml +# Build matrix: each platform job installs Qt, runs ./configure.sh (which +# handles deps, qtapng, themes, cmake+ninja, and windeployqt on Windows), +# then deploys + uploads. name: CI Build on: @@ -28,84 +29,39 @@ jobs: - uses: actions/checkout@master with: submodules: recursive - + - name: Install Qt uses: jurplel/install-qt-action@v4 with: version: 6.5.3 target: desktop arch: win64_mingw + tools: 'tools_mingw1310' cache: true cache-key-prefix: install-qt-action - modules: 'qtimageformats qtwebsockets' + modules: 'qtimageformats qtwebsockets qtmultimedia' - - name: Install Windows Discord RPC - shell: bash - run: | - curl -L https://github.com/discordapp/discord-rpc/releases/download/v3.4.0/discord-rpc-win.zip -o discord_rpc.zip - unzip discord_rpc.zip - cp ./discord-rpc/win64-dynamic/lib/discord-rpc.lib ./lib/ - cp ./discord-rpc/win64-dynamic/bin/discord-rpc.dll ./bin/ - cp ./discord-rpc/win64-dynamic/include/discord*.h ./lib/ - - - name: Install Windows BASS + - name: Configure and build shell: bash - run: | - curl http://www.un4seen.com/files/bass24.zip -o bass.zip - unzip -d bass -o bass.zip - cp ./bass/c/bass.h ./lib - cp ./bass/c/x64/bass.lib ./lib/ - cp ./bass/x64/bass.dll ./bin/ - - curl http://www.un4seen.com/files/bassopus24.zip -o bassopus.zip - unzip -d bass -o bassopus.zip - cp ./bass/c/bassopus.h ./lib - cp ./bass/c/x64/bassopus.lib ./lib/ - cp ./bass/x64/bassopus.dll ./bin/ - - - name: Clone Apng plugin - uses: actions/checkout@master - with: - repository: jurplel/QtApng - path: ./qtapng - - - name: Build Apng plugin - run: | - cd ./qtapng - cmake . -G "MinGW Makefiles" - cmake --build . --config Release - mkdir -p ${{ github.workspace }}/bin/imageformats/ - cp plugins/imageformats/qapng.dll ${{ github.workspace }}/bin/imageformats/qapng.dll + run: ./configure.sh QT_PATH="$QT_ROOT_DIR" BUILD_TYPE=Release - - name: Build - run: | - cmake . -G "MinGW Makefiles" -D CMAKE_BUILD_TYPE=Release - cmake --build . --config Release - - - name: Deploy Windows - working-directory: ${{github.workspace}}/bin/ + - name: Run tests shell: bash - run: | - windeployqt --no-quick-import --no-translations --no-compiler-runtime --no-opengl-sw ./Attorney_Online.exe + run: ctest --output-on-failure - - name: Clone Themes - uses: actions/checkout@master - with: - repository: AttorneyOnline/AO2-Themes - path: "bin/base/themes" - - - name: Cleanup Themes Checkout + - name: Stage MinGW runtime DLLs + shell: pwsh run: | - rm ./bin/base/themes/.gitignore - rm ./bin/base/themes/.gitattributes - Remove-Item -Path "./bin/base/themes/.git" -Recurse -Force + Copy-Item "$Env:IQTA_TOOLS\mingw1310_64\bin\libgcc_s_seh-1.dll" bin\ + Copy-Item "$Env:IQTA_TOOLS\mingw1310_64\bin\libstdc++-6.dll" bin\ + Copy-Item "$Env:IQTA_TOOLS\mingw1310_64\bin\libwinpthread-1.dll" bin\ - name: Upload Artifact uses: actions/upload-artifact@master with: name: Attorney_Online-Windows - path: ${{github.workspace}}/bin - + path: ${{ github.workspace }}/bin + build-linux: needs: formatting-check runs-on: ubuntu-22.04 @@ -124,103 +80,55 @@ jobs: arch: 'gcc_64' cache: true cache-key-prefix: install-qt-action - modules: 'qtimageformats qtwebsockets' + modules: 'qtimageformats qtwebsockets qtmultimedia' - - name: Install Linux Discord RPC - run: | - curl -L https://github.com/discordapp/discord-rpc/releases/download/v3.4.0/discord-rpc-linux.zip -o discord_rpc.zip - unzip discord_rpc.zip - cp ./discord-rpc/linux-dynamic/lib/libdiscord-rpc.so ./lib/ - cp ./discord-rpc/linux-dynamic/lib/libdiscord-rpc.so ./bin/ - cp ./discord-rpc/linux-dynamic/include/discord*.h ./src/ + - name: Install system build tools + run: sudo apt-get update && sudo apt-get install -y ninja-build patchelf libxcb-cursor0 - - name: Install Linux BASS - run: | - curl http://www.un4seen.com/files/bass24-linux.zip -o bass.zip - unzip -d bass -o bass.zip - cp ./bass/c/bass.h ./lib - cp ./bass/libs/x86_64/libbass.so ./lib/ - cp ./bass/libs/x86_64/libbass.so ./bin/ - - curl http://www.un4seen.com/files/bassopus24-linux.zip -o bassopus.zip - unzip -d bass -o bassopus.zip - cp ./bass/c/bassopus.h ./lib - cp ./bass/libs/x86_64/libbassopus.so ./lib/ - cp ./bass/libs/x86_64/libbassopus.so ./bin/ - - - name: Clone Apng plugin - uses: actions/checkout@master - with: - repository: jurplel/QtApng - path: ./qtapng + - name: Configure and build + run: ./configure.sh QT_PATH="$QT_ROOT_DIR" BUILD_TYPE=Release - - name: Build Apng plugin - run: | - cd ./qtapng - cmake . -D CMAKE_LIBRARY_OUTPUT_DIRECTORY_RELEASE="${{ github.workspace }}/bin/imageformats/" - cmake --build . --config Release + - name: Run tests + env: + QT_QPA_PLATFORM: offscreen + run: ctest --output-on-failure - # install plugin - cp plugins/imageformats/libqapng.so ${QT_ROOT_DIR}/plugins/imageformats - - - name: Build + - name: Stage APNG plugin for AppImage run: | - cmake . - cmake --build . --config Release - - - name: Clone Themes - uses: actions/checkout@master - with: - repository: AttorneyOnline/AO2-Themes - path: "bin/base/themes" + mkdir -p "$QT_ROOT_DIR/plugins/imageformats" + cp qtapng/plugins/imageformats/libqapng.so "$QT_ROOT_DIR/plugins/imageformats/" - - name: Cleanup Themes Checkout - run: | - rm ./bin/base/themes/.gitignore - rm ./bin/base/themes/.gitattributes - rm -r ./bin/base/themes/.git - - - name: Deploy Linux + - name: Package dynamic tarball shell: bash run: | - cd ${{ github.workspace }}/bin - mkdir ./imageformats - cp ../qtapng/plugins/imageformats/libqapng.so ./imageformats + cd "${{ github.workspace }}/bin" cp ../data/logo-client.png ./icon.png cp ../README_LINUX.md . cp ../scripts/DYNAMIC_INSTALL.sh ./INSTALL.sh - chmod +x INSTALL.sh - chmod +x Attorney_Online - + chmod +x INSTALL.sh Attorney_Online patchelf --add-rpath . Attorney_Online - cd .. tar --transform='flags=r;s|bin|Attorney Online|' -cvf Attorney_Online-Dynamic.tar bin - - name: Create AppImage + - name: Build AppImage shell: bash run: | - # necessary, apparently - sudo apt install libxcb-cursor0 # from https://github.com/probonopd/go-appimage/blob/master/src/appimagetool/README.md wget -c https://github.com/$(wget -q https://github.com/probonopd/go-appimage/releases/expanded_assets/continuous -O - | grep "appimagetool-.*-x86_64.AppImage" | head -n 1 | cut -d '"' -f 2) mv appimagetool-*-x86_64.AppImage appimagetool chmod +x appimagetool - mkdir -p AppDir/usr/bin - mkdir -p AppDir/usr/lib/plugins/imageformats - mkdir -p AppDir/usr/share/applications - + mkdir -p AppDir/usr/bin AppDir/usr/lib/plugins/imageformats AppDir/usr/share/applications cp bin/Attorney_Online AppDir/usr/bin - cp bin/lib*.so AppDir/usr/lib + cp bin/lib*.so AppDir/usr/lib 2>/dev/null || true cp scripts/Attorney_Online.desktop AppDir/usr/share/applications cp data/logo-client.png AppDir/Attorney_Online.png GIT_SHORT_SHA="${GITHUB_SHA::8}" - QTDIR=${QT_ROOT_DIR} ./appimagetool deploy AppDir/usr/share/applications/Attorney_Online.desktop + QTDIR="$QT_ROOT_DIR" ./appimagetool deploy AppDir/usr/share/applications/Attorney_Online.desktop ARCH=x86_64 VERSION=${GIT_SHORT_SHA} ./appimagetool AppDir - - name: Deploy AppImage + - name: Package AppImage tarball shell: bash run: | mkdir bin-appimage @@ -229,9 +137,7 @@ jobs: cp README_LINUX.md bin-appimage cp scripts/APPIMAGE_INSTALL.sh bin-appimage/INSTALL.sh cp Attorney_Online-*-x86_64.AppImage bin-appimage - chmod +x bin-appimage/INSTALL.sh - chmod +x bin-appimage/Attorney_Online-*-x86_64.AppImage - + chmod +x bin-appimage/INSTALL.sh bin-appimage/Attorney_Online-*-x86_64.AppImage tar --transform='flags=r;s|bin-appimage|Attorney Online|' -cvf Attorney_Online-AppImage.tar bin-appimage - name: Upload Dynamic Artifact diff --git a/CMakeLists.txt b/CMakeLists.txt index ebaaae253..f5c9376e1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -15,7 +15,7 @@ option(AO_BUILD_TESTS "Build test programs" ON) option(AO_ENABLE_DISCORD_RPC "Enable Discord Rich Presence" ON) find_package(QT NAMES Qt6) -find_package(Qt6 REQUIRED COMPONENTS Core Gui Network Widgets Concurrent WebSockets UiTools) +find_package(Qt6 REQUIRED COMPONENTS Core Gui Network Widgets Concurrent WebSockets UiTools Multimedia) qt_add_executable(Attorney_Online src/aoapplication.cpp @@ -77,6 +77,8 @@ qt_add_executable(Attorney_Online src/hardware_functions.h src/lobby.cpp src/lobby.h + src/loopsidecar.cpp + src/loopsidecar.h src/main.cpp src/network/websocketconnection.cpp src/network/websocketconnection.h @@ -125,8 +127,7 @@ target_link_libraries(Attorney_Online PRIVATE Qt${QT_VERSION_MAJOR}::Concurrent Qt${QT_VERSION_MAJOR}::WebSockets Qt${QT_VERSION_MAJOR}::UiTools - bass - bassopus + Qt${QT_VERSION_MAJOR}::Multimedia ) if(AO_ENABLE_DISCORD_RPC) @@ -135,6 +136,7 @@ if(AO_ENABLE_DISCORD_RPC) endif() if(AO_BUILD_TESTS) + enable_testing() add_subdirectory(test) endif() diff --git a/cmake/FindWrapOpenGL.cmake b/cmake/FindWrapOpenGL.cmake new file mode 100644 index 000000000..737b0b2ac --- /dev/null +++ b/cmake/FindWrapOpenGL.cmake @@ -0,0 +1,43 @@ +# Override for Qt's FindWrapOpenGL.cmake on macOS. +# +# Qt 6.5–6.8's bundled FindWrapOpenGL.cmake unconditionally adds +# `-framework AGL` to QtGui's public link interface. AGL was removed from +# the macOS SDK around Xcode 16 / macOS 15, so the link fails with +# "framework 'AGL' not found". Fixed upstream in Qt 6.9. +# +# This file is a copy of Qt's module with the single AGL target_link_libraries +# line removed. It is picked up via CMAKE_MODULE_PATH before Qt's copy. +# +# Original: Copyright (C) 2022 The Qt Company Ltd. SPDX BSD-3-Clause. + +if(TARGET WrapOpenGL::WrapOpenGL) + set(WrapOpenGL_FOUND ON) + return() +endif() + +set(WrapOpenGL_FOUND OFF) + +find_package(OpenGL ${WrapOpenGL_FIND_VERSION}) + +if (OpenGL_FOUND) + set(WrapOpenGL_FOUND ON) + + add_library(WrapOpenGL::WrapOpenGL INTERFACE IMPORTED) + if(APPLE) + get_target_property(__opengl_fw_lib_path OpenGL::GL IMPORTED_LOCATION) + if(__opengl_fw_lib_path AND NOT __opengl_fw_lib_path MATCHES "/([^/]+)\\.framework$") + get_filename_component(__opengl_fw_path "${__opengl_fw_lib_path}" DIRECTORY) + endif() + + if(NOT __opengl_fw_path) + set(__opengl_fw_path "-framework OpenGL") + endif() + + target_link_libraries(WrapOpenGL::WrapOpenGL INTERFACE ${__opengl_fw_path}) + else() + target_link_libraries(WrapOpenGL::WrapOpenGL INTERFACE OpenGL::GL) + endif() +endif() + +include(FindPackageHandleStandardArgs) +find_package_handle_standard_args(WrapOpenGL DEFAULT_MSG WrapOpenGL_FOUND) diff --git a/configure.sh b/configure.sh index ebb8b16e4..4856243f6 100755 --- a/configure.sh +++ b/configure.sh @@ -16,17 +16,27 @@ detect_platform() { echo "${platform}" } +detect_arch() { + case "$(uname -m)" in + x86_64|amd64) echo "x86_64";; + arm64|aarch64) echo "arm64";; + *) echo "unknown";; + esac +} + # Basic data such as platform can be global PLATFORM=$(detect_platform) +ARCH=$(detect_arch) BUILD_CONFIG="Debug" -QT_VERSION="6.5.3" +QT_MIN_VERSION="6.5.0" print_help() { echo "Usage: $0 [options]" echo "Options:" echo " -h, --help: Print this help message" echo " clean: Remove all files from lib, bin and tmp" - echo " QT_ROOT=path: Specify the root path to where Qt is installed (eg. /c/Qt/)" + echo " QT_PATH=path: Use this Qt toolchain directly, skipping auto-detection (eg. /c/Qt/6.5.3/mingw_64)" + echo " BUILD_TYPE=Debug|Release: CMake build type (default: Debug)" } # Check if a given command returns a non-zero exit code @@ -42,30 +52,16 @@ check_command() { } find_qt() { + # Auto-detect the Qt root by checking common install locations. + # Emits the path on stdout, or empty string if nothing was found. local qt_root="" - - # Function to check if a dir exists - check_path() { - if [[ -d "$1" ]]; then - qt_root="$1" - return 0 - else - return 1 - fi - } - - # Check common Qt installation paths on different OSes if [[ "$PLATFORM" == "windows" ]]; then - # Windows paths, maybe check for more in the future - check_path "/c/Qt" - elif [[ "$PLATFORM" == "linux" ]]; then - check_path "$HOME/Qt" - elif [[ "$PLATFORM" == "macos" ]]; then - check_path "$HOME/Qt" + qt_root="/c/Qt" + else + qt_root="$HOME/Qt" fi - # If qt-cmake is found, print the path - if [[ -n "$qt_root" ]]; then + if [[ -d "$qt_root" ]]; then echo "$qt_root" else echo "" @@ -73,91 +69,96 @@ find_qt() { } find_qtpath() { - local qt_path="" - - check_path() { - if [[ -d "$1" ]]; then - qt_path="$1" - return 0 - else - return 1 - fi - } - + # Pick the newest installed Qt under $QT_ROOT whose version is at least + # QT_MIN_VERSION and which has the toolchain subdir for this platform. + local toolchain="" if [[ "$PLATFORM" == "windows" ]]; then - check_path "${QT_ROOT}/${QT_VERSION}/mingw_64" + toolchain="mingw_64" elif [[ "$PLATFORM" == "linux" ]]; then - check_path "${QT_ROOT}/${QT_VERSION}/gcc_64" + toolchain="gcc_64" elif [[ "$PLATFORM" == "macos" ]]; then - check_path "${QT_ROOT}/${QT_VERSION}/macos" + toolchain="macos" fi - echo "$qt_path" + local best_ver="" + local best_path="" + + shopt -s nullglob + local dir ver + for dir in "$QT_ROOT"/*/ ; do + ver=$(basename "$dir") + [[ "$ver" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || continue + [[ -d "${dir}${toolchain}" ]] || continue + # Skip versions below the floor + if [[ "$(printf '%s\n%s\n' "$QT_MIN_VERSION" "$ver" | sort -V | head -n 1)" != "$QT_MIN_VERSION" ]]; then + continue + fi + if [[ -z "$best_ver" || "$(printf '%s\n%s\n' "$best_ver" "$ver" | sort -V | tail -n 1)" == "$ver" ]]; then + best_ver="$ver" + best_path="${dir}${toolchain}" + fi + done + shopt -u nullglob + + echo "$best_path" } find_cmake() { + # Prefer the cmake bundled with Qt; emits the path on stdout, or empty + # string if none is bundled (the caller falls back to cmake on PATH). local cmake_path="" - - # Function to check if a file exists - check_path() { - if [[ -f "$1" ]]; then - cmake_path="$1" - return 0 - else - return 1 - fi - } - - # See if we can find the cmake bundled with Qt if [[ "$PLATFORM" == "windows" ]]; then - check_path "${QT_ROOT}/Tools/CMake_64/bin/cmake.exe" + cmake_path="${QT_ROOT}/Tools/CMake_64/bin/cmake.exe" elif [[ "$PLATFORM" == "linux" ]]; then - check_path "${QT_ROOT}/Tools/CMake/bin/cmake" + cmake_path="${QT_ROOT}/Tools/CMake/bin/cmake" elif [[ "$PLATFORM" == "macos" ]]; then - check_path "${QT_ROOT}/Tools/CMake/CMake.app/Contents/bin/cmake" - else - echo "Unsupported platform: ${PLATFORM}" - return 1 + cmake_path="${QT_ROOT}/Tools/CMake/CMake.app/Contents/bin/cmake" fi - # If cmake is found, print the path - if [[ -n "$cmake_path" ]]; then + if [[ -f "$cmake_path" ]]; then echo "$cmake_path" - return 0 else echo "" - return 1 fi } find_mingw() { - # Find a mingw installation bundled with Qt - - QT_TOOLS_PATH="${QT_ROOT}/Tools" - - mingw_dir=$(find "${QT_TOOLS_PATH}" -maxdepth 1 -type d -name "mingw*" -print0 | xargs -0 ls -td | head -n 1) - - # Find returns . if the directory is not found - if [[ "$mingw_dir" == "." ]]; then - mingw_dir="" + # Find the newest MinGW installation bundled under ${QT_ROOT}/Tools/. + # Emits the path on stdout, or empty if the Tools dir or mingw dir is absent. + local tools_path="${QT_ROOT}/Tools" + if [[ ! -d "$tools_path" ]]; then + echo "" + return 0 fi + local mingw_dir="" + mingw_dir=$(find "$tools_path" -maxdepth 1 -type d -name "mingw*" -print0 \ + | xargs -0 -r ls -td 2>/dev/null \ + | head -n 1) + echo "$mingw_dir" } find_ninja() { - # Find a ninja installation bundled with Qt - QT_TOOLS_PATH="${QT_ROOT}/Tools" - - local ninja_path="" - + # Prefer the ninja bundled with Qt, fall back to ninja on PATH. + local bundled="" if [[ "$PLATFORM" == "windows" ]]; then - ninja_path="${QT_TOOLS_PATH}/Ninja/ninja.exe" + bundled="${QT_ROOT}/Tools/Ninja/ninja.exe" else - ninja_path="${QT_TOOLS_PATH}/Ninja/ninja" + bundled="${QT_ROOT}/Tools/Ninja/ninja" + fi + + if [[ -f "$bundled" ]]; then + echo "$bundled" + return 0 fi - echo "$ninja_path" + if command -v ninja >/dev/null 2>&1; then + echo "ninja" + return 0 + fi + + echo "" } get_zip() { @@ -192,10 +193,13 @@ get_zip() { return 1 fi - # First, check that all the specified files exist in the zip archive + # First, check that all the specified files exist in the zip archive. + # Snapshot the listing into a variable — piping to `grep -q` under + # `set -o pipefail` can trip SIGPIPE on `unzip` and spuriously fail. + zip_listing=$(unzip -l "$tmp_zip") for arg in "$@" ; do src_file="${arg%%:*}" - if ! unzip -l "$tmp_zip" | grep -q "$src_file"; then + if ! grep -q "$src_file" <<< "$zip_listing"; then echo "Error: The file '$src_file' does not exist in the zip archive $tmp_zip." return 1 fi @@ -210,7 +214,7 @@ get_zip() { # Create the destination directory if it doesn't exist mkdir -p "$dst_dir" - unzip -j "$tmp_zip" "$src_file" -d "$dst_dir" + unzip -o -j "$tmp_zip" "$src_file" -d "$dst_dir" shift done @@ -219,58 +223,6 @@ get_zip() { rm -rf "$tmp_zip" } -get_bass() { - echo "Checking for BASS..." - # If lib/bass.h exists, assume that BASS is already present - if [ -f "./lib/bass.h" ]; then - echo "BASS is installed." - return 0 - fi - - echo "Downloading BASS..." - if [[ "$PLATFORM" == "windows" ]]; then - get_zip https://www.un4seen.com/files/bass24.zip \ - c/bass.h:./lib \ - c/x64/bass.lib:./lib \ - x64/bass.dll:./bin - elif [[ "$PLATFORM" == "linux" ]]; then - get_zip https://www.un4seen.com/files/bass24-linux.zip \ - c/bass.h:./lib \ - libs/x86_64/libbass.so:./lib \ - libs/x86_64/libbass.so:./bin - elif [[ "$PLATFORM" == "macos" ]]; then - get_zip https://www.un4seen.com/files/bass24-osx.zip \ - c/bass.h:./lib \ - libbass.dylib:./lib - fi -} - -get_bassopus() { - echo "Checking for BASSOPUS..." - # If lib/bassopus.h exists, assume that BASSOPUS is already present - if [ -f "./lib/bassopus.h" ]; then - echo "BASSOPUS is installed." - return 0 - fi - - echo "Downloading BASSOPUS..." - if [[ "$PLATFORM" == "windows" ]]; then - get_zip https://www.un4seen.com/files/bassopus24.zip \ - c/bassopus.h:./lib \ - c/x64/bassopus.lib:./lib \ - x64/bassopus.dll:./bin - elif [[ "$PLATFORM" == "linux" ]]; then - get_zip https://www.un4seen.com/files/bassopus24-linux.zip \ - c/bassopus.h:./lib \ - libs/x86_64/libbassopus.so:./lib \ - libs/x86_64/libbassopus.so:./bin - elif [[ "$PLATFORM" == "macos" ]]; then - get_zip https://www.un4seen.com/files/bassopus24-osx.zip \ - c/bassopus.h:./lib \ - libbassopus.dylib:./lib - fi -} - get_discordrpc() { echo "Checking for Discord RPC..." # If lib/discord_rpc.h exists, assume that Discord RPC is already present @@ -293,10 +245,18 @@ get_discordrpc() { discord-rpc/linux-dynamic/include/discord_rpc.h:./lib \ discord-rpc/linux-dynamic/include/discord_register.h:./lib elif [[ "$PLATFORM" == "macos" ]]; then - get_zip https://github.com/discord/discord-rpc/releases/download/v3.4.0/discord-rpc-osx.zip \ - discord-rpc/osx-dynamic/lib/libdiscord-rpc.dylib:./lib \ - discord-rpc/osx-dynamic/include/discord_rpc.h:./lib \ - discord-rpc/osx-dynamic/include/discord_rpc.h:./lib + if [[ "$ARCH" == "x86_64" ]]; then + get_zip https://github.com/discord/discord-rpc/releases/download/v3.4.0/discord-rpc-osx.zip \ + discord-rpc/osx-dynamic/lib/libdiscord-rpc.dylib:./lib \ + discord-rpc/osx-dynamic/include/discord_rpc.h:./lib \ + discord-rpc/osx-dynamic/include/discord_register.h:./lib + else + # The official discord-rpc v3.4.0 release only ships an x86_64 + # dylib and the repo was archived in 2018, so there is no native + # arm64 build. Skip the download — Discord RPC is disabled at + # build time on arm64 macOS via -DAO_ENABLE_DISCORD_RPC=OFF below. + echo "Skipping Discord RPC on macOS ${ARCH} (no native binary available)." + fi fi } @@ -333,6 +293,7 @@ get_qtapng() { -G Ninja \ -DCMAKE_MAKE_PROGRAM="$NINJA" \ -DCMAKE_PREFIX_PATH="$QT_PATH" \ + -DCMAKE_MODULE_PATH="${SCRIPT_DIR}/cmake" \ -DCMAKE_C_COMPILER="$CC" \ -DCMAKE_CXX_COMPILER="$CXX" @@ -387,31 +348,38 @@ configure() { exit 1 fi - # Now we look for qt - QT_ROOT="" - - # If QT_ROOT=path is passed, use that - if [ "$#" -gt 0 ] && [ "${1%%=*}" = "QT_ROOT" ]; then - QT_ROOT="${1#*=}" + # Parse KEY=VALUE overrides + QT_PATH="" + while [ "$#" -gt 0 ]; do + case "$1" in + QT_PATH=*) QT_PATH="${1#*=}" ;; + BUILD_TYPE=*) BUILD_CONFIG="${1#*=}" ;; + *) echo "Unknown argument: $1"; print_help; exit 1 ;; + esac shift - # Try to find it otherwise + done + + # Resolve QT_PATH: explicit override wins, otherwise auto-detect under $HOME/Qt. + # QT_ROOT is the parent of the version dir, derived from QT_PATH. Tools/ lives + # under it (find_cmake / find_mingw / find_ninja look there). + if [ -n "$QT_PATH" ]; then + if [ ! -d "$QT_PATH" ]; then + echo "$QT_PATH is not a directory. Aborting." + exit 1 + fi + QT_ROOT="$(cd "$QT_PATH/../.." && pwd)" else QT_ROOT=$(find_qt) if [ -z "$QT_ROOT" ]; then echo "Qt not found. Aborting."; exit 1; fi - fi - if [ ! -d "$QT_ROOT" ]; then - echo "$QT_ROOT is not a directory. Aborting." - exit 1 + QT_PATH=$(find_qtpath) + if [ -z "$QT_PATH" ] || [ ! -d "$QT_PATH" ]; then + echo "No Qt >= ${QT_MIN_VERSION} found under ${QT_ROOT}. Aborting." + exit 1 + fi fi echo "Using Qt root: $QT_ROOT" - - QT_PATH=$(find_qtpath) - if [ ! -d "$QT_PATH" ]; then - echo "$QT_PATH is not a directory. Aborting." - exit 1 - fi echo "Using Qt installation: $QT_PATH" # Check for cmake, and prefer the one bundled with Qt @@ -428,20 +396,21 @@ configure() { check_command "$CMAKE" --version || { echo "cmake not working. Aborting."; exit 1; } echo "Using cmake: $CMAKE" - # Find the compiler bundled in Qt + # Prefer the MinGW bundled with Qt on Windows; fall back to gcc/g++ on PATH. + # On non-Windows platforms the system compiler is usually safe. CC="" CXX="" - # If we're on Windows, find mingw if [[ "$PLATFORM" == "windows" ]]; then MINGW_PATH=$(find_mingw) - if [ -z "$MINGW_PATH" ]; then - echo "MinGW not found. Aborting." - exit 1 + if [ -n "$MINGW_PATH" ]; then + CC="${MINGW_PATH}/bin/gcc.exe" + CXX="${MINGW_PATH}/bin/g++.exe" + else + echo "No MinGW bundled with Qt found. Trying PATH..." + CC="gcc" + CXX="g++" fi - CC="${MINGW_PATH}/bin/gcc.exe" - CXX="${MINGW_PATH}/bin/g++.exe" else - # On non-Windows platforms, use the system compiler, as it's usually safe CC="gcc" CXX="g++" fi @@ -466,21 +435,27 @@ configure() { mkdir -p ./bin/ # Get the dependencies - get_bass - get_bassopus get_discordrpc get_qtapng get_themes + # Discord RPC has no native arm64 macOS binary, so turn it off there. + EXTRA_CMAKE_FLAGS="" + if [[ "$PLATFORM" == "macos" && "$ARCH" != "x86_64" ]]; then + EXTRA_CMAKE_FLAGS="-DAO_ENABLE_DISCORD_RPC=OFF" + fi + # Typically, IDEs like running cmake themselves, but we need the binary to fix dependencies correctly FULL_CMAKE_CMD="\ $CMAKE . \ -G Ninja \ -DCMAKE_MAKE_PROGRAM=${NINJA} \ -DCMAKE_PREFIX_PATH=${QT_PATH} \ +-DCMAKE_MODULE_PATH=${SCRIPT_DIR}/cmake \ -DCMAKE_BUILD_TYPE=${BUILD_CONFIG} \ -DCMAKE_C_COMPILER=${CC} \ --DCMAKE_CXX_COMPILER=${CXX}" +-DCMAKE_CXX_COMPILER=${CXX} \ +${EXTRA_CMAKE_FLAGS}" $FULL_CMAKE_CMD $NINJA diff --git a/src/aoapplication.cpp b/src/aoapplication.cpp index c38eef7e9..5f8917ddd 100644 --- a/src/aoapplication.cpp +++ b/src/aoapplication.cpp @@ -6,6 +6,8 @@ #include "options.h" #include "widgets/aooptionsdialog.h" +#include + static QtMessageHandler original_message_handler; static AOApplication *message_handler_context; @@ -214,24 +216,6 @@ void AOApplication::call_settings_menu() delete l_dialog; } -// Callback for when BASS device is lost -// Only actually used for music syncs -void CALLBACK AOApplication::BASSreset(HSTREAM handle, DWORD channel, DWORD data, void *user) -{ - Q_UNUSED(handle); - Q_UNUSED(channel); - Q_UNUSED(data); - Q_UNUSED(user); - doBASSreset(); -} - -void AOApplication::doBASSreset() -{ - BASS_Free(); - BASS_Init(-1, 48000, BASS_DEVICE_LATENCY, nullptr, nullptr); - load_bass_plugins(); -} - void AOApplication::server_connected() { qInfo() << "Established connection to server."; @@ -242,36 +226,21 @@ void AOApplication::server_connected() courtroom_loaded = false; } -void AOApplication::initBASS() +QAudioDevice AOApplication::currentAudioDevice() const { - BASS_SetConfig(BASS_CONFIG_DEV_DEFAULT, 1); - BASS_Free(); - // Change the default audio output device to be the one the user has given - // in his config.ini file for now. - unsigned int a = 0; - BASS_DEVICEINFO info; - - if (Options::getInstance().audioOutputDevice() == "default") + QString pref = Options::getInstance().audioOutputDevice(); + if (pref != "default") { - BASS_Init(-1, 48000, BASS_DEVICE_LATENCY, nullptr, nullptr); - load_bass_plugins(); - } - else - { - for (a = 0; BASS_GetDeviceInfo(a, &info); a++) + const QList devices = QMediaDevices::audioOutputs(); + for (const QAudioDevice &dev : devices) { - if (Options::getInstance().audioOutputDevice() == info.name) + if (dev.description() == pref) { - BASS_SetDevice(a); - BASS_Init(static_cast(a), 48000, BASS_DEVICE_LATENCY, nullptr, nullptr); - load_bass_plugins(); - qInfo() << info.name << "was set as the default audio output device."; - return; + return dev; } } - BASS_Init(-1, 48000, BASS_DEVICE_LATENCY, nullptr, nullptr); - load_bass_plugins(); } + return QMediaDevices::defaultAudioOutput(); } bool AOApplication::pointExistsOnScreen(QPoint point) @@ -301,22 +270,3 @@ void AOApplication::centerOrMoveWidgetOnPrimaryScreen(QWidget *widget) widget->move(point->x(), point->y()); } } - -#if (defined(_WIN32) || defined(_WIN64)) -void AOApplication::load_bass_plugins() -{ - BASS_PluginLoad("bassopus.dll", 0); -} -#elif defined __APPLE__ -void AOApplication::load_bass_plugins() -{ - BASS_PluginLoad("libbassopus.dylib", 0); -} -#elif (defined(LINUX) || defined(__linux__)) -void AOApplication::load_bass_plugins() -{ - BASS_PluginLoad("libbassopus.so", 0); -} -#else -#error This operating system is unsupported for BASS plugins. -#endif diff --git a/src/aoapplication.h b/src/aoapplication.h index 92611ec1a..d0fa705f6 100644 --- a/src/aoapplication.h +++ b/src/aoapplication.h @@ -7,8 +7,7 @@ #include "serverdata.h" #include "widgets/aooptionsdialog.h" -#include - +#include #include #include #include @@ -335,10 +334,9 @@ class AOApplication : public QObject bool pointExistsOnScreen(QPoint point); void centerOrMoveWidgetOnPrimaryScreen(QWidget *widget); - void initBASS(); - static void load_bass_plugins(); - static void CALLBACK BASSreset(HSTREAM handle, DWORD channel, DWORD data, void *user); - static void doBASSreset(); + // Resolve the user-selected audio output device. Returns the default + // output if the preference is "default" or no match is found. + QAudioDevice currentAudioDevice() const; QElapsedTimer demo_timer; DemoServer *demo_server = nullptr; diff --git a/src/aoblipplayer.cpp b/src/aoblipplayer.cpp index 3a13d7879..8b4bf1112 100644 --- a/src/aoblipplayer.cpp +++ b/src/aoblipplayer.cpp @@ -1,5 +1,14 @@ #include "aoblipplayer.h" +#include +#include +#include +#include +#include +#include +#include +#include + AOBlipPlayer::AOBlipPlayer(AOApplication *ao_app) : ao_app(ao_app) {} @@ -19,36 +28,133 @@ void AOBlipPlayer::setMuted(bool enabled) void AOBlipPlayer::setBlip(QString blip) { QString path = ao_app->get_sfx_suffix(ao_app->get_sounds_path(blip)); + QString playable = resolveToPlayablePath(path); + QUrl source = QUrl::fromLocalFile(playable); + QAudioDevice device = ao_app->currentAudioDevice(); for (int i = 0; i < STREAM_COUNT; ++i) { - BASS_StreamFree(m_stream[i]); - - if (path.endsWith(".opus")) - { - m_stream[i] = BASS_OPUS_StreamCreateFile(FALSE, path.utf16(), 0, 0, BASS_UNICODE | BASS_ASYNCFILE); - } - else - { - m_stream[i] = BASS_StreamCreateFile(FALSE, path.utf16(), 0, 0, BASS_UNICODE | BASS_ASYNCFILE); - } + m_stream[i].setAudioDevice(device); + m_stream[i].setSource(source); } - updateInternalVolume(); } void AOBlipPlayer::playBlip() { - HSTREAM stream = m_stream[m_cycle]; - BASS_ChannelSetDevice(stream, BASS_GetDevice()); - BASS_ChannelPlay(stream, false); - m_cycle = ++m_cycle % STREAM_COUNT; + m_stream[m_cycle].play(); + m_cycle = (m_cycle + 1) % STREAM_COUNT; } void AOBlipPlayer::updateInternalVolume() { - float volume = m_muted ? 0.0f : (m_volume * 0.01); + float volume = m_muted ? 0.0f : (m_volume * 0.01f); for (int i = 0; i < STREAM_COUNT; ++i) { - BASS_ChannelSetAttribute(m_stream[i], BASS_ATTRIB_VOL, volume); + m_stream[i].setVolume(volume); + } +} + +namespace +{ +// Standard 44-byte WAV header for PCM data. +void writeWavHeader(QFile &out, const QAudioFormat &fmt, qint32 pcmBytes) +{ + const quint32 sampleRate = static_cast(fmt.sampleRate()); + const quint16 channels = static_cast(fmt.channelCount()); + const quint16 bitsPerSample = static_cast(fmt.bytesPerSample() * 8); + const quint32 byteRate = sampleRate * channels * (bitsPerSample / 8); + const quint16 blockAlign = channels * (bitsPerSample / 8); + const quint32 riffSize = 36 + static_cast(pcmBytes); + + auto write32 = [&](quint32 v) { + char b[4] = {char(v & 0xff), char((v >> 8) & 0xff), char((v >> 16) & 0xff), char((v >> 24) & 0xff)}; + out.write(b, 4); + }; + auto write16 = [&](quint16 v) { + char b[2] = {char(v & 0xff), char((v >> 8) & 0xff)}; + out.write(b, 2); + }; + + out.write("RIFF", 4); + write32(riffSize); + out.write("WAVE", 4); + out.write("fmt ", 4); + write32(16); + write16(1); // PCM + write16(channels); + write32(sampleRate); + write32(byteRate); + write16(blockAlign); + write16(bitsPerSample); + out.write("data", 4); + write32(static_cast(pcmBytes)); +} +} // namespace + +QString AOBlipPlayer::resolveToPlayablePath(const QString &sourcePath) +{ + if (sourcePath.endsWith(".wav", Qt::CaseInsensitive)) + { + return sourcePath; } + + static QMap cache; + auto cached = cache.constFind(sourcePath); + if (cached != cache.constEnd()) + { + return *cached; + } + + QAudioDecoder decoder; + decoder.setSource(QUrl::fromLocalFile(sourcePath)); + + QByteArray pcm; + QAudioFormat format; + bool errored = false; + + QEventLoop loop; + QObject::connect(&decoder, &QAudioDecoder::bufferReady, &loop, [&]() { + while (decoder.bufferAvailable()) + { + QAudioBuffer buf = decoder.read(); + if (!format.isValid()) + { + format = buf.format(); + } + pcm.append(buf.constData(), buf.byteCount()); + } + }); + QObject::connect(&decoder, &QAudioDecoder::finished, &loop, &QEventLoop::quit); + QObject::connect(&decoder, qOverload(&QAudioDecoder::error), &loop, [&](QAudioDecoder::Error) { + errored = true; + loop.quit(); + }); + + decoder.start(); + loop.exec(); + + if (errored || pcm.isEmpty() || !format.isValid()) + { + // Decode failed; fall back to the original path so QSoundEffect at least logs a clear error. + cache.insert(sourcePath, sourcePath); + return sourcePath; + } + + QString hash = QString::number(qHash(sourcePath), 16); + QDir cacheDir(QDir::current().absoluteFilePath("tmp/blip-cache")); + cacheDir.mkpath("."); + QString outPath = cacheDir.absoluteFilePath(hash + ".wav"); + + QFile out(outPath); + if (!out.open(QIODevice::WriteOnly | QIODevice::Truncate)) + { + cache.insert(sourcePath, sourcePath); + return sourcePath; + } + writeWavHeader(out, format, pcm.size()); + out.write(pcm); + out.close(); + + cache.insert(sourcePath, outPath); + return outPath; } diff --git a/src/aoblipplayer.h b/src/aoblipplayer.h index 92b43d297..237697f4a 100644 --- a/src/aoblipplayer.h +++ b/src/aoblipplayer.h @@ -2,14 +2,7 @@ #include "aoapplication.h" -#include -#include - -#include -#include -#include - -#include +#include class AOBlipPlayer { @@ -30,8 +23,12 @@ class AOBlipPlayer int m_volume = 0; bool m_muted = false; - HSTREAM m_stream[STREAM_COUNT]{}; + QSoundEffect m_stream[STREAM_COUNT]; int m_cycle = 0; void updateInternalVolume(); + + // QSoundEffect only plays wav. Decode anything else to a temp wav and + // cache by source path. Returns the playable path (same as input for wav). + static QString resolveToPlayablePath(const QString &sourcePath); }; diff --git a/src/aomusicplayer.cpp b/src/aomusicplayer.cpp index 6ea8c83e9..5e7f8211b 100644 --- a/src/aomusicplayer.cpp +++ b/src/aomusicplayer.cpp @@ -1,13 +1,61 @@ #include "aomusicplayer.h" +#include "datatypes.h" #include "file_functions.h" +#include "loopsidecar.h" #include "options.h" -#include - +#include +#include +#include #include -#include -#include +#include +#include +#include +#include + +namespace +{ +constexpr int FADE_OUT_MS = 4000; +constexpr int FADE_IN_MS = 1000; +constexpr int LOOP_POLL_INTERVAL_MS = 5; + +// Probe the sample rate of an audio file by reading one buffer via QAudioDecoder. +// Returns 0 on failure. Synchronous; only used by the legacy non-seconds loop sidecar form. +int probeSampleRate(const QString &mediaPath) +{ + QAudioDecoder decoder; + decoder.setSource(QUrl::fromLocalFile(mediaPath)); + + int rate = 0; + bool errored = false; + QEventLoop loop; + + QObject::connect(&decoder, &QAudioDecoder::bufferReady, &loop, [&]() { + while (decoder.bufferAvailable()) + { + QAudioBuffer buf = decoder.read(); + if (buf.format().isValid()) + { + rate = buf.format().sampleRate(); + loop.quit(); + return; + } + } + }); + QObject::connect(&decoder, &QAudioDecoder::finished, &loop, &QEventLoop::quit); + QObject::connect(&decoder, qOverload(&QAudioDecoder::error), &loop, [&](QAudioDecoder::Error) { + errored = true; + loop.quit(); + }); + + decoder.start(); + loop.exec(); + decoder.stop(); + + return errored ? 0 : rate; +} +} // namespace AOMusicPlayer::AOMusicPlayer(AOApplication *ao_app) : ao_app(ao_app) @@ -15,175 +63,256 @@ AOMusicPlayer::AOMusicPlayer(AOApplication *ao_app) AOMusicPlayer::~AOMusicPlayer() { - for (int n_stream = 0; n_stream < STREAM_COUNT; ++n_stream) + for (int i = 0; i < STREAM_COUNT; ++i) { - BASS_ChannelStop(m_stream_list[n_stream]); + destroyStream(i); } } -QString AOMusicPlayer::playStream(QString song, int streamId, bool loopEnabled, int effectFlags) +bool AOMusicPlayer::ensureValidStreamId(int streamId) +{ + return streamId >= 0 && streamId < STREAM_COUNT; +} + +void AOMusicPlayer::destroyStream(int streamId) { if (!ensureValidStreamId(streamId)) { - return "[ERROR] Invalid Channel"; + return; } - - bool isLooping = loopEnabled && !(effectFlags & NO_REPEAT); - - quint32 flags = BASS_STREAM_AUTOFREE; - if (isLooping) + Stream &s = m_streams[streamId]; + if (s.loopTimer) + { + s.loopTimer->stop(); + s.loopTimer->deleteLater(); + s.loopTimer = nullptr; + } + if (s.player) + { + s.player->stop(); + s.player->deleteLater(); + s.player = nullptr; + } + if (s.output) { - flags |= BASS_SAMPLE_LOOP; + s.output->deleteLater(); + s.output = nullptr; } + s.loop_start_ms = 0; + s.loop_end_ms = 0; +} - QString f_path = song; - HSTREAM newstream; - if (f_path.startsWith("http")) +void AOMusicPlayer::applyVolume(int streamId) +{ + Stream &s = m_streams[streamId]; + if (!s.output) { - if (!Options::getInstance().streamingEnabled()) + return; + } + float volume = m_muted ? 0.0f : (m_volume[streamId] / 100.0f); + s.output->setVolume(volume); +} + +void AOMusicPlayer::fadeOutAndDelete(QMediaPlayer *player, QAudioOutput *output, int durationMs) +{ + auto *anim = new QVariantAnimation(player); + anim->setStartValue(output->volume()); + anim->setEndValue(0.0f); + anim->setDuration(durationMs); + anim->setEasingCurve(QEasingCurve::InExpo); + QObject::connect(anim, &QVariantAnimation::valueChanged, output, [output](const QVariant &v) { output->setVolume(v.toFloat()); }); + QObject::connect(anim, &QVariantAnimation::finished, player, [player, output]() { + player->stop(); + player->deleteLater(); + output->deleteLater(); + }); + anim->start(QAbstractAnimation::DeleteWhenStopped); +} + +void AOMusicPlayer::parseLoopSidecar(int streamId, const QString &dataPath, const QString &mediaPath) +{ + Stream &s = m_streams[streamId]; + QString text = ao_app->read_file(dataPath); + LoopPoints lp = parseLoopSidecarText(text, [&]() { + int rate = probeSampleRate(mediaPath); + if (rate == 0) { - BASS_ChannelStop(m_stream_list[streamId]); - return QObject::tr("[MISSING] Streaming disabled."); + qWarning() << "Failed to probe sample rate for" << mediaPath << "— legacy byte-form loop points will be ignored."; } - QUrl l_url = QUrl(f_path); - newstream = BASS_StreamCreateURL(l_url.toEncoded().toStdString().c_str(), 0, flags, nullptr, 0); + return rate; + }); + s.loop_start_ms = lp.start_ms; + s.loop_end_ms = lp.end_ms; +} + +void AOMusicPlayer::armLoopWatcher(int streamId) +{ + Stream &s = m_streams[streamId]; + if (!s.player) + { + return; + } + + if (s.loopTimer) + { + s.loopTimer->stop(); + s.loopTimer->deleteLater(); + s.loopTimer = nullptr; + } + + if (s.loop_start_ms < s.loop_end_ms) + { + // Custom loop points: poll position; wrap on cross. + s.loopTimer = new QTimer(s.player); + s.loopTimer->setInterval(LOOP_POLL_INTERVAL_MS); + QMediaPlayer *p = s.player; + qint64 loopStart = s.loop_start_ms; + qint64 loopEnd = s.loop_end_ms; + QObject::connect(s.loopTimer, &QTimer::timeout, p, [p, loopStart, loopEnd]() { + if (p->position() >= loopEnd) + { + p->setPosition(loopStart); + } + }); + s.loopTimer->start(); + s.player->setLoops(1); } else { - flags |= BASS_STREAM_PRESCAN | BASS_UNICODE | BASS_ASYNCFILE; + // No custom points: let QMediaPlayer handle the loop natively. + s.player->setLoops(QMediaPlayer::Infinite); + } +} - f_path = ao_app->get_real_path(ao_app->get_music_path(song)); - newstream = BASS_StreamCreateFile(FALSE, f_path.utf16(), 0, 0, flags); +QString AOMusicPlayer::playStream(QString song, int streamId, bool loopEnabled, int effectFlags) +{ + if (!ensureValidStreamId(streamId)) + { + return "[ERROR] Invalid Channel"; } - int error = BASS_ErrorGetCode(); - if (Options::getInstance().audioOutputDevice() != "default") + bool isLooping = loopEnabled && !(effectFlags & NO_REPEAT); + bool isHttp = song.startsWith("http"); + bool is_stop = (song == "~stop.mp3"); + + QString p_song_clear = QUrl(song).fileName(); + p_song_clear = p_song_clear.left(p_song_clear.lastIndexOf('.')); + + // Streaming disabled gate. + if (isHttp && !Options::getInstance().streamingEnabled()) { - BASS_ChannelSetDevice(m_stream_list[streamId], BASS_GetDevice()); + destroyStream(streamId); + return QObject::tr("[MISSING] Streaming disabled."); } - m_loop_start[streamId] = 0; - m_loop_end[streamId] = 0; + // "~stop.mp3" is a sentinel meaning "stop the current music." + if (is_stop) + { + destroyStream(streamId); + return streamId == 0 ? QObject::tr("None") : QString(); + } - QString d_path = f_path + ".txt"; - if (isLooping && file_exists(d_path)) // Contains loop/etc. information file + // Resolve local path and check existence. + QString resolvedPath = isHttp ? song : ao_app->get_real_path(ao_app->get_music_path(song)); + if (!isHttp && !file_exists(resolvedPath)) { - QStringList lines = ao_app->read_file(d_path).split("\n"); - bool seconds_mode = false; - foreach (QString line, lines) - { - QStringList args = line.split("="); - if (args.size() < 2) - { - continue; - } - QString arg = args[0].trimmed(); - if (arg == "seconds") - { - if (args[1].trimmed() == "true") - { - seconds_mode = true; // Use new epic behavior - continue; - } + destroyStream(streamId); + return QObject::tr("[MISSING] %1").arg(p_song_clear); + } - continue; - } + Stream &s = m_streams[streamId]; + QMediaPlayer *oldPlayer = s.player; + QAudioOutput *oldOutput = s.output; + qint64 oldPositionMs = (oldPlayer && oldPlayer->playbackState() == QMediaPlayer::PlayingState) ? oldPlayer->position() : -1; - float sample_rate; - BASS_ChannelGetAttribute(newstream, BASS_ATTRIB_FREQ, &sample_rate); + if (s.loopTimer) + { + s.loopTimer->stop(); + s.loopTimer->deleteLater(); + s.loopTimer = nullptr; + } - // Grab number of bytes for sample size - int sample_size = 16 / 8; + // Build the new player. + auto *player = new QMediaPlayer(); + auto *output = new QAudioOutput(); + output->setDevice(ao_app->currentAudioDevice()); + player->setAudioOutput(output); - // number of channels (stereo/mono) - int num_channels = 2; + s.player = player; + s.output = output; + s.loop_start_ms = 0; + s.loop_end_ms = 0; - // Calculate the bytes for loop_start/loop_end to use with the sync proc - QWORD bytes; - if (seconds_mode) - { - bytes = BASS_ChannelSeconds2Bytes(newstream, args[1].trimmed().toDouble()); - } - else - { - bytes = static_cast(args[1].trimmed().toUInt() * sample_size * num_channels); - } - if (arg == "loop_start") - { - m_loop_start[streamId] = bytes; - } - else if (arg == "loop_length") - { - m_loop_end[streamId] = m_loop_start[streamId] + bytes; - } - else if (arg == "loop_end") + // Parse sidecar loop metadata for local files. + QString sidecar = resolvedPath + ".txt"; + if (isLooping && !isHttp && file_exists(sidecar)) + { + parseLoopSidecar(streamId, sidecar, resolvedPath); + } + + player->setSource(isHttp ? QUrl(song) : QUrl::fromLocalFile(resolvedPath)); + + // SYNC_POS: seek the new player to the old player's position once it's loaded. + if (oldPositionMs >= 0 && (effectFlags & SYNC_POS)) + { + QObject::connect(player, &QMediaPlayer::mediaStatusChanged, player, [player, oldPositionMs](QMediaPlayer::MediaStatus status) { + if (status == QMediaPlayer::LoadedMedia || status == QMediaPlayer::BufferedMedia) { - m_loop_end[streamId] = bytes; + player->setPosition(oldPositionMs); } - } - qDebug() << "Found data file for song" << song << "length" << BASS_ChannelGetLength(newstream, BASS_POS_BYTE) << "loop start" << m_loop_start[streamId] << "loop end" << m_loop_end[streamId]; + }); } - if (BASS_ChannelIsActive(m_stream_list[streamId]) == BASS_ACTIVE_PLAYING) + // Configure looping for the new stream. + if (isLooping) { - DWORD oldstream = m_stream_list[streamId]; - - if (effectFlags & SYNC_POS) - { - BASS_ChannelLock(oldstream, true); - // Sync it with the new sample - BASS_ChannelSetPosition(newstream, BASS_ChannelGetPosition(oldstream, BASS_POS_BYTE), BASS_POS_BYTE); - BASS_ChannelLock(oldstream, false); - } - - if ((effectFlags & FADE_OUT) && m_volume[streamId] > 0) - { - // Fade out the other sample and stop it (due to -1) - BASS_ChannelSlideAttribute(oldstream, BASS_ATTRIB_VOL | BASS_SLIDE_LOG, -1, 4000); - } - else - { - BASS_ChannelStop(oldstream); // Stop the sample since we don't need it anymore - } + armLoopWatcher(streamId); } else { - BASS_ChannelStop(m_stream_list[streamId]); + player->setLoops(1); } - m_stream_list[streamId] = newstream; - BASS_ChannelPlay(newstream, false); + // FADE_IN: ramp volume from 0 to target over FADE_IN_MS. if (effectFlags & FADE_IN) { - // Fade in our sample - BASS_ChannelSetAttribute(newstream, BASS_ATTRIB_VOL, 0); - BASS_ChannelSlideAttribute(newstream, BASS_ATTRIB_VOL, static_cast(m_volume[streamId] / 100.0f), 1000); + output->setVolume(0.0f); + auto *anim = new QVariantAnimation(player); + anim->setStartValue(0.0f); + float target = m_muted ? 0.0f : (m_volume[streamId] / 100.0f); + anim->setEndValue(target); + anim->setDuration(FADE_IN_MS); + anim->setEasingCurve(QEasingCurve::OutExpo); + QObject::connect(anim, &QVariantAnimation::valueChanged, output, [output](const QVariant &v) { output->setVolume(v.toFloat()); }); + anim->start(QAbstractAnimation::DeleteWhenStopped); } else { - this->setStreamVolume(m_volume[streamId], streamId); + applyVolume(streamId); } - BASS_ChannelSetSync(newstream, BASS_SYNC_DEV_FAIL, 0, ao_app->BASSreset, 0); + player->play(); - this->setStreamLooping(isLooping, streamId); // Have to do this here due to any - // crossfading-related changes, etc. - - bool is_stop = (song == "~stop.mp3"); - QString p_song_clear = QUrl(song).fileName(); - p_song_clear = p_song_clear.left(p_song_clear.lastIndexOf('.')); - - if (is_stop && streamId == 0) - { // don't send text on channels besides 0 - return QObject::tr("None"); - } - - if (error == BASS_ERROR_HANDLE) - { // Cheap hack to see if file missing - return QObject::tr("[MISSING] %1").arg(p_song_clear); + // Dispose of the old player. Detach from the Stream struct first so destroyStream + // can't trample our new player if it runs later. + if (oldPlayer) + { + if ((effectFlags & FADE_OUT) && m_volume[streamId] > 0) + { + fadeOutAndDelete(oldPlayer, oldOutput, FADE_OUT_MS); + } + else + { + oldPlayer->stop(); + oldPlayer->deleteLater(); + if (oldOutput) + { + oldOutput->deleteLater(); + } + } } - if (song.startsWith("http") && streamId == 0) + if (isHttp && streamId == 0) { return QObject::tr("[STREAM] %1").arg(p_song_clear); } @@ -193,16 +322,15 @@ QString AOMusicPlayer::playStream(QString song, int streamId, bool loopEnabled, return p_song_clear; } - return ""; + return QString(); } void AOMusicPlayer::setMuted(bool enabled) { m_muted = enabled; - // Update all volume based on the mute setting - for (int n_stream = 0; n_stream < STREAM_COUNT; ++n_stream) + for (int i = 0; i < STREAM_COUNT; ++i) { - setStreamVolume(m_volume[n_stream], n_stream); + applyVolume(i); } } @@ -213,31 +341,8 @@ void AOMusicPlayer::setStreamVolume(int value, int streamId) qWarning().noquote() << QObject::tr("Invalid stream ID '%2'").arg(streamId); return; } - m_volume[streamId] = value; - // If muted, volume will always be 0 - float volume = (m_volume[streamId] / 100.0f) * !m_muted; - if (streamId < 0) - { - for (int n_stream = 0; n_stream < STREAM_COUNT; ++n_stream) - { - BASS_ChannelSetAttribute(m_stream_list[n_stream], BASS_ATTRIB_VOL, volume); - } - } - else - { - BASS_ChannelSetAttribute(m_stream_list[streamId], BASS_ATTRIB_VOL, volume); - } -} - -void CALLBACK loopProc(HSYNC handle, DWORD channel, DWORD data, void *user) -{ - Q_UNUSED(handle); - Q_UNUSED(data); - QWORD loop_start = *(static_cast(user)); - BASS_ChannelLock(channel, true); - BASS_ChannelSetPosition(channel, loop_start, BASS_POS_BYTE); - BASS_ChannelLock(channel, false); + applyVolume(streamId); } void AOMusicPlayer::setStreamLooping(bool enabled, int streamId) @@ -248,40 +353,23 @@ void AOMusicPlayer::setStreamLooping(bool enabled, int streamId) return; } - if (!enabled) + Stream &s = m_streams[streamId]; + if (!s.player) { - if (BASS_ChannelFlags(m_stream_list[streamId], 0, 0) & BASS_SAMPLE_LOOP) - { - BASS_ChannelFlags(m_stream_list[streamId], 0, - BASS_SAMPLE_LOOP); // remove the LOOP flag - } - BASS_ChannelRemoveSync(m_stream_list[streamId], m_loop_sync[streamId]); - m_loop_sync[streamId] = 0; return; } - BASS_ChannelFlags(m_stream_list[streamId], BASS_SAMPLE_LOOP, - BASS_SAMPLE_LOOP); // set the LOOP flag - if (m_loop_sync[streamId] != 0) - { - BASS_ChannelRemoveSync(m_stream_list[streamId], - m_loop_sync[streamId]); // remove the sync - m_loop_sync[streamId] = 0; - } - - if (m_loop_start[streamId] < m_loop_end[streamId]) - { - // Loop when the endpoint is reached. - m_loop_sync[streamId] = BASS_ChannelSetSync(m_stream_list[streamId], BASS_SYNC_POS | BASS_SYNC_MIXTIME, m_loop_end[streamId], loopProc, &m_loop_start[streamId]); - } - else + if (!enabled) { - // Loop when the end of the file is reached. - m_loop_sync[streamId] = BASS_ChannelSetSync(m_stream_list[streamId], BASS_SYNC_END | BASS_SYNC_MIXTIME, 0, loopProc, &m_loop_start[streamId]); + if (s.loopTimer) + { + s.loopTimer->stop(); + s.loopTimer->deleteLater(); + s.loopTimer = nullptr; + } + s.player->setLoops(1); + return; } -} -bool AOMusicPlayer::ensureValidStreamId(int streamId) -{ - return (streamId >= 0 && streamId < STREAM_COUNT); + armLoopWatcher(streamId); } diff --git a/src/aomusicplayer.h b/src/aomusicplayer.h index 707d64ad3..0d79d4b4e 100644 --- a/src/aomusicplayer.h +++ b/src/aomusicplayer.h @@ -2,7 +2,9 @@ #include "aoapplication.h" -#include +#include +#include +#include class AOMusicPlayer { @@ -21,18 +23,26 @@ class AOMusicPlayer void setStreamVolume(int value, int streamId); void setStreamLooping(bool enabled, int streamId); - QFutureWatcher m_watcher; - private: AOApplication *ao_app; bool m_muted = false; - int m_volume[STREAM_COUNT]{}; - HSTREAM m_stream_list[STREAM_COUNT]{}; - HSYNC m_loop_sync[STREAM_COUNT]{}; - quint32 m_loop_start[STREAM_COUNT]{}; - quint32 m_loop_end[STREAM_COUNT]{}; + struct Stream + { + QMediaPlayer *player = nullptr; + QAudioOutput *output = nullptr; + QTimer *loopTimer = nullptr; + qint64 loop_start_ms = 0; + qint64 loop_end_ms = 0; + }; + Stream m_streams[STREAM_COUNT]; + + void destroyStream(int streamId); + void parseLoopSidecar(int streamId, const QString &dataPath, const QString &mediaPath); + void armLoopWatcher(int streamId); + void applyVolume(int streamId); + void fadeOutAndDelete(QMediaPlayer *player, QAudioOutput *output, int durationMs); bool ensureValidStreamId(int streamId); }; diff --git a/src/aosfxplayer.cpp b/src/aosfxplayer.cpp index ac3b8513d..2f6599d14 100644 --- a/src/aosfxplayer.cpp +++ b/src/aosfxplayer.cpp @@ -2,6 +2,9 @@ #include "file_functions.h" +#include +#include + AOSfxPlayer::AOSfxPlayer(AOApplication *ao_app) : ao_app(ao_app) {} @@ -21,7 +24,7 @@ void AOSfxPlayer::play(QString path) { for (int i = 0; i < STREAM_COUNT; ++i) { - if (BASS_ChannelIsActive(m_stream[i]) == BASS_ACTIVE_PLAYING) + if (m_stream[i].isPlaying()) { m_current_stream_id = (i + 1) % STREAM_COUNT; } @@ -32,20 +35,12 @@ void AOSfxPlayer::play(QString path) } } - if (path.endsWith(".opus")) - { - m_stream[m_current_stream_id] = BASS_OPUS_StreamCreateFile(FALSE, path.utf16(), 0, 0, BASS_STREAM_AUTOFREE | BASS_UNICODE | BASS_ASYNCFILE); - } - else - { - m_stream[m_current_stream_id] = BASS_StreamCreateFile(FALSE, path.utf16(), 0, 0, BASS_STREAM_AUTOFREE | BASS_UNICODE | BASS_ASYNCFILE); - } - + QSoundEffect &stream = m_stream[m_current_stream_id]; + stream.setAudioDevice(ao_app->currentAudioDevice()); + stream.setLoopCount(1); + stream.setSource(QUrl::fromLocalFile(path)); updateInternalVolume(); - - BASS_ChannelSetDevice(m_stream[m_current_stream_id], BASS_GetDevice()); - BASS_ChannelPlay(m_stream[m_current_stream_id], false); - BASS_ChannelSetSync(m_stream[m_current_stream_id], BASS_SYNC_DEV_FAIL, 0, ao_app->BASSreset, 0); + stream.play(); } void AOSfxPlayer::findAndPlaySfx(QString sfx) @@ -81,7 +76,7 @@ void AOSfxPlayer::stopAllLoopingStream() { for (int i = 0; i < STREAM_COUNT; ++i) { - if (BASS_ChannelFlags(m_stream[i], 0, 0) & BASS_SAMPLE_LOOP) + if (m_stream[i].loopCount() == QSoundEffect::Infinite) { stop(i); } @@ -97,22 +92,21 @@ void AOSfxPlayer::stop(int streamId) return; } - BASS_ChannelStop(m_stream[streamId]); + m_stream[streamId].stop(); } void AOSfxPlayer::setMuted(bool toggle) { m_muted = toggle; - // Update the audio volume updateInternalVolume(); } void AOSfxPlayer::updateInternalVolume() { - float volume = m_muted ? 0.0f : (m_volume * 0.01); + float volume = m_muted ? 0.0f : (m_volume * 0.01f); for (int i = 0; i < STREAM_COUNT; ++i) { - BASS_ChannelSetAttribute(m_stream[i], BASS_ATTRIB_VOL, volume); + m_stream[i].setVolume(volume); } } @@ -126,22 +120,7 @@ void AOSfxPlayer::setLooping(bool toggle, int streamId) } m_looping = toggle; - if (BASS_ChannelFlags(m_stream[streamId], 0, 0) & BASS_SAMPLE_LOOP) - { - if (m_looping == false) - { - BASS_ChannelFlags(m_stream[streamId], 0, - BASS_SAMPLE_LOOP); // remove the LOOP flag - } - } - else - { - if (m_looping == true) - { - BASS_ChannelFlags(m_stream[streamId], BASS_SAMPLE_LOOP, - BASS_SAMPLE_LOOP); // set the LOOP flag - } - } + m_stream[streamId].setLoopCount(m_looping ? QSoundEffect::Infinite : 1); } int AOSfxPlayer::maybeFetchCurrentStreamId(int streamId) diff --git a/src/aosfxplayer.h b/src/aosfxplayer.h index 7669f0de9..faae90deb 100644 --- a/src/aosfxplayer.h +++ b/src/aosfxplayer.h @@ -2,11 +2,7 @@ #include "aoapplication.h" -#include -#include - -#include -#include +#include class AOSfxPlayer { @@ -36,7 +32,7 @@ class AOSfxPlayer int m_volume = 0; bool m_muted = false; bool m_looping = true; - HSTREAM m_stream[STREAM_COUNT]{}; + QSoundEffect m_stream[STREAM_COUNT]; int m_current_stream_id = 0; int maybeFetchCurrentStreamId(int streamId); diff --git a/src/courtroom.cpp b/src/courtroom.cpp index 4f1f869c9..bf2ded8e6 100644 --- a/src/courtroom.cpp +++ b/src/courtroom.cpp @@ -16,7 +16,6 @@ Courtroom::Courtroom(AOApplication *p_ao_app) setWindowFlags((this->windowFlags() | Qt::CustomizeWindowHint) & ~Qt::WindowMaximizeButtonHint); setObjectName("courtroom"); - ao_app->initBASS(); keepalive_timer = new QTimer(this); keepalive_timer->start(45000); @@ -33,7 +32,6 @@ Courtroom::Courtroom(AOApplication *p_ao_app) music_player = new AOMusicPlayer(ao_app); music_player->setMuted(true); - connect(&music_player->m_watcher, &QFutureWatcher::finished, this, &Courtroom::update_ui_music_name, Qt::QueuedConnection); sfx_player = new AOSfxPlayer(ao_app); sfx_player->setMuted(true); @@ -4829,28 +4827,11 @@ void Courtroom::handle_song(QStringList *p_contents) } } - if (channel == 0) + QString result = music_player->playStream(f_song, channel, looping, effect_flags); + if (channel == 0 && !result.isEmpty()) { - // Current song UI only displays the song playing, not other channels. - // Any other music playing is irrelevant. - if (music_player->m_watcher.isRunning()) - { - music_player->m_watcher.cancel(); - } - ui_music_name->setText(tr("[LOADING] %1").arg(f_song_clear)); - } - - music_player->m_watcher.setFuture(QtConcurrent::run([=, this]() -> QString { return music_player->playStream(f_song, channel, looping, effect_flags); })); -} - -void Courtroom::update_ui_music_name() -{ - QString result = music_player->m_watcher.result(); - if (result.isEmpty()) - { - return; + ui_music_name->setText(result); } - ui_music_name->setText(result); } void Courtroom::handle_wtce(QString p_wtce, int variant) diff --git a/src/courtroom.h b/src/courtroom.h index 3b6e2804d..704b8cadf 100644 --- a/src/courtroom.h +++ b/src/courtroom.h @@ -818,8 +818,6 @@ public Q_SLOTS: void on_reload_theme_clicked(); - void update_ui_music_name(); - private Q_SLOTS: void start_chat_ticking(); void play_sfx(); diff --git a/src/lobby.cpp b/src/lobby.cpp index c019135d6..a482b34e4 100644 --- a/src/lobby.cpp +++ b/src/lobby.cpp @@ -303,7 +303,7 @@ void Lobby::on_about_clicked() "is copyright (c) 2016-2022 Attorney Online developers. Open-source " "licenses apply. All other assets are the property of their " "respective owners." - "

Running on Qt version %2 with the BASS audio engine.
" + "

Running on Qt version %2 with the QtMultimedia audio backend.
" "APNG plugin loaded: %3" "

Built on %4") .arg(ao_app->get_version_string()) diff --git a/src/loopsidecar.cpp b/src/loopsidecar.cpp new file mode 100644 index 000000000..1ae4fd11c --- /dev/null +++ b/src/loopsidecar.cpp @@ -0,0 +1,64 @@ +#include "loopsidecar.h" + +#include + +LoopPoints parseLoopSidecarText(const QString &sidecar_text, const std::function &sample_rate_provider) +{ + LoopPoints out; + bool seconds_mode = false; + int sample_rate = 0; + constexpr int sample_size = 2; // 16-bit + constexpr int num_channels = 2; + + const QStringList lines = sidecar_text.split("\n"); + for (const QString &line : lines) + { + QStringList args = line.split("="); + if (args.size() < 2) + { + continue; + } + QString arg = args[0].trimmed(); + QString val = args[1].trimmed(); + + if (arg == "seconds") + { + seconds_mode = (val == "true"); + continue; + } + + qint64 ms = 0; + if (seconds_mode) + { + ms = static_cast(val.toDouble() * 1000.0); + } + else + { + if (sample_rate == 0) + { + sample_rate = sample_rate_provider ? sample_rate_provider() : 0; + if (sample_rate == 0) + { + continue; + } + } + quint64 bytes = static_cast(val.toUInt()) * sample_size * num_channels; + quint64 frame_bytes = static_cast(sample_rate) * sample_size * num_channels; + ms = static_cast(bytes * 1000 / frame_bytes); + } + + if (arg == "loop_start") + { + out.start_ms = ms; + } + else if (arg == "loop_length") + { + out.end_ms = out.start_ms + ms; + } + else if (arg == "loop_end") + { + out.end_ms = ms; + } + } + return out; +} diff --git a/src/loopsidecar.h b/src/loopsidecar.h new file mode 100644 index 000000000..21cd4cda4 --- /dev/null +++ b/src/loopsidecar.h @@ -0,0 +1,23 @@ +#pragma once + +#include + +#include + +struct LoopPoints +{ + qint64 start_ms = 0; + qint64 end_ms = 0; +}; + +// Parse loop-point metadata from an AO music sidecar (the `.txt` that lives +// next to a music file). Recognises `seconds=(true|false)`, `loop_start=N`, +// `loop_end=N`, `loop_length=N`. +// +// In the legacy form (`seconds=false` or absent) N is a sample count and +// must be divided by the file's sample rate to get a ms timestamp. +// `sample_rate_provider` is invoked lazily — at most once, only if the +// sidecar actually contains a non-seconds entry — and should return the +// file's sample rate, or 0 to signal probe failure. When 0, legacy entries +// are silently ignored. +LoopPoints parseLoopSidecarText(const QString &sidecar_text, const std::function &sample_rate_provider); diff --git a/src/widgets/aooptionsdialog.cpp b/src/widgets/aooptionsdialog.cpp index d580caaf8..7db9d71b6 100644 --- a/src/widgets/aooptionsdialog.cpp +++ b/src/widgets/aooptionsdialog.cpp @@ -7,11 +7,11 @@ #include "networkmanager.h" #include "options.h" -#include - +#include #include #include #include +#include #include #include #include @@ -31,10 +31,10 @@ void AOOptionsDialog::populateAudioDevices() ui_audio_device_combobox->addItem("default", "default"); } - BASS_DEVICEINFO info; - for (int a = 0; BASS_GetDeviceInfo(a, &info); a++) + const QList devices = QMediaDevices::audioOutputs(); + for (const QAudioDevice &dev : devices) { - ui_audio_device_combobox->addItem(info.name, info.name); + ui_audio_device_combobox->addItem(dev.description(), dev.description()); } } diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index c78740c1c..65a676ea1 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -4,8 +4,6 @@ find_package(Qt${QT_VERSION_MAJOR} COMPONENTS Test REQUIRED) set(CMAKE_INCLUDE_CURRENT_DIR ON) -enable_testing(true) - set(SKIP_AUTOMOC ON) function(ao_declare_test test_id) @@ -19,3 +17,4 @@ function(ao_declare_test test_id) endfunction() ao_declare_test(test_aopacket test_aopacket.cpp ../src/aopacket.cpp) +ao_declare_test(test_loopsidecar test_loopsidecar.cpp ../src/loopsidecar.cpp) diff --git a/test/test_loopsidecar.cpp b/test/test_loopsidecar.cpp new file mode 100644 index 000000000..448d1c5ad --- /dev/null +++ b/test/test_loopsidecar.cpp @@ -0,0 +1,171 @@ +#include "loopsidecar.h" + +#include + +class test_LoopSidecar : public QObject +{ + Q_OBJECT + +private: + // 44100 Hz, 16-bit stereo: 1 second = 44100 samples = 176400 bytes. + // The parser's legacy form takes sample counts, so a value of 44100 → 1000 ms. + static constexpr int SAMPLE_RATE = 44100; + + static std::function fixedRate(int rate) + { + return [rate]() { + return rate; + }; + } + +private Q_SLOTS: + void emptyInput() + { + LoopPoints lp = parseLoopSidecarText("", fixedRate(SAMPLE_RATE)); + QCOMPARE(lp.start_ms, qint64(0)); + QCOMPARE(lp.end_ms, qint64(0)); + } + + void onlyWhitespace() + { + LoopPoints lp = parseLoopSidecarText("\n\n \n", fixedRate(SAMPLE_RATE)); + QCOMPARE(lp.start_ms, qint64(0)); + QCOMPARE(lp.end_ms, qint64(0)); + } + + void secondsFormStartEnd() + { + LoopPoints lp = parseLoopSidecarText("seconds=true\nloop_start=2.5\nloop_end=10", fixedRate(SAMPLE_RATE)); + QCOMPARE(lp.start_ms, qint64(2500)); + QCOMPARE(lp.end_ms, qint64(10000)); + } + + void secondsFormStartLength() + { + LoopPoints lp = parseLoopSidecarText("seconds=true\nloop_start=2.5\nloop_length=7.5", fixedRate(SAMPLE_RATE)); + QCOMPARE(lp.start_ms, qint64(2500)); + QCOMPARE(lp.end_ms, qint64(10000)); + } + + void legacyFormStartEnd() + { + // 44100 samples = 1 second at 44.1 kHz; 88200 samples = 2 seconds. + LoopPoints lp = parseLoopSidecarText("loop_start=44100\nloop_end=88200", fixedRate(SAMPLE_RATE)); + QCOMPARE(lp.start_ms, qint64(1000)); + QCOMPARE(lp.end_ms, qint64(2000)); + } + + void legacyFormStartLength() + { + LoopPoints lp = parseLoopSidecarText("loop_start=22050\nloop_length=22050", fixedRate(SAMPLE_RATE)); + QCOMPARE(lp.start_ms, qint64(500)); + QCOMPARE(lp.end_ms, qint64(1000)); + } + + void explicitSecondsFalseIsLegacy() + { + LoopPoints lp = parseLoopSidecarText("seconds=false\nloop_start=44100\nloop_end=88200", fixedRate(SAMPLE_RATE)); + QCOMPARE(lp.start_ms, qint64(1000)); + QCOMPARE(lp.end_ms, qint64(2000)); + } + + void secondsFormSkipsProbe() + { + int probeCalls = 0; + auto provider = [&]() { + ++probeCalls; + return SAMPLE_RATE; + }; + LoopPoints lp = parseLoopSidecarText("seconds=true\nloop_start=1\nloop_end=2", provider); + QCOMPARE(lp.start_ms, qint64(1000)); + QCOMPARE(lp.end_ms, qint64(2000)); + QCOMPARE(probeCalls, 0); + } + + void legacyFormProbesAtMostOnce() + { + int probeCalls = 0; + auto provider = [&]() { + ++probeCalls; + return SAMPLE_RATE; + }; + LoopPoints lp = parseLoopSidecarText("loop_start=44100\nloop_length=44100\nloop_end=132300", provider); + QCOMPARE(lp.start_ms, qint64(1000)); + QCOMPARE(lp.end_ms, qint64(3000)); // loop_end overrides the loop_length computation + QCOMPARE(probeCalls, 1); + } + + void probeFailureSilencesLegacyEntries() + { + LoopPoints lp = parseLoopSidecarText("loop_start=44100\nloop_end=88200", fixedRate(0)); + QCOMPARE(lp.start_ms, qint64(0)); + QCOMPARE(lp.end_ms, qint64(0)); + } + + void nullProviderTreatedAsFailure() + { + LoopPoints lp = parseLoopSidecarText("loop_start=44100\nloop_end=88200", nullptr); + QCOMPARE(lp.start_ms, qint64(0)); + QCOMPARE(lp.end_ms, qint64(0)); + } + + void malformedLinesSkipped() + { + QString input = "this is not a kv line\n" + "seconds=true\n" + "loop_start=2.5\n" + "garbage_without_equals\n" + "loop_end=10\n"; + LoopPoints lp = parseLoopSidecarText(input, fixedRate(SAMPLE_RATE)); + QCOMPARE(lp.start_ms, qint64(2500)); + QCOMPARE(lp.end_ms, qint64(10000)); + } + + void unknownKeysIgnored() + { + QString input = "seconds=true\n" + "loop_start=2.5\n" + "something_else=42\n" + "loop_end=10\n"; + LoopPoints lp = parseLoopSidecarText(input, fixedRate(SAMPLE_RATE)); + QCOMPARE(lp.start_ms, qint64(2500)); + QCOMPARE(lp.end_ms, qint64(10000)); + } + + void whitespaceAroundKvIsTrimmed() + { + QString input = " seconds = true \n" + " loop_start = 2.5\n" + "loop_end= 10 \n"; + LoopPoints lp = parseLoopSidecarText(input, fixedRate(SAMPLE_RATE)); + QCOMPARE(lp.start_ms, qint64(2500)); + QCOMPARE(lp.end_ms, qint64(10000)); + } + + void lastLoopStartWins() + { + QString input = "seconds=true\n" + "loop_start=1\n" + "loop_start=5\n" + "loop_end=10\n"; + LoopPoints lp = parseLoopSidecarText(input, fixedRate(SAMPLE_RATE)); + QCOMPARE(lp.start_ms, qint64(5000)); + QCOMPARE(lp.end_ms, qint64(10000)); + } + + void secondsToggleMidStreamApplies() + { + // Mode change mid-file: loop_start uses legacy (sample count at 44.1k), + // then `seconds=true` flips the mode, and loop_end uses seconds. + QString input = "loop_start=44100\n" + "seconds=true\n" + "loop_end=10\n"; + LoopPoints lp = parseLoopSidecarText(input, fixedRate(SAMPLE_RATE)); + QCOMPARE(lp.start_ms, qint64(1000)); + QCOMPARE(lp.end_ms, qint64(10000)); + } +}; + +#include "test/test_loopsidecar.moc" + +QTEST_APPLESS_MAIN(test_LoopSidecar)