diff --git a/deps/CMakeLists.txt b/deps/CMakeLists.txt index f3b277e..eb31c16 100644 --- a/deps/CMakeLists.txt +++ b/deps/CMakeLists.txt @@ -17,7 +17,7 @@ FetchContent_Declare(gvdi FetchContent_Declare( capo GIT_REPOSITORY https://github.com/capo-devs/capo-lite - GIT_TAG v2.0.1 + GIT_TAG v2.1.0 GIT_SHALLOW TRUE ) diff --git a/library/include/juke/core/AudioFile.hpp b/library/include/juke/core/AudioFile.hpp new file mode 100644 index 0000000..3fc9df1 --- /dev/null +++ b/library/include/juke/core/AudioFile.hpp @@ -0,0 +1,18 @@ +#pragma once + +#include +#include + +namespace juke { + +class AudioFile : public IMediaFile { + public: + explicit AudioFile(std::filesystem::path const& path); + + bool bind_to(capo::ISource& source) final { return source.bind_to(m_buffer); } + + private: + std::shared_ptr m_buffer{std::make_shared()}; +}; + +} // namespace juke diff --git a/library/include/juke/core/MediaError.hpp b/library/include/juke/core/MediaError.hpp new file mode 100644 index 0000000..9cd44e4 --- /dev/null +++ b/library/include/juke/core/MediaError.hpp @@ -0,0 +1,11 @@ +#pragma once + +#include + +namespace juke { + +struct MediaError : std::runtime_error { + using std::runtime_error::runtime_error; +}; + +} // namespace juke diff --git a/library/include/juke/core/MediaFile.hpp b/library/include/juke/core/MediaFile.hpp index f3bd9bd..6852a83 100644 --- a/library/include/juke/core/MediaFile.hpp +++ b/library/include/juke/core/MediaFile.hpp @@ -1,19 +1,20 @@ #pragma once -#include +#include #include namespace juke { -class MediaFile { +class IMediaFile : public capo::Polymorphic { public: - explicit MediaFile(std::filesystem::path const& path); - [[nodiscard]] capo::Buffer const& get_buffer() const { return m_buffer; } + explicit IMediaFile(std::filesystem::path const& path) : m_filename(path.filename().string()) {} + + virtual bool bind_to(capo::ISource& source) = 0; + [[nodiscard]] std::string const& get_filename() const { return m_filename; } private: - capo::Buffer m_buffer{}; std::string m_filename{}; }; diff --git a/library/include/juke/core/MediaPlayer.hpp b/library/include/juke/core/MediaPlayer.hpp index 466dc26..29b44f1 100644 --- a/library/include/juke/core/MediaPlayer.hpp +++ b/library/include/juke/core/MediaPlayer.hpp @@ -1,10 +1,11 @@ #pragma once -#include +#include #include +#include #include #include -#include +#include #include namespace juke { @@ -26,7 +27,7 @@ class MediaPlayer { MediaStatus m_status{MediaStatus::stopped}; [[maybe_unused]] bool m_trigger{}; - std::optional m_file{}; + std::unique_ptr m_media_file{}; std::unique_ptr m_source{}; std::string m_status_string{"stopped"}; diff --git a/library/include/juke/core/XMDecode.hpp b/library/include/juke/core/XMDecode.hpp deleted file mode 100644 index b9e7fe8..0000000 --- a/library/include/juke/core/XMDecode.hpp +++ /dev/null @@ -1,12 +0,0 @@ -#pragma once -#include -#include - -namespace juke::xm { - -/// \returns true iff file at path is valid XM and JUKE_USE_LIBXMP is defined. -[[nodiscard]] bool is_xm(std::filesystem::path const& path); -/// \returns Stereo samples at 48kHz. Empty vector on failure or if JUKE_USE_LIBXMP is not defined. -std::vector decode(std::filesystem::path const& xm_path); - -} // namespace juke::xm diff --git a/library/include/juke/core/XMFile.hpp b/library/include/juke/core/XMFile.hpp new file mode 100644 index 0000000..1dd1157 --- /dev/null +++ b/library/include/juke/core/XMFile.hpp @@ -0,0 +1,20 @@ +#pragma once + +#include +#include + +namespace juke { + +class XMFile : public IMediaFile { + public: + static bool is_xm(std::filesystem::path const& path) { return XMStream::is_xm(path); } + + explicit XMFile(std::filesystem::path const& path); + + bool bind_to(capo::ISource& source) final { return source.bind_to(m_stream); } + + private: + std::shared_ptr m_stream{}; +}; + +} // namespace juke diff --git a/library/include/juke/core/XMStream.hpp b/library/include/juke/core/XMStream.hpp new file mode 100644 index 0000000..b6e04a5 --- /dev/null +++ b/library/include/juke/core/XMStream.hpp @@ -0,0 +1,30 @@ +#pragma once + +#include +#include +#include +#include + +namespace juke { + +class XMStream : public capo::IStreamPipe { + public: + static bool is_xm(std::filesystem::path const& path); + + explicit XMStream(std::filesystem::path const& path); + + private: + struct Impl; + struct Deleter { + void operator()(Impl* ptr) const noexcept; + }; + + void push_samples(std::vector& out) final; + [[nodiscard]] auto get_sample_rate() const -> std::uint32_t final { return capo::Buffer::sample_rate_v; } + [[nodiscard]] auto get_channels() const -> std::uint8_t final { return 2; } + auto set_looping(bool looping) -> bool final; + + std::unique_ptr m_impl{}; +}; + +} // namespace juke diff --git a/library/src/core/AudioFile.cpp b/library/src/core/AudioFile.cpp new file mode 100644 index 0000000..d03b795 --- /dev/null +++ b/library/src/core/AudioFile.cpp @@ -0,0 +1,11 @@ +#include +#include + +namespace juke { + +AudioFile::AudioFile(std::filesystem::path const& path) : IMediaFile(path) { + auto const path_str = path.string(); + if (!m_buffer->decode_file(path_str.c_str())) { throw MediaError{"Failed to decode file: " + path_str}; } +} + +} // namespace juke diff --git a/library/src/core/CMakeLists.txt b/library/src/core/CMakeLists.txt index b94a4d9..9d7ba19 100644 --- a/library/src/core/CMakeLists.txt +++ b/library/src/core/CMakeLists.txt @@ -1,7 +1,8 @@ target_sources(juke PRIVATE Application.cpp - MediaFile.cpp + AudioFile.cpp MediaPlayer.cpp - XMDecode.cpp + XMFile.cpp + XMStream.cpp ) diff --git a/library/src/core/MediaFile.cpp b/library/src/core/MediaFile.cpp deleted file mode 100644 index 864db67..0000000 --- a/library/src/core/MediaFile.cpp +++ /dev/null @@ -1,20 +0,0 @@ - -#include -#include -#include - -namespace juke { - -MediaFile::MediaFile(std::filesystem::path const& path) { - auto const path_str = path.string(); - if (xm::is_xm(path)) { - auto pcm_frames = xm::decode(path); - if (pcm_frames.empty()) { throw std::runtime_error{"Failed to decode XM file: " + path_str}; } - m_buffer.set_frames(std::move(pcm_frames), 2); - } else if (!m_buffer.decode_file(path_str.c_str())) { - throw std::runtime_error{"Failed to decode file: " + path_str}; - } - m_filename = path.filename().string(); -} - -} // namespace juke diff --git a/library/src/core/MediaPlayer.cpp b/library/src/core/MediaPlayer.cpp index 2cb199e..cd16279 100644 --- a/library/src/core/MediaPlayer.cpp +++ b/library/src/core/MediaPlayer.cpp @@ -1,33 +1,40 @@ #include #include +#include +#include #include +#include #include -#include #include namespace juke { +using namespace std::chrono_literals; + MediaPlayer::MediaPlayer(capo::IEngine& audio_engine) : m_source(audio_engine.create_source()) { if (!m_source) { throw std::runtime_error{"Failed to create Audio Source"}; } } bool MediaPlayer::load_media(std::filesystem::path const& path) { try { - auto file = MediaFile(path); - m_source->stop(); // if file didn't throw, stop playback as m_file will be replaced - m_file = std::move(file); - } catch (std::exception const& e) { + auto media_file = std::unique_ptr{}; + if (XMFile::is_xm(path)) { + media_file = std::make_unique(path); + } else { + media_file = std::make_unique(path); + } + m_source->stop(); + if (!media_file->bind_to(*m_source)) { + std::println("ERROR: Failed to bind Source to PCM"); + return false; + } + m_media_file = std::move(media_file); + } catch (MediaError const& e) { // only MediaErrors are caught here, others are raised all the way to main. std::println("ERROR: {}", e.what()); return false; - } catch (...) { - std::println("ERROR: UNKNOWN"); - return false; - } - if (!m_source->bind_to(&m_file->get_buffer())) { - std::println("ERROR: Failed to bind Source to PCM"); - return false; } + std::println("Media file loaded: {}", path.string()); m_source->play(); @@ -40,10 +47,11 @@ void MediaPlayer::handle_input() { ImGuiWindowFlags flags = ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoMove; if (ImGui::Begin("Player", nullptr, flags)) { - /* Progress Bar */ - auto fraction = 0.f; - if (m_file) { fraction = m_source->get_cursor() / m_source->get_duration(); } - ImGui::ProgressBar(fraction); + if (auto const duration = m_source->get_duration(); duration > 0s) { + /* Progress Bar */ + auto const fraction = m_source->get_cursor() / duration; + ImGui::ProgressBar(fraction); + } /* Control Buttons */ ImGui::Separator(); @@ -62,10 +70,13 @@ void MediaPlayer::handle_input() { m_source->set_cursor({}); // seek to start m_status = MediaStatus::stopped; } + ImGui::SameLine(); + auto loop = m_source->is_looping(); + if (ImGui::Checkbox("loop", &loop)) { m_source->set_looping(loop); } ImGui::Separator(); ImGui::Text("File Name: "); ImGui::SameLine(); - m_file ? ImGui::TextColored(colors::blue, "%s", m_file->get_filename().c_str()) : ImGui::TextColored(colors::grey, ""); + m_media_file ? ImGui::TextColored(colors::blue, "%s", m_media_file->get_filename().c_str()) : ImGui::TextColored(colors::grey, ""); m_status_string.clear(); std::format_to(std::back_inserter(m_status_string), "{}", playing() ? "playing" : paused() ? "paused" : "stopped"); diff --git a/library/src/core/XMDecode.cpp b/library/src/core/XMDecode.cpp deleted file mode 100644 index 9db6d23..0000000 --- a/library/src/core/XMDecode.cpp +++ /dev/null @@ -1,77 +0,0 @@ -#include -#include -#include -#include -#include - -#if defined(JUKE_USE_LIBXMP) -#include -#endif - -// Such an approach keeps all the ugly ifdef logic in one file. - -namespace juke { - -bool xm::is_xm([[maybe_unused]] std::filesystem::path const& path) { -#if defined(JUKE_USE_LIBXMP) - auto info = xmp_test_info{}; - return xmp_test_module(path.string().c_str(), &info) == 0; -#else - return false; -#endif -} - -#if defined(JUKE_USE_LIBXMP) -namespace { - -struct Deleter { - void operator()(xmp_context context) const noexcept { - xmp_end_player(context); - xmp_release_module(context); - xmp_free_context(context); - } -}; - -// unique_ptr is used not for new/delete but to ensure custom deinitialization -// of the XM module: to end its player and release it at scope exit. -// the Deleter does not modify the passed context, it's safe to reuse. -using UniqueContext = std::unique_ptr; // xmp_context is char* - -[[nodiscard]] UniqueContext create_context() { return UniqueContext{xmp_create_context()}; } - -} // namespace -#endif - -std::vector xm::decode([[maybe_unused]] std::filesystem::path const& xm_path) { - auto ret = std::vector{}; - -#if defined(JUKE_USE_LIBXMP) - auto context = create_context(); - if (xmp_load_module(context.get(), xm_path.string().c_str()) != 0) { return {}; } - - // happy path begins (no more failures possible). - // there may still be bugs in the code which might trigger an exception. - // the RAII context takes care of freeing XMP allocations even if that happens. - xmp_start_player(context.get(), static_cast(capo::Buffer::sample_rate_v), 0); - - // since the total size is unknown, we cannot pre-reserve any meaningful capacity. - // relying on vector's exponential growth, and malloc's eventual full-page allocations, is acceptable here. - - auto frame_info = xmp_frame_info{}; // one XM frame contains numerous PCM frames. - while (xmp_play_frame(context.get()) == 0) { - xmp_get_frame_info(context.get(), &frame_info); - if (frame_info.loop_count > 0) { break; } // per docs. - // XM framebuffer contains 16-bit signed samples. - 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) { ret.push_back(static_cast(sample) / sample_max_v); } - } -#endif - - return ret; -} - -} // namespace juke diff --git a/library/src/core/XMFile.cpp b/library/src/core/XMFile.cpp new file mode 100644 index 0000000..1c6a289 --- /dev/null +++ b/library/src/core/XMFile.cpp @@ -0,0 +1,7 @@ +#include + +namespace juke { + +XMFile::XMFile(std::filesystem::path const& path) : IMediaFile(path), m_stream(std::make_shared(path)) {} + +} // namespace juke diff --git a/library/src/core/XMStream.cpp b/library/src/core/XMStream.cpp new file mode 100644 index 0000000..af1a76c --- /dev/null +++ b/library/src/core/XMStream.cpp @@ -0,0 +1,68 @@ +#include +#include +#include +#include + +#if defined(JUKE_USE_LIBXMP) +#include +#endif + +namespace juke { + +#if defined(JUKE_USE_LIBXMP) +struct XMStream::Impl { + [[nodiscard]] bool load_module(std::filesystem::path const& path) const { return xmp_load_module(context, path.string().c_str()) == 0; } + + xmp_context context{xmp_create_context()}; + bool looping{}; // if true, ignore xmp_frame_info::loop_count +}; + +void XMStream::Deleter::operator()(Impl* ptr) const noexcept { + xmp_end_player(ptr->context); + xmp_release_module(ptr->context); + xmp_free_context(ptr->context); + std::default_delete{}(ptr); +} + +XMStream::XMStream(std::filesystem::path const& path) : m_impl(new Impl) { + 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); +} + +bool XMStream::is_xm(std::filesystem::path const& path) { + auto info = xmp_test_info{}; + return xmp_test_module(path.string().c_str(), &info) == 0; +} + +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); + 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); } +} + +auto XMStream::set_looping(bool const looping) -> bool { + m_impl->looping = looping; + return true; +} + +#else + +void XMStream::Deleter::operator()(Impl* /*ptr*/) const noexcept {} + +XMStream::XMStream(std::filesystem::path const& /*path*/) { throw std::runtime_error{"JUKE_USE_LIBXMP not enabled"}; } + +bool XMStream::is_xm(std::filesystem::path const& /*path*/) { return false; } + +void XMStream::push_samples(std::vector& /*out*/) {} + +auto XMStream::set_looping(bool const /*looping*/) -> bool { return false; } + +#endif +} // namespace juke