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