From 4c6eb8dfe92999ab33338fd1ce9fc72346a533b8 Mon Sep 17 00:00:00 2001 From: pszsh Date: Fri, 27 Feb 2026 16:23:23 -0800 Subject: [PATCH] nice --- CMakeLists.txt | 20 +++ Makefile | 3 +- platform/macos/Info.plist.in | 44 ++++++ platform/macos/YrCrystals.entitlements | 8 + src/AudioEngine.cpp | 206 ++++++++++++++++++------- src/AudioEngine.h | 10 ++ src/PlayerControls.cpp | 19 +++ src/VisualizerWidget.cpp | 7 +- src/VisualizerWidget.h | 2 + 9 files changed, 256 insertions(+), 63 deletions(-) create mode 100644 platform/macos/Info.plist.in create mode 100644 platform/macos/YrCrystals.entitlements diff --git a/CMakeLists.txt b/CMakeLists.txt index 20f0404..38394f3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -181,6 +181,7 @@ set(PROJECT_SOURCES src/PlayerControls.cpp src/MainWindow.cpp src/complex_block.cpp + src/complex_frames.cpp src/trig_interpolation.cpp ) @@ -202,6 +203,7 @@ set(PROJECT_HEADERS src/PlayerControls.h src/MainWindow.h src/complex_block.h + src/complex_frames.h src/trig_interpolation.h ) @@ -316,8 +318,26 @@ if(APPLE AND NOT BUILD_IOS) MACOSX_BUNDLE_BUNDLE_NAME "Yr Crystals" MACOSX_BUNDLE_GUI_IDENTIFIER "com.yrcrystals.app" MACOSX_BUNDLE_ICON_FILE "app_icon.icns" + MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_SOURCE_DIR}/platform/macos/Info.plist.in" RESOURCE "${MACOS_ICON}" ) + + # --- macOS Code Signing --- + # Qt 6.9+ on macOS Sequoia requires audio entitlements for CoreAudio access. + # Set MACOS_SIGNING_IDENTITY to your identity (e.g. "Apple Development: Name (ID)") + # or leave empty to use ad-hoc signing. Run `security find-identity -v -p codesigning` + # to list available identities. + set(MACOS_ENTITLEMENTS "${CMAKE_CURRENT_SOURCE_DIR}/platform/macos/YrCrystals.entitlements") + if(NOT DEFINED MACOS_SIGNING_IDENTITY) + set(MACOS_SIGNING_IDENTITY "-") + endif() + add_custom_command(TARGET YrCrystals POST_BUILD + COMMAND codesign --force --sign "${MACOS_SIGNING_IDENTITY}" + --entitlements "${MACOS_ENTITLEMENTS}" + --deep "$" + COMMENT "Signing YrCrystals.app with audio entitlements..." + VERBATIM + ) elseif(WIN32) set_target_properties(YrCrystals PROPERTIES WIN32_EXECUTABLE TRUE diff --git a/Makefile b/Makefile index 13e9eab..242b143 100644 --- a/Makefile +++ b/Makefile @@ -25,7 +25,8 @@ macos: @mkdir -p $(BUILD_DIR_MACOS) @cd $(BUILD_DIR_MACOS) && cmake .. \ -DCMAKE_PREFIX_PATH="$(QT_MACOS_PATH);/opt/homebrew" \ - -DCMAKE_BUILD_TYPE=Release + -DCMAKE_BUILD_TYPE=Release \ + -DMACOS_SIGNING_IDENTITY="$(MACOS_SIGNING_IDENTITY)" @$(MAKE) -C $(BUILD_DIR_MACOS) @echo "Build Complete. Run with: open $(BUILD_DIR_MACOS)/$(TARGET).app" diff --git a/platform/macos/Info.plist.in b/platform/macos/Info.plist.in new file mode 100644 index 0000000..3c8f749 --- /dev/null +++ b/platform/macos/Info.plist.in @@ -0,0 +1,44 @@ + + + + + CFBundleInfoDictionaryVersion + 6.0 + CFBundlePackageType + APPL + + CFBundleName + @MACOSX_BUNDLE_BUNDLE_NAME@ + CFBundleIdentifier + @MACOSX_BUNDLE_GUI_IDENTIFIER@ + CFBundleExecutable + @MACOSX_BUNDLE_EXECUTABLE_NAME@ + + CFBundleVersion + @MACOSX_BUNDLE_BUNDLE_VERSION@ + CFBundleShortVersionString + @MACOSX_BUNDLE_SHORT_VERSION_STRING@ + + NSHumanReadableCopyright + @MACOSX_BUNDLE_COPYRIGHT@ + + CFBundleIconFile + @MACOSX_BUNDLE_ICON_FILE@ + + CFBundleDevelopmentRegion + en + CFBundleAllowMixedLocalizations + + + NSPrincipalClass + NSApplication + + NSSupportsAutomaticGraphicsSwitching + + NSHighResolutionCapable + + + NSMicrophoneUsageDescription + Audio playback requires access to your audio device. + + diff --git a/platform/macos/YrCrystals.entitlements b/platform/macos/YrCrystals.entitlements new file mode 100644 index 0000000..d459cb2 --- /dev/null +++ b/platform/macos/YrCrystals.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.device.audio-input + + + diff --git a/src/AudioEngine.cpp b/src/AudioEngine.cpp index 3eeccf5..6f72da8 100644 --- a/src/AudioEngine.cpp +++ b/src/AudioEngine.cpp @@ -312,6 +312,7 @@ void AudioEngine::onFinished() { } #endif +#ifndef IS_MOBILE // 2. Hilbert Transform — process one channel at a time to minimize // peak memory. FFTW uses 4 buffers per channel instead of 8. auto finalData = std::make_shared(); @@ -351,6 +352,7 @@ void AudioEngine::onFinished() { Qt::QueuedConnection, Q_ARG(std::shared_ptr, finalData)); } +#endif // !IS_MOBILE }); } @@ -388,9 +390,12 @@ void AudioEngine::play() { qDebug() << "AudioEngine: Final Output Format:" << format; 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_source.bytesAvailable() == 0) { + connect(m_sink, &QAudioSink::stateChanged, this, [this](QtAudio::State state) { + qDebug() << "AudioEngine: stateChanged ->" << state + << "error:" << m_sink->error() + << "bytesAvail:" << m_source.bytesAvailable(); + if (state == QtAudio::IdleState && m_sink->error() == QtAudio::NoError) { + if (m_source.bytesAvailable() == 0 && m_source.isAtEnd()) { m_playTimer->stop(); m_atomicPosition = 1.0; emit playbackFinished(); @@ -401,6 +406,8 @@ void AudioEngine::play() { m_source.enablePrebuffer(150); #endif m_sink->start(&m_source); + qDebug() << "AudioEngine: sink started, state:" << m_sink->state() + << "error:" << m_sink->error(); m_playTimer->start(); } @@ -413,6 +420,7 @@ void AudioEngine::pause() { void AudioEngine::stop() { m_playTimer->stop(); if (m_sink) { + m_sink->reset(); // immediate halt (Qt 6.9: stop() now drains synchronously) m_sink->stop(); delete m_sink; m_sink = nullptr; @@ -496,6 +504,9 @@ void AudioAnalyzer::setTrackData(std::shared_ptr data) { p->setSampleRate(m_data->sampleRate); for (auto p : m_deepProcessors) p->setSampleRate(m_data->sampleRate); +#ifdef IS_MOBILE + m_hilbertNeedsReset = true; +#endif } } @@ -504,6 +515,13 @@ void AudioAnalyzer::setAtomicPositionRef(std::atomic *posRef) { } void AudioAnalyzer::setDspParams(int frameSize, int hopSize) { +#ifdef IS_MOBILE + if (frameSize != m_hilbertFftSize || hopSize != m_hilbertHopSize) { + m_hilbertFftSize = frameSize; + m_hilbertHopSize = hopSize; + m_hilbertNeedsReset = true; + } +#endif m_frameSize = frameSize; m_hopSize = hopSize; for (auto p : m_processors) @@ -535,61 +553,7 @@ void AudioAnalyzer::setSmoothingParams(int granularity, int detail, p->setCepstralParams(granularity, detail, strength * 1.2f); } -void AudioAnalyzer::processLoop() { - if (!m_data || !m_data->valid || !m_posRef) - return; - - // 1. Poll Atomic Position (Non-blocking) - double pos = m_posRef->load(); - - // 2. Calculate Index — use complexData if available, else fallback to pcmData - bool useComplex = !m_data->complexData.empty(); - size_t totalSamples; - if (useComplex) { - totalSamples = m_data->complexData.size() / 2; - } else { - totalSamples = m_data->pcmData.size() / sizeof(float) / 2; - } - size_t sampleIdx = static_cast(pos * totalSamples); - - // Boundary check - if (sampleIdx + m_frameSize >= totalSamples) - return; - - // 3. Extract Data (Read-only from shared memory) - std::vector> ch0(m_frameSize), ch1(m_frameSize); - if (useComplex) { - for (int i = 0; i < m_frameSize; ++i) { - ch0[i] = m_data->complexData[(sampleIdx + i) * 2]; - ch1[i] = m_data->complexData[(sampleIdx + i) * 2 + 1]; - } - } else { - const float *raw = - reinterpret_cast(m_data->pcmData.constData()); - for (int i = 0; i < m_frameSize; ++i) { - ch0[i] = std::complex(raw[(sampleIdx + i) * 2], 0.0); - ch1[i] = std::complex(raw[(sampleIdx + i) * 2 + 1], 0.0); - } - } - - // 4. Push to Processors - m_processors[0]->pushData(ch0); - m_processors[1]->pushData(ch1); - - int transSize = std::max(64, m_frameSize / 4); - std::vector> tCh0(transSize), tCh1(transSize); - int offset = m_frameSize - transSize; - for (int i = 0; i < transSize; ++i) { - tCh0[i] = ch0[offset + i]; - tCh1[i] = ch1[offset + i]; - } - m_transientProcessors[0]->pushData(tCh0); - m_transientProcessors[1]->pushData(tCh1); - - m_deepProcessors[0]->pushData(ch0); - m_deepProcessors[1]->pushData(ch1); - - // 5. Compute Spectrum +void AudioAnalyzer::computeAndPublishSpectrum() { std::vector results; float compThreshold = -15.0f; float compRatio = 4.0f; @@ -610,13 +574,11 @@ void AudioAnalyzer::processLoop() { } } FrameData fd{specMain.freqs, specMain.db, primaryDb, {}}; - // Pass cepstrum from ch0 main processor only (mono is sufficient) if (i == 0) fd.cepstrum = std::move(specMain.cepstrum); results.push_back(std::move(fd)); } - // 6. Publish Result { QMutexLocker locker(&m_frameMutex); m_lastFrameDataVector = results; @@ -624,6 +586,130 @@ void AudioAnalyzer::processLoop() { emit spectrumAvailable(); } +void AudioAnalyzer::processLoop() { + if (!m_data || !m_data->valid || !m_posRef) + return; + + double pos = m_posRef->load(); + +#ifdef IS_MOBILE + // Mobile path: streaming RealtimeHilbert produces complex frames on-the-fly + size_t totalSamples = m_data->pcmData.size() / sizeof(float) / 2; + size_t sampleIdx = static_cast(pos * totalSamples); + + int hopSize = m_hilbertHopSize; + int fftSize = m_hilbertFftSize; + + if (sampleIdx + static_cast(hopSize) >= totalSamples) + return; + + // Seek detection: position jump > 2x hop_size triggers reinit + if (!m_hilbertNeedsReset) { + size_t delta = (sampleIdx > m_lastHilbertSamplePos) + ? sampleIdx - m_lastHilbertSamplePos + : m_lastHilbertSamplePos - sampleIdx; + if (delta > static_cast(2 * hopSize)) + m_hilbertNeedsReset = true; + } + + // Reset + warmup if needed (track change, seek, or DSP settings change) + if (m_hilbertNeedsReset) { + m_hilbert.reinit(fftSize); + m_hilbertNeedsReset = false; + + // Warmup: feed preceding audio to converge overlap-add history + int warmupBlocks = fftSize / hopSize; + size_t warmupStart = (sampleIdx >= static_cast(warmupBlocks * hopSize)) + ? sampleIdx - warmupBlocks * hopSize + : 0; + const float *raw = + reinterpret_cast(m_data->pcmData.constData()); + + for (int w = 0; w < warmupBlocks; ++w) { + size_t blockStart = warmupStart + w * hopSize; + if (blockStart + hopSize > totalSamples) + break; + + std::vector leftBlock(hopSize), rightBlock(hopSize); + for (int i = 0; i < hopSize; ++i) { + leftBlock[i] = static_cast(raw[(blockStart + i) * 2]); + rightBlock[i] = static_cast(raw[(blockStart + i) * 2 + 1]); + } + m_hilbert.process(leftBlock, rightBlock); // Discard warmup output + } + } + + m_lastHilbertSamplePos = sampleIdx; + + // Read hop_size raw PCM samples + const float *raw = + reinterpret_cast(m_data->pcmData.constData()); + std::vector leftBlock(hopSize), rightBlock(hopSize); + for (int i = 0; i < hopSize; ++i) { + leftBlock[i] = static_cast(raw[(sampleIdx + i) * 2]); + rightBlock[i] = static_cast(raw[(sampleIdx + i) * 2 + 1]); + } + + // Produce complex frames via RealtimeHilbert + auto [complexL, complexR] = m_hilbert.process(leftBlock, rightBlock); + + // Push to all processors (sliding buffer accumulates partial pushes) + m_processors[0]->pushData(complexL); + m_processors[1]->pushData(complexR); + m_transientProcessors[0]->pushData(complexL); + m_transientProcessors[1]->pushData(complexR); + m_deepProcessors[0]->pushData(complexL); + m_deepProcessors[1]->pushData(complexR); + +#else + // Desktop path: random-access read from precomputed complex/PCM data + bool useComplex = !m_data->complexData.empty(); + size_t totalSamples; + if (useComplex) { + totalSamples = m_data->complexData.size() / 2; + } else { + totalSamples = m_data->pcmData.size() / sizeof(float) / 2; + } + size_t sampleIdx = static_cast(pos * totalSamples); + + if (sampleIdx + m_frameSize >= totalSamples) + return; + + std::vector> ch0(m_frameSize), ch1(m_frameSize); + if (useComplex) { + for (int i = 0; i < m_frameSize; ++i) { + ch0[i] = m_data->complexData[(sampleIdx + i) * 2]; + ch1[i] = m_data->complexData[(sampleIdx + i) * 2 + 1]; + } + } else { + const float *raw = + reinterpret_cast(m_data->pcmData.constData()); + for (int i = 0; i < m_frameSize; ++i) { + ch0[i] = std::complex(raw[(sampleIdx + i) * 2], 0.0); + ch1[i] = std::complex(raw[(sampleIdx + i) * 2 + 1], 0.0); + } + } + + m_processors[0]->pushData(ch0); + m_processors[1]->pushData(ch1); + + int transSize = std::max(64, m_frameSize / 4); + std::vector> tCh0(transSize), tCh1(transSize); + int offset = m_frameSize - transSize; + for (int i = 0; i < transSize; ++i) { + tCh0[i] = ch0[offset + i]; + tCh1[i] = ch1[offset + i]; + } + m_transientProcessors[0]->pushData(tCh0); + m_transientProcessors[1]->pushData(tCh1); + + m_deepProcessors[0]->pushData(ch0); + m_deepProcessors[1]->pushData(ch1); +#endif + + computeAndPublishSpectrum(); +} + bool AudioAnalyzer::getLatestSpectrum(std::vector &out) { QMutexLocker locker(&m_frameMutex); if (m_lastFrameDataVector.empty()) diff --git a/src/AudioEngine.h b/src/AudioEngine.h index 8f0a602..9f23760 100644 --- a/src/AudioEngine.h +++ b/src/AudioEngine.h @@ -2,6 +2,7 @@ #pragma once #include "Processor.h" #include "complex_block.h" +#include "complex_frames.h" #include #include #include @@ -234,6 +235,8 @@ private slots: void processLoop(); private: + void computeAndPublishSpectrum(); + QTimer *m_timer = nullptr; std::atomic *m_posRef = nullptr; std::shared_ptr m_data; @@ -245,6 +248,13 @@ private: int m_frameSize = 4096; int m_hopSize = 1024; + // RealtimeHilbert for mobile (streaming complex frames) + RealtimeHilbert m_hilbert; + int m_hilbertFftSize = 8192; + int m_hilbertHopSize = 1024; + bool m_hilbertNeedsReset = true; + size_t m_lastHilbertSamplePos = 0; + // Output Buffer std::vector m_lastFrameDataVector; mutable QMutex m_frameMutex; diff --git a/src/PlayerControls.cpp b/src/PlayerControls.cpp index 4149735..17b2633 100644 --- a/src/PlayerControls.cpp +++ b/src/PlayerControls.cpp @@ -6,6 +6,12 @@ #include #include +#if defined(Q_OS_ANDROID) || defined(Q_OS_IOS) +#ifndef IS_MOBILE +#define IS_MOBILE +#endif +#endif + PlaybackWidget::PlaybackWidget(QWidget *parent) : QWidget(parent) { setStyleSheet( "background-color: rgba(0, 0, 0, 150); border-top: 1px solid #444;"); @@ -208,12 +214,22 @@ SettingsWidget::SettingsWidget(QWidget *parent) : QWidget(parent) { QHBoxLayout *padsLayout = new QHBoxLayout(); m_padDsp = new XYPad("DSP", this); +#ifdef IS_MOBILE + m_padDsp->setFormatter([](float x, float y) { + int power = 6 + (int)(x * 7.0f + 0.5f); + int window = std::pow(2, power); + int hop = 64 + y * (8192 - 64); + if (hop > window) hop = window; + return QString("Window: %1\nHop: %2").arg(window).arg(hop); + }); +#else m_padDsp->setFormatter([](float x, float y) { int power = 6 + (int)(x * 7.0f + 0.5f); int fft = std::pow(2, power); int hop = 64 + y * (8192 - 64); return QString("FFT: %1\nHop: %2").arg(fft).arg(hop); }); +#endif // Default to FFT 8192 (x=1.0), Hop 64 (y=0.0) m_padDsp->setValues(1.0f, 0.0f); connect(m_padDsp, &XYPad::valuesChanged, this, @@ -314,6 +330,9 @@ void SettingsWidget::onDspPadChanged(float x, float y) { int power = 6 + (int)(x * 7.0f + 0.5f); m_fft = std::pow(2, power); m_hop = 64 + y * (8192 - 64); +#ifdef IS_MOBILE + if (m_hop > m_fft) m_hop = m_fft; +#endif emit dspParamsChanged(m_fft, m_hop); } diff --git a/src/VisualizerWidget.cpp b/src/VisualizerWidget.cpp index 101d308..5bcf5aa 100644 --- a/src/VisualizerWidget.cpp +++ b/src/VisualizerWidget.cpp @@ -384,9 +384,12 @@ void VisualizerWidget::render(QRhiCommandBuffer *cb) { int w = width(); int h = height(); - // Only rebuild vertices when new data has arrived - if (m_dataDirty) { + // Rebuild vertices when new data arrives OR when the widget has been resized + bool sizeChanged = (w != m_lastBuildW || h != m_lastBuildH); + if (m_dataDirty || sizeChanged) { m_dataDirty = false; + m_lastBuildW = w; + m_lastBuildH = h; if (m_mirrored) { buildVertices(w * 0.55f, h / 2); buildCepstrumVertices(w, h); diff --git a/src/VisualizerWidget.h b/src/VisualizerWidget.h index 86d9e69..1ae95ce 100644 --- a/src/VisualizerWidget.h +++ b/src/VisualizerWidget.h @@ -68,6 +68,8 @@ private: int m_targetFps = 60; qint64 m_lastFrameTime = 0; bool m_dataDirty = false; + int m_lastBuildW = 0; + int m_lastBuildH = 0; // RHI resources QRhi *m_rhi = nullptr;