diff --git a/tests/test_codec_translations.c b/tests/test_codec_translations.c new file mode 100644 index 0000000000..2bc47aa16f --- /dev/null +++ b/tests/test_codec_translations.c @@ -0,0 +1,552 @@ +/* + * Asterisk -- An open source telephony toolkit. + * + * Copyright (C) 2026, Asterisk Community + * + * See http://www.asterisk.org for more information about + * the Asterisk project. Please do not directly contact + * any of the maintainers of this project for assistance; + * the project provides a web site, mailing lists and IRC + * channels for your use. + * + * This program is free software, distributed under the terms of + * the GNU General Public License Version 2. See the LICENSE file + * at the top of the source tree. + */ + +/*! + * \file + * \brief Codec Translation Roundtrip Tests + * + * \author Sebastian Jennen + * + * Tests that encoding sample frames through a codec and decoding them + * back to slin produces output that is (almost) identical to + * the original input. This verifies that each codec translator pair + * (slin -> codec -> slin) does not corrupt or destroy the audio signal. + * + * For near-lossless codecs (alaw, ulaw) the tolerance is very tight. + * For lossy codecs (adpcm, g726, gsm, g722, speex, ilbc, opus, g729, etc.) + * a Signal-to-Noise Ratio (SNR) threshold is used since some degradation + * is inherent to the compression algorithm. Wideband codecs are tested + * with a matching slin16/slin32/slin48 signal at the codec's native + * sample rate. + */ + +/*** MODULEINFO + TEST_FRAMEWORK + extended + ***/ + +#include "asterisk.h" + +#include + +#include "asterisk/module.h" +#include "asterisk/test.h" +#include "asterisk/translate.h" +#include "asterisk/format.h" +#include "asterisk/format_cap.h" +#include "asterisk/format_cache.h" +#include "asterisk/codec.h" +#include "asterisk/frame.h" +#include "asterisk/slin.h" +#include "asterisk/logger.h" +#include "asterisk/utils.h" + +/*! Duration of the test signal in seconds */ +#define TEST_DURATION_SECS 2 + +/*! Chunk duration in milliseconds to feed chunks to the coders */ +#define CHUNK_MS 20 + +/*! Minimum acceptable SNR in dB for lossy codecs. + * Most telephony codecs at 8 kHz should exceed ~20 dB */ +#define MIN_SNR_LOSSY_DB 15.0 + +/*! Maximum per-sample absolute error for near-lossless codecs (ulaw/alaw). + * μ-law/A-law quantisation can reach a max error of 256. */ +#define MAX_SAMPLE_ERR_LOSSLESS 256 + +/*! Minimum fraction of input samples that must survive the roundtrip. + * Codec lookahead + trailing partial frame may consume some samples; + * require at least 90 % to call the test valid. */ +#define MIN_DECODED_RATIO 0.90 + +/*! + * \brief Generate a synthetic speech-like test signal in linear sample. + * + * a mix of a 200 Hz fundamental and an 800 Hz harmonic with a slow 4 Hz + * amplitude-modulation envelope, loosely inspired by ITU-T P.50 artificial + * voice. + * + * \param[out] buf Buffer to fill (must hold at least \a samples int16 values) + * \param[in] samples Number of samples to generate + * \param[in] sample_rate Sampling rate in Hz (used to scale time correctly) + */ +static void generate_speech_signal(int16_t *buf, int samples, int sample_rate) +{ + int i; + for (i = 0; i < samples; i++) { + double t = (double)i / sample_rate; + double sig = 0.6 * sin(2.0 * M_PI * 200.0 * t) + + 0.4 * sin(2.0 * M_PI * 800.0 * t); + /* Slow AM at 4 Hz to simulate syllable rhythm */ + sig *= 0.5 * (1.0 + sin(2.0 * M_PI * 4.0 * t)); + buf[i] = (int16_t)(sig * 16000.0); + } +} + +/*! + * \brief Compute Signal-to-Noise Ratio between original and roundtripped signal. + * + * \param[in] orig Original sample buffer + * \param[in] roundtrip Decoded (roundtripped) sample buffer + * \param[in] samples Number of samples + * + * \return SNR in dB + */ +static double compute_snr(const int16_t *orig, const int16_t *roundtrip, int samples) +{ + double signal_power = 0.0; + double noise_power = 0.0; + int i; + + for (i = 0; i < samples; i++) { + double s = (double)orig[i]; + double n = (double)(orig[i] - roundtrip[i]); + signal_power += s * s; + noise_power += n * n; + } + + if (signal_power < 1.0) { + return -100.0; /* degenerate signal */ + } + if (noise_power < 1.0) { + return 999.0; /* essentially perfect */ + } + + return 10.0 * log10(signal_power / noise_power); +} + +/*! + * \brief Compute SNR after aligning the decoded signal via cross-correlation. + * + * Some codecs (e.g. speex, opus) introduce an algorithmic lookahead delay: + * the decoded signal is time-shifted by a fixed number of samples relative to + * the original. Computing SNR without compensating for this shift yields a + * near-zero result even when the codec works perfectly. + * + * This function searches delays d = 0..max_delay, finds the integer shift + * that maximises the cross-correlation sum orig[i]·decoded[i+d], then returns + * the SNR computed at that optimal alignment. + * + * \param[in] orig Original sample buffer (must hold at least \a samples values) + * \param[in] decoded Decoded sample buffer (must hold at least \a samples values) + * \param[in] samples Number of samples available in each buffer + * \param[in] max_delay Search range: delays 0..max_delay are tested + * \param[out] delay_out Receives the best-fit delay found; may be NULL + * + * \return SNR in dB at the best-fit alignment, or the unaligned SNR when + * \a samples <= \a max_delay + */ +static double compute_snr_aligned(const int16_t *orig, const int16_t *decoded, + int samples, int max_delay, int *delay_out) +{ + double best_corr = -1e300; + int best_delay = 0; + int d; + + if (delay_out) { + *delay_out = 0; + } + + /* Need enough samples for a meaningful search */ + if (samples <= max_delay || max_delay <= 0) { + return compute_snr(orig, decoded, samples); + } + + for (d = 0; d <= max_delay; d++) { + int n = samples - d; + double corr = 0.0; + int i; + for (i = 0; i < n; i++) { + corr += (double)orig[i] * (double)decoded[i + d]; + } + if (corr > best_corr) { + best_corr = corr; + best_delay = d; + } + } + + if (delay_out) { + *delay_out = best_delay; + } + + /* Compute SNR at the best alignment */ + return compute_snr(orig, decoded + best_delay, samples - best_delay); +} + +/*! + * \brief Compute maximum absolute per-sample error. + */ +static int compute_max_error(const int16_t *orig, const int16_t *roundtrip, int samples) +{ + int max_err = 0; + int i; + + for (i = 0; i < samples; i++) { + int err = abs((int)orig[i] - (int)roundtrip[i]); + if (err > max_err) { + max_err = err; + } + } + return max_err; +} + +/*! + * \brief Attempt a roundtrip encode/decode for one codec format. + * + * Feeds the original slin signal through the encoder and decoder in 20 ms + * chunks (matching the universal VoIP frame duration), accumulates the + * decoded output, and compares the full result against the original. + * This naturally respects every codec's internal buffer sizes and frame + * granularity without needing per-codec sample-count overrides. + * + * \param[in] test Test framework handle (for status messages) + * \param[in] slin_fmt The slin format appropriate for the codec's sample rate + * \param[in] target_fmt The codec format to test + * \param[in] orig_buf Original slin sample buffer + * \param[in] total_samples Number of samples in orig_buf + * \param[in] sample_rate Sample rate in Hz + * \param[in] is_lossy If non-zero, use SNR-based comparison; otherwise use max-error + * \param[in] min_snr_db Minimum acceptable SNR for lossy check (ignored when !is_lossy) + * + * \retval AST_TEST_PASS on success + * \retval AST_TEST_FAIL on failure + */ +static enum ast_test_result_state check_codec( + struct ast_test *test, + struct ast_format *slin_fmt, + struct ast_format *target_fmt, + const int16_t *orig_buf, + int total_samples, + int sample_rate, + int is_lossy, + double min_snr_db) +{ + struct ast_trans_pvt *encode_path = NULL; + struct ast_trans_pvt *decode_path = NULL; + int16_t *decoded_buf = NULL; + int total_decoded = 0; + int chunk_samples = sample_rate * CHUNK_MS / 1000; + int buf_capacity; + int offset; + enum ast_test_result_state result = AST_TEST_FAIL; + const char *codec_name; + + codec_name = ast_format_get_name(target_fmt); + + /* --- Build encoder path: slin -> codec --- */ + encode_path = ast_translator_build_path(target_fmt, slin_fmt); + if (!encode_path) { + ast_test_status_update(test, + "Skipping %s: no translation path from slin to %s\n", + codec_name, codec_name); + return AST_TEST_PASS; + } + + /* --- Build decoder path: codec -> slin --- */ + decode_path = ast_translator_build_path(slin_fmt, target_fmt); + if (!decode_path) { + ast_test_status_update(test, + "FAIL %s: found encoder but no decoder path back to slin\n", + codec_name); + goto cleanup; + } + + /* Allocate output buffer with some headroom for codec expansion */ + buf_capacity = total_samples + chunk_samples; + decoded_buf = ast_calloc(buf_capacity, sizeof(int16_t)); + if (!decoded_buf) { + goto cleanup; + } + + /* --- Feed audio in CHUNK_MS chunks through encode -> decode --- */ + for (offset = 0; offset < total_samples; offset += chunk_samples) { + struct ast_frame input_frame; + struct ast_frame *encoded; + struct ast_frame *decoded; + struct ast_frame *cur; + int feed = total_samples - offset; + if (feed > chunk_samples) { + feed = chunk_samples; + } + + memset(&input_frame, 0, sizeof(input_frame)); + input_frame.frametype = AST_FRAME_VOICE; + input_frame.subclass.format = slin_fmt; + input_frame.datalen = feed * sizeof(int16_t); + input_frame.samples = feed; + input_frame.data.ptr = (void *)(orig_buf + offset); + input_frame.mallocd = 0; + input_frame.src = "test_codec_translations"; + + /* Encode: slin -> codec. NULL means the encoder is buffering. */ + encoded = ast_translate(encode_path, &input_frame, 0); + if (!encoded) { + continue; + } + + /* Decode: codec -> slin. ast_translate follows the frame linked + * list internally, so passing the head feeds all encoded frames. */ + decoded = ast_translate(decode_path, encoded, 0); + if (!decoded) { + continue; + } + + /* Copy decoded samples into our accumulation buffer. + * Walk the linked list in case frameout produced multiple frames. */ + for (cur = decoded; cur; cur = AST_LIST_NEXT(cur, frame_list)) { + int copy; + if (cur->frametype != AST_FRAME_VOICE || !cur->data.ptr + || cur->samples <= 0) { + continue; + } + copy = cur->samples; + if (total_decoded + copy > buf_capacity) { + copy = buf_capacity - total_decoded; + } + memcpy(decoded_buf + total_decoded, cur->data.ptr, + copy * sizeof(int16_t)); + total_decoded += copy; + } + } + + /* --- Verify we got enough decoded audio --- */ + if (total_decoded < (int)(total_samples * MIN_DECODED_RATIO)) { + ast_test_status_update(test, + "FAIL %s: decoded only %d of %d samples (%.0f%%)\n", + codec_name, total_decoded, total_samples, + 100.0 * total_decoded / total_samples); + goto cleanup; + } + + /* --- Compare original vs decoded --- */ + { + int cmp_samples = total_decoded < total_samples + ? total_decoded : total_samples; + + if (is_lossy) { + int max_delay = sample_rate * 20 / 1000; /* 20 ms search */ + int delay = 0; + double snr = compute_snr_aligned(orig_buf, decoded_buf, + cmp_samples, max_delay, &delay); + + ast_test_status_update(test, + " %s (lossy): SNR = %.1f dB (threshold %.1f dB)" + " [%d/%d samples, delay=%d/%.1fms]\n", + codec_name, snr, min_snr_db, + cmp_samples, total_samples, + delay, 1000.0 * delay / sample_rate); + + if (snr < min_snr_db) { + ast_test_status_update(test, + "FAIL %s: SNR %.1f dB is below minimum %.1f dB\n", + codec_name, snr, min_snr_db); + goto cleanup; + } + } else { + int max_err = compute_max_error(orig_buf, decoded_buf, cmp_samples); + double snr = compute_snr(orig_buf, decoded_buf, cmp_samples); + + ast_test_status_update(test, + " %s (lossless): max_err = %d (limit %d)," + " SNR = %.1f dB [%d/%d samples]\n", + codec_name, max_err, MAX_SAMPLE_ERR_LOSSLESS, snr, + cmp_samples, total_samples); + + if (max_err > MAX_SAMPLE_ERR_LOSSLESS) { + ast_test_status_update(test, + "FAIL %s: max sample error %d exceeds limit %d\n", + codec_name, max_err, MAX_SAMPLE_ERR_LOSSLESS); + goto cleanup; + } + } + } + + result = AST_TEST_PASS; + +cleanup: + ast_free(decoded_buf); + if (encode_path) { + ast_translator_free_path(encode_path); + } + if (decode_path) { + ast_translator_free_path(decode_path); + } + + return result; +} + +/*! + * \brief Codec roundtrip entry: table of codecs to test. + */ +struct codec_test_entry { + const char *format_name; /*!< Name used in ast_format_cache_get() */ + int is_lossy; /*!< 1 = lossy (use SNR check), 0 = near-lossless (use max err) */ + double min_snr_db; /*!< Per-codec SNR floor; 0.0 = use MIN_SNR_LOSSY_DB default */ + int sample_rate; /*!< Native slin rate: 8000/16000/32000/48000; 0 = default 8000 */ +}; + +/*! Table of codecs to roundtrip-test.*/ +static const struct codec_test_entry codec_table[] = { + /* Near-lossless narrowband (8 kHz) — verified with max per-sample error */ + { "ulaw", 0 }, + { "alaw", 0 }, + + /* Lossy narrowband (8 kHz) — verified with SNR threshold */ + { "adpcm", 1 }, /* ADPCM: ~20 dB SNR, lossy by design */ + { "g726", 1 }, /* G.726 ADPCM: ~28 dB SNR, lossy by design */ + { "g726aal2", 1 }, /* G.726 AAL2 ADPCM: same as g726 */ + { "gsm", 1 }, + { "speex", 1, 7.0 }, /* speex is quite lossy */ + { "ilbc", 1, 7.0 }, /* 30 ms frames: coarser quantisation lowers SNR floor */ + { "codec2", 1, -2.0 }, /* vocoder: snr is really low, smoke-test only */ + { "lpc10", 1, -2.0 }, /* vocoder: snr is really low, smoke-test only */ + + /* { "g729", 1 }, UNTESTED yet */ + /* { "silk8", 1 }, UNTESTED yet */ + /* { "silk12", 1 }, UNTESTED yet */ + /* { "silk16", 1 }, UNTESTED yet */ + /* { "silk24", 1 }, UNTESTED yet */ + + /* Wideband (16 kHz) — tested with slin16 signal. */ + { "g722", 1, 0.0, 16000 }, + { "speex16", 1, 5.0, 16000 }, + + /* Ultra-wideband (32 kHz) — tested with slin32 signal */ + { "speex32", 1, 5.0, 32000 }, + + /* Opus native rate is 48 kHz */ + { "opus", 1, 8.0, 48000 }, +}; + +AST_TEST_DEFINE(codec_translate_roundtrip) +{ + int i; + int tested = 0; + int failed = 0; + enum ast_test_result_state overall = AST_TEST_PASS; + + switch (cmd) { + case TEST_INIT: + info->name = "codec_translations"; + info->category = "/main/codec/"; + info->summary = "Roundtrip encode/decode test and quality check for various codecs referenced in this test and present in the installation"; + info->description = + "Generates a synthetic speech-like signal (200 Hz +\n" + "800 Hz with 4 Hz AM envelope) at the codec's native sample\n" + "rate, feeds it through the codec and back,\n" + "then verifies the output quality over the full duration.\n" + "For near-lossless codecs (ulaw, alaw) it checks that the\n" + "maximum per-sample error is within a tight bound.\n" + "For lossy codecs it checks that the SNR exceeds a per-codec\n" + "minimum threshold. Vocoders use a near-zero threshold\n" + "(smoke test)."; + return AST_TEST_NOT_RUN; + case TEST_EXECUTE: + break; + } + + ast_test_status_update(test, + "Starting codec roundtrip tests (%d codecs, %d seconds of audio)\n", + (int)ARRAY_LEN(codec_table), TEST_DURATION_SECS); + + for (i = 0; i < (int)ARRAY_LEN(codec_table); i++) { + struct ast_format *slin_fmt; + struct ast_format *target_fmt; + int16_t *orig_buf; + enum ast_test_result_state res; + int rate = codec_table[i].sample_rate > 0 + ? codec_table[i].sample_rate : 8000; + int total_samples = rate * TEST_DURATION_SECS; + + /* Select the slin format for this codec's native rate */ + switch (rate) { + case 16000: + slin_fmt = ast_format_slin16; + break; + case 32000: + slin_fmt = ast_format_slin32; + break; + case 48000: + slin_fmt = ast_format_slin48; + break; + default: + slin_fmt = ast_format_slin; + break; + } + + target_fmt = ast_format_cache_get(codec_table[i].format_name); + if (!target_fmt + || ast_translate_path_steps(target_fmt, slin_fmt) == -1) { + ast_test_status_update(test, + " %s: no translation path available, skipping\n", + codec_table[i].format_name); + ao2_cleanup(target_fmt); + continue; + } + + orig_buf = ast_malloc(total_samples * sizeof(int16_t)); + if (!orig_buf) { + ao2_ref(target_fmt, -1); + overall = AST_TEST_FAIL; + break; + } + + generate_speech_signal(orig_buf, total_samples, rate); + + res = check_codec( + test, slin_fmt, target_fmt, orig_buf, + total_samples, rate, + codec_table[i].is_lossy, + codec_table[i].min_snr_db != 0.0 + ? codec_table[i].min_snr_db : MIN_SNR_LOSSY_DB); + + ast_free(orig_buf); + ao2_ref(target_fmt, -1); + + tested++; + if (res == AST_TEST_FAIL) { + failed++; + overall = AST_TEST_FAIL; + } + } + + ast_test_status_update(test, + "\nCodec roundtrip summary: %d tested, %d passed, %d failed\n", + tested, tested - failed, failed); + + if (tested == 0) { + ast_test_status_update(test, + "WARNING: No codecs were available to test. " + "Ensure codec modules are loaded.\n"); + } + + return overall; +} + +static int unload_module(void) +{ + AST_TEST_UNREGISTER(codec_translate_roundtrip); + return 0; +} + +static int load_module(void) +{ + AST_TEST_REGISTER(codec_translate_roundtrip); + return AST_MODULE_LOAD_SUCCESS; +} + +AST_MODULE_INFO_STANDARD_EXTENDED(ASTERISK_GPL_KEY, "Codec Translation Roundtrip Tests");