diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 988b3dc..a5508f6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,95 +1,51 @@ -name: ci-pr -on: [pull_request, workflow_dispatch] +name: Build +on: [push, pull_request] +defaults: + run: + shell: pwsh jobs: - x64-linux-gcc: - runs-on: ubuntu-latest + build: + name: ${{ matrix.platform.name }} ${{ matrix.config.name }} + runs-on: ${{ matrix.platform.os }} + env: + CMAKE_BUILD_PARALLEL_LEVEL: 4 + strategy: + fail-fast: false + matrix: + platform: + - { name: "Windows VS2022", os: windows-2022, flags: "" } + - { name: "Linux GCC", os: ubuntu-latest, flags: "" } + - { name: "Linux Clang", os: ubuntu-latest, flags: "-DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++" } + - { name: "macOS AppleClang", os: macos-latest, flags: "" } + config: + - { name: "Static", flags: "-DBUILD_SHARED_LIBS=FALSE" } steps: - - uses: actions/checkout@v4 - - name: init - run: uname -m; sudo apt update -yqq && sudo apt install -yqq ninja-build mesa-common-dev libwayland-dev libxkbcommon-dev wayland-protocols extra-cmake-modules - - name: configure - run: cmake -S . --preset=ninja-gcc -B build -DGLFW_BUILD_X11=OFF -DJUKE_USE_LIBXMP=OFF -DCMAKE_C_COMPILER=gcc-14 -DCMAKE_CXX_COMPILER=g++-14 - - name: build debug - run: cmake --build build --config=Debug -- -v - - name: build release - run: cmake --build build --config=Release -- -v - - name: test debug - run: cd build && ctest -V -C Debug - - name: test release - run: cd build && ctest -V -C Release - x64-linux-clang: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: init - run: uname -m; sudo apt update -yqq && sudo apt install -yqq ninja-build clang-19 mesa-common-dev libwayland-dev libxkbcommon-dev wayland-protocols extra-cmake-modules - - name: configure - run: cmake -S . --preset=ninja-clang -B build -DGLFW_BUILD_X11=OFF -DJUKE_USE_LIBXMP=OFF -DCMAKE_C_COMPILER=clang-19 -DCMAKE_CXX_COMPILER=clang++-19 - - name: build debug - run: cmake --build build --config=Debug -- -v - - name: build release - run: cmake --build build --config=Release -- -v - - name: test debug - run: cd build && ctest -V -C Debug - - name: test release - run: cd build && ctest -V -C Release - arm64-linux-gcc: - runs-on: ubuntu-24.04-arm - steps: - - uses: actions/checkout@v4 - - name: init - run: uname -m; sudo apt update -yqq && sudo apt install -yqq ninja-build mesa-common-dev libwayland-dev libxkbcommon-dev wayland-protocols extra-cmake-modules - - name: configure - run: cmake -S . --preset=ninja-gcc -B build -DGLFW_BUILD_X11=OFF -DJUKE_USE_LIBXMP=OFF -DCMAKE_C_COMPILER=gcc-14 -DCMAKE_CXX_COMPILER=g++-14 - - name: build debug - run: cmake --build build --config=Debug -- -v - - name: build release - run: cmake --build build --config=Release -- -v - - name: test debug - run: cd build && ctest -V -C Debug - - name: test release - run: cd build && ctest -V -C Release - arm64-linux-clang: - runs-on: ubuntu-24.04-arm - steps: - - uses: actions/checkout@v4 - - name: init - run: uname -m; sudo apt update -yqq && sudo apt install -yqq ninja-build clang-19 mesa-common-dev libwayland-dev libxkbcommon-dev wayland-protocols extra-cmake-modules - - name: configure - run: cmake -S . --preset=ninja-clang -B build -DGLFW_BUILD_X11=OFF -DJUKE_USE_LIBXMP=OFF -DCMAKE_C_COMPILER=clang-19 -DCMAKE_CXX_COMPILER=clang++-19 - - name: build debug - run: cmake --build build --config=Debug -- -v - - name: build release - run: cmake --build build --config=Release -- -v - - name: test debug - run: cd build && ctest -V -C Debug - - name: test release - run: cd build && ctest -V -C Release - x64-windows-vs22: - runs-on: windows-latest - steps: - - uses: actions/checkout@v4 - - name: configure - run: cmake -S . --preset=vs22 -B build -DJUKE_USE_LIBXMP=OFF - - name: build debug - run: cmake --build build --config=Debug --parallel - - name: build release - run: cmake --build build --config=Release --parallel - - name: test debug - run: cd build && ctest -V -C Debug - - name: test release - run: cd build && ctest -V -C Release - x64-windows-clang: - runs-on: windows-latest - steps: - - uses: actions/checkout@v4 - - name: configure - run: cmake -S . --preset=ninja-clang -B build -DJUKE_USE_LIBXMP=OFF - - name: build debug - run: cmake --build build --config=Debug -- -v - - name: build release - run: cmake --build build --config=Release -- -v - - name: test debug - run: cd build && ctest -V -C Debug - - name: test release - run: cd build && ctest -V -C Release + - name: Checkout + uses: actions/checkout@v4 + - name: Install Dependencies + run: | + if ($env:RUNNER_OS -eq 'Windows') { + choco install ninja -y + } elseif ($env:RUNNER_OS -eq 'Linux') { + sudo apt-get update + sudo apt-get install -y ninja-build libxrandr-dev libxcursor-dev libxi-dev libudev-dev libflac-dev libvorbis-dev libgl1-mesa-dev libegl1-mesa-dev libfreetype-dev + } elseif ($env:RUNNER_OS -eq 'macOS') { + brew install ninja + } else { + Write-Error "Unsupported OS: $env:RUNNER_OS" + exit 1 + } + - name: Configure + run: | + if ($env:RUNNER_OS -eq 'Windows') { + . "C:\Program Files\Microsoft Visual Studio\2022\Enterprise\Common7\Tools\Launch-VsDevShell.ps1" -Arch amd64 -HostArch amd64 + Set-Location $env:GITHUB_WORKSPACE + } + cmake -B build -G "Ninja Multi-Config" ${{ matrix.platform.flags }} ${{ matrix.config.flags }} + - name: Build + run: | + if ($env:RUNNER_OS -eq 'Windows') { + . "C:\Program Files\Microsoft Visual Studio\2022\Enterprise\Common7\Tools\Launch-VsDevShell.ps1" -Arch amd64 -HostArch amd64 + Set-Location $env:GITHUB_WORKSPACE + } + cmake --build build --config Release \ No newline at end of file diff --git a/app/include/app/MediaPlayer.hpp b/app/include/app/MediaPlayer.hpp index 0f7ea62..6db0fcb 100644 --- a/app/include/app/MediaPlayer.hpp +++ b/app/include/app/MediaPlayer.hpp @@ -1,10 +1,12 @@ #pragma once +#include #include #include #include #include #include +#include namespace juke { @@ -14,8 +16,22 @@ class MediaPlayer { bool load_media(std::filesystem::path const& path); void handle_input(); + [[nodiscard]] auto get_content_size() const -> ImVec2 { return m_content_size; } + [[nodiscard]] auto has_size_changed() const -> bool { return m_content_size.x != m_previous_content_size.x || m_content_size.y != m_previous_content_size.y; } + + private: + void generate_soundtrack(); + private: Jukebox m_jukebox; + ImVec2 m_content_size{}; + ImVec2 m_previous_content_size{}; + std::filesystem::path m_source_folder{}; + + // threaded soundtrack variables + std::jthread m_loader_thread; + std::atomic m_progress = 0.0f; + std::atomic m_loading = false; }; } // namespace juke diff --git a/app/src/Application.cpp b/app/src/Application.cpp index 0b3e04c..c5c7d53 100644 --- a/app/src/Application.cpp +++ b/app/src/Application.cpp @@ -22,6 +22,7 @@ void Application::run() { while (m_context->next_frame()) { m_player->handle_input(); m_context->render(); + // if (m_player->get_content_size().x > 0 && m_player->get_content_size().y > 0 && !m_player->has_size_changed()) { glfwSetWindowSize(m_context->get_window(), m_player->get_content_size().x, m_player->get_content_size().y); } } } diff --git a/app/src/MediaPlayer.cpp b/app/src/MediaPlayer.cpp index 7071205..116ca33 100644 --- a/app/src/MediaPlayer.cpp +++ b/app/src/MediaPlayer.cpp @@ -1,5 +1,4 @@ -#include #include #include #include @@ -14,10 +13,17 @@ using namespace std::chrono_literals; MediaPlayer::MediaPlayer(capo::IEngine& audio_engine) : m_jukebox(audio_engine) {} -bool MediaPlayer::load_media(std::filesystem::path const& path) { return m_jukebox.load_media(path); } +bool MediaPlayer::load_media(std::filesystem::path const& path) { + m_source_folder = path.parent_path(); + return m_jukebox.load_media(path); +} void MediaPlayer::handle_input() { - ImGuiWindowFlags flags = ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoMove; + ImGuiViewport* vp = ImGui::GetMainViewport(); + + ImGui::SetNextWindowPos(vp->WorkPos); + ImGui::SetNextWindowSize(vp->WorkSize); + ImGuiWindowFlags flags = ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoResize; if (ImGui::Begin("Player", nullptr, flags)) { /* ImGui variables */ @@ -26,13 +32,19 @@ void MediaPlayer::handle_input() { static auto gain{1.f}; static auto pan{0.f}; static auto pitch{1.f}; + static auto lo_cutoff{sample_rate_v}; + static auto hi_cutoff{0.f}; static float pos[] = {0.f, 0.f, 0.f}; /* Metadata & Info */ + ImGui::Text("Folder: %s", m_source_folder.string().c_str()); ImGui::Text("File Name: "); ImGui::SameLine(); m_jukebox.has_file() ? ImGui::TextColored(colors::blue, "%s", m_jukebox.get_filename().c_str()) : ImGui::TextColored(colors::grey, ""); - ImGui::Text("Media Status: %s", m_jukebox.is_playing() ? "playing" : "paused"); + if (m_jukebox.has_file()) { + ImGui::Text("Media Status: %s", m_jukebox.is_playing() ? "playing" : "paused"); + ImGui::ProgressBar(m_jukebox.get_percent_complete(), {300.f, 4.f}, ""); + } ImGui::Separator(); @@ -42,35 +54,73 @@ void MediaPlayer::handle_input() { if (ImGui::Button("pause")) { m_jukebox.pause(); } ImGui::SameLine(); if (ImGui::Button("stop")) { m_jukebox.stop(); } - if (ImGui::TreeNode("advanced", "advanced")) { + ImGui::Separator(); + if (m_jukebox.has_file()) { + if (ImGui::TreeNode("advanced", "advanced")) { - if (ImGui::Button("reset")) { - gain = 1.f; - pitch = 1.f; - pan = 0.f; - } + if (ImGui::Button("reset")) { + gain = 1.f; + pitch = 1.f; + pan = 0.f; + } + + ImGui::Checkbox("loop", &looping); + + ImGui::SliderFloat("volume", &gain, 0.f, 1.f); + ImGui::SliderFloat("pitch", &pitch, 0.f, 2.f); + ImGui::SliderFloat("pan", &pan, -1.f, 1.f); + m_jukebox.set_gain(gain); + m_jukebox.set_pitch(pitch); + m_jukebox.set_pan(pan); - ImGui::Checkbox("loop", &looping); - - ImGui::SliderFloat("volume", &gain, 0.f, 1.f); - ImGui::SliderFloat("pitch", &pitch, 0.f, 2.f); - ImGui::SliderFloat("pan", &pan, -1.f, 1.f); - m_jukebox.set_gain(gain); - m_jukebox.set_pitch(pitch); - m_jukebox.set_pan(pan); - - ImGui::Checkbox("spatialized", &spatialized); - m_jukebox.set_spatialized(spatialized); - if (m_jukebox.is_spatialized()) { - ImGui::Separator(); - ImGui::SliderFloat3("position", pos, -1.f, 1.f, "%.1f"); - m_jukebox.set_position(capo::Vec3f{pos[0], pos[1], pos[2]}); - if (ImGui::Button("reset")) { pos[0] = pos[1] = pos[2] = 0.f; } + ImGui::Checkbox("spatialized", &spatialized); + m_jukebox.set_spatialized(spatialized); + if (m_jukebox.is_spatialized()) { + ImGui::Separator(); + ImGui::SliderFloat3("position", pos, -1.f, 1.f, "%.1f"); + m_jukebox.set_position(capo::Vec3f{pos[0], pos[1], pos[2]}); + if (ImGui::Button("reset")) { pos[0] = pos[1] = pos[2] = 0.f; } + } + + ImGui::SliderFloat("Low Pass", &lo_cutoff, 0.f, sample_rate_v, "%.1f", ImGuiSliderFlags_Logarithmic); + m_jukebox.set_cutoff(FilterType::low, lo_cutoff, sample_rate_v); + ImGui::SliderFloat("High Pass:", &hi_cutoff, 0.f, sample_rate_v, "%.1f", ImGuiSliderFlags_Logarithmic); + m_jukebox.set_cutoff(FilterType::high, hi_cutoff, sample_rate_v); + ImGui::TreePop(); + } + if (ImGui::TreeNode("export", "export")) { + if (ImGui::Button("write to .wav")) { m_jukebox.write_to_wav(); } + if (ImGui::Button("generate soundtrack")) { generate_soundtrack(); } + if (m_loading) { + ImGui::TextColored(colors::grey, "generating soundtrack..."); + ImGui::ProgressBar(m_progress); + } + ImGui::TreePop(); } - ImGui::TreePop(); } } + ImVec2 required = ImGui::GetCursorPos(); + ImGuiStyle const& style = ImGui::GetStyle(); + + required.x += style.WindowPadding.x; + required.y += style.WindowPadding.y; + auto size = ImVec2{std::ceil(required.x), std::ceil(required.y)}; + m_previous_content_size = m_content_size; + m_content_size = size; ImGui::End(); } +void MediaPlayer::generate_soundtrack() { + m_loading = true; + m_loader_thread = std::jthread([this](std::stop_token stop) { + auto total = std::ranges::distance(std::filesystem::recursive_directory_iterator(m_source_folder)); + int current = 0; + for (auto const& file : std::filesystem::recursive_directory_iterator(m_source_folder)) { + if (m_jukebox.load_media(file)) { m_jukebox.write_to_wav(); } + m_progress = static_cast(++current) / total; + } + m_loading = false; + }); +} + } // namespace juke diff --git a/deps/CMakeLists.txt b/deps/CMakeLists.txt index 8e913f2..ef4958a 100644 --- a/deps/CMakeLists.txt +++ b/deps/CMakeLists.txt @@ -23,6 +23,21 @@ if(JUKE_USE_LIBXMP) GIT_SHALLOW TRUE ) list(APPEND fetch_list libxmp) + + #dr_wav + FetchContent_Declare( + dr_wav + GIT_REPOSITORY https://github.com/mackron/dr_libs.git + GIT_TAG master + GIT_SHALLOW TRUE + ) + FetchContent_MakeAvailable(dr_wav) + message(STATUS "dr_wav_SOURCE_DIR = ${dr_wav_SOURCE_DIR}") + + add_library(dr_wav INTERFACE) + target_include_directories(dr_wav INTERFACE + ${dr_wav_SOURCE_DIR} + ) endif() set(BUILD_SHARED OFF CACHE BOOL "" FORCE) @@ -34,4 +49,5 @@ add_library(jukebox::deps ALIAS jukebox-deps) target_link_libraries(jukebox-deps INTERFACE capo::capo + dr_wav ) diff --git a/library/include/juke/core/AudioFile.hpp b/library/include/juke/core/AudioFile.hpp index 3fc9df1..5515af2 100644 --- a/library/include/juke/core/AudioFile.hpp +++ b/library/include/juke/core/AudioFile.hpp @@ -1,6 +1,7 @@ #pragma once #include +#include #include namespace juke { @@ -10,6 +11,10 @@ class AudioFile : public IMediaFile { explicit AudioFile(std::filesystem::path const& path); bool bind_to(capo::ISource& source) final { return source.bind_to(m_buffer); } + void set_cutoff(FilterType type, float to_cutoff, float sample_rate) override {} + void write_to_wav() override {} + + [[nodiscard]] auto get_progress() const -> float { return 0.f; } private: std::shared_ptr m_buffer{std::make_shared()}; diff --git a/library/include/juke/core/Common.hpp b/library/include/juke/core/Common.hpp new file mode 100644 index 0000000..e87ead4 --- /dev/null +++ b/library/include/juke/core/Common.hpp @@ -0,0 +1,10 @@ + +#pragma once + +namespace juke { + +enum class FilterType : std::uint8_t { high, low }; + +constexpr auto sample_rate_v = 44100.0f; + +} // namespace juke diff --git a/library/include/juke/core/MediaFile.hpp b/library/include/juke/core/MediaFile.hpp index 6852a83..d785772 100644 --- a/library/include/juke/core/MediaFile.hpp +++ b/library/include/juke/core/MediaFile.hpp @@ -2,20 +2,26 @@ #pragma once #include +#include #include namespace juke { class IMediaFile : public capo::Polymorphic { public: - explicit IMediaFile(std::filesystem::path const& path) : m_filename(path.filename().string()) {} + explicit IMediaFile(std::filesystem::path const& path) : m_filename(path.filename().string()), m_extension(path.filename().extension().string()) {} virtual bool bind_to(capo::ISource& source) = 0; + virtual void set_cutoff(FilterType type, float to_cutoff, float sample_rate) = 0; + virtual void write_to_wav() = 0; [[nodiscard]] std::string const& get_filename() const { return m_filename; } + [[nodiscard]] std::string const& get_extension() const { return m_extension; } + [[nodiscard]] virtual auto get_progress() const -> float = 0; private: std::string m_filename{}; + std::string m_extension{}; }; } // namespace juke diff --git a/library/include/juke/core/XMFile.hpp b/library/include/juke/core/XMFile.hpp index 1dd1157..144a5c9 100644 --- a/library/include/juke/core/XMFile.hpp +++ b/library/include/juke/core/XMFile.hpp @@ -12,6 +12,11 @@ class XMFile : public IMediaFile { explicit XMFile(std::filesystem::path const& path); bool bind_to(capo::ISource& source) final { return source.bind_to(m_stream); } + void write_to_wav() override; + + [[nodiscard]] auto get_progress() const -> float { return m_stream->get_progress(); } + + void set_cutoff(FilterType type, float to_cutoff, float sample_rate) override { m_stream->set_cutoff(type, to_cutoff, sample_rate); } private: std::shared_ptr m_stream{}; diff --git a/library/include/juke/core/XMStream.hpp b/library/include/juke/core/XMStream.hpp index b6e04a5..e2c8bcd 100644 --- a/library/include/juke/core/XMStream.hpp +++ b/library/include/juke/core/XMStream.hpp @@ -2,6 +2,9 @@ #include #include +#include +#include +#include #include #include @@ -13,6 +16,11 @@ class XMStream : public capo::IStreamPipe { explicit XMStream(std::filesystem::path const& path); + void set_cutoff(FilterType type, float to_cutoff, float sample_rate); + void write_to_wav(); + + [[nodiscard]] auto get_progress() const -> float { return m_progress; } + private: struct Impl; struct Deleter { @@ -25,6 +33,13 @@ class XMStream : public capo::IStreamPipe { auto set_looping(bool looping) -> bool final; std::unique_ptr m_impl{}; + + LowPassFilter m_lowpass; + HighPassFilter m_highpass; + + float m_progress{}; + + std::filesystem::path m_path; }; } // namespace juke diff --git a/library/include/juke/effect/HighPassFilter.hpp b/library/include/juke/effect/HighPassFilter.hpp new file mode 100644 index 0000000..28395db --- /dev/null +++ b/library/include/juke/effect/HighPassFilter.hpp @@ -0,0 +1,37 @@ + +#pragma once + +#include + +namespace juke { + +class HighPassFilter { + public: + HighPassFilter(float cutoff_hz, float sample_rate) { set_cutoff(cutoff_hz, sample_rate); } + + void set_cutoff(float cutoff_hz, float sample_rate) { + if (cutoff_hz <= 0.0f) { + m_alpha = 0.0f; + return; + } + + auto const dt = 1.0f / sample_rate; + auto const rc = 1.0f / (2.0f * std::numbers::pi * cutoff_hz); + m_alpha = rc / (rc + dt); + } + + float process(float input) { + if (m_alpha == 0.0f) return input; + auto output = m_alpha * (m_prev_out + input - m_prev_in); + m_prev_in = input; + m_prev_out = output; + return output; + } + + private: + float m_prev_in{}; + float m_prev_out{}; + float m_alpha{}; +}; + +} // namespace juke diff --git a/library/include/juke/effect/LowPassFilter.hpp b/library/include/juke/effect/LowPassFilter.hpp new file mode 100644 index 0000000..bb14b61 --- /dev/null +++ b/library/include/juke/effect/LowPassFilter.hpp @@ -0,0 +1,28 @@ + +#pragma once + +#include + +namespace juke { + +class LowPassFilter { + public: + LowPassFilter(float cutoff_hz, float sample_rate) { set_cutoff(cutoff_hz, sample_rate); } + + void set_cutoff(float cutoff_hz, float sample_rate) { + auto const rc = 1.0f / (2.0f * std::numbers::pi * cutoff_hz); + auto const dt = 1.0f / sample_rate; + m_alpha = dt / (rc + dt); + } + + float process(float in) { + m_prev_out = m_alpha * in + (1.0f - m_alpha) * m_prev_out; + return m_prev_out; + } + + private: + float m_prev_out{}; + float m_alpha{}; +}; + +} // namespace juke diff --git a/library/include/juke/juke.hpp b/library/include/juke/juke.hpp index dcff637..76c907d 100644 --- a/library/include/juke/juke.hpp +++ b/library/include/juke/juke.hpp @@ -28,6 +28,12 @@ class Jukebox { /// \brief Stop audio and set cursor to beginning. This does not work for XM format. void stop(); + /// \brief Write the current XM module to WAV format. + void write_to_wav(); + + // utility functions + void set_cutoff(FilterType type, float cutoff_hz, float sample_rate); + /* capo API wrapper functions */ // setters @@ -39,7 +45,10 @@ class Jukebox { void set_position(capo::Vec3f pos) { m_source->set_position(pos); } void set_spatialized(bool spatialized) { m_source->set_spatialized(spatialized); } - //getters + // getters + [[nodiscard]] auto get_duration() const -> std::chrono::duration { return m_source->get_duration(); } + [[nodiscard]] auto get_cursor() const -> std::chrono::duration { return m_source->get_cursor(); } + [[nodiscard]] auto get_percent_complete() const -> float { return is_xm() ? m_media_file->get_progress() : get_cursor().count() / get_duration().count(); } [[nodiscard]] auto get_gain() const -> float { return m_source->get_gain(); } [[nodiscard]] auto get_pitch() const -> float { return m_source->get_pitch(); } [[nodiscard]] auto get_pan() const -> float { return m_source->get_pan(); } @@ -50,7 +59,7 @@ class Jukebox { /// \brief Get the name of the loaded file as a string. [[nodiscard]] auto get_filename() const& -> std::string { return m_media_file->get_filename(); } - + [[nodiscard]] auto is_xm() const -> bool { return m_media_file->get_extension() == ".xm"; } [[nodiscard]] auto has_file() const -> bool { return static_cast(m_media_file); } [[nodiscard]] auto is_playing() const -> bool { return m_source->is_playing(); } [[nodiscard]] auto is_stopped() const -> bool { return !is_playing(); } diff --git a/library/src/CMakeLists.txt b/library/src/CMakeLists.txt index 618f8d7..88646d6 100644 --- a/library/src/CMakeLists.txt +++ b/library/src/CMakeLists.txt @@ -1,5 +1,6 @@ add_subdirectory(core) +add_subdirectory(ext) target_sources(juke-lib PRIVATE juke.cpp diff --git a/library/src/core/XMFile.cpp b/library/src/core/XMFile.cpp index 1c6a289..d5c7a41 100644 --- a/library/src/core/XMFile.cpp +++ b/library/src/core/XMFile.cpp @@ -4,4 +4,6 @@ namespace juke { XMFile::XMFile(std::filesystem::path const& path) : IMediaFile(path), m_stream(std::make_shared(path)) {} +void XMFile::write_to_wav() { m_stream->write_to_wav(); } + } // namespace juke diff --git a/library/src/core/XMStream.cpp b/library/src/core/XMStream.cpp index af1a76c..bb4224d 100644 --- a/library/src/core/XMStream.cpp +++ b/library/src/core/XMStream.cpp @@ -4,6 +4,7 @@ #include #if defined(JUKE_USE_LIBXMP) +#include #include #endif @@ -24,7 +25,7 @@ void XMStream::Deleter::operator()(Impl* ptr) const noexcept { std::default_delete{}(ptr); } -XMStream::XMStream(std::filesystem::path const& path) : m_impl(new Impl) { +XMStream::XMStream(std::filesystem::path const& path) : m_impl(new Impl), m_lowpass{sample_rate_v, sample_rate_v}, m_highpass{0.f, sample_rate_v}, m_path{path} { if (!m_impl->load_module(path)) { throw MediaError{"Failed to open XM file: " + path.generic_string()}; } xmp_start_player(m_impl->context, static_cast(capo::Buffer::sample_rate_v), 0); } @@ -38,13 +39,27 @@ void XMStream::push_samples(std::vector& out) { auto frame_info = xmp_frame_info{}; if (xmp_play_frame(m_impl->context) != 0) { return; } xmp_get_frame_info(m_impl->context, &frame_info); + + // calculate module progress + auto mi = xmp_module_info{}; + xmp_get_module_info(m_impl->context, &mi); + float total_seconds = mi.seq_data->duration / 1000.0f; + float current_seconds = frame_info.time / 1000.0f; + m_progress = current_seconds / total_seconds; + if (!m_impl->looping && frame_info.loop_count > 0) { return; } assert(frame_info.buffer_size % sizeof(std::int16_t) == 0); // wrap buffer into strongly-typed span for easy iteration and normalization. auto const src = std::span{static_cast(frame_info.buffer), static_cast(frame_info.buffer_size) / sizeof(std::int16_t)}; // need to normalize each u16 sample [-32k,32k] to f32 [-1,1]. + static constexpr auto sample_max_v = static_cast(std::numeric_limits::max()); - for (std::int16_t const sample : src) { out.push_back(static_cast(sample) / sample_max_v); } + for (std::int16_t const sample : src) { + auto normalized = static_cast(sample) / sample_max_v; + auto high_passed = m_highpass.process(normalized); + auto band_passed = m_lowpass.process(high_passed); + out.push_back(band_passed); + } } auto XMStream::set_looping(bool const looping) -> bool { @@ -52,6 +67,81 @@ auto XMStream::set_looping(bool const looping) -> bool { return true; } +void XMStream::set_cutoff(FilterType type, float to_cutoff, float sample_rate) { + switch (type) { + case FilterType::high: m_highpass.set_cutoff(to_cutoff, sample_rate); break; + case FilterType::low: m_lowpass.set_cutoff(to_cutoff, sample_rate); break; + default: break; + } +} + +void XMStream::write_to_wav() { + + std::filesystem::create_directories("soundtrack"); + auto output_path = std::filesystem::path("soundtrack") / (m_path.stem().string() + ".wav"); + + drwav_data_format format{}; + format.container = drwav_container_riff; + format.format = DR_WAVE_FORMAT_PCM; + format.channels = get_channels(); + format.sampleRate = get_sample_rate(); + format.bitsPerSample = 16; + + drwav wav{}; + if (!drwav_init_file_write(&wav, output_path.string().c_str(), &format, nullptr)) { return; } + + constexpr std::uint32_t fade_seconds = 10; + std::uint64_t const fade_samples = static_cast(format.sampleRate) * format.channels * fade_seconds; + + bool fading = false; + std::uint64_t fade_pos = 0; + + xmp_frame_info info{}; + + while (xmp_play_frame(m_impl->context) == 0) { + xmp_get_frame_info(m_impl->context, &info); + + // Start fading after the first loop. + if (!fading && info.loop_count > 0) { fading = true; } + + auto const* src = static_cast(info.buffer); + auto const sample_count = info.buffer_size / sizeof(std::int16_t); + + if (!fading) { + auto const frames = sample_count / format.channels; + drwav_write_pcm_frames(&wav, frames, src); + continue; + } + + // Apply fade to a temporary buffer. + std::vector faded(src, src + sample_count); + + std::size_t samples_to_write = sample_count; + + for (std::size_t i = 0; i < sample_count; ++i) { + if (fade_pos >= fade_samples) { + samples_to_write = i; + break; + } + + float t = static_cast(fade_pos) / static_cast(fade_samples); + + // Cosine fade (sounds smoother than linear). + float gain = std::cos(t * std::numbers::pi_v * 0.5f); + + faded[i] = static_cast(static_cast(faded[i]) * gain); + + ++fade_pos; + } + + if (samples_to_write > 0) { drwav_write_pcm_frames(&wav, samples_to_write / format.channels, faded.data()); } + + if (fade_pos >= fade_samples) { break; } + } + + drwav_uninit(&wav); +} + #else void XMStream::Deleter::operator()(Impl* /*ptr*/) const noexcept {} diff --git a/library/src/ext/CMakeLists.txt b/library/src/ext/CMakeLists.txt new file mode 100644 index 0000000..dafcaad --- /dev/null +++ b/library/src/ext/CMakeLists.txt @@ -0,0 +1,4 @@ + +target_sources(juke-lib PRIVATE + dr_wav.cpp +) diff --git a/library/src/ext/dr_wav.cpp b/library/src/ext/dr_wav.cpp new file mode 100644 index 0000000..ab0cd72 --- /dev/null +++ b/library/src/ext/dr_wav.cpp @@ -0,0 +1,2 @@ +#define DR_WAV_IMPLEMENTATION +#include diff --git a/library/src/juke.cpp b/library/src/juke.cpp index 7692bf9..9f6941c 100644 --- a/library/src/juke.cpp +++ b/library/src/juke.cpp @@ -48,4 +48,8 @@ void Jukebox::stop() { m_source->set_cursor({}); // seek to start. this will do nothing for XM streams } +void Jukebox::write_to_wav() { m_media_file->write_to_wav(); } + +void Jukebox::set_cutoff(FilterType type, float cutoff_hz, float sample_rate) { m_media_file->set_cutoff(type, cutoff_hz, sample_rate); } + } // namespace juke