commit 2bc03b0143b7e03534bcebf2aaab673cf226c189 Author: pszsh Date: Sat Jan 24 21:07:09 2026 -0800 Yr Crystals diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cc66a45 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +build/ +build_android/ +build_ios/ +build_macos/ +build_windows/ +icons +*.png \ No newline at end of file diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..01eab8a --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,216 @@ +# CMakeLists.txt + +cmake_minimum_required(VERSION 3.18) + +project(YrCrystals VERSION 1.0 LANGUAGES CXX C) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_AUTOMOC ON) +set(CMAKE_AUTORCC ON) +set(CMAKE_AUTOUIC ON) + +include(FetchContent) + +option(BUILD_ANDROID "Build for Android" OFF) +option(BUILD_IOS "Build for iOS" OFF) + +find_package(Qt6 REQUIRED COMPONENTS Core Gui Widgets Multimedia OpenGLWidgets) + +# --- FFTW3 Configuration --- + +if(APPLE AND CMAKE_SYSTEM_PROCESSOR STREQUAL "arm64" AND NOT BUILD_IOS) + message(STATUS "Detected Apple Silicon Desktop. Using Homebrew FFTW3F.") + find_library(FFTW3_LIB NAMES fftw3f libfftw3f PATHS /opt/homebrew/lib NO_DEFAULT_PATH) + find_path(FFTW3_INCLUDE_DIR fftw3.h PATHS /opt/homebrew/include NO_DEFAULT_PATH) + + if(NOT FFTW3_LIB OR NOT FFTW3_INCLUDE_DIR) + message(FATAL_ERROR "FFTW3F not found in /opt/homebrew. Please run: brew install fftw") + endif() + + add_library(fftw3 STATIC IMPORTED) + set_target_properties(fftw3 PROPERTIES + IMPORTED_LOCATION "${FFTW3_LIB}" + INTERFACE_INCLUDE_DIRECTORIES "${FFTW3_INCLUDE_DIR}" + ) + +else() + message(STATUS "Building FFTW3 from source...") + + set(ENABLE_FLOAT ON CACHE BOOL "Build single precision" FORCE) + set(ENABLE_SSE OFF CACHE BOOL "Disable SSE" FORCE) + set(ENABLE_SSE2 OFF CACHE BOOL "Disable SSE2" FORCE) + set(ENABLE_AVX OFF CACHE BOOL "Disable AVX" FORCE) + set(ENABLE_AVX2 OFF CACHE BOOL "Disable AVX2" FORCE) + set(ENABLE_THREADS OFF CACHE BOOL "Disable Threads" FORCE) + set(ENABLE_OPENMP OFF CACHE BOOL "Disable OpenMP" FORCE) + set(ENABLE_MPI OFF CACHE BOOL "Disable MPI" FORCE) + set(BUILD_SHARED_LIBS OFF CACHE BOOL "Build Static Libs" FORCE) + set(BUILD_TESTS OFF CACHE BOOL "Disable Tests" FORCE) + + if(ANDROID_ABI STREQUAL "arm64-v8a") + message(STATUS "Enabling NEON for Android ARM64") + set(ENABLE_NEON ON CACHE BOOL "Enable NEON" FORCE) + elseif(BUILD_IOS) + set(ENABLE_NEON ON CACHE BOOL "Enable NEON" FORCE) + endif() + + set(PATCH_CMD sed -i.bak "s/cmake_minimum_required.*/cmake_minimum_required(VERSION 3.16)/" /CMakeLists.txt) + + FetchContent_Declare( + fftw3_source + URL https://www.fftw.org/fftw-3.3.10.tar.gz + DOWNLOAD_EXTRACT_TIMESTAMP TRUE + PATCH_COMMAND ${PATCH_CMD} + ) + + FetchContent_MakeAvailable(fftw3_source) +endif() + +# --- Icon Generation --- + +set(ICON_SOURCE "${CMAKE_CURRENT_SOURCE_DIR}/assets/icon_source.png") +set(ICON_SCRIPT "${CMAKE_CURRENT_SOURCE_DIR}/scripts/generate_icons.sh") +set(MACOS_ICON "${CMAKE_CURRENT_SOURCE_DIR}/assets/icons/app_icon.icns") +set(WINDOWS_ICON "${CMAKE_CURRENT_SOURCE_DIR}/assets/icons/app_icon.ico") +set(IOS_ASSETS_PATH "${CMAKE_CURRENT_SOURCE_DIR}/assets/icons/ios/Assets.xcassets") +set(IOS_CONTENTS_JSON "${IOS_ASSETS_PATH}/Contents.json") + +# Find ImageMagick 'magick' executable +find_program(MAGICK_EXECUTABLE NAMES magick) +if(NOT MAGICK_EXECUTABLE) + message(WARNING "ImageMagick 'magick' not found. Icons will not be generated.") +endif() + +if(EXISTS "${ICON_SOURCE}" AND MAGICK_EXECUTABLE) + execute_process(COMMAND chmod +x "${ICON_SCRIPT}") + + add_custom_command( + OUTPUT "${MACOS_ICON}" "${WINDOWS_ICON}" "${IOS_CONTENTS_JSON}" + COMMAND "${ICON_SCRIPT}" "${MAGICK_EXECUTABLE}" + WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" + DEPENDS "${ICON_SOURCE}" "${ICON_SCRIPT}" + COMMENT "Generating icons from source using ${MAGICK_EXECUTABLE}..." + VERBATIM + ) + + add_custom_target(GenerateIcons DEPENDS "${MACOS_ICON}" "${WINDOWS_ICON}" "${IOS_CONTENTS_JSON}") +endif() + +# --- Sources --- + +set(PROJECT_SOURCES + src/main.cpp + src/Utils.cpp + src/Processor.cpp + src/AudioEngine.cpp + src/VisualizerWidget.cpp + src/CommonWidgets.cpp + src/PlayerControls.cpp + src/MainWindow.cpp +) + +if(EXISTS "${ICON_SOURCE}") + list(APPEND PROJECT_SOURCES ${MACOS_ICON} ${WINDOWS_ICON}) +endif() + +set(PROJECT_HEADERS + src/Utils.h + src/Processor.h + src/AudioEngine.h + src/VisualizerWidget.h + src/CommonWidgets.h + src/PlayerControls.h + src/MainWindow.h +) + +qt_add_executable(YrCrystals MANUAL_FINALIZATION ${PROJECT_SOURCES} ${PROJECT_HEADERS}) + +if(EXISTS "${ICON_SOURCE}" AND MAGICK_EXECUTABLE) + add_dependencies(YrCrystals GenerateIcons) +endif() + +# --- Mobile Definitions --- +if(BUILD_ANDROID OR BUILD_IOS) + target_compile_definitions(YrCrystals PRIVATE IS_MOBILE) +endif() + +# --- Linking --- + +if(TARGET fftw3f) + set(FFTW_TARGET fftw3f) + target_include_directories(YrCrystals PRIVATE + "${fftw3_source_SOURCE_DIR}/api" + "${fftw3_source_BINARY_DIR}" + ) +else() + set(FFTW_TARGET fftw3) +endif() + +target_link_libraries(YrCrystals PRIVATE + Qt6::Core Qt6::Gui Qt6::Widgets Qt6::Multimedia Qt6::OpenGLWidgets + ${FFTW_TARGET} +) + +if(BUILD_ANDROID) + target_link_libraries(YrCrystals PRIVATE log m) + set_property(TARGET YrCrystals PROPERTY QT_ANDROID_PACKAGE_SOURCE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/assets/icons/android") +endif() + +if(BUILD_IOS) + if(NOT EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/ios/Info.plist") + message(FATAL_ERROR "Missing ios/Info.plist. Please create it before building.") + endif() + + set_source_files_properties(src/Utils.cpp PROPERTIES LANGUAGE OBJCXX) + + set_target_properties(YrCrystals PROPERTIES + MACOSX_BUNDLE TRUE + MACOSX_BUNDLE_BUNDLE_NAME "Yr Crystals" + MACOSX_BUNDLE_GUI_IDENTIFIER "com.yrcrystals.app" + MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_SOURCE_DIR}/ios/Info.plist" + XCODE_ATTRIBUTE_ASSETCATALOG_COMPILER_APPICON_NAME "AppIcon" + ) + + if(EXISTS "${ICON_SOURCE}") + # Ensure directory exists at configure time so target_sources doesn't complain + file(MAKE_DIRECTORY "${IOS_ASSETS_PATH}") + target_sources(YrCrystals PRIVATE "${IOS_ASSETS_PATH}") + endif() + + qt_import_plugins(YrCrystals + EXCLUDE + Qt6::FFmpegMediaPlugin + Qt6::QFFmpegMediaPlugin + ) + + target_link_libraries(YrCrystals PRIVATE + "-framework AudioToolbox" + "-framework AVFoundation" + "-framework CoreMedia" + "-framework VideoToolbox" + "-framework CoreVideo" + "-framework CoreFoundation" + "-framework UIKit" + "-framework UniformTypeIdentifiers" + "-lz" + "-lbz2" + "-liconv" + ) +endif() + +if(APPLE AND NOT BUILD_IOS) + set_target_properties(YrCrystals PROPERTIES + MACOSX_BUNDLE TRUE + MACOSX_BUNDLE_BUNDLE_NAME "Yr Crystals" + MACOSX_BUNDLE_GUI_IDENTIFIER "com.yrcrystals.app" + MACOSX_BUNDLE_ICON_FILE "app_icon.icns" + RESOURCE "${MACOS_ICON}" + ) +elseif(WIN32) + set_target_properties(YrCrystals PROPERTIES + WIN32_EXECUTABLE TRUE + ) +endif() + +qt_finalize_executable(YrCrystals) \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d4ba34e --- /dev/null +++ b/Makefile @@ -0,0 +1,95 @@ +# Makefile + +QT_ANDROID_KIT ?= $(HOME)/Qt/6.8.3/android_arm64_v8a +QT_IOS_KIT ?= $(HOME)/Qt/6.8.3/ios +QT_MACOS_PATH ?= /opt/homebrew/opt/qt@6 +QT_WIN_PATH ?= C:/Qt/6.8.3/msvc2019_64 + +BUILD_DIR_MACOS = build_macos +BUILD_DIR_WIN = build_windows +BUILD_DIR_ANDROID = build_android +BUILD_DIR_IOS = build_ios +TARGET = YrCrystals + +# Android Specifics +PKG_NAME = org.qtproject.example.YrCrystals +# CRITICAL FIX: Qt6 generates 'android-build-debug.apk' by default +APK_PATH = $(BUILD_DIR_ANDROID)/android-build/build/outputs/apk/debug/android-build-debug.apk + +all: macos + +desktop: macos + +macos: + @echo "Building for macOS (Apple Silicon)..." + @mkdir -p $(BUILD_DIR_MACOS) + @cd $(BUILD_DIR_MACOS) && cmake .. \ + -DCMAKE_PREFIX_PATH="$(QT_MACOS_PATH);/opt/homebrew" \ + -DCMAKE_BUILD_TYPE=Release + @$(MAKE) -C $(BUILD_DIR_MACOS) + @echo "Build Complete. Run with: open $(BUILD_DIR_MACOS)/$(TARGET).app" + +windows: + @echo "Building for Windows..." + @mkdir -p $(BUILD_DIR_WIN) + @cd $(BUILD_DIR_WIN) && cmake .. \ + -DCMAKE_PREFIX_PATH="$(QT_WIN_PATH)" \ + -DCMAKE_BUILD_TYPE=Release + @cmake --build $(BUILD_DIR_WIN) + +android: + @if [ ! -d "$(QT_ANDROID_KIT)" ]; then echo "Error: QT_ANDROID_KIT not found at $(QT_ANDROID_KIT)"; exit 1; fi + @mkdir -p $(BUILD_DIR_ANDROID) + @cd $(BUILD_DIR_ANDROID) && cmake .. \ + -DCMAKE_TOOLCHAIN_FILE=$(QT_ANDROID_KIT)/lib/cmake/Qt6/qt.toolchain.cmake \ + -DQT_ANDROID_ABIS="arm64-v8a" \ + -DANDROID_PLATFORM=android-24 \ + -DQT_ANDROID_BUILD_ALL_ABIS=OFF \ + -DBUILD_ANDROID=ON \ + -DCMAKE_BUILD_TYPE=Debug + @cmake --build $(BUILD_DIR_ANDROID) --target apk + @echo "APK generated at $(APK_PATH)" + +ios: + @if [ ! -d "$(QT_IOS_KIT)" ]; then echo "Error: QT_IOS_KIT not found at $(QT_IOS_KIT)"; exit 1; fi + @mkdir -p $(BUILD_DIR_IOS) + @echo "Configuring iOS CMake..." + @cd $(BUILD_DIR_IOS) && cmake .. -G Xcode \ + -DCMAKE_TOOLCHAIN_FILE=$(QT_IOS_KIT)/lib/cmake/Qt6/qt.toolchain.cmake \ + -DCMAKE_SYSTEM_NAME=iOS \ + -DCMAKE_OSX_DEPLOYMENT_TARGET=16.0 \ + -DBUILD_IOS=ON + @echo "iOS Project generated at $(BUILD_DIR_IOS)/$(TARGET).xcodeproj" + + +run: + @open $(BUILD_DIR_MACOS)/$(TARGET).app + +# --- Android Deployment Wrappers --- + +install_android: android + @echo "Installing $(APK_PATH) to device..." + @adb install -r $(APK_PATH) + +run_android: install_android + @echo "Launching $(PKG_NAME)..." + @adb shell am start -n $(PKG_NAME)/org.qtproject.qt.android.bindings.QtActivity + +debug_android: run_android + @echo "Attaching Logcat (Ctrl+C to exit)..." + @adb logcat -v color -s YrCrystals Qt:* DEBUG + +# --- Cleaning --- + +clean: + @echo "Cleaning build artifacts..." + @if [ -f "$(BUILD_DIR_MACOS)/Makefile" ]; then cmake --build $(BUILD_DIR_MACOS) --target clean; fi + @if [ -f "$(BUILD_DIR_WIN)/Makefile" ] || [ -f "$(BUILD_DIR_WIN)/build.ninja" ]; then cmake --build $(BUILD_DIR_WIN) --target clean; fi + @if [ -f "$(BUILD_DIR_ANDROID)/Makefile" ] || [ -f "$(BUILD_DIR_ANDROID)/build.ninja" ]; then cmake --build $(BUILD_DIR_ANDROID) --target clean; fi + @if [ -d "$(BUILD_DIR_IOS)/$(TARGET).xcodeproj" ]; then cmake --build $(BUILD_DIR_IOS) --target clean; fi + +distclean: + @rm -rf $(BUILD_DIR_MACOS) $(BUILD_DIR_WIN) $(BUILD_DIR_ANDROID) $(BUILD_DIR_IOS) + @echo "Removed all build directories." + +.PHONY: all desktop macos windows android ios run install_android run_android debug_android clean distclean \ No newline at end of file diff --git a/android/AndroidManifest.xml b/android/AndroidManifest.xml new file mode 100644 index 0000000..26199f8 --- /dev/null +++ b/android/AndroidManifest.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ios/Info.plist b/ios/Info.plist new file mode 100644 index 0000000..644f189 --- /dev/null +++ b/ios/Info.plist @@ -0,0 +1,78 @@ + + + + + CFBundleDisplayName + Yr Crystals + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + UIRequiresFullScreen + + UILaunchStoryboardName + LaunchScreen + UIRequiredDeviceCapabilities + + arm64 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + + NSMicrophoneUsageDescription + This app requires audio access to visualize music. + NSAppleMusicUsageDescription + This app requires access to your music library to play tracks. + NSPhotoLibraryUsageDescription + This app requires access to the photo library to load album art. + NSCameraUsageDescription + This app requires camera access for visualizer input. + + + UIFileSharingEnabled + + LSSupportsOpeningDocumentsInPlace + + + + CFBundleDocumentTypes + + + CFBundleTypeName + Audio + LSHandlerRank + Alternate + LSItemContentTypes + + public.audio + public.mp3 + public.mpeg-4-audio + public.folder + public.directory + public.content + public.data + + + + + UIBackgroundModes + + audio + + + \ No newline at end of file diff --git a/scripts/generate_icons.sh b/scripts/generate_icons.sh new file mode 100755 index 0000000..a0b2f61 --- /dev/null +++ b/scripts/generate_icons.sh @@ -0,0 +1,135 @@ +# scripts/generate_icons.sh + +#!/bin/bash + +# Argument 1: Path to magick executable +MAGICK_BIN="$1" + +# Fallback if not provided +if [ -z "$MAGICK_BIN" ]; then + MAGICK_BIN="magick" +fi + +# Assumes running from Project Root +SOURCE="assets/icon_source.png" +OUT_DIR="assets/icons" + +if [ ! -f "$SOURCE" ]; then + echo "Error: Source image '$SOURCE' not found in $(pwd)" + exit 1 +fi + +# Verify magick works +"$MAGICK_BIN" -version >/dev/null 2>&1 +if [ $? -ne 0 ]; then + echo "Error: ImageMagick tool '$MAGICK_BIN' not found or not working." + exit 1 +fi + +mkdir -p "$OUT_DIR" + +# macOS +ICONSET="$OUT_DIR/icon.iconset" +mkdir -p "$ICONSET" + +"$MAGICK_BIN" "$SOURCE" -scale 16x16 "$ICONSET/icon_16x16.png" +"$MAGICK_BIN" "$SOURCE" -scale 32x32 "$ICONSET/icon_16x16@2x.png" +"$MAGICK_BIN" "$SOURCE" -scale 32x32 "$ICONSET/icon_32x32.png" +"$MAGICK_BIN" "$SOURCE" -scale 64x64 "$ICONSET/icon_32x32@2x.png" +"$MAGICK_BIN" "$SOURCE" -scale 128x128 "$ICONSET/icon_128x128.png" +"$MAGICK_BIN" "$SOURCE" -scale 256x256 "$ICONSET/icon_128x128@2x.png" +"$MAGICK_BIN" "$SOURCE" -scale 256x256 "$ICONSET/icon_256x256.png" +"$MAGICK_BIN" "$SOURCE" -scale 512x512 "$ICONSET/icon_256x256@2x.png" +"$MAGICK_BIN" "$SOURCE" -scale 512x512 "$ICONSET/icon_512x512.png" +"$MAGICK_BIN" "$SOURCE" -scale 1024x1024 "$ICONSET/icon_512x512@2x.png" + +iconutil -c icns "$ICONSET" -o "$OUT_DIR/app_icon.icns" +rm -rf "$ICONSET" + +# Windows +"$MAGICK_BIN" "$SOURCE" \ + \( -clone 0 -scale 256x256 \) \ + \( -clone 0 -scale 128x128 \) \ + \( -clone 0 -scale 64x64 \) \ + \( -clone 0 -scale 48x48 \) \ + \( -clone 0 -scale 32x32 \) \ + \( -clone 0 -scale 16x16 \) \ + -delete 0 -alpha off -colors 256 "$OUT_DIR/app_icon.ico" + +# Android +ANDROID_DIR="$OUT_DIR/android/res" + +mkdir -p "$ANDROID_DIR/mipmap-mdpi" +"$MAGICK_BIN" "$SOURCE" -scale 48x48 "$ANDROID_DIR/mipmap-mdpi/ic_launcher.png" + +mkdir -p "$ANDROID_DIR/mipmap-hdpi" +"$MAGICK_BIN" "$SOURCE" -scale 72x72 "$ANDROID_DIR/mipmap-hdpi/ic_launcher.png" + +mkdir -p "$ANDROID_DIR/mipmap-xhdpi" +"$MAGICK_BIN" "$SOURCE" -scale 96x96 "$ANDROID_DIR/mipmap-xhdpi/ic_launcher.png" + +mkdir -p "$ANDROID_DIR/mipmap-xxhdpi" +"$MAGICK_BIN" "$SOURCE" -scale 144x144 "$ANDROID_DIR/mipmap-xxhdpi/ic_launcher.png" + +mkdir -p "$ANDROID_DIR/mipmap-xxxhdpi" +"$MAGICK_BIN" "$SOURCE" -scale 192x192 "$ANDROID_DIR/mipmap-xxxhdpi/ic_launcher.png" + +# iOS +XCASSETS_DIR="$OUT_DIR/ios/Assets.xcassets" +IOS_DIR="$XCASSETS_DIR/AppIcon.appiconset" +mkdir -p "$IOS_DIR" + +cat > "$XCASSETS_DIR/Contents.json" < "$IOS_DIR/Contents.json" < +#include +#include +#include +#include +#include +#include +#include + +AudioEngine::AudioEngine(QObject* parent) : QObject(parent) { + m_processors.push_back(new Processor(m_frameSize, m_sampleRate)); + m_processors.push_back(new Processor(m_frameSize, m_sampleRate)); + + m_processTimer = new QTimer(this); + m_processTimer->setInterval(16); + connect(m_processTimer, &QTimer::timeout, this, &AudioEngine::onProcessTimer); +} + +AudioEngine::~AudioEngine() { + stop(); + for(auto p : m_processors) delete p; + if (m_fileSource) delete m_fileSource; +} + +void AudioEngine::setNumBins(int n) { + for(auto p : m_processors) p->setNumBins(n); +} + +void AudioEngine::loadTrack(const QString& filePath) { + stop(); + m_pcmData.clear(); + m_buffer.close(); + + if (m_fileSource) { + m_fileSource->close(); + delete m_fileSource; + m_fileSource = nullptr; + } + + if (m_decoder) delete m_decoder; + m_decoder = new QAudioDecoder(this); + + QAudioFormat format; + format.setSampleRate(44100); + format.setChannelCount(2); + format.setSampleFormat(QAudioFormat::Int16); + m_decoder->setAudioFormat(format); + + connect(m_decoder, &QAudioDecoder::bufferReady, this, &AudioEngine::onBufferReady); + connect(m_decoder, &QAudioDecoder::finished, this, &AudioEngine::onFinished); + connect(m_decoder, QOverload::of(&QAudioDecoder::error), this, &AudioEngine::onError); + + qDebug() << "AudioEngine: Attempting to load" << filePath; + +#ifdef Q_OS_ANDROID + m_fileSource = new QFile(filePath); + if (m_fileSource->open(QIODevice::ReadOnly)) { + m_decoder->setSourceDevice(m_fileSource); + } else { + delete m_fileSource; + m_fileSource = nullptr; + // Fix: Handle content:// URIs correctly + if (filePath.startsWith("content://")) { + m_decoder->setSource(QUrl(filePath)); + } else { + m_decoder->setSource(QUrl::fromLocalFile(filePath)); + } + } +#else + m_decoder->setSource(QUrl::fromLocalFile(filePath)); +#endif + + m_decoder->start(); +} + +void AudioEngine::onError(QAudioDecoder::Error error) { + qWarning() << "AudioEngine: Decoder Error:" << error << m_decoder->errorString(); + emit trackLoaded(false); +} + +void AudioEngine::onBufferReady() { + QAudioBuffer buffer = m_decoder->read(); + if (!buffer.isValid()) return; + + // Fix: Explicit cast to int to avoid warning + const int frames = static_cast(buffer.frameCount()); + const int channels = buffer.format().channelCount(); + auto sampleType = buffer.format().sampleFormat(); + + if (sampleType == QAudioFormat::Int16) { + const int16_t* src = buffer.constData(); + if (!src) return; + + for (int i = 0; i < frames; ++i) { + float left = 0.0f; + float right = 0.0f; + + if (channels == 1) { + left = src[i] / 32768.0f; + right = left; + } else if (channels >= 2) { + left = src[i * channels] / 32768.0f; + right = src[i * channels + 1] / 32768.0f; + } + + m_pcmData.append(reinterpret_cast(&left), sizeof(float)); + m_pcmData.append(reinterpret_cast(&right), sizeof(float)); + } + } + else if (sampleType == QAudioFormat::Float) { + const float* src = buffer.constData(); + if (!src) return; + + for (int i = 0; i < frames; ++i) { + float left = 0.0f; + float right = 0.0f; + + if (channels == 1) { + left = src[i]; + right = left; + } else if (channels >= 2) { + left = src[i * channels]; + right = src[i * channels + 1]; + } + + m_pcmData.append(reinterpret_cast(&left), sizeof(float)); + m_pcmData.append(reinterpret_cast(&right), sizeof(float)); + } + } +} + +void AudioEngine::onFinished() { + if (m_pcmData.isEmpty()) { + emit trackLoaded(false); + return; + } + m_buffer.setData(m_pcmData); + if (!m_buffer.open(QIODevice::ReadOnly)) { + emit trackLoaded(false); + return; + } + emit trackLoaded(true); +} + +void AudioEngine::play() { + if (!m_buffer.isOpen() || m_pcmData.isEmpty()) return; + + if (m_sink) { + m_sink->resume(); + m_processTimer->start(); + return; + } + + QAudioFormat format; + format.setSampleRate(44100); + format.setChannelCount(2); + format.setSampleFormat(QAudioFormat::Float); + + QAudioDevice device = QMediaDevices::defaultAudioOutput(); + if (device.isNull()) { + qWarning() << "AudioEngine: No audio output device found."; + return; + } + + if (!device.isFormatSupported(format)) { + qWarning() << "AudioEngine: Float format not supported, using preferred format."; + format = device.preferredFormat(); + } + + m_sink = new QAudioSink(device, format, this); + + + connect(m_sink, &QAudioSink::stateChanged, this, [this](QAudio::State state){ + if (state == QAudio::IdleState && m_sink->error() == QAudio::NoError) { + if (m_buffer.bytesAvailable() == 0) { + m_processTimer->stop(); + emit playbackFinished(); + } + } + }); + + m_sink->start(&m_buffer); + m_processTimer->start(); +} + +void AudioEngine::pause() { + if (m_sink) m_sink->suspend(); + m_processTimer->stop(); +} + +void AudioEngine::stop() { + m_processTimer->stop(); + if (m_sink) { + m_sink->stop(); + delete m_sink; + m_sink = nullptr; + } +} + +void AudioEngine::seek(float position) { + if (m_pcmData.isEmpty()) return; + qint64 pos = position * m_pcmData.size(); + pos -= pos % 8; + if (m_buffer.isOpen()) m_buffer.seek(pos); +} + +void AudioEngine::setDspParams(int frameSize, int hopSize) { + m_frameSize = frameSize; + m_hopSize = hopSize; + for(auto p : m_processors) p->setFrameSize(frameSize); +} + +void AudioEngine::onProcessTimer() { + if (!m_buffer.isOpen()) return; + + qint64 currentPos = m_buffer.pos(); + emit positionChanged((float)currentPos / m_pcmData.size()); + + const float* samples = reinterpret_cast(m_pcmData.constData()); + qint64 sampleIdx = currentPos / sizeof(float); + qint64 totalSamples = m_pcmData.size() / sizeof(float); + + if (sampleIdx + m_frameSize * 2 >= totalSamples) return; + + std::vector ch0(m_frameSize), ch1(m_frameSize); + for (int i = 0; i < m_frameSize; ++i) { + ch0[i] = samples[sampleIdx + i*2]; + ch1[i] = samples[sampleIdx + i*2 + 1]; + } + + m_processors[0]->pushData(ch0); + m_processors[1]->pushData(ch1); + + std::vector results; + for (auto p : m_processors) { + auto spec = p->getSpectrum(); + results.push_back({spec.freqs, spec.db}); + } + emit spectrumReady(results); +} \ No newline at end of file diff --git a/src/AudioEngine.h b/src/AudioEngine.h new file mode 100644 index 0000000..7ac49f1 --- /dev/null +++ b/src/AudioEngine.h @@ -0,0 +1,59 @@ +// src/AudioEngine.h + +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include "Processor.h" + +class AudioEngine : public QObject { + Q_OBJECT +public: + AudioEngine(QObject* parent = nullptr); + ~AudioEngine(); + + struct FrameData { + std::vector freqs; + std::vector db; + }; + +public slots: + void loadTrack(const QString& filePath); + void play(); + void pause(); + void stop(); + void seek(float position); + void setDspParams(int frameSize, int hopSize); + void setNumBins(int n); + +signals: + void playbackFinished(); + void positionChanged(float pos); + void trackLoaded(bool success); + void spectrumReady(const std::vector& data); + +private slots: + void onBufferReady(); + void onFinished(); + void onError(QAudioDecoder::Error error); + void onProcessTimer(); + +private: + QAudioSink* m_sink = nullptr; + QBuffer m_buffer; + QByteArray m_pcmData; + + QAudioDecoder* m_decoder = nullptr; + QFile* m_fileSource = nullptr; + QTimer* m_processTimer = nullptr; + + std::vector m_processors; + int m_frameSize = 32768; + int m_hopSize = 1024; + int m_sampleRate = 44100; + int m_channels = 2; +}; \ No newline at end of file diff --git a/src/CommonWidgets.cpp b/src/CommonWidgets.cpp new file mode 100644 index 0000000..cb91f58 --- /dev/null +++ b/src/CommonWidgets.cpp @@ -0,0 +1,157 @@ +#include "CommonWidgets.h" +#include +#include +#include +#include +#include +#include +#include + +PlaylistItemWidget::PlaylistItemWidget(const Utils::Metadata& meta, QWidget* parent) + : QWidget(parent) +{ + QHBoxLayout* layout = new QHBoxLayout(this); + layout->setContentsMargins(5, 5, 5, 5); + layout->setSpacing(10); + + m_thumb = new QLabel(this); + m_thumb->setFixedSize(50, 50); + m_thumb->setStyleSheet("background-color: #333; border-radius: 4px;"); + m_thumb->setScaledContents(true); + if (!meta.art.isNull()) m_thumb->setPixmap(QPixmap::fromImage(meta.art)); + layout->addWidget(m_thumb); + + QVBoxLayout* textLayout = new QVBoxLayout(); + textLayout->setSpacing(2); + + m_title = new QLabel(meta.title, this); + m_title->setStyleSheet("color: white; font-weight: bold; font-size: 14px;"); + + m_artist = new QLabel(meta.artist, this); + m_artist->setStyleSheet("color: #aaa; font-size: 12px;"); + + textLayout->addWidget(m_title); + textLayout->addWidget(m_artist); + textLayout->addStretch(); + + layout->addLayout(textLayout); + layout->addStretch(); +} + +XYPad::XYPad(const QString& title, QWidget* parent) : QWidget(parent), m_title(title) { + setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); + setMinimumHeight(150); + setCursor(Qt::CrossCursor); +} + +void XYPad::setFormatter(std::function formatter) { + m_formatter = formatter; +} + +void XYPad::setValues(float x, float y) { + m_x = std::clamp(x, 0.0f, 1.0f); + m_y = std::clamp(y, 0.0f, 1.0f); + update(); +} + +void XYPad::paintEvent(QPaintEvent*) { + QPainter p(this); + p.setRenderHint(QPainter::Antialiasing); + + p.fillRect(rect(), QColor(40, 40, 40, 200)); + p.setPen(QPen(QColor(80, 80, 80), 1)); + p.drawRect(rect().adjusted(0, 0, -1, -1)); + + p.setPen(QPen(QColor(60, 60, 60), 1, Qt::DotLine)); + p.drawLine(width()/2, 0, width()/2, height()); + p.drawLine(0, height()/2, width(), height()/2); + + int px = m_x * width(); + int py = (1.0f - m_y) * height(); + + p.setPen(Qt::NoPen); + p.setBrush(QColor(0, 212, 255, 180)); + p.drawEllipse(QPoint(px, py), 12, 12); + p.setBrush(Qt::white); + p.drawEllipse(QPoint(px, py), 4, 4); + + p.setPen(QPen(QColor(0, 212, 255, 100), 1)); + p.drawLine(px, 0, px, height()); + p.drawLine(0, py, width(), py); + + p.setPen(Qt::white); + QFont f = font(); + f.setBold(true); + f.setPointSize(10); + p.setFont(f); + + QString text = m_title; + if (m_formatter) { + text += "\n" + m_formatter(m_x, m_y); + } + p.drawText(rect().adjusted(10, 10, -10, -10), Qt::AlignLeft | Qt::AlignTop, text); +} + +void XYPad::mousePressEvent(QMouseEvent* event) { updateFromPos(event->pos()); } +void XYPad::mouseMoveEvent(QMouseEvent* event) { updateFromPos(event->pos()); } + +void XYPad::updateFromPos(const QPoint& pos) { + m_x = std::clamp((float)pos.x() / width(), 0.0f, 1.0f); + m_y = std::clamp(1.0f - (float)pos.y() / height(), 0.0f, 1.0f); + update(); + emit valuesChanged(m_x, m_y); +} + +OverlayWidget::OverlayWidget(QWidget* content, QWidget* parent) : QWidget(parent), m_content(content) { + QPalette pal = palette(); + pal.setColor(QPalette::Window, QColor(0, 0, 0, 100)); + setAutoFillBackground(true); + setPalette(pal); + + QVBoxLayout* layout = new QVBoxLayout(this); + layout->setAlignment(Qt::AlignCenter); + layout->setContentsMargins(20, 20, 20, 20); + + content->setParent(this); + content->setMaximumWidth(500); + content->setMaximumHeight(600); + + layout->addWidget(content); + hide(); +} + +void OverlayWidget::mousePressEvent(QMouseEvent* event) { + if (!m_content->geometry().contains(event->pos())) { + hide(); + } +} + +void OverlayWidget::paintEvent(QPaintEvent* event) { + QPainter p(this); + p.fillRect(rect(), QColor(0, 0, 0, 100)); +} + +WelcomeWidget::WelcomeWidget(QWidget* parent) : QWidget(parent) { + QVBoxLayout* layout = new QVBoxLayout(this); + layout->setAlignment(Qt::AlignCenter); + layout->setSpacing(20); + + QLabel* title = new QLabel("Yr Crystals", this); + title->setStyleSheet("color: white; font-size: 32px; font-weight: bold;"); + title->setAlignment(Qt::AlignCenter); + layout->addWidget(title); + + QString btnStyle = "QPushButton { background-color: #333; color: white; border: 1px solid #555; border-radius: 8px; padding: 15px; font-size: 18px; } QPushButton:pressed { background-color: #555; }"; + + QPushButton* btnFile = new QPushButton("Open File", this); + btnFile->setStyleSheet(btnStyle); + btnFile->setFixedWidth(250); + connect(btnFile, &QPushButton::clicked, this, &WelcomeWidget::openFileClicked); + layout->addWidget(btnFile); + + QPushButton* btnFolder = new QPushButton("Open Folder", this); + btnFolder->setStyleSheet(btnStyle); + btnFolder->setFixedWidth(250); + connect(btnFolder, &QPushButton::clicked, this, &WelcomeWidget::openFolderClicked); + layout->addWidget(btnFolder); +} \ No newline at end of file diff --git a/src/CommonWidgets.h b/src/CommonWidgets.h new file mode 100644 index 0000000..2991cbe --- /dev/null +++ b/src/CommonWidgets.h @@ -0,0 +1,55 @@ +#pragma once +#include +#include +#include +#include "Utils.h" + +class PlaylistItemWidget : public QWidget { + Q_OBJECT +public: + PlaylistItemWidget(const Utils::Metadata& meta, QWidget* parent = nullptr); +private: + QLabel* m_thumb; + QLabel* m_title; + QLabel* m_artist; +}; + +class XYPad : public QWidget { + Q_OBJECT +public: + XYPad(const QString& title, QWidget* parent = nullptr); + void setFormatter(std::function formatter); + void setValues(float x, float y); +signals: + void valuesChanged(float x, float y); +protected: + void paintEvent(QPaintEvent* event) override; + void mousePressEvent(QMouseEvent* event) override; + void mouseMoveEvent(QMouseEvent* event) override; +private: + void updateFromPos(const QPoint& pos); + QString m_title; + float m_x = 0.5f; + float m_y = 0.5f; + std::function m_formatter; +}; + +class OverlayWidget : public QWidget { + Q_OBJECT +public: + OverlayWidget(QWidget* content, QWidget* parent = nullptr); +protected: + void mousePressEvent(QMouseEvent* event) override; + void paintEvent(QPaintEvent* event) override; +private: + QWidget* m_content; +}; + +class WelcomeWidget : public QWidget { + Q_OBJECT +public: + WelcomeWidget(QWidget* parent = nullptr); +signals: + void openFileClicked(); + void openFolderClicked(); +}; \ No newline at end of file diff --git a/src/MainWindow.cpp b/src/MainWindow.cpp new file mode 100644 index 0000000..f2724ef --- /dev/null +++ b/src/MainWindow.cpp @@ -0,0 +1,346 @@ +// src/MainWindow.cpp + +#include "MainWindow.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(Q_OS_ANDROID) || defined(Q_OS_IOS) +#ifndef IS_MOBILE +#define IS_MOBILE +#endif +#endif + +MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) { + setWindowTitle("Yr Crystals"); + resize(1280, 800); + + m_stack = new QStackedWidget(this); + setCentralWidget(m_stack); + + m_welcome = new WelcomeWidget(this); + connect(m_welcome, &WelcomeWidget::openFileClicked, this, &MainWindow::onOpenFile); + connect(m_welcome, &WelcomeWidget::openFolderClicked, this, &MainWindow::onOpenFolder); + m_stack->addWidget(m_welcome); + + initUi(); + + m_engine = new AudioEngine(); + QThread* audioThread = new QThread(this); + m_engine->moveToThread(audioThread); + + connect(audioThread, &QThread::finished, m_engine, &QObject::deleteLater); + connect(m_engine, &AudioEngine::playbackFinished, this, &MainWindow::onTrackFinished); + connect(m_engine, &AudioEngine::trackLoaded, this, &MainWindow::onTrackLoaded); + connect(m_engine, &AudioEngine::positionChanged, m_playerPage->playback(), &PlaybackWidget::updateSeek); + + connect(m_engine, &AudioEngine::spectrumReady, m_playerPage->visualizer(), &VisualizerWidget::updateData); + + audioThread->start(); +} + +MainWindow::~MainWindow() { + if (m_engine) { + QMetaObject::invokeMethod(m_engine, "stop", Qt::BlockingQueuedConnection); + m_engine->thread()->quit(); + m_engine->thread()->wait(); + } +} + +void MainWindow::initUi() { + m_playerPage = new PlayerPage(this); + + m_playlist = new QListWidget(); + m_playlist->setStyleSheet("QListWidget { background-color: #111; border: none; } QListWidget::item { border-bottom: 1px solid #222; padding: 5px; } QListWidget::item:selected { background-color: #333; }"); + m_playlist->setSelectionMode(QAbstractItemView::SingleSelection); + QScroller::grabGesture(m_playlist, QScroller::LeftMouseButtonGesture); + connect(m_playlist, &QListWidget::itemDoubleClicked, this, &MainWindow::onTrackDoubleClicked); + + PlaybackWidget* pb = m_playerPage->playback(); + SettingsWidget* set = m_playerPage->settings(); + VisualizerWidget* viz = m_playerPage->visualizer(); + + connect(pb, &PlaybackWidget::playClicked, this, &MainWindow::play); + connect(pb, &PlaybackWidget::pauseClicked, this, &MainWindow::pause); + connect(pb, &PlaybackWidget::nextClicked, this, &MainWindow::nextTrack); + connect(pb, &PlaybackWidget::prevClicked, this, &MainWindow::prevTrack); + connect(pb, &PlaybackWidget::seekChanged, this, &MainWindow::seek); + + connect(set, &SettingsWidget::paramsChanged, viz, &VisualizerWidget::setParams); + connect(set, &SettingsWidget::dspParamsChanged, this, &MainWindow::onDspChanged); + connect(set, &SettingsWidget::binsChanged, this, &MainWindow::onBinsChanged); + + connect(set, &SettingsWidget::paramsChanged, this, &MainWindow::saveSettings); + connect(set, &SettingsWidget::binsChanged, this, &MainWindow::saveSettings); + + connect(m_playerPage, &PlayerPage::toggleFullScreen, this, &MainWindow::onToggleFullScreen); + +#ifdef IS_MOBILE + m_mobileTabs = new QTabWidget(); + m_mobileTabs->setStyleSheet("QTabWidget::pane { border: 0; } QTabBar::tab { background: #222; color: white; padding: 15px; min-width: 100px; } QTabBar::tab:selected { background: #444; }"); + + m_mobileTabs->addTab(m_playerPage, "Visualizer"); + m_mobileTabs->addTab(m_playlist, "Playlist"); + + m_stack->addWidget(m_mobileTabs); +#else + m_dock = new QDockWidget("Playlist", this); + m_dock->setWidget(m_playlist); + addDockWidget(Qt::LeftDockWidgetArea, m_dock); + m_stack->addWidget(m_playerPage); +#endif +} + +void MainWindow::onToggleFullScreen() { + static bool isFs = false; + isFs = !isFs; + + m_playerPage->setFullScreen(isFs); + +#ifdef IS_MOBILE + if (m_mobileTabs) { + QTabBar* bar = m_mobileTabs->findChild(); + if (bar) bar->setVisible(!isFs); + } +#else + if (m_dock) m_dock->setVisible(!isFs); +#endif +} + +void MainWindow::onOpenFile() { + m_pendingAction = PendingAction::File; + QMetaObject::invokeMethod(this, "onPermissionsResult", Qt::QueuedConnection, Q_ARG(bool, true)); +} + +void MainWindow::onOpenFolder() { + m_pendingAction = PendingAction::Folder; + QMetaObject::invokeMethod(this, "onPermissionsResult", Qt::QueuedConnection, Q_ARG(bool, true)); +} + +void MainWindow::onPermissionsResult(bool granted) { + if (!granted) return; + +#ifdef Q_OS_IOS + auto callback = [this, recursive = (m_pendingAction == PendingAction::Folder)](QString path) { + if (!path.isEmpty()) loadPath(path, recursive); + }; + Utils::openIosPicker(m_pendingAction == PendingAction::Folder, callback); +#else + QString initialPath; + QString filter = "Audio (*.mp3 *.m4a *.wav *.flac *.ogg)"; + +#ifdef Q_OS_ANDROID + initialPath = QStandardPaths::writableLocation(QStandardPaths::MusicLocation); + if (initialPath.isEmpty()) initialPath = QStandardPaths::writableLocation(QStandardPaths::DocumentsLocation); +#else + initialPath = QStandardPaths::writableLocation(QStandardPaths::MusicLocation); +#endif + + QString path; + if (m_pendingAction == PendingAction::File) { + path = QFileDialog::getOpenFileName(nullptr, "Open Audio", initialPath, filter); + if (!path.isEmpty()) loadPath(path, false); + } else if (m_pendingAction == PendingAction::Folder) { + path = QFileDialog::getExistingDirectory(nullptr, "Open Folder", initialPath); + if (!path.isEmpty()) loadPath(path, true); + } +#endif + m_pendingAction = PendingAction::None; +} + +void MainWindow::loadPath(const QString& rawPath, bool recursive) { + QString path = rawPath; + QUrl url(rawPath); + + if (url.isValid() && url.isLocalFile()) { + path = url.toLocalFile(); + } else if (rawPath.startsWith("file://")) { + path = QUrl::fromPercentEncoding(rawPath.toUtf8()).mid(7); + } + + m_tracks.clear(); + m_playlist->clear(); + + QFileInfo info(path); + bool isDir = info.isDir(); + bool isFile = info.isFile(); + + if (!isDir && !isFile && QFile::exists(path)) { + if (path.endsWith(".mp3") || path.endsWith(".m4a") || path.endsWith(".wav") || path.endsWith(".flac") || path.endsWith(".ogg")) { + isFile = true; + } else { + isDir = true; + } + } + + if (isDir || path.startsWith("content://")) { + m_settingsDir = path; + QStringList files = Utils::scanDirectory(path, recursive); + for (const auto& f : files) m_tracks.append({f, Utils::getMetadata(f)}); + + std::sort(m_tracks.begin(), m_tracks.end(), [](const TrackInfo& a, const TrackInfo& b) { + if (a.meta.album != b.meta.album) return a.meta.album < b.meta.album; + if (a.meta.trackNumber != b.meta.trackNumber) return a.meta.trackNumber < b.meta.trackNumber; + return a.meta.title < b.meta.title; + }); + + for (const auto& t : m_tracks) { + QListWidgetItem* item = new QListWidgetItem(m_playlist); + item->setSizeHint(QSize(0, 70)); + m_playlist->addItem(item); + m_playlist->setItemWidget(item, new PlaylistItemWidget(t.meta)); + } + if (!m_tracks.isEmpty()) loadIndex(0); + + } else if (isFile) { + m_settingsDir = info.path(); + TrackInfo t = {path, Utils::getMetadata(path)}; + m_tracks.append(t); + QListWidgetItem* item = new QListWidgetItem(m_playlist); + item->setSizeHint(QSize(0, 70)); + m_playlist->addItem(item); + m_playlist->setItemWidget(item, new PlaylistItemWidget(t.meta)); + loadIndex(0); + } + + loadSettings(); + +#ifdef IS_MOBILE + m_stack->setCurrentWidget(m_mobileTabs); +#else + m_stack->setCurrentWidget(m_playerPage); +#endif +} + +void MainWindow::loadSettings() { + if (m_settingsDir.isEmpty()) return; + if (m_settingsDir.startsWith("content://")) return; + + QFile f(QDir(m_settingsDir).filePath(".yrcrystals.json")); + if (f.open(QIODevice::ReadOnly)) { + QJsonDocument doc = QJsonDocument::fromJson(f.readAll()); + QJsonObject root = doc.object(); + + bool glass = root["glass"].toBool(true); + bool focus = root["focus"].toBool(false); + bool trails = root["trails"].toBool(false); + bool albumColors = root["albumColors"].toBool(false); + bool shadow = root["shadow"].toBool(false); + bool mirrored = root["mirrored"].toBool(false); + int bins = root["bins"].toInt(26); + float brightness = root["brightness"].toDouble(1.0); + float entropy = root["entropy"].toDouble(1.0); + + m_playerPage->settings()->setParams(glass, focus, trails, albumColors, shadow, mirrored, bins, brightness, entropy); + } +} + +void MainWindow::saveSettings() { + if (m_settingsDir.isEmpty()) return; + if (m_settingsDir.startsWith("content://")) return; + + SettingsWidget* s = m_playerPage->settings(); + QJsonObject root; + root["glass"] = s->isGlass(); + root["focus"] = s->isFocus(); + root["trails"] = s->isTrails(); + root["albumColors"] = s->isAlbumColors(); + root["shadow"] = s->isShadow(); + root["mirrored"] = s->isMirrored(); + root["bins"] = s->getBins(); + root["brightness"] = s->getBrightness(); + root["entropy"] = s->getEntropy(); + + QFile f(QDir(m_settingsDir).filePath(".yrcrystals.json")); + if (f.open(QIODevice::WriteOnly)) { + f.write(QJsonDocument(root).toJson()); + } +} + +void MainWindow::loadIndex(int index) { + if (index < 0 || index >= m_tracks.size()) return; + m_currentIndex = index; + const auto& t = m_tracks[index]; + m_playlist->setCurrentRow(index); + + int bins = m_playerPage->settings()->findChild()->value(); + auto colors = Utils::extractAlbumColors(t.meta.art, bins); + std::vector stdColors; + for(const auto& c : colors) stdColors.push_back(c); + m_playerPage->visualizer()->setAlbumPalette(stdColors); + + QMetaObject::invokeMethod(m_engine, "loadTrack", Qt::QueuedConnection, Q_ARG(QString, t.path)); +} + +void MainWindow::onTrackLoaded(bool success) { + if (success) { + play(); + if (m_currentIndex >= 0) { + const auto& t = m_tracks[m_currentIndex]; + QString title = t.meta.title; + if (!t.meta.artist.isEmpty()) title += " - " + t.meta.artist; + setWindowTitle(title); + } + } else { + nextTrack(); + } +} + +void MainWindow::onTrackDoubleClicked(QListWidgetItem* item) { + loadIndex(m_playlist->row(item)); +} + +void MainWindow::play() { + QMetaObject::invokeMethod(m_engine, "play", Qt::QueuedConnection); + m_playerPage->playback()->setPlaying(true); +} +void MainWindow::pause() { + QMetaObject::invokeMethod(m_engine, "pause", Qt::QueuedConnection); + m_playerPage->playback()->setPlaying(false); +} +void MainWindow::nextTrack() { + int next = m_currentIndex + 1; + if (next >= static_cast(m_tracks.size())) next = 0; + loadIndex(next); +} +void MainWindow::prevTrack() { + int prev = m_currentIndex - 1; + if (prev < 0) prev = static_cast(m_tracks.size()) - 1; + loadIndex(prev); +} +void MainWindow::seek(float pos) { + QMetaObject::invokeMethod(m_engine, "seek", Qt::QueuedConnection, Q_ARG(float, pos)); +} +void MainWindow::onTrackFinished() { nextTrack(); } +void MainWindow::updateLoop() { +} +void MainWindow::onDspChanged(int fft, int hop) { + QMetaObject::invokeMethod(m_engine, "setDspParams", Qt::QueuedConnection, Q_ARG(int, fft), Q_ARG(int, hop)); +} +void MainWindow::closeEvent(QCloseEvent* event) { + event->accept(); +} + +void MainWindow::onBinsChanged(int n) { + QMetaObject::invokeMethod(m_engine, "setNumBins", Qt::QueuedConnection, Q_ARG(int, n)); + m_playerPage->visualizer()->setNumBins(n); + + if (m_currentIndex >= 0 && m_currentIndex < m_tracks.size()) { + const auto& t = m_tracks[m_currentIndex]; + auto colors = Utils::extractAlbumColors(t.meta.art, n); + std::vector stdColors; + for(const auto& c : colors) stdColors.push_back(c); + m_playerPage->visualizer()->setAlbumPalette(stdColors); + } +} \ No newline at end of file diff --git a/src/MainWindow.h b/src/MainWindow.h new file mode 100644 index 0000000..81a1e39 --- /dev/null +++ b/src/MainWindow.h @@ -0,0 +1,59 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include "AudioEngine.h" +#include "PlayerControls.h" +#include "CommonWidgets.h" + +class MainWindow : public QMainWindow { + Q_OBJECT +public: + MainWindow(QWidget* parent = nullptr); + ~MainWindow(); + void loadPath(const QString& path, bool recursive); +protected: + void closeEvent(QCloseEvent* event) override; +private slots: + void onOpenFile(); + void onOpenFolder(); + void onPermissionsResult(bool granted); + void updateLoop(); + void onTrackFinished(); + void onTrackLoaded(bool success); + void onTrackDoubleClicked(QListWidgetItem* item); + void play(); + void pause(); + void nextTrack(); + void prevTrack(); + void seek(float pos); + void onDspChanged(int fft, int hop); + void onBinsChanged(int n); + void onToggleFullScreen(); + void saveSettings(); +private: + void initUi(); + void loadIndex(int index); + void loadSettings(); + + QStackedWidget* m_stack; + WelcomeWidget* m_welcome; + PlayerPage* m_playerPage; + QDockWidget* m_dock; + QTabWidget* m_mobileTabs; + QListWidget* m_playlist; + AudioEngine* m_engine; + QTimer* m_timer; + struct TrackInfo { + QString path; + Utils::Metadata meta; + }; + QVector m_tracks; + int m_currentIndex = -1; + enum class PendingAction { None, File, Folder }; + PendingAction m_pendingAction = PendingAction::None; + QString m_settingsDir; +}; \ No newline at end of file diff --git a/src/PlayerControls.cpp b/src/PlayerControls.cpp new file mode 100644 index 0000000..eeffc04 --- /dev/null +++ b/src/PlayerControls.cpp @@ -0,0 +1,296 @@ +// src/PlayerControls.cpp + +#include "PlayerControls.h" +#include +#include +#include +#include + +PlaybackWidget::PlaybackWidget(QWidget* parent) : QWidget(parent) { + setStyleSheet("background-color: rgba(0, 0, 0, 150); border-top: 1px solid #444;"); + QVBoxLayout* mainLayout = new QVBoxLayout(this); + mainLayout->setContentsMargins(10, 5, 10, 10); + + m_seekSlider = new QSlider(Qt::Horizontal, this); + m_seekSlider->setRange(0, 1000); + m_seekSlider->setFixedHeight(30); + m_seekSlider->setStyleSheet( + "QSlider::handle:horizontal { background: white; width: 20px; margin: -8px 0; border-radius: 10px; }" + "QSlider::groove:horizontal { background: #444; height: 4px; }" + "QSlider::sub-page:horizontal { background: #00d4ff; }" + ); + connect(m_seekSlider, &QSlider::sliderPressed, this, &PlaybackWidget::onSeekPressed); + connect(m_seekSlider, &QSlider::sliderReleased, this, &PlaybackWidget::onSeekReleased); + mainLayout->addWidget(m_seekSlider); + + QHBoxLayout* rowLayout = new QHBoxLayout(); + QString btnStyle = "QPushButton { background: transparent; color: white; font-size: 24px; border: 1px solid #444; border-radius: 8px; padding: 10px 20px; } QPushButton:pressed { background: #333; }"; + + QPushButton* btnPrev = new QPushButton("<<", this); + btnPrev->setStyleSheet(btnStyle); + connect(btnPrev, &QPushButton::clicked, this, &PlaybackWidget::prevClicked); + + m_btnPlay = new QPushButton(">", this); + m_btnPlay->setStyleSheet(btnStyle); + connect(m_btnPlay, &QPushButton::clicked, this, &PlaybackWidget::onPlayToggle); + + QPushButton* btnNext = new QPushButton(">>", this); + btnNext->setStyleSheet(btnStyle); + connect(btnNext, &QPushButton::clicked, this, &PlaybackWidget::nextClicked); + + QPushButton* btnSettings = new QPushButton("⚙", this); + btnSettings->setStyleSheet("QPushButton { background: transparent; color: #aaa; font-size: 24px; border: none; padding: 10px; } QPushButton:pressed { color: white; }"); + connect(btnSettings, &QPushButton::clicked, this, &PlaybackWidget::settingsClicked); + + rowLayout->addWidget(btnPrev); + rowLayout->addSpacing(10); + rowLayout->addWidget(m_btnPlay); + rowLayout->addSpacing(10); + rowLayout->addWidget(btnNext); + rowLayout->addStretch(); + rowLayout->addWidget(btnSettings); + + mainLayout->addLayout(rowLayout); +} + +void PlaybackWidget::setPlaying(bool playing) { + m_isPlaying = playing; + m_btnPlay->setText(playing ? "||" : ">"); +} + +void PlaybackWidget::updateSeek(float pos) { + if (!m_seeking) m_seekSlider->setValue(static_cast(pos * 1000)); +} + +void PlaybackWidget::onSeekPressed() { m_seeking = true; } +void PlaybackWidget::onSeekReleased() { + m_seeking = false; + emit seekChanged(m_seekSlider->value() / 1000.0f); +} +void PlaybackWidget::onPlayToggle() { + if (m_isPlaying) emit pauseClicked(); else emit playClicked(); +} + +SettingsWidget::SettingsWidget(QWidget* parent) : QWidget(parent) { + setStyleSheet("background-color: rgba(30, 30, 30, 230); border-radius: 12px; border: 1px solid #666;"); + + QVBoxLayout* layout = new QVBoxLayout(this); + layout->setContentsMargins(15, 15, 15, 15); + layout->setSpacing(15); + + QHBoxLayout* header = new QHBoxLayout(); + QLabel* title = new QLabel("Settings", this); + title->setStyleSheet("color: white; font-size: 18px; font-weight: bold; border: none; background: transparent;"); + + QPushButton* btnClose = new QPushButton("✕", this); + btnClose->setFixedSize(30, 30); + btnClose->setStyleSheet("QPushButton { background: #444; color: white; border-radius: 15px; border: none; font-weight: bold; } QPushButton:pressed { background: #666; }"); + connect(btnClose, &QPushButton::clicked, this, &SettingsWidget::closeClicked); + + header->addWidget(title); + header->addStretch(); + header->addWidget(btnClose); + layout->addLayout(header); + + QGridLayout* grid = new QGridLayout(); + auto createCheck = [&](const QString& text, bool checked, int r, int c) { + QCheckBox* cb = new QCheckBox(text, this); + cb->setChecked(checked); + cb->setStyleSheet("QCheckBox { color: white; font-size: 14px; padding: 5px; border: none; background: transparent; } QCheckBox::indicator { width: 20px; height: 20px; }"); + connect(cb, &QCheckBox::toggled, this, &SettingsWidget::emitParams); + grid->addWidget(cb, r, c); + return cb; + }; + + // Defaults: Only Glass checked + m_checkGlass = createCheck("Glass", true, 0, 0); + m_checkFocus = createCheck("Focus", false, 0, 1); + m_checkTrails = createCheck("Trails", false, 1, 0); + m_checkAlbumColors = createCheck("Album Colors", false, 1, 1); + m_checkShadow = createCheck("Shadow", false, 2, 0); + m_checkMirrored = createCheck("Mirrored", false, 2, 1); + layout->addLayout(grid); + + // Bins Slider + QHBoxLayout* binsLayout = new QHBoxLayout(); + m_lblBins = new QLabel("Bins: 26", this); + m_lblBins->setStyleSheet("color: white; font-weight: bold; border: none; background: transparent; min-width: 80px;"); + + m_sliderBins = new QSlider(Qt::Horizontal, this); + m_sliderBins->setRange(10, 64); + m_sliderBins->setValue(26); + m_sliderBins->setStyleSheet("QSlider::handle:horizontal { background: #aaa; width: 24px; margin: -10px 0; border-radius: 12px; } QSlider::groove:horizontal { background: #444; height: 4px; }"); + connect(m_sliderBins, &QSlider::valueChanged, this, &SettingsWidget::onBinsChanged); + + binsLayout->addWidget(m_lblBins); + binsLayout->addWidget(m_sliderBins); + layout->addLayout(binsLayout); + + // Brightness Slider + QHBoxLayout* brightLayout = new QHBoxLayout(); + m_lblBrightness = new QLabel("Bright: 100%", this); + m_lblBrightness->setStyleSheet("color: white; font-weight: bold; border: none; background: transparent; min-width: 80px;"); + + m_sliderBrightness = new QSlider(Qt::Horizontal, this); + m_sliderBrightness->setRange(10, 200); // 10% to 200% + m_sliderBrightness->setValue(100); + m_sliderBrightness->setStyleSheet("QSlider::handle:horizontal { background: #aaa; width: 24px; margin: -10px 0; border-radius: 12px; } QSlider::groove:horizontal { background: #444; height: 4px; }"); + connect(m_sliderBrightness, &QSlider::valueChanged, this, &SettingsWidget::onBrightnessChanged); + + brightLayout->addWidget(m_lblBrightness); + brightLayout->addWidget(m_sliderBrightness); + layout->addLayout(brightLayout); + + // Entropy Slider + QHBoxLayout* entropyLayout = new QHBoxLayout(); + m_lblEntropy = new QLabel("Entropy: 1.0", this); + m_lblEntropy->setStyleSheet("color: white; font-weight: bold; border: none; background: transparent; min-width: 80px;"); + + m_sliderEntropy = new QSlider(Qt::Horizontal, this); + m_sliderEntropy->setRange(0, 300); // 0.0 to 3.0 + m_sliderEntropy->setValue(100); + m_sliderEntropy->setStyleSheet("QSlider::handle:horizontal { background: #aaa; width: 24px; margin: -10px 0; border-radius: 12px; } QSlider::groove:horizontal { background: #444; height: 4px; }"); + connect(m_sliderEntropy, &QSlider::valueChanged, this, &SettingsWidget::onEntropyChanged); + + entropyLayout->addWidget(m_lblEntropy); + entropyLayout->addWidget(m_sliderEntropy); + layout->addLayout(entropyLayout); + + QHBoxLayout* padsLayout = new QHBoxLayout(); + + m_padDsp = new XYPad("DSP", this); + m_padDsp->setFormatter([](float x, float y) { + int fft = std::pow(2, 13 + (int)(x * 4.0f + 0.5f)); + int hop = 64 + y * (8192 - 64); + return QString("FFT: %1\nHop: %2").arg(fft).arg(hop); + }); + + m_padDsp->setValues(0.5f, 0.118f); + connect(m_padDsp, &XYPad::valuesChanged, this, &SettingsWidget::onDspPadChanged); + padsLayout->addWidget(m_padDsp); + + m_padColor = new XYPad("Color", this); + m_padColor->setFormatter([](float x, float y) { + float hue = x * 2.0f; + float cont = 0.1f + y * 2.9f; + return QString("Hue: %1\nCont: %2").arg(hue, 0, 'f', 2).arg(cont, 0, 'f', 2); + }); + m_padColor->setValues(0.45f, (1.0f - 0.1f) / 2.9f); + connect(m_padColor, &XYPad::valuesChanged, this, &SettingsWidget::onColorPadChanged); + padsLayout->addWidget(m_padColor); + + layout->addLayout(padsLayout); +} + +void SettingsWidget::setParams(bool glass, bool focus, bool trails, bool albumColors, bool shadow, bool mirrored, int bins, float brightness, float entropy) { + bool oldState = blockSignals(true); + m_checkGlass->setChecked(glass); + m_checkFocus->setChecked(focus); + m_checkTrails->setChecked(trails); + m_checkAlbumColors->setChecked(albumColors); + m_checkShadow->setChecked(shadow); + m_checkMirrored->setChecked(mirrored); + m_sliderBins->setValue(bins); + m_lblBins->setText(QString("Bins: %1").arg(bins)); + + m_brightness = brightness; + int brightVal = static_cast(brightness * 100.0f); + m_sliderBrightness->setValue(brightVal); + m_lblBrightness->setText(QString("Bright: %1%").arg(brightVal)); + + m_entropy = entropy; + int entVal = static_cast(entropy * 100.0f); + m_sliderEntropy->setValue(entVal); + m_lblEntropy->setText(QString("Entropy: %1").arg(entropy, 0, 'f', 1)); + + blockSignals(oldState); + + emitParams(); + emit binsChanged(bins); +} + +void SettingsWidget::emitParams() { + emit paramsChanged( + m_checkGlass->isChecked(), + m_checkFocus->isChecked(), + m_checkTrails->isChecked(), + m_checkAlbumColors->isChecked(), + m_checkShadow->isChecked(), + m_checkMirrored->isChecked(), + m_hue, + m_contrast, + m_brightness, + m_entropy + ); +} + +void SettingsWidget::onDspPadChanged(float x, float y) { + int power = 13 + (int)(x * 4.0f + 0.5f); + m_fft = std::pow(2, power); + m_hop = 64 + y * (8192 - 64); + emit dspParamsChanged(m_fft, m_hop); +} + +void SettingsWidget::onColorPadChanged(float x, float y) { + m_hue = x * 2.0f; + m_contrast = 0.1f + y * 2.9f; + emitParams(); +} + +void SettingsWidget::onBinsChanged(int val) { + m_bins = val; + m_lblBins->setText(QString("Bins: %1").arg(val)); + emit binsChanged(val); +} + +void SettingsWidget::onBrightnessChanged(int val) { + m_brightness = val / 100.0f; + m_lblBrightness->setText(QString("Bright: %1%").arg(val)); + emitParams(); +} + +void SettingsWidget::onEntropyChanged(int val) { + m_entropy = val / 100.0f; + m_lblEntropy->setText(QString("Entropy: %1").arg(m_entropy, 0, 'f', 1)); + emitParams(); +} + +PlayerPage::PlayerPage(QWidget* parent) : QWidget(parent) { + m_visualizer = new VisualizerWidget(this); + m_playback = new PlaybackWidget(this); + m_settings = new SettingsWidget(); + m_overlay = new OverlayWidget(m_settings, this); + + connect(m_playback, &PlaybackWidget::settingsClicked, this, &PlayerPage::toggleOverlay); + connect(m_settings, &SettingsWidget::closeClicked, this, &PlayerPage::closeOverlay); + + connect(m_visualizer, &VisualizerWidget::tapDetected, this, &PlayerPage::toggleFullScreen); +} + +void PlayerPage::setFullScreen(bool fs) { + m_playback->setVisible(!fs); +} + +void PlayerPage::toggleOverlay() { + if (m_overlay->isVisible()) m_overlay->hide(); + else { + m_overlay->raise(); + m_overlay->show(); + } +} + +void PlayerPage::closeOverlay() { + m_overlay->hide(); +} + +void PlayerPage::resizeEvent(QResizeEvent* event) { + int w = event->size().width(); + int h = event->size().height(); + + m_visualizer->setGeometry(0, 0, w, h); + + int pbHeight = 120; + m_playback->setGeometry(0, h - pbHeight, w, pbHeight); + + m_overlay->setGeometry(0, 0, w, h); +} \ No newline at end of file diff --git a/src/PlayerControls.h b/src/PlayerControls.h new file mode 100644 index 0000000..470570b --- /dev/null +++ b/src/PlayerControls.h @@ -0,0 +1,109 @@ +// src/PlayerControls.h + +#pragma once +#include +#include +#include +#include +#include +#include "VisualizerWidget.h" +#include "CommonWidgets.h" + +class PlaybackWidget : public QWidget { + Q_OBJECT +public: + PlaybackWidget(QWidget* parent = nullptr); + void setPlaying(bool playing); + void updateSeek(float pos); +signals: + void playClicked(); + void pauseClicked(); + void nextClicked(); + void prevClicked(); + void seekChanged(float pos); + void settingsClicked(); +private slots: + void onSeekPressed(); + void onSeekReleased(); + void onPlayToggle(); +private: + QSlider* m_seekSlider; + bool m_seeking = false; + bool m_isPlaying = false; + QPushButton* m_btnPlay; +}; + +class SettingsWidget : public QWidget { + Q_OBJECT +public: + SettingsWidget(QWidget* parent = nullptr); + + bool isGlass() const { return m_checkGlass->isChecked(); } + bool isFocus() const { return m_checkFocus->isChecked(); } + bool isTrails() const { return m_checkTrails->isChecked(); } + bool isAlbumColors() const { return m_checkAlbumColors->isChecked(); } + bool isShadow() const { return m_checkShadow->isChecked(); } + bool isMirrored() const { return m_checkMirrored->isChecked(); } + int getBins() const { return m_sliderBins->value(); } + float getBrightness() const { return m_brightness; } + float getEntropy() const { return m_entropy; } + + void setParams(bool glass, bool focus, bool trails, bool albumColors, bool shadow, bool mirrored, int bins, float brightness, float entropy); + +signals: + void paramsChanged(bool glass, bool focus, bool trails, bool albumColors, bool shadow, bool mirrored, float hue, float contrast, float brightness, float entropy); + void dspParamsChanged(int fft, int hop); + void binsChanged(int n); + void closeClicked(); +private slots: + void emitParams(); + void onDspPadChanged(float x, float y); + void onColorPadChanged(float x, float y); + void onBinsChanged(int val); + void onBrightnessChanged(int val); + void onEntropyChanged(int val); +private: + QCheckBox* m_checkGlass; + QCheckBox* m_checkFocus; + QCheckBox* m_checkTrails; + QCheckBox* m_checkAlbumColors; + QCheckBox* m_checkShadow; + QCheckBox* m_checkMirrored; + XYPad* m_padDsp; + XYPad* m_padColor; + QSlider* m_sliderBins; + QLabel* m_lblBins; + QSlider* m_sliderBrightness; + QLabel* m_lblBrightness; + QSlider* m_sliderEntropy; + QLabel* m_lblEntropy; + float m_hue = 0.9f; + float m_contrast = 1.0f; + float m_brightness = 1.0f; + float m_entropy = 1.0f; + int m_fft = 32768; + int m_hop = 1024; + int m_bins = 26; +}; + +class PlayerPage : public QWidget { + Q_OBJECT +public: + PlayerPage(QWidget* parent = nullptr); + VisualizerWidget* visualizer() { return m_visualizer; } + PlaybackWidget* playback() { return m_playback; } + SettingsWidget* settings() { return m_settings; } + void setFullScreen(bool fs); +signals: + void toggleFullScreen(); +protected: + void resizeEvent(QResizeEvent* event) override; +private slots: + void toggleOverlay(); + void closeOverlay(); +private: + VisualizerWidget* m_visualizer; + PlaybackWidget* m_playback; + SettingsWidget* m_settings; + OverlayWidget* m_overlay; +}; \ No newline at end of file diff --git a/src/Processor.cpp b/src/Processor.cpp new file mode 100644 index 0000000..33aef44 --- /dev/null +++ b/src/Processor.cpp @@ -0,0 +1,154 @@ +// src/Processor.cpp + +#include "Processor.h" +#include +#include +#include + +const double PI = 3.14159265358979323846; + +Processor::Processor(int frameSize, int sampleRate) + : m_frameSize(0), m_sampleRate(sampleRate), + m_in(nullptr), m_out(nullptr), m_plan(nullptr) +{ + setFrameSize(frameSize); + setNumBins(26); +} + +Processor::~Processor() { + if (m_plan) fftwf_destroy_plan(m_plan); + if (m_in) fftwf_free(m_in); + if (m_out) fftwf_free(m_out); +} + +void Processor::setNumBins(int n) { + m_customBins.clear(); + m_freqsConst.clear(); + m_history.clear(); // Clear history on bin change to avoid size mismatch + + float minFreq = 40.0f; + float maxFreq = 11000.0f; + + for (int i = 0; i <= n; ++i) { + float f = minFreq * std::pow(maxFreq / minFreq, (float)i / n); + m_customBins.push_back(f); + } + + m_freqsConst.push_back(10.0f); + for (size_t i = 0; i < m_customBins.size() - 1; ++i) { + m_freqsConst.push_back((m_customBins[i] + m_customBins[i+1]) / 2.0f); + } +} + +void Processor::setFrameSize(int size) { + if (m_frameSize == size) return; + + if (m_plan) fftwf_destroy_plan(m_plan); + if (m_in) fftwf_free(m_in); + if (m_out) fftwf_free(m_out); + + m_frameSize = size; + + m_in = (float*)fftwf_malloc(sizeof(float) * m_frameSize); + m_out = (fftwf_complex*)fftwf_malloc(sizeof(fftwf_complex) * (m_frameSize / 2 + 1)); + m_plan = fftwf_plan_dft_r2c_1d(m_frameSize, m_in, m_out, FFTW_ESTIMATE); + + m_window.resize(m_frameSize); + // Blackman-Harris window for excellent side-lobe suppression (reduces spectral leakage/noise) + for (int i = 0; i < m_frameSize; ++i) { + float a0 = 0.35875f; + float a1 = 0.48829f; + float a2 = 0.14128f; + float a3 = 0.01168f; + m_window[i] = a0 - a1 * std::cos(2.0f * PI * i / (m_frameSize - 1)) + + a2 * std::cos(4.0f * PI * i / (m_frameSize - 1)) + - a3 * std::cos(6.0f * PI * i / (m_frameSize - 1)); + } + + m_buffer.assign(m_frameSize, 0.0f); + m_history.clear(); +} + +void Processor::pushData(const std::vector& data) { + if (data.size() == m_frameSize) { + std::copy(data.begin(), data.end(), m_buffer.begin()); + } else if (data.size() < m_frameSize) { + std::copy(m_buffer.begin() + data.size(), m_buffer.end(), m_buffer.begin()); + std::copy(data.begin(), data.end(), m_buffer.end() - data.size()); + } +} + +float Processor::getInterpolatedDb(const std::vector& freqs, const std::vector& db, float targetFreq) { + auto it = std::lower_bound(freqs.begin(), freqs.end(), targetFreq); + if (it == freqs.begin()) return db[0]; + if (it == freqs.end()) return db.back(); + + size_t idxUpper = std::distance(freqs.begin(), it); + size_t idxLower = idxUpper - 1; + + float f0 = freqs[idxLower]; + float f1 = freqs[idxUpper]; + float d0 = db[idxLower]; + float d1 = db[idxUpper]; + + float t = (targetFreq - f0) / (f1 - f0); + return d0 + t * (d1 - d0); +} + +Processor::Spectrum Processor::getSpectrum() { + // 1. Windowing + for (int i = 0; i < m_frameSize; ++i) { + m_in[i] = m_buffer[i] * m_window[i]; + } + + // 2. FFT + fftwf_execute(m_plan); + + // 3. Compute Magnitude (dB) + int bins = m_frameSize / 2 + 1; + std::vector freqsFull(bins); + std::vector dbFull(bins); + + for (int i = 0; i < bins; ++i) { + float re = m_out[i][0]; + float im = m_out[i][1]; + float mag = 2.0f * std::sqrt(re*re + im*im) / m_frameSize; + + dbFull[i] = 20.0f * std::log10(std::max(mag, 1e-12f)); + freqsFull[i] = i * (float)m_sampleRate / m_frameSize; + } + + // 4. Map to Custom Bins (Log Scale) + std::vector currentDb(m_freqsConst.size()); + for (size_t i = 0; i < m_freqsConst.size(); ++i) { + float val = getInterpolatedDb(freqsFull, dbFull, m_freqsConst[i]); + if (val < -100.0f) val = -100.0f; + currentDb[i] = val; + } + + // 5. Moving Average Filter + // CRITICAL CHANGE: Reduced smoothing to 1 (effectively off) to allow + // the VisualizerWidget to detect sharp transients (Flux) accurately. + // The Visualizer will handle its own aesthetic smoothing. + m_smoothingLength = 1; + + m_history.push_back(currentDb); + if (m_history.size() > m_smoothingLength) { + m_history.pop_front(); + } + + std::vector averagedDb(currentDb.size(), 0.0f); + if (!m_history.empty()) { + for (const auto& vec : m_history) { + for (size_t i = 0; i < vec.size(); ++i) { + averagedDb[i] += vec[i]; + } + } + float factor = 1.0f / m_history.size(); + for (float& v : averagedDb) { + v *= factor; + } + } + + return {m_freqsConst, averagedDb}; +} \ No newline at end of file diff --git a/src/Processor.h b/src/Processor.h new file mode 100644 index 0000000..e4c0871 --- /dev/null +++ b/src/Processor.h @@ -0,0 +1,45 @@ +// src/Processor.h + +#pragma once +#include +#include +#include + +class Processor { +public: + Processor(int frameSize, int sampleRate); + ~Processor(); + + void setFrameSize(int size); + void setNumBins(int n); + void pushData(const std::vector& data); + + struct Spectrum { + std::vector freqs; + std::vector db; + }; + + Spectrum getSpectrum(); + +private: + int m_frameSize; + int m_sampleRate; + + float* m_in; + fftwf_complex* m_out; + fftwf_plan m_plan; + std::vector m_window; + + // Buffer for the current audio frame + std::vector m_buffer; + + // Mapping & Smoothing + std::vector m_customBins; + std::vector m_freqsConst; + + // Moving Average History + std::deque> m_history; + size_t m_smoothingLength = 3; // Number of frames to average + + float getInterpolatedDb(const std::vector& freqs, const std::vector& db, float targetFreq); +}; \ No newline at end of file diff --git a/src/Utils.cpp b/src/Utils.cpp new file mode 100644 index 0000000..0e9939a --- /dev/null +++ b/src/Utils.cpp @@ -0,0 +1,413 @@ +// src/Utils.cpp + +#include "Utils.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef Q_OS_ANDROID +#include +#include +#include +#include + +// Helper to scan Android Content URIs (Tree) +void scanAndroidTree(const QJniObject& context, const QJniObject& treeUri, const QJniObject& parentDocId, QStringList& results, bool recursive) { + QJniEnvironment env; + + QJniObject contentResolver = context.callObjectMethod("getContentResolver", "()Landroid/content/ContentResolver;"); + if (env.checkAndClearExceptions()) return; + + QJniObject childrenUri = QJniObject::callStaticObjectMethod( + "android/provider/DocumentsContract", "buildChildDocumentsUriUsingTree", + "(Landroid/net/Uri;Ljava/lang/String;)Landroid/net/Uri;", + treeUri.object(), parentDocId.object() + ); + if (env.checkAndClearExceptions()) return; + + QJniObject cursor = contentResolver.callObjectMethod( + "query", + "(Landroid/net/Uri;[Ljava/lang/String;Ljava/lang/String;[Ljava/lang/String;Ljava/lang/String;)Landroid/database/Cursor;", + childrenUri.object(), + nullptr, nullptr, nullptr, nullptr + ); + + if (env.checkAndClearExceptions()) { + qWarning() << "JNI: SecurityException or other error querying children of" << parentDocId.toString(); + return; + } + + if (!cursor.isValid()) return; + + jint colDocId = cursor.callMethod("getColumnIndex", "(Ljava/lang/String;)I", QJniObject::fromString("document_id").object()); + jint colMime = cursor.callMethod("getColumnIndex", "(Ljava/lang/String;)I", QJniObject::fromString("mime_type").object()); + + while (cursor.callMethod("moveToNext")) { + if (env.checkAndClearExceptions()) break; + + QString mime = cursor.callObjectMethod("getString", "(I)Ljava/lang/String;", colMime).toString(); + QString docId = cursor.callObjectMethod("getString", "(I)Ljava/lang/String;", colDocId).toString(); + + if (mime == "vnd.android.document/directory") { + if (recursive) { + scanAndroidTree(context, treeUri, QJniObject::fromString(docId), results, true); + } + } else if (mime.startsWith("audio/") || mime == "application/ogg" || mime == "audio/x-wav") { + QJniObject fileUri = QJniObject::callStaticObjectMethod( + "android/provider/DocumentsContract", "buildDocumentUriUsingTree", + "(Landroid/net/Uri;Ljava/lang/String;)Landroid/net/Uri;", + treeUri.object(), QJniObject::fromString(docId).object() + ); + if (fileUri.isValid()) { + results << fileUri.toString(); + } + } + } + cursor.callMethod("close"); + env.checkAndClearExceptions(); +} + +Utils::Metadata getMetadataAndroid(const QString &path) { + Utils::Metadata meta; + meta.title = QFileInfo(path).fileName(); // Fallback + + QJniObject retriever("android/media/MediaMetadataRetriever"); + if (!retriever.isValid()) return meta; + + QJniObject context = QNativeInterface::QAndroidApplication::context(); + QJniEnvironment env; + + try { + if (path.startsWith("content://")) { + QJniObject uri = QJniObject::callStaticObjectMethod( + "android/net/Uri", + "parse", + "(Ljava/lang/String;)Landroid/net/Uri;", + QJniObject::fromString(path).object() + ); + + retriever.callMethod( + "setDataSource", + "(Landroid/content/Context;Landroid/net/Uri;)V", + context.object(), + uri.object() + ); + } else { + retriever.callMethod( + "setDataSource", + "(Ljava/lang/String;)V", + QJniObject::fromString(path).object() + ); + } + } catch (...) { + qWarning() << "JNI: Failed to set data source for" << path; + env.checkAndClearExceptions(); + return meta; + } + + if (env.checkAndClearExceptions()) return meta; + + auto extract = [&](int key) -> QString { + QJniObject val = retriever.callObjectMethod( + "extractMetadata", + "(I)Ljava/lang/String;", + key + ); + if (env.checkAndClearExceptions()) return QString(); + return val.isValid() ? val.toString() : QString(); + }; + + // METADATA_KEY_TITLE = 7 + QString t = extract(7); + if (!t.isEmpty()) meta.title = t; + + // METADATA_KEY_ARTIST = 2 + QString a = extract(2); + if (!a.isEmpty()) meta.artist = a; + + // METADATA_KEY_ALBUM = 1 + QString al = extract(1); + if (!al.isEmpty()) meta.album = al; + + // METADATA_KEY_CD_TRACK_NUMBER = 0 + QString tr = extract(0); + if (!tr.isEmpty()) meta.trackNumber = tr.split('/').first().toInt(); + + QJniObject artObj = retriever.callObjectMethod("getEmbeddedPicture", "()[B"); + if (!env.checkAndClearExceptions() && artObj.isValid()) { + jbyteArray jBa = artObj.object(); + if (jBa) { + int len = env->GetArrayLength(jBa); + QByteArray ba; + ba.resize(len); + env->GetByteArrayRegion(jBa, 0, len, reinterpret_cast(ba.data())); + meta.art.loadFromData(ba); + } + } + + retriever.callMethod("release"); + env.checkAndClearExceptions(); + return meta; +} +#endif + +#ifdef Q_OS_IOS +#include +#include +#include + +// Native iOS Metadata Extraction +Utils::Metadata getMetadataIOS(const QString &path) { + Utils::Metadata meta; + meta.title = QFileInfo(path).fileName(); + + NSURL *url = [NSURL fileURLWithPath:path.toNSString()]; + AVURLAsset *asset = [AVURLAsset URLAssetWithURL:url options:nil]; + + // Note: In a real app, we should load values asynchronously, but for this simple player + // we access commonMetadata directly which might block slightly or return cached data. + // For local files, it's usually fast enough. + NSArray *metadata = [asset commonMetadata]; + + for (AVMetadataItem *item in metadata) { + if (item.value == nil) continue; + + if ([item.commonKey isEqualToString:AVMetadataCommonKeyTitle]) { + meta.title = QString::fromNSString((NSString *)item.value); + } else if ([item.commonKey isEqualToString:AVMetadataCommonKeyArtist]) { + meta.artist = QString::fromNSString((NSString *)item.value); + } else if ([item.commonKey isEqualToString:AVMetadataCommonKeyAlbumName]) { + meta.album = QString::fromNSString((NSString *)item.value); + } else if ([item.commonKey isEqualToString:AVMetadataCommonKeyArtwork]) { + if ([item.value isKindOfClass:[NSData class]]) { + NSData *data = (NSData *)item.value; + meta.art.loadFromData(QByteArray::fromRawData((const char *)data.bytes, data.length)); + } + } + } + return meta; +} + +// Native iOS File Picker Delegate +@interface FilePickerDelegate : NSObject +@property (nonatomic, assign) std::function callback; +@property (nonatomic, assign) bool isFolder; +@end + +@implementation FilePickerDelegate +- (void)documentPicker:(UIDocumentPickerViewController *)controller didPickDocumentsAtURLs:(NSArray *)urls { + if (urls.count > 0) { + NSURL *url = urls.firstObject; + + // If it's a folder, we must start accessing the security scoped resource. + // We intentionally do NOT stop accessing it to ensure the app can read it later. + // The OS will clean up when the app terminates. + if (self.isFolder) { + [url startAccessingSecurityScopedResource]; + } + + if (self.callback) { + self.callback(QString::fromNSString(url.absoluteString)); + } + } +} +- (void)documentPickerWasCancelled:(UIDocumentPickerViewController *)controller { + // Do nothing +} +@end + +static FilePickerDelegate* g_pickerDelegate = nil; + +namespace Utils { + void openIosPicker(bool folder, std::function callback) { + if (!g_pickerDelegate) { + g_pickerDelegate = [[FilePickerDelegate alloc] init]; + } + g_pickerDelegate.callback = callback; + g_pickerDelegate.isFolder = folder; + + UIDocumentPickerViewController *picker = nil; + + // Use modern API (iOS 14+) + if (folder) { + // Open folder in place (asCopy: NO) + picker = [[UIDocumentPickerViewController alloc] initForOpeningContentTypes:@[UTTypeFolder] asCopy:NO]; + } else { + // Import file (asCopy: YES) - copies to app sandbox + picker = [[UIDocumentPickerViewController alloc] initForOpeningContentTypes:@[UTTypeAudio] asCopy:YES]; + } + + picker.delegate = g_pickerDelegate; + picker.allowsMultipleSelection = NO; + + // Find Root VC (Scene-aware) + UIViewController *root = nil; + for (UIScene *scene in UIApplication.sharedApplication.connectedScenes) { + if (scene.activationState == UISceneActivationStateForegroundActive && [scene isKindOfClass:[UIWindowScene class]]) { + root = ((UIWindowScene *)scene).windows.firstObject.rootViewController; + if (root) break; + } + } + + // Fallback + if (!root) { + #pragma clang diagnostic push + #pragma clang diagnostic ignored "-Wdeprecated-declarations" + root = [UIApplication sharedApplication].keyWindow.rootViewController; + #pragma clang diagnostic pop + } + + if (root) { + [root presentViewController:picker animated:YES completion:nil]; + } else { + qWarning() << "iOS: Could not find root view controller to present picker."; + } + } +} +#endif + +namespace Utils { + +bool checkDependencies() { +#if defined(Q_OS_ANDROID) || defined(Q_OS_IOS) + return true; +#else + QProcess p; + p.start("ffmpeg", {"-version"}); + return p.waitForFinished() && p.exitCode() == 0; +#endif +} + +QString convertToWav(const QString &inputPath) { +#if defined(Q_OS_ANDROID) || defined(Q_OS_IOS) + return inputPath; +#else + QString wavPath = inputPath + ".temp.wav"; + if (QFile::exists(wavPath)) QFile::remove(wavPath); + + QProcess p; + p.start("ffmpeg", {"-y", "-i", inputPath, "-vn", "-loglevel", "error", "-f", "wav", wavPath}); + if (p.waitForFinished() && p.exitCode() == 0) { + return wavPath; + } + return QString(); +#endif +} + +Metadata getMetadata(const QString &filePath) { +#ifdef Q_OS_ANDROID + return getMetadataAndroid(filePath); +#elif defined(Q_OS_IOS) + return getMetadataIOS(filePath); +#else + Metadata meta; + meta.title = QFileInfo(filePath).fileName(); + + QProcess p; + p.start("ffprobe", {"-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", filePath}); + if (p.waitForFinished()) { + QJsonDocument doc = QJsonDocument::fromJson(p.readAllStandardOutput()); + QJsonObject root = doc.object(); + QJsonObject format = root["format"].toObject(); + QJsonObject tags = format["tags"].toObject(); + + if (tags.contains("title")) meta.title = tags["title"].toString(); + if (tags.contains("artist")) meta.artist = tags["artist"].toString(); + if (tags.contains("album")) meta.album = tags["album"].toString(); + if (tags.contains("track")) { + meta.trackNumber = tags["track"].toString().split('/').first().toInt(); + } + } + + QProcess pArt; + pArt.start("ffmpeg", {"-y", "-i", filePath, "-an", "-vcodec", "png", "-f", "image2pipe", "-"}); + if (pArt.waitForFinished()) { + QByteArray data = pArt.readAllStandardOutput(); + if (!data.isEmpty()) meta.art.loadFromData(data); + } + return meta; +#endif +} + +QVector extractAlbumColors(const QImage &art, int numBins) { + QVector palette(numBins, QColor(127, 127, 127)); + if (art.isNull()) return palette; + + QImage scaled = art.scaled(numBins, 20, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); + for (int x = 0; x < numBins; ++x) { + float maxVibrancy = -1.0f; + QColor bestColor = QColor(127, 127, 127); + for (int y = 0; y < scaled.height(); ++y) { + QColor c = scaled.pixelColor(x, y); + float s = c.hsvSaturationF(); + float v = c.valueF(); + if (s * v > maxVibrancy) { + maxVibrancy = s * v; + bestColor = c; + } + } + palette[x] = bestColor; + } + return palette; +} + +QStringList scanDirectory(const QString &path, bool recursive) { +#ifdef Q_OS_ANDROID + if (path.startsWith("content://")) { + QStringList results; + QJniEnvironment env; + QJniObject uri = QJniObject::callStaticObjectMethod( + "android/net/Uri", "parse", "(Ljava/lang/String;)Landroid/net/Uri;", + QJniObject::fromString(path).object() + ); + if (!uri.isValid()) return results; + + QJniObject context = QNativeInterface::QAndroidApplication::context(); + QJniObject contentResolver = context.callObjectMethod("getContentResolver", "()Landroid/content/ContentResolver;"); + + // Try to take persistable permission (best effort) + contentResolver.callMethod( + "takePersistableUriPermission", + "(Landroid/net/Uri;I)V", + uri.object(), + 1 + ); + + if (env.checkAndClearExceptions()) { + qWarning() << "JNI: Failed to take persistable URI permission for" << path; + } + + QJniObject docId = QJniObject::callStaticObjectMethod( + "android/provider/DocumentsContract", "getTreeDocumentId", + "(Landroid/net/Uri;)Ljava/lang/String;", uri.object() + ); + + if (env.checkAndClearExceptions() || !docId.isValid()) { + qWarning() << "JNI: Failed to get Tree Document ID for" << path; + return results; + } + + scanAndroidTree(context, uri, docId, results, recursive); + return results; + } +#endif + + QStringList files; + QStringList filters = {"*.mp3", "*.m4a", "*.wav", "*.flac", "*.ogg", "*.aiff"}; + QDirIterator::IteratorFlag flag = recursive ? QDirIterator::Subdirectories : QDirIterator::NoIteratorFlags; + QDirIterator it(path, filters, QDir::Files, flag); + while (it.hasNext()) files << it.next(); + return files; +} + +void requestAndroidPermissions(std::function callback) { + callback(true); +} + +} \ No newline at end of file diff --git a/src/Utils.h b/src/Utils.h new file mode 100644 index 0000000..dabfd42 --- /dev/null +++ b/src/Utils.h @@ -0,0 +1,32 @@ +// src/Utils.h + +#pragma once +#include +#include +#include +#include +#include +#include + +namespace Utils { + bool checkDependencies(); + QString convertToWav(const QString &inputPath); + + struct Metadata { + QString title; + QString artist; + QString album; + int trackNumber = 0; + QImage art; + }; + + Metadata getMetadata(const QString &filePath); + QVector extractAlbumColors(const QImage &art, int numBins); + QStringList scanDirectory(const QString &path, bool recursive); + + void requestAndroidPermissions(std::function callback); + +#ifdef Q_OS_IOS + void openIosPicker(bool folder, std::function callback); +#endif +} \ No newline at end of file diff --git a/src/VisualizerWidget.cpp b/src/VisualizerWidget.cpp new file mode 100644 index 0000000..cb35c88 --- /dev/null +++ b/src/VisualizerWidget.cpp @@ -0,0 +1,358 @@ +// src/VisualizerWidget.cpp + +#include "VisualizerWidget.h" +#include +#include +#include +#include +#include +#include +#include +#include + +VisualizerWidget::VisualizerWidget(QWidget* parent) : QWidget(parent) { + setAttribute(Qt::WA_OpaquePaintEvent); + setNumBins(26); +} + +void VisualizerWidget::mouseReleaseEvent(QMouseEvent* event) { + if (event->button() == Qt::LeftButton) { + emit tapDetected(); + } + QWidget::mouseReleaseEvent(event); +} + +void VisualizerWidget::setNumBins(int n) { + m_customBins.clear(); + float minFreq = 40.0f; + float maxFreq = 11000.0f; + for (int i = 0; i <= n; ++i) { + float f = minFreq * std::pow(maxFreq / minFreq, (float)i / n); + m_customBins.push_back(f); + } + m_channels.clear(); +} + +void VisualizerWidget::setParams(bool glass, bool focus, bool trails, bool albumColors, bool shadow, bool mirrored, float hue, float contrast, float brightness, float entropy) { + m_glass = glass; + m_focus = focus; + m_trailsEnabled = trails; + m_useAlbumColors = albumColors; + m_shadowMode = shadow; + m_mirrored = mirrored; + m_hueFactor = hue; + m_contrast = contrast; + m_brightness = brightness; + m_entropyStrength = entropy; + update(); +} + +void VisualizerWidget::setAlbumPalette(const std::vector& palette) { + m_albumPalette.clear(); + int targetLen = static_cast(m_customBins.size()) - 1; + if (palette.empty()) return; + for (int i = 0; i < targetLen; ++i) { + int idx = (i * static_cast(palette.size() - 1)) / (targetLen - 1); + m_albumPalette.push_back(palette[idx]); + } +} + +float VisualizerWidget::getX(float freq) { + float logMin = std::log10(20.0f); + float logMax = std::log10(20000.0f); + if (freq <= 0) return 0; + return (std::log10(std::max(freq, 1e-9f)) - logMin) / (logMax - logMin); +} + +QColor VisualizerWidget::applyModifiers(QColor c) { + float h, s, l; + c.getHslF(&h, &s, &l); + s = std::clamp(s * m_hueFactor, 0.0f, 1.0f); + float v = c.valueF(); + v = std::clamp(v * (m_contrast * 0.5f + 0.5f), 0.0f, 1.0f); + return QColor::fromHsvF(c.hsvHueF(), s, v); +} + +float VisualizerWidget::calculateEntropy(const std::deque& history) { + if (history.size() < 2) return 0.0f; + float sum = std::accumulate(history.begin(), history.end(), 0.0f); + float mean = sum / history.size(); + float sqSum = 0.0f; + for (float v : history) sqSum += (v - mean) * (v - mean); + // Normalize: 10dB std dev is considered "Max Chaos" + return std::clamp(std::sqrt(sqSum / history.size()) / 10.0f, 0.0f, 1.0f); +} + +void VisualizerWidget::updateData(const std::vector& data) { + if (QApplication::applicationState() != Qt::ApplicationActive || !isVisible()) return; + m_data = data; + + if (m_channels.size() != data.size()) m_channels.resize(data.size()); + + for (size_t ch = 0; ch < data.size(); ++ch) { + const auto& db = data[ch].db; + size_t numBins = db.size(); + auto& bins = m_channels[ch].bins; + if (bins.size() != numBins) bins.resize(numBins); + + for (size_t i = 0; i < numBins; ++i) { + auto& bin = bins[i]; + float rawVal = db[i]; + + // 1. Update History & Calculate Entropy + bin.history.push_back(rawVal); + if (bin.history.size() > 15) bin.history.pop_front(); + + float entropy = calculateEntropy(bin.history); + float order = 1.0f - entropy; + + // 2. Calculate Responsiveness (The Physics Core) + float p = 3.0f * m_entropyStrength; + float responsiveness = 0.02f + (0.98f * std::pow(order, p)); + + // 3. Update Visual Bar Height (Slew Limiting) + bin.visualDb = (bin.visualDb * (1.0f - responsiveness)) + (rawVal * responsiveness); + + // 4. Trail Physics + bin.trailLife = std::max(bin.trailLife - 0.02f, 0.0f); + + float flux = rawVal - bin.lastRawDb; + bin.lastRawDb = rawVal; + + if (flux > 0) { + // Impact Multiplier: Boosts the "Kick" of ordered signals + float impactMultiplier = 1.0f + (3.0f * order * m_entropyStrength); + float jumpTarget = bin.visualDb + (flux * impactMultiplier); + + if (jumpTarget > bin.trailDb) { + bin.trailDb = jumpTarget; + bin.trailLife = 1.0f; + // Thickness based on order + bin.trailThickness = 1.0f + (order * 4.0f); + } + } + + if (bin.trailDb < bin.visualDb) bin.trailDb = bin.visualDb; + } + } + update(); +} + +void VisualizerWidget::paintEvent(QPaintEvent*) { + if (QApplication::applicationState() != Qt::ApplicationActive || !isVisible()) return; + + QPainter p(this); + p.fillRect(rect(), Qt::black); + p.setRenderHint(QPainter::Antialiasing); + + if (m_data.empty()) return; + + int w = width(); + int h = height(); + + if (m_mirrored) { + int hw = w / 2; + int hh = h / 2; + + // Top-Left + p.save(); + drawContent(p, hw, hh); + p.restore(); + + // Top-Right (Mirror X) + p.save(); + p.translate(w, 0); + p.scale(-1, 1); + drawContent(p, hw, hh); + p.restore(); + + // Bottom-Left (Mirror Y) + p.save(); + p.translate(0, h); + p.scale(1, -1); + drawContent(p, hw, hh); + p.restore(); + + // Bottom-Right (Mirror XY) + p.save(); + p.translate(w, h); + p.scale(-1, -1); + drawContent(p, hw, hh); + p.restore(); + } else { + drawContent(p, w, h); + } +} + +void VisualizerWidget::drawContent(QPainter& p, int w, int h) { + auto getScreenY = [&](float normY) { + float screenH = normY * h; + return m_shadowMode ? screenH : h - screenH; + }; + + // --- Unified Glass Color Logic --- + QColor unifiedColor = Qt::white; + if (m_glass && !m_data.empty()) { + size_t midIdx = m_data[0].freqs.size() / 2; + float frameMidFreq = (midIdx < m_data[0].freqs.size()) ? m_data[0].freqs[midIdx] : 1000.0f; + + float sumDb = 0; + for(float v : m_data[0].db) sumDb += v; + float frameMeanDb = m_data[0].db.empty() ? -80.0f : sumDb / m_data[0].db.size(); + + float logMin = std::log10(20.0f); + float logMax = std::log10(20000.0f); + float frameFreqNorm = (std::log10(std::max(frameMidFreq, 1e-9f)) - logMin) / (logMax - logMin); + + float frameAmpNorm = std::clamp((frameMeanDb + 80.0f) / 80.0f, 0.0f, 1.0f); + float frameAmpWeight = 1.0f / std::pow(frameFreqNorm + 1e-4f, 5.0f); + frameAmpWeight = std::clamp(frameAmpWeight * 2.0f, 0.5f, 6.0f); + + float frameHue = std::fmod(frameFreqNorm + frameAmpNorm * frameAmpWeight * m_hueFactor, 1.0f); + if (m_mirrored) frameHue = 1.0f - frameHue; // Invert hue for mirrored mode + if (frameHue < 0) frameHue += 1.0f; + + unifiedColor = QColor::fromHsvF(frameHue, 1.0, 1.0); + } + + // --- Draw Trails First (Behind) --- + if (m_trailsEnabled) { + for (size_t ch = 0; ch < m_channels.size(); ++ch) { + const auto& freqs = m_data[ch].freqs; + const auto& bins = m_channels[ch].bins; + if (bins.empty()) continue; + + float xOffset = (ch == 1 && m_data.size() > 1) ? 1.002f : 1.0f; + + for (size_t i = 0; i < freqs.size() - 1; ++i) { + const auto& b = bins[i]; + const auto& bNext = bins[i+1]; + + if (b.trailLife < 0.01f) continue; + + float saturation = 1.0f - std::sqrt(b.trailLife); + float alpha = b.trailLife * 0.6f; + + QColor c; + if (m_useAlbumColors && !m_albumPalette.empty()) { + int palIdx = i; + if (m_mirrored) palIdx = m_albumPalette.size() - 1 - i; + palIdx = std::clamp(palIdx, 0, (int)m_albumPalette.size() - 1); + + QColor base = m_albumPalette[palIdx]; + base = applyModifiers(base); + float h_val, s, v, a; + base.getHsvF(&h_val, &s, &v, &a); + c = QColor::fromHsvF(h_val, s * saturation, v, alpha); + } else { + float hue = (float)i / freqs.size(); + if (m_mirrored) hue = 1.0f - hue; + c = QColor::fromHsvF(hue, saturation, 1.0f, alpha); + } + + float x1 = getX(freqs[i] * xOffset) * w; + float x2 = getX(freqs[i+1] * xOffset) * w; + + float y1 = getScreenY((b.trailDb + 80.0f) / 80.0f); + float y2 = getScreenY((bNext.trailDb + 80.0f) / 80.0f); + + p.setPen(QPen(c, b.trailThickness)); + p.drawLine(QPointF(x1, y1), QPointF(x2, y2)); + } + } + } + + // --- Draw Bars (Trapezoids) --- + for (size_t ch = 0; ch < m_channels.size(); ++ch) { + const auto& freqs = m_data[ch].freqs; + const auto& bins = m_channels[ch].bins; + if (bins.empty()) continue; + + float xOffset = (ch == 1 && m_data.size() > 1) ? 1.005f : 1.0f; + + for (size_t i = 0; i < freqs.size() - 1; ++i) { + const auto& b = bins[i]; + const auto& bNext = bins[i+1]; + + QColor binColor; + if (m_useAlbumColors && !m_albumPalette.empty()) { + int palIdx = i; + if (m_mirrored) palIdx = m_albumPalette.size() - 1 - i; + palIdx = std::clamp(palIdx, 0, (int)m_albumPalette.size() - 1); + + binColor = m_albumPalette[palIdx]; + binColor = applyModifiers(binColor); + } else { + float hue = (float)i / (freqs.size() - 1); + if (m_mirrored) hue = 1.0f - hue; + binColor = QColor::fromHsvF(hue, 1.0f, 1.0f); + } + + // Use visualDb (Physics processed) + float meanDb = (b.visualDb + bNext.visualDb) / 2.0f; + float intensity = std::clamp((meanDb + 80.0f) / 80.0f, 0.0f, 1.0f); + + float h_val, s, v, a; + binColor.getHsvF(&h_val, &s, &v, &a); + float brightness = (0.4f + 0.6f * intensity) * m_brightness; + v = std::clamp(v * brightness, 0.0f, 1.0f); + QColor dynamicBinColor = QColor::fromHsvF(h_val, s, v); + + QColor fillColor, lineColor; + if (m_glass) { + float uh, us, uv, ua; + unifiedColor.getHsvF(&uh, &us, &uv, &ua); + float uBrightness = (0.4f + 0.6f * intensity) * m_brightness; + fillColor = QColor::fromHsvF(uh, us, std::clamp(uv * uBrightness, 0.0f, 1.0f)); + lineColor = dynamicBinColor; + } else { + fillColor = dynamicBinColor; + lineColor = dynamicBinColor; + } + + float alpha = 0.4f + (intensity - 0.5f) * m_contrast; + alpha = std::clamp(alpha, 0.0f, 1.0f); + fillColor.setAlphaF(alpha); + lineColor.setAlphaF(0.9f); + + if (ch == 1 && m_data.size() > 1) { + int r,g,b_val,a_val; + fillColor.getRgb(&r,&g,&b_val,&a_val); + fillColor.setRgb(std::max(0, r-40), std::max(0, g-40), std::min(255, b_val+40), a_val); + lineColor.getRgb(&r,&g,&b_val,&a_val); + lineColor.setRgb(std::max(0, r-40), std::max(0, g-40), std::min(255, b_val+40), a_val); + } + + float x1 = getX(freqs[i] * xOffset) * w; + float x2 = getX(freqs[i+1] * xOffset) * w; + + float barH1 = std::clamp((b.visualDb + 80.0f) / 80.0f * h, 0.0f, (float)h); + float barH2 = std::clamp((bNext.visualDb + 80.0f) / 80.0f * h, 0.0f, (float)h); + + float y1, y2, anchorY; + if (m_shadowMode) { + anchorY = 0; + y1 = barH1; + y2 = barH2; + } else { + anchorY = h; + y1 = h - barH1; + y2 = h - barH2; + } + + QPainterPath fillPath; + fillPath.moveTo(x1, anchorY); + fillPath.lineTo(x1, y1); + fillPath.lineTo(x2, y2); + fillPath.lineTo(x2, anchorY); + fillPath.closeSubpath(); + p.fillPath(fillPath, fillColor); + + p.setPen(QPen(lineColor, 1)); + p.drawLine(QPointF(x1, anchorY), QPointF(x1, y1)); + if (i == freqs.size() - 2) { + p.drawLine(QPointF(x2, anchorY), QPointF(x2, y2)); + } + } + } +} \ No newline at end of file diff --git a/src/VisualizerWidget.h b/src/VisualizerWidget.h new file mode 100644 index 0000000..5720d97 --- /dev/null +++ b/src/VisualizerWidget.h @@ -0,0 +1,64 @@ +// src/VisualizerWidget.h + +#pragma once +#include +#include +#include +#include +#include +#include "AudioEngine.h" + +class VisualizerWidget : public QWidget { + Q_OBJECT +public: + VisualizerWidget(QWidget* parent = nullptr); + void updateData(const std::vector& data); + void setParams(bool glass, bool focus, bool trails, bool albumColors, bool shadow, bool mirrored, float hue, float contrast, float brightness, float entropy); + void setAlbumPalette(const std::vector& palette); + void setNumBins(int n); + +signals: + void tapDetected(); + +protected: + void paintEvent(QPaintEvent* e) override; + void mouseReleaseEvent(QMouseEvent* event) override; + +private: + void drawContent(QPainter& p, int w, int h); + + struct BinState { + std::deque history; // For entropy calculation + float visualDb = -100.0f; // The dampened/processed value for display + float lastRawDb = -100.0f; // To calculate flux + + // Trail Physics + float trailDb = -100.0f; + float trailLife = 0.0f; + float trailThickness = 2.0f; + }; + + struct ChannelState { + std::vector bins; + }; + + std::vector m_data; + std::vector m_channels; + std::vector m_albumPalette; + std::vector m_customBins; + + bool m_glass = true; + bool m_focus = false; + bool m_trailsEnabled = false; + bool m_useAlbumColors = false; + bool m_shadowMode = false; + bool m_mirrored = false; + float m_hueFactor = 0.9f; + float m_contrast = 1.0f; + float m_brightness = 1.0f; + float m_entropyStrength = 1.0f; + + float getX(float freq); + QColor applyModifiers(QColor c); + float calculateEntropy(const std::deque& history); +}; \ No newline at end of file diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..be3eec0 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,61 @@ +#include +#include +#include "MainWindow.h" +#include "AudioEngine.h" + +#ifdef Q_OS_ANDROID +#include + +void androidMessageHandler(QtMsgType type, const QMessageLogContext &context, const QString &msg) +{ + android_LogPriority priority = ANDROID_LOG_DEBUG; + switch (type) { + case QtDebugMsg: priority = ANDROID_LOG_DEBUG; break; + case QtInfoMsg: priority = ANDROID_LOG_INFO; break; + case QtWarningMsg: priority = ANDROID_LOG_WARN; break; + case QtCriticalMsg: priority = ANDROID_LOG_ERROR; break; + case QtFatalMsg: priority = ANDROID_LOG_FATAL; break; + } + __android_log_print(priority, "YrCrystals", "%s", qPrintable(msg)); +} +#endif + +int main(int argc, char *argv[]) { +#ifdef Q_OS_ANDROID + qInstallMessageHandler(androidMessageHandler); +#endif + + QApplication app(argc, argv); + QApplication::setApplicationName("Yr Crystals"); + QApplication::setApplicationVersion("1.0"); + + qRegisterMetaType>("std::vector"); + + QPalette p = app.palette(); + p.setColor(QPalette::Window, Qt::black); + p.setColor(QPalette::WindowText, Qt::white); + app.setPalette(p); + + QCommandLineParser parser; + parser.setApplicationDescription("Yr Crystals Player"); + parser.addHelpOption(); + parser.addVersionOption(); + QCommandLineOption recursiveOption(QStringList() << "r" << "recursive", "Recursively scan directory."); + parser.addOption(recursiveOption); + parser.addPositionalArgument("source", "Audio file or directory to play."); + parser.process(app); + + QStringList args = parser.positionalArguments(); + QString inputPath; + if (!args.isEmpty()) inputPath = args.first(); + bool recursive = parser.isSet(recursiveOption); + + MainWindow w; + w.show(); + + if (!inputPath.isEmpty()) { + w.loadPath(inputPath, recursive); + } + + return app.exec(); +} \ No newline at end of file