diff --git a/.github/workflows/build-mediaprocessor.yml b/.github/workflows/build-mediaprocessor.yml deleted file mode 100644 index 244a6e2..0000000 --- a/.github/workflows/build-mediaprocessor.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: Build MediaProcessor - -on: - push: - branches: - - main - pull_request: - branches: - - main - -jobs: - build: - runs-on: ubuntu-latest - - steps: - - name: Checkout Repository - uses: actions/checkout@v3 - - - name: Install Dependencies - run: | - sudo apt update - sudo apt install -y ffmpeg cmake nlohmann-json3-dev libsndfile1-dev - - - name: Compile MediaProcessor - run: | - cd MediaProcessor - mkdir -p build - cd build - cmake .. - make diff --git a/.github/workflows/build_test_and_format_core.yml b/.github/workflows/build_test_and_format_core.yml new file mode 100644 index 0000000..caa76e6 --- /dev/null +++ b/.github/workflows/build_test_and_format_core.yml @@ -0,0 +1,52 @@ +name: Build, Test and Format Core + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout Repository + uses: actions/checkout@v3 + + - name: Install Dependencies + run: | + sudo apt update + sudo apt install -y ffmpeg cmake nlohmann-json3-dev libsndfile1-dev g++ + + - name: Build with Tests Enabled + run: | + mkdir -p build + cd build + cmake -DBUILD_TESTING=ON -DCMAKE_BUILD_TYPE=Release ../MediaProcessor + make -j$(nproc) + + - name: Run Tests + run: | + cd build + ctest --output-on-failure + + format: + runs-on: ubuntu-latest + + steps: + - name: Checkout Repository + uses: actions/checkout@v3 + + - name: Install Dependencies + run: sudo apt update && sudo apt install -y clang-format + + - name: Run clang-format Check on MediaProcessor + run: | + find MediaProcessor/src -regex '.*\.\(cpp\|h\)' -exec clang-format --dry-run --Werror {} + + + - name: Display Message if Formatting Fails + if: failure() + run: echo "Code formatting issues found in MediaProcessor. Please run clang-format to fix them." diff --git a/.github/workflows/clang-format_check.yml b/.github/workflows/clang-format_check.yml deleted file mode 100644 index a50c1f8..0000000 --- a/.github/workflows/clang-format_check.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: Clang Format Check - -on: - push: - branches: - - main - pull_request: - branches: - - main - -jobs: - clang-format-check: - runs-on: ubuntu-latest - - steps: - - name: Checkout Repository - uses: actions/checkout@v3 - - - name: Install clang-format - run: sudo apt update && sudo apt install -y clang-format - - - name: Run clang-format Check on MediaProcessor - run: | - find MediaProcessor/src -regex '.*\.\(cpp\|h\)' -exec clang-format --dry-run --Werror {} + - - - name: Display Message if Formatting Fails - if: failure() - run: echo "Code formatting issues found in MediaProcessor. Please run clang-format to fix them." diff --git a/.github/workflows/pep8-check.yml b/.github/workflows/format_backend.yml similarity index 95% rename from .github/workflows/pep8-check.yml rename to .github/workflows/format_backend.yml index c40e5a0..d13b6d9 100644 --- a/.github/workflows/pep8-check.yml +++ b/.github/workflows/format_backend.yml @@ -1,4 +1,4 @@ -name: Code Formatting Check +name: Backend Formatter on: push: diff --git a/.gitignore b/.gitignore index 86f2cf7..5c0d4da 100644 --- a/.gitignore +++ b/.gitignore @@ -157,6 +157,9 @@ dmypy.json # Cython debug symbols cython_debug/ +# VSCode +.vscode/ + # PyCharm # JetBrains specific template is maintained in a separate JetBrains.gitignore that can # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore diff --git a/MediaProcessor/CMakeLists.txt b/MediaProcessor/CMakeLists.txt index fde94d7..436760a 100644 --- a/MediaProcessor/CMakeLists.txt +++ b/MediaProcessor/CMakeLists.txt @@ -12,31 +12,25 @@ endif() set(CMAKE_CXX_FLAGS_RELEASE "${CMAKE_CXX_FLAGS_RELEASE} -O2") -include_directories(include) -include_directories(${CMAKE_SOURCE_DIR}/third_party/nlohmann) - -add_executable(MediaProcessor - src/main.cpp - src/ConfigManager.cpp - src/AudioProcessor.cpp - src/VideoProcessor.cpp - src/Utils.cpp - src/CommandBuilder.cpp - src/HardwareUtils.cpp - src/FFmpegSettingsManager.cpp -) +# Tests are excluded by default, use `cmake -DBUILD_TESTING=ON` to build with tests. +option(BUILD_TESTING "Test Build" OFF) + +# Set include directories +include(CTest) +include_directories(${CMAKE_SOURCE_DIR}/include) # Threads is required set(THREADS_PREFER_PTHREAD_FLAG ON) find_package(Threads REQUIRED) -target_link_libraries(MediaProcessor PRIVATE Threads::Threads) - -# Link the DeepFilter library -target_link_libraries(MediaProcessor PRIVATE ${CMAKE_SOURCE_DIR}/lib/libdf.so) # SNDFILE: for read/write of sampled audio files # https://github.com/libsndfile/libsndfile find_package(PkgConfig REQUIRED) pkg_check_modules(SNDFILE REQUIRED sndfile) include_directories(${SNDFILE_INCLUDE_DIRS}) -target_link_libraries(MediaProcessor PRIVATE ${SNDFILE_LIBRARIES}) + +# Include the cmake files under cmake/ +include(cmake/src.cmake) +if(BUILD_TESTING) + include(cmake/test.cmake) +endif() diff --git a/MediaProcessor/cmake/src.cmake b/MediaProcessor/cmake/src.cmake new file mode 100644 index 0000000..0e77903 --- /dev/null +++ b/MediaProcessor/cmake/src.cmake @@ -0,0 +1,21 @@ +# CMake configuration for building the main MediaProcessor executable + +add_executable(MediaProcessor + ${CMAKE_SOURCE_DIR}/src/main.cpp + ${CMAKE_SOURCE_DIR}/src/ConfigManager.cpp + ${CMAKE_SOURCE_DIR}/src/AudioProcessor.cpp + ${CMAKE_SOURCE_DIR}/src/VideoProcessor.cpp + ${CMAKE_SOURCE_DIR}/src/Utils.cpp + ${CMAKE_SOURCE_DIR}/src/CommandBuilder.cpp + ${CMAKE_SOURCE_DIR}/src/HardwareUtils.cpp + ${CMAKE_SOURCE_DIR}/src/FFmpegSettingsManager.cpp + ${CMAKE_SOURCE_DIR}/src/DeepFilterCommandBuilder.cpp +) + +target_link_libraries(MediaProcessor PRIVATE Threads::Threads) +target_link_libraries(MediaProcessor PRIVATE ${CMAKE_SOURCE_DIR}/lib/libdf.so) +target_link_libraries(MediaProcessor PRIVATE ${SNDFILE_LIBRARIES}) + +set_target_properties(MediaProcessor PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}" +) diff --git a/MediaProcessor/cmake/test.cmake b/MediaProcessor/cmake/test.cmake new file mode 100644 index 0000000..a2d8b51 --- /dev/null +++ b/MediaProcessor/cmake/test.cmake @@ -0,0 +1,71 @@ +include(FetchContent) +cmake_policy(SET CMP0135 NEW) # Use the latest policy for FetchContent consistency + +find_package(GTest QUIET) +if(NOT GTEST_FOUND) + # Fetch GTest if not found + FetchContent_Declare( + googletest + URL https://github.com/google/googletest/archive/5376968f6948923e2411081fd9372e71a59d8e77.zip + DOWNLOAD_EXTRACT_TIMESTAMP TRUE + ) + set(gtest_force_shared_crt ON CACHE BOOL "" FORCE) + FetchContent_MakeAvailable(googletest) +endif() + +# Setup test media directory +set(TEST_MEDIA_DIR "${CMAKE_SOURCE_DIR}/tests/TestMedia" CACHE PATH "Path to test media files") + +# Common libraries for all test targets +set(COMMON_LIBRARIES gtest_main ${CMAKE_SOURCE_DIR}/lib/libdf.so ${SNDFILE_LIBRARIES}) + +# Macro for adding a test executable +macro(add_test_executable name) + add_executable(${name} ${ARGN}) + target_compile_definitions(${name} PRIVATE TEST_MEDIA_DIR="${TEST_MEDIA_DIR}") + target_link_libraries(${name} PRIVATE ${COMMON_LIBRARIES}) + add_test(NAME ${name} COMMAND ${name}) +endmacro() + +# Add test executables using the macro +add_test_executable(ConfigManagerTester + ${CMAKE_SOURCE_DIR}/tests/ConfigManagerTester.cpp + ${CMAKE_SOURCE_DIR}/src/ConfigManager.cpp + ${CMAKE_SOURCE_DIR}/src/CommandBuilder.cpp + ${CMAKE_SOURCE_DIR}/src/HardwareUtils.cpp + ${CMAKE_SOURCE_DIR}/src/Utils.cpp + ${CMAKE_SOURCE_DIR}/tests/TestUtils.cpp +) + +add_test_executable(UtilsTester + ${CMAKE_SOURCE_DIR}/tests/UtilsTester.cpp + ${CMAKE_SOURCE_DIR}/src/Utils.cpp + ${CMAKE_SOURCE_DIR}/src/CommandBuilder.cpp +) + +add_test_executable(AudioProcessorTester + ${CMAKE_SOURCE_DIR}/tests/AudioProcessorTester.cpp + ${CMAKE_SOURCE_DIR}/src/AudioProcessor.cpp + ${CMAKE_SOURCE_DIR}/src/CommandBuilder.cpp + ${CMAKE_SOURCE_DIR}/src/Utils.cpp + ${CMAKE_SOURCE_DIR}/src/HardwareUtils.cpp + ${CMAKE_SOURCE_DIR}/src/ConfigManager.cpp + ${CMAKE_SOURCE_DIR}/tests/TestUtils.cpp +) + +add_test_executable(VideoProcessorTester + ${CMAKE_SOURCE_DIR}/tests/VideoProcessorTester.cpp + ${CMAKE_SOURCE_DIR}/src/VideoProcessor.cpp + ${CMAKE_SOURCE_DIR}/src/CommandBuilder.cpp + ${CMAKE_SOURCE_DIR}/src/Utils.cpp + ${CMAKE_SOURCE_DIR}/src/ConfigManager.cpp + ${CMAKE_SOURCE_DIR}/src/HardwareUtils.cpp + ${CMAKE_SOURCE_DIR}/tests/TestUtils.cpp +) + +add_test_executable(CommandBuilderTester + ${CMAKE_SOURCE_DIR}/tests/CommandBuilderTester.cpp + ${CMAKE_SOURCE_DIR}/src/CommandBuilder.cpp + ${CMAKE_SOURCE_DIR}/src/Utils.cpp +) + diff --git a/MediaProcessor/src/AudioProcessor.cpp b/MediaProcessor/src/AudioProcessor.cpp index b6f57f5..d424e34 100644 --- a/MediaProcessor/src/AudioProcessor.cpp +++ b/MediaProcessor/src/AudioProcessor.cpp @@ -22,9 +22,9 @@ AudioProcessor::AudioProcessor(const fs::path& inputVideoPath, const fs::path& o : m_inputVideoPath(inputVideoPath), m_outputAudioPath(outputAudioPath), m_overlapDuration(DEFAULT_OVERLAP_DURATION) { - m_outputDir = m_outputAudioPath.parent_path(); - m_chunksDir = m_outputDir / "chunks"; - m_processedChunksDir = m_outputDir / "processed_chunks"; + m_outputPath = m_outputAudioPath.parent_path(); + m_chunksPath = m_outputPath / "chunks"; + m_processedChunksDir = m_outputPath / "processed_chunks"; m_numChunks = ConfigManager::getInstance().getOptimalThreadCount(); std::cerr << "INFO: using " << m_numChunks << " threads." << std::endl; @@ -38,7 +38,7 @@ bool AudioProcessor::isolateVocals() { ConfigManager& configManager = ConfigManager::getInstance(); // Ensure output directory exists and remove output file if it exists - Utils::ensureDirectoryExists(m_outputDir); + Utils::ensureDirectoryExists(m_outputPath); Utils::removeFileIfExists(m_outputAudioPath); std::cerr << "Input video path: " << m_inputVideoPath << std::endl; @@ -48,7 +48,7 @@ bool AudioProcessor::isolateVocals() { return false; } - m_totalDuration = getAudioDuration(m_outputAudioPath); + m_totalDuration = Utils::getMediaDuration(m_outputAudioPath); if (m_totalDuration <= 0) { std::cerr << "Error: Invalid audio duration." << std::endl; return false; @@ -67,7 +67,7 @@ bool AudioProcessor::isolateVocals() { } // Intermediary files - fs::remove_all(m_chunksDir); + fs::remove_all(m_chunksPath); fs::remove_all(m_processedChunksDir); return true; @@ -100,7 +100,7 @@ bool AudioProcessor::splitAudioIntoChunks() { ConfigManager& configManager = ConfigManager::getInstance(); fs::path ffmpegPath = configManager.getFFmpegPath(); - Utils::ensureDirectoryExists(m_chunksDir); + Utils::ensureDirectoryExists(m_chunksPath); std::vector chunkStartTimes; std::vector chunkDurations; @@ -117,7 +117,7 @@ bool AudioProcessor::splitAudioIntoChunks() { bool AudioProcessor::generateChunkFile(int index, const double startTime, const double duration, const fs::path& ffmpegPath) { - fs::path chunkPath = m_chunksDir / ("chunk_" + std::to_string(index) + ".wav"); + fs::path chunkPath = m_chunksPath / ("chunk_" + std::to_string(index) + ".wav"); // Set higher precision for chunk boundaries std::ostringstream ssStartTime, ssDuration; @@ -139,7 +139,7 @@ bool AudioProcessor::generateChunkFile(int index, const double startTime, const return false; } - m_chunkPaths.push_back(chunkPath); + m_chunkPathCol.push_back(chunkPath); return true; } @@ -148,6 +148,7 @@ bool AudioProcessor::invokeDeepFilter(fs::path chunkPath) { const fs::path deepFilterPath = configManager.getDeepFilterPath(); const fs::path deepFilterTarballPath = configManager.getDeepFilterTarballPath(); + // TODO: implement with DeepFilterCommandBuilder once base class is updated (#51) // `--compensate-delay` ensures the audio remains in sync after filtering CommandBuilder cmd; cmd.addArgument(deepFilterPath.string()); @@ -221,7 +222,7 @@ bool AudioProcessor::filterChunks() { std::vector> results; for (int i = 0; i < m_numChunks; ++i) { results.emplace_back(pool.enqueue([&, i]() { - fs::path chunkPath = m_chunkPaths[i]; + fs::path chunkPath = m_chunkPathCol[i]; invokeDeepFilter(chunkPath); // invokeDeepFilterFFI(chunkPath); // RT API still under validation @@ -235,9 +236,9 @@ bool AudioProcessor::filterChunks() { // Prepare paths for processed chunks for (int i = 0; i < m_numChunks; ++i) { - fs::path chunkPath = m_chunkPaths[i]; + fs::path chunkPath = m_chunkPathCol[i]; fs::path processedChunkPath = m_processedChunksDir / chunkPath.filename(); - m_processedChunkPaths.push_back(processedChunkPath); + m_processedChunkCol.push_back(processedChunkPath); } return true; @@ -265,12 +266,12 @@ std::string AudioProcessor::buildFilterComplex() const { std::string filterComplex = ""; int filterIndex = 0; - if (m_processedChunkPaths.size() < 2) { + if (m_processedChunkCol.size() < 2) { return filterComplex; // Return empty string if not enough chunks } // TODO: extract this into an `applyCrossFade()` method. - for (int i = 0; i < static_cast(m_processedChunkPaths.size()) - 1; ++i) { + for (int i = 0; i < static_cast(m_processedChunkCol.size()) - 1; ++i) { if (i == 0) { // Generate a `crossfade` for the first chunk pair (0 and 1) filterComplex += "[" + std::to_string(i) + ":a][" + std::to_string(i + 1) + @@ -299,11 +300,11 @@ bool AudioProcessor::mergeChunks() { CommandBuilder cmd; cmd.addArgument(ffmpegPath.string()); cmd.addFlag("-y"); - for (const auto& chunkPath : m_processedChunkPaths) { + for (const auto& chunkPath : m_processedChunkCol) { cmd.addFlag("-i", chunkPath.string()); } - if (static_cast(m_processedChunkPaths.size()) >= 2) { + if (static_cast(m_processedChunkCol.size()) >= 2) { cmd.addFlag("-filter_complex", buildFilterComplex()); cmd.addFlag("-map", "[outa]"); } @@ -321,34 +322,4 @@ bool AudioProcessor::mergeChunks() { return true; } -double AudioProcessor::getAudioDuration(const fs::path& audioPath) { - // Prepare ffprobe - CommandBuilder cmd; - cmd.addArgument("ffprobe"); - cmd.addFlag("-v", "error"); - cmd.addFlag("-show_entries", "format=duration"); - cmd.addFlag("-of", "default=noprint_wrappers=1:nokey=1"); - cmd.addArgument(audioPath.string()); - - FILE* pipe = popen(cmd.build().c_str(), "r"); - if (!pipe) { - std::cerr << "Error: Failed to run ffprobe to get audio duration." << std::endl; - return -1; - } - - char buffer[128]; - std::string result; - while (fgets(buffer, sizeof buffer, pipe) != nullptr) { - result += buffer; - } - pclose(pipe); - - try { - return std::stod(result); - } catch (std::exception& e) { - std::cerr << "Error: Could not parse audio duration." << std::endl; - return -1; - } -} - } // namespace MediaProcessor diff --git a/MediaProcessor/src/AudioProcessor.h b/MediaProcessor/src/AudioProcessor.h index 9ca8bbc..b5dc082 100644 --- a/MediaProcessor/src/AudioProcessor.h +++ b/MediaProcessor/src/AudioProcessor.h @@ -34,11 +34,11 @@ class AudioProcessor { private: fs::path m_inputVideoPath; fs::path m_outputAudioPath; - fs::path m_outputDir; - fs::path m_chunksDir; + fs::path m_outputPath; + fs::path m_chunksPath; fs::path m_processedChunksDir; - std::vector m_chunkPaths; - std::vector m_processedChunkPaths; + std::vector m_chunkPathCol; + std::vector m_processedChunkCol; int m_numChunks; @@ -56,8 +56,6 @@ class AudioProcessor { std::string buildFilterComplex() const; - double getAudioDuration(const fs::path &audioPath); - void populateChunkDurations(std::vector &startTimes, std::vector &durations) const; }; diff --git a/MediaProcessor/src/ConfigManager.cpp b/MediaProcessor/src/ConfigManager.cpp index f0ebf6f..e9c325d 100644 --- a/MediaProcessor/src/ConfigManager.cpp +++ b/MediaProcessor/src/ConfigManager.cpp @@ -16,32 +16,35 @@ ConfigManager& ConfigManager::getInstance() { bool ConfigManager::loadConfig(const fs::path& configFilePath) { std::ifstream config_file(configFilePath); if (!config_file.is_open()) { - std::cerr << "Error: Could not open " << configFilePath << std::endl; + throw std::runtime_error("Error: Could not open " + configFilePath.string()); return false; } - - config_file >> m_config; + try { + config_file >> m_config; + } catch (const std::exception& e) { + throw std::runtime_error("Could not read config file: " + std::string(e.what())); + } return true; } fs::path ConfigManager::getDeepFilterPath() const { - return m_config["deep_filter_path"].get(); + return getConfigValue("deep_filter_path"); } fs::path ConfigManager::getDeepFilterTarballPath() const { - return m_config["deep_filter_tarball_path"].get(); + return getConfigValue("deep_filter_tarball_path"); } fs::path ConfigManager::getDeepFilterEncoderPath() const { - return m_config["deep_filter_encoder_path"].get(); + return getConfigValue("deep_filter_encoder_path"); } fs::path ConfigManager::getDeepFilterDecoderPath() const { - return m_config["deep_filter_decoder_path"].get(); + return getConfigValue("deep_filter_decoder_path"); } fs::path ConfigManager::getFFmpegPath() const { - return m_config["ffmpeg_path"].get(); + return getConfigValue("ffmpeg_path"); } unsigned int ConfigManager::getOptimalThreadCount() { @@ -52,14 +55,15 @@ unsigned int ConfigManager::getOptimalThreadCount() { } unsigned int ConfigManager::getNumThreadsValue() { - return (m_config["use_thread_cap"].get()) - ? m_config["max_threads_if_capped"].get() - : 0; + if (!getConfigValue("use_thread_cap")) { + return 0; + } + return getConfigValue("max_threads_if_capped"); } unsigned int ConfigManager::determineNumThreads(unsigned int configNumThreads, unsigned int hardwareNumThreads) { - return Utils::isWithinRange(configNumThreads, 1, hardwareNumThreads) ? configNumThreads - : hardwareNumThreads; + return Utils::isWithinRange(configNumThreads, 1u, hardwareNumThreads) ? configNumThreads + : hardwareNumThreads; } } // namespace MediaProcessor diff --git a/MediaProcessor/src/ConfigManager.h b/MediaProcessor/src/ConfigManager.h index 5397320..03617fd 100644 --- a/MediaProcessor/src/ConfigManager.h +++ b/MediaProcessor/src/ConfigManager.h @@ -54,10 +54,37 @@ class ConfigManager { unsigned int determineNumThreads(unsigned int configNumThreads, unsigned int hardwareNumThreads); + template + T getConfigValue(const std::string& optionName) const; + + template + T getConfigValue(const std::string& optionName, const T& defaultValue) const; + ConfigManager() = default; nlohmann::json m_config; /**< JSON object holding the configuration data. */ }; +template +T ConfigManager::getConfigValue(const std::string& optionName) const { + if (!m_config.contains(optionName)) { + throw std::runtime_error("Config option '" + optionName + "' not found."); + } + + try { + return m_config[optionName].get(); + } catch (const nlohmann::json::exception& e) { + throw std::runtime_error("Failed to retrieve config option '" + optionName + + "': " + std::string(e.what())); + } +} + +template +T ConfigManager::getConfigValue(const std::string& optionName, const T& defaultValue) const { + return m_config.value( + optionName, + defaultValue); // built-in .value() from the JSON lib handles the checks for us +} + } // namespace MediaProcessor #endif // CONFIGMANAGER_H diff --git a/MediaProcessor/src/DeepFilterCommandBuilder.cpp b/MediaProcessor/src/DeepFilterCommandBuilder.cpp new file mode 100644 index 0000000..369e2d7 --- /dev/null +++ b/MediaProcessor/src/DeepFilterCommandBuilder.cpp @@ -0,0 +1,58 @@ +#include "DeepFilterCommandBuilder.h" + +#include +#include + +#include "Utils.h" + +namespace MediaProcessor { + +DeepFilterCommandBuilder& DeepFilterCommandBuilder::setInputFile( + const std::string& inputAudioPath) { + /** + * FIXME: Find a solution to ensure flexibility in calling `setInputFile()`. + * See the header for more details on the problem. + */ + + m_inputAudioPath = inputAudioPath; + addArgument(inputAudioPath); + + return *this; +} + +DeepFilterCommandBuilder& DeepFilterCommandBuilder::setOutputFile( + const std::string& outputAudioPath) { + m_outputAudioPath = outputAudioPath; + addFlag("--output-dir", outputAudioPath); + + return *this; +} + +DeepFilterCommandBuilder& DeepFilterCommandBuilder::setNoiseReductionLevel(double level) { + if (!Utils::isWithinRange(level, 0.0, 1.0)) { + throw std::invalid_argument("Noise reduction level must be between 0.0 and 1.0"); + } + m_noiseReductionLevel = level; + addFlag("--noise-reduction", std::to_string(level)); + + return *this; +} + +DeepFilterCommandBuilder& DeepFilterCommandBuilder::enableDelayCompensation() { + addFlag("--compensate-delay"); + + return *this; +} + +std::string DeepFilterCommandBuilder::build() const { + if (m_inputAudioPath.empty()) { + throw std::runtime_error("Input audio path must be specified."); + } + if (m_outputAudioPath.empty()) { + throw std::runtime_error("Output audio path must be specified."); + } + + return CommandBuilder::build(); +} + +} // namespace MediaProcessor \ No newline at end of file diff --git a/MediaProcessor/src/DeepFilterCommandBuilder.h b/MediaProcessor/src/DeepFilterCommandBuilder.h new file mode 100644 index 0000000..eca544c --- /dev/null +++ b/MediaProcessor/src/DeepFilterCommandBuilder.h @@ -0,0 +1,72 @@ +#ifndef DEEPFILTERCOMMANDBUILDER_H +#define DEEPFILTERCOMMANDBUILDER_H + +#include "CommandBuilder.h" + +namespace MediaProcessor { + +/** + * @brief A builder class for constructing DeepFilter commands. + */ +class DeepFilterCommandBuilder : public CommandBuilder { + public: + /** + * @brief Sets the input audio file for the DeepFilter command. + * + * @note + * To ensure the correct behavior, please call `setInputFile()` as the first + * method when configuring this builder. This guarantees that the input file + * is added in the proper order before other optional parameters. + * + * DeepFilter not providing an input file flag poses challenges to implement + * this without breaking the builder pattern or sacrificing const-correctness. + * We'll try to find a good solution for this ASAP. + * + * @return A reference to the updated object for method chaining. + */ + DeepFilterCommandBuilder& setInputFile(const std::string& inputAudioPath); + + /** + * @brief Sets the output audio file for the DeepFilter command. + * + * @return A reference to the updated object for method chaining. + */ + DeepFilterCommandBuilder& setOutputFile(const std::string& outputAudioPath); + + /** + * @brief Sets the noise reduction level for the DeepFilter command. + * + * @param level The noise reduction level: from 0.0 (no reduction) to 1.0 (max reduction). + * @return A reference to the updated DeepFilterCommandBuilder instance. + * + * @throws std::invalid_argument if the level is not within the range [0.0, 1.0]. + */ + DeepFilterCommandBuilder& setNoiseReductionLevel(double level); + + /** + * @brief Enabled filtering delay compensation for the DeepFilter command. + * + * @return A reference to the updated DeepFilterCommandBuilder instance. + */ + DeepFilterCommandBuilder& enableDelayCompensation(); + + /** + * @brief Builds the final DeepFilter command string. + * + * Constructs the command string using the provided parameters and options. + * + * @return The constructed command string. + * + * @throws std::runtime_error if required parameters (input or output file) are missing. + */ + std::string build() const override; + + private: + std::string m_inputAudioPath; + std::string m_outputAudioPath; + double m_noiseReductionLevel = 0.5; +}; + +} // namespace MediaProcessor + +#endif \ No newline at end of file diff --git a/MediaProcessor/src/FFmpegSettingsManager.cpp b/MediaProcessor/src/FFmpegSettingsManager.cpp index 1fe390c..bc1371c 100644 --- a/MediaProcessor/src/FFmpegSettingsManager.cpp +++ b/MediaProcessor/src/FFmpegSettingsManager.cpp @@ -16,11 +16,6 @@ FFmpegSettingsManager::FFmpegSettingsManager() { {VideoCodec::UNKNOWN, "unknown"}}; } -FFmpegSettingsManager& FFmpegSettingsManager::getInstance() { - static FFmpegSettingsManager instance; - return instance; -} - // Global Setters void FFmpegSettingsManager::setOverwrite(bool overwrite) { m_globalSettings.overwrite = overwrite; @@ -90,7 +85,7 @@ std::string FFmpegSettingsManager::enumToString( return (it != valueMap.end()) ? it->second : "unknown"; } -// Explicit instantiations ensure the compiler generates the template for a type +// Explicit template instantiations for AudioCodec and VideoCodec template std::string FFmpegSettingsManager::enumToString( const AudioCodec& codec, const std::unordered_map& codecMap) const; template std::string FFmpegSettingsManager::enumToString( diff --git a/MediaProcessor/src/FFmpegSettingsManager.h b/MediaProcessor/src/FFmpegSettingsManager.h index ca89e92..81753ed 100644 --- a/MediaProcessor/src/FFmpegSettingsManager.h +++ b/MediaProcessor/src/FFmpegSettingsManager.h @@ -4,7 +4,11 @@ #include #include -#include "ConfigManager.h" +/** + * TODO: + * Having input/output file operations in the settings manager is not nice. + * These should be handled as the command is built, within the FFmpegCommandBuilder class. + */ namespace MediaProcessor { @@ -12,20 +16,14 @@ enum class AudioCodec { AAC, MP3, FLAC, OPUS, UNKNOWN }; enum class VideoCodec { H264, H265, VP8, VP9, UNKNOWN }; /** - * @brief Manages FFmpeg-specific global, audio, and video settings. - * - * The FFmpegSettingsManager is a singleton that provides interfaces to set - * and get settings for global, audio, and video configurations. + * @brief Manages settings for FFmpeg-specific global, audio, and video settings. * - * Note: This class does not manage configuration data directly. Configurations - * should be retrieved via the `ConfigManager`. + * Provides an interface for setting and retrieving configuration options, + * including file paths, codecs, and other processing parameters. */ class FFmpegSettingsManager { public: - /** - * @brief Retrieves the singleton instance of FFmpegSettingsManager. - */ - static FFmpegSettingsManager& getInstance(); + FFmpegSettingsManager(); // Global Setters void setOverwrite(bool overwrite); @@ -65,18 +63,7 @@ class FFmpegSettingsManager { std::string enumToString(const T& value, const std::unordered_map& valueMap) const; - /** - * @brief Retrieves the path to the FFmpeg executable. - * - * @return The FFmpeg path as a string. - */ - std::string getFFmpegPath() const { - return ConfigManager::getInstance().getFFmpegPath().string(); - } - private: - FFmpegSettingsManager(); - std::unordered_map m_audioCodecToString; std::unordered_map m_videoCodecToString; diff --git a/MediaProcessor/src/Utils.cpp b/MediaProcessor/src/Utils.cpp index c2bcfbd..fed17d7 100644 --- a/MediaProcessor/src/Utils.cpp +++ b/MediaProcessor/src/Utils.cpp @@ -6,14 +6,13 @@ #include #include #include +#include + +#include "CommandBuilder.h" namespace MediaProcessor::Utils { bool runCommand(const std::string &command) { - /* - * Executes a system command, captures its output, and returns true if successful. - */ - std::array buffer; std::string result; std::string fullCommand = command + " 2>&1"; // Redirect stderr to stdout @@ -38,10 +37,6 @@ bool runCommand(const std::string &command) { std::pair prepareOutputPaths( const std::filesystem::path &videoPath) { - /* - * Prepares and returns the output paths for the vocals and processed video files. - */ - std::string baseFilename = videoPath.stem().string(); std::filesystem::path outputDir = videoPath.parent_path(); @@ -53,10 +48,6 @@ std::pair prepareOutputPaths( } bool ensureDirectoryExists(const std::filesystem::path &path) { - /* - * Ensures the specified directory exists by making the directory if necessary - */ - if (!std::filesystem::exists(path)) { std::cerr << "Output directory does not exist, creating it: " << path << std::endl; std::filesystem::create_directories(path); @@ -78,10 +69,6 @@ bool containsWhitespace(const std::string &str) { return str.find(' ') != std::string::npos; } -bool isWithinRange(unsigned int value, unsigned int lowerBound, unsigned int upperBound) { - return value >= lowerBound && value <= upperBound; -} - std::string trimTrailingSpace(const std::string &str) { if (str.empty() || str.back() != ' ') { return str; @@ -89,4 +76,34 @@ std::string trimTrailingSpace(const std::string &str) { return str.substr(0, str.size() - 1); } +double getMediaDuration(const fs::path &mediaPath) { + // Prepare ffprobe command + CommandBuilder cmd; + cmd.addArgument("ffprobe"); + cmd.addFlag("-v", "error"); + cmd.addFlag("-show_entries", "format=duration"); + cmd.addFlag("-of", "default=noprint_wrappers=1:nokey=1"); + cmd.addArgument(mediaPath.string()); + + FILE *pipe = popen(cmd.build().c_str(), "r"); + if (!pipe) { + std::cerr << "Error: Failed to run ffprobe to get media duration." << std::endl; + return -1; + } + + char buffer[128]; + std::string result; + while (fgets(buffer, sizeof buffer, pipe) != nullptr) { + result += buffer; + } + pclose(pipe); + + try { + return std::stod(result); + } catch (const std::exception &e) { + std::cerr << "Error: Could not parse media duration." << std::endl; + return -1; + } +} + } // namespace MediaProcessor::Utils diff --git a/MediaProcessor/src/Utils.h b/MediaProcessor/src/Utils.h index e71b024..a2fee59 100644 --- a/MediaProcessor/src/Utils.h +++ b/MediaProcessor/src/Utils.h @@ -37,13 +37,6 @@ bool removeFileIfExists(const fs::path &filePath); */ bool containsWhitespace(const std::string &str); -/** - * @brief Checks if a value is within a specified range (inclusive). - * - * @return true if the value is within the range, false otherwise. - */ -bool isWithinRange(unsigned int value, unsigned int lowerBound, unsigned int upperBound); - /** * @brief Prepares the output paths for audio and video processing. * @@ -58,6 +51,23 @@ std::pair prepareOutputPaths(const fs::path &videoPath); */ std::string trimTrailingSpace(const std::string &str); +/** + * @brief Gets the duration of a media file (audio or video) using ffprobe. + * + * @return The duration of the media in seconds, or -1 if an error occurred. + */ +double getMediaDuration(const fs::path &mediaPath); + +/** + * @brief Checks if a value is within a specified range (inclusive). + * + * @return true if the value is within the range, false otherwise. + */ +template +bool isWithinRange(T value, T lowerBound, T upperBound) { + return value >= lowerBound && value <= upperBound; +} + } // namespace MediaProcessor::Utils #endif // UTILS_H diff --git a/MediaProcessor/tests/AudioProcessorTester.cpp b/MediaProcessor/tests/AudioProcessorTester.cpp new file mode 100644 index 0000000..5d36007 --- /dev/null +++ b/MediaProcessor/tests/AudioProcessorTester.cpp @@ -0,0 +1,63 @@ +#include + +#include +#include + +#include "../src/AudioProcessor.h" +#include "../src/ConfigManager.h" +#include "TestUtils.h" + +namespace fs = std::filesystem; +namespace MediaProcessor::Tests { + +fs::path testMediaPath = TEST_MEDIA_DIR; + +class AudioProcessorTester : public ::testing::Test { + protected: + fs::path testVideoPath; + fs::path testAudioProcessedPath; + fs::path testOutputDir; + TestUtils::TestConfigFile testConfigFile; + + void assertFileExists(fs::path path) { + ASSERT_TRUE(fs::exists(path)) << path << " not found."; + } + + void SetUp() override { + fs::path currentPath = fs::current_path(); + + testVideoPath = testMediaPath / "test_video.mkv"; + testAudioProcessedPath = testMediaPath / "test_audio_processed.wav"; + + assertFileExists(testVideoPath); + assertFileExists(testAudioProcessedPath); + + testOutputDir = currentPath / "test_output"; + fs::create_directories(testOutputDir); + + testConfigFile.changeConfigOptions("use_thread_cap", true, "max_threads_if_capped", 1); + } + + void TearDown() override { + fs::remove_all(testOutputDir); + } +}; + +// ClassName_MethodName_StateUnderTest_ExpectedBehavior gtest std naming convention +TEST_F(AudioProcessorTester, IsolateVocals_FiltersAudioCorrectly) { + ConfigManager &configManager = ConfigManager::getInstance(); + ASSERT_TRUE(configManager.loadConfig(testConfigFile.getFilePath())) + << "Unable to Load TestConfigFile"; + + fs::path testAudioOutputPath = testOutputDir / "test_output_audio.wav"; + AudioProcessor audioProcessor(testVideoPath, testAudioOutputPath); + + EXPECT_EQ(audioProcessor.isolateVocals(), true); + + EXPECT_TRUE(fs::exists(testAudioOutputPath)); + + EXPECT_TRUE( + TestUtils::CompareFiles::compareAudioFiles(testAudioOutputPath, testAudioProcessedPath)); +} + +} // namespace MediaProcessor::Tests diff --git a/MediaProcessor/tests/CommandBuilderTester.cpp b/MediaProcessor/tests/CommandBuilderTester.cpp new file mode 100644 index 0000000..add3826 --- /dev/null +++ b/MediaProcessor/tests/CommandBuilderTester.cpp @@ -0,0 +1,18 @@ +#include + +#include "../src/CommandBuilder.h" + +namespace MediaProcessor::Testing { + +// ClassName_MethodName_StateUnderTest_ExpectedBehavior gtest std naming convention +TEST(CommandBuilderTest, BuildCommand_ConstructsExpectedCommandString) { + CommandBuilder builder; + builder.addArgument("arg1"); + builder.addFlag("flag"); + builder.addArgument("arg2"); + builder.addFlag("flag2", "value"); + std::string command = builder.build(); + EXPECT_EQ(command, "arg1 flag arg2 flag2 value"); +} + +} // namespace MediaProcessor::Testing \ No newline at end of file diff --git a/MediaProcessor/tests/ConfigManagerTester.cpp b/MediaProcessor/tests/ConfigManagerTester.cpp new file mode 100644 index 0000000..b371295 --- /dev/null +++ b/MediaProcessor/tests/ConfigManagerTester.cpp @@ -0,0 +1,86 @@ +#include + +#include + +#include "../src/ConfigManager.h" +#include "TestUtils.h" + +namespace fs = std::filesystem; +namespace MediaProcessor::Tests { + +class ConfigManagerTest : public ::testing::Test { + protected: + ConfigManager& configManager; + TestUtils::TestConfigFile testConfigFile; + + ConfigManagerTest() : configManager(ConfigManager::getInstance()) {} +}; + +// ClassName_MethodName_StateUnderTest_ExpectedBehavior gtest std naming convention +TEST_F(ConfigManagerTest, LoadValidConfigFile_Succeeds) { + // Generate a temporary config file with valid JSON object + nlohmann::json jsonObject = { + {"deep_filter_path", "MediaProcessor/res/deep-filter-0.5.6-x86_64-unknown-linux-musl"}, + {"deep_filter_tarball_path", "MediaProcessor/res/DeepFilterNet3_ll_onnx.tar.gz"}, + {"deep_filter_encoder_path", + "MediaProcessor/res/DeepFilterNet3_ll_onnx/tmp/export/enc.onnx"}, + {"deep_filter_decoder_path", + "MediaProcessor/res/DeepFilterNet3_ll_onnx/tmp/export/df_dec.onnx"}, + {"ffmpeg_path", "/usr/bin/ffmpeg"}, + {"downloads_path", "downloads"}, + {"uploads_path", "uploads"}, + {"use_thread_cap", true}, + {"max_threads_if_capped", 1}}; + + testConfigFile.generateConfigFile("testConfig.json", jsonObject); + + bool loadSuccess = configManager.loadConfig(testConfigFile.getFilePath()); + + EXPECT_TRUE(loadSuccess); + EXPECT_EQ(configManager.getDeepFilterPath(), jsonObject["deep_filter_path"].get()); + EXPECT_EQ(configManager.getDeepFilterTarballPath(), + jsonObject["deep_filter_tarball_path"].get()); + EXPECT_EQ(configManager.getDeepFilterEncoderPath(), + jsonObject["deep_filter_encoder_path"].get()); + EXPECT_EQ(configManager.getDeepFilterDecoderPath(), + jsonObject["deep_filter_decoder_path"].get()); + EXPECT_EQ(configManager.getFFmpegPath(), jsonObject["ffmpeg_path"].get()); + EXPECT_EQ(configManager.getOptimalThreadCount(), + jsonObject["max_threads_if_capped"].get()); +} + +TEST_F(ConfigManagerTest, LoadInvalidConfigFile) { + fs::path invalidConfigPath = "invalid_config.json"; + + // Generate an invalid config file for testing + std::ofstream(invalidConfigPath) << "not a json"; + + EXPECT_THROW(configManager.loadConfig(invalidConfigPath), std::runtime_error); +} + +TEST_F(ConfigManagerTest, LoadInvalidConfigOptions) { + // Generate a temporary config file with valid JSON object with invalid options + nlohmann::json jsonObject = {{"deep_filter_path", false}, + {"deep_filter_tarball_path", true}, + {"deep_filter_encoder_path", 1.0}, + {"deep_filter_decoder_path", -1}, + {"ffmpeg_path", false}, + {"downloads_path", false}, + {"uploads_path", false}, + {"use_thread_cap", "true"}, + {"max_threads_if_capped", -1}}; + + testConfigFile.generateConfigFile("testConfig.json", jsonObject); + + bool loadSuccess = configManager.loadConfig(testConfigFile.getFilePath()); + + EXPECT_TRUE(loadSuccess); + EXPECT_THROW(configManager.getDeepFilterPath(), std::runtime_error); + EXPECT_THROW(configManager.getDeepFilterTarballPath(), std::runtime_error); + EXPECT_THROW(configManager.getDeepFilterEncoderPath(), std::runtime_error); + EXPECT_THROW(configManager.getDeepFilterDecoderPath(), std::runtime_error); + EXPECT_THROW(configManager.getFFmpegPath(), std::runtime_error); + EXPECT_THROW(configManager.getOptimalThreadCount(), std::runtime_error); +} + +} // namespace MediaProcessor::Tests \ No newline at end of file diff --git a/MediaProcessor/tests/TestMedia/test_audio.wav b/MediaProcessor/tests/TestMedia/test_audio.wav new file mode 100644 index 0000000..3a8d3ce Binary files /dev/null and b/MediaProcessor/tests/TestMedia/test_audio.wav differ diff --git a/MediaProcessor/tests/TestMedia/test_audio_processed.wav b/MediaProcessor/tests/TestMedia/test_audio_processed.wav new file mode 100644 index 0000000..a1699fa Binary files /dev/null and b/MediaProcessor/tests/TestMedia/test_audio_processed.wav differ diff --git a/MediaProcessor/tests/TestMedia/test_video.mkv b/MediaProcessor/tests/TestMedia/test_video.mkv new file mode 100644 index 0000000..c394973 Binary files /dev/null and b/MediaProcessor/tests/TestMedia/test_video.mkv differ diff --git a/MediaProcessor/tests/TestMedia/test_video_processed.mp4 b/MediaProcessor/tests/TestMedia/test_video_processed.mp4 new file mode 100644 index 0000000..d3a55d5 Binary files /dev/null and b/MediaProcessor/tests/TestMedia/test_video_processed.mp4 differ diff --git a/MediaProcessor/tests/TestUtils.cpp b/MediaProcessor/tests/TestUtils.cpp new file mode 100644 index 0000000..26984cd --- /dev/null +++ b/MediaProcessor/tests/TestUtils.cpp @@ -0,0 +1,138 @@ +#include "TestUtils.h" + +#include + +#include +#include +#include + +namespace MediaProcessor::TestUtils { + +void TestConfigFile::writeJsonToFile(const fs::path& path, const nlohmann::json& jsonObject) const { + std::ofstream file(m_filePath); + if (!file.is_open()) { + throw std::runtime_error("Failed to open Test Configuration File at " + + m_filePath.string()); + } + file << jsonObject.dump(4); + file.close(); +} + +void TestConfigFile::generateConfigFile(const fs::path& path, const nlohmann::json& jsonObject) { + deleteConfigFile(); + m_filePath = path; + writeJsonToFile(path, jsonObject); +} + +bool TestConfigFile::deleteConfigFile() const { + if (!fs::exists(m_filePath)) { + return false; + } + if (!fs::remove(m_filePath)) { + throw std::runtime_error("Failed to delete Test Configuration File at" + + m_filePath.string()); + } + return true; +} + +bool CompareFiles::compareFilesByteByByte(const fs::path& filePath1, const fs::path& filePath2, + size_t chunkSize) { + std::ifstream f1(filePath1, std::ios::binary); + std::ifstream f2(filePath2, std::ios::binary); + + if (!f1.is_open()) { + throw std::runtime_error("Failed to open file: " + filePath1.string()); + } + if (!f2.is_open()) { + throw std::runtime_error("Failed to open file: " + filePath2.string()); + } + + f1.seekg(0, std::ios::end); + f2.seekg(0, std::ios::end); + if (f1.tellg() != f2.tellg()) { + return false; // Files are of different sizes + } + + f1.seekg(0); + f2.seekg(0); + + std::vector buffer1(chunkSize); + std::vector buffer2(chunkSize); + + // Compare in chunks + while (f1 && f2) { + f1.read(buffer1.data(), chunkSize); + f2.read(buffer2.data(), chunkSize); + + std::streamsize bytesRead1 = f1.gcount(); + std::streamsize bytesRead2 = f2.gcount(); + + if (bytesRead1 != bytesRead2 || + !std::equal(buffer1.begin(), buffer1.begin() + bytesRead1, buffer2.begin())) { + return false; + } + } + + return true; +} + +bool CompareFiles::compareAudioFiles(const fs::path& filePath1, const fs::path& filePath2, + double tolerance, size_t chunkSize) { + SF_INFO sfInfo1, sfInfo2; + SNDFILE* sndFile1 = sf_open(filePath1.c_str(), SFM_READ, &sfInfo1); + if (!sndFile1) { + throw std::runtime_error("Failed to open file " + filePath1.string()); + } + + SNDFILE* sndFile2 = sf_open(filePath2.c_str(), SFM_READ, &sfInfo2); + if (!sndFile2) { + sf_close(sndFile1); + throw std::runtime_error("Failed to open file " + filePath2.string()); + } + + // Check if the file formats are the same + if (sfInfo1.channels != sfInfo2.channels || sfInfo1.samplerate != sfInfo2.samplerate) { + sf_close(sndFile1); + sf_close(sndFile2); + return false; + } + + std::vector buffer1(chunkSize * sfInfo1.channels); + std::vector buffer2(chunkSize * sfInfo2.channels); + + // Compare in chunks + sf_count_t framesRead1, framesRead2; + while ((framesRead1 = sf_readf_float(sndFile1, buffer1.data(), chunkSize)) > 0 && + (framesRead2 = sf_readf_float(sndFile2, buffer2.data(), chunkSize)) > 0) { + if (framesRead1 != framesRead2 || + !compareBuffersWithTolerance(buffer1.begin(), buffer1.begin() + framesRead1, + buffer2.begin(), buffer2.begin() + framesRead2, + tolerance)) { + sf_close(sndFile1); + sf_close(sndFile2); + return false; + } + } + + sf_close(sndFile1); + sf_close(sndFile2); + return true; +} + +template +bool CompareFiles::isWithinTolerance(const T& a, const T& b, double tolerance) { + return std::fabs(a - b) <= tolerance; +} + +template +bool CompareFiles::compareBuffersWithTolerance(T begin1, T end1, T begin2, T end2, + double tolerance) { + if (std::distance(begin1, end1) != std::distance(begin2, end2)) { + return false; + } + return std::equal(begin1, end1, begin2, end2, [tolerance](const auto& a, const auto& b) { + return isWithinTolerance(a, b, tolerance); + }); +} + +} // namespace MediaProcessor::TestUtils \ No newline at end of file diff --git a/MediaProcessor/tests/TestUtils.h b/MediaProcessor/tests/TestUtils.h new file mode 100644 index 0000000..6b24c73 --- /dev/null +++ b/MediaProcessor/tests/TestUtils.h @@ -0,0 +1,134 @@ +#ifndef TESTUTILS_H +#define TESTUTILS_H + +#include +#include +#include + +namespace fs = std::filesystem; +namespace MediaProcessor::TestUtils { + +constexpr const char* DEFAULT_TEST_CONFIG_FILE_PATH = "testConfig.json"; + +/** + * @brief Generate a test configuration file. + */ +class TestConfigFile { + public: + TestConfigFile() : m_filePath(DEFAULT_TEST_CONFIG_FILE_PATH) { + writeJsonToFile(m_filePath, jsonObject); + } + explicit TestConfigFile(const fs::path& path) : m_filePath(path) { + writeJsonToFile(path, jsonObject); + } + + /** + * @brief Generate a test configuration file with a custom object. + */ + void generateConfigFile(const fs::path& path, const nlohmann::json& jsonObject); + + /** + * @brief Updates configuration options and writes them to the file. + * + * Accepts key-value pairs as arguments (e.g., "key", 1, "key2", true). + * + * @tparam T Variadic template parameter pack for argument types. + * @param args Key-value pairs of configuration options and their values. + */ + template + void changeConfigOptions(T&&... args) { + changeJsonObjects(std::forward(args)...); + writeJsonToFile(m_filePath, jsonObject); + } + + ~TestConfigFile() { + deleteConfigFile(); + } + + /** + * @brief return the path of the test configuration file the object holds. + * + * @return Path of the test configuration file. + */ + fs::path getFilePath() const { + return m_filePath; + } + + private: + void writeJsonToFile(const fs::path& path, const nlohmann::json& jsonContent) const; + bool deleteConfigFile() const; + + template + void changeJsonObject(const std::string& option, const T& value) { + if (!jsonObject.contains(option)) { + throw std::runtime_error(option + " not found"); + } + jsonObject[option] = value; + } + + void changeJsonObjects() {} + template + void changeJsonObjects(const TKey& option, const TValue& value, TRest&&... rest) { + changeJsonObject(option, value); + changeJsonObjects(std::forward(rest)...); + } + + fs::path m_filePath; + fs::path m_rootPath = fs::path(TEST_MEDIA_DIR).parent_path().parent_path(); + nlohmann::json jsonObject = { + {"deep_filter_path", + (m_rootPath / "res/deep-filter-0.5.6-x86_64-unknown-linux-musl").string()}, + {"deep_filter_tarball_path", (m_rootPath / "res/DeepFilterNet3_ll_onnx.tar.gz")}, + {"deep_filter_encoder_path", + (m_rootPath / "res/DeepFilterNet3_ll_onnx/tmp/export/enc.onnx")}, + {"deep_filter_decoder_path", + (m_rootPath / "res/DeepFilterNet3_ll_onnx/tmp/export/df_dec.onnx")}, + {"ffmpeg_path", "/usr/bin/ffmpeg"}, + {"downloads_path", "downloads"}, + {"uploads_path", "uploads"}, + {"use_thread_cap", false}, + {"max_threads_if_capped", 6}}; +}; + +/** + * @brief A utility class for comparing files. + * + */ +class CompareFiles { + public: + /** + * @brief Compare two files byte-by-byte. + * + * @param filePath1 The path to the first file. + * @param filePath2 The path to the second file. + * @param chunkSize The size of the chunks to read (default: 1024). + * @return true if the files are identical, false otherwise. + */ + static bool compareFilesByteByByte(const fs::path& filePath1, const fs::path& filePath2, + size_t chunkSize = DEFAULT_CHUNK_SIZE); + /** + * @brief Compare two audio files with a given tolerance. + * + * @param filePath1 The path to the first audio file. + * @param filePath2 The path to the second audio file. + * @param tolerance The tolerance level for comparing audio samples (default: 0.01). + * @param chunkSize The size of the chunks to read in terms of frames (default: 1024). + * @return true if the audio files are similar within the tolerance, false otherwise. + */ + static bool compareAudioFiles(const fs::path& filePath1, const fs::path& filePath2, + double tolerance = DEFAULT_TOLERANCE, + size_t chunkSize = DEFAULT_CHUNK_SIZE); + + private: + static constexpr size_t DEFAULT_CHUNK_SIZE = 1024; + static constexpr double DEFAULT_TOLERANCE = 0.01; + + template + static bool isWithinTolerance(const T& a, const T& b, double tolerance); + template + static bool compareBuffersWithTolerance(T begin1, T end1, T begin2, T end2, double tolerance); +}; + +} // namespace MediaProcessor::TestUtils + +#endif // TESTUTILS_H \ No newline at end of file diff --git a/MediaProcessor/tests/UtilsTester.cpp b/MediaProcessor/tests/UtilsTester.cpp new file mode 100644 index 0000000..a2bfe67 --- /dev/null +++ b/MediaProcessor/tests/UtilsTester.cpp @@ -0,0 +1,41 @@ +#include + +#include + +#include "../src/Utils.h" + +namespace fs = std::filesystem; +namespace MediaProcessor::Tests { + +TEST(UtilsTester, checkPreparedOutputPaths) { + fs::path videoPath = "/Tests/Video.mp4"; + fs::path expectedVocalsPath = "/Tests/Video_isolated_audio.wav", + expectedProcessedVideoPath = "/Tests/Video_processed_video.mp4"; + + auto [outputVocalsPath, outputProcessedVideoPath] = Utils::prepareOutputPaths(videoPath); + EXPECT_EQ(expectedVocalsPath, outputVocalsPath); + EXPECT_EQ(expectedProcessedVideoPath, outputProcessedVideoPath); +} + +TEST(UtilsTester, EnsureDirectoryExists) { + fs::path tempPath = fs::temp_directory_path() / "test_dir"; + + EXPECT_FALSE(fs::exists(tempPath)); + + EXPECT_TRUE(Utils::ensureDirectoryExists(tempPath)); + EXPECT_TRUE(fs::exists(tempPath)); + + EXPECT_FALSE(Utils::ensureDirectoryExists(tempPath)); + + fs::remove_all(tempPath); +} + +TEST(UtilsTester, TrimTrailingSpaces) { + std::string inputWithTrailingSpace = "Hello, World! "; + std::string inputWithoutTrailingSpace = "Hello, World!"; + + EXPECT_EQ(inputWithoutTrailingSpace, Utils::trimTrailingSpace(inputWithTrailingSpace)); + EXPECT_EQ(inputWithoutTrailingSpace, Utils::trimTrailingSpace(inputWithoutTrailingSpace)); +} + +} // namespace MediaProcessor::Tests \ No newline at end of file diff --git a/MediaProcessor/tests/VideoProcessorTester.cpp b/MediaProcessor/tests/VideoProcessorTester.cpp new file mode 100644 index 0000000..dfbf722 --- /dev/null +++ b/MediaProcessor/tests/VideoProcessorTester.cpp @@ -0,0 +1,78 @@ +#include + +#include +#include + +#include "../src/ConfigManager.h" +#include "../src/Utils.h" +#include "../src/VideoProcessor.h" +#include "TestUtils.h" + +namespace fs = std::filesystem; +namespace MediaProcessor::Tests { + +fs::path testMediaPath = TEST_MEDIA_DIR; + +class VideoProcessorTester : public ::testing::Test { + protected: + fs::path testVideoPath; + fs::path testAudioPath; + fs::path testOutputDir; + TestUtils::TestConfigFile testConfigFile; + ConfigManager& configManager; + + VideoProcessorTester() : configManager(ConfigManager::getInstance()) {} + + void assertFileExists(const fs::path& path) { + ASSERT_TRUE(fs::exists(path)) << path << " not found."; + } + + void SetUp() override { + testVideoPath = testMediaPath / "test_video.mkv"; + testAudioPath = testMediaPath / "test_audio_processed.wav"; + + assertFileExists(testVideoPath); + assertFileExists(testAudioPath); + + testOutputDir = fs::current_path() / "test_output"; + fs::create_directories(testOutputDir); + + nlohmann::json jsonObject = { + {"ffmpeg_path", "/usr/bin/ffmpeg"}, + {"deep_filter_path", "MediaProcessor/res/deep-filter-0.5.6-x86_64-unknown-linux-musl"}, + {"downloads_path", "downloads"}, + {"uploads_path", "uploads"}, + {"use_thread_cap", true}, + {"max_threads_if_capped", 4}}; + testConfigFile.generateConfigFile("testConfig.json", jsonObject); + + ASSERT_TRUE(configManager.loadConfig(testConfigFile.getFilePath())) + << "Failed to load test configuration file."; + } + + void TearDown() override { + fs::remove_all(testOutputDir); + } +}; + +// ClassName_MethodName_StateUnderTest_ExpectedBehavior gtest std naming convention +TEST_F(VideoProcessorTester, MergeMedia_MergesAudioAndVideoCorrectly) { + /** + * FIXME: currently only checking for duration here as the filter function + * is already being checked within the audio tester. + * Eventually we need check for sensible metrics here. + */ + + fs::path testOutputVideoPath = testOutputDir / "test_output_video.mp4"; + VideoProcessor videoProcessor(testVideoPath, testAudioPath, testOutputVideoPath); + + EXPECT_EQ(videoProcessor.mergeMedia(), true); + EXPECT_TRUE(fs::exists(testOutputVideoPath)); + + double originalDuration = MediaProcessor::Utils::getMediaDuration(testVideoPath); + double outputDuration = MediaProcessor::Utils::getMediaDuration(testOutputVideoPath); + EXPECT_NEAR(originalDuration, outputDuration, 0.5) + << "Duration of the merged video differs significantly from the original."; +} + +} // namespace MediaProcessor::Tests