diff --git a/.editorconfig b/.editorconfig index a219353e4..433a8f65d 100644 --- a/.editorconfig +++ b/.editorconfig @@ -7,7 +7,7 @@ indent_style = space # Code files [*.{cs,csx,vb,vbx}] -indent_size = 4 +indent_size = 4 insert_final_newline = true charset = utf-8-bom guidelines = 100 @@ -18,74 +18,83 @@ guidelines = 100 # Organize usings dotnet_sort_system_directives_first = true # this. preferences -dotnet_style_qualification_for_field = false:silent -dotnet_style_qualification_for_property = false:silent -dotnet_style_qualification_for_method = false:silent -dotnet_style_qualification_for_event = false:silent +dotnet_style_qualification_for_field = false:silent +dotnet_style_qualification_for_property = false:silent +dotnet_style_qualification_for_method = false:silent +dotnet_style_qualification_for_event = false:silent # Language keywords vs BCL types preferences dotnet_style_predefined_type_for_locals_parameters_members = true:silent dotnet_style_predefined_type_for_member_access = true:silent # Parentheses preferences -dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent -dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent -dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent -dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent +dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent # Modifier preferences -dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent -dotnet_style_readonly_field = true:suggestion +dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent +dotnet_style_readonly_field = true:suggestion # Expression-level preferences -dotnet_style_object_initializer = true:suggestion -dotnet_style_collection_initializer = true:suggestion -dotnet_style_explicit_tuple_names = true:suggestion -dotnet_style_null_propagation = true:suggestion -dotnet_style_coalesce_expression = true:suggestion -dotnet_style_prefer_is_null_check_over_reference_equality_method = true:silent +dotnet_style_object_initializer = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_explicit_tuple_names = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:silent dotnet_prefer_inferred_tuple_names = true:suggestion dotnet_prefer_inferred_anonymous_type_member_names = true:suggestion -dotnet_style_prefer_auto_properties = true:silent -dotnet_style_prefer_conditional_expression_over_assignment = true:silent -dotnet_style_prefer_conditional_expression_over_return = true:silent +dotnet_style_prefer_auto_properties = true:silent +dotnet_style_prefer_conditional_expression_over_assignment = true:silent +dotnet_style_prefer_conditional_expression_over_return = true:silent ############################### # Naming Conventions # ############################### # Style Definitions dotnet_naming_style.pascal_case_style.capitalization = pascal_case # Use PascalCase for constant fields -dotnet_naming_rule.constant_fields_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.constant_fields_should_be_pascal_case.severity = suggestion dotnet_naming_rule.constant_fields_should_be_pascal_case.symbols = constant_fields -dotnet_naming_rule.constant_fields_should_be_pascal_case.style = pascal_case_style +dotnet_naming_rule.constant_fields_should_be_pascal_case.style = pascal_case_style dotnet_naming_symbols.constant_fields.applicable_kinds = field dotnet_naming_symbols.constant_fields.applicable_accessibilities = * dotnet_naming_symbols.constant_fields.required_modifiers = const +tab_width = 4 +dotnet_code_quality_unused_parameters = all:suggestion +dotnet_style_prefer_simplified_boolean_expressions = true:suggestion +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_prefer_simplified_interpolation = true:suggestion +dotnet_style_prefer_compound_assignment = true:suggestion +dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion +dotnet_style_namespace_match_folder = true:suggestion ############################### # C# Coding Conventions # ############################### [*.cs] # var preferences -csharp_style_var_for_built_in_types = true:silent -csharp_style_var_when_type_is_apparent = true:silent -csharp_style_var_elsewhere = true:silent +csharp_style_var_for_built_in_types = true:silent +csharp_style_var_when_type_is_apparent = true:silent +csharp_style_var_elsewhere = true:silent # Expression-bodied members -csharp_style_expression_bodied_methods = false:silent -csharp_style_expression_bodied_constructors = false:silent -csharp_style_expression_bodied_operators = false:silent -csharp_style_expression_bodied_properties = true:silent -csharp_style_expression_bodied_indexers = true:silent -csharp_style_expression_bodied_accessors = true:silent +csharp_style_expression_bodied_methods = false:silent +csharp_style_expression_bodied_constructors = false:silent +csharp_style_expression_bodied_operators = false:silent +csharp_style_expression_bodied_properties = true:silent +csharp_style_expression_bodied_indexers = true:silent +csharp_style_expression_bodied_accessors = true:silent # Pattern matching preferences -csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion -csharp_style_pattern_matching_over_as_with_null_check = true:suggestion +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion # Null-checking preferences -csharp_style_throw_expression = true:suggestion -csharp_style_conditional_delegate_call = true:suggestion +csharp_style_throw_expression = true:suggestion +csharp_style_conditional_delegate_call = true:suggestion # Modifier preferences csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:suggestion # Expression-level preferences -csharp_prefer_braces = true:silent -csharp_style_deconstructed_variable_declaration = true:suggestion -csharp_prefer_simple_default_expression = true:suggestion +csharp_prefer_braces = true:silent +csharp_style_deconstructed_variable_declaration = true:suggestion +csharp_prefer_simple_default_expression = true:suggestion csharp_style_pattern_local_over_anonymous_function = true:suggestion -csharp_style_inlined_variable_declaration = true:suggestion +csharp_style_inlined_variable_declaration = true:suggestion ############################### # C# Formatting Rules # ############################### @@ -100,7 +109,7 @@ csharp_new_line_between_query_expression_clauses = false # Indentation preferences csharp_indent_case_contents = true csharp_indent_switch_labels = true -csharp_indent_labels = flush_left +csharp_indent_labels = flush_left # Space preferences csharp_space_after_cast = false csharp_space_after_keywords_in_control_flow_statements = true @@ -109,13 +118,36 @@ csharp_space_between_method_declaration_parameter_list_parentheses = false csharp_space_between_parentheses = false csharp_space_before_colon_in_inheritance_clause = true csharp_space_after_colon_in_inheritance_clause = true -csharp_space_around_binary_operators = before_and_after +csharp_space_around_binary_operators = before_and_after csharp_space_between_method_declaration_empty_parameter_list_parentheses = false csharp_space_between_method_call_name_and_opening_parenthesis = false csharp_space_between_method_call_empty_parameter_list_parentheses = false # Wrapping preferences csharp_preserve_single_line_statements = true csharp_preserve_single_line_blocks = true +csharp_using_directive_placement = outside_namespace:silent +csharp_prefer_simple_using_statement = true:suggestion +csharp_style_prefer_top_level_statements = true:silent +csharp_style_prefer_method_group_conversion = true:silent +csharp_style_namespace_declarations = block_scoped:silent +csharp_style_prefer_switch_expression = true:suggestion +csharp_style_prefer_primary_constructors = true:suggestion +csharp_style_prefer_pattern_matching = true:silent +csharp_style_prefer_extended_property_pattern = true:suggestion +csharp_style_prefer_not_pattern = true:suggestion +csharp_prefer_static_local_function = true:suggestion +csharp_prefer_static_anonymous_function = true:suggestion +csharp_style_prefer_readonly_struct = true:suggestion +csharp_style_prefer_readonly_struct_member = true:suggestion +csharp_style_expression_bodied_lambdas = true:silent +csharp_style_expression_bodied_local_functions = false:silent +csharp_style_prefer_null_check_over_type_check = true:suggestion +csharp_style_prefer_local_over_anonymous_function = true:suggestion +csharp_style_prefer_index_operator = true:suggestion +csharp_style_prefer_range_operator = true:suggestion +csharp_style_prefer_tuple_swap = true:suggestion +csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion +csharp_style_prefer_utf8_string_literals = true:suggestion ############################### # VB Coding Conventions # ############################### diff --git a/.github/workflows/build-daw-plugin.yml b/.github/workflows/build-daw-plugin.yml new file mode 100644 index 000000000..044f9d7c3 --- /dev/null +++ b/.github/workflows/build-daw-plugin.yml @@ -0,0 +1,155 @@ +name: Build DAW Plugin + +on: + workflow_dispatch: + inputs: + version: + description: "Release tag" + default: "0.0.0" + required: true + type: string + release: + description: "Upload to GitHub Release" + default: false + type: boolean + beta: + description: "Pre-release" + default: true + type: boolean + draft: + description: "Draft release" + default: true + type: boolean + workflow_call: + inputs: + version: + description: "Release tag" + required: true + type: string + release: + description: "Upload to GitHub Release" + default: false + type: boolean + beta: + description: "Pre-release" + default: true + type: boolean + draft: + description: "Draft release" + default: true + type: boolean + +permissions: + contents: write + +env: + release-name: ${{ inputs.version }}${{ inputs.beta && ' Beta' || '' }} + +jobs: + build: + runs-on: ${{ matrix.arch.runs-on }} + + strategy: + fail-fast: false + matrix: + arch: + - { name: win-x64, os: win, runs-on: windows-latest, cmake-arch: x64 } + - { name: win-arm64, os: win, runs-on: windows-latest, cmake-arch: ARM64 } + - { name: osx-x64, os: osx, runs-on: macos-15-intel, cmake-arch: x86_64 } + - { name: osx-arm64, os: osx, runs-on: macos-15, cmake-arch: arm64 } + - { name: linux-x64, os: linux, runs-on: ubuntu-latest } + - { name: linux-arm64, os: linux, runs-on: ubuntu-24.04-arm } + + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Build (Windows) + if: ${{ matrix.arch.os == 'win' }} + shell: pwsh + run: | + cd DawPlugin + + New-Item -ItemType Directory -Force deps/dpf/khronos/GL, deps/dpf/khronos/KHR + curl.exe --fail --location --retry 5 --retry-all-errors ` + https://raw.githubusercontent.com/KhronosGroup/OpenGL-Registry/main/api/GL/glext.h ` + --output deps/dpf/khronos/GL/glext.h + if ($LASTEXITCODE -ne 0) { + throw "Failed to download glext.h." + } + curl.exe --fail --location --retry 5 --retry-all-errors ` + https://raw.githubusercontent.com/KhronosGroup/EGL-Registry/main/api/KHR/khrplatform.h ` + --output deps/dpf/khronos/KHR/khrplatform.h + if ($LASTEXITCODE -ne 0) { + throw "Failed to download khrplatform.h." + } + if (-not (Select-String -Quiet -Path deps/dpf/khronos/GL/glext.h -Pattern "PFNGLACTIVETEXTUREPROC")) { + throw "Downloaded glext.h is invalid." + } + + cmake -B build -S . -A ${{ matrix.arch.cmake-arch }} -DCMAKE_BUILD_TYPE=Release + cmake --build build --config Release + + - name: Build (macOS) + if: ${{ matrix.arch.os == 'osx' }} + run: | + cd DawPlugin + cmake -B build -S . -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_C_COMPILER="$(xcrun --find clang)" \ + -DCMAKE_CXX_COMPILER="$(xcrun --find clang++)" \ + -DCMAKE_OSX_ARCHITECTURES=${{ matrix.arch.cmake-arch }} + cmake --build build --config Release + + - name: Build (Linux) + if: ${{ matrix.arch.os == 'linux' }} + run: | + sudo apt-get update + sudo apt-get install -y libgl1-mesa-dev + cd DawPlugin + cmake -B build -S . -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_C_COMPILER=gcc-14 \ + -DCMAKE_CXX_COMPILER=g++-14 + cmake --build build --config Release + + - name: Package VST3 + shell: bash + run: | + cd DawPlugin/build/bin + 7z a openutau_daw_plugin-${{ matrix.arch.name }}.vst3.zip openutau_daw_plugin.vst3 + + - name: Package Audio Unit + if: ${{ matrix.arch.os == 'osx' }} + shell: bash + run: | + cd DawPlugin/build/bin + 7z a openutau_daw_plugin-${{ matrix.arch.name }}.au.zip openutau_daw_plugin.component + + - uses: actions/upload-artifact@v4 + with: + name: openutau_daw_plugin-${{ matrix.arch.name }} + path: | + DawPlugin/build/bin/openutau_daw_plugin-${{ matrix.arch.name }}.vst3.zip + DawPlugin/build/bin/openutau_daw_plugin-${{ matrix.arch.name }}.au.zip + if-no-files-found: error + + release: + if: ${{ inputs.release }} + needs: build + runs-on: ubuntu-latest + + steps: + - uses: actions/download-artifact@v4 + with: + pattern: openutau_daw_plugin-* + path: release + merge-multiple: true + + - name: Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ inputs.version }} + name: ${{ env.release-name }} + prerelease: ${{ inputs.beta }} + draft: ${{ inputs.draft }} + files: release/* diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5c71c6a69..5704f3807 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,3 +1,5 @@ +name: Build OpenUtau + on: workflow_dispatch: inputs: @@ -19,6 +21,9 @@ on: default: true description: "Draft" +permissions: + contents: write + env: release-name: ${{ inputs.version }}${{ inputs.beta && ' Beta' || '' }} @@ -35,12 +40,14 @@ jobs: - { name: win-arm64, rid: win-arm64, arch: arm64, os: win, runs-on: windows-latest } - { name: osx-x64, rid: osx-x64, arch: x64, os: osx, runs-on: macos-15-intel } - { name: osx-arm64, rid: osx-arm64, arch: arm64, os: osx, runs-on: macos-15-intel } - - { name: linux-x64, rid: linux-x64, arch: x64, os: linux, runs-on: ubuntu-latest, linuxarch: x86_64} - - { name: linux-arm64, rid: linux-arm64, arch: arm64, os: linux, runs-on: ubuntu-latest, linuxarch: aarch64} + - { name: linux-x64, rid: linux-x64, arch: x64, os: linux, runs-on: ubuntu-latest, linuxarch: x86_64 } + - { name: linux-arm64, rid: linux-arm64, arch: arm64, os: linux, runs-on: ubuntu-latest, linuxarch: aarch64 } steps: # Setup - uses: actions/checkout@v4 + with: + submodules: recursive - uses: actions/setup-dotnet@v4 with: dotnet-version: | @@ -61,7 +68,7 @@ jobs: - name: Test run: dotnet test OpenUtau.Test - # Build + # Build: Main - name: Restore run: dotnet restore OpenUtau -r ${{ matrix.arch.rid }} @@ -212,3 +219,12 @@ jobs: ## macOS says "This app is damaged" Open a terminal and run `xattr -rc /Applications/OpenUtau.app`. Try opening OpenUtau again. if: ${{ inputs.release }} + + daw-plugin: + needs: build + uses: ./.github/workflows/build-daw-plugin.yml + with: + version: ${{ inputs.version }} + release: ${{ inputs.release }} + beta: ${{ inputs.beta }} + draft: ${{ inputs.draft }} diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..f8641518c --- /dev/null +++ b/.gitmodules @@ -0,0 +1,24 @@ +[submodule "DawPlugin/deps/dpf"] + path = DawPlugin/deps/dpf + url = https://github.com/distrho/DPF.git +[submodule "DawPlugin/deps/asio"] + path = DawPlugin/deps/asio + url = https://github.com/chriskohlhoff/asio +[submodule "DawPlugin/deps/dpf_widgets"] + path = DawPlugin/deps/dpf_widgets + url = https://github.com/rokujyushi/DPF-Widgets.git +[submodule "DawPlugin/deps/choc"] + path = DawPlugin/deps/choc + url = https://github.com/Tracktion/choc +[submodule "DawPlugin/deps/uuid-v4"] + path = DawPlugin/deps/uuid-v4 + url = https://github.com/rkg82/uuid-v4.git +[submodule "DawPlugin/deps/yamc"] + path = DawPlugin/deps/yamc + url = https://github.com/yohhoy/yamc.git +[submodule "DawPlugin/deps/zlib"] + path = DawPlugin/deps/zlib + url = https://github.com/madler/zlib.git +[submodule "DawPlugin/deps/gzip-hpp"] + path = DawPlugin/deps/gzip-hpp + url = https://github.com/mapbox/gzip-hpp.git diff --git a/DawPlugin/.gitignore b/DawPlugin/.gitignore new file mode 100644 index 000000000..b8262d117 --- /dev/null +++ b/DawPlugin/.gitignore @@ -0,0 +1,25 @@ +# Created by https://www.toptal.com/developers/gitignore/api/cmake +# Edit at https://www.toptal.com/developers/gitignore?templates=cmake + +### CMake ### +CMakeLists.txt.user +CMakeCache.txt +CMakeFiles +CMakeScripts +Testing +Makefile +cmake_install.cmake +install_manifest.txt +compile_commands.json +CTestTestfile.cmake +_deps + +### CMake Patch ### +# External projects +*-prefix/ + +# End of https://www.toptal.com/developers/gitignore/api/cmake + +build/ +!deps/noto_sans/.gitkeep +deps/noto_sans/* diff --git a/DawPlugin/CMakeLists.txt b/DawPlugin/CMakeLists.txt new file mode 100644 index 000000000..a1e30955d --- /dev/null +++ b/DawPlugin/CMakeLists.txt @@ -0,0 +1,108 @@ +cmake_minimum_required(VERSION 3.24) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +set(DPF_ROOT_DIR "${CMAKE_CURRENT_SOURCE_DIR}/deps/dpf") + +if(CMAKE_BUILD_TYPE STREQUAL "Debug") + set(PROJECT_NAME openutau_daw_plugin_debug) +elseif(CMAKE_BUILD_TYPE STREQUAL "Release") + set(PROJECT_NAME openutau_daw_plugin) +elseif(NOT CMAKE_BUILD_TYPE) + message(FATAL_ERROR "Build type not set") +else() + message(FATAL_ERROR "Unknown build type: ${CMAKE_BUILD_TYPE}") +endif() + +set(NOTO_SANS_DIR "${CMAKE_CURRENT_SOURCE_DIR}/deps/noto_sans") +if(NOT EXISTS "${NOTO_SANS_DIR}/otf") + file(DOWNLOAD + "https://github.com/notofonts/noto-cjk/releases/download/Sans2.004/05_NotoSansCJK-SubsetOTF.zip" + "${NOTO_SANS_DIR}/05_NotoSansCJK-SubsetOTF.zip") + file(ARCHIVE_EXTRACT INPUT "${NOTO_SANS_DIR}/05_NotoSansCJK-SubsetOTF.zip" + DESTINATION "${NOTO_SANS_DIR}/otf") +endif() +if(NOT EXISTS "${NOTO_SANS_DIR}/noto_sans.hpp") + execute_process(COMMAND xxd -i "${NOTO_SANS_DIR}/otf/SubsetOTF/JP/NotoSansJP-Regular.otf" "${NOTO_SANS_DIR}/noto_sans.raw.hpp") + file(READ "${NOTO_SANS_DIR}/noto_sans.raw.hpp" NOTO_SANS_HPP) + string(REGEX REPLACE "unsigned char .+\\[\\]" "const unsigned char notoSansJpRegular[]" NOTO_SANS_HPP "${NOTO_SANS_HPP}") + string(REGEX REPLACE "unsigned int .+_len" "const unsigned int notoSansJpRegularLen" NOTO_SANS_HPP "${NOTO_SANS_HPP}") + file(WRITE "${NOTO_SANS_DIR}/noto_sans.hpp" "${NOTO_SANS_HPP}") +endif() + +include(./deps/dpf/cmake/DPF-plugin.cmake) +project(${PROJECT_NAME}) + +# MSVC only: +# - Use UTF-8 code page +# - Make asio use Windows 10 APIs +# - Enable IME +if(MSVC) + add_compile_options("/utf-8") + add_definitions(-D_WIN32_WINNT=0x0A00) + add_definitions(-DIMGUI_ENABLE_WIN32_DEFAULT_IME_FUNCTIONS) +endif() + +# macOS only: +# - Enable libc++ C++20 stop_token/jthread support +# - Use -Wno-implicit-function-declaration for zlib +if(APPLE) + add_compile_options($<$:-fexperimental-library>) + add_link_options(-fexperimental-library) + add_compile_options("-Wno-implicit-function-declaration") +endif() + +if(CMAKE_BUILD_TYPE STREQUAL "Debug") + add_definitions(-DDEBUG) + add_definitions(-DDPF_DEBUG) +elseif(CMAKE_BUILD_TYPE STREQUAL "Release") + # nothing atm +endif() + +# Disable DGL's default font +add_definitions(-DDGL_NO_SHARED_RESOURCES=1) + +# DPF does not currently recognize MSVC's uppercase "ARM64" architecture ID. +# Use the package architecture name required by the VST3 bundle layout. +if(MSVC AND MSVC_CXX_ARCHITECTURE_ID STREQUAL "ARM64") + set(DPF_VST3_ARCHITECTURE "arm64") +endif() + +dpf_add_plugin( + ${PROJECT_NAME} + TARGETS + vst3 au + UI_TYPE + opengl + USE_FILE_BROWSER + FILES_COMMON + src/common.cpp + FILES_DSP + src/plugin.cpp + FILES_UI + src/ui.cpp + deps/dpf_widgets/opengl/DearImGui.cpp) + +# Including other libraries before DPF causes "find_library" to fail with infinite +# recursion, so we include it after DPF +set(ZLIB_BUILD_EXAMPLES OFF) +set(RENAME_ZCONF OFF) +add_subdirectory(deps/zlib) + +target_include_directories( + ${PROJECT_NAME} + PUBLIC "src" + "deps" + "deps/choc" + "deps/dpf/dgl" + "deps/dpf/distrho" + "deps/asio/asio/include" + "deps/dpf_widgets" + "deps/dpf_widgets/opengl" + "deps/uuid-v4" + "deps/zlib" + "deps/yamc/include" + "deps/gzip-hpp/include") + +target_link_libraries(${PROJECT_NAME} PRIVATE zlibstatic) diff --git a/DawPlugin/CMakeSettings.json b/DawPlugin/CMakeSettings.json new file mode 100644 index 000000000..5a03c8d7c --- /dev/null +++ b/DawPlugin/CMakeSettings.json @@ -0,0 +1,24 @@ +{ + "configurations": [ + { + "name": "x64-Debug", + "generator": "Visual Studio 17 2022 Win64", + "configurationType": "Debug", + "inheritEnvironments": [ "msvc_x64_x64" ], + "buildRoot": "${projectDir}\\build\\${name}", + "installRoot": "${projectDir}\\out\\install\\${name}", + "cmakeCommandArgs": "-DCMAKE_BUILD_TYPE=Debug", + "ctestCommandArgs": "" + }, + { + "name": "x64-Release", + "generator": "Visual Studio 17 2022 Win64", + "configurationType": "Release", + "inheritEnvironments": [ "msvc_x64_x64" ], + "buildRoot": "${projectDir}\\build\\${name}", + "installRoot": "${projectDir}\\out\\install\\${name}", + "cmakeCommandArgs": "-DCMAKE_BUILD_TYPE=Release", + "ctestCommandArgs": "" + } + ] +} diff --git a/DawPlugin/README.md b/DawPlugin/README.md new file mode 100644 index 000000000..db793bdb5 --- /dev/null +++ b/DawPlugin/README.md @@ -0,0 +1,12 @@ +# Building + +1. Install CMake +2. `cd` to `DawPlugin` folder. +3. Run `cmake -B build -S . -DCMAKE_BUILD_TYPE=Debug` to generate build files. +4. Run `cmake --build build` to build the plugin. +5. Add `path/to/OpenUtau/DawPlugin/build/bin` to your DAW's plugin search path. + +Notes: +- `-DCMAKE_BUILD_TYPE=Debug` can be replaced with `-DCMAKE_BUILD_TYPE=Release` for a release build. +- When you're using Visual Studio, your plugin will be built in `build/x64-Debug` or `build/x64-Release` instead. +- This plugin is very unstable! Don't forget to save your work often. diff --git a/DawPlugin/deps/asio b/DawPlugin/deps/asio new file mode 160000 index 000000000..231cb29ba --- /dev/null +++ b/DawPlugin/deps/asio @@ -0,0 +1 @@ +Subproject commit 231cb29bab30f82712fcd54faaea42424cc6e710 diff --git a/DawPlugin/deps/choc b/DawPlugin/deps/choc new file mode 160000 index 000000000..b262a1fd6 --- /dev/null +++ b/DawPlugin/deps/choc @@ -0,0 +1 @@ +Subproject commit b262a1fd6352c0d07f946a7bb7122b644bee7ade diff --git a/DawPlugin/deps/dpf b/DawPlugin/deps/dpf new file mode 160000 index 000000000..d47aaadfd --- /dev/null +++ b/DawPlugin/deps/dpf @@ -0,0 +1 @@ +Subproject commit d47aaadfdfe47b7841bf03b76971fa83579f6a64 diff --git a/DawPlugin/deps/dpf_widgets b/DawPlugin/deps/dpf_widgets new file mode 160000 index 000000000..e72ad69a9 --- /dev/null +++ b/DawPlugin/deps/dpf_widgets @@ -0,0 +1 @@ +Subproject commit e72ad69a9276a8e254e27f8559903cf0bfb437c4 diff --git a/DawPlugin/deps/gzip-hpp b/DawPlugin/deps/gzip-hpp new file mode 160000 index 000000000..7546b35ab --- /dev/null +++ b/DawPlugin/deps/gzip-hpp @@ -0,0 +1 @@ +Subproject commit 7546b35aba5917154a0e9ae43f804b57d22bb966 diff --git a/DawPlugin/deps/uuid-v4 b/DawPlugin/deps/uuid-v4 new file mode 160000 index 000000000..dd2f75c02 --- /dev/null +++ b/DawPlugin/deps/uuid-v4 @@ -0,0 +1 @@ +Subproject commit dd2f75c027d033586e9eb62b484748cb4bfc515d diff --git a/DawPlugin/deps/yamc b/DawPlugin/deps/yamc new file mode 160000 index 000000000..4e015a7e8 --- /dev/null +++ b/DawPlugin/deps/yamc @@ -0,0 +1 @@ +Subproject commit 4e015a7e8eb0d61c34e6928676c8c78881a72d73 diff --git a/DawPlugin/deps/zlib b/DawPlugin/deps/zlib new file mode 160000 index 000000000..5a82f71ed --- /dev/null +++ b/DawPlugin/deps/zlib @@ -0,0 +1 @@ +Subproject commit 5a82f71ed1dfc0bec044d9702463dbdf84ea3b71 diff --git a/DawPlugin/src/DistrhoPluginInfo.h b/DawPlugin/src/DistrhoPluginInfo.h new file mode 100644 index 000000000..504edefe9 --- /dev/null +++ b/DawPlugin/src/DistrhoPluginInfo.h @@ -0,0 +1,39 @@ +#ifndef DISTRHO_PLUGIN_INFO_H_INCLUDED +#define DISTRHO_PLUGIN_INFO_H_INCLUDED + +#define DISTRHO_PLUGIN_BRAND "stakira" +#ifdef DEBUG +#define DISTRHO_PLUGIN_NAME "OpenUtau Bridge (Debug)" +#else +#define DISTRHO_PLUGIN_NAME "OpenUtau Bridge" +#endif +#define DISTRHO_PLUGIN_URI "https://github.com/stakira/OpenUtau/" + +#define DISTRHO_PLUGIN_BRAND_ID Stak +#ifdef DEBUG +#define DISTRHO_PLUGIN_UNIQUE_ID OpUD +#define DISTRHO_PLUGIN_CLAP_ID "stakira.openutau-bridge-debug" +#else +#define DISTRHO_PLUGIN_UNIQUE_ID OpUt +#define DISTRHO_PLUGIN_CLAP_ID "stakira.openutau-bridge" +#endif + +#define DISTRHO_PLUGIN_HAS_UI 1 +#define DISTRHO_PLUGIN_IS_SYNTH 1 +#define DISTRHO_PLUGIN_IS_RT_SAFE 1 +#define DISTRHO_PLUGIN_NUM_INPUTS 0 +#define DISTRHO_PLUGIN_NUM_OUTPUTS 32 +#define DISTRHO_PLUGIN_WANT_TIMEPOS 1 +#define DISTRHO_PLUGIN_WANT_STATE 1 +#define DISTRHO_PLUGIN_WANT_FULL_STATE 1 +#define DISTRHO_UI_DEFAULT_WIDTH 1024 +#define DISTRHO_UI_DEFAULT_HEIGHT 256 +#define DISTRHO_UI_FILE_BROWSER 1 +#define DISTRHO_UI_USER_RESIZABLE 1 +#define DISTRHO_PLUGIN_WANT_DIRECT_ACCESS 1 + +#define DISTRHO_UI_USE_CUSTOM 1 +#define DISTRHO_UI_CUSTOM_INCLUDE_PATH "dpf_widgets/opengl/DearImGui.hpp" +#define DISTRHO_UI_CUSTOM_WIDGET_TYPE DGL_NAMESPACE::ImGuiTopLevelWidget + +#endif // DISTRHO_PLUGIN_INFO_H_INCLUDED diff --git a/DawPlugin/src/common.cpp b/DawPlugin/src/common.cpp new file mode 100644 index 000000000..5fa777786 --- /dev/null +++ b/DawPlugin/src/common.cpp @@ -0,0 +1,86 @@ +#include "common.hpp" +#include "choc/memory/choc_Base64.h" +#include "choc/text/choc_JSON.h" +#include "gzip/decompress.hpp" + +std::vector Utils::gunzip(const char *data, size_t size) { + std::vector decompressed; + gzip::Decompressor decompressor; + decompressor.decompress(decompressed, data, size); + return decompressed; +} +std::string Utils::unBase64ToString(const std::string &encoded) { + std::vector decoded; + choc::base64::decodeToContainer(decoded, encoded); + return std::string(decoded.begin(), decoded.end()); +} +std::vector Utils::unBase64ToVector(const std::string &encoded) { + std::vector decoded; + choc::base64::decodeToContainer(decoded, encoded); + return decoded; +} + +double Utils::dbToMultiplier(double db) { + return (db <= -24) ? 0 + : (db < -16) ? std::pow(10, (db * 2 + 16) / 20) + : std::pow(10, db / 20); +} + +choc::value::Value Structures::Track::serialize() const { + return choc::value::createObject("", "name", name, "pan", pan, "volume", + volume); +} +Structures::Track +Structures::Track::deserialize(const choc::value::ValueView &value) { + Track track; + track.name = value["name"].get(); + track.pan = value["pan"].get(); + track.volume = value["volume"].get(); + return track; +} + +std::string Structures::serializeTracks(const std::vector &tracks) { + choc::value::Value value = choc::value::createEmptyArray(); + for (const auto &track : tracks) { + value.addArrayElement(track.serialize()); + } + auto json = choc::json::toString(value); + return choc::base64::encodeToString(json.data(), json.size()); +} +std::vector +Structures::deserializeTracks(const std::string &data) { + auto json = Utils::unBase64ToString(data); + auto value = choc::json::parse(json); + std::vector tracks; + for (const auto &trackValue : value) { + tracks.push_back(Track::deserialize(trackValue)); + } + return tracks; +} + +std::string Structures::serializeOutputMap(const OutputMap &outputMap) { + choc::value::Value value = choc::value::createEmptyArray(); + for (const auto &mapping : outputMap) { + value.addArrayElement(mapping.first.to_string()); + value.addArrayElement(mapping.second.to_string()); + } + auto json = choc::json::toString(value); + return json; +} + +Structures::OutputMap +Structures::deserializeOutputMap(const std::string &data) { + auto value = choc::json::parse(data); + OutputMap outputMap; + for (uint32_t i = 0; i < value.size(); i += 2) { + auto leftChannelString = std::string(value[i].getString()); + auto leftChannel = + std::bitset(leftChannelString); + auto rightChannelString = std::string(value[i + 1].getString()); + auto rightChannel = + std::bitset(rightChannelString); + + outputMap.push_back({leftChannel, rightChannel}); + } + return outputMap; +} diff --git a/DawPlugin/src/common.hpp b/DawPlugin/src/common.hpp new file mode 100644 index 000000000..9f5a105cc --- /dev/null +++ b/DawPlugin/src/common.hpp @@ -0,0 +1,41 @@ +#pragma once +#include "DistrhoPluginInfo.h" +#include "choc/containers/choc_Value.h" +#include +#include +#include + +namespace Utils { +std::vector gunzip(const char *data, size_t size); +std::string unBase64ToString(const std::string &encoded); +std::vector unBase64ToVector(const std::string &encoded); + +double dbToMultiplier(double db); +} // namespace Utils + +namespace Structures { +class Track { +public: + std::string name; + double pan; + double volume; + + choc::value::Value serialize() const; + static Track deserialize(const choc::value::ValueView &value); +}; +using OutputMap = + std::vector, + std::bitset>>; +std::string serializeTracks(const std::vector &tracks); +std::vector deserializeTracks(const std::string &data); + +std::string serializeOutputMap(const OutputMap &outputMap); +OutputMap deserializeOutputMap(const std::string &data); + +} // namespace Structures + +namespace Constants { +constexpr uint32_t majorVersion = 0; +constexpr uint32_t minorVersion = 1; +constexpr uint32_t patchVersion = 0; +} // namespace Constants diff --git a/DawPlugin/src/plugin.cpp b/DawPlugin/src/plugin.cpp new file mode 100644 index 000000000..fed3efc8e --- /dev/null +++ b/DawPlugin/src/plugin.cpp @@ -0,0 +1,923 @@ +#include "plugin.hpp" +#include "DistrhoDetails.hpp" +#include "DistrhoPlugin.hpp" +#include "DistrhoPluginInfo.h" +#include "asio.hpp" +#include "choc/containers/choc_Value.h" +#include "choc/memory/choc_Base64.h" +#include "choc/text/choc_JSON.h" +#include "common.hpp" +#include "dpf/distrho/extra/String.hpp" +#include "gzip/compress.hpp" +#include "uuid/v4/uuid.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace Network { +std::shared_ptr ioThread; +std::shared_ptr ioContext; +std::shared_ptr getIoContext() { + if (ioThread == nullptr) { + if (ioContext == nullptr) { + ioContext = std::make_shared(); + } + ioThread = std::make_shared([](std::stop_token st) { + while (!st.stop_requested()) { + ioContext->run(); + } + }); + std::atexit([]() { + ioContext->stop(); + ioThread->request_stop(); + ioThread->join(); + }); + } + + return ioContext; +} +} // namespace Network + +// note: OpenUtau returns 44100Hz, 2ch, 32bit float audio + +choc::value::Value Part::serialize() const { + auto obj = choc::value::createObject("", "trackNo", trackNo, "startMs", + startMs, "endMs", endMs); + if (hash.has_value()) { + // choc cannot set uint32_t, so we need to cast it to int64_t + obj.setMember("audioHash", (int64_t)hash.value()); + } + + return obj; +} +Part Part::deserialize(const choc::value::ValueView &value) { + Part part; + part.trackNo = value["trackNo"].get(); + part.startMs = value["startMs"].get(); + part.endMs = value["endMs"].get(); + auto audioHash = value["audioHash"]; + if (audioHash.isVoid()) { + part.hash = std::nullopt; + } else { + part.hash = (uint32_t)audioHash.get(); + } + return part; +} + +// ----------------------------------------------------------------------------------------------------------- +OpenUtauPlugin::OpenUtauPlugin() + : Plugin(0, 0, 6) + +{ + + if (this->isDummyInstance()) { + return; + } + + std::string uuid = uuid::v4::UUID::New().String(); + setState("uuid", uuid.c_str()); + + setState("name", uuid.c_str()); + + this->resampleThread = + std::jthread([this](std::stop_token st) { resampleWorkerLoop(st); }); + initializeNetwork(); +} +OpenUtauPlugin::~OpenUtauPlugin() { + if (this->isDummyInstance()) { + return; + } + + if (std::filesystem::exists(this->socketPath)) { + std::filesystem::remove(this->socketPath); + } + + this->shuttingDown = true; + if (this->acceptor != nullptr) { + asio::error_code error; + this->acceptor->close(error); + } + closeActiveSocket(); + this->connectionThread.request_stop(); + this->resampleThread.request_stop(); + this->resampleRequestCondition.notify_all(); +} + +const char *OpenUtauPlugin::getLabel() const { +#ifdef DEBUG + return "OpenUtau_Debug"; +#else + return "OpenUtau"; +#endif +} + +const char *OpenUtauPlugin::getDescription() const { + return "Bridge between OpenUtau and your DAW"; +} + +const char *OpenUtauPlugin::getMaker() const { return "stakira"; } + +const char *OpenUtauPlugin::getHomePage() const { + return "https://github.com/stakira/OpenUtau/"; +} + +void OpenUtauPlugin::initState(uint32_t index, State &state) { + switch (index) { + case 0: + state.key = "name"; + state.label = "Plugin Name"; + state.defaultValue = ""; + break; + case 1: + state.key = "ustx"; + state.label = "USTx"; + state.hints = kStateIsBase64Blob; + break; + case 2: + state.key = "audios"; + state.label = "Audios"; + state.hints = kStateIsBase64Blob; + break; + case 3: + state.key = "parts"; + state.label = "Parts"; + break; + case 4: + state.key = "tracks"; + state.label = "Tracks"; + state.hints = kStateIsBase64Blob; + break; + case 5: + state.key = "mapping"; + state.label = "Output Mapping"; + break; + } +} + +String OpenUtauPlugin::getState(const char *rawKey) const { + // DPF cannot handle binary data, so we need to encode it to base64 + std::string key(rawKey); + + if (key == "name") { + auto _lock = std::lock_guard(this->stateMutex); + return String(name.c_str()); + } else if (key == "uuid") { + auto _lock = std::lock_guard(this->stateMutex); + return String(uuid.c_str()); + } else if (key == "ustx") { + auto _lock = std::lock_guard(this->stateMutex); + std::string encoded = choc::base64::encodeToString(ustx); + return String(encoded.c_str()); + } else if (key == "audios") { + auto _lock = std::shared_lock(this->audioBuffersMutex); + choc::value::Value value = choc::value::createObject(""); + for (const auto &[audioHash, audio] : audioBuffers) { + std::string compressed = + gzip::compress((char *)audio.data(), audio.size() * sizeof(float)); + std::string encoded = choc::base64::encodeToString(compressed); + value.setMember(std::to_string(audioHash), encoded); + } + + return String(choc::json::toString(value).c_str()); + } else if (key == "parts") { + auto _lock = std::shared_lock(this->partsMutex); + choc::value::Value value = choc::value::createEmptyArray(); + for (const auto &[trackNo, parts] : parts) { + for (const auto &part : parts) { + value.addArrayElement(part.serialize()); + } + } + return String(choc::json::toString(value).c_str()); + } else if (key == "tracks") { + auto _lock = std::shared_lock(this->tracksMutex); + return String(Structures::serializeTracks(tracks).c_str()); + } else if (key == "mapping") { + auto _lock = std::shared_lock(this->tracksMutex); + return String(Structures::serializeOutputMap(outputMap).c_str()); + } + return String(); +} + +void OpenUtauPlugin::setState(const char *rawKey, const char *value) { + std::string key(rawKey); + if (key == "name") { + setPluginName(value); + } else if (key == "uuid") { + auto _lock = std::lock_guard(this->stateMutex); + this->uuid = value; + } else if (key == "ustx") { + auto _lock = std::lock_guard(this->stateMutex); + this->ustx = Utils::unBase64ToString(value); + } else if (key == "audios") { + choc::value::Value audioValue = choc::json::parse(value); + std::map> audioBuffers; + choc::value::ValueView(audioValue) + .visitObjectMembers( + [&](std::string_view key, const choc::value::ValueView &value) { + auto hash = std::stoul(std::string(key)); + std::string encoded = value.get(); + auto decoded = Utils::unBase64ToVector(encoded); + auto decompressed = + Utils::gunzip((char *)decoded.data(), decoded.size()); + std::vector audio((float *)decompressed.data(), + (float *)decompressed.data() + + decompressed.size() / sizeof(float)); + audioBuffers[hash] = audio; + }); + + { + auto _lock = std::lock_guard(this->audioBuffersMutex); + this->audioBuffers = audioBuffers; + } + this->requestResampleMixes(this->currentSampleRate.load()); + } else if (key == "parts") { + choc::value::Value partsValue = choc::json::parse(value); + std::map> parts; + for (const auto &partValue : partsValue) { + Part part = Part::deserialize(partValue); + if (parts.find(part.trackNo) == parts.end()) { + parts[part.trackNo] = std::vector(); + } + parts[part.trackNo].push_back(part); + } + { + auto _lock = std::lock_guard(this->partsMutex); + this->parts = parts; + } + this->requestResampleMixes(this->currentSampleRate.load()); + } else if (key == "tracks") { + auto _lock = std::lock_guard(this->tracksMutex); + this->tracks = Structures::deserializeTracks(value); + } else if (key == "mapping") { + setOutputMap(Structures::deserializeOutputMap(value)); + } +} + +const char *OpenUtauPlugin::getLicense() const { return "MIT"; } + +uint32_t OpenUtauPlugin::getVersion() const { + return d_version(Constants::majorVersion, Constants::minorVersion, + Constants::patchVersion); +} + +void OpenUtauPlugin::initAudioPort(bool input, uint32_t index, + AudioPort &port) { + port.groupId = index / 2; + auto name = std::format("Channel {}", index / 2 + 1); + auto symbol = + std::format("channel-{}-{}", index / 2, index % 2 == 0 ? "l" : "r"); + port.name = String(name.c_str()); + port.symbol = String(symbol.c_str()); +} +void OpenUtauPlugin::initPortGroup(uint32_t groupId, PortGroup &group) { + auto name = std::format("Group {}", groupId + 1); + auto symbol = std::format("group-{}", groupId); + group.symbol = String(symbol.c_str()); + group.name = String(name.c_str()); +} + +void OpenUtauPlugin::run(const float **inputs, float **outputs, uint32_t frames, + const MidiEvent *midiEvents, uint32_t midiEventCount) { + + auto timePosition = this->getTimePosition(); + const bool wasPlaying = this->wasPlaying.exchange(timePosition.playing); + if (timePosition.playing && !wasPlaying) { + this->playbackStartedPending.store(true); + } + + for (uint32_t i = 0; i < DISTRHO_PLUGIN_NUM_OUTPUTS; ++i) { + for (uint32_t j = 0; j < frames; ++j) { + outputs[i][j] = 0; + } + } + + auto sampleRate = getSampleRate(); + auto mixLock = std::shared_lock(this->mixMutex, std::defer_lock); + auto tracksLock = std::shared_lock(this->tracksMutex, std::defer_lock); + if (timePosition.playing && mixLock.try_lock() && tracksLock.try_lock()) { + if (this->currentSampleRate.load() == sampleRate) { + for (uint32_t j = 0; j < mixes.size(); ++j) { + if (j >= this->outputMap.size()) { + break; + } + if (j >= this->tracks.size()) { + break; + } + const auto &mapping = outputMap[j]; + const auto &left = mixes[j].first; + const auto &right = mixes[j].second; + + const auto &track = tracks[j]; + + for (uint32_t i = 0; i < frames; ++i) { + auto frame = (i + timePosition.frame); + if (frame >= left.size()) { + break; + } + if (frame >= right.size()) { + break; + } + auto fadedLeft = left[frame] * Utils::dbToMultiplier(track.volume); + auto fadedRight = right[frame] * Utils::dbToMultiplier(track.volume); + if (track.pan < 0) { + fadedRight *= 1 + (track.pan / 100.0); + } else if (track.pan > 0) { + fadedLeft *= 1 - (track.pan / 100.0); + } + for (uint32_t k = 0; k < DISTRHO_PLUGIN_NUM_OUTPUTS; ++k) { + if (mapping.first[k] && frame < left.size()) { + if (outputs[k][i] > FLT_MAX - fadedLeft) { + outputs[k][i] = FLT_MAX; + } else if (outputs[k][i] < -FLT_MAX + fadedLeft) { + outputs[k][i] = -FLT_MAX; + } else { + outputs[k][i] += fadedLeft; + } + } + if (mapping.second[k] && frame < right.size()) { + if (outputs[k][i] > FLT_MAX - fadedRight) { + outputs[k][i] = FLT_MAX; + } else if (outputs[k][i] < -FLT_MAX + fadedRight) { + outputs[k][i] = -FLT_MAX; + } else { + outputs[k][i] += fadedRight; + } + } + } + } + } + } else { + requestResampleMixes(sampleRate, false); + } + } +}; + +void OpenUtauPlugin::sampleRateChanged(double newSampleRate) { + requestResampleMixes(newSampleRate); +} + +void OpenUtauPlugin::onAccept(OpenUtauPlugin *self, + const asio::error_code &error, + asio::ip::tcp::socket socket) { + if (self->shuttingDown) { + return; + } + self->willAccept(); + if (error) { + if (error != asio::error::operation_aborted) { + self->setConnectionState(ConnectionState::Error, error.message()); + } + return; + } + + auto socketPtr = + std::make_shared(std::move(socket)); + { + auto lock = std::lock_guard(self->connectionMutex); + if (self->activeSocket != nullptr) { + asio::error_code closeError; + socketPtr->close(closeError); + return; + } + self->activeSocket = socketPtr; + } + + if (self->connectionThread.joinable()) { + self->connectionThread.join(); + } + self->connectionThread = std::jthread( + [self, socketPtr](std::stop_token st) { + self->connectionLoop(st, socketPtr); + }); +} + +void OpenUtauPlugin::willAccept() { + if (this->shuttingDown || this->acceptor == nullptr || + !this->acceptor->is_open()) { + return; + } + acceptor->async_accept(std::bind(&OpenUtauPlugin::onAccept, this, + std::placeholders::_1, + std::placeholders::_2)); +} + +void OpenUtauPlugin::connectionLoop( + std::stop_token stopToken, + std::shared_ptr socket) { + setConnectionState(ConnectionState::Connected); + std::string disconnectError; + asio::error_code nonBlockingError; + socket->non_blocking(true, nonBlockingError); + if (nonBlockingError) { + disconnectError = nonBlockingError.message(); + } else { + std::string messageBuffer; + std::array buffer; + std::jthread heartbeatThread( + [this, socket](std::stop_token heartbeatStopToken) { + int heartbeatTicks = 0; + while (!heartbeatStopToken.stop_requested() && + !this->shuttingDown) { + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + if (heartbeatStopToken.stop_requested() || this->shuttingDown) { + return; + } + try { + if (this->playbackStartedPending.exchange(false)) { + sendMessage(socket, + formatMessage("notification:playbackStarted", + choc::value::createObject(""))); + } + if (++heartbeatTicks >= 50) { + heartbeatTicks = 0; + sendMessage(socket, + formatMessage("notification:ping", + choc::value::createObject(""))); + } + } catch (const std::exception &) { + asio::error_code error; + socket->close(error); + return; + } + } + }); + + while (!stopToken.stop_requested() && !this->shuttingDown) { + asio::error_code error; + const auto len = socket->read_some(asio::buffer(buffer), error); + if (!error) { + if (len == 0) { + disconnectError = "Connection closed by OpenUtau"; + break; + } + messageBuffer.append(buffer.data(), len); + size_t pos; + while ((pos = messageBuffer.find('\n')) != std::string::npos) { + auto message = messageBuffer.substr(0, pos); + messageBuffer.erase(0, pos + 1); + if (message == "close") { + disconnectError.clear(); + goto connection_finished; + } + try { + processMessage(message, socket); + } catch (const std::exception &e) { + disconnectError = e.what(); + goto connection_finished; + } + } + } else if (error != asio::error::would_block && + error != asio::error::try_again) { + if (error != asio::error::operation_aborted) { + disconnectError = error.message(); + } + break; + } + + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + } + +connection_finished: + { + auto lock = std::lock_guard(this->connectionMutex); + if (this->activeSocket == socket) { + this->activeSocket.reset(); + } + } + asio::error_code closeError; + socket->close(closeError); + if (this->shuttingDown || stopToken.stop_requested() || + disconnectError.empty()) { + setConnectionState(ConnectionState::Disconnected); + } else { + setConnectionState(ConnectionState::Error, disconnectError); + } +} + +void OpenUtauPlugin::processMessage( + const std::string &message, + const std::shared_ptr &socket) { + const auto separator = message.find(' '); + if (separator == std::string::npos) { + throw std::runtime_error("Malformed message: missing payload"); + } + const auto header = message.substr(0, separator); + const auto payload = choc::json::parse(message.substr(separator + 1)); + const auto firstColon = header.find(':'); + if (firstColon == std::string::npos) { + throw std::runtime_error("Malformed message header"); + } + + const auto messageType = header.substr(0, firstColon); + if (messageType == "request") { + const auto secondColon = header.find(':', firstColon + 1); + if (secondColon == std::string::npos) { + throw std::runtime_error("Malformed request header"); + } + const auto messageId = + header.substr(firstColon + 1, secondColon - firstColon - 1); + const auto requestType = header.substr(secondColon + 1); + auto responseObject = choc::value::createObject(""); + try { + responseObject.setMember("success", true); + responseObject.setMember("data", onRequest(requestType, payload)); + } catch (const std::exception &e) { + responseObject.setMember("success", false); + responseObject.setMember("error", e.what()); + } + sendMessage(socket, formatMessage(std::format("response:{}", messageId), + responseObject)); + } else if (messageType == "notification") { + onNotification(header.substr(firstColon + 1), payload); + } else { + throw std::runtime_error("Unknown message type"); + } +} + +void OpenUtauPlugin::sendMessage( + const std::shared_ptr &socket, + const std::string &message) { + auto lock = std::lock_guard(this->socketWriteMutex); + asio::error_code error; + asio::write(*socket, asio::buffer(message), error); + if (error) { + throw asio::system_error(error); + } +} + +void OpenUtauPlugin::closeActiveSocket() { + std::shared_ptr socket; + { + auto lock = std::lock_guard(this->connectionMutex); + socket = std::exchange(this->activeSocket, nullptr); + } + if (socket != nullptr) { + asio::error_code error; + socket->shutdown(asio::ip::tcp::socket::shutdown_both, error); + socket->close(error); + } +} + +void OpenUtauPlugin::setConnectionState(ConnectionState state, + const std::string &error) { + auto lock = std::lock_guard(this->stateMutex); + this->connectionState = state; + this->lastError = error; +} + +void OpenUtauPlugin::initializeNetwork() { + this->acceptor = std::make_unique( + Network::getIoContext()->get_executor(), + asio::ip::tcp::endpoint(asio::ip::make_address("127.0.0.1"), 0)); + + { + auto lock = std::lock_guard(this->stateMutex); + this->port = this->acceptor->local_endpoint().port(); + } + updatePluginServerFile(); + willAccept(); +} + +void OpenUtauPlugin::updatePluginServerFile() { + int port; + std::string name; + std::string uuid; + { + auto lock = std::lock_guard(this->stateMutex); + port = this->port; + name = this->name; + uuid = this->uuid; + } + std::filesystem::path tempPath = std::filesystem::temp_directory_path(); + std::filesystem::path socketPath = tempPath / "OpenUtau" / "PluginServers" / + std::format("{}.json", uuid); + std::string socketContent = choc::json::toString( + choc::value::createObject("", "port", port, "name", name)); + + std::filesystem::create_directories(socketPath.parent_path()); + std::ofstream socketFile(socketPath); + socketFile << socketContent; + socketFile.close(); + + this->socketPath = socketPath; +} + +choc::value::Value OpenUtauPlugin::onRequest(const std::string kind, + const choc::value::Value payload) { + if (kind == "init") { + auto _lock = std::lock_guard(this->stateMutex); + choc::value::Value response = + choc::value::createObject("", "ustx", this->ustx); + return response; + } else if (kind == "updatePartLayout") { + std::map> parts; + std::vector flatParts; + std::set hashes; + for (const auto &part : payload["parts"]) { + flatParts.push_back(Part::deserialize(part)); + } + for (const auto &part : flatParts) { + if (parts.find(part.trackNo) == parts.end()) { + parts[part.trackNo] = std::vector(); + } + parts[part.trackNo].push_back(part); + if (part.hash.has_value()) { + hashes.insert(part.hash.value()); + } + } + { + auto _lock = std::lock_guard(this->partsMutex); + this->parts = parts; + } + std::set toAdd; + { + auto _lock = std::unique_lock(this->audioBuffersMutex); + for (auto it = this->audioBuffers.begin(); + it != this->audioBuffers.end();) { + if (!hashes.contains(it->first)) { + it = this->audioBuffers.erase(it); + } else { + ++it; + } + } + for (const auto &hash : hashes) { + if (!this->audioBuffers.contains(hash)) { + toAdd.insert(hash); + } + } + } + + choc::value::Value response = choc::value::createObject(""); + auto missingAudios = choc::value::createEmptyArray(); + for (const auto &hash : toAdd) { + missingAudios.addArrayElement(std::to_string(hash)); + } + response.setMember("missingAudios", missingAudios); + + this->requestResampleMixes(this->currentSampleRate.load()); + + if (toAdd.size() == 0) { + auto _lock = std::lock_guard(this->stateMutex); + this->lastSync = std::chrono::system_clock::now(); + } + + return response; + } + + throw std::runtime_error("Unknown request type"); +} +void OpenUtauPlugin::onNotification(const std::string kind, + const choc::value::Value payload) { + if (kind == "updateUstx") { + auto ustx = payload["ustx"].get(); + auto ustxBase64 = choc::base64::encodeToString(ustx); + setState("ustx", ustxBase64.c_str()); + } else if (kind == "updateTracks") { + { + auto _lock = std::unique_lock(this->tracksMutex); + auto tracks = std::vector(); + for (const auto &track : payload["tracks"]) { + tracks.push_back(Structures::Track::deserialize(track)); + } + + this->tracks = tracks; + } + syncMapping(); + } else if (kind == "updateAudio") { + { + auto _lock = std::unique_lock(this->audioBuffersMutex); + + auto audioBuffers = this->audioBuffers; + payload["audios"].visitObjectMembers( + [&](std::string_view key, const choc::value::ValueView &value) { + auto hash = std::stoul(std::string(key)); + std::string encoded = value.get(); + auto decoded = Utils::unBase64ToVector(encoded); + auto decompressed = + Utils::gunzip((char *)decoded.data(), decoded.size()); + std::vector audio((float *)decompressed.data(), + (float *)decompressed.data() + + decompressed.size() / sizeof(float)); + audioBuffers[hash] = audio; + }); + + this->audioBuffers = audioBuffers; + } + + { + auto _lock = std::lock_guard(this->stateMutex); + this->lastSync = std::chrono::system_clock::now(); + } + this->requestResampleMixes(this->currentSampleRate.load()); + } +} + +void OpenUtauPlugin::syncMapping() { + Structures::OutputMap newOutputMap; + { + auto _lock = std::unique_lock(this->tracksMutex); + newOutputMap = this->outputMap; + if (tracks.size() < newOutputMap.size()) { + newOutputMap.resize(tracks.size()); + } else if (tracks.size() > newOutputMap.size()) { + for (size_t i = newOutputMap.size(); i < tracks.size(); ++i) { + auto leftChannel = std::bitset(); + auto rightChannel = std::bitset(); + leftChannel[0] = true; + rightChannel[1] = true; + + newOutputMap.push_back({leftChannel, rightChannel}); + } + } + this->outputMap = newOutputMap; + } + setState("mapping", Structures::serializeOutputMap(newOutputMap).c_str()); +} + +void OpenUtauPlugin::requestResampleMixes(double newSampleRate, bool force) { + { + auto lock = std::lock_guard(this->resampleRequestMutex); + if (!force && this->requestedSampleRate == newSampleRate && + this->requestedResampleGeneration > 0) { + return; + } + this->requestedSampleRate = newSampleRate; + ++this->requestedResampleGeneration; + } + this->resampleRequestCondition.notify_one(); +} + +void OpenUtauPlugin::resampleWorkerLoop(std::stop_token stopToken) { + uint64_t completedGeneration = 0; + while (!stopToken.stop_requested()) { + double sampleRate; + uint64_t generation; + { + auto lock = std::unique_lock(this->resampleRequestMutex); + this->resampleRequestCondition.wait( + lock, stopToken, [&] { + return this->requestedResampleGeneration > completedGeneration; + }); + if (stopToken.stop_requested()) { + return; + } + sampleRate = this->requestedSampleRate; + generation = this->requestedResampleGeneration; + } + resampleMixes(sampleRate, generation); + completedGeneration = generation; + } +} + +void OpenUtauPlugin::resampleMixes(double newSampleRate, + uint64_t generation) { + this->mixMutexLocked.store(true); + std::map> audioBuffers; + std::map> parts; + { + auto lock = std::shared_lock(this->audioBuffersMutex); + audioBuffers = this->audioBuffers; + } + { + auto lock = std::shared_lock(this->partsMutex); + parts = this->parts; + } + + std::vector, std::vector>> mixes; + for (const auto &[trackNo, trackParts] : parts) { + if (trackNo < 0) { + continue; + } + std::vector resampledLeft; + std::vector resampledRight; + if (mixes.size() <= static_cast(trackNo)) { + mixes.resize(static_cast(trackNo) + 1); + } + if (trackParts.empty()) { + continue; + } + auto maxEndMs = std::max_element( + trackParts.begin(), trackParts.end(), + [](const Part &a, const Part &b) { return a.endMs < b.endMs; }); + resampledLeft.resize((size_t)(maxEndMs->endMs / 1000.0 * newSampleRate) + + 1); + resampledRight.resize((size_t)(maxEndMs->endMs / 1000.0 * newSampleRate) + + 1); + for (const auto &part : trackParts) { + if (!part.hash.has_value()) { + continue; + } + auto startFrame = (size_t)(part.startMs / 1000.0 * newSampleRate); + auto endFrame = (size_t)(part.endMs / 1000.0 * newSampleRate); + auto rate = 44100.0 / newSampleRate; + const auto audio = audioBuffers.find(part.hash.value()); + if (audio == audioBuffers.end()) { + continue; + } + const auto &buffer = audio->second; + + for (size_t i = startFrame; i < endFrame; ++i) { + auto frame = (size_t)((i - startFrame) * rate); + auto leftFrameLeft = frame * 2; + auto leftFrameRight = frame * 2 + 2; + auto rightFrameLeft = frame * 2 + 1; + auto rightFrameRight = frame * 2 + 3; + auto lerp = ((i - startFrame) * rate) - frame; + if (rightFrameRight >= buffer.size()) { + break; + } + auto left = + (1 - lerp) * buffer[leftFrameLeft] + lerp * buffer[leftFrameRight]; + auto right = (1 - lerp) * buffer[rightFrameLeft] + + lerp * buffer[rightFrameRight]; + if (resampledLeft[i] > FLT_MAX - left) { + resampledLeft[i] = FLT_MAX; + } else if (resampledLeft[i] < -FLT_MAX + left) { + resampledLeft[i] = -FLT_MAX; + } else { + resampledLeft[i] += left; + } + if (resampledRight[i] > FLT_MAX - right) { + resampledRight[i] = FLT_MAX; + } else if (resampledRight[i] < -FLT_MAX + right) { + resampledRight[i] = -FLT_MAX; + } else { + resampledRight[i] += right; + } + } + } + + mixes[trackNo] = {std::move(resampledLeft), std::move(resampledRight)}; + } + + { + auto requestLock = std::lock_guard(this->resampleRequestMutex); + if (generation != this->requestedResampleGeneration) { + this->mixMutexLocked.store(false); + return; + } + } + { + auto mixLock = std::unique_lock(this->mixMutex); + this->mixes = std::move(mixes); + this->currentSampleRate.store(newSampleRate); + } + this->mixMutexLocked.store(false); +} + +std::string +OpenUtauPlugin::formatMessage(const std::string &kind, + const choc::value::ValueView &payload) { + std::string json = choc::json::toString(payload); + return std::format("{} {}\n", kind, json); +} + +OpenUtauPlugin::UiSnapshot OpenUtauPlugin::getUiSnapshot() const { + UiSnapshot snapshot; + { + auto lock = std::lock_guard(this->stateMutex); + snapshot.port = this->port; + snapshot.connectionState = this->connectionState; + snapshot.name = this->name; + snapshot.lastError = this->lastError; + snapshot.lastSync = this->lastSync; + } + { + auto lock = std::shared_lock(this->tracksMutex); + snapshot.tracks = this->tracks; + snapshot.outputMap = this->outputMap; + } + snapshot.processing = this->mixMutexLocked.load(); + return snapshot; +} + +void OpenUtauPlugin::setPluginName(const std::string &newName) { + { + auto lock = std::lock_guard(this->stateMutex); + this->name = newName; + } + updatePluginServerFile(); +} + +void OpenUtauPlugin::setOutputMap( + const Structures::OutputMap &newOutputMap) { + auto lock = std::unique_lock(this->tracksMutex); + this->outputMap = newOutputMap; +} + +// ------------------------------------------------------------------------------------------------------------ + +START_NAMESPACE_DISTRHO +Plugin *createPlugin() { return new OpenUtauPlugin(); } +END_NAMESPACE_DISTRHO + +// ----------------------------------------------------------------------------------------------------------- diff --git a/DawPlugin/src/plugin.hpp b/DawPlugin/src/plugin.hpp new file mode 100644 index 000000000..42e7972c8 --- /dev/null +++ b/DawPlugin/src/plugin.hpp @@ -0,0 +1,175 @@ +#pragma once +#include "DistrhoPlugin.hpp" +#include "alternate_shared_mutex.hpp" +#include "asio.hpp" +#include "choc/containers/choc_Value.h" +#include "common.hpp" +#include "extra/String.hpp" +#include "yamc_rwlock_sched.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// note: OpenUtau returns 44100Hz, 2ch, 32bit float audio + +using AudioHash = uint32_t; +class Part { +public: + int trackNo; + double startMs; + double endMs; + + std::optional hash; + + static Part deserialize(const choc::value::ValueView &value); + choc::value::Value serialize() const; +}; + +// ----------------------------------------------------------------------------------------------------------- + +class OpenUtauPlugin : public Plugin { +public: + enum class ConnectionState { + Disconnected, + Connected, + Error, + }; + + struct UiSnapshot { + int port; + ConnectionState connectionState; + std::string name; + std::string lastError; + std::optional> lastSync; + std::vector tracks; + Structures::OutputMap outputMap; + bool processing; + }; + + OpenUtauPlugin(); + + ~OpenUtauPlugin() override; + + UiSnapshot getUiSnapshot() const; + void setPluginName(const std::string &newName); + void setOutputMap(const Structures::OutputMap &newOutputMap); + +protected: + // -------------------------------------------------------------------------------------------------------- + const char *getLabel() const override; + + const char *getDescription() const override; + + const char *getMaker() const override; + + const char *getHomePage() const override; + + void initState(uint32_t index, State &state) override; + + String getState(const char *key) const override; + + void setState(const char *key, const char *value) override; + + const char *getLicense() const override; + + uint32_t getVersion() const override; + + // -------------------------------------------------------------------------------------------------------- + void initAudioPort(bool input, uint32_t index, AudioPort &port) override; + void initPortGroup(uint32_t groupId, PortGroup &group) override; + + // -------------------------------------------------------------------------------------------------------- + + void run(const float **inputs, float **outputs, uint32_t frames, + const MidiEvent *midiEvents, uint32_t midiEventCount) override; + + // -------------------------------------------------------------------------------------------------------- + + void sampleRateChanged(double newSampleRate) override; + + // ------------------------------------------------------------------------------------------------------- + +private: + static void onAccept(OpenUtauPlugin *self, const asio::error_code &error, + asio::ip::tcp::socket socket); + + void willAccept(); + + void initializeNetwork(); + void connectionLoop(std::stop_token stopToken, + std::shared_ptr socket); + void processMessage(const std::string &message, + const std::shared_ptr &socket); + void sendMessage(const std::shared_ptr &socket, + const std::string &message); + void closeActiveSocket(); + void setConnectionState(ConnectionState state, + const std::string &error = {}); + + choc::value::Value onRequest(const std::string kind, + const choc::value::Value payload); + void onNotification(const std::string kind, const choc::value::Value payload); + + static std::string formatMessage(const std::string &kind, + const choc::value::ValueView &payload); + + void syncMapping(); + void updatePluginServerFile(); + void requestResampleMixes(double newSampleRate, bool force = true); + void resampleWorkerLoop(std::stop_token stopToken); + void resampleMixes(double newSampleRate, uint64_t generation); + + mutable std::mutex stateMutex; + int port = 0; + std::string name; + std::string ustx; + std::string uuid; + ConnectionState connectionState = ConnectionState::Disconnected; + std::string lastError; + std::optional> lastSync; + + mutable yamc::alternate::basic_shared_mutex + tracksMutex; + std::vector tracks; + Structures::OutputMap outputMap; + + mutable yamc::alternate::basic_shared_mutex + audioBuffersMutex; + std::map> audioBuffers; + + mutable yamc::alternate::basic_shared_mutex + partsMutex; + std::map> parts; + + yamc::alternate::basic_shared_mutex mixMutex; + std::atomic_bool mixMutexLocked = false; + std::vector, std::vector>> mixes; + std::atomic currentSampleRate = 44100.0; + + std::mutex resampleRequestMutex; + std::condition_variable_any resampleRequestCondition; + double requestedSampleRate = 44100.0; + uint64_t requestedResampleGeneration = 0; + std::jthread resampleThread; + + std::filesystem::path socketPath; + + std::unique_ptr acceptor; + std::mutex connectionMutex; + std::mutex socketWriteMutex; + std::shared_ptr activeSocket; + std::jthread connectionThread; + std::atomic_bool shuttingDown = false; + std::atomic_bool wasPlaying = false; + std::atomic_bool playbackStartedPending = false; + + DISTRHO_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(OpenUtauPlugin) +}; +// ----------------------------------------------------------------------------------------------------------- diff --git a/DawPlugin/src/ui.cpp b/DawPlugin/src/ui.cpp new file mode 100644 index 000000000..6b5529bff --- /dev/null +++ b/DawPlugin/src/ui.cpp @@ -0,0 +1,322 @@ +#include "DistrhoPluginInfo.h" +#include "DistrhoUI.hpp" +#include "common.hpp" +#include "dpf_widgets/generic/ResizeHandle.hpp" +#include "dpf_widgets/opengl/DearImGui/imgui.h" +#include "noto_sans/noto_sans.hpp" +#include "plugin.hpp" +#include +#include + +START_NAMESPACE_DISTRHO + +// -------------------------------------------------------------------------------------------------------------------- + +int fontSize = 16.f; + +// #ff679d +static auto themePinkColor = ImVec4(1.0f, 0.4f, 0.6f, 1.0f); +static auto themeBlueColor = ImVec4(0.3f, 0.7f, 0.9f, 1.0f); + +class OpenUtauUI : public UI { +public: + OpenUtauUI() + : UI(DISTRHO_UI_DEFAULT_WIDTH, DISTRHO_UI_DEFAULT_HEIGHT), + resizeHandle(this) { + const double scaleFactor = getScaleFactor(); + + if (d_isEqual(scaleFactor, 1.0)) { + setGeometryConstraints(DISTRHO_UI_DEFAULT_WIDTH, + DISTRHO_UI_DEFAULT_HEIGHT); + } else { + const uint width = DISTRHO_UI_DEFAULT_WIDTH * scaleFactor; + const uint height = DISTRHO_UI_DEFAULT_HEIGHT * scaleFactor; + setGeometryConstraints(width, height); + setSize(width, height); + } + + if (isResizable()) + resizeHandle.hide(); + + setTheme(); + setFont(); + } + + bool showDemoWindow = false; + +protected: + // ---------------------------------------------------------------------------------------------------------------- + void parameterChanged(uint32_t, float) override {} + + void stateChanged(const char *, const char *) override {} + + void onImGuiDisplay() override { + ImGui::SetNextWindowPos(ImVec2(0, 0)); + ImGui::SetNextWindowSize(ImVec2(getWidth(), getHeight())); + + auto plugin = getPlugin(); + auto snapshot = plugin->getUiSnapshot(); + + auto &style = ImGui::GetStyle(); + + ImGui::Begin("OpenUtau Bridge", nullptr, + ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize); + + ImGui::TextColored(themePinkColor, +#ifdef DEBUG + "OpenUtau Bridge v%d.%d.%d (Debug)", +#else + "OpenUtau Bridge v%d.%d.%d", +#endif + Constants::majorVersion, Constants::minorVersion, + Constants::patchVersion); + + ImGui::Separator(); + + ImGui::Text("Plugin name:"); + ImGui::SameLine(); + ImGui::InputText("##plugin-name", nameBuffer, sizeof(nameBuffer)); + if (ImGui::IsItemDeactivatedAfterEdit()) { + setState("name", nameBuffer); + plugin->setPluginName(nameBuffer); + } else if (!(ImGui::IsItemActive() && + ImGui::TempInputIsActive(ImGui::GetActiveID()))) { +#if CHOC_WINDOWS + strncpy_s(nameBuffer, snapshot.name.c_str(), sizeof(nameBuffer)); +#else + strncpy(nameBuffer, snapshot.name.c_str(), sizeof(nameBuffer)); +#endif + } + + partiallyColoredText( + std::format("Plugin identifier: [{} ({})]", snapshot.name, + snapshot.port), + themePinkColor); + + const char *connectionLabel = "Disconnected"; + ImVec4 connectionColor = style.Colors[ImGuiCol_TextDisabled]; + if (snapshot.connectionState == + OpenUtauPlugin::ConnectionState::Connected) { + connectionLabel = "Connected"; + connectionColor = themePinkColor; + } else if (snapshot.connectionState == + OpenUtauPlugin::ConnectionState::Error) { + connectionLabel = "Error"; + connectionColor = themeBlueColor; + } + partiallyColoredText( + std::format("Connection: [{}]", connectionLabel), connectionColor); + if (!snapshot.lastError.empty()) { + ImGui::TextWrapped("Last error: %s", snapshot.lastError.c_str()); + } + +#ifdef DEBUG + if (ImGui::IsKeyPressed(ImGui::GetKeyIndex(ImGuiKey_D))) { + showDemoWindow = !showDemoWindow; + } + if (showDemoWindow) { + ImGui::ShowDemoWindow(&showDemoWindow); + } +#endif + + ImGui::Text("Last audio sync: "); + ImGui::SameLine(0, 0); + + if (snapshot.processing) { + ImGui::TextColored(themeBlueColor, "Processing"); + } else { + if (snapshot.lastSync) { + long lastSyncDuration = + std::chrono::duration_cast( + std::chrono::system_clock::now() - *snapshot.lastSync) + .count(); + if (lastSyncDuration > 60) { + ImGui::Text("%ldm ago", lastSyncDuration / 60); + } else { + ImGui::TextColored(themePinkColor, "%lds ago", lastSyncDuration); + } + } else { + ImGui::TextColored(style.Colors[ImGuiCol_TextDisabled], "N/A"); + } + } + + if (!snapshot.tracks.empty()) { + ImGui::Spacing(); + ImGui::TextColored(themePinkColor, "Track Mapping:"); + if (ImGui::BeginTable("##track_mapping", + DISTRHO_PLUGIN_NUM_OUTPUTS / 2 + 1, + ImGuiTableFlags_Borders)) { + ImGui::TableSetupColumn("Track", ImGuiTableColumnFlags_NoReorder); + for (int i = 0; i < DISTRHO_PLUGIN_NUM_OUTPUTS / 2; i++) { + ImGui::TableSetupColumn(std::format("Ch. {}", i + 1).c_str(), + ImGuiTableColumnFlags_NoReorder | + ImGuiTableColumnFlags_WidthFixed | + ImGuiTableColumnFlags_NoSort, + style.FramePadding.x * 4 + fontSize * 2); + } + ImGui::TableHeadersRow(); + const auto mappingCount = + std::min(snapshot.tracks.size(), snapshot.outputMap.size()); + for (size_t i = 0; i < mappingCount; i++) { + ImGui::TableNextRow(); + for (int lr = 0; lr < 2; lr++) { + ImGui::TableNextColumn(); + ImGui::Text("%s: %s", snapshot.tracks[i].name.c_str(), + lr == 0 ? "L" : "R"); + for (int j = 0; j < DISTRHO_PLUGIN_NUM_OUTPUTS; j += 2) { + ImGui::TableNextColumn(); + bool leftValue; + bool rightValue; + if (lr == 0) { + leftValue = snapshot.outputMap[i].first[j]; + rightValue = snapshot.outputMap[i].first[j + 1]; + } else { + leftValue = snapshot.outputMap[i].second[j]; + rightValue = snapshot.outputMap[i].second[j + 1]; + } + + bool leftCheckboxChanged = ImGui::Checkbox( + std::format("##track-mapping-checkbox-{}-{}-{}", i, lr, j) + .c_str(), + &leftValue); + if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) { + ImGui::SetTooltip("Channel %d, Left", j / 2 + 1); + } + ImGui::SameLine(0, style.ItemSpacing.x / 2.0f); + bool rightCheckboxChanged = ImGui::Checkbox( + std::format("##track-mapping-checkbox-{}-{}-{}", i, lr, j + 1) + .c_str(), + &rightValue); + if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) { + ImGui::SetTooltip("Channel %d, Right", j / 2 + 1); + } + if (leftCheckboxChanged || rightCheckboxChanged) { + auto newOutputMap = snapshot.outputMap; + if (lr == 0) { + newOutputMap[i].first[j] = leftValue; + newOutputMap[i].first[j + 1] = rightValue; + } else { + newOutputMap[i].second[j] = leftValue; + newOutputMap[i].second[j + 1] = rightValue; + } + setState("mapping", + Structures::serializeOutputMap(newOutputMap).c_str()); + plugin->setOutputMap(newOutputMap); + } + } + } + } + ImGui::EndTable(); + } + } else { + ImGui::Text("Please sync with OpenUtau first."); + ImGui::Text("1. Launch OpenUtau"); + partiallyColoredText( + "2. Click ['File'] > ['Connect to DAW...'] in OpenUtau", + themePinkColor); + partiallyColoredText(std::format("3. Select ['{} ({})'] in the list", + snapshot.name, snapshot.port), + themePinkColor); + } + + ImGui::End(); + } + + OpenUtauPlugin *getPlugin() { + return static_cast(getPluginInstancePointer()); + } + + // ---------------------------------------------------------------------------------------------------------------- + void setTheme() { + ImGui::StyleColorsLight(); + ImVec4 *colors = ImGui::GetStyle().Colors; + // #ff679d + auto color = themePinkColor; + auto darkColor = ImVec4(0.8f, 0.3f, 0.5f, 1.0f); + auto lightColor = ImVec4(1.0f, 0.5f, 0.7f, 1.0f); + colors[ImGuiCol_SliderGrab] = color; + colors[ImGuiCol_SliderGrabActive] = darkColor; + colors[ImGuiCol_ButtonActive] = darkColor; + colors[ImGuiCol_SeparatorHovered] = lightColor; + colors[ImGuiCol_TabHovered] = lightColor; + colors[ImGuiCol_TabActive] = color; + colors[ImGuiCol_CheckMark] = color; + colors[ImGuiCol_PlotHistogram] = color; + colors[ImGuiCol_PlotHistogramHovered] = darkColor; + + // #4ea6ea + auto hoverColor = themeBlueColor; + colors[ImGuiCol_FrameBgHovered] = + ImVec4(hoverColor.w, hoverColor.x, hoverColor.y, 0.20f); + colors[ImGuiCol_FrameBgActive] = + ImVec4(hoverColor.w, hoverColor.x, hoverColor.y, 0.40f); + } + + // TODO: Implement Chinese font properly + // https://heistak.github.io/your-code-displays-japanese-wrong/ + void setFont() { + const double scaleFactor = getScaleFactor(); + + auto &io = ImGui::GetIO(); + ImFontConfig fc; + fc.FontDataOwnedByAtlas = false; + fc.OversampleH = 1; + fc.OversampleV = 1; + fc.PixelSnapH = true; + + ImFontGlyphRangesBuilder rangeBuilder; + static ImVector ranges; + rangeBuilder.AddRanges(ImGui::GetIO().Fonts->GetGlyphRangesDefault()); + rangeBuilder.AddRanges(ImGui::GetIO().Fonts->GetGlyphRangesJapanese()); + rangeBuilder.AddRanges(ImGui::GetIO().Fonts->GetGlyphRangesKorean()); + rangeBuilder.AddRanges(ImGui::GetIO().Fonts->GetGlyphRangesCyrillic()); + rangeBuilder.AddRanges(ImGui::GetIO().Fonts->GetGlyphRangesVietnamese()); + rangeBuilder.AddRanges(ImGui::GetIO().Fonts->GetGlyphRangesChineseFull()); + rangeBuilder.AddRanges(ImGui::GetIO().Fonts->GetGlyphRangesThai()); + rangeBuilder.AddRanges(ImGui::GetIO().Fonts->GetGlyphRangesGreek()); + rangeBuilder.BuildRanges(&ranges); + + io.Fonts->AddFontFromMemoryTTF((void *)notoSansJpRegular, + notoSansJpRegularLen, fontSize * scaleFactor, + &fc, ranges.Data); + io.Fonts->Build(); + } + + void partiallyColoredText(const std::string &text, const ImVec4 &color) { + std::string remainingText = text; + while (remainingText.size() > 0) { + auto pos = remainingText.find_first_of('['); + if (pos == std::string::npos) { + ImGui::TextUnformatted(remainingText.c_str()); + break; + } + ImGui::TextUnformatted(remainingText.substr(0, pos).c_str()); + ImGui::SameLine(0, 0); + remainingText = remainingText.substr(pos + 1); + pos = remainingText.find_first_of(']'); + if (pos == std::string::npos) { + ImGui::TextUnformatted(remainingText.c_str()); + break; + } + auto coloredText = remainingText.substr(0, pos); + ImGui::TextColored(color, "%s", coloredText.c_str()); + remainingText = remainingText.substr(pos + 1); + if (remainingText.size() > 0) { + ImGui::SameLine(0, 0); + } + } + } + + ResizeHandle resizeHandle; + char nameBuffer[1024]; + + DISTRHO_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(OpenUtauUI) +}; + +// -------------------------------------------------------------------------------------------------------------------- + +UI *createUI() { return new OpenUtauUI(); } + +// -------------------------------------------------------------------------------------------------------------------- + +END_NAMESPACE_DISTRHO diff --git a/OpenUtau.Core/Commands/Notifications.cs b/OpenUtau.Core/Commands/Notifications.cs index 0ee6448fa..bc63898d2 100644 --- a/OpenUtau.Core/Commands/Notifications.cs +++ b/OpenUtau.Core/Commands/Notifications.cs @@ -249,4 +249,11 @@ public NotePresetChangedNotification() { } public override string ToString() => "Note preset changed."; } + + public class DawConnectedNotification : UNotification { + public override string ToString() => $"Connected to DAW."; + } + public class DawDisconnectedNotification : UNotification { + public override string ToString() => $"Disconnected from DAW."; + } } diff --git a/OpenUtau.Core/DawIntegration/DawClient.cs b/OpenUtau.Core/DawIntegration/DawClient.cs new file mode 100644 index 000000000..24ad8510a --- /dev/null +++ b/OpenUtau.Core/DawIntegration/DawClient.cs @@ -0,0 +1,250 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Serilog; + +namespace OpenUtau.Core.DawIntegration { + public sealed class DawClient : IDisposable { + private static readonly TimeSpan DefaultRequestTimeout = TimeSpan.FromSeconds(10); + private static readonly TimeSpan HeartbeatTimeout = TimeSpan.FromSeconds(15); + + public readonly DawServer server; + + private readonly TcpClient tcpClient = new TcpClient(); + private readonly SemaphoreSlim writerSemaphore = new SemaphoreSlim(1, 1); + private readonly object handlersLock = new object(); + private readonly Dictionary> handlers = new Dictionary>(); + private readonly Dictionary> pendingRequests = + new Dictionary>(); + private readonly CancellationTokenSource lifetimeCancellation = new CancellationTokenSource(); + + private NetworkStream? stream; + private Task? receiver; + private Task? heartbeatMonitor; + private long lastMessageTicks = DateTime.UtcNow.Ticks; + private int disconnected; + + public event Action? Disconnected; + public event Action? PlaybackStarted; + + private DawClient(DawServer server) { + this.server = server; + } + + public static async Task<(DawClient, string)> Connect( + DawServer server, + CancellationToken cancellationToken = default) { + var client = new DawClient(server); + try { + await client.tcpClient.ConnectAsync( + "127.0.0.1", server.Port, cancellationToken); + client.stream = client.tcpClient.GetStream(); + client.RegisterNotification("ping", _ => { }); + client.RegisterNotification( + "playbackStarted", _ => client.PlaybackStarted?.Invoke()); + client.receiver = client.StartReceiver(client.lifetimeCancellation.Token); + client.heartbeatMonitor = client.MonitorHeartbeat(client.lifetimeCancellation.Token); + + using var initTimeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + initTimeout.CancelAfter(TimeSpan.FromSeconds(5)); + var initMessage = await client.SendRequest( + new InitRequest(), initTimeout.Token); + return (client, initMessage.ustx); + } catch { + client.Disconnect(); + throw; + } + } + + private async Task StartReceiver(CancellationToken token) { + if (stream == null) { + throw new InvalidOperationException("DAW stream is not initialized."); + } + + Exception? disconnectError = null; + var currentMessageBuffer = new List(); + var buffer = new byte[16 * 1024]; + try { + while (!token.IsCancellationRequested) { + var bytesRead = await stream.ReadAsync(buffer, token); + if (bytesRead == 0) { + throw new EndOfStreamException("DAW closed the connection."); + } + Interlocked.Exchange(ref lastMessageTicks, DateTime.UtcNow.Ticks); + for (var i = 0; i < bytesRead; ++i) { + if (buffer[i] == (byte)'\n') { + HandleMessage(Encoding.UTF8.GetString(currentMessageBuffer.ToArray())); + currentMessageBuffer.Clear(); + } else { + currentMessageBuffer.Add(buffer[i]); + } + } + } + } catch (OperationCanceledException) when (token.IsCancellationRequested) { + } catch (Exception e) { + disconnectError = e; + } finally { + DisconnectCore(disconnectError); + } + } + + private void HandleMessage(string message) { + var parts = message.Split(' ', 2); + if (parts.Length != 2) { + throw new InvalidDataException("Malformed DAW message."); + } + + Action? notificationHandler = null; + TaskCompletionSource? requestHandler = null; + lock (handlersLock) { + if (handlers.TryGetValue(parts[0], out var handler)) { + notificationHandler = handler; + } else if (pendingRequests.Remove(parts[0], out var pending)) { + requestHandler = pending; + } + } + + if (notificationHandler != null) { + notificationHandler(parts[1]); + } else if (requestHandler != null) { + requestHandler.TrySetResult(parts[1]); + } else { + Log.Warning("Unhandled DAW message: {Kind}", parts[0]); + } + } + + private async Task MonitorHeartbeat(CancellationToken token) { + try { + while (!token.IsCancellationRequested) { + await Task.Delay(TimeSpan.FromSeconds(2), token); + var lastMessage = new DateTime( + Interlocked.Read(ref lastMessageTicks), DateTimeKind.Utc); + if (DateTime.UtcNow - lastMessage > HeartbeatTimeout) { + DisconnectCore(new TimeoutException("DAW heartbeat timed out.")); + return; + } + } + } catch (OperationCanceledException) when (token.IsCancellationRequested) { + } + } + + private async Task SendMessage( + string header, + DawMessage data, + CancellationToken cancellationToken) { + if (stream == null || Volatile.Read(ref disconnected) != 0) { + throw new IOException("DAW is disconnected."); + } + + var message = Encoding.UTF8.GetBytes( + $"{header} {JsonConvert.SerializeObject(data)}\n"); + await writerSemaphore.WaitAsync(cancellationToken); + try { + await stream.WriteAsync(message, cancellationToken); + await stream.FlushAsync(cancellationToken); + } catch (Exception e) when ( + e is IOException || e is SocketException || e is ObjectDisposedException) { + DisconnectCore(e); + throw; + } finally { + writerSemaphore.Release(); + } + } + + public async Task SendRequest( + DawDawRequest data, + CancellationToken cancellationToken = default) where T : DawDawResponse { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(DefaultRequestTimeout); + + var uuid = Guid.NewGuid().ToString(); + var responseKind = $"response:{uuid}"; + var responseSource = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + lock (handlersLock) { + if (Volatile.Read(ref disconnected) != 0) { + throw new IOException("DAW is disconnected."); + } + pendingRequests.Add(responseKind, responseSource); + } + + using var registration = timeout.Token.Register(() => + responseSource.TrySetException(new TimeoutException( + $"DAW request '{data.kind}' timed out."))); + try { + await SendMessage($"request:{uuid}:{data.kind}", data, timeout.Token); + var message = await responseSource.Task; + var result = JsonConvert.DeserializeObject>(message) + ?? throw new InvalidDataException("Invalid DAW response."); + if (!result.success) { + throw new InvalidOperationException( + $"DAW returned error to request {data.kind}: {result.error}"); + } + return result.data + ?? throw new InvalidDataException("DAW response did not contain data."); + } catch (TimeoutException e) { + DisconnectCore(e); + throw; + } finally { + lock (handlersLock) { + pendingRequests.Remove(responseKind); + } + } + } + + public Task SendNotification( + DawDawNotification data, + CancellationToken cancellationToken = default) { + return SendMessage($"notification:{data.kind}", data, cancellationToken); + } + + public void RegisterNotification(string kind, Action handler) + where T : DawOuNotification { + lock (handlersLock) { + handlers[$"notification:{kind}"] = message => { + var notification = JsonConvert.DeserializeObject(message) + ?? throw new InvalidDataException($"Invalid {kind} notification."); + handler(notification); + }; + } + } + + public void Disconnect() { + DisconnectCore(null); + } + + private void DisconnectCore(Exception? error) { + if (Interlocked.Exchange(ref disconnected, 1) != 0) { + return; + } + + lifetimeCancellation.Cancel(); + try { + tcpClient.Close(); + } catch { + } + + List> pending; + lock (handlersLock) { + pending = new List>(pendingRequests.Values); + pendingRequests.Clear(); + } + var disconnectException = error ?? new IOException("DAW disconnected."); + foreach (var request in pending) { + request.TrySetException(disconnectException); + } + Disconnected?.Invoke(this, error); + } + + public void Dispose() { + Disconnect(); + lifetimeCancellation.Dispose(); + writerSemaphore.Dispose(); + } + } +} diff --git a/OpenUtau.Core/DawIntegration/DawManager.cs b/OpenUtau.Core/DawIntegration/DawManager.cs new file mode 100644 index 000000000..7c70882e6 --- /dev/null +++ b/OpenUtau.Core/DawIntegration/DawManager.cs @@ -0,0 +1,314 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using K4os.Hash.xxHash; +using OpenUtau.Core.Ustx; +using OpenUtau.Core.Util; +using Serilog; + +namespace OpenUtau.Core.DawIntegration { + public class DawManager : SingletonBase, ICmdSubscriber { + private static readonly TimeSpan[] ReconnectDelays = { + TimeSpan.FromMilliseconds(500), + TimeSpan.FromSeconds(1), + TimeSpan.FromSeconds(2), + }; + + private readonly object clientLock = new object(); + private readonly SemaphoreSlim updateSemaphore = new SemaphoreSlim(1, 1); + private readonly Debounce sendLayoutDebounce = new Debounce(); + private readonly Debounce sendAudioDebounce = new Debounce(); + + private DawClient? client; + private CancellationTokenSource? reconnectCancellation; + private volatile bool manualDisconnect; + private int reconnecting; + + public DawClient? Client { + get { + lock (clientLock) { + return client; + } + } + } + + public bool IsConnected => Client != null; + + private DawManager() { + DocManager.Inst.AddSubscriber(this); + } + + public void OnNext(UCommand cmd, bool isUndo) { + if (!IsConnected) { + return; + } + if (cmd is UNotification && !( + cmd is PartRenderedNotification || + cmd is VolumeChangeNotification || + cmd is PanChangeNotification)) { + return; + } + + sendLayoutDebounce.Do(TimeSpan.FromSeconds(1), () => + RunSerializedUpdate(async currentClient => { + await UpdateUstx(currentClient); + await UpdateTracks(currentClient); + })); + sendAudioDebounce.Do(TimeSpan.FromSeconds(5), () => + RunSerializedUpdate(UpdateAudio)); + } + + public async Task Connect( + DawServer server, + CancellationToken cancellationToken = default) { + await DisconnectInternal(sendFinalUpdate: false, notify: false); + manualDisconnect = false; + reconnectCancellation = new CancellationTokenSource(); + var (newClient, ustx) = await DawClient.Connect(server, cancellationToken); + AttachClient(newClient); + return ustx; + } + + public Task Synchronize() { + return ForceFullSync(); + } + + public Task Disconnect() { + return DisconnectInternal(sendFinalUpdate: true, notify: true); + } + + private async Task DisconnectInternal(bool sendFinalUpdate, bool notify) { + manualDisconnect = true; + reconnectCancellation?.Cancel(); + reconnectCancellation?.Dispose(); + reconnectCancellation = null; + sendLayoutDebounce.Cancel(); + sendAudioDebounce.Cancel(); + + DawClient? currentClient; + lock (clientLock) { + currentClient = client; + } + if (currentClient == null) { + return; + } + + if (sendFinalUpdate) { + try { + await RunSerializedUpdate(async connectedClient => { + await UpdateUstx(connectedClient); + await UpdateTracks(connectedClient); + await UpdateAudio(connectedClient); + }); + } catch (Exception e) { + Log.Warning(e, "Failed to send final DAW update."); + } + } + + DetachClient(currentClient); + currentClient.Disconnect(); + currentClient.Dispose(); + if (notify) { + DocManager.Inst.ExecuteCmd(new DawDisconnectedNotification()); + } + } + + private void AttachClient(DawClient newClient) { + lock (clientLock) { + client = newClient; + } + newClient.Disconnected += OnClientDisconnected; + newClient.PlaybackStarted += OnPlaybackStarted; + } + + private void DetachClient(DawClient disconnectedClient) { + disconnectedClient.Disconnected -= OnClientDisconnected; + disconnectedClient.PlaybackStarted -= OnPlaybackStarted; + lock (clientLock) { + if (ReferenceEquals(client, disconnectedClient)) { + client = null; + } + } + } + + private void OnClientDisconnected(DawClient disconnectedClient, Exception? error) { + DetachClient(disconnectedClient); + if (manualDisconnect) { + return; + } + + var cancellation = reconnectCancellation; + if (cancellation == null || cancellation.IsCancellationRequested) { + return; + } + if (Interlocked.CompareExchange(ref reconnecting, 1, 0) != 0) { + return; + } + _ = Reconnect(disconnectedClient.server, error, cancellation.Token); + } + + private void OnPlaybackStarted() { + _ = FlushPendingUpdates(); + } + + private async Task FlushPendingUpdates() { + try { + await sendLayoutDebounce.Flush(); + await sendAudioDebounce.Flush(); + } catch (Exception e) { + Log.Warning(e, "Failed to flush pending DAW updates before playback."); + } + } + + private async Task Reconnect( + DawServer server, + Exception? disconnectError, + CancellationToken cancellationToken) { + try { + Log.Warning(disconnectError, "DAW connection lost. Reconnecting..."); + DocManager.Inst.ExecuteCmd(new ProgressBarNotification( + 0, "DAW connection lost. Reconnecting...")); + + Exception? lastError = disconnectError; + foreach (var delay in ReconnectDelays) { + try { + await Task.Delay(delay, cancellationToken); + var (newClient, _) = await DawClient.Connect(server, cancellationToken); + if (cancellationToken.IsCancellationRequested || manualDisconnect) { + newClient.Dispose(); + return; + } + AttachClient(newClient); + await ForceFullSync(); + DocManager.Inst.ExecuteCmd(new DawConnectedNotification()); + Log.Information("Reconnected to DAW."); + return; + } catch (OperationCanceledException) + when (cancellationToken.IsCancellationRequested) { + return; + } catch (Exception e) { + lastError = e; + Log.Warning(e, "Failed to reconnect to DAW."); + } + } + + DocManager.Inst.ExecuteCmd(new DawDisconnectedNotification()); + if (lastError != null) { + Log.Error(lastError, "DAW reconnection attempts exhausted."); + } + } finally { + Interlocked.Exchange(ref reconnecting, 0); + } + } + + private async Task ForceFullSync() { + await RunSerializedUpdate(async currentClient => { + await UpdateUstx(currentClient); + await UpdateTracks(currentClient); + await UpdateAudio(currentClient); + }); + } + + private async Task RunSerializedUpdate(Func update) { + await updateSemaphore.WaitAsync(); + try { + var currentClient = Client; + if (currentClient != null) { + await update(currentClient); + } + } finally { + updateSemaphore.Release(); + } + } + + private async Task UpdateUstx(DawClient currentClient) { + Log.Information("Updating ustx in DAW..."); + try { + var ustx = Format.Ustx.FromProject(DocManager.Inst.Project); + await currentClient.SendNotification(new UpdateUstxNotification(ustx)); + Log.Information("Sent ustx to DAW."); + } catch (Exception e) { + Log.Error(e, "Failed to send ustx to DAW."); + throw; + } + } + + private async Task UpdateTracks(DawClient currentClient) { + Log.Information("Updating tracks in DAW..."); + try { + await currentClient.SendNotification( + new UpdateTracksNotification( + DocManager.Inst.Project.tracks.Select(track => + new UpdateTracksNotification.Track( + track.TrackName, + track.Volume, + track.Pan)).ToList())); + Log.Information("Sent tracks to DAW."); + } catch (Exception e) { + Log.Error(e, "Failed to send tracks to DAW."); + throw; + } + } + + private async Task UpdateAudio(DawClient currentClient) { + try { + var readyParts = DocManager.Inst.Project.parts + .OfType() + .Where(part => part.Mix != null) + .ToList(); + + Log.Information("Rendering prerenders for DAW..."); + var buffers = readyParts.Select(part => { + double startMs = DocManager.Inst.Project.timeAxis.TickPosToMsPos(part.position); + double endMs = DocManager.Inst.Project.timeAxis.TickPosToMsPos(part.position + part.duration); + int samplePos = (int)(startMs * 44100 / 1000) * 2; + int sampleCount = (int)((endMs - startMs) * 44100 / 1000) * 2; + var floatBuffer = new float[sampleCount]; + part.Mix!.Mix(samplePos, floatBuffer, 0, sampleCount); + var byteBuffer = new byte[floatBuffer.Length * 4]; + Buffer.BlockCopy(floatBuffer, 0, byteBuffer, 0, byteBuffer.Length); + return ( + part, + startMs, + endMs, + byteBuffer, + hash: XXH32.DigestOf(byteBuffer)); + }).ToList(); + + Log.Information("Sending part layout to DAW..."); + var missingAudios = await currentClient.SendRequest( + new UpdatePartLayoutRequest( + buffers.Select(buffer => new UpdatePartLayoutRequest.Part( + buffer.part.trackNo, + buffer.startMs, + buffer.endMs, + buffer.hash)).ToList())); + Log.Information("Sent part layout to DAW."); + + if (missingAudios.missingAudios.Count > 0) { + Log.Information( + "DAW requested {Count} missing audios.", + missingAudios.missingAudios.Count); + var buffersDict = buffers.ToDictionary(buffer => buffer.hash); + var audios = new Dictionary(); + foreach (var audioHash in missingAudios.missingAudios) { + var buffer = buffersDict[audioHash]; + audios[audioHash] = + Convert.ToBase64String(Gzip.Compress(buffer.byteBuffer)); + } + + await currentClient.SendNotification( + new UpdateAudioNotification(audios)); + Log.Information("Sent missing audios to DAW."); + } else { + Log.Information("Audios in DAW are up to date."); + } + } catch (Exception e) { + Log.Error(e, "Failed to send status to DAW."); + throw; + } + } + } +} diff --git a/OpenUtau.Core/DawIntegration/DawMessages.cs b/OpenUtau.Core/DawIntegration/DawMessages.cs new file mode 100644 index 000000000..24339756c --- /dev/null +++ b/OpenUtau.Core/DawIntegration/DawMessages.cs @@ -0,0 +1,147 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace OpenUtau.Core.DawIntegration { + public abstract class DawMessage { + } + + /// + /// OpenUtau to DAW notification. + /// Does not require a response. + /// + public abstract class DawDawNotification : DawMessage { + [JsonIgnore] + public abstract string kind { get; } + } + /// + /// DAW to OpenUtau notification. + /// + public abstract class DawOuNotification : DawMessage { + } + public sealed class PingNotification : DawOuNotification { + } + public sealed class PlaybackStartedNotification : DawOuNotification { + } + /// + /// OpenUtau to DAW request. + /// + public abstract class DawDawRequest : DawMessage { + [JsonIgnore] + public abstract string kind { get; } + } + /// + /// OpenUtau to DAW response. + /// + public class DawDawResponse : DawMessage { + } + + /// + /// DAW to OpenUtau request. + /// + public abstract class DawOuRequest : DawMessage { + } + /// + /// DAW to OpenUtau response. + /// + public class DawOuResponse : DawMessage { + } + + public class DawResult : DawMessage where T : DawDawResponse { + public bool success; + public T? data; + public string? error; + + public DawResult(bool success, T? data, string? error) { + this.success = success; + this.data = data; + this.error = error; + } + } + + + public class InitRequest : DawDawRequest { + public InitRequest() { + } + public override string kind => "init"; + } + public class InitResponse : DawDawResponse { + public string ustx; + + public InitResponse(string ustx) { + this.ustx = ustx; + } + } + public class UpdateUstxNotification : DawDawNotification { + public string ustx; + + public UpdateUstxNotification(string ustx) { + this.ustx = ustx; + } + + public override string kind => "updateUstx"; + } + public class UpdatePartLayoutRequest : DawDawRequest { + public List parts; + + public override string kind => "updatePartLayout"; + + public UpdatePartLayoutRequest(List parts) { + this.parts = parts; + } + + public class Part { + public int trackNo; + public double startMs; + public double endMs; + public uint audioHash; + + public Part(int trackNo, double startMs, double endMs, uint audioHash) { + this.trackNo = trackNo; + this.startMs = startMs; + this.endMs = endMs; + this.audioHash = audioHash; + } + } + } + + public class UpdatePartLayoutResponse : DawDawResponse { + public List missingAudios; + + public UpdatePartLayoutResponse(List missingAudios) { + this.missingAudios = missingAudios; + } + + } + + public class UpdateTracksNotification : DawDawNotification { + public List tracks; + + public override string kind => "updateTracks"; + + public UpdateTracksNotification(List tracks) { + this.tracks = tracks; + } + + public class Track { + public string name; + public double volume; + public double pan; + + public Track(string name, double volume, double pan) { + this.name = name; + this.volume = volume; + this.pan = pan; + } + } + } + + public class UpdateAudioNotification : DawDawNotification { + public Dictionary audios; + + public override string kind => "updateAudio"; + + public UpdateAudioNotification(Dictionary audios) { + this.audios = audios; + } + } +} diff --git a/OpenUtau.Core/DawIntegration/DawServerFinder.cs b/OpenUtau.Core/DawIntegration/DawServerFinder.cs new file mode 100644 index 000000000..9030c0fbc --- /dev/null +++ b/OpenUtau.Core/DawIntegration/DawServerFinder.cs @@ -0,0 +1,77 @@ +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Threading.Tasks; +using Newtonsoft.Json; + +namespace OpenUtau.Core.DawIntegration { + public class DawServerFinder { + private static string getServerPath() { + var temp = Path.GetTempPath(); + + return $"{temp}OpenUtau/PluginServers"; + } + public static async Task> FindServers() { + var path = getServerPath(); + if (!Directory.Exists(path)) { + return new List(); + } + + var di = new DirectoryInfo(path); + var files = di.GetFiles("*.json"); + + var servers = new List(); + foreach (FileInfo file in files) { + try { + var json = await File.ReadAllTextAsync(file.FullName); + var server = JsonConvert.DeserializeObject(json); + if (server != null && CheckPortUsing(server.Port)) { + servers.Add(server); + continue; + } + } catch { + // Ignore invalid server files + } + // Delete invalid server files + file.Delete(); + } + + return servers; + } + + private static bool CheckPortUsing(int port) { + var tcpListener = default(TcpListener); + + try { + var ipAddress = IPAddress.Parse("127.0.0.1"); + + tcpListener = new TcpListener(ipAddress, port); + tcpListener.Start(); + + return false; + } catch (SocketException) { + } finally { + if (tcpListener != null) + tcpListener.Stop(); + } + + return true; + } + } + + public class DawServer { + public int Port { get; } + public string Name { get; } + + [JsonConstructor] + DawServer(int port, string name) { + Port = port; + Name = name; + } + + public override string ToString() { + return Name; + } + } +} diff --git a/OpenUtau.Core/DocManager.cs b/OpenUtau.Core/DocManager.cs index 22fb2fbba..aa77f701f 100644 --- a/OpenUtau.Core/DocManager.cs +++ b/OpenUtau.Core/DocManager.cs @@ -190,6 +190,7 @@ public void AutoSave() { } } + public void ExecuteCmd(UCommand cmd) { if (mainThread != Thread.CurrentThread) { if (!(cmd is ProgressBarNotification)) { diff --git a/OpenUtau.Core/Format/USTx.cs b/OpenUtau.Core/Format/USTx.cs index 4922d0918..fdff84663 100644 --- a/OpenUtau.Core/Format/USTx.cs +++ b/OpenUtau.Core/Format/USTx.cs @@ -96,21 +96,28 @@ public static UProject Create() { public static void Save(string filePath, UProject project) { try { - project.ustxVersion = kUstxVersion; + var ustx = FromProject(project); project.FilePath = filePath; - project.BeforeSave(); - File.WriteAllText(filePath, Yaml.DefaultSerializer.Serialize(project), Encoding.UTF8); - project.Saved = true; - project.AfterSave(); - Preferences.Default.RecoveryPath = string.Empty; - Preferences.Save(); - DocManager.Inst.Recovered = false; + File.WriteAllText(filePath, ustx, Encoding.UTF8); } catch (Exception ex) { var e = new MessageCustomizableException($"Failed to save ustx: {filePath}", $": {filePath}", ex); DocManager.Inst.ExecuteCmd(new ErrorMessageNotification(e)); } } + public static string FromProject(UProject project) { + project.ustxVersion = kUstxVersion; + project.BeforeSave(); + var ustx = Yaml.DefaultSerializer.Serialize(project); + project.Saved = true; + project.AfterSave(); + Preferences.Default.RecoveryPath = string.Empty; + Preferences.Save(); + DocManager.Inst.Recovered = false; + + return ustx; + } + public static void AutoSave(string filePath, UProject project) { try { project.ustxVersion = kUstxVersion; @@ -125,15 +132,21 @@ public static void AutoSave(string filePath, UProject project) { } public static UProject Load(string filePath) { - string text = File.ReadAllText(filePath, Encoding.UTF8); + var text = File.ReadAllText(filePath, Encoding.UTF8); + var project = LoadText(text); + project.FilePath = filePath; + + return project; + } + + public static UProject LoadText(string text) { UProject project = Yaml.DefaultDeserializer.Deserialize(text); AddDefaultExpressions(project); - project.FilePath = filePath; project.Saved = true; project.AfterLoad(); project.ValidateFull(); if (project.ustxVersion > kUstxVersion) { - throw new MessageCustomizableException($"Project file is newer than software: {filePath}", $":\n{filePath}", new FileFormatException("Project file is newer than software.")); + throw new MessageCustomizableException($"Project file is newer than software: {project.FilePath}", $":\n{project.FilePath}", new FileFormatException("Project file is newer than software.")); } if (project.ustxVersion < kUstxVersion) { Log.Information($"Upgrading project from {project.ustxVersion} to {kUstxVersion}"); diff --git a/OpenUtau.Core/PlaybackManager.cs b/OpenUtau.Core/PlaybackManager.cs index 9bc67a6ed..d3a9d5b34 100644 --- a/OpenUtau.Core/PlaybackManager.cs +++ b/OpenUtau.Core/PlaybackManager.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using NAudio.Wave; using NAudio.Wave.SampleProviders; +using OpenUtau.Core.DawIntegration; using OpenUtau.Core.Render; using OpenUtau.Core.SignalChain; using OpenUtau.Core.Ustx; @@ -403,7 +404,8 @@ public void OnNext(UCommand cmd, bool isUndo) { DocManager.Inst.ExecuteCmd(new SetPlayPosTickNotification(0)); } if (cmd is PreRenderNotification || cmd is LoadProjectNotification) { - if (Util.Preferences.Default.PreRender) { + // Always prerender when it's connected to daw + if (Util.Preferences.Default.PreRender || DawManager.Inst.IsConnected) { SchedulePreRender(); } } diff --git a/OpenUtau.Core/Ustx/UPart.cs b/OpenUtau.Core/Ustx/UPart.cs index dc4b75dff..8f77a0b39 100644 --- a/OpenUtau.Core/Ustx/UPart.cs +++ b/OpenUtau.Core/Ustx/UPart.cs @@ -456,7 +456,7 @@ public override void BeforeSave(UProject project, UTrack track) { public override void AfterLoad(UProject project, UTrack track) { try { - FilePath = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(project.FilePath), relativePath ?? "")); + FilePath = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(project.FilePath) ?? string.Empty, relativePath ?? "")); } catch { if (string.IsNullOrWhiteSpace(FilePath)) { throw; diff --git a/OpenUtau.Core/Util/Debounce.cs b/OpenUtau.Core/Util/Debounce.cs new file mode 100644 index 000000000..270dea693 --- /dev/null +++ b/OpenUtau.Core/Util/Debounce.cs @@ -0,0 +1,77 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Serilog; + +namespace OpenUtau.Core.Util { + public sealed class Debounce { + private readonly object cancellationLock = new object(); + private CancellationTokenSource? cancellation; + private Func? pendingCallback; + + public void Do(TimeSpan timeSpan, Func callback) { + CancellationTokenSource currentCancellation; + lock (cancellationLock) { + cancellation?.Cancel(); + cancellation?.Dispose(); + cancellation = currentCancellation = new CancellationTokenSource(); + pendingCallback = callback; + } + _ = Run(timeSpan, currentCancellation); + } + + public void Cancel() { + lock (cancellationLock) { + cancellation?.Cancel(); + cancellation?.Dispose(); + cancellation = null; + pendingCallback = null; + } + } + + public Task Flush() { + Func? callback; + lock (cancellationLock) { + cancellation?.Cancel(); + cancellation?.Dispose(); + cancellation = null; + callback = pendingCallback; + pendingCallback = null; + } + return callback?.Invoke() ?? Task.CompletedTask; + } + + private async Task Run( + TimeSpan timeSpan, + CancellationTokenSource currentCancellation) { + try { + await Task.Delay(timeSpan, currentCancellation.Token); + Func? callback; + lock (cancellationLock) { + if (!ReferenceEquals(cancellation, currentCancellation)) { + return; + } + cancellation = null; + callback = pendingCallback; + pendingCallback = null; + } + if (callback == null) { + return; + } + await callback(); + } catch (OperationCanceledException) + when (currentCancellation.IsCancellationRequested) { + } catch (Exception e) { + Log.Error(e, "Debounced operation failed."); + } finally { + lock (cancellationLock) { + if (ReferenceEquals(cancellation, currentCancellation)) { + cancellation = null; + pendingCallback = null; + } + } + currentCancellation.Dispose(); + } + } + } +} diff --git a/OpenUtau.Core/Util/Gzip.cs b/OpenUtau.Core/Util/Gzip.cs new file mode 100644 index 000000000..48ffe2037 --- /dev/null +++ b/OpenUtau.Core/Util/Gzip.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace OpenUtau.Core.Util { + public static class Gzip + { + public static byte[] Compress(byte[] data) { + using (var compressedStream = new System.IO.MemoryStream()) { + using (var zipStream = new System.IO.Compression.GZipStream(compressedStream, System.IO.Compression.CompressionMode.Compress)) { + zipStream.Write(data, 0, data.Length); + } + return compressedStream.ToArray(); + } + } + + public static byte[] Decompress(byte[] data) { + using (var compressedStream = new System.IO.MemoryStream(data)) { + using (var zipStream = new System.IO.Compression.GZipStream(compressedStream, System.IO.Compression.CompressionMode.Decompress)) { + using (var resultStream = new System.IO.MemoryStream()) { + zipStream.CopyTo(resultStream); + return resultStream.ToArray(); + } + } + } + } + } +} diff --git a/OpenUtau.Test/Core/DawIntegration/DawClientTest.cs b/OpenUtau.Test/Core/DawIntegration/DawClientTest.cs new file mode 100644 index 000000000..253919340 --- /dev/null +++ b/OpenUtau.Test/Core/DawIntegration/DawClientTest.cs @@ -0,0 +1,197 @@ +using System; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using OpenUtau.Core.DawIntegration; +using Xunit; + +namespace OpenUtau.Test.Core.DawIntegration { + public class DawClientTest { + private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(5); + + [Fact] + public async Task ConnectHandlesFragmentedAndCombinedMessages() { + await using var server = new FakeDawServer(async (stream, _) => { + var request = await ReadLine(stream); + var response = InitResponseFor(request, "test-ustx"); + var split = response.Length / 2; + await stream.WriteAsync(Encoding.UTF8.GetBytes(response[..split])); + await Task.Delay(20); + await stream.WriteAsync(Encoding.UTF8.GetBytes( + response[split..] + "notification:ping {}\n")); + }); + + var (client, ustx) = await DawClient.Connect(server.Server); + using (client) { + Assert.Equal("test-ustx", ustx); + } + } + + [Fact] + public async Task ConnectHandlesPingNotification() { + await using var server = new FakeDawServer(async (stream, _) => { + var request = await ReadLine(stream); + var requestId = request.Split(':', 3)[1]; + await stream.WriteAsync(Encoding.UTF8.GetBytes( + $"response:{requestId} {{\"success\":true,\"data\":{{\"ustx\":\"\"}},\"error\":null}}\n")); + await stream.WriteAsync(Encoding.UTF8.GetBytes( + "notification:ping {}\n")); + await Task.Delay(100); + }); + + var (client, _) = await DawClient.Connect(server.Server); + using (client) { + await Task.Delay(150); + } + } + + [Fact] + public async Task RaisesPlaybackStartedNotification() { + var sendPlaybackStarted = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + await using var server = new FakeDawServer(async (stream, _) => { + await CompleteHandshake(stream); + await sendPlaybackStarted.Task; + await stream.WriteAsync(Encoding.UTF8.GetBytes( + "notification:playbackStarted {}\n")); + await Task.Delay(100); + }); + + var (client, _) = await DawClient.Connect(server.Server); + using (client) { + var playbackStarted = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + client.PlaybackStarted += () => playbackStarted.TrySetResult(); + sendPlaybackStarted.TrySetResult(); + await playbackStarted.Task.WaitAsync(TestTimeout); + } + } + + [Fact] + public async Task RequestCancellationRemovesPendingRequest() { + var requestReceived = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + await using var server = new FakeDawServer(async (stream, token) => { + await CompleteHandshake(stream); + await ReadLine(stream); + requestReceived.TrySetResult(); + await Task.Delay(Timeout.Infinite, token); + }); + + var (client, _) = await DawClient.Connect(server.Server); + using (client) { + using var cancellation = new CancellationTokenSource( + TimeSpan.FromMilliseconds(100)); + await Assert.ThrowsAsync(() => + client.SendRequest( + new InitRequest(), cancellation.Token)); + await requestReceived.Task.WaitAsync(TestTimeout); + } + } + + [Fact] + public async Task EofCompletesPendingRequestAndNotifiesOnce() { + var closeConnection = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + await using var server = new FakeDawServer(async (stream, _) => { + await CompleteHandshake(stream); + await ReadLine(stream); + closeConnection.TrySetResult(); + }); + + var (client, _) = await DawClient.Connect(server.Server); + using (client) { + var disconnected = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var disconnectCount = 0; + client.Disconnected += (_, _) => { + Interlocked.Increment(ref disconnectCount); + disconnected.TrySetResult(); + }; + + var request = client.SendRequest(new InitRequest()); + await closeConnection.Task.WaitAsync(TestTimeout); + await Assert.ThrowsAnyAsync(() => request); + await disconnected.Task.WaitAsync(TestTimeout); + + client.Disconnect(); + Assert.Equal(1, Volatile.Read(ref disconnectCount)); + } + } + + private static async Task CompleteHandshake(NetworkStream stream) { + var request = await ReadLine(stream); + await stream.WriteAsync(Encoding.UTF8.GetBytes( + InitResponseFor(request, ""))); + } + + private static string InitResponseFor(string request, string ustx) { + var header = request.Split(' ', 2)[0]; + var parts = header.Split(':', 3); + return $"response:{parts[1]} " + + JsonConvert.SerializeObject(new { + success = true, + data = new { ustx }, + error = string.Empty, + }) + "\n"; + } + + private static async Task ReadLine(NetworkStream stream) { + using var buffer = new MemoryStream(); + var singleByte = new byte[1]; + while (true) { + var read = await stream.ReadAsync(singleByte); + if (read == 0) { + throw new EndOfStreamException(); + } + if (singleByte[0] == (byte)'\n') { + return Encoding.UTF8.GetString(buffer.ToArray()); + } + buffer.WriteByte(singleByte[0]); + } + } + + private sealed class FakeDawServer : IAsyncDisposable { + private readonly TcpListener listener; + private readonly CancellationTokenSource cancellation = new(); + private readonly Task serverTask; + + public DawServer Server { get; } + + public FakeDawServer( + Func handler) { + listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + Server = JsonConvert.DeserializeObject( + $"{{\"port\":{port},\"name\":\"test\"}}")!; + serverTask = Run(handler); + } + + private async Task Run( + Func handler) { + try { + using var tcpClient = await listener.AcceptTcpClientAsync( + cancellation.Token); + await handler(tcpClient.GetStream(), cancellation.Token); + } catch (OperationCanceledException) + when (cancellation.IsCancellationRequested) { + } + } + + public async ValueTask DisposeAsync() { + cancellation.Cancel(); + listener.Stop(); + try { + await serverTask; + } catch (Exception) when (cancellation.IsCancellationRequested) { + } + cancellation.Dispose(); + } + } + } +} diff --git a/OpenUtau.Test/Core/Util/DebounceTest.cs b/OpenUtau.Test/Core/Util/DebounceTest.cs new file mode 100644 index 000000000..b4524891c --- /dev/null +++ b/OpenUtau.Test/Core/Util/DebounceTest.cs @@ -0,0 +1,24 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using OpenUtau.Core.Util; +using Xunit; + +namespace OpenUtau.Test.Core.Util { + public class DebounceTest { + [Fact] + public async Task FlushRunsPendingCallbackOnce() { + var debounce = new Debounce(); + var callbackCount = 0; + debounce.Do(TimeSpan.FromSeconds(10), () => { + Interlocked.Increment(ref callbackCount); + return Task.CompletedTask; + }); + + await debounce.Flush(); + await Task.Delay(50); + + Assert.Equal(1, Volatile.Read(ref callbackCount)); + } + } +} diff --git a/OpenUtau/OpenUtau.csproj b/OpenUtau/OpenUtau.csproj index 85bf69c8b..cd11045dd 100644 --- a/OpenUtau/OpenUtau.csproj +++ b/OpenUtau/OpenUtau.csproj @@ -119,6 +119,9 @@ TrackColorDialog.axaml + + DawIntegrationTerminalDialog.axaml + diff --git a/OpenUtau/Strings/Strings.axaml b/OpenUtau/Strings/Strings.axaml index 7c08bab77..599526ce4 100644 --- a/OpenUtau/Strings/Strings.axaml +++ b/OpenUtau/Strings/Strings.axaml @@ -303,6 +303,8 @@ Do you want to continue by splitting at the nearest position after current playh Save Save As... Save Template... + Connect to DAW... + Disconnect from DAW Help About OpenUtau Check Update @@ -807,6 +809,18 @@ General Change track color Track Settings + Connect to DAW + Download Plugin, show UI and connect (temporary text) + Download Plugin + No plugin host found. + Name + Refresh + Connect + + Connected to DAW + Disconnected from DAW + (Connected to DAW) + Segoe UI,San Francisco,Helvetica Neue Check for Update diff --git a/OpenUtau/ViewModels/DawIntegrationTerminalViewModel.cs b/OpenUtau/ViewModels/DawIntegrationTerminalViewModel.cs new file mode 100644 index 000000000..c52ba927f --- /dev/null +++ b/OpenUtau/ViewModels/DawIntegrationTerminalViewModel.cs @@ -0,0 +1,46 @@ +using DynamicData.Binding; +using OpenUtau.Core; +using OpenUtau.Core.DawIntegration; +using ReactiveUI.Fody.Helpers; +using System.Threading.Tasks; + +namespace OpenUtau.App.ViewModels { + public class DawIntegrationTerminalViewModel : ViewModelBase { + [Reactive] public DawServer? SelectedServer { get; set; } = null; + [Reactive] public bool CanConnect { get; set; } = true; + public ObservableCollectionExtended ServerList { get; set; } = new ObservableCollectionExtended(); + + public DawIntegrationTerminalViewModel() { + Task.Run(() => RefreshServerList()); + } + + public async Task RefreshServerList() { + var servers = await DawServerFinder.FindServers(); + + ServerList.Load(servers); + if (servers.Count == 0) { + SelectedServer = null; + } else { + SelectedServer = ServerList[0]; + } + } + + public async Task Connect() { + if (SelectedServer == null) { + return; + } + try { + CanConnect = false; + var ustx = await DawManager.Inst.Connect(SelectedServer); + + if (ustx.Length > 0) { + DocManager.Inst.ExecuteCmd(new LoadProjectNotification(Core.Format.Ustx.LoadText(ustx))); + } + await DawManager.Inst.Synchronize(); + } finally { + CanConnect = true; + } + } + + } +} diff --git a/OpenUtau/ViewModels/MainWindowViewModel.cs b/OpenUtau/ViewModels/MainWindowViewModel.cs index d3090673f..819a45c25 100644 --- a/OpenUtau/ViewModels/MainWindowViewModel.cs +++ b/OpenUtau/ViewModels/MainWindowViewModel.cs @@ -8,6 +8,7 @@ using DynamicData.Binding; using OpenUtau.App.Views; using OpenUtau.Core; +using OpenUtau.Core.DawIntegration; using OpenUtau.Core.Ustx; using OpenUtau.Core.Util; using ReactiveUI; @@ -44,13 +45,27 @@ public RecentFileInfo(string path) { } } - public class MainWindowViewModel : ViewModelBase, ICmdSubscriber { - public string Title => !ProjectSaved - ? $"{AppVersion}" - : $"{(DocManager.Inst.ChangesSaved ? "" : "*")}{AppVersion} [{DocManager.Inst.Project.FilePath}]"; - public double Width => Preferences.Default.MainWindowSize.Width; - public double Height => Preferences.Default.MainWindowSize.Height; + public class MainWindowViewModel : ViewModelBase, ICmdSubscriber + { + public string Title + { + get + { + if (IsConnectedToDaw) + { + return $"{AppVersion} [{DawManager.Inst.Client!.server.Name}] (Attached to DAW)"; + } + else + { + var baseTitle = !ProjectSaved + ? $"{AppVersion}" + : $"{(DocManager.Inst.ChangesSaved ? "" : "*")}{AppVersion} [{DocManager.Inst.Project.FilePath}]"; + return baseTitle; + } + } + } + public bool IsConnectedToDaw => DawManager.Inst.IsConnected; /// ///0: welcome page, 1: tracks page /// @@ -314,7 +329,7 @@ public void ImportMidi(string file) { var track = new UTrack(project); track.TrackNo = project.tracks.Count; part.trackNo = track.TrackNo; - if(part.name != "New Part"){ + if (part.name != "New Part") { track.TrackName = part.name; } part.AfterLoad(project, track); @@ -400,7 +415,7 @@ public void RefreshTimelineContextMenu(int tick) { /// Remap a tick position from the old time axis to the new time axis without changing its absolute position (in ms). /// Note that this can only be used on positions, not durations. /// - private int RemapTickPos(int tickPos, TimeAxis oldTimeAxis, TimeAxis newTimeAxis){ + private int RemapTickPos(int tickPos, TimeAxis oldTimeAxis, TimeAxis newTimeAxis) { double msPos = oldTimeAxis.TickPosToMsPos(tickPos); return newTimeAxis.MsPosToTickPos(msPos); } @@ -409,12 +424,12 @@ private int RemapTickPos(int tickPos, TimeAxis oldTimeAxis, TimeAxis newTimeAxis /// Remap the starting and ending positions of all the notes and parts in the whole project /// from the old time axis to the new time axis, without changing their absolute positions in ms. /// - public void RemapTimeAxis(TimeAxis oldTimeAxis, TimeAxis newTimeAxis){ + public void RemapTimeAxis(TimeAxis oldTimeAxis, TimeAxis newTimeAxis) { var project = DocManager.Inst.Project; - foreach(var part in project.parts){ + foreach (var part in project.parts) { var partOldStartTick = part.position; var partNewStartTick = RemapTickPos(part.position, oldTimeAxis, newTimeAxis); - if(partNewStartTick != partOldStartTick){ + if (partNewStartTick != partOldStartTick) { DocManager.Inst.ExecuteCmd(new MovePartCommand( project, part, partNewStartTick, part.trackNo)); } @@ -426,24 +441,24 @@ public void RemapTimeAxis(TimeAxis oldTimeAxis, TimeAxis newTimeAxis){ project, voicePart, partNewDuration - partOldDuration, false)); } var noteCommands = new List(); - foreach(var note in voicePart.notes){ + foreach (var note in voicePart.notes) { var noteOldStartTick = note.position + partOldStartTick; var noteOldEndTick = note.End + partOldStartTick; var noteOldDuration = note.duration; var noteNewStartTick = RemapTickPos(noteOldStartTick, oldTimeAxis, newTimeAxis); var noteNewEndTick = RemapTickPos(noteOldEndTick, oldTimeAxis, newTimeAxis); var deltaPosTickInPart = (noteNewStartTick - partNewStartTick) - (noteOldStartTick - partOldStartTick); - if(deltaPosTickInPart != 0){ + if (deltaPosTickInPart != 0) { noteCommands.Add(new MoveNoteCommand(voicePart, note, deltaPosTickInPart, 0)); } var noteNewDuration = noteNewEndTick - noteNewStartTick; var deltaDur = noteNewDuration - noteOldDuration; - if(deltaDur != 0){ + if (deltaDur != 0) { noteCommands.Add(new ResizeNoteCommand(voicePart, note, deltaDur)); } //TODO: expression curve remapping, phoneme timing remapping } - foreach(var command in noteCommands){ + foreach (var command in noteCommands) { DocManager.Inst.ExecuteCmd(command); } } @@ -461,7 +476,9 @@ public void OnNext(UCommand cmd, bool isUndo) { } else if (cmd is LoadProjectNotification loadProject) { Preferences.AddRecentFileIfEnabled(loadProject.project.FilePath); } else if (cmd is SaveProjectNotification saveProject) { - Preferences.AddRecentFileIfEnabled(saveProject.Path); + Core.Util.Preferences.AddRecentFileIfEnabled(saveProject.Path); + } else if (cmd is DawConnectedNotification ||cmd is DawDisconnectedNotification) { + this.RaisePropertyChanged(nameof(IsConnectedToDaw)); } SetUndoState(); this.RaisePropertyChanged(nameof(Title)); diff --git a/OpenUtau/Views/DawIntegrationTerminalDialog.axaml b/OpenUtau/Views/DawIntegrationTerminalDialog.axaml new file mode 100644 index 000000000..bb5c4e09e --- /dev/null +++ b/OpenUtau/Views/DawIntegrationTerminalDialog.axaml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + diff --git a/OpenUtau/Views/DawIntegrationTerminalDialog.axaml.cs b/OpenUtau/Views/DawIntegrationTerminalDialog.axaml.cs new file mode 100644 index 000000000..74b064df4 --- /dev/null +++ b/OpenUtau/Views/DawIntegrationTerminalDialog.axaml.cs @@ -0,0 +1,37 @@ +using System; +using Avalonia.Controls; +using Avalonia.Interactivity; +using OpenUtau.App.ViewModels; +using OpenUtau.Core; + +namespace OpenUtau.App.Views { + public partial class DawIntegrationTerminalDialog : Window { + public readonly DawIntegrationTerminalViewModel ViewModel; + public DawIntegrationTerminalDialog() { + InitializeComponent(); + DataContext = ViewModel = new DawIntegrationTerminalViewModel(); + } + + void OnClosing(object sender, WindowClosingEventArgs e) { + } + + async void OnConnect(object sender, RoutedEventArgs args) { + try { + await ViewModel.Connect(); + + DocManager.Inst.ExecuteCmd(new DawConnectedNotification()); + Close(); + } catch (Exception e) { + DocManager.Inst.ExecuteCmd(new ErrorMessageNotification(e)); + } + } + + void OnDownloadClick(object sender, RoutedEventArgs args) { + try { + OS.OpenWeb("https://example.com"); + } catch (Exception e) { + DocManager.Inst.ExecuteCmd(new ErrorMessageNotification(e)); + } + } + } +} diff --git a/OpenUtau/Views/MainWindow.axaml b/OpenUtau/Views/MainWindow.axaml index 1e16d445c..4684738ae 100644 --- a/OpenUtau/Views/MainWindow.axaml +++ b/OpenUtau/Views/MainWindow.axaml @@ -47,6 +47,9 @@ + + + diff --git a/OpenUtau/Views/MainWindow.axaml.cs b/OpenUtau/Views/MainWindow.axaml.cs index 5ea4cdb0c..2aacad179 100644 --- a/OpenUtau/Views/MainWindow.axaml.cs +++ b/OpenUtau/Views/MainWindow.axaml.cs @@ -18,6 +18,7 @@ using OpenUtau.Classic; using OpenUtau.Core; using OpenUtau.Core.Analysis; +using OpenUtau.Core.DawIntegration; using OpenUtau.Core.DiffSinger; using OpenUtau.Core.Format; using OpenUtau.Core.Ustx; @@ -482,6 +483,25 @@ async void OnMenuExportMidi(object sender, RoutedEventArgs e) { } } + async void OnMenuDawIntegrationTerminal(object sender, RoutedEventArgs args) { + if (!DocManager.Inst.ChangesSaved && !await AskIfSaveAndContinue()) { + return; + } + DawIntegrationTerminalViewModel dataContext; + dataContext = new DawIntegrationTerminalViewModel(); + var dialog = new DawIntegrationTerminalDialog() { + DataContext = dataContext + }; + await dialog.ShowDialog(this); + if (dialog.Position.Y < 0) { + dialog.Position = dialog.Position.WithY(0); + } + } + async void OnMenuDisconnectFromDaw(object sender, RoutedEventArgs args) { + await DawManager.Inst.Disconnect(); + } + + private async Task WarnToSave(UProject project) { if (string.IsNullOrEmpty(project.FilePath)) { await MessageBox.Show( @@ -1853,6 +1873,14 @@ public void OnNext(UCommand cmd, bool isUndo) { VoiceColorRemapping(track, oldColors, newColors); } } + } else if (cmd is DawConnectedNotification) { + DocManager.Inst.ExecuteCmd(new ProgressBarNotification(1, + ThemeManager.GetString("dawintegration.status.connected") + )); + } else if (cmd is DawDisconnectedNotification) { + DocManager.Inst.ExecuteCmd(new ErrorMessageNotification( + ThemeManager.GetString("dawintegration.status.disconnected") + )); } } }