diff --git a/.gitignore b/.gitignore index 42d23d9..ff8a37e 100644 --- a/.gitignore +++ b/.gitignore @@ -55,4 +55,9 @@ compile_commands.json *_qmlcache.qrc -./build \ No newline at end of file +./build +./dist +AppDir + +*.AppImage +*.html \ No newline at end of file diff --git a/CMakeLists.txt b/CMakeLists.txt index 61db41f..b5cbfe1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -4,10 +4,9 @@ project(SubWave LANGUAGES CXX) set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) -# ── Find Qt6 ───────────────────────────────────────────────────────────────── find_package(Qt6 REQUIRED COMPONENTS Widgets Multimedia OpenGL OpenGLWidgets) -# ── Find FFmpeg via pkg-config ─────────────────────────────────────────────── +# require ffmpeg find_package(PkgConfig REQUIRED) pkg_check_modules(FFMPEG REQUIRED IMPORTED_TARGET libavformat @@ -15,17 +14,21 @@ pkg_check_modules(FFMPEG REQUIRED IMPORTED_TARGET libavutil libswresample ) +# require fftw (fourier transform) +pkg_check_modules(FFTW REQUIRED IMPORTED_TARGET fftw3) -# Auto-generate MOC, and now also RCC for resource files set(CMAKE_AUTOMOC ON) set(CMAKE_AUTORCC ON) -# ── Sources ────────────────────────────────────────────────────────────────── +# source files set(SOURCES src/main.cpp src/MetadataReader.cpp src/Config.cpp - src/AudioEngine.cpp + src/engine/AudioEngine.cpp + src/engine/FFmpegDecoder.cpp + src/engine/EqProcessor.cpp + src/engine/SpectrumAnalyzer.cpp src/MusicLibrary.cpp src/CoverArtWidget.cpp src/SpectrogramWidget.cpp @@ -40,7 +43,7 @@ set(HEADERS src/Track.h src/MetadataReader.h src/Config.h - src/AudioEngine.h + src/engine/AudioEngine.h src/MusicLibrary.h src/CoverArtWidget.h src/SpectrogramWidget.h @@ -51,19 +54,24 @@ set(HEADERS src/MainWindow.h ) -set(RESOURCES src/shaders.qrc) +set(RESOURCES + src/shaders.qrc + src/application.qrc +) add_executable(SubWave ${SOURCES} ${HEADERS} ${RESOURCES}) +target_include_directories(SubWave PRIVATE src src/engine) + target_link_libraries(SubWave PRIVATE Qt6::Widgets Qt6::Multimedia Qt6::OpenGL Qt6::OpenGLWidgets PkgConfig::FFMPEG + PkgConfig::FFTW ) -# ── Copy FFmpeg shared libs next to the binary on Windows ──────────────────── if(WIN32) add_custom_command(TARGET SubWave POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_if_different diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..b83a0a3 --- /dev/null +++ b/build.sh @@ -0,0 +1,6 @@ +rm -rf build +mkdir build +cd build +cmake .. +make -j$(nproc) +./SubWave \ No newline at end of file diff --git a/ico/wavy_ico.ico b/ico/wavy_ico.ico new file mode 100644 index 0000000..faa6906 Binary files /dev/null and b/ico/wavy_ico.ico differ diff --git a/ico/wavy_png.png b/ico/wavy_png.png new file mode 100644 index 0000000..437336a Binary files /dev/null and b/ico/wavy_png.png differ diff --git a/package.sh b/package.sh new file mode 100755 index 0000000..45f1fa6 --- /dev/null +++ b/package.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +set -e + +APP_NAME="SubWave" +BUILD_DIR="build" +APPDIR="AppDir" +DIST_DIR="dist" + +ICON_SRC_PNG="ico/wavy_png.png" +ICON_SRC_ICO="ico/wavy_ico.ico" + +echo "==> Cleaning old build" +rm -rf "$BUILD_DIR" "$APPDIR" "$DIST_DIR" +mkdir -p "$DIST_DIR" + +echo "==> Configuring project" +cmake -B "$BUILD_DIR" -DCMAKE_BUILD_TYPE=Release + +echo "==> Building project" +cmake --build "$BUILD_DIR" -j$(nproc) + +echo "==> Installing into AppDir" +cmake --install "$BUILD_DIR" --prefix "$APPDIR/usr" + +echo "==> Ensuring binary location" +mkdir -p "$APPDIR/usr/bin" + +if [ ! -f "$APPDIR/usr/bin/$APP_NAME" ]; then + cp "$BUILD_DIR/$APP_NAME" "$APPDIR/usr/bin/" || true +fi + +echo "==> Creating desktop file (REQUIRED for AppImage)" + +mkdir -p "$APPDIR/usr/share/applications" + +cat > "$APPDIR/usr/share/applications/$APP_NAME.desktop" < Installing icon" + +ICON_DEST_DIR="$APPDIR/usr/share/icons/hicolor/256x256/apps" +mkdir -p "$ICON_DEST_DIR" + +if [ -f "$ICON_SRC_PNG" ]; then + cp "$ICON_SRC_PNG" "$ICON_DEST_DIR/subwave.png" +elif [ -f "$ICON_SRC_ICO" ]; then + if command -v convert >/dev/null 2>&1; then + convert "$ICON_SRC_ICO[0]" "$ICON_DEST_DIR/subwave.png" + else + echo "WARNING: ImageMagick not installed, copying .ico as fallback" + cp "$ICON_SRC_ICO" "$ICON_DEST_DIR/subwave.ico" + fi +else + echo "WARNING: No icon found, build will still work but no icon will be shown" +fi + +echo "==> Downloading linuxdeploy if needed" + +if [ ! -f linuxdeploy-x86_64.AppImage ]; then + wget -q https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage + chmod +x linuxdeploy-x86_64.AppImage +fi + +echo "==> Building AppImage" + +export ARCH=x86_64 + +./linuxdeploy-x86_64.AppImage \ + --appdir "$APPDIR" \ + --output appimage + +echo "==> Moving output" +mkdir -p "$DIST_DIR" +mv *.AppImage "$DIST_DIR/$APP_NAME.AppImage" + +echo "==> DONE" +echo "Run:" +echo " chmod +x dist/$APP_NAME.AppImage" +echo " ./dist/$APP_NAME.AppImage" \ No newline at end of file diff --git a/scripts/install_reqdeb.sh b/scripts/install_reqdeb.sh new file mode 100755 index 0000000..def324c --- /dev/null +++ b/scripts/install_reqdeb.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +set -e + +echo "Installing build tools..." +sudo apt install -y \ + build-essential \ + cmake \ + ninja-build \ + pkg-config \ + git + +echo "Installing Qt6 dependencies..." +sudo apt install -y \ + qt6-base-dev \ + qt6-base-dev-tools \ + qt6-multimedia-dev \ + qt6-tools-dev \ + qt6-tools-dev-tools \ + libqt6opengl6-dev \ + libqt6openglwidgets6 + +echo "Installing FFmpeg development libraries..." +sudo apt install -y \ + libavformat-dev \ + libavcodec-dev \ + libavutil-dev \ + libswresample-dev \ + libswscale-dev \ + ffmpeg + +echo "Installing FFTW3..." +sudo apt install -y \ + libfftw3-dev + +echo "Installing OpenGL dependencies..." +sudo apt install -y \ + libgl1-mesa-dev \ + libglu1-mesa-dev + +echo "Verifying pkg-config modules..." +pkg-config --modversion libavformat || echo "FFmpeg pkg-config missing" +pkg-config --modversion fftw3 || echo "FFTW pkg-config missing" diff --git a/src/AudioEngine.cpp b/src/AudioEngine.cpp deleted file mode 100644 index bb78713..0000000 --- a/src/AudioEngine.cpp +++ /dev/null @@ -1,355 +0,0 @@ -#include "AudioEngine.h" -#include -#include -#include -#include -#include -#include -#include - -extern "C" { -#include -#include -#include -#include -} - -AudioEngine::AudioEngine(QObject *parent) : QObject(parent) { - initEq(); -} - -AudioEngine::~AudioEngine() { stop(); } - -AudioEngine::State AudioEngine::state() const { return m_state.load(); } -qint64 AudioEngine::positionFrames() const { return m_posFrames.load(); } -qint64 AudioEngine::totalFrames() const { return m_totalFrames.load(); } -int AudioEngine::sampleRate() const { return m_sampleRate.load(); } - -void AudioEngine::setVolume(float v) { - m_volume = std::clamp(v, 0.f, 1.f); - if (m_sink) m_sink->setVolume(m_volume); -} - -void AudioEngine::play(const Track &track) { - stop(); - m_stopReq = false; - m_pauseReq = false; - m_track = track; - m_thread = QThread::create([this]{ playbackLoop(); }); - m_thread->setObjectName("subwave-audio"); - m_thread->start(QThread::HighestPriority); -} - -void AudioEngine::togglePause() { - if (m_state == State::Playing) { - m_pauseReq = true; - m_state = State::Paused; - } else if (m_state == State::Paused) { - m_pauseReq = false; - m_state = State::Playing; - m_pauseCv.wakeAll(); - } -} - -void AudioEngine::stop() { - m_stopReq = true; - m_pauseReq = false; - m_pauseCv.wakeAll(); - if (m_thread) { - m_thread->quit(); - m_thread->wait(3000); - delete m_thread; - m_thread = nullptr; - } - m_state = State::Stopped; - m_posFrames = 0; -} - -void AudioEngine::seekFraction(double fraction) { - m_seekFraction = std::clamp(fraction, 0.0, 1.0); - m_seekPending = true; -} - -void AudioEngine::setEqBand(int band, float db) { - if (band < 0 || band >= EQ_BANDS) return; - m_eqGainDb[band] = db; - recomputeBiquad(band); -} - -float AudioEngine::eqBand(int band) const { - return (band >= 0 && band < EQ_BANDS) ? m_eqGainDb[band] : 0.f; -} - -void AudioEngine::initEq() { - for (int b = 0; b < EQ_BANDS; b++) { m_eqGainDb[b] = 0.f; recomputeBiquad(b); } -} - -void AudioEngine::recomputeBiquad(int band) { - float sr = static_cast(m_sampleRate.load()); - float f0 = EQ_FREQS[band]; - float dBg = m_eqGainDb[band]; - float Q = 1.41421356f; - float A = std::pow(10.f, dBg / 40.f); - double w0 = 2.0 * std::numbers::pi * f0 / sr; - double cosW = std::cos(w0), sinW = std::sin(w0); - double alpha= sinW / (2.0 * Q); - double b0 = 1+alpha*A, b1 = -2*cosW, b2 = 1-alpha*A; - double a0 = 1+alpha/A, a1 = -2*cosW, a2 = 1-alpha/A; - m_bqCoeff[band][0] = float(b0/a0); m_bqCoeff[band][1] = float(b1/a0); - m_bqCoeff[band][2] = float(b2/a0); m_bqCoeff[band][3] = float(a1/a0); - m_bqCoeff[band][4] = float(a2/a0); -} - -void AudioEngine::applyEq(float *samples, int count, int channels) { - for (int band = 0; band < EQ_BANDS; band++) { - if (std::abs(m_eqGainDb[band]) < 0.01f) continue; - float b0=m_bqCoeff[band][0], b1=m_bqCoeff[band][1], b2=m_bqCoeff[band][2]; - float a1=m_bqCoeff[band][3], a2=m_bqCoeff[band][4]; - for (int i = 0; i < count; i++) { - int ch = i % channels; - float x = samples[i]; - float z1 = m_bqState[band][ch][0], z2 = m_bqState[band][ch][1]; - float y = b0*x + z1; - m_bqState[band][ch][0] = b1*x - a1*y + z2; - m_bqState[band][ch][1] = b2*x - a2*y; - samples[i] = y; - } - } -} - -void AudioEngine::resetBiquadState() { - for (auto &b : m_bqState) for (auto &c : b) { c[0]=0; c[1]=0; } -} - -void AudioEngine::feedFft(const float *samples, int count, int channels) { - for (int i = 0; i < count; i += channels) { - float mono = 0; - for (int c = 0; c < channels; c++) mono += samples[i+c]; - mono /= channels; - m_fftBuf[m_fftPos++] = mono; - if (m_fftPos == FFT_SIZE) { - QVector mag = computeFFT(m_fftBuf, FFT_SIZE); - emit fftReady(mag); - std::copy(m_fftBuf + FFT_SIZE/2, m_fftBuf + FFT_SIZE, m_fftBuf); - m_fftPos = FFT_SIZE / 2; - } - } -} - -QVector AudioEngine::computeFFT(const float *input, int n) { - std::vector re(input, input + n), im(n, 0.f); - for (int i = 0; i < n; i++) - re[i] *= 0.5f * (1.f - std::cos(2.f * std::numbers::pi_v * i / (n-1))); - for (int j=1, i=0; j>1; - for (; (i&bit)!=0; bit>>=1) i^=bit; - i^=bit; - if (j mag(n/2); - for (int i=0; istop(); - delete sinkToDelete; - }, Qt::QueuedConnection); - } - - m_state = State::Stopped; - }; - - AVDictionary *opts = nullptr; - av_dict_set(&opts, "probesize", "5000000", 0); - av_dict_set(&opts, "analyzeduration", "5000000", 0); - - if (avformat_open_input(&fmt, m_track.filePath.toUtf8().constData(), - nullptr, &opts) < 0) { - av_dict_free(&opts); - qWarning() << "[SubWave] Cannot open:" << m_track.filePath; - cleanup(); return; - } - av_dict_free(&opts); - avformat_find_stream_info(fmt, nullptr); - - if (fmt->duration != AV_NOPTS_VALUE && fmt->duration > 0) { - qint64 secs = fmt->duration / AV_TIME_BASE; - if (m_track.durationSecs == 0) m_track.durationSecs = secs; - emit durationKnown(m_track.durationSecs); - } - - int audioIdx = av_find_best_stream(fmt, AVMEDIA_TYPE_AUDIO, -1, -1, nullptr, 0); - if (audioIdx < 0) { - qWarning() << "[SubWave] No audio stream:" << m_track.filePath; - cleanup(); return; - } - - AVStream *stream = fmt->streams[audioIdx]; - const AVCodec *dec = avcodec_find_decoder(stream->codecpar->codec_id); - codec = avcodec_alloc_context3(dec); - avcodec_parameters_to_context(codec, stream->codecpar); - if (avcodec_open2(codec, dec, nullptr) < 0) { - qWarning() << "[SubWave] Cannot open codec"; - cleanup(); return; - } - - const int srcRate = codec->sample_rate; - const int outRate = srcRate; - const int channels = codec->ch_layout.nb_channels; - m_sampleRate = outRate; - - if (m_totalFrames == 0 && m_track.durationSecs > 0) - m_totalFrames = m_track.durationSecs * outRate; - - AVChannelLayout chLayout {}; - av_channel_layout_default(&chLayout, channels); - - swr_alloc_set_opts2(&swr, - &chLayout, AV_SAMPLE_FMT_FLT, outRate, - &codec->ch_layout, codec->sample_fmt, srcRate, - 0, nullptr); - swr_init(swr); - - QAudioFormat af; - af.setSampleRate(outRate); - af.setChannelCount(channels); - af.setSampleFormat(QAudioFormat::Float); - - QSemaphore sinkReady(0); - QMetaObject::invokeMethod(this, [&]{ - QAudioDevice dev = QMediaDevices::defaultAudioOutput(); - m_sink = new QAudioSink(dev, af); - m_sink->setVolume(m_volume); - m_sinkIO = m_sink->start(); - sinkReady.release(); - }, Qt::QueuedConnection); - sinkReady.acquire(); - - if (!m_sinkIO) { - qWarning() << "[SubWave] QAudioSink failed to start"; - cleanup(); return; - } - - resetBiquadState(); - m_posFrames = 0; - - std::vector outBuf; - - while (!m_stopReq) { - { - QMutexLocker lk(&m_pauseMtx); - while (m_pauseReq && !m_stopReq) - m_pauseCv.wait(&m_pauseMtx, 50); - } - if (m_stopReq) break; - - if (m_seekPending) { - m_seekPending = false; - double frac = m_seekFraction.load(); - qint64 durUs = (fmt->duration != AV_NOPTS_VALUE && fmt->duration > 0) - ? fmt->duration - : (m_track.durationSecs * AV_TIME_BASE); - if (durUs > 0) { - qint64 targetUs = static_cast(frac * durUs); - av_seek_frame(fmt, -1, targetUs, AVSEEK_FLAG_BACKWARD); - avcodec_flush_buffers(codec); - m_posFrames = static_cast(frac * m_totalFrames.load()); - } - resetBiquadState(); - continue; - } - - int ret = av_read_frame(fmt, pkt); - if (ret < 0) break; - - if (pkt->stream_index != audioIdx) { - av_packet_unref(pkt); - continue; - } - - avcodec_send_packet(codec, pkt); - av_packet_unref(pkt); - - while (!m_stopReq) { - ret = avcodec_receive_frame(codec, frame); - if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) break; - if (ret < 0) break; - - int outSamples = swr_get_out_samples(swr, frame->nb_samples); - outBuf.resize(static_cast(outSamples) * channels); - - uint8_t *outPtr = reinterpret_cast(outBuf.data()); - int converted = swr_convert(swr, - &outPtr, outSamples, - const_cast(frame->data), frame->nb_samples); - - if (converted <= 0) { av_frame_unref(frame); continue; } - - int count = converted * channels; - outBuf.resize(static_cast(count)); - - applyEq(outBuf.data(), count, channels); - feedFft(outBuf.data(), count, channels); - - const char *src = reinterpret_cast(outBuf.data()); - qsizetype bytes = count * sizeof(float); - qsizetype written = 0; - while (written < bytes && !m_stopReq) { - qsizetype n = m_sinkIO->write(src + written, bytes - written); - if (n <= 0) QThread::msleep(1); - else written += n; - } - - m_posFrames += converted; - emit positionChanged(m_posFrames.load()); - - if (m_totalFrames == 0 && stream->duration != AV_NOPTS_VALUE) { - double tb = av_q2d(stream->time_base); - m_totalFrames = static_cast(stream->duration * tb * outRate); - } - - av_frame_unref(frame); - } - } - - bool natural = !m_stopReq; - cleanup(); - if (natural) emit trackEnded(); -} \ No newline at end of file diff --git a/src/EqualizerWidget.cpp b/src/EqualizerWidget.cpp index 2858666..14623e8 100644 --- a/src/EqualizerWidget.cpp +++ b/src/EqualizerWidget.cpp @@ -5,13 +5,14 @@ #include #include #include +#include #include #include +#include +#include #include #include #include -#include -#include #include #include @@ -41,6 +42,7 @@ EqualizerWidget::EqualizerWidget(AudioEngine *engine, QWidget *parent) auto *topBar = new QHBoxLayout; topBar->addWidget(new QLabel("Preset:")); m_presetBox = new QComboBox; + m_presetBox->setStyle(QStyleFactory::create("Fusion")); topBar->addWidget(m_presetBox); auto *saveBtn = new QPushButton("Save..."); auto *deleteBtn = new QPushButton("Delete"); @@ -65,7 +67,11 @@ EqualizerWidget::EqualizerWidget(AudioEngine *engine, QWidget *parent) m_dbLabels[i] = new QLabel("0 dB"); m_dbLabels[i]->setAlignment(Qt::AlignHCenter); - QFont sf = m_dbLabels[i]->font(); sf.setPointSizeF(8); m_dbLabels[i]->setFont(sf); + // Fixed width and monospace to prevent left/right shifting + QFont sf("Monospace", 8); + sf.setStyleHint(QFont::Monospace); + m_dbLabels[i]->setFont(sf); + m_dbLabels[i]->setFixedWidth(50); // ample room for "-12 dB" m_sliders[i] = new QSlider(Qt::Vertical); m_sliders[i]->setRange(-120, 120); @@ -97,8 +103,16 @@ EqualizerWidget::EqualizerWidget(AudioEngine *engine, QWidget *parent) populatePresetBox(); loadState(); - connect(m_presetBox, QOverload::of(&QComboBox::currentIndexChanged), - this, &EqualizerWidget::applyPresetAt); + connect(m_presetBox, QOverload::of(&QComboBox::activated), + this, [this](int idx) { + m_presetBox->hidePopup(); // force-close before anything else + m_presetBox->setEnabled(false); + QMetaObject::invokeMethod(this, [this, idx] { + applyPresetAt(idx); + m_presetBox->setEnabled(true); + }, Qt::QueuedConnection); + }); + connect(saveBtn, &QPushButton::clicked, this, [this]{ bool ok = false; QString name = QInputDialog::getText(this, "Save Preset", "Preset name:", QLineEdit::Normal, "", &ok); @@ -126,44 +140,43 @@ EqualizerWidget::EqualizerWidget(AudioEngine *engine, QWidget *parent) savePresets(); populatePresetBox(); }); - connect(resetBtn, &QPushButton::clicked, this, [this]{ applyPresetAt(0); }); + connect(resetBtn, &QPushButton::clicked, this, [this]{ + m_presetBox->hidePopup(); + m_presetBox->setCurrentIndex(0); + m_presetBox->setEnabled(false); + QMetaObject::invokeMethod(this, [this]{ + applyPresetAt(0); + m_presetBox->setEnabled(true); + }, Qt::QueuedConnection); + }); } void EqualizerWidget::saveState() { QDir().mkpath(QFileInfo(stateFile()).absolutePath()); QFile f(stateFile()); if (!f.open(QIODevice::WriteOnly|QIODevice::Truncate)) return; + QJsonObject obj; QString vals; for (int i=0; ivalue()); } - f.write(("{\n \"bands\": \"" + vals + "\"\n}\n").toUtf8()); + obj["bands"] = vals; + f.write(QJsonDocument(obj).toJson()); } void EqualizerWidget::loadPresets() { - m_userPresets.clear(); QFile f(presetFile()); if (!f.open(QIODevice::ReadOnly)) return; - QString json = QString::fromUtf8(f.readAll()); - int pos = 0; - while (pos < json.size()) { - int ns = json.indexOf("\"name\"", pos); if (ns<0) break; - int nq1= json.indexOf('"', ns+7); if (nq1<0) break; - int nq2= nq1+1; - while (nq2update(); - m_presetBox->hidePopup(); } void EqualizerWidget::loadState() { QFile f(stateFile()); if (!f.open(QIODevice::ReadOnly)) return; - QString json = QString::fromUtf8(f.readAll()); - int bi = json.indexOf("\"bands\""); if (bi<0) return; - int q1 = json.indexOf('"', bi+8); if (q1<0) return; - int q2 = q1+1; - while (q2 float { + return (std::log10(f) - logMin) / (logMax - logMin) * w; + }; - float logMin = std::log10(20.f), logMax = std::log10(20000.f); + // 0 dB line + float midY = graphH / 2.f; + p.setPen(QColor(180, 180, 180)); + p.drawLine(0, int(midY), w, int(midY)); - QPainterPath path; - for (int i=0; ivalue()/10.f; - if (std::abs(db)<0.01f) continue; - float f0 = AudioEngine::EQ_FREQS[b], Q=1.41421356f; - double ratio = f/f0; - double lrSq = (ratio-1.0/ratio)*(ratio-1.0/ratio); - double denom = 1+lrSq/(Q*Q*(ratio+1.0/ratio)*(ratio+1.0/ratio)); - totalDb += db*denom; - } - float py = midY - float(totalDb/12.0*h/2*0.9); - py = std::clamp(py, 0.f, float(h)); - if (i==0) path.moveTo(i,py); else path.lineTo(i,py); + // Draw frequency axis ticks and labels + const float tickFreqs[] = {20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000}; + QFont tickFont = font(); + tickFont.setPointSizeF(7); + p.setFont(tickFont); + p.setPen(palette().windowText().color()); + for (float fq : tickFreqs) { + float x = freqToX(fq); + if (x < 0 || x > w) continue; + // Tick mark + p.drawLine(QPointF(x, graphH), QPointF(x, graphH + 3)); + // Label + QString label; + if (fq >= 1000) label = QString::number(fq / 1000, 'f', 0) + "k"; + else label = QString::number(fq, 'f', 0); + QRectF textRect(x - 20, graphH + 3, 40, labelArea - 3); + p.drawText(textRect, Qt::AlignHCenter | Qt::AlignTop, label); } - p.setPen(QPen(palette().windowText().color(), 1.5f)); + + // Draw EQ curve + QPainterPath path; + for (int i = 0; i < w; ++i) { + float f = std::pow(10.f, logMin + (logMax - logMin) * i / w); + double totalDb = 0.0; + for (int b = 0; b < BANDS; ++b) { + float db = m_sliders[b]->value() / 10.f; + if (std::abs(db) < 0.01f) continue; + float f0 = AudioEngine::EQ_FREQS[b], Q = 1.41421356f; + double ratio = f / f0; + double lrSq = (ratio - 1.0 / ratio) * (ratio - 1.0 / ratio); + double denom = 1.0 + lrSq / (Q * Q * (ratio + 1.0 / ratio) * (ratio + 1.0 / ratio)); + totalDb += db * denom; + } + float py = midY - float(totalDb / 12.0 * graphH / 2 * 0.9); + py = std::clamp(py, 0.f, float(graphH)); + if (i == 0) path.moveTo(i, py); + else path.lineTo(i, py); + } + p.setPen(QPen(palette().windowText().color(), 1.5)); p.drawPath(path); + // Draw control points (band handles) p.setPen(Qt::NoPen); - p.setBrush(QColor(60,120,255)); - for (int b=0; bvalue()/10.f; - float nx = (std::log10(f0)-logMin)/(logMax-logMin)*w; - float ny = midY-(db/12.f*h/2*0.9f); - ny = std::clamp(ny,0.f,float(h)); - p.drawEllipse(QPointF(nx,ny),3,3); + float db = m_sliders[b]->value() / 10.f; + float nx = freqToX(f0); + float ny = midY - (db / 12.f * graphH / 2 * 0.9f); + ny = std::clamp(ny, 0.f, float(graphH)); + p.drawEllipse(QPointF(nx, ny), 3, 3); } } \ No newline at end of file diff --git a/src/EqualizerWidget.h b/src/EqualizerWidget.h index 07d056a..a49da4f 100644 --- a/src/EqualizerWidget.h +++ b/src/EqualizerWidget.h @@ -4,6 +4,9 @@ #include #include #include +#include +#include +#include #include "AudioEngine.h" class EqualizerWidget : public QWidget { diff --git a/src/application.qrc b/src/application.qrc new file mode 100644 index 0000000..7eff022 --- /dev/null +++ b/src/application.qrc @@ -0,0 +1,6 @@ + + + ico/wavy_ico.ico + ico/wavy_png.png + + \ No newline at end of file diff --git a/src/engine/AudioBuffer.h b/src/engine/AudioBuffer.h new file mode 100644 index 0000000..cad5ce8 --- /dev/null +++ b/src/engine/AudioBuffer.h @@ -0,0 +1,29 @@ +#pragma once +#include +#include + +namespace engine { + +struct AudioBuffer { + std::vector samples; + int sampleRate { 0 }; + int channels { 0 }; + + int frameCount() const { + return channels > 0 ? static_cast(samples.size()) / channels : 0; + } + + bool empty() const { return samples.empty(); } + + void resize(int frames) { + samples.resize(static_cast(frames) * channels); + } + + void truncate(int frames) { + samples.resize(static_cast(frames) * channels); + } + + int sampleCount() const { return static_cast(samples.size()); } +}; + +} // namespace engine \ No newline at end of file diff --git a/src/engine/AudioEngine.cpp b/src/engine/AudioEngine.cpp new file mode 100644 index 0000000..0be69a7 --- /dev/null +++ b/src/engine/AudioEngine.cpp @@ -0,0 +1,201 @@ +#include "AudioEngine.h" +#include "engine/FFmpegDecoder.h" +#include "engine/EqProcessor.h" +#include "engine/SpectrumAnalyzer.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +AudioEngine::AudioEngine(QObject *parent) : QObject(parent) { + initEq(); +} + +AudioEngine::~AudioEngine() { stop(); } + +AudioEngine::State AudioEngine::state() const { return m_state.load(); } +qint64 AudioEngine::positionFrames() const { return m_posFrames.load(); } +qint64 AudioEngine::totalFrames() const { return m_totalFrames.load(); } +int AudioEngine::sampleRate() const { return m_sampleRate.load(); } + +void AudioEngine::setVolume(float v) { + m_volume = std::clamp(v, 0.f, 1.f); + if (m_sink) m_sink->setVolume(m_volume); +} + +void AudioEngine::play(const Track &track) { + stop(); + m_stopReq = false; + m_pauseReq = false; + m_seekPending = false; + m_track = track; + m_thread = QThread::create([this]{ playbackLoop(); }); + m_thread->setObjectName("subwave-audio"); + m_thread->start(QThread::HighestPriority); +} + +void AudioEngine::togglePause() { + if (m_state == State::Playing) { + m_pauseReq = true; + m_state = State::Paused; + } else if (m_state == State::Paused) { + m_pauseReq = false; + m_state = State::Playing; + m_pauseCv.wakeAll(); + } +} + +void AudioEngine::stop() { + m_stopReq = true; + m_pauseReq = false; + m_pauseCv.wakeAll(); + if (m_thread) { + m_thread->quit(); + m_thread->wait(3000); + delete m_thread; + m_thread = nullptr; + } + m_state = State::Stopped; + m_posFrames = 0; +} + +void AudioEngine::seekFraction(double fraction) { + m_seekFraction = std::clamp(fraction, 0.0, 1.0); + m_seekPending = true; +} + +void AudioEngine::setEqBand(int band, float db) { + if (band < 0 || band >= EQ_BANDS) return; + m_eq.setBand(band, db); +} + +float AudioEngine::eqBand(int band) const { + return m_eq.band(band); +} + +void AudioEngine::initEq() { + for (int b = 0; b < EQ_BANDS; ++b) + m_eq.setBand(b, 0.f); +} + +void AudioEngine::playbackLoop() { + m_state = State::Playing; + + engine::FFmpegDecoder decoder; + if (!decoder.open(m_track.filePath)) { + qWarning() << "[AudioEngine] Cannot open:" << m_track.filePath; + m_state = State::Stopped; + return; + } + + if (decoder.sampleRate() <= 0 || decoder.channels() <= 0) { + qWarning() << "[AudioEngine] Invalid stream format:" << decoder.sampleRate() << "Hz" << decoder.channels() << "channels"; + m_state = State::Stopped; + return; + } + + m_sampleRate = decoder.sampleRate(); + m_eq.setSampleRate(m_sampleRate); + m_eq.reset(); + + if (m_track.durationSecs == 0 && decoder.durationSeconds() > 0) { + m_track.durationSecs = decoder.durationSeconds(); + } + emit durationKnown(m_track.durationSecs); + + m_totalFrames = decoder.totalFrames(); + + QAudioFormat af; + af.setSampleRate(m_sampleRate); + af.setChannelCount(decoder.channels()); + af.setSampleFormat(QAudioFormat::Float); + + QSemaphore sinkReady(0); + QMetaObject::invokeMethod(this, [this, &af, &sinkReady] { + QAudioDevice dev = QMediaDevices::defaultAudioOutput(); + m_sink = new QAudioSink(dev, af); + m_sink->setVolume(m_volume); + m_sinkIO = m_sink->start(); + sinkReady.release(); + }, Qt::QueuedConnection); + sinkReady.acquire(); + + if (!m_sinkIO) { + qWarning() << "[AudioEngine] QAudioSink failed to start"; + m_state = State::Stopped; + return; + } + + engine::SpectrumAnalyzer spectrumAnalyzer; + connect(&spectrumAnalyzer, &engine::SpectrumAnalyzer::fftReady, + this, &AudioEngine::fftReady, Qt::QueuedConnection); + + auto cleanup = [&] { + QAudioSink *sinkToDelete = m_sink; + m_sink = nullptr; + m_sinkIO = nullptr; + if (sinkToDelete) { + QMetaObject::invokeMethod(this, [sinkToDelete] { + sinkToDelete->stop(); + delete sinkToDelete; + }, Qt::QueuedConnection); + } + m_state = State::Stopped; + }; + + m_posFrames = 0; + engine::AudioBuffer buffer; + + while (!m_stopReq) { + { + QMutexLocker lock(&m_pauseMtx); + while (m_pauseReq && !m_stopReq) + m_pauseCv.wait(&m_pauseMtx, 50); + } + if (m_stopReq) break; + + if (m_seekPending) { + m_seekPending = false; + double frac = m_seekFraction.load(); + decoder.seek(frac); + if (m_totalFrames > 0) + m_posFrames = static_cast(frac * m_totalFrames.load()); + m_eq.reset(); + continue; + } + + if (!decoder.decodeNext(buffer)) + break; + + if (buffer.empty()) + continue; + + m_eq.process(buffer); + spectrumAnalyzer.feed(buffer); + + const char *src = reinterpret_cast(buffer.samples.data()); + qsizetype bytes = static_cast(buffer.sampleCount()) * sizeof(float); + qsizetype written = 0; + while (written < bytes && !m_stopReq) { + qsizetype n = m_sinkIO->write(src + written, bytes - written); + if (n <= 0) + QThread::msleep(1); + else + written += n; + } + + m_posFrames += buffer.frameCount(); + emit positionChanged(m_posFrames.load()); + } + + bool natural = !m_stopReq; + cleanup(); + if (natural) + emit trackEnded(); +} diff --git a/src/AudioEngine.h b/src/engine/AudioEngine.h similarity index 96% rename from src/AudioEngine.h rename to src/engine/AudioEngine.h index f6525ac..cde5198 100644 --- a/src/AudioEngine.h +++ b/src/engine/AudioEngine.h @@ -7,7 +7,9 @@ #include #include #include +#include #include "Track.h" +#include "engine/EqProcessor.h" class AudioEngine : public QObject { Q_OBJECT @@ -67,6 +69,7 @@ private: QWaitCondition m_pauseCv; Track m_track; + engine::EqProcessor m_eq; float m_eqGainDb[EQ_BANDS] {}; float m_bqCoeff[EQ_BANDS][5] {}; @@ -75,4 +78,4 @@ private: static constexpr int FFT_SIZE = 4096; float m_fftBuf[FFT_SIZE] {}; int m_fftPos { 0 }; -}; \ No newline at end of file +}; diff --git a/src/engine/EqProcessor.cpp b/src/engine/EqProcessor.cpp new file mode 100644 index 0000000..9dc9c7e --- /dev/null +++ b/src/engine/EqProcessor.cpp @@ -0,0 +1,83 @@ +#include "EqProcessor.h" +#include +#include +#include +#include + +namespace engine { + +EqProcessor::EqProcessor() { + for (int b = 0; b < BANDS; ++b) { + m_gainDb[b] = 0.f; + recomputeBiquad(b); + } +} + +void EqProcessor::setSampleRate(int sr) { + m_sampleRate = sr; + for (int b = 0; b < BANDS; ++b) + recomputeBiquad(b); +} + +void EqProcessor::setBand(int band, float dB) { + if (band < 0 || band >= BANDS) return; + m_gainDb[band] = dB; + recomputeBiquad(band); +} + +float EqProcessor::band(int band) const { + return (band >= 0 && band < BANDS) ? m_gainDb[band] : 0.f; +} + +void EqProcessor::reset() { + std::memset(m_state, 0, sizeof(m_state)); +} + +void EqProcessor::process(AudioBuffer &buf) { + const int channels = buf.channels; + const int count = buf.sampleCount(); + float *samples = buf.samples.data(); + + for (int band = 0; band < BANDS; ++band) { + if (std::abs(m_gainDb[band]) < 0.01f) continue; + + const float b0 = m_coeff[band][0], b1 = m_coeff[band][1], b2 = m_coeff[band][2]; + const float a1 = m_coeff[band][3], a2 = m_coeff[band][4]; + + for (int i = 0; i < count; ++i) { + const int ch = i % channels; + float &z1 = m_state[band][ch][0]; + float &z2 = m_state[band][ch][1]; + + float x = samples[i]; + float y = b0 * x + z1; + z1 = b1 * x - a1 * y + z2; + z2 = b2 * x - a2 * y; + samples[i] = y; + } + } +} + +void EqProcessor::recomputeBiquad(int band) { + const float sr = static_cast(m_sampleRate); + const float f0 = FREQS[band]; + const float dBg = m_gainDb[band]; + const float Q = 1.41421356f; // sqrt(2) , Butterworth Q + const float A = std::pow(10.f, dBg / 40.f); + const double w0 = 2.0 * std::numbers::pi * f0 / sr; + const double cW = std::cos(w0); + const double sW = std::sin(w0); + const double alp = sW / (2.0 * Q); + + // Peaking EQ biquad + const double b0 = 1 + alp * A, b1 = -2 * cW, b2 = 1 - alp * A; + const double a0 = 1 + alp / A, a1 = -2 * cW, a2 = 1 - alp / A; + + m_coeff[band][0] = float(b0 / a0); + m_coeff[band][1] = float(b1 / a0); + m_coeff[band][2] = float(b2 / a0); + m_coeff[band][3] = float(a1 / a0); + m_coeff[band][4] = float(a2 / a0); +} + +} // namespace engine \ No newline at end of file diff --git a/src/engine/EqProcessor.h b/src/engine/EqProcessor.h new file mode 100644 index 0000000..df150cc --- /dev/null +++ b/src/engine/EqProcessor.h @@ -0,0 +1,37 @@ +#pragma once +#include +#include "AudioBuffer.h" + +namespace engine { + +class EqProcessor { +public: + static constexpr int BANDS = 10; + static constexpr float FREQS[BANDS] = { + 32, 64, 125, 250, 500, 1000, 2000, 4000, 8000, 16000 + }; + + EqProcessor(); + + void setBand(int band, float dB); + float band(int band) const; + + void process(AudioBuffer &buf); + + void reset(); + + void setSampleRate(int sr); + +private: + void recomputeBiquad(int band); + + int m_sampleRate { 44100 }; + float m_gainDb[BANDS] {}; + + float m_coeff[BANDS][5] {}; + + static constexpr int MAX_CH = 8; + float m_state[BANDS][MAX_CH][2] {}; +}; + +} // namespace engine \ No newline at end of file diff --git a/src/engine/FFmpegDecoder.cpp b/src/engine/FFmpegDecoder.cpp new file mode 100644 index 0000000..28f9ac8 --- /dev/null +++ b/src/engine/FFmpegDecoder.cpp @@ -0,0 +1,191 @@ +#include "FFmpegDecoder.h" +#include +#include + +extern "C" { +#include +#include +#include +#include +} + +namespace engine { + +void FFmpegDecoder::FmtDeleter::operator()(AVFormatContext *p) const { + avformat_close_input(&p); +} +void FFmpegDecoder::CodecDeleter::operator()(AVCodecContext *p) const { + avcodec_free_context(&p); +} +void FFmpegDecoder::SwrDeleter::operator()(SwrContext *p) const { + swr_free(&p); +} +void FFmpegDecoder::PktDeleter::operator()(AVPacket *p) const { + av_packet_free(&p); +} +void FFmpegDecoder::FrameDeleter::operator()(AVFrame *p) const { + av_frame_free(&p); +} + +FFmpegDecoder::FFmpegDecoder() = default; +FFmpegDecoder::~FFmpegDecoder() { close(); } + +bool FFmpegDecoder::open(const QString &filePath) { + close(); + + AVFormatContext *rawFmt = nullptr; + AVDictionary *opts = nullptr; + av_dict_set(&opts, "probesize", "5000000", 0); + av_dict_set(&opts, "analyzeduration", "5000000", 0); + + if (avformat_open_input(&rawFmt, filePath.toUtf8().constData(), + nullptr, &opts) < 0) { + av_dict_free(&opts); + qWarning() << "[FFmpegDecoder] Cannot open:" << filePath; + return false; + } + av_dict_free(&opts); + m_fmt.reset(rawFmt); + + avformat_find_stream_info(m_fmt.get(), nullptr); + + if (m_fmt->duration != AV_NOPTS_VALUE && m_fmt->duration > 0) + m_durationSecs = m_fmt->duration / AV_TIME_BASE; + + m_audioIdx = av_find_best_stream(m_fmt.get(), AVMEDIA_TYPE_AUDIO, + -1, -1, nullptr, 0); + if (m_audioIdx < 0) { + qWarning() << "[FFmpegDecoder] No audio stream in:" << filePath; + close(); + return false; + } + + AVStream *stream = m_fmt->streams[m_audioIdx]; + const AVCodec *dec = avcodec_find_decoder(stream->codecpar->codec_id); + if (!dec) { + qWarning() << "[FFmpegDecoder] No decoder for codec"; + close(); + return false; + } + + AVCodecContext *rawCodec = avcodec_alloc_context3(dec); + avcodec_parameters_to_context(rawCodec, stream->codecpar); + if (avcodec_open2(rawCodec, dec, nullptr) < 0) { + avcodec_free_context(&rawCodec); + qWarning() << "[FFmpegDecoder] Cannot open codec"; + close(); + return false; + } + m_codec.reset(rawCodec); + + m_sampleRate = m_codec->sample_rate; + m_channels = m_codec->ch_layout.nb_channels; + + // Derive total frames from stream duration where possible + if (stream->duration != AV_NOPTS_VALUE) { + double tb = av_q2d(stream->time_base); + m_totalFrames = static_cast(stream->duration * tb * m_sampleRate); + } else if (m_durationSecs > 0) { + m_totalFrames = m_durationSecs * m_sampleRate; + } + + AVChannelLayout outLayout{}; + av_channel_layout_default(&outLayout, m_channels); + + SwrContext *rawSwr = nullptr; + swr_alloc_set_opts2(&rawSwr, + &outLayout, AV_SAMPLE_FMT_FLT, m_sampleRate, + &m_codec->ch_layout, m_codec->sample_fmt, m_sampleRate, + 0, nullptr); + swr_init(rawSwr); + m_swr.reset(rawSwr); + + m_pkt.reset(av_packet_alloc()); + m_frame.reset(av_frame_alloc()); + + return true; +} + +void FFmpegDecoder::close() { + m_frame.reset(); + m_pkt.reset(); + m_swr.reset(); + m_codec.reset(); + m_fmt.reset(); + m_audioIdx = -1; + m_sampleRate = 0; + m_channels = 0; + m_durationSecs = -1; + m_totalFrames = 0; +} + +void FFmpegDecoder::seek(double fraction) { + if (!m_fmt) return; + fraction = std::clamp(fraction, 0.0, 1.0); + + qint64 durUs = (m_fmt->duration != AV_NOPTS_VALUE && m_fmt->duration > 0) + ? m_fmt->duration + : (m_durationSecs * AV_TIME_BASE); + + if (durUs <= 0) return; + + qint64 targetUs = static_cast(fraction * static_cast(durUs)); + av_seek_frame(m_fmt.get(), -1, targetUs, AVSEEK_FLAG_BACKWARD); + avcodec_flush_buffers(m_codec.get()); +} + +bool FFmpegDecoder::decodeNext(AudioBuffer &buf) { + if (!m_fmt || !m_codec || !m_swr) return false; + + buf.sampleRate = m_sampleRate; + buf.channels = m_channels; + buf.samples.clear(); + + while (true) { + int ret = av_read_frame(m_fmt.get(), m_pkt.get()); + if (ret < 0) return false; // EOF or error + + if (m_pkt->stream_index != m_audioIdx) { + av_packet_unref(m_pkt.get()); + continue; + } + + avcodec_send_packet(m_codec.get(), m_pkt.get()); + av_packet_unref(m_pkt.get()); + + if (receiveFrames(buf)) + return true; + } +} + +bool FFmpegDecoder::receiveFrames(AudioBuffer &buf) { + bool gotSamples = false; + + while (true) { + int ret = avcodec_receive_frame(m_codec.get(), m_frame.get()); + if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) break; + if (ret < 0) break; + + int outSamples = swr_get_out_samples(m_swr.get(), m_frame->nb_samples); + std::size_t offset = buf.samples.size(); + buf.samples.resize(offset + static_cast(outSamples) * m_channels); + + uint8_t *outPtr = reinterpret_cast(buf.samples.data() + offset); + int converted = swr_convert(m_swr.get(), + &outPtr, outSamples, + const_cast(m_frame->data), m_frame->nb_samples); + + if (converted <= 0) { + buf.samples.resize(offset); // roll back the reservation + } else { + buf.samples.resize(offset + static_cast(converted) * m_channels); + gotSamples = true; + } + + av_frame_unref(m_frame.get()); + } + + return gotSamples; +} + +} // namespace engine \ No newline at end of file diff --git a/src/engine/FFmpegDecoder.h b/src/engine/FFmpegDecoder.h new file mode 100644 index 0000000..3926fa4 --- /dev/null +++ b/src/engine/FFmpegDecoder.h @@ -0,0 +1,57 @@ +#pragma once +#include +#include +#include "AudioBuffer.h" + +struct AVFormatContext; +struct AVCodecContext; +struct SwrContext; +struct AVPacket; +struct AVFrame; + +namespace engine { + +class FFmpegDecoder { +public: + FFmpegDecoder(); + ~FFmpegDecoder(); + + FFmpegDecoder(const FFmpegDecoder &) = delete; + FFmpegDecoder &operator=(const FFmpegDecoder &) = delete; + + bool open(const QString &filePath); + + void close(); + + bool decodeNext(AudioBuffer &buf); + + void seek(double fraction); + + int sampleRate() const { return m_sampleRate; } + int channels() const { return m_channels; } + qint64 durationSeconds() const { return m_durationSecs; } + qint64 totalFrames() const { return m_totalFrames; } + +private: + struct FmtDeleter { void operator()(AVFormatContext *p) const; }; + struct CodecDeleter { void operator()(AVCodecContext *p) const; }; + struct SwrDeleter { void operator()(SwrContext *p) const; }; + struct PktDeleter { void operator()(AVPacket *p) const; }; + struct FrameDeleter { void operator()(AVFrame *p) const; }; + + std::unique_ptr m_fmt; + std::unique_ptr m_codec; + std::unique_ptr m_swr; + std::unique_ptr m_pkt; + std::unique_ptr m_frame; + + int m_audioIdx { -1 }; + int m_sampleRate { 0 }; + int m_channels { 0 }; + qint64 m_durationSecs{ -1 }; + qint64 m_totalFrames { 0 }; + + bool receiveFrames(AudioBuffer &buf); +}; + +} // namespace engine \ No newline at end of file diff --git a/src/engine/PlaybackState.h b/src/engine/PlaybackState.h new file mode 100644 index 0000000..fbacf50 --- /dev/null +++ b/src/engine/PlaybackState.h @@ -0,0 +1,21 @@ +#pragma once +#include + +namespace engine { + +enum class State { Stopped, Playing, Paused }; + +struct PlaybackState { + std::atomic state { State::Stopped }; + std::atomic posFrames { 0 }; + std::atomic totalFrames { 0 }; + std::atomic sampleRate { 44100 }; + + void reset() { + state = State::Stopped; + posFrames = 0; + totalFrames = 0; + } +}; + +} // namespace engine \ No newline at end of file diff --git a/src/engine/SpectrumAnalyzer.cpp b/src/engine/SpectrumAnalyzer.cpp new file mode 100644 index 0000000..1b0fc13 --- /dev/null +++ b/src/engine/SpectrumAnalyzer.cpp @@ -0,0 +1,84 @@ +#include "SpectrumAnalyzer.h" +#include +#include +#include +#include + +namespace engine { + +SpectrumAnalyzer::SpectrumAnalyzer(QObject *parent) + : QObject(parent) +{ + m_fftIn = fftw_alloc_real(FFT_SIZE); + m_fftOut = fftw_alloc_complex(FFT_SIZE / 2 + 1); + if (!m_fftIn || !m_fftOut) + throw std::bad_alloc(); + + m_plan = fftw_plan_dft_r2c_1d(FFT_SIZE, m_fftIn, m_fftOut, FFTW_ESTIMATE); + if (!m_plan) + throw std::runtime_error("FFTW plan creation failed"); + + reset(); +} + +SpectrumAnalyzer::~SpectrumAnalyzer() +{ + if (m_plan) fftw_destroy_plan(m_plan); + if (m_fftIn) fftw_free(m_fftIn); + if (m_fftOut) fftw_free(m_fftOut); +} + +void SpectrumAnalyzer::reset() +{ + std::fill(std::begin(m_buf), std::end(m_buf), 0.f); + m_pos = FFT_SIZE / 2; +} + +void SpectrumAnalyzer::feed(const AudioBuffer &buf) +{ + const int channels = buf.channels; + const int count = buf.sampleCount(); + const float *src = buf.samples.data(); + + for (int i = 0; i < count; i += channels) { + float mono = 0.f; + for (int c = 0; c < channels; ++c) + mono += src[i + c]; + mono /= static_cast(channels); + + m_buf[m_pos++] = mono; + + if (m_pos == FFT_SIZE) { + emit fftReady(computeFFT(m_buf)); + // 50 % overlap + std::copy(m_buf + FFT_SIZE / 2, m_buf + FFT_SIZE, m_buf); + m_pos = FFT_SIZE / 2; + } + } +} + +QVector SpectrumAnalyzer::computeFFT(const float *input) +{ + // Copy input and apply Hann window + const double invN1 = 1.0 / static_cast(FFT_SIZE - 1); + for (int i = 0; i < FFT_SIZE; ++i) { + const double w = 0.5 * (1.0 - std::cos(2.0 * std::numbers::pi * i * invN1)); + m_fftIn[i] = static_cast(input[i]) * w; + } + + // Execute FFTW plan + fftw_execute(m_plan); + + // Compute magnitude spectrum (bins 0 .. FFT_SIZE/2 - 1) + const int bins = FFT_SIZE / 2; + QVector mag(bins); + const double invN = 1.0 / static_cast(FFT_SIZE); + for (int i = 0; i < bins; ++i) { + const double re = m_fftOut[i][0]; + const double im = m_fftOut[i][1]; + mag[i] = static_cast(std::sqrt(re * re + im * im) * invN); + } + return mag; +} + +} // namespace engine \ No newline at end of file diff --git a/src/engine/SpectrumAnalyzer.h b/src/engine/SpectrumAnalyzer.h new file mode 100644 index 0000000..2adadca --- /dev/null +++ b/src/engine/SpectrumAnalyzer.h @@ -0,0 +1,35 @@ +#pragma once +#include +#include +#include "AudioBuffer.h" +#include + +namespace engine { + +class SpectrumAnalyzer : public QObject { + Q_OBJECT +public: + static constexpr int FFT_SIZE = 4096; + + explicit SpectrumAnalyzer(QObject *parent = nullptr); + ~SpectrumAnalyzer() override; + + void feed(const AudioBuffer &buf); + void reset(); + +signals: + void fftReady(QVector magnitudes); + +private: + QVector computeFFT(const float *input); + + float m_buf[FFT_SIZE] {}; + int m_pos { 0 }; + + // FFTW members + fftw_plan m_plan = nullptr; + double *m_fftIn = nullptr; + fftw_complex *m_fftOut = nullptr; +}; + +} // namespace engine \ No newline at end of file diff --git a/src/ico/wavy_ico.ico b/src/ico/wavy_ico.ico new file mode 100644 index 0000000..faa6906 Binary files /dev/null and b/src/ico/wavy_ico.ico differ diff --git a/src/ico/wavy_png.png b/src/ico/wavy_png.png new file mode 100644 index 0000000..437336a Binary files /dev/null and b/src/ico/wavy_png.png differ diff --git a/src/main.cpp b/src/main.cpp index f6fc9ac..626d2d6 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -8,8 +8,10 @@ int main(int argc, char *argv[]) QApplication app(argc, argv); app.setApplicationName("SubWave"); app.setOrganizationName("SubWave"); + app.setWindowIcon(QIcon(":/icons/ico/wavy_png.png")); - qDebug() << QResource(":/shaders/bars.vert").isValid(); + qDebug() << "AppIcon there?: " << QResource(":/icons/ico/wavy_png.png").isValid(); + qDebug() << "Shaders there?: " << QResource(":/shaders/bars.vert").isValid(); MainWindow w; w.show();