implement new audio engine
This commit is contained in:
+6
-1
@@ -55,4 +55,9 @@ compile_commands.json
|
|||||||
|
|
||||||
*_qmlcache.qrc
|
*_qmlcache.qrc
|
||||||
|
|
||||||
./build
|
./build
|
||||||
|
./dist
|
||||||
|
AppDir
|
||||||
|
|
||||||
|
*.AppImage
|
||||||
|
*.html
|
||||||
+16
-8
@@ -4,10 +4,9 @@ project(SubWave LANGUAGES CXX)
|
|||||||
set(CMAKE_CXX_STANDARD 20)
|
set(CMAKE_CXX_STANDARD 20)
|
||||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||||
|
|
||||||
# ── Find Qt6 ─────────────────────────────────────────────────────────────────
|
|
||||||
find_package(Qt6 REQUIRED COMPONENTS Widgets Multimedia OpenGL OpenGLWidgets)
|
find_package(Qt6 REQUIRED COMPONENTS Widgets Multimedia OpenGL OpenGLWidgets)
|
||||||
|
|
||||||
# ── Find FFmpeg via pkg-config ───────────────────────────────────────────────
|
# require ffmpeg
|
||||||
find_package(PkgConfig REQUIRED)
|
find_package(PkgConfig REQUIRED)
|
||||||
pkg_check_modules(FFMPEG REQUIRED IMPORTED_TARGET
|
pkg_check_modules(FFMPEG REQUIRED IMPORTED_TARGET
|
||||||
libavformat
|
libavformat
|
||||||
@@ -15,17 +14,21 @@ pkg_check_modules(FFMPEG REQUIRED IMPORTED_TARGET
|
|||||||
libavutil
|
libavutil
|
||||||
libswresample
|
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_AUTOMOC ON)
|
||||||
set(CMAKE_AUTORCC ON)
|
set(CMAKE_AUTORCC ON)
|
||||||
|
|
||||||
# ── Sources ──────────────────────────────────────────────────────────────────
|
# source files
|
||||||
set(SOURCES
|
set(SOURCES
|
||||||
src/main.cpp
|
src/main.cpp
|
||||||
src/MetadataReader.cpp
|
src/MetadataReader.cpp
|
||||||
src/Config.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/MusicLibrary.cpp
|
||||||
src/CoverArtWidget.cpp
|
src/CoverArtWidget.cpp
|
||||||
src/SpectrogramWidget.cpp
|
src/SpectrogramWidget.cpp
|
||||||
@@ -40,7 +43,7 @@ set(HEADERS
|
|||||||
src/Track.h
|
src/Track.h
|
||||||
src/MetadataReader.h
|
src/MetadataReader.h
|
||||||
src/Config.h
|
src/Config.h
|
||||||
src/AudioEngine.h
|
src/engine/AudioEngine.h
|
||||||
src/MusicLibrary.h
|
src/MusicLibrary.h
|
||||||
src/CoverArtWidget.h
|
src/CoverArtWidget.h
|
||||||
src/SpectrogramWidget.h
|
src/SpectrogramWidget.h
|
||||||
@@ -51,19 +54,24 @@ set(HEADERS
|
|||||||
src/MainWindow.h
|
src/MainWindow.h
|
||||||
)
|
)
|
||||||
|
|
||||||
set(RESOURCES src/shaders.qrc)
|
set(RESOURCES
|
||||||
|
src/shaders.qrc
|
||||||
|
src/application.qrc
|
||||||
|
)
|
||||||
|
|
||||||
add_executable(SubWave ${SOURCES} ${HEADERS} ${RESOURCES})
|
add_executable(SubWave ${SOURCES} ${HEADERS} ${RESOURCES})
|
||||||
|
|
||||||
|
target_include_directories(SubWave PRIVATE src src/engine)
|
||||||
|
|
||||||
target_link_libraries(SubWave PRIVATE
|
target_link_libraries(SubWave PRIVATE
|
||||||
Qt6::Widgets
|
Qt6::Widgets
|
||||||
Qt6::Multimedia
|
Qt6::Multimedia
|
||||||
Qt6::OpenGL
|
Qt6::OpenGL
|
||||||
Qt6::OpenGLWidgets
|
Qt6::OpenGLWidgets
|
||||||
PkgConfig::FFMPEG
|
PkgConfig::FFMPEG
|
||||||
|
PkgConfig::FFTW
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── Copy FFmpeg shared libs next to the binary on Windows ────────────────────
|
|
||||||
if(WIN32)
|
if(WIN32)
|
||||||
add_custom_command(TARGET SubWave POST_BUILD
|
add_custom_command(TARGET SubWave POST_BUILD
|
||||||
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
rm -rf build
|
||||||
|
mkdir build
|
||||||
|
cd build
|
||||||
|
cmake ..
|
||||||
|
make -j$(nproc)
|
||||||
|
./SubWave
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 648 B |
Executable
+86
@@ -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" <<EOF
|
||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=SubWave
|
||||||
|
Exec=SubWave
|
||||||
|
Icon=subwave
|
||||||
|
Categories=AudioVideo;Audio;Player;
|
||||||
|
Terminal=false
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "==> 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"
|
||||||
Executable
+42
@@ -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"
|
||||||
@@ -1,355 +0,0 @@
|
|||||||
#include "AudioEngine.h"
|
|
||||||
#include <cmath>
|
|
||||||
#include <numbers>
|
|
||||||
#include <algorithm>
|
|
||||||
#include <QThread>
|
|
||||||
#include <QMediaDevices>
|
|
||||||
#include <QAudioDevice>
|
|
||||||
#include <QAudioFormat>
|
|
||||||
|
|
||||||
extern "C" {
|
|
||||||
#include <libavformat/avformat.h>
|
|
||||||
#include <libavcodec/avcodec.h>
|
|
||||||
#include <libavutil/avutil.h>
|
|
||||||
#include <libswresample/swresample.h>
|
|
||||||
}
|
|
||||||
|
|
||||||
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<float>(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<float> 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<float> AudioEngine::computeFFT(const float *input, int n) {
|
|
||||||
std::vector<float> 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<float> * i / (n-1)));
|
|
||||||
for (int j=1, i=0; j<n; j++) {
|
|
||||||
int bit = n>>1;
|
|
||||||
for (; (i&bit)!=0; bit>>=1) i^=bit;
|
|
||||||
i^=bit;
|
|
||||||
if (j<i) { std::swap(re[j],re[i]); std::swap(im[j],im[i]); }
|
|
||||||
}
|
|
||||||
for (int len=2; len<=n; len<<=1) {
|
|
||||||
double angle = -2.0*std::numbers::pi/len;
|
|
||||||
float wRe = std::cos(angle), wIm = std::sin(angle);
|
|
||||||
for (int i=0; i<n; i+=len) {
|
|
||||||
float cRe=1, cIm=0;
|
|
||||||
for (int j=0; j<len/2; j++) {
|
|
||||||
float uRe=re[i+j], uIm=im[i+j];
|
|
||||||
float vRe=re[i+j+len/2]*cRe - im[i+j+len/2]*cIm;
|
|
||||||
float vIm=re[i+j+len/2]*cIm + im[i+j+len/2]*cRe;
|
|
||||||
re[i+j]=uRe+vRe; im[i+j]=uIm+vIm;
|
|
||||||
re[i+j+len/2]=uRe-vRe; im[i+j+len/2]=uIm-vIm;
|
|
||||||
float nRe=cRe*wRe-cIm*wIm; cIm=cRe*wIm+cIm*wRe; cRe=nRe;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
QVector<float> mag(n/2);
|
|
||||||
for (int i=0; i<n/2; i++)
|
|
||||||
mag[i] = std::sqrt(re[i]*re[i]+im[i]*im[i]) / n;
|
|
||||||
return mag;
|
|
||||||
}
|
|
||||||
|
|
||||||
void AudioEngine::playbackLoop() {
|
|
||||||
m_state = State::Playing;
|
|
||||||
|
|
||||||
AVFormatContext *fmt = nullptr;
|
|
||||||
AVCodecContext *codec = nullptr;
|
|
||||||
SwrContext *swr = nullptr;
|
|
||||||
AVPacket *pkt = av_packet_alloc();
|
|
||||||
AVFrame *frame = av_frame_alloc();
|
|
||||||
|
|
||||||
auto cleanup = [&]{
|
|
||||||
av_frame_free(&frame);
|
|
||||||
av_packet_free(&pkt);
|
|
||||||
swr_free(&swr);
|
|
||||||
if (codec) avcodec_free_context(&codec);
|
|
||||||
if (fmt) avformat_close_input(&fmt);
|
|
||||||
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
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<float> 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<qint64>(frac * durUs);
|
|
||||||
av_seek_frame(fmt, -1, targetUs, AVSEEK_FLAG_BACKWARD);
|
|
||||||
avcodec_flush_buffers(codec);
|
|
||||||
m_posFrames = static_cast<qint64>(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<size_t>(outSamples) * channels);
|
|
||||||
|
|
||||||
uint8_t *outPtr = reinterpret_cast<uint8_t*>(outBuf.data());
|
|
||||||
int converted = swr_convert(swr,
|
|
||||||
&outPtr, outSamples,
|
|
||||||
const_cast<const uint8_t**>(frame->data), frame->nb_samples);
|
|
||||||
|
|
||||||
if (converted <= 0) { av_frame_unref(frame); continue; }
|
|
||||||
|
|
||||||
int count = converted * channels;
|
|
||||||
outBuf.resize(static_cast<size_t>(count));
|
|
||||||
|
|
||||||
applyEq(outBuf.data(), count, channels);
|
|
||||||
feedFft(outBuf.data(), count, channels);
|
|
||||||
|
|
||||||
const char *src = reinterpret_cast<const char*>(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<qint64>(stream->duration * tb * outRate);
|
|
||||||
}
|
|
||||||
|
|
||||||
av_frame_unref(frame);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bool natural = !m_stopReq;
|
|
||||||
cleanup();
|
|
||||||
if (natural) emit trackEnded();
|
|
||||||
}
|
|
||||||
+111
-74
@@ -5,13 +5,14 @@
|
|||||||
#include <QMessageBox>
|
#include <QMessageBox>
|
||||||
#include <QInputDialog>
|
#include <QInputDialog>
|
||||||
#include <QFileDialog>
|
#include <QFileDialog>
|
||||||
|
#include <QStyleFactory>
|
||||||
#include <QFile>
|
#include <QFile>
|
||||||
#include <QDir>
|
#include <QDir>
|
||||||
|
#include <QPainter>
|
||||||
|
#include <QPainterPath>
|
||||||
#include <QJsonDocument>
|
#include <QJsonDocument>
|
||||||
#include <QJsonObject>
|
#include <QJsonObject>
|
||||||
#include <QJsonArray>
|
#include <QJsonArray>
|
||||||
#include <QPainter>
|
|
||||||
#include <QPainterPath>
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
|
|
||||||
@@ -41,6 +42,7 @@ EqualizerWidget::EqualizerWidget(AudioEngine *engine, QWidget *parent)
|
|||||||
auto *topBar = new QHBoxLayout;
|
auto *topBar = new QHBoxLayout;
|
||||||
topBar->addWidget(new QLabel("Preset:"));
|
topBar->addWidget(new QLabel("Preset:"));
|
||||||
m_presetBox = new QComboBox;
|
m_presetBox = new QComboBox;
|
||||||
|
m_presetBox->setStyle(QStyleFactory::create("Fusion"));
|
||||||
topBar->addWidget(m_presetBox);
|
topBar->addWidget(m_presetBox);
|
||||||
auto *saveBtn = new QPushButton("Save...");
|
auto *saveBtn = new QPushButton("Save...");
|
||||||
auto *deleteBtn = new QPushButton("Delete");
|
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] = new QLabel("0 dB");
|
||||||
m_dbLabels[i]->setAlignment(Qt::AlignHCenter);
|
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] = new QSlider(Qt::Vertical);
|
||||||
m_sliders[i]->setRange(-120, 120);
|
m_sliders[i]->setRange(-120, 120);
|
||||||
@@ -97,8 +103,16 @@ EqualizerWidget::EqualizerWidget(AudioEngine *engine, QWidget *parent)
|
|||||||
populatePresetBox();
|
populatePresetBox();
|
||||||
loadState();
|
loadState();
|
||||||
|
|
||||||
connect(m_presetBox, QOverload<int>::of(&QComboBox::currentIndexChanged),
|
connect(m_presetBox, QOverload<int>::of(&QComboBox::activated),
|
||||||
this, &EqualizerWidget::applyPresetAt);
|
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]{
|
connect(saveBtn, &QPushButton::clicked, this, [this]{
|
||||||
bool ok = false;
|
bool ok = false;
|
||||||
QString name = QInputDialog::getText(this, "Save Preset", "Preset name:", QLineEdit::Normal, "", &ok);
|
QString name = QInputDialog::getText(this, "Save Preset", "Preset name:", QLineEdit::Normal, "", &ok);
|
||||||
@@ -126,44 +140,43 @@ EqualizerWidget::EqualizerWidget(AudioEngine *engine, QWidget *parent)
|
|||||||
savePresets();
|
savePresets();
|
||||||
populatePresetBox();
|
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() {
|
void EqualizerWidget::saveState() {
|
||||||
QDir().mkpath(QFileInfo(stateFile()).absolutePath());
|
QDir().mkpath(QFileInfo(stateFile()).absolutePath());
|
||||||
QFile f(stateFile());
|
QFile f(stateFile());
|
||||||
if (!f.open(QIODevice::WriteOnly|QIODevice::Truncate)) return;
|
if (!f.open(QIODevice::WriteOnly|QIODevice::Truncate)) return;
|
||||||
|
QJsonObject obj;
|
||||||
QString vals;
|
QString vals;
|
||||||
for (int i=0; i<BANDS; i++) {
|
for (int i=0; i<BANDS; i++) {
|
||||||
if (i) vals += ',';
|
if (i) vals += ',';
|
||||||
vals += QString::number(m_sliders[i]->value());
|
vals += QString::number(m_sliders[i]->value());
|
||||||
}
|
}
|
||||||
f.write(("{\n \"bands\": \"" + vals + "\"\n}\n").toUtf8());
|
obj["bands"] = vals;
|
||||||
|
f.write(QJsonDocument(obj).toJson());
|
||||||
}
|
}
|
||||||
|
|
||||||
void EqualizerWidget::loadPresets() {
|
void EqualizerWidget::loadPresets() {
|
||||||
m_userPresets.clear();
|
|
||||||
QFile f(presetFile());
|
QFile f(presetFile());
|
||||||
if (!f.open(QIODevice::ReadOnly)) return;
|
if (!f.open(QIODevice::ReadOnly)) return;
|
||||||
QString json = QString::fromUtf8(f.readAll());
|
QJsonDocument doc = QJsonDocument::fromJson(f.readAll());
|
||||||
int pos = 0;
|
m_userPresets.clear();
|
||||||
while (pos < json.size()) {
|
if (!doc.isArray()) return;
|
||||||
int ns = json.indexOf("\"name\"", pos); if (ns<0) break;
|
for (const QJsonValue &v : doc.array()) {
|
||||||
int nq1= json.indexOf('"', ns+7); if (nq1<0) break;
|
QJsonObject obj = v.toObject();
|
||||||
int nq2= nq1+1;
|
QString name = obj["name"].toString();
|
||||||
while (nq2<json.size() && json[nq2]!='"') {
|
QString bands = obj["bands"].toString();
|
||||||
if (json[nq2]=='\\') nq2++; nq2++;
|
if (!name.isEmpty() && !bands.isEmpty())
|
||||||
}
|
m_userPresets.append({name, bands});
|
||||||
QString name = json.mid(nq1+1, nq2-nq1-1);
|
|
||||||
|
|
||||||
int bs = json.indexOf("\"bands\"", nq2); if (bs<0) break;
|
|
||||||
int bq1= json.indexOf('"', bs+8); if (bq1<0) break;
|
|
||||||
int bq2= bq1+1;
|
|
||||||
while (bq2<json.size() && json[bq2]!='"') bq2++;
|
|
||||||
QString bands = json.mid(bq1+1, bq2-bq1-1);
|
|
||||||
|
|
||||||
m_userPresets.append({name, bands});
|
|
||||||
pos = bq2+1;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,18 +184,14 @@ void EqualizerWidget::savePresets() {
|
|||||||
QDir().mkpath(QFileInfo(presetFile()).absolutePath());
|
QDir().mkpath(QFileInfo(presetFile()).absolutePath());
|
||||||
QFile f(presetFile());
|
QFile f(presetFile());
|
||||||
if (!f.open(QIODevice::WriteOnly|QIODevice::Truncate)) return;
|
if (!f.open(QIODevice::WriteOnly|QIODevice::Truncate)) return;
|
||||||
QString json = "[\n";
|
QJsonArray arr;
|
||||||
for (int i=0; i<m_userPresets.size(); i++) {
|
for (const auto &pr : m_userPresets) {
|
||||||
auto escq = [](const QString &s){
|
QJsonObject obj;
|
||||||
return QString(s).replace("\\","\\\\").replace("\"","\\\"");
|
obj["name"] = pr.first;
|
||||||
};
|
obj["bands"] = pr.second;
|
||||||
json += " {\"name\":\"" + escq(m_userPresets[i].first)
|
arr.append(obj);
|
||||||
+ "\",\"bands\":\"" + m_userPresets[i].second + "\"}";
|
|
||||||
if (i+1<m_userPresets.size()) json += ",";
|
|
||||||
json += "\n";
|
|
||||||
}
|
}
|
||||||
json += "]\n";
|
f.write(QJsonDocument(arr).toJson());
|
||||||
f.write(json.toUtf8());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void EqualizerWidget::populatePresetBox() {
|
void EqualizerWidget::populatePresetBox() {
|
||||||
@@ -215,18 +224,16 @@ void EqualizerWidget::applyPresetAt(int idx) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
m_curve->update();
|
m_curve->update();
|
||||||
m_presetBox->hidePopup();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void EqualizerWidget::loadState() {
|
void EqualizerWidget::loadState() {
|
||||||
QFile f(stateFile());
|
QFile f(stateFile());
|
||||||
if (!f.open(QIODevice::ReadOnly)) return;
|
if (!f.open(QIODevice::ReadOnly)) return;
|
||||||
QString json = QString::fromUtf8(f.readAll());
|
QJsonDocument doc = QJsonDocument::fromJson(f.readAll());
|
||||||
int bi = json.indexOf("\"bands\""); if (bi<0) return;
|
if (!doc.isObject()) return;
|
||||||
int q1 = json.indexOf('"', bi+8); if (q1<0) return;
|
QJsonObject obj = doc.object();
|
||||||
int q2 = q1+1;
|
if (!obj.contains("bands")) return;
|
||||||
while (q2<json.size() && json[q2]!='"') q2++;
|
QString vals = obj["bands"].toString();
|
||||||
QString vals = json.mid(q1+1, q2-q1-1);
|
|
||||||
QStringList parts = vals.split(',');
|
QStringList parts = vals.split(',');
|
||||||
for (int i=0; i<BANDS && i<parts.size(); i++) {
|
for (int i=0; i<BANDS && i<parts.size(); i++) {
|
||||||
bool ok=false;
|
bool ok=false;
|
||||||
@@ -247,43 +254,73 @@ void EqualizerWidget::FreqCurveWidget::paintEvent(QPaintEvent *) {
|
|||||||
if (!m_sliders) return;
|
if (!m_sliders) return;
|
||||||
QPainter p(this);
|
QPainter p(this);
|
||||||
p.setRenderHint(QPainter::Antialiasing);
|
p.setRenderHint(QPainter::Antialiasing);
|
||||||
int w=width(), h=height();
|
int w = width(), h = height();
|
||||||
|
const int labelArea = 20;
|
||||||
|
int graphH = h - labelArea;
|
||||||
|
|
||||||
p.fillRect(rect(), palette().base());
|
p.fillRect(rect(), palette().base());
|
||||||
|
|
||||||
float midY = h/2.f;
|
float logMin = std::log10(20.f);
|
||||||
p.setPen(QColor(180,180,180));
|
float logMax = std::log10(20000.f);
|
||||||
p.drawLine(0,int(midY),w,int(midY));
|
auto freqToX = [&](float f) -> 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;
|
// Draw frequency axis ticks and labels
|
||||||
for (int i=0; i<w; i++) {
|
const float tickFreqs[] = {20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000};
|
||||||
float f = std::pow(10.f, logMin+(logMax-logMin)*i/w);
|
QFont tickFont = font();
|
||||||
double totalDb = 0;
|
tickFont.setPointSizeF(7);
|
||||||
for (int b=0; b<BANDS; b++) {
|
p.setFont(tickFont);
|
||||||
float db = m_sliders[b]->value()/10.f;
|
p.setPen(palette().windowText().color());
|
||||||
if (std::abs(db)<0.01f) continue;
|
for (float fq : tickFreqs) {
|
||||||
float f0 = AudioEngine::EQ_FREQS[b], Q=1.41421356f;
|
float x = freqToX(fq);
|
||||||
double ratio = f/f0;
|
if (x < 0 || x > w) continue;
|
||||||
double lrSq = (ratio-1.0/ratio)*(ratio-1.0/ratio);
|
// Tick mark
|
||||||
double denom = 1+lrSq/(Q*Q*(ratio+1.0/ratio)*(ratio+1.0/ratio));
|
p.drawLine(QPointF(x, graphH), QPointF(x, graphH + 3));
|
||||||
totalDb += db*denom;
|
// Label
|
||||||
}
|
QString label;
|
||||||
float py = midY - float(totalDb/12.0*h/2*0.9);
|
if (fq >= 1000) label = QString::number(fq / 1000, 'f', 0) + "k";
|
||||||
py = std::clamp(py, 0.f, float(h));
|
else label = QString::number(fq, 'f', 0);
|
||||||
if (i==0) path.moveTo(i,py); else path.lineTo(i,py);
|
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);
|
p.drawPath(path);
|
||||||
|
|
||||||
|
// Draw control points (band handles)
|
||||||
p.setPen(Qt::NoPen);
|
p.setPen(Qt::NoPen);
|
||||||
p.setBrush(QColor(60,120,255));
|
p.setBrush(QColor(60, 120, 255));
|
||||||
for (int b=0; b<BANDS; b++) {
|
for (int b = 0; b < BANDS; ++b) {
|
||||||
float f0 = AudioEngine::EQ_FREQS[b];
|
float f0 = AudioEngine::EQ_FREQS[b];
|
||||||
float db = m_sliders[b]->value()/10.f;
|
float db = m_sliders[b]->value() / 10.f;
|
||||||
float nx = (std::log10(f0)-logMin)/(logMax-logMin)*w;
|
float nx = freqToX(f0);
|
||||||
float ny = midY-(db/12.f*h/2*0.9f);
|
float ny = midY - (db / 12.f * graphH / 2 * 0.9f);
|
||||||
ny = std::clamp(ny,0.f,float(h));
|
ny = std::clamp(ny, 0.f, float(graphH));
|
||||||
p.drawEllipse(QPointF(nx,ny),3,3);
|
p.drawEllipse(QPointF(nx, ny), 3, 3);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,9 @@
|
|||||||
#include <QSlider>
|
#include <QSlider>
|
||||||
#include <QLabel>
|
#include <QLabel>
|
||||||
#include <QPushButton>
|
#include <QPushButton>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QJsonArray>
|
||||||
#include "AudioEngine.h"
|
#include "AudioEngine.h"
|
||||||
|
|
||||||
class EqualizerWidget : public QWidget {
|
class EqualizerWidget : public QWidget {
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<RCC>
|
||||||
|
<qresource prefix="/icons">
|
||||||
|
<file>ico/wavy_ico.ico</file>
|
||||||
|
<file>ico/wavy_png.png</file>
|
||||||
|
</qresource>
|
||||||
|
</RCC>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <vector>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
namespace engine {
|
||||||
|
|
||||||
|
struct AudioBuffer {
|
||||||
|
std::vector<float> samples;
|
||||||
|
int sampleRate { 0 };
|
||||||
|
int channels { 0 };
|
||||||
|
|
||||||
|
int frameCount() const {
|
||||||
|
return channels > 0 ? static_cast<int>(samples.size()) / channels : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool empty() const { return samples.empty(); }
|
||||||
|
|
||||||
|
void resize(int frames) {
|
||||||
|
samples.resize(static_cast<std::size_t>(frames) * channels);
|
||||||
|
}
|
||||||
|
|
||||||
|
void truncate(int frames) {
|
||||||
|
samples.resize(static_cast<std::size_t>(frames) * channels);
|
||||||
|
}
|
||||||
|
|
||||||
|
int sampleCount() const { return static_cast<int>(samples.size()); }
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace engine
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
#include "AudioEngine.h"
|
||||||
|
#include "engine/FFmpegDecoder.h"
|
||||||
|
#include "engine/EqProcessor.h"
|
||||||
|
#include "engine/SpectrumAnalyzer.h"
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
#include <numbers>
|
||||||
|
#include <algorithm>
|
||||||
|
#include <QThread>
|
||||||
|
#include <QMediaDevices>
|
||||||
|
#include <QAudioDevice>
|
||||||
|
#include <QAudioFormat>
|
||||||
|
#include <QSemaphore>
|
||||||
|
#include <QMutexLocker>
|
||||||
|
|
||||||
|
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<qint64>(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<const char *>(buffer.samples.data());
|
||||||
|
qsizetype bytes = static_cast<qsizetype>(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();
|
||||||
|
}
|
||||||
@@ -7,7 +7,9 @@
|
|||||||
#include <QSemaphore>
|
#include <QSemaphore>
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
#include <QVector>
|
||||||
#include "Track.h"
|
#include "Track.h"
|
||||||
|
#include "engine/EqProcessor.h"
|
||||||
|
|
||||||
class AudioEngine : public QObject {
|
class AudioEngine : public QObject {
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
@@ -67,6 +69,7 @@ private:
|
|||||||
QWaitCondition m_pauseCv;
|
QWaitCondition m_pauseCv;
|
||||||
|
|
||||||
Track m_track;
|
Track m_track;
|
||||||
|
engine::EqProcessor m_eq;
|
||||||
|
|
||||||
float m_eqGainDb[EQ_BANDS] {};
|
float m_eqGainDb[EQ_BANDS] {};
|
||||||
float m_bqCoeff[EQ_BANDS][5] {};
|
float m_bqCoeff[EQ_BANDS][5] {};
|
||||||
@@ -75,4 +78,4 @@ private:
|
|||||||
static constexpr int FFT_SIZE = 4096;
|
static constexpr int FFT_SIZE = 4096;
|
||||||
float m_fftBuf[FFT_SIZE] {};
|
float m_fftBuf[FFT_SIZE] {};
|
||||||
int m_fftPos { 0 };
|
int m_fftPos { 0 };
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
#include "EqProcessor.h"
|
||||||
|
#include <cmath>
|
||||||
|
#include <numbers>
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
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<float>(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
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <array>
|
||||||
|
#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
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
#include "FFmpegDecoder.h"
|
||||||
|
#include <QDebug>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
extern "C" {
|
||||||
|
#include <libavformat/avformat.h>
|
||||||
|
#include <libavcodec/avcodec.h>
|
||||||
|
#include <libavutil/avutil.h>
|
||||||
|
#include <libswresample/swresample.h>
|
||||||
|
}
|
||||||
|
|
||||||
|
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<qint64>(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<qint64>(fraction * static_cast<double>(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<std::size_t>(outSamples) * m_channels);
|
||||||
|
|
||||||
|
uint8_t *outPtr = reinterpret_cast<uint8_t *>(buf.samples.data() + offset);
|
||||||
|
int converted = swr_convert(m_swr.get(),
|
||||||
|
&outPtr, outSamples,
|
||||||
|
const_cast<const uint8_t **>(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<std::size_t>(converted) * m_channels);
|
||||||
|
gotSamples = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
av_frame_unref(m_frame.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
return gotSamples;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace engine
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <QString>
|
||||||
|
#include <memory>
|
||||||
|
#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<AVFormatContext, FmtDeleter> m_fmt;
|
||||||
|
std::unique_ptr<AVCodecContext, CodecDeleter> m_codec;
|
||||||
|
std::unique_ptr<SwrContext, SwrDeleter> m_swr;
|
||||||
|
std::unique_ptr<AVPacket, PktDeleter> m_pkt;
|
||||||
|
std::unique_ptr<AVFrame, FrameDeleter> 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
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <atomic>
|
||||||
|
|
||||||
|
namespace engine {
|
||||||
|
|
||||||
|
enum class State { Stopped, Playing, Paused };
|
||||||
|
|
||||||
|
struct PlaybackState {
|
||||||
|
std::atomic<State> state { State::Stopped };
|
||||||
|
std::atomic<qint64> posFrames { 0 };
|
||||||
|
std::atomic<qint64> totalFrames { 0 };
|
||||||
|
std::atomic<int> sampleRate { 44100 };
|
||||||
|
|
||||||
|
void reset() {
|
||||||
|
state = State::Stopped;
|
||||||
|
posFrames = 0;
|
||||||
|
totalFrames = 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace engine
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
#include "SpectrumAnalyzer.h"
|
||||||
|
#include <cmath>
|
||||||
|
#include <numbers>
|
||||||
|
#include <algorithm>
|
||||||
|
#include <stdexcept>
|
||||||
|
|
||||||
|
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<float>(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<float> SpectrumAnalyzer::computeFFT(const float *input)
|
||||||
|
{
|
||||||
|
// Copy input and apply Hann window
|
||||||
|
const double invN1 = 1.0 / static_cast<double>(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<double>(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<float> mag(bins);
|
||||||
|
const double invN = 1.0 / static_cast<double>(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<float>(std::sqrt(re * re + im * im) * invN);
|
||||||
|
}
|
||||||
|
return mag;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace engine
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <QObject>
|
||||||
|
#include <QVector>
|
||||||
|
#include "AudioBuffer.h"
|
||||||
|
#include <fftw3.h>
|
||||||
|
|
||||||
|
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<float> magnitudes);
|
||||||
|
|
||||||
|
private:
|
||||||
|
QVector<float> 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
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 648 B |
+3
-1
@@ -8,8 +8,10 @@ int main(int argc, char *argv[])
|
|||||||
QApplication app(argc, argv);
|
QApplication app(argc, argv);
|
||||||
app.setApplicationName("SubWave");
|
app.setApplicationName("SubWave");
|
||||||
app.setOrganizationName("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;
|
MainWindow w;
|
||||||
w.show();
|
w.show();
|
||||||
|
|||||||
Reference in New Issue
Block a user