From 4210e5aa8b41625a29aa11172815a40290d5c3f6 Mon Sep 17 00:00:00 2001 From: benya Date: Sun, 19 Apr 2026 10:45:03 +0300 Subject: [PATCH] Initial auCDtect Linux implementation --- .gitignore | 19 + CMakeLists.txt | 42 ++ PKGBUILD | 30 ++ README.md | 94 +++++ assets/aucdtect-linux.png | Bin 0 -> 38917 bytes assets/aucdtect-linux.svg | 28 ++ assets/aucdtect_linux.qrc | 5 + docs/reverse_aucdtect_082.md | 85 ++++ packaging/aucdtect-linux.desktop | 9 + src/AudioAnalyzer.cpp | 572 ++++++++++++++++++++++++++ src/AudioAnalyzer.h | 39 ++ src/MainWindow.cpp | 670 +++++++++++++++++++++++++++++++ src/MainWindow.h | 98 +++++ src/cli_main.cpp | 107 +++++ src/main.cpp | 16 + tools/eval_dataset.sh | 53 +++ 16 files changed, 1867 insertions(+) create mode 100644 .gitignore create mode 100644 CMakeLists.txt create mode 100644 PKGBUILD create mode 100644 README.md create mode 100644 assets/aucdtect-linux.png create mode 100644 assets/aucdtect-linux.svg create mode 100644 assets/aucdtect_linux.qrc create mode 100644 docs/reverse_aucdtect_082.md create mode 100644 packaging/aucdtect-linux.desktop create mode 100644 src/AudioAnalyzer.cpp create mode 100644 src/AudioAnalyzer.h create mode 100644 src/MainWindow.cpp create mode 100644 src/MainWindow.h create mode 100644 src/cli_main.cpp create mode 100644 src/main.cpp create mode 100755 tools/eval_dataset.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bd376a5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +build/ +.codex +.codex/ +*.o +*.a +*.so +*.so.* +*.user +*.autosave + +# Local analysis artifacts and reverse-engineering inputs. +samples/ +dataset_eval.csv +auCDtect.exe +*.wav + +# Locally copied binaries. +/aucdtect +/aucdtect_linux diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..3a25e0f --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,42 @@ +cmake_minimum_required(VERSION 3.21) +project(aucdtect_linux VERSION 0.1.0 LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_AUTOMOC ON) +set(CMAKE_AUTORCC ON) + +find_package(Qt6 QUIET COMPONENTS Widgets Concurrent) +if(Qt6_FOUND) + set(QT_PACKAGE Qt6) +else() + find_package(Qt5 REQUIRED COMPONENTS Widgets Concurrent) + set(QT_PACKAGE Qt5) +endif() + +add_library(aucdtect_core + src/AudioAnalyzer.cpp + src/AudioAnalyzer.h +) + +target_link_libraries(aucdtect_core PRIVATE ${QT_PACKAGE}::Core) + +add_executable(aucdtect_linux + src/main.cpp + src/MainWindow.cpp + src/MainWindow.h + assets/aucdtect_linux.qrc +) + +target_link_libraries(aucdtect_linux PRIVATE aucdtect_core ${QT_PACKAGE}::Widgets ${QT_PACKAGE}::Concurrent) + +add_executable(aucdtect + src/cli_main.cpp +) + +target_link_libraries(aucdtect PRIVATE aucdtect_core ${QT_PACKAGE}::Core) + +install(TARGETS aucdtect_linux aucdtect RUNTIME DESTINATION bin) +install(FILES packaging/aucdtect-linux.desktop DESTINATION share/applications) +install(FILES assets/aucdtect-linux.svg DESTINATION share/icons/hicolor/scalable/apps) +install(FILES assets/aucdtect-linux.png DESTINATION share/icons/hicolor/256x256/apps) diff --git a/PKGBUILD b/PKGBUILD new file mode 100644 index 0000000..936b734 --- /dev/null +++ b/PKGBUILD @@ -0,0 +1,30 @@ +# Maintainer: benya +pkgname=aucdtect-linux-git +pkgver=0.1.0.r0.g0000000 +pkgrel=1 +pkgdesc='Linux Qt clone of auCDtect and auCDtect Task Manager' +arch=('x86_64') +url='ssh://git@git.daemonlord.ru/benya/auCDtect_linux.git' +license=('custom') +depends=('qt6-base' 'ffmpeg') +makedepends=('git' 'cmake' 'ninja') +provides=('aucdtect-linux' 'aucdtect') +conflicts=('aucdtect-linux' 'aucdtect') +source=("${pkgname}::git+ssh://git@git.daemonlord.ru/benya/auCDtect_linux.git") +sha256sums=('SKIP') + +pkgver() { + cd "${srcdir}/${pkgname}" + git describe --long --tags --always --match 'v*' | sed 's/^v//;s/-/.r/;s/-/./' +} + +build() { + cmake -S "${srcdir}/${pkgname}" -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=None \ + -DCMAKE_INSTALL_PREFIX=/usr + cmake --build build +} + +package() { + DESTDIR="${pkgdir}" cmake --install build +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..f8570f6 --- /dev/null +++ b/README.md @@ -0,0 +1,94 @@ +# aucdtect_linux + +Starter project for a Linux clone of `auCDtect` and `auCDtect Task Manager`, implemented in C++ with Qt Widgets plus a shared analysis core. + +## Current scope + +- Queue-based desktop UI for adding lossless audio files +- Built-in WAV analyzer with multi-feature CDDA/MPEG heuristic classification +- Optional external decoder for formats that must be converted to WAV first +- Parallel worker queue, detailed task view, live log, and report export +- Separate CLI executable `aucdtect` using the same engine as the GUI + +## Command templates + +The UI accepts an optional decoder command with placeholders: + +- `{input}`: source audio file +- `{decoded}`: temporary WAV path +- `{report}`: report output path + +Recommended setup: + +```text +Decoder command: ffmpeg -loglevel error -y -i {input} -map 0:a:0 -vn -sn -dn -ar 44100 -ac 2 -c:a pcm_s16le {decoded} +``` + +If the input file is already WAV, the built-in analyzer runs directly and the decoder is skipped. + +## Build + +### CMake + +```bash +cmake -S . -B build +cmake --build build +``` + +GUI binary: `build/aucdtect_linux` + +CLI binary: `build/aucdtect [more.wav ...]` + +Install into a staging prefix: + +```bash +cmake --install build --prefix /tmp/aucdtect-linux +``` + +The install target includes the GUI binary, CLI binary, desktop launcher, and app icons. + +Feature dump: + +```bash +build/aucdtect --dump-features /path/to/file.wav +``` + +Dataset evaluation: + +```bash +tools/eval_dataset.sh samples dataset_eval.csv +``` + +### Arch Linux package + +The repository includes a VCS `PKGBUILD`: + +```bash +makepkg -si +``` + +Recommended sample layout: + +```text +samples/ + cdda/ + album1-track01.flac + album1-track02.flac + mp3_to_flac/ + aac_to_flac/ + uncertain/ +``` + +### Direct Qt5 build + +```bash +g++ -std=c++20 -fPIC src/main.cpp src/MainWindow.cpp src/AudioAnalyzer.cpp -o aucdtect_linux $(pkg-config --cflags --libs Qt5Widgets Qt5Concurrent) +g++ -std=c++20 -fPIC src/cli_main.cpp src/AudioAnalyzer.cpp -o aucdtect $(pkg-config --cflags --libs Qt5Core) +``` + +## Next steps + +- Calibrate the spectral model against known genuine-CDDA and transcode samples +- Replicate the original Windows layout and task detail panes even more faithfully +- Replace the temporary decoder bridge with native format readers +- Add cancelation that can terminate active decoder subprocesses immediately diff --git a/assets/aucdtect-linux.png b/assets/aucdtect-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..10e41ba65121247bdea1d9fdd9907f8c0d93c8fa GIT binary patch literal 38917 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 P00000NkvXXu0mjf3DS%( literal 0 HcmV?d00001 diff --git a/assets/aucdtect-linux.svg b/assets/aucdtect-linux.svg new file mode 100644 index 0000000..4d3d559 --- /dev/null +++ b/assets/aucdtect-linux.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/aucdtect_linux.qrc b/assets/aucdtect_linux.qrc new file mode 100644 index 0000000..9df0191 --- /dev/null +++ b/assets/aucdtect_linux.qrc @@ -0,0 +1,5 @@ + + + aucdtect-linux.png + + diff --git a/docs/reverse_aucdtect_082.md b/docs/reverse_aucdtect_082.md new file mode 100644 index 0000000..788a76b --- /dev/null +++ b/docs/reverse_aucdtect_082.md @@ -0,0 +1,85 @@ +# auCDtect 0.8.2 reverse notes + +Binary: + +- File: `auCDtect.exe` +- SHA256: `efe47eec21c33431e7fceea4b171b8a15a729e3adcda967ee0fc165a6a41adba` +- Format: PE32 console, i386 +- Build timestamp: 2004-09-24 05:29:58 +- Version string: `auCDtect: CD records authenticity detector, version 0.8.2` + +Useful functions found with radare2: + +- `0x4014a0`: prints verbose per-track metrics and final per-track conclusion. +- `0x401650`: main CLI flow, option parsing, per-file processing. +- `0x403ef0`: summary classifier for a set of tracks. +- `0x402630`: large analysis function called during per-file processing. +- `0x402050`: WAV loading / analysis setup area. + +Printed metrics in verbose mode: + +- `Detected average hi-boundary frequency` +- `Detected average lo-boundary frequency` +- `Detected average hi-cut frequency` +- `Detected average lo-cut frequency` +- `Maximum probablis boundary frequency` +- `Coefficient of nonlinearity of a phase` +- `First order smothness` +- `Second order smothness` + +Result struct offsets used by `0x4014a0`: + +- `+0x00`: hi-boundary frequency, double +- `+0x08`: lo-boundary frequency, double +- `+0x18`: hi-cut frequency, double +- `+0x20`: lo-cut frequency, double +- `+0x30`: maximum probable boundary frequency, double +- `+0x38`: coefficient of phase nonlinearity, double +- `+0x40`: first-order smoothness denominator, double +- `+0x48`: first-order smoothness numerator, double +- `+0x50`: second-order smoothness denominator, double +- `+0x58`: second-order smoothness numerator, double +- `+0x88`: probability history / mode array, double[] +- `+0xa8`: result code / MPEG probability presence +- `+0xc0`: index into probability history + +Observed original outputs: + +## CDDA sample + +Source: `samples/cdda/01 - In the Flesh.flac`, decoded to `cdda_in_the_flesh.wav` + +```text +Detected average hi-boundary frequency: 2.062375e+004 Hz +Detected average lo-boundary frequency: 1.383399e+004 Hz +Detected average hi-cut frequency: 2.184077e+004 Hz +Detected average lo-cut frequency: 1.469628e+004 Hz +Maximum probablis boundary frequency: 2.152500e+004 Hz +Coefficient of nonlinearity of a phase: 1.496717e+000 +First order smothness: 3.164557e-001 +Second order smothness: 8.059072e-001 +This track looks like CDDA with probability 100% +``` + +## MP3 transcode sample + +Source: `samples/mp3_to_flac/01 - In the Flesh.flac`, decoded to `mp3_in_the_flesh.wav` + +```text +Detected average hi-boundary frequency: 1.883405e+004 Hz +Detected average lo-boundary frequency: 1.721351e+004 Hz +Detected average hi-cut frequency: 1.875735e+004 Hz +Detected average lo-cut frequency: 1.760003e+004 Hz +Maximum probablis boundary frequency: 1.669100e+004 Hz +Coefficient of nonlinearity of a phase: 4.837348e-002 +First order smothness: 8.607595e-001 +Second order smothness: 7.594937e-001 +This track looks like MPEG with probability 95% +``` + +Immediate implementation implications: + +- Original does not rely only on upper-band energy. It estimates boundary and cut frequencies. +- The strongest contrast in the sample pair is phase nonlinearity: CDDA is high (`1.49`), MP3 transcode is low (`0.048`). +- First-order smoothness also separates the sample pair: CDDA low (`0.316`), MP3 transcode high (`0.861`). +- Our current detector should grow original-style fields before attempting stricter behavioral compatibility. diff --git a/packaging/aucdtect-linux.desktop b/packaging/aucdtect-linux.desktop new file mode 100644 index 0000000..aee7ce6 --- /dev/null +++ b/packaging/aucdtect-linux.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Type=Application +Name=auCDtect Linux +Comment=Analyze audio files for CDDA or lossy MPEG-like spectral signatures +Exec=aucdtect_linux +Icon=aucdtect-linux +Terminal=false +Categories=AudioVideo;Audio;Qt; +Keywords=audio;cd;flac;mp3;spectrum;aucdtect; diff --git a/src/AudioAnalyzer.cpp b/src/AudioAnalyzer.cpp new file mode 100644 index 0000000..4cf784c --- /dev/null +++ b/src/AudioAnalyzer.cpp @@ -0,0 +1,572 @@ +#include "AudioAnalyzer.h" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace { +constexpr int kWindowSize = 2048; +constexpr int kMaxWindows = 96; + +struct WavData { + bool ok = false; + QString error; + int channels = 0; + int sampleRate = 0; + int bitsPerSample = 0; + int bytesPerSample = 0; + qint64 sampleFrames = 0; + std::vector monoSamples; +}; + +struct SpectrumStats { + bool ok = false; + double cutoffKhz = 0.0; + double rolloffKhz = 0.0; + double spectralFlatness = 0.0; + double highBandRatio = 0.0; + double veryHighBandRatio = 0.0; + double upperMidRatio = 0.0; + double lowpassRatio = 0.0; + double phaseNonlinearity = 0.0; + double firstOrderSmoothness = 0.0; + double secondOrderSmoothness = 0.0; + double rms = 0.0; + bool informative = false; + bool suspect = false; + bool genuine = false; +}; + +quint32 readLe32(const char *p) { + return qFromLittleEndian(reinterpret_cast(p)); +} + +quint16 readLe16(const char *p) { + return qFromLittleEndian(reinterpret_cast(p)); +} + +WavData readWavFile(const QString &path) { + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + return {.error = QString("Cannot open file: %1").arg(path)}; + } + + const QByteArray data = file.readAll(); + if (data.size() < 44) { + return {.error = "File is too small to be a valid WAV"}; + } + + const char *bytes = data.constData(); + if (QByteArray(bytes, 4) != "RIFF" || QByteArray(bytes + 8, 4) != "WAVE") { + return {.error = "Only PCM WAV files are supported at this stage"}; + } + + int formatChannels = 0; + int formatSampleRate = 0; + int formatBitsPerSample = 0; + int formatAudioType = 0; + QByteArray pcmData; + + int offset = 12; + while (offset + 8 <= data.size()) { + const QByteArray chunkId(bytes + offset, 4); + const quint32 chunkSize = readLe32(bytes + offset + 4); + const int chunkDataOffset = offset + 8; + const int paddedSize = static_cast(chunkSize + (chunkSize % 2)); + + if (chunkDataOffset + static_cast(chunkSize) > data.size()) { + return {.error = "Corrupt WAV chunk layout"}; + } + + if (chunkId == "fmt ") { + if (chunkSize < 16) { + return {.error = "Unsupported fmt chunk"}; + } + formatAudioType = readLe16(bytes + chunkDataOffset); + formatChannels = readLe16(bytes + chunkDataOffset + 2); + formatSampleRate = static_cast(readLe32(bytes + chunkDataOffset + 4)); + formatBitsPerSample = readLe16(bytes + chunkDataOffset + 14); + } else if (chunkId == "data") { + pcmData = data.mid(chunkDataOffset, static_cast(chunkSize)); + } + + offset = chunkDataOffset + paddedSize; + } + + if (formatAudioType != 1) { + return {.error = "Only uncompressed PCM WAV is currently supported"}; + } + if (formatChannels <= 0 || formatSampleRate <= 0 || formatBitsPerSample <= 0) { + return {.error = "Incomplete WAV format information"}; + } + if (pcmData.isEmpty()) { + return {.error = "WAV data chunk is missing"}; + } + + const int bytesPerSample = formatBitsPerSample / 8; + if (bytesPerSample <= 0 || (formatBitsPerSample != 16 && formatBitsPerSample != 24 && formatBitsPerSample != 32)) { + return {.error = "Only 16/24/32-bit PCM WAV is currently supported"}; + } + + const int frameSize = bytesPerSample * formatChannels; + if (frameSize <= 0 || pcmData.size() % frameSize != 0) { + return {.error = "PCM data size does not match the WAV format"}; + } + + const qint64 sampleFrames = pcmData.size() / frameSize; + std::vector monoSamples; + monoSamples.reserve(static_cast(sampleFrames)); + + const char *pcm = pcmData.constData(); + for (qint64 frame = 0; frame < sampleFrames; ++frame) { + double sum = 0.0; + for (int ch = 0; ch < formatChannels; ++ch) { + const char *samplePtr = pcm + frame * frameSize + ch * bytesPerSample; + qint32 value = 0; + if (formatBitsPerSample == 16) { + value = qFromLittleEndian(reinterpret_cast(samplePtr)); + sum += static_cast(value) / 32768.0; + } else if (formatBitsPerSample == 24) { + value = (static_cast(samplePtr[0])) | + (static_cast(samplePtr[1]) << 8) | + (static_cast(samplePtr[2]) << 16); + if (value & 0x800000) { + value |= ~0xffffff; + } + sum += static_cast(value) / 8388608.0; + } else { + value = qFromLittleEndian(reinterpret_cast(samplePtr)); + sum += static_cast(value) / 2147483648.0; + } + } + monoSamples.push_back(sum / static_cast(formatChannels)); + } + + return { + .ok = true, + .channels = formatChannels, + .sampleRate = formatSampleRate, + .bitsPerSample = formatBitsPerSample, + .bytesPerSample = bytesPerSample, + .sampleFrames = sampleFrames, + .monoSamples = std::move(monoSamples), + }; +} + +int freqToBin(double freqKhz, int sampleRate, int bins) { + if (sampleRate <= 0 || bins <= 0) { + return 0; + } + const double nyquistKhz = sampleRate / 2000.0; + const double normalized = std::clamp(freqKhz / nyquistKhz, 0.0, 1.0); + return std::clamp(static_cast(std::round(normalized * (bins - 1))), 0, bins - 1); +} + +double bandEnergy(const std::vector &spectrum, int beginBin, int endBin) { + if (spectrum.empty()) { + return 0.0; + } + beginBin = std::clamp(beginBin, 0, static_cast(spectrum.size()) - 1); + endBin = std::clamp(endBin, beginBin, static_cast(spectrum.size()) - 1); + double sum = 0.0; + for (int i = beginBin; i <= endBin; ++i) { + sum += spectrum[static_cast(i)]; + } + return sum; +} + +double estimateRms(const std::vector &window) { + double sumSquares = 0.0; + for (double value : window) { + sumSquares += value * value; + } + return std::sqrt(sumSquares / std::max(1, window.size())); +} + +std::vector powerSpectrumForWindow(const std::vector &samples, size_t start) { + std::vector window(kWindowSize, 0.0); + std::vector spectrum(kWindowSize / 2, 0.0); + + for (int i = 0; i < kWindowSize; ++i) { + const double hann = 0.5 - 0.5 * std::cos((2.0 * std::numbers::pi * i) / (kWindowSize - 1)); + window[i] = samples[start + static_cast(i)] * hann; + } + + for (int k = 0; k < kWindowSize / 2; ++k) { + std::complex sum(0.0, 0.0); + for (int n = 0; n < kWindowSize; ++n) { + const double phase = -2.0 * std::numbers::pi * k * n / kWindowSize; + sum += window[n] * std::complex(std::cos(phase), std::sin(phase)); + } + spectrum[static_cast(k)] = std::norm(sum); + } + + return spectrum; +} + +std::vector> complexSpectrumForWindow(const std::vector &samples, size_t start) { + std::vector window(kWindowSize, 0.0); + std::vector> spectrum(kWindowSize / 2); + + for (int i = 0; i < kWindowSize; ++i) { + const double hann = 0.5 - 0.5 * std::cos((2.0 * std::numbers::pi * i) / (kWindowSize - 1)); + window[i] = samples[start + static_cast(i)] * hann; + } + + for (int k = 0; k < kWindowSize / 2; ++k) { + std::complex sum(0.0, 0.0); + for (int n = 0; n < kWindowSize; ++n) { + const double phase = -2.0 * std::numbers::pi * k * n / kWindowSize; + sum += window[n] * std::complex(std::cos(phase), std::sin(phase)); + } + spectrum[static_cast(k)] = sum; + } + + return spectrum; +} + +double normalizedRoughness(const std::vector &values, int order) { + if (values.size() <= static_cast(order + 1)) { + return 0.0; + } + + double diffEnergy = 0.0; + double valueEnergy = 0.0; + for (double value : values) { + valueEnergy += value * value; + } + if (valueEnergy <= std::numeric_limits::epsilon()) { + return 0.0; + } + + if (order == 1) { + for (size_t i = 1; i < values.size(); ++i) { + const double d = values[i] - values[i - 1]; + diffEnergy += d * d; + } + } else { + for (size_t i = 2; i < values.size(); ++i) { + const double d = values[i] - 2.0 * values[i - 1] + values[i - 2]; + diffEnergy += d * d; + } + } + + return std::sqrt(diffEnergy / valueEnergy); +} + +double phaseNonlinearityMetric(const std::vector> &spectrum, int beginBin, int endBin) { + beginBin = std::clamp(beginBin, 0, static_cast(spectrum.size()) - 1); + endBin = std::clamp(endBin, beginBin + 2, static_cast(spectrum.size()) - 1); + + std::vector phases; + phases.reserve(static_cast(endBin - beginBin + 1)); + + double previous = std::arg(spectrum[static_cast(beginBin)]); + phases.push_back(previous); + for (int i = beginBin + 1; i <= endBin; ++i) { + double phase = std::arg(spectrum[static_cast(i)]); + while (phase - previous > std::numbers::pi) { + phase -= 2.0 * std::numbers::pi; + } + while (phase - previous < -std::numbers::pi) { + phase += 2.0 * std::numbers::pi; + } + phases.push_back(phase); + previous = phase; + } + + return normalizedRoughness(phases, 2); +} + +double estimateLastSignificantFrequencyKhz(const std::vector &spectrum, int sampleRate, double relativeThreshold) { + if (spectrum.empty()) { + return 0.0; + } + + const double peak = *std::max_element(spectrum.begin(), spectrum.end()); + if (peak <= std::numeric_limits::epsilon()) { + return 0.0; + } + + const int bin10 = freqToBin(10.0, sampleRate, static_cast(spectrum.size())); + int lastStrong = bin10; + for (int i = bin10; i < static_cast(spectrum.size()); ++i) { + if (spectrum[static_cast(i)] > peak * relativeThreshold) { + lastStrong = i; + } + } + + return (static_cast(lastStrong) * sampleRate / 2.0 / spectrum.size()) / 1000.0; +} + +double estimateSpectralFlatness(const std::vector &spectrum, int beginBin, int endBin) { + beginBin = std::clamp(beginBin, 0, static_cast(spectrum.size()) - 1); + endBin = std::clamp(endBin, beginBin, static_cast(spectrum.size()) - 1); + double logSum = 0.0; + double linearSum = 0.0; + int count = 0; + for (int i = beginBin; i <= endBin; ++i) { + const double adjusted = std::max(spectrum[static_cast(i)], 1e-15); + logSum += std::log(adjusted); + linearSum += adjusted; + ++count; + } + if (count == 0 || linearSum <= 0.0) { + return 0.0; + } + return std::exp(logSum / count) / (linearSum / count); +} + +SpectrumStats analyzeWindow(const std::vector &samples, size_t start, int sampleRate) { + SpectrumStats stats; + + std::vector rawWindow(kWindowSize, 0.0); + for (int i = 0; i < kWindowSize; ++i) { + rawWindow[static_cast(i)] = samples[start + static_cast(i)]; + } + stats.rms = estimateRms(rawWindow); + if (stats.rms < 0.015) { + return stats; + } + + const std::vector> complexSpectrum = complexSpectrumForWindow(samples, start); + std::vector spectrum; + spectrum.reserve(complexSpectrum.size()); + for (const auto &value : complexSpectrum) { + spectrum.push_back(std::norm(value)); + } + const int bins = static_cast(spectrum.size()); + const double total = bandEnergy(spectrum, 0, bins - 1); + if (total <= std::numeric_limits::epsilon()) { + return stats; + } + + const int bin8 = freqToBin(8.0, sampleRate, bins); + const int bin11 = freqToBin(11.0, sampleRate, bins); + const int bin14 = freqToBin(14.0, sampleRate, bins); + const int bin16 = freqToBin(16.0, sampleRate, bins); + const int bin18 = freqToBin(18.0, sampleRate, bins); + const int bin20 = freqToBin(20.0, sampleRate, bins); + const int binNyquist = bins - 1; + + const double upperMid = bandEnergy(spectrum, bin8, bin14) / total; + const double highBand = bandEnergy(spectrum, bin16, bin20) / total; + const double veryHigh = bandEnergy(spectrum, bin20, binNyquist) / total; + const double lowpassRatio = highBand / std::max(upperMid, 1e-12); + const double flatness = estimateSpectralFlatness(spectrum, bin11, bin20); + const double cutoff = estimateLastSignificantFrequencyKhz(spectrum, sampleRate, 1e-8); + const double rolloff = estimateLastSignificantFrequencyKhz(spectrum, sampleRate, 1e-7); + + stats.ok = true; + stats.cutoffKhz = cutoff; + stats.rolloffKhz = rolloff; + stats.spectralFlatness = flatness; + stats.highBandRatio = highBand; + stats.veryHighBandRatio = veryHigh; + stats.upperMidRatio = upperMid; + stats.lowpassRatio = lowpassRatio; + stats.phaseNonlinearity = phaseNonlinearityMetric(complexSpectrum, bin11, bin20); + stats.firstOrderSmoothness = normalizedRoughness(spectrum, 1); + stats.secondOrderSmoothness = normalizedRoughness(spectrum, 2); + + const bool hasUsefulTreble = upperMid > 0.003; + const bool notPureTone = flatness > 0.015 || rolloff > 8.0; + stats.informative = hasUsefulTreble && notPureTone; + if (!stats.informative) { + return stats; + } + + const bool lowpassCutoff = cutoff < 16.5 || rolloff < 15.0; + const bool lowHighEnergy = highBand < 0.0018 && veryHigh < 0.00018; + const bool steepDrop = lowpassRatio < 0.085; + const bool healthyTop = cutoff > 18.7 && rolloff > 17.2 && highBand > 0.0035 && lowpassRatio > 0.16; + + stats.suspect = (lowpassCutoff && steepDrop) || (lowHighEnergy && lowpassCutoff); + stats.genuine = healthyTop; + return stats; +} + +std::vector analyzeWindows(const std::vector &samples, int sampleRate) { + if (samples.size() < static_cast(kWindowSize)) { + return {}; + } + + const size_t available = samples.size() - kWindowSize; + const size_t windows = std::min(kMaxWindows, samples.size() / kWindowSize); + const size_t hop = windows > 1 ? std::max(1, available / (windows - 1)) : 1; + + std::vector stats; + stats.reserve(windows); + for (size_t i = 0; i < windows; ++i) { + const size_t start = std::min(available, i * hop); + stats.push_back(analyzeWindow(samples, start, sampleRate)); + } + return stats; +} + +QString buildReport(const WavData &wav, const AudioAnalysisReport &report) { + QStringList lines; + lines << "auCDtect Linux report" + << "--------------------" + << QString("Sample rate : %1 Hz").arg(wav.sampleRate) + << QString("Channels : %1").arg(wav.channels) + << QString("Bit depth : %1").arg(wav.bitsPerSample) + << QString("Frames : %1").arg(wav.sampleFrames) + << QString("Cutoff : %1 kHz").arg(report.cutoffKhz, 0, 'f', 2) + << QString("Spectral rolloff : %1 kHz").arg(report.rolloffKhz, 0, 'f', 2) + << QString("Lo-cut frequency : %1 Hz").arg(report.loCutHz, 0, 'f', 2) + << QString("Hi-cut frequency : %1 Hz").arg(report.hiCutHz, 0, 'f', 2) + << QString("Lo-boundary freq : %1 Hz").arg(report.loBoundaryHz, 0, 'f', 2) + << QString("Hi-boundary freq : %1 Hz").arg(report.hiBoundaryHz, 0, 'f', 2) + << QString("Probable boundary : %1 Hz").arg(report.probableBoundaryHz, 0, 'f', 2) + << QString("Phase nonlinearity : %1").arg(report.phaseNonlinearity, 0, 'f', 6) + << QString("First smoothness : %1").arg(report.firstOrderSmoothness, 0, 'f', 6) + << QString("Second smoothness : %1").arg(report.secondOrderSmoothness, 0, 'f', 6) + << QString("High band ratio : %1").arg(report.highBandRatio, 0, 'f', 5) + << QString("Very high ratio : %1").arg(report.veryHighBandRatio, 0, 'f', 5) + << QString("Spectral flatness : %1").arg(report.spectralFlatness, 0, 'f', 5) + << QString("Analyzed windows : %1").arg(report.analyzedWindows) + << QString("Informative windows: %1").arg(report.informativeWindows) + << QString("Suspect windows : %1").arg(report.suspectWindows) + << QString("Genuine windows : %1").arg(report.genuineWindows) + << QString("Suspect ratio : %1").arg(report.suspectRatio, 0, 'f', 3) + << QString("Accuracy : %1").arg(report.accuracy) + << QString("Conclusion : %1").arg(report.conclusion); + return lines.join('\n') + '\n'; +} +} + +AudioAnalysisReport AudioAnalyzer::analyzeFile(const QString &path) const { + const WavData wav = readWavFile(path); + if (!wav.ok) { + return {.ok = false, .error = wav.error}; + } + + AudioAnalysisReport report; + report.sampleRate = wav.sampleRate; + report.channels = wav.channels; + report.bitsPerSample = wav.bitsPerSample; + report.sampleFrames = wav.sampleFrames; + + const std::vector windows = analyzeWindows(wav.monoSamples, wav.sampleRate); + if (windows.empty()) { + report.error = "Audio is too short for spectral analysis"; + return report; + } + + double cutoffSum = 0.0; + double rolloffSum = 0.0; + double highBandSum = 0.0; + double veryHighSum = 0.0; + double flatnessSum = 0.0; + double phaseSum = 0.0; + double firstSmoothnessSum = 0.0; + double secondSmoothnessSum = 0.0; + int measured = 0; + + report.analyzedWindows = static_cast(windows.size()); + for (const SpectrumStats &stats : windows) { + if (!stats.ok) { + continue; + } + cutoffSum += stats.cutoffKhz; + rolloffSum += stats.rolloffKhz; + highBandSum += stats.highBandRatio; + veryHighSum += stats.veryHighBandRatio; + flatnessSum += stats.spectralFlatness; + phaseSum += stats.phaseNonlinearity; + firstSmoothnessSum += stats.firstOrderSmoothness; + secondSmoothnessSum += stats.secondOrderSmoothness; + ++measured; + if (stats.informative) { + ++report.informativeWindows; + } + if (stats.suspect) { + ++report.suspectWindows; + } + if (stats.genuine) { + ++report.genuineWindows; + } + } + + if (measured == 0) { + report.error = "Could not derive spectral statistics"; + return report; + } + + report.cutoffKhz = cutoffSum / measured; + report.rolloffKhz = rolloffSum / measured; + report.highBandRatio = highBandSum / measured; + report.veryHighBandRatio = veryHighSum / measured; + report.spectralFlatness = flatnessSum / measured; + report.phaseNonlinearity = phaseSum / measured; + report.firstOrderSmoothness = firstSmoothnessSum / measured; + report.secondOrderSmoothness = secondSmoothnessSum / measured; + report.loCutHz = std::max(0.0, (report.cutoffKhz - 1.0) * 1000.0); + report.hiCutHz = report.cutoffKhz * 1000.0; + report.loBoundaryHz = std::max(0.0, (report.rolloffKhz - 1.0) * 1000.0); + report.hiBoundaryHz = report.rolloffKhz * 1000.0; + report.probableBoundaryHz = (report.hiCutHz + report.hiBoundaryHz) * 0.5; + + if (report.informativeWindows == 0) { + report.conclusion = "Unknown 50%"; + report.accuracy = "50%"; + report.confidence = 50; + report.ok = true; + report.rawReport = buildReport(wav, report); + return report; + } + + report.suspectRatio = static_cast(report.suspectWindows) / report.informativeWindows; + const double genuineRatio = static_cast(report.genuineWindows) / report.informativeWindows; + + const bool hasStrongCdBoundary = report.cutoffKhz > 20.5 && report.rolloffKhz > 18.5 && + report.spectralFlatness > 0.09 && report.suspectRatio <= 0.20; + const bool hasCdTopBand = report.veryHighBandRatio > 0.0000005 && report.spectralFlatness > 0.09; + const bool hasLossyFlatness = report.spectralFlatness < 0.075; + const bool hasAacLikeLowpass = report.veryHighBandRatio <= 0.0000005 && report.spectralFlatness < 0.19 && + report.suspectRatio >= 0.55 && report.suspectWindows >= 2; + + if (hasStrongCdBoundary) { + const double boundaryBoost = std::clamp((report.rolloffKhz - 18.5) * 4.0, 0.0, 8.0); + const double flatnessBoost = std::clamp((report.spectralFlatness - 0.09) * 90.0, 0.0, 8.0); + const int confidence = std::clamp(static_cast(std::round(88.0 + boundaryBoost + flatnessBoost)), 88, 99); + report.conclusion = QString("CDDA %1").arg(QString::number(confidence) + "%"); + report.accuracy = QString::number(confidence) + "%"; + report.confidence = confidence; + } else if (hasCdTopBand) { + const double topBandBoost = std::clamp(std::log10(report.veryHighBandRatio / 0.0000005 + 1.0) * 14.0, 0.0, 18.0); + const double flatnessBoost = std::clamp((report.spectralFlatness - 0.09) * 120.0, 0.0, 22.0); + const int confidence = std::clamp(static_cast(std::round(62.0 + topBandBoost + flatnessBoost)), 62, 96); + report.conclusion = QString("CDDA %1").arg(QString::number(confidence) + "%"); + report.accuracy = QString::number(confidence) + "%"; + report.confidence = confidence; + } else if (hasLossyFlatness || hasAacLikeLowpass || (report.suspectRatio >= 0.72 && report.suspectWindows >= 4)) { + const int confidence = std::clamp(static_cast(std::round(55.0 + report.suspectRatio * 45.0)), 55, 100); + report.conclusion = QString("MPEG %1").arg(QString::number(confidence) + "%"); + report.accuracy = QString::number(confidence) + "%"; + report.confidence = confidence; + } else if (genuineRatio >= 0.40 && report.suspectRatio <= 0.22) { + const int confidence = std::clamp(static_cast(std::round(60.0 + genuineRatio * 35.0)), 60, 95); + report.conclusion = QString("CDDA %1").arg(QString::number(confidence) + "%"); + report.accuracy = QString::number(confidence) + "%"; + report.confidence = confidence; + } else { + const int confidence = std::clamp(static_cast(std::round(45.0 + std::abs(report.suspectRatio - genuineRatio) * 30.0)), 45, 75); + report.conclusion = QString("Unknown %1").arg(QString::number(confidence) + "%"); + report.accuracy = QString::number(confidence) + "%"; + report.confidence = confidence; + } + + report.ok = true; + report.rawReport = buildReport(wav, report); + return report; +} diff --git a/src/AudioAnalyzer.h b/src/AudioAnalyzer.h new file mode 100644 index 0000000..1e3c341 --- /dev/null +++ b/src/AudioAnalyzer.h @@ -0,0 +1,39 @@ +#pragma once + +#include + +struct AudioAnalysisReport { + bool ok = false; + QString error; + QString rawReport; + QString conclusion = "Unknown"; + QString accuracy = "0%"; + int confidence = 0; + double cutoffKhz = 0.0; + int sampleRate = 0; + int channels = 0; + int bitsPerSample = 0; + qint64 sampleFrames = 0; + double spectralFlatness = 0.0; + double highBandRatio = 0.0; + double veryHighBandRatio = 0.0; + double rolloffKhz = 0.0; + double loCutHz = 0.0; + double hiCutHz = 0.0; + double loBoundaryHz = 0.0; + double hiBoundaryHz = 0.0; + double probableBoundaryHz = 0.0; + double phaseNonlinearity = 0.0; + double firstOrderSmoothness = 0.0; + double secondOrderSmoothness = 0.0; + int analyzedWindows = 0; + int informativeWindows = 0; + int suspectWindows = 0; + int genuineWindows = 0; + double suspectRatio = 0.0; +}; + +class AudioAnalyzer final { +public: + AudioAnalysisReport analyzeFile(const QString &path) const; +}; diff --git a/src/MainWindow.cpp b/src/MainWindow.cpp new file mode 100644 index 0000000..3a01277 --- /dev/null +++ b/src/MainWindow.cpp @@ -0,0 +1,670 @@ +#include "MainWindow.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +constexpr auto kOrgName = "aucdtect-linux"; +constexpr auto kAppName = "aucdtect-linux"; +constexpr auto kDecoderTemplateKey = "decoderCommand"; +constexpr auto kReportPathKey = "reportPath"; +constexpr auto kParallelismKey = "parallelism"; +constexpr auto kStatusPending = "Pending"; +constexpr auto kStatusRunning = "Running"; +constexpr auto kStatusDone = "Done"; +constexpr auto kStatusFailed = "Failed"; +constexpr auto kStatusStopped = "Stopped"; +constexpr auto kDefaultDecoderTemplate = "ffmpeg -loglevel error -y -i {input} -map 0:a:0 -vn -sn -dn -ar 44100 -ac 2 -c:a pcm_s16le {decoded}"; + +const QStringList kAudioNameFilters = { + "*.wav", "*.flac", "*.ape", "*.wv", "*.tak", "*.tta", "*.m4a", "*.alac", "*.shn", "*.ofr", "*.ofs", "*.mp3", "*.aac" +}; + +bool isWavInput(const QString &path) { + return QFileInfo(path).suffix().compare("wav", Qt::CaseInsensitive) == 0; +} + +QString computeFileHash(const QString &path) { + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + return {}; + } + + QCryptographicHash hash(QCryptographicHash::Md5); + while (!file.atEnd()) { + hash.addData(file.read(1 << 20)); + } + return hash.result().toHex(); +} + +QString computeSignature(const QString &inputPath, const QString &conclusion, const QString &accuracy, const QString &fileHash) { + const QString material = inputPath + "|" + conclusion + "|" + accuracy + "|" + fileHash; + return QCryptographicHash::hash(material.toUtf8(), QCryptographicHash::Sha1).toHex(); +} + +struct WorkerPayload { + int taskIndex = -1; + QString inputPath; + QString decoderTemplate; + QString reportPath; +}; + +QString escapeShellArgumentStandalone(const QString &value) { + QString escaped = value; + escaped.replace('\'', "'\"'\"'"); + return "'" + escaped + "'"; +} + +QString applyPlaceholders(QString command, const QString &inputPath, const QString &decodedPath, const QString &reportPath) { + command.replace("{input}", escapeShellArgumentStandalone(inputPath)); + command.replace("{decoded}", escapeShellArgumentStandalone(decodedPath)); + command.replace("{report}", escapeShellArgumentStandalone(reportPath)); + return command; +} + +WorkerResult runWorker(const WorkerPayload &payload) { + WorkerResult result; + result.status = kStatusFailed; + + const QString baseName = QFileInfo(payload.inputPath).baseName(); + const QString cacheBase = QStandardPaths::writableLocation(QStandardPaths::CacheLocation); + QDir().mkpath(cacheBase); + const QString tempDirPath = cacheBase + "/" + baseName + "-" + QString::number(QDateTime::currentMSecsSinceEpoch()) + + "-" + QString::number(payload.taskIndex); + QDir().mkpath(tempDirPath); + + const QString decodedPath = isWavInput(payload.inputPath) ? payload.inputPath : tempDirPath + "/decoded.wav"; + QString externalOutput; + + if (!isWavInput(payload.inputPath)) { + if (payload.decoderTemplate.trimmed().isEmpty()) { + result.conclusion = "Decoder command is required for non-WAV input"; + result.analyzerOutput = result.conclusion; + QDir(tempDirPath).removeRecursively(); + return result; + } + + QProcess decoder; + decoder.setProcessChannelMode(QProcess::MergedChannels); + const QString reportPath = payload.reportPath.isEmpty() ? QDir(tempDirPath).filePath("analysis-report.txt") : payload.reportPath; + const QString command = applyPlaceholders(payload.decoderTemplate, payload.inputPath, decodedPath, reportPath); + decoder.start("/bin/sh", {"-lc", command}); + if (!decoder.waitForStarted()) { + result.conclusion = "Decoder failed to start"; + result.analyzerOutput = result.conclusion; + QDir(tempDirPath).removeRecursively(); + return result; + } + decoder.waitForFinished(-1); + result.exitCode = decoder.exitCode(); + externalOutput = QString::fromLocal8Bit(decoder.readAll()); + if (decoder.exitStatus() != QProcess::NormalExit || decoder.exitCode() != 0) { + result.conclusion = "Decoder failed"; + result.analyzerOutput = externalOutput; + QDir(tempDirPath).removeRecursively(); + return result; + } + } + + AudioAnalyzer analyzer; + const AudioAnalysisReport report = analyzer.analyzeFile(decodedPath); + if (!report.ok) { + result.conclusion = report.error; + result.analyzerOutput = report.rawReport.isEmpty() ? report.error : report.rawReport; + QDir(tempDirPath).removeRecursively(); + return result; + } + + result.status = kStatusDone; + result.exitCode = 0; + result.conclusion = report.conclusion; + result.accuracy = report.accuracy; + result.fileHash = computeFileHash(payload.inputPath); + result.signature = computeSignature(payload.inputPath, report.conclusion, report.accuracy, result.fileHash); + result.analyzerOutput = report.rawReport; + if (!externalOutput.trimmed().isEmpty()) { + result.analyzerOutput += "\n\nDecoder output\n--------------\n" + externalOutput.trimmed() + "\n"; + } + + QDir(tempDirPath).removeRecursively(); + return result; +} +} + +MainWindow::MainWindow() { + buildUi(); + loadSettings(); + connectSignals(); + updateSummary(); + updateDetailsPanel(); + appendLog("Ready. Internal analysis is built in, worker count controls concurrent jobs."); +} + +MainWindow::~MainWindow() = default; + +void MainWindow::closeEvent(QCloseEvent *event) { + saveSettings(); + QMainWindow::closeEvent(event); +} + +void MainWindow::buildUi() { + setWindowTitle("auCDtect Linux"); + resize(1360, 860); + + auto *central = new QWidget(this); + auto *mainLayout = new QVBoxLayout(central); + + auto *toolbarLayout = new QHBoxLayout(); + addFilesButton_ = new QPushButton("Add Files", this); + addFolderButton_ = new QPushButton("Add Folder", this); + clearButton_ = new QPushButton("Clear", this); + startButton_ = new QPushButton("Start", this); + stopButton_ = new QPushButton("Stop", this); + exportButton_ = new QPushButton("Save Report", this); + toolbarLayout->addWidget(addFilesButton_); + toolbarLayout->addWidget(addFolderButton_); + toolbarLayout->addWidget(clearButton_); + toolbarLayout->addStretch(); + toolbarLayout->addWidget(startButton_); + toolbarLayout->addWidget(stopButton_); + toolbarLayout->addWidget(exportButton_); + + auto *engineGroup = new QGroupBox("Engine Setup", this); + auto *engineLayout = new QFormLayout(engineGroup); + decoderCommandEdit_ = new QLineEdit(this); + reportPathEdit_ = new QLineEdit(this); + parallelismSpin_ = new QSpinBox(this); + parallelismSpin_->setRange(1, 32); + parallelismSpin_->setValue(1); + decoderCommandEdit_->setPlaceholderText(kDefaultDecoderTemplate); + reportPathEdit_->setPlaceholderText("Optional shared report path"); + engineLayout->addRow("Decoder", decoderCommandEdit_); + engineLayout->addRow("Workers", parallelismSpin_); + engineLayout->addRow("Report File", reportPathEdit_); + + auto *summaryGroup = new QGroupBox("Task Summary", this); + auto *summaryLayout = new QHBoxLayout(summaryGroup); + pendingLabel_ = new QLabel(this); + runningLabel_ = new QLabel(this); + doneLabel_ = new QLabel(this); + failedLabel_ = new QLabel(this); + summaryLayout->addWidget(pendingLabel_); + summaryLayout->addWidget(runningLabel_); + summaryLayout->addWidget(doneLabel_); + summaryLayout->addWidget(failedLabel_); + summaryLayout->addStretch(); + + taskTable_ = new QTableWidget(this); + taskTable_->setColumnCount(6); + taskTable_->setHorizontalHeaderLabels({"File", "Status", "Conclusion", "Accuracy", "Hash", "Exit"}); + taskTable_->horizontalHeader()->setSectionResizeMode(0, QHeaderView::Stretch); + taskTable_->horizontalHeader()->setSectionResizeMode(1, QHeaderView::ResizeToContents); + taskTable_->horizontalHeader()->setSectionResizeMode(2, QHeaderView::ResizeToContents); + taskTable_->horizontalHeader()->setSectionResizeMode(3, QHeaderView::ResizeToContents); + taskTable_->horizontalHeader()->setSectionResizeMode(4, QHeaderView::Stretch); + taskTable_->horizontalHeader()->setSectionResizeMode(5, QHeaderView::ResizeToContents); + taskTable_->verticalHeader()->setVisible(false); + taskTable_->setAlternatingRowColors(true); + taskTable_->setSelectionBehavior(QAbstractItemView::SelectRows); + taskTable_->setEditTriggers(QAbstractItemView::NoEditTriggers); + + detailsView_ = new QPlainTextEdit(this); + detailsView_->setReadOnly(true); + detailsView_->setPlaceholderText("Select a task to inspect the detailed report."); + + auto *detailsGroup = new QGroupBox("Selected Task", this); + auto *detailsLayout = new QVBoxLayout(detailsGroup); + detailsLayout->addWidget(detailsView_); + + auto *topSplitter = new QSplitter(Qt::Horizontal, this); + topSplitter->addWidget(taskTable_); + topSplitter->addWidget(detailsGroup); + topSplitter->setStretchFactor(0, 3); + topSplitter->setStretchFactor(1, 2); + + logView_ = new QPlainTextEdit(this); + logView_->setReadOnly(true); + logView_->setPlaceholderText("Execution log"); + + auto *bottomGroup = new QGroupBox("Run Log", this); + auto *bottomLayout = new QVBoxLayout(bottomGroup); + bottomLayout->addWidget(logView_); + + auto *mainSplitter = new QSplitter(Qt::Vertical, this); + mainSplitter->addWidget(topSplitter); + mainSplitter->addWidget(bottomGroup); + mainSplitter->setStretchFactor(0, 4); + mainSplitter->setStretchFactor(1, 2); + + auto *topMetaLayout = new QHBoxLayout(); + topMetaLayout->addWidget(engineGroup, 2); + topMetaLayout->addWidget(summaryGroup, 1); + + mainLayout->addLayout(toolbarLayout); + mainLayout->addLayout(topMetaLayout); + mainLayout->addWidget(mainSplitter); + + setCentralWidget(central); + stopButton_->setEnabled(false); +} + +void MainWindow::loadSettings() { + QSettings settings(kOrgName, kAppName); + decoderCommandEdit_->setText(settings.value(kDecoderTemplateKey, kDefaultDecoderTemplate).toString()); + reportPathEdit_->setText(settings.value(kReportPathKey).toString()); + parallelismSpin_->setValue(settings.value(kParallelismKey, 1).toInt()); +} + +void MainWindow::saveSettings() const { + QSettings settings(kOrgName, kAppName); + settings.setValue(kDecoderTemplateKey, decoderCommandEdit_->text().trimmed()); + settings.setValue(kReportPathKey, reportPathEdit_->text().trimmed()); + settings.setValue(kParallelismKey, parallelismSpin_->value()); +} + +void MainWindow::connectSignals() { + connect(addFilesButton_, &QPushButton::clicked, this, [this] { addFiles(); }); + connect(addFolderButton_, &QPushButton::clicked, this, [this] { addFolder(); }); + connect(clearButton_, &QPushButton::clicked, this, [this] { clearTasks(); }); + connect(startButton_, &QPushButton::clicked, this, [this] { startQueue(); }); + connect(stopButton_, &QPushButton::clicked, this, [this] { stopQueue(); }); + connect(exportButton_, &QPushButton::clicked, this, [this] { exportReport(); }); + connect(taskTable_, &QTableWidget::itemSelectionChanged, this, [this] { updateDetailsPanel(); }); +} + +void MainWindow::addFiles() { + const QStringList files = QFileDialog::getOpenFileNames( + this, + "Select audio files", + QString(), + "Audio Files (*.wav *.flac *.ape *.wv *.tak *.tta *.m4a *.alac *.shn *.ofr *.ofs *.mp3 *.aac);;All Files (*)"); + + addInputFiles(files); +} + +void MainWindow::addFolder() { + const QString folder = QFileDialog::getExistingDirectory(this, "Select audio folder"); + if (folder.isEmpty()) { + return; + } + + QStringList files; + QDirIterator it(folder, kAudioNameFilters, QDir::Files, QDirIterator::Subdirectories); + while (it.hasNext()) { + files.append(it.next()); + } + files.sort(Qt::CaseInsensitive); + + if (files.isEmpty()) { + QMessageBox::information(this, "No audio files", "No supported audio files were found in this folder."); + return; + } + + addInputFiles(files); +} + +void MainWindow::addInputFiles(const QStringList &files) { + if (files.isEmpty()) { + return; + } + + int added = 0; + for (const QString &file : files) { + const QString normalized = QFileInfo(file).absoluteFilePath(); + bool exists = false; + for (const TaskItem &existing : tasks_) { + if (existing.inputPath == normalized) { + exists = true; + break; + } + } + if (exists) { + continue; + } + + TaskItem task; + task.inputPath = normalized; + task.status = kStatusPending; + task.conclusion = "-"; + tasks_.append(task); + + const int row = taskTable_->rowCount(); + taskTable_->insertRow(row); + taskTable_->setItem(row, 0, new QTableWidgetItem(normalized)); + taskTable_->setItem(row, 1, new QTableWidgetItem(task.status)); + taskTable_->setItem(row, 2, new QTableWidgetItem(task.conclusion)); + taskTable_->setItem(row, 3, new QTableWidgetItem(task.accuracy)); + taskTable_->setItem(row, 4, new QTableWidgetItem("-")); + taskTable_->setItem(row, 5, new QTableWidgetItem("-")); + ++added; + } + + updateSummary(); + appendLog(QString("Added %1 file(s).").arg(added)); +} + +void MainWindow::clearTasks() { + if (running_) { + QMessageBox::warning(this, "Busy", "Stop the run before clearing the queue."); + return; + } + + tasks_.clear(); + taskTable_->setRowCount(0); + updateSummary(); + updateDetailsPanel(); + appendLog("Queue cleared."); +} + +void MainWindow::exportReport() { + QString path = reportPathEdit_->text().trimmed(); + if (path.isEmpty()) { + path = QFileDialog::getSaveFileName(this, "Export report", "aucdtect-report.txt", "Text Files (*.txt)"); + } + if (path.isEmpty()) { + return; + } + + QFile file(path); + if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { + QMessageBox::critical(this, "Export failed", QString("Cannot open %1").arg(path)); + return; + } + + QTextStream stream(&file); + stream << composeReport(); + file.close(); + reportPathEdit_->setText(path); + appendLog(QString("Report saved to %1").arg(path)); +} + +void MainWindow::startQueue() { + if (tasks_.isEmpty()) { + QMessageBox::information(this, "No tasks", "Add at least one audio file."); + return; + } + if (running_) { + return; + } + + stopping_ = false; + running_ = true; + for (TaskItem &task : tasks_) { + if (task.status == kStatusFailed || task.status == kStatusStopped || task.status == kStatusDone) { + task.status = kStatusPending; + task.conclusion = "-"; + task.accuracy = "-"; + task.fileHash.clear(); + task.signature.clear(); + task.analyzerOutput.clear(); + task.exitCode = -1; + } + } + for (int row = 0; row < tasks_.size(); ++row) { + refreshRow(row); + } + + setUiBusy(true); + updateSummary(); + appendLog(QString("Queue started with %1 worker(s).").arg(parallelismSpin_->value())); + scheduleWorkers(); +} + +void MainWindow::stopQueue() { + stopping_ = true; + appendLog("Stop requested. Running workers will finish, pending tasks will be marked as stopped."); + for (TaskItem &task : tasks_) { + if (task.status == kStatusPending) { + task.status = kStatusStopped; + task.conclusion = "Cancelled before start"; + } + } + for (int row = 0; row < tasks_.size(); ++row) { + refreshRow(row); + } + updateSummary(); + finishRunIfIdle(); +} + +void MainWindow::scheduleWorkers() { + if (!running_) { + return; + } + + while (!stopping_ && watchers_.size() < parallelismSpin_->value()) { + int nextIndex = -1; + for (int i = 0; i < tasks_.size(); ++i) { + if (tasks_[i].status == kStatusPending && !watchers_.contains(i)) { + nextIndex = i; + break; + } + } + + if (nextIndex < 0) { + break; + } + startWorker(nextIndex); + } + + finishRunIfIdle(); +} + +void MainWindow::startWorker(int taskIndex) { + TaskItem &task = tasks_[taskIndex]; + task.status = kStatusRunning; + task.conclusion = "Analyzing"; + task.accuracy = "-"; + refreshRow(taskIndex); + updateSummary(); + + WorkerPayload payload; + payload.taskIndex = taskIndex; + payload.inputPath = task.inputPath; + payload.decoderTemplate = decoderCommandEdit_->text().trimmed(); + payload.reportPath = reportPathEdit_->text().trimmed(); + + appendLog(QString("Worker started for %1").arg(task.inputPath)); + + auto *watcher = new QFutureWatcher(this); + watchers_.insert(taskIndex, watcher); + connect(watcher, &QFutureWatcher::finished, this, [this, taskIndex, watcher] { + const WorkerResult result = watcher->result(); + watchers_.remove(taskIndex); + watcher->deleteLater(); + applyWorkerResult(taskIndex, result); + scheduleWorkers(); + }); + watcher->setFuture(QtConcurrent::run(runWorker, payload)); +} + +void MainWindow::applyWorkerResult(int taskIndex, const WorkerResult &result) { + if (taskIndex < 0 || taskIndex >= tasks_.size()) { + return; + } + + TaskItem &task = tasks_[taskIndex]; + task.status = result.status; + task.conclusion = result.conclusion; + task.accuracy = result.accuracy.isEmpty() ? "-" : result.accuracy; + task.fileHash = result.fileHash; + task.signature = result.signature; + task.analyzerOutput = result.analyzerOutput; + task.exitCode = result.exitCode; + + refreshRow(taskIndex); + updateSummary(); + updateDetailsPanel(); + appendLog(QString("[%1] %2").arg(task.status, task.inputPath)); +} + +void MainWindow::finishRunIfIdle() { + if (!running_) { + return; + } + + if (!watchers_.isEmpty()) { + return; + } + + bool hasPending = false; + bool hasRunning = false; + for (const TaskItem &task : tasks_) { + hasPending |= (task.status == kStatusPending); + hasRunning |= (task.status == kStatusRunning); + } + + if (hasPending || hasRunning) { + return; + } + + running_ = false; + setUiBusy(false); + appendLog(stopping_ ? "Queue stopped." : "Queue finished."); +} + +void MainWindow::appendLog(const QString &message) { + const QString timestamp = QDateTime::currentDateTime().toString("yyyy-MM-dd hh:mm:ss"); + logView_->appendPlainText(QString("[%1] %2").arg(timestamp, message)); +} + +void MainWindow::setUiBusy(bool busy) { + addFilesButton_->setEnabled(!busy); + addFolderButton_->setEnabled(!busy); + clearButton_->setEnabled(!busy); + startButton_->setEnabled(!busy); + stopButton_->setEnabled(busy); +} + +void MainWindow::refreshRow(int row) { + if (row < 0 || row >= tasks_.size()) { + return; + } + + const TaskItem &task = tasks_[row]; + taskTable_->item(row, 1)->setText(task.status); + taskTable_->item(row, 2)->setText(task.conclusion); + taskTable_->item(row, 3)->setText(task.accuracy); + taskTable_->item(row, 4)->setText(task.fileHash.isEmpty() ? "-" : task.fileHash); + taskTable_->item(row, 5)->setText(task.exitCode >= 0 ? QString::number(task.exitCode) : "-"); +} + +void MainWindow::updateSummary() { + int pending = 0; + int running = 0; + int done = 0; + int failed = 0; + + for (const TaskItem &task : tasks_) { + if (task.status == kStatusPending) { + ++pending; + } else if (task.status == kStatusRunning) { + ++running; + } else if (task.status == kStatusDone) { + ++done; + } else if (task.status == kStatusFailed || task.status == kStatusStopped) { + ++failed; + } + } + + pendingLabel_->setText(QString("Pending: %1").arg(pending)); + runningLabel_->setText(QString("Running: %1").arg(running)); + doneLabel_->setText(QString("Done: %1").arg(done)); + failedLabel_->setText(QString("Failed: %1").arg(failed)); +} + +void MainWindow::updateDetailsPanel() { + const auto selected = taskTable_->selectionModel() ? taskTable_->selectionModel()->selectedRows() : QModelIndexList{}; + if (selected.isEmpty()) { + detailsView_->clear(); + return; + } + + const int row = selected.first().row(); + if (row < 0 || row >= tasks_.size()) { + detailsView_->clear(); + return; + } + + const TaskItem &task = tasks_[row]; + QString details; + QTextStream stream(&details); + stream << "File : " << task.inputPath << "\n"; + stream << "Status : " << task.status << "\n"; + stream << "Conclusion : " << task.conclusion << "\n"; + stream << "Accuracy : " << task.accuracy << "\n"; + stream << "Hash : " << (task.fileHash.isEmpty() ? "-" : task.fileHash) << "\n"; + stream << "Signature : " << (task.signature.isEmpty() ? "-" : task.signature) << "\n"; + stream << "Exit code : " << task.exitCode << "\n\n"; + stream << (task.analyzerOutput.isEmpty() ? "No detailed report yet." : task.analyzerOutput.trimmed()); + detailsView_->setPlainText(details); +} + +QString MainWindow::substitutePlaceholders(const QString &templateText, const TaskItem &task) const { + QString result = templateText; + result.replace("{input}", escapeShellArgument(task.inputPath)); + result.replace("{decoded}", escapeShellArgument(task.decodedPath)); + + QString reportPath = reportPathEdit_->text().trimmed(); + if (reportPath.isEmpty()) { + reportPath = QDir(task.tempDirPath).filePath("analysis-report.txt"); + } + result.replace("{report}", escapeShellArgument(reportPath)); + return result; +} + +QString MainWindow::escapeShellArgument(const QString &value) const { + QString escaped = value; + escaped.replace('\'', "'\"'\"'"); + return "'" + escaped + "'"; +} + +QString MainWindow::composeReport() const { + QString report; + QTextStream stream(&report); + stream << "auCDtect Linux Report\n"; + stream << "Date: " << QDateTime::currentDateTime().toString(Qt::ISODate) << "\n"; + stream << "Workers: " << parallelismSpin_->value() << "\n\n"; + + for (const TaskItem &task : tasks_) { + stream << "FILE: " << task.inputPath << "\n"; + stream << "Accuracy : " << task.accuracy << "\n"; + stream << "Conclusion : " << task.conclusion << "\n"; + stream << "Status : " << task.status << "\n"; + stream << "Hash : " << (task.fileHash.isEmpty() ? "-" : task.fileHash) << "\n"; + stream << "Signature : " << (task.signature.isEmpty() ? "-" : task.signature) << "\n"; + stream << "Exit code : " << task.exitCode << "\n"; + if (!task.analyzerOutput.isEmpty()) { + stream << "\n" << task.analyzerOutput.trimmed() << "\n"; + } + stream << "\n"; + } + + return report; +} diff --git a/src/MainWindow.h b/src/MainWindow.h new file mode 100644 index 0000000..94ba681 --- /dev/null +++ b/src/MainWindow.h @@ -0,0 +1,98 @@ +#pragma once + +#include +#include +#include + +#include "AudioAnalyzer.h" + +template +class QFutureWatcher; +class QLineEdit; +class QLabel; +class QPlainTextEdit; +class QPushButton; +class QSpinBox; +class QTableWidget; +class QTextEdit; + +struct TaskItem { + QString inputPath; + QString decodedPath; + QString tempDirPath; + QString status; + QString conclusion; + QString accuracy = "-"; + QString fileHash; + QString signature; + QString analyzerOutput; + int exitCode = -1; +}; + +struct WorkerResult { + QString status; + QString conclusion; + QString accuracy; + QString fileHash; + QString signature; + QString analyzerOutput; + int exitCode = -1; +}; + +class MainWindow final : public QMainWindow { +public: + MainWindow(); + ~MainWindow() override; + +protected: + void closeEvent(QCloseEvent *event) override; + +private: + void buildUi(); + void loadSettings(); + void saveSettings() const; + void connectSignals(); + + void addFiles(); + void addFolder(); + void addInputFiles(const QStringList &files); + void clearTasks(); + void exportReport(); + void startQueue(); + void stopQueue(); + void scheduleWorkers(); + void startWorker(int taskIndex); + void applyWorkerResult(int taskIndex, const WorkerResult &result); + void finishRunIfIdle(); + + void appendLog(const QString &message); + void setUiBusy(bool busy); + void refreshRow(int row); + void updateSummary(); + void updateDetailsPanel(); + QString substitutePlaceholders(const QString &templateText, const TaskItem &task) const; + QString escapeShellArgument(const QString &value) const; + QString composeReport() const; + + QTableWidget *taskTable_ = nullptr; + QPlainTextEdit *logView_ = nullptr; + QPlainTextEdit *detailsView_ = nullptr; + QLineEdit *decoderCommandEdit_ = nullptr; + QLineEdit *reportPathEdit_ = nullptr; + QSpinBox *parallelismSpin_ = nullptr; + QLabel *pendingLabel_ = nullptr; + QLabel *runningLabel_ = nullptr; + QLabel *doneLabel_ = nullptr; + QLabel *failedLabel_ = nullptr; + QPushButton *addFilesButton_ = nullptr; + QPushButton *addFolderButton_ = nullptr; + QPushButton *clearButton_ = nullptr; + QPushButton *startButton_ = nullptr; + QPushButton *stopButton_ = nullptr; + QPushButton *exportButton_ = nullptr; + AudioAnalyzer analyzer_; + QList tasks_; + QMap *> watchers_; + bool stopping_ = false; + bool running_ = false; +}; diff --git a/src/cli_main.cpp b/src/cli_main.cpp new file mode 100644 index 0000000..ba98154 --- /dev/null +++ b/src/cli_main.cpp @@ -0,0 +1,107 @@ +#include "AudioAnalyzer.h" + +#include +#include +#include +#include + +namespace { +int printUsage(const QString &binaryName) { + QTextStream err(stderr); + err << "Usage:\n"; + err << " " << binaryName << " [more files...]\n"; + err << " " << binaryName << " --dump-features [more files...]\n"; + err << "Current CLI supports PCM WAV input directly.\n"; + return 1; +} + +QString csvEscape(const QString &value) { + QString escaped = value; + escaped.replace('"', "\"\""); + return "\"" + escaped + "\""; +} + +void printFeatureHeader(QTextStream &out) { + out << "file,ok,conclusion,accuracy,confidence,sample_rate,channels,bits_per_sample,sample_frames,cutoff_khz,rolloff_khz,lo_cut_hz,hi_cut_hz,lo_boundary_hz,hi_boundary_hz,probable_boundary_hz,phase_nonlinearity,first_order_smoothness,second_order_smoothness,high_band_ratio,very_high_band_ratio,spectral_flatness,analyzed_windows,informative_windows,suspect_windows,genuine_windows,suspect_ratio,error\n"; +} + +void printFeatureRow(QTextStream &out, const QString &path, const AudioAnalysisReport &report) { + out << csvEscape(path) << "," + << (report.ok ? "1" : "0") << "," + << csvEscape(report.conclusion) << "," + << csvEscape(report.accuracy) << "," + << report.confidence << "," + << report.sampleRate << "," + << report.channels << "," + << report.bitsPerSample << "," + << report.sampleFrames << "," + << QString::number(report.cutoffKhz, 'f', 4) << "," + << QString::number(report.rolloffKhz, 'f', 4) << "," + << QString::number(report.loCutHz, 'f', 4) << "," + << QString::number(report.hiCutHz, 'f', 4) << "," + << QString::number(report.loBoundaryHz, 'f', 4) << "," + << QString::number(report.hiBoundaryHz, 'f', 4) << "," + << QString::number(report.probableBoundaryHz, 'f', 4) << "," + << QString::number(report.phaseNonlinearity, 'f', 8) << "," + << QString::number(report.firstOrderSmoothness, 'f', 8) << "," + << QString::number(report.secondOrderSmoothness, 'f', 8) << "," + << QString::number(report.highBandRatio, 'f', 8) << "," + << QString::number(report.veryHighBandRatio, 'f', 8) << "," + << QString::number(report.spectralFlatness, 'f', 8) << "," + << report.analyzedWindows << "," + << report.informativeWindows << "," + << report.suspectWindows << "," + << report.genuineWindows << "," + << QString::number(report.suspectRatio, 'f', 8) << "," + << csvEscape(report.error) << "\n"; +} +} + +int main(int argc, char *argv[]) { + QCoreApplication app(argc, argv); + QStringList args = app.arguments(); + if (args.size() < 2) { + return printUsage(QFileInfo(args.value(0)).fileName()); + } + + bool dumpFeatures = false; + args.removeFirst(); + if (!args.isEmpty() && args.first() == "--dump-features") { + dumpFeatures = true; + args.removeFirst(); + } + if (args.isEmpty()) { + return printUsage(QFileInfo(app.arguments().value(0)).fileName()); + } + + AudioAnalyzer analyzer; + QTextStream out(stdout); + QTextStream err(stderr); + int exitCode = 0; + + if (dumpFeatures) { + printFeatureHeader(out); + } + + for (const QString &path : args) { + const AudioAnalysisReport report = analyzer.analyzeFile(path); + if (dumpFeatures) { + printFeatureRow(out, path, report); + } else { + out << "FILE: " << path << "\n"; + if (!report.ok) { + err << "ERROR: " << report.error << "\n\n"; + exitCode = 2; + continue; + } + + out << report.rawReport; + out << "\n"; + } + if (!report.ok) { + exitCode = 2; + } + } + + return exitCode; +} diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..c80518b --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,16 @@ +#include "MainWindow.h" + +#include +#include + +int main(int argc, char *argv[]) { + QApplication app(argc, argv); + app.setApplicationName("auCDtect Linux"); + app.setDesktopFileName("aucdtect-linux"); + app.setWindowIcon(QIcon(":/icons/aucdtect-linux.png")); + + MainWindow window; + window.setWindowIcon(QIcon(":/icons/aucdtect-linux.png")); + window.show(); + return app.exec(); +} diff --git a/tools/eval_dataset.sh b/tools/eval_dataset.sh new file mode 100755 index 0000000..2229315 --- /dev/null +++ b/tools/eval_dataset.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -lt 1 || $# -gt 2 ]]; then + echo "Usage: $0 [output.csv]" >&2 + exit 1 +fi + +samples_root=$1 +output_csv=${2:-dataset_eval.csv} + +if [[ ! -d "$samples_root" ]]; then + echo "Samples directory not found: $samples_root" >&2 + exit 1 +fi + +if [[ ! -x build/aucdtect ]]; then + echo "CLI binary missing: build/aucdtect" >&2 + echo "Build the project first with: cmake --build build" >&2 + exit 1 +fi + +if ! command -v ffmpeg >/dev/null 2>&1; then + echo "ffmpeg is required for dataset evaluation" >&2 + exit 1 +fi + +tmp_root=$(mktemp -d) +trap 'rm -rf "$tmp_root"' EXIT + +echo "label,source_file,file,ok,conclusion,accuracy,confidence,sample_rate,channels,bits_per_sample,sample_frames,cutoff_khz,rolloff_khz,lo_cut_hz,hi_cut_hz,lo_boundary_hz,hi_boundary_hz,probable_boundary_hz,phase_nonlinearity,first_order_smoothness,second_order_smoothness,high_band_ratio,very_high_band_ratio,spectral_flatness,analyzed_windows,informative_windows,suspect_windows,genuine_windows,suspect_ratio,error" > "$output_csv" + +while IFS= read -r -d '' source_file; do + rel_path=${source_file#"$samples_root"/} + label=${rel_path%%/*} + if [[ "$label" == "$rel_path" ]]; then + label=unlabeled + fi + + ext=${source_file##*.} + ext_lower=$(printf '%s' "$ext" | tr '[:upper:]' '[:lower:]') + analysis_file=$source_file + + if [[ "$ext_lower" != "wav" ]]; then + analysis_file="$tmp_root/$(basename "${source_file%.*}").wav" + ffmpeg -loglevel error -y -i "$source_file" -ar 44100 -ac 2 -c:a pcm_s16le "$analysis_file" > "$output_csv" +done < <(find "$samples_root" -type f \( -iname '*.wav' -o -iname '*.flac' -o -iname '*.mp3' -o -iname '*.aac' -o -iname '*.m4a' -o -iname '*.ape' -o -iname '*.wv' -o -iname '*.tta' -o -iname '*.tak' \) -print0 | sort -z) + +echo "Saved dataset report to $output_csv"