This commit is contained in:
pszsh 2026-02-27 16:23:23 -08:00
parent b6ef417242
commit 4c6eb8dfe9
9 changed files with 256 additions and 63 deletions

View File

@ -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 "$<TARGET_BUNDLE_DIR:YrCrystals>"
COMMENT "Signing YrCrystals.app with audio entitlements..."
VERBATIM
)
elseif(WIN32)
set_target_properties(YrCrystals PROPERTIES
WIN32_EXECUTABLE TRUE

View File

@ -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"

View File

@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleName</key>
<string>@MACOSX_BUNDLE_BUNDLE_NAME@</string>
<key>CFBundleIdentifier</key>
<string>@MACOSX_BUNDLE_GUI_IDENTIFIER@</string>
<key>CFBundleExecutable</key>
<string>@MACOSX_BUNDLE_EXECUTABLE_NAME@</string>
<key>CFBundleVersion</key>
<string>@MACOSX_BUNDLE_BUNDLE_VERSION@</string>
<key>CFBundleShortVersionString</key>
<string>@MACOSX_BUNDLE_SHORT_VERSION_STRING@</string>
<key>NSHumanReadableCopyright</key>
<string>@MACOSX_BUNDLE_COPYRIGHT@</string>
<key>CFBundleIconFile</key>
<string>@MACOSX_BUNDLE_ICON_FILE@</string>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleAllowMixedLocalizations</key>
<true/>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<key>NSSupportsAutomaticGraphicsSwitching</key>
<true/>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSMicrophoneUsageDescription</key>
<string>Audio playback requires access to your audio device.</string>
</dict>
</plist>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.device.audio-input</key>
<true/>
</dict>
</plist>

View File

@ -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<TrackData>();
@ -351,6 +352,7 @@ void AudioEngine::onFinished() {
Qt::QueuedConnection,
Q_ARG(std::shared_ptr<TrackData>, 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<TrackData> 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<double> *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<size_t>(pos * totalSamples);
// Boundary check
if (sampleIdx + m_frameSize >= totalSamples)
return;
// 3. Extract Data (Read-only from shared memory)
std::vector<std::complex<double>> 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<const float *>(m_data->pcmData.constData());
for (int i = 0; i < m_frameSize; ++i) {
ch0[i] = std::complex<double>(raw[(sampleIdx + i) * 2], 0.0);
ch1[i] = std::complex<double>(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<std::complex<double>> 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<FrameData> 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<size_t>(pos * totalSamples);
int hopSize = m_hilbertHopSize;
int fftSize = m_hilbertFftSize;
if (sampleIdx + static_cast<size_t>(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<size_t>(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<size_t>(warmupBlocks * hopSize))
? sampleIdx - warmupBlocks * hopSize
: 0;
const float *raw =
reinterpret_cast<const float *>(m_data->pcmData.constData());
for (int w = 0; w < warmupBlocks; ++w) {
size_t blockStart = warmupStart + w * hopSize;
if (blockStart + hopSize > totalSamples)
break;
std::vector<double> leftBlock(hopSize), rightBlock(hopSize);
for (int i = 0; i < hopSize; ++i) {
leftBlock[i] = static_cast<double>(raw[(blockStart + i) * 2]);
rightBlock[i] = static_cast<double>(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<const float *>(m_data->pcmData.constData());
std::vector<double> leftBlock(hopSize), rightBlock(hopSize);
for (int i = 0; i < hopSize; ++i) {
leftBlock[i] = static_cast<double>(raw[(sampleIdx + i) * 2]);
rightBlock[i] = static_cast<double>(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<size_t>(pos * totalSamples);
if (sampleIdx + m_frameSize >= totalSamples)
return;
std::vector<std::complex<double>> 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<const float *>(m_data->pcmData.constData());
for (int i = 0; i < m_frameSize; ++i) {
ch0[i] = std::complex<double>(raw[(sampleIdx + i) * 2], 0.0);
ch1[i] = std::complex<double>(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<std::complex<double>> 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<FrameData> &out) {
QMutexLocker locker(&m_frameMutex);
if (m_lastFrameDataVector.empty())

View File

@ -2,6 +2,7 @@
#pragma once
#include "Processor.h"
#include "complex_block.h"
#include "complex_frames.h"
#include <QAudioDecoder>
#include <QAudioSink>
#include <QBuffer>
@ -234,6 +235,8 @@ private slots:
void processLoop();
private:
void computeAndPublishSpectrum();
QTimer *m_timer = nullptr;
std::atomic<double> *m_posRef = nullptr;
std::shared_ptr<TrackData> 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<FrameData> m_lastFrameDataVector;
mutable QMutex m_frameMutex;

View File

@ -6,6 +6,12 @@
#include <QVBoxLayout>
#include <cmath>
#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);
}

View File

@ -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);

View File

@ -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;