Initial auCDtect Linux implementation
This commit is contained in:
19
.gitignore
vendored
Normal file
19
.gitignore
vendored
Normal 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
42
CMakeLists.txt
Normal 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
30
PKGBUILD
Normal 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
94
README.md
Normal 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
BIN
assets/aucdtect-linux.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
28
assets/aucdtect-linux.svg
Normal file
28
assets/aucdtect-linux.svg
Normal 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 |
5
assets/aucdtect_linux.qrc
Normal file
5
assets/aucdtect_linux.qrc
Normal file
@@ -0,0 +1,5 @@
|
||||
<RCC>
|
||||
<qresource prefix="/icons">
|
||||
<file>aucdtect-linux.png</file>
|
||||
</qresource>
|
||||
</RCC>
|
||||
85
docs/reverse_aucdtect_082.md
Normal file
85
docs/reverse_aucdtect_082.md
Normal 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.
|
||||
9
packaging/aucdtect-linux.desktop
Normal file
9
packaging/aucdtect-linux.desktop
Normal 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
572
src/AudioAnalyzer.cpp
Normal 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
39
src/AudioAnalyzer.h
Normal 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
670
src/MainWindow.cpp
Normal 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
98
src/MainWindow.h
Normal 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
107
src/cli_main.cpp
Normal 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
16
src/main.cpp
Normal 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
53
tools/eval_dataset.sh
Executable 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"
|
||||
Reference in New Issue
Block a user