Initial auCDtect Linux implementation

This commit is contained in:
2026-04-19 10:45:03 +03:00
commit 4210e5aa8b
16 changed files with 1867 additions and 0 deletions

19
.gitignore vendored Normal file
View File

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

42
CMakeLists.txt Normal file
View File

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

30
PKGBUILD Normal file
View File

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

94
README.md Normal file
View File

@@ -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 <input.wav> [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

BIN
assets/aucdtect-linux.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

28
assets/aucdtect-linux.svg Normal file
View File

@@ -0,0 +1,28 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<defs>
<linearGradient id="bg" x1="24" y1="20" x2="228" y2="238" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#0b1d2a"/>
<stop offset="0.55" stop-color="#123b46"/>
<stop offset="1" stop-color="#d67a2c"/>
</linearGradient>
<linearGradient id="disc" x1="68" y1="52" x2="190" y2="196" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#f8f2dd"/>
<stop offset="0.42" stop-color="#98d7cf"/>
<stop offset="0.7" stop-color="#2d6971"/>
<stop offset="1" stop-color="#102a35"/>
</linearGradient>
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="10" stdDeviation="12" flood-color="#061015" flood-opacity="0.45"/>
</filter>
</defs>
<rect width="256" height="256" rx="48" fill="url(#bg)"/>
<path d="M35 181c28-20 45-24 72-15 27 10 45 8 70-11 17-13 30-18 44-18v71H35z" fill="#f29c38" opacity="0.88"/>
<path d="M35 194c31-18 53-18 82-5 23 11 45 10 69-4 16-10 28-13 35-12v35H35z" fill="#ffe3a2" opacity="0.7"/>
<circle cx="128" cy="119" r="76" fill="url(#disc)" filter="url(#shadow)"/>
<circle cx="128" cy="119" r="54" fill="none" stroke="#f9f5df" stroke-width="5" opacity="0.5"/>
<circle cx="128" cy="119" r="30" fill="none" stroke="#102b34" stroke-width="8" opacity="0.5"/>
<circle cx="128" cy="119" r="15" fill="#f8f2dd"/>
<path d="M61 84c22-42 78-54 116-24" fill="none" stroke="#ffffff" stroke-width="10" stroke-linecap="round" opacity="0.35"/>
<path d="M55 154h34l9-31 17 58 22-86 18 59h46" fill="none" stroke="#ffffff" stroke-width="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M55 154h34l9-31 17 58 22-86 18 59h46" fill="none" stroke="#0b1d2a" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" opacity="0.55"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1,5 @@
<RCC>
<qresource prefix="/icons">
<file>aucdtect-linux.png</file>
</qresource>
</RCC>

View File

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

View File

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

572
src/AudioAnalyzer.cpp Normal file
View File

@@ -0,0 +1,572 @@
#include "AudioAnalyzer.h"
#include <QFile>
#include <QStringList>
#include <QtEndian>
#include <algorithm>
#include <cmath>
#include <complex>
#include <limits>
#include <numbers>
#include <numeric>
#include <vector>
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<double> 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<quint32>(reinterpret_cast<const uchar *>(p));
}
quint16 readLe16(const char *p) {
return qFromLittleEndian<quint16>(reinterpret_cast<const uchar *>(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<int>(chunkSize + (chunkSize % 2));
if (chunkDataOffset + static_cast<int>(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<int>(readLe32(bytes + chunkDataOffset + 4));
formatBitsPerSample = readLe16(bytes + chunkDataOffset + 14);
} else if (chunkId == "data") {
pcmData = data.mid(chunkDataOffset, static_cast<int>(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<double> monoSamples;
monoSamples.reserve(static_cast<size_t>(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<qint16>(reinterpret_cast<const uchar *>(samplePtr));
sum += static_cast<double>(value) / 32768.0;
} else if (formatBitsPerSample == 24) {
value = (static_cast<unsigned char>(samplePtr[0])) |
(static_cast<unsigned char>(samplePtr[1]) << 8) |
(static_cast<unsigned char>(samplePtr[2]) << 16);
if (value & 0x800000) {
value |= ~0xffffff;
}
sum += static_cast<double>(value) / 8388608.0;
} else {
value = qFromLittleEndian<qint32>(reinterpret_cast<const uchar *>(samplePtr));
sum += static_cast<double>(value) / 2147483648.0;
}
}
monoSamples.push_back(sum / static_cast<double>(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<int>(std::round(normalized * (bins - 1))), 0, bins - 1);
}
double bandEnergy(const std::vector<double> &spectrum, int beginBin, int endBin) {
if (spectrum.empty()) {
return 0.0;
}
beginBin = std::clamp(beginBin, 0, static_cast<int>(spectrum.size()) - 1);
endBin = std::clamp(endBin, beginBin, static_cast<int>(spectrum.size()) - 1);
double sum = 0.0;
for (int i = beginBin; i <= endBin; ++i) {
sum += spectrum[static_cast<size_t>(i)];
}
return sum;
}
double estimateRms(const std::vector<double> &window) {
double sumSquares = 0.0;
for (double value : window) {
sumSquares += value * value;
}
return std::sqrt(sumSquares / std::max<size_t>(1, window.size()));
}
std::vector<double> powerSpectrumForWindow(const std::vector<double> &samples, size_t start) {
std::vector<double> window(kWindowSize, 0.0);
std::vector<double> 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<size_t>(i)] * hann;
}
for (int k = 0; k < kWindowSize / 2; ++k) {
std::complex<double> 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<double>(std::cos(phase), std::sin(phase));
}
spectrum[static_cast<size_t>(k)] = std::norm(sum);
}
return spectrum;
}
std::vector<std::complex<double>> complexSpectrumForWindow(const std::vector<double> &samples, size_t start) {
std::vector<double> window(kWindowSize, 0.0);
std::vector<std::complex<double>> 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<size_t>(i)] * hann;
}
for (int k = 0; k < kWindowSize / 2; ++k) {
std::complex<double> 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<double>(std::cos(phase), std::sin(phase));
}
spectrum[static_cast<size_t>(k)] = sum;
}
return spectrum;
}
double normalizedRoughness(const std::vector<double> &values, int order) {
if (values.size() <= static_cast<size_t>(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<double>::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<std::complex<double>> &spectrum, int beginBin, int endBin) {
beginBin = std::clamp(beginBin, 0, static_cast<int>(spectrum.size()) - 1);
endBin = std::clamp(endBin, beginBin + 2, static_cast<int>(spectrum.size()) - 1);
std::vector<double> phases;
phases.reserve(static_cast<size_t>(endBin - beginBin + 1));
double previous = std::arg(spectrum[static_cast<size_t>(beginBin)]);
phases.push_back(previous);
for (int i = beginBin + 1; i <= endBin; ++i) {
double phase = std::arg(spectrum[static_cast<size_t>(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<double> &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<double>::epsilon()) {
return 0.0;
}
const int bin10 = freqToBin(10.0, sampleRate, static_cast<int>(spectrum.size()));
int lastStrong = bin10;
for (int i = bin10; i < static_cast<int>(spectrum.size()); ++i) {
if (spectrum[static_cast<size_t>(i)] > peak * relativeThreshold) {
lastStrong = i;
}
}
return (static_cast<double>(lastStrong) * sampleRate / 2.0 / spectrum.size()) / 1000.0;
}
double estimateSpectralFlatness(const std::vector<double> &spectrum, int beginBin, int endBin) {
beginBin = std::clamp(beginBin, 0, static_cast<int>(spectrum.size()) - 1);
endBin = std::clamp(endBin, beginBin, static_cast<int>(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<size_t>(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<double> &samples, size_t start, int sampleRate) {
SpectrumStats stats;
std::vector<double> rawWindow(kWindowSize, 0.0);
for (int i = 0; i < kWindowSize; ++i) {
rawWindow[static_cast<size_t>(i)] = samples[start + static_cast<size_t>(i)];
}
stats.rms = estimateRms(rawWindow);
if (stats.rms < 0.015) {
return stats;
}
const std::vector<std::complex<double>> complexSpectrum = complexSpectrumForWindow(samples, start);
std::vector<double> spectrum;
spectrum.reserve(complexSpectrum.size());
for (const auto &value : complexSpectrum) {
spectrum.push_back(std::norm(value));
}
const int bins = static_cast<int>(spectrum.size());
const double total = bandEnergy(spectrum, 0, bins - 1);
if (total <= std::numeric_limits<double>::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<SpectrumStats> analyzeWindows(const std::vector<double> &samples, int sampleRate) {
if (samples.size() < static_cast<size_t>(kWindowSize)) {
return {};
}
const size_t available = samples.size() - kWindowSize;
const size_t windows = std::min<size_t>(kMaxWindows, samples.size() / kWindowSize);
const size_t hop = windows > 1 ? std::max<size_t>(1, available / (windows - 1)) : 1;
std::vector<SpectrumStats> 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<SpectrumStats> 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<int>(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<double>(report.suspectWindows) / report.informativeWindows;
const double genuineRatio = static_cast<double>(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<int>(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<int>(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<int>(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<int>(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<int>(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;
}

39
src/AudioAnalyzer.h Normal file
View File

@@ -0,0 +1,39 @@
#pragma once
#include <QString>
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;
};

670
src/MainWindow.cpp Normal file
View File

@@ -0,0 +1,670 @@
#include "MainWindow.h"
#include <QCloseEvent>
#include <QCryptographicHash>
#include <QDateTime>
#include <QDir>
#include <QFile>
#include <QFileDialog>
#include <QFileInfo>
#include <QDirIterator>
#include <QFormLayout>
#include <QFutureWatcher>
#include <QGroupBox>
#include <QHeaderView>
#include <QHBoxLayout>
#include <QLabel>
#include <QLineEdit>
#include <QMessageBox>
#include <QPlainTextEdit>
#include <QProcess>
#include <QPushButton>
#include <QSettings>
#include <QSpinBox>
#include <QSplitter>
#include <QStandardPaths>
#include <QTableWidget>
#include <QTableWidgetItem>
#include <QTextStream>
#include <QVBoxLayout>
#include <QWidget>
#include <QtConcurrent/QtConcurrentRun>
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<WorkerResult>(this);
watchers_.insert(taskIndex, watcher);
connect(watcher, &QFutureWatcher<WorkerResult>::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;
}

98
src/MainWindow.h Normal file
View File

@@ -0,0 +1,98 @@
#pragma once
#include <QMainWindow>
#include <QMap>
#include <QStringList>
#include "AudioAnalyzer.h"
template <typename T>
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<TaskItem> tasks_;
QMap<int, QFutureWatcher<WorkerResult> *> watchers_;
bool stopping_ = false;
bool running_ = false;
};

107
src/cli_main.cpp Normal file
View File

@@ -0,0 +1,107 @@
#include "AudioAnalyzer.h"
#include <QCoreApplication>
#include <QFileInfo>
#include <QStringList>
#include <QTextStream>
namespace {
int printUsage(const QString &binaryName) {
QTextStream err(stderr);
err << "Usage:\n";
err << " " << binaryName << " <input.wav> [more files...]\n";
err << " " << binaryName << " --dump-features <input.wav> [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;
}

16
src/main.cpp Normal file
View File

@@ -0,0 +1,16 @@
#include "MainWindow.h"
#include <QApplication>
#include <QIcon>
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();
}

53
tools/eval_dataset.sh Executable file
View File

@@ -0,0 +1,53 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -lt 1 || $# -gt 2 ]]; then
echo "Usage: $0 <samples-root> [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" </dev/null
fi
row=$(build/aucdtect --dump-features "$analysis_file" </dev/null | tail -n +2)
printf '"%s","%s",%s\n' "$label" "$source_file" "$row" >> "$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"