From 3695eab1c50d13f4ecc44788f23ea00288b3db21 Mon Sep 17 00:00:00 2001 From: Armin Date: Fri, 3 Jul 2026 13:52:15 +0200 Subject: [PATCH] add AGENTS.md, update README, fix frame in tcd.c --- AGENTS.md | 14 +++++ README.md | 6 +- tcd.c | 169 ++++++++++++++++++++++++++++++++++++++++++++++-------- 3 files changed, 163 insertions(+), 26 deletions(-) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d052a36 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,14 @@ +Implements a command line utility that allows to detect the TRUE quality of your audio material in one batch. Uses psychoacoustics as defined and scientifically proven in: + +- ISO/IEC 11172-3:1993 - Coding of moving pictures and associated audio for + digital storage media at up to about 1.5 Mbit/s, Part 3: Audio (MPEG-1 + Audio Layer III, "MP3"). +- ISO/IEC 13818-7:2006 - Generic coding of moving pictures and associated + audio information, Part 7: Advanced Audio Coding (AAC). +- Zwicker, E. & Fastl, H. - *Psychoacoustics: Facts and Models*, Springer, + 1999 (Fletcher–Munson equal-loudness contours). +- Lerch, A. - *An Introduction to Audio Content Analysis*, Wiley, 2012 + (spectral features for audio forensics). + +Always implement only what is scientifically provable. As it is possible to view on a spectrum analysis of the audio to see the hard cutoff introduced by compression algorithms as MP3, it is possible to tell if we have a 96k MP3 file that just was re-encoded as 320k. This tool should allow to spot exactly that. + diff --git a/README.md b/README.md index b8a52bb..5626264 100644 --- a/README.md +++ b/README.md @@ -25,8 +25,8 @@ How it works ### 1. Signal acquisition The program opens the file with libavformat, selects the first audio stream, -decodes up to `--duration` (default 60) seconds of audio, and converts every -sample to 32-bit float PCM. +decodes up to `--duration` (default 60) seconds of audio (or the entire file +when `--full` is used), and converts every sample to 32-bit float PCM. ### 2. Windowing & FFT @@ -304,6 +304,8 @@ tcd [options] higher = more detections. Adjust in small steps. -f, --fft-size N FFT size (power of 2) [default: 4096] -d, --duration SEC Max seconds to analyze [default: 60] + -r, --recursive Recurse into subdirectories + -F, --full Analyze entire file (overrides --duration) -v, --verbose Verbose output -s, --visual Graphical spectrum visualization (TUI) -V Alias for -s diff --git a/tcd.c b/tcd.c index 16dba33..dc5691b 100644 --- a/tcd.c +++ b/tcd.c @@ -11,6 +11,7 @@ #include #include +#include #include #include @@ -864,26 +865,117 @@ static void render_visual_spectrum( free(col_max); } +static int vis_len(const char *s) +{ + int len = 0; + while (*s) { + unsigned char c = (unsigned char)*s; + if (c == '\033') { + while (*s && *s != 'm') s++; + if (*s) s++; + } else if ((c & 0xC0) == 0x80) { + s++; + } else { + len++; + s++; + } + } + return len; +} + +static void pn(const char *s) { printf("%s", s); } +static void pl(const char *s, int w) +{ + int sl = vis_len(s); + printf(ANSI_BLUE "│" ANSI_RESET " %s", s); + int pad = w - 4 - sl; + if (pad > 0) repeat_char(' ', pad); + printf(ANSI_BLUE "│" ANSI_RESET "\n"); +} + static void print_help(const char *prog) { - printf("Usage: %s [options] [ ...]\n\n", prog); - printf("Detect whether an MP3 or FLAC file is a transcode (lossy->lossless re-encode)\n"); - printf("by analyzing the audio spectrum for frequency cutoffs.\n"); - printf("If no file is given, processes all audio files in the current directory recursively.\n\n"); - printf("Options:\n"); - printf(" -t, --threshold PCT Overall detection sensitivity (1-99). Controls all\n"); - printf(" decision thresholds: transition bandwidth, roughness,\n"); - printf(" and band ratio. Maps to -40 dB cutoff threshold (1,\n"); - printf(" least sensitive) through -80 dB (99, most sensitive).\n"); - printf(" [default: %d]. 50 is neutral; lower = fewer detections,\n", THRESHOLD_DEFAULT); - printf(" higher = more detections. Adjust in small steps.\n"); - printf(" -f, --fft-size N FFT size (power of 2) [default: %d]\n", FFT_SIZE_DEFAULT); - printf(" -d, --duration SEC Max seconds to analyze [default: %d]\n", MAX_ANALYSIS_SECS); - printf(" -v, --verbose Verbose output\n"); - printf(" -s, --visual Graphical spectrum visualization (TUI)\n"); - printf(" -V Alias for -s\n"); - printf(" -a, --auto-remove Automatically remove non-native files\n"); - printf(" -h, --help Show this help\n"); + int tw = get_term_width(); + if (tw < 60) tw = 80; + + pn(ANSI_BLUE "╭"); repeat_str("─", tw - 2); pn(ANSI_BLUE "╮" ANSI_RESET "\n"); + + pl("", tw); + { + char buf[256]; + snprintf(buf, sizeof(buf), ANSI_BOLD ANSI_CYAN "tcd" ANSI_RESET + " \xe2\x80\x94 Transcode Detector " + "Psychoacoustic audio authenticity analysis"); + int v = vis_len(buf); + printf(ANSI_BLUE "│" ANSI_RESET " %s", buf); + int pad = tw - 4 - v; + if (pad > 0) repeat_char(' ', pad); + printf(ANSI_BLUE "│" ANSI_RESET "\n"); + } + pl("", tw); + + pl("Analyze audio files to detect transcodes (lossy \xe2\x86\x92 lossless", tw); + pl("re-encodes) by measuring spectral cutoffs and artifacts.", tw); + pl("", tw); + { + char buf[256]; + snprintf(buf, sizeof(buf), + ANSI_BOLD "Usage:" ANSI_RESET " %s [options] [ ...]", prog); + int v = vis_len(buf); + printf(ANSI_BLUE "│" ANSI_RESET " %s", buf); + int pad = tw - 4 - v; + if (pad > 0) repeat_char(' ', pad); + printf(ANSI_BLUE "│" ANSI_RESET "\n"); + } + pl("", tw); + + pn(ANSI_BLUE "│" ANSI_RESET " " ANSI_BOLD "Options:" ANSI_RESET); + repeat_char(' ', tw - 12); + pn(ANSI_BLUE "│" ANSI_RESET "\n"); + +#define OPT(fmt, desc) do { \ + printf(ANSI_BLUE "│" ANSI_RESET " " ANSI_GREEN fmt ANSI_RESET " %s", desc); \ + int v = 1 + 4 + (int)strlen(fmt) + 2 + vis_len(desc); \ + int pad = tw - 1 - v; \ + if (pad > 0) repeat_char(' ', pad); \ + printf(ANSI_BLUE "│" ANSI_RESET "\n"); \ +} while (0) + + { + char buf[80]; + snprintf(buf, sizeof(buf), "Detection sensitivity 1-99 [" ANSI_CYAN "%d" ANSI_RESET "]", THRESHOLD_DEFAULT); + OPT("-t, --threshold PCT", buf); + } + { + char buf[80]; + snprintf(buf, sizeof(buf), ANSI_DIM " Lower = fewer, higher = more" ANSI_RESET); + int v = 1 + 4 + vis_len(buf); + printf(ANSI_BLUE "│" ANSI_RESET " %s", buf); + int pad = tw - 1 - v; + if (pad > 0) repeat_char(' ', pad); + printf(ANSI_BLUE "│" ANSI_RESET "\n"); + } + { + char buf[80]; + snprintf(buf, sizeof(buf), "FFT size, power of 2 [" ANSI_CYAN "%d" ANSI_RESET "]", FFT_SIZE_DEFAULT); + OPT("-f, --fft-size N", buf); + } + { + char buf[80]; + snprintf(buf, sizeof(buf), "Seconds to analyze [" ANSI_CYAN "%d" ANSI_RESET "]", MAX_ANALYSIS_SECS); + OPT("-d, --duration SEC", buf); + } + OPT("-r, --recursive", "Recurse into subdirectories"); + OPT("-F, --full", "Analyze entire file (no duration limit)"); + OPT("-v, --verbose", "Verbose output with decision log"); + OPT("-s, --visual", "Graphical spectrum TUI visualization"); + OPT("-V", "Alias for " ANSI_GREEN "-s" ANSI_RESET); + OPT("-a, --auto-remove", "Automatically delete detected transcodes"); + OPT("-h, --help", "Show this help screen"); + +#undef OPT + + pn(ANSI_BLUE "╰"); repeat_str("─", tw - 2); pn(ANSI_BLUE "╯" ANSI_RESET "\n"); } static void maybe_remove(const char *filename, int peak_db_neg90, @@ -919,6 +1011,8 @@ typedef struct { int dump_spectrum; int visual; int auto_remove; + int recursive; + int full; } Options; /* ---- Audio extension check ---- */ @@ -1027,7 +1121,7 @@ static int process_file(const char *filename, const Options *opts) return 1; } - int max_frames = sample_rate * opts->max_secs; + int max_frames = opts->full ? INT_MAX : sample_rate * opts->max_secs; int total_frames = 0; float *ring = calloc(opts->fft_size * channels, sizeof(float)); @@ -1243,7 +1337,6 @@ static int process_path(const char *path, const Options *opts) struct dirent *e; while ((e = readdir(dir))) { if (e->d_name[0] == '.') continue; - if (!is_audio_ext(e->d_name)) continue; size_t nlen = strlen(e->d_name); char *full = malloc(plen + 1 + nlen + 1); @@ -1252,6 +1345,28 @@ static int process_path(const char *path, const Options *opts) full[plen] = '/'; memcpy(full + plen + 1, e->d_name, nlen + 1); + struct stat st; + if (stat(full, &st) < 0) { + free(full); + continue; + } + + if (S_ISDIR(st.st_mode)) { + if (opts->recursive) { + int rc = process_path(full, opts); + free(full); + if (rc > overall) overall = rc; + } else { + free(full); + } + continue; + } + + if (!is_audio_ext(e->d_name)) { + free(full); + continue; + } + int rc = process_path(full, opts); free(full); if (rc > overall) overall = rc; @@ -1278,7 +1393,7 @@ int main(int argc, char **argv) const char *prog = argv[0]; Options opts = { - .threshold_db = -(double)THRESHOLD_DEFAULT, + .threshold_db = -(40.0 + (double)(THRESHOLD_DEFAULT - 1) * 40.0 / 98.0), .sensitivity = 0.5, .fft_size = FFT_SIZE_DEFAULT, .max_secs = MAX_ANALYSIS_SECS, @@ -1286,6 +1401,8 @@ int main(int argc, char **argv) .dump_spectrum = 0, .visual = 0, .auto_remove = 0, + .recursive = 0, + .full = 0, }; static const struct option long_opts[] = { @@ -1295,13 +1412,15 @@ int main(int argc, char **argv) {"verbose", no_argument, NULL, 'v'}, {"spectrum", no_argument, NULL, 's'}, {"visual", no_argument, NULL, 'V'}, + {"recursive", no_argument, NULL, 'r'}, + {"full", no_argument, NULL, 'F'}, {"auto-remove", no_argument, NULL, 'a'}, {"help", no_argument, NULL, 'h'}, {NULL, 0, NULL, 0} }; int opt; - while ((opt = getopt_long(argc, argv, "t:f:d:asvVh", long_opts, NULL)) != -1) { + while ((opt = getopt_long(argc, argv, "t:f:d:arFsvVh", long_opts, NULL)) != -1) { switch (opt) { case 't': { int pct = atoi(optarg); @@ -1314,6 +1433,8 @@ int main(int argc, char **argv) case 'f': opts.fft_size = atoi(optarg); break; case 'd': opts.max_secs = atoi(optarg); break; case 'a': opts.auto_remove = 1; break; + case 'r': opts.recursive = 1; break; + case 'F': opts.full = 1; break; case 's': opts.visual = 1; break; case 'v': opts.verbose = 1; break; case 'V': opts.visual = 1; break; @@ -1323,8 +1444,8 @@ int main(int argc, char **argv) } if (optind >= argc) { - argv[optind] = "."; - argc = optind + 1; + print_help(prog); + return 0; } if (opts.fft_size < 64 || opts.fft_size > 65536 || (opts.fft_size & (opts.fft_size - 1)) != 0) {