commit 4210e5aa8b41625a29aa11172815a40290d5c3f6 Author: benya Date: Sun Apr 19 10:45:03 2026 +0300 Initial auCDtect Linux implementation 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 0000000..10e41ba Binary files /dev/null and b/assets/aucdtect-linux.png differ 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"