diff --git a/docs/CONFIGURE.md b/docs/CONFIGURE.md index 76a0c64f5..68298e9f4 100644 --- a/docs/CONFIGURE.md +++ b/docs/CONFIGURE.md @@ -323,17 +323,17 @@ Each system can optionally define an `audio_postprocess` object to control clean ```json "audio_postprocess": { -"enabled": false, +"enabled": true, "outputRawAudio": false, -"highpass_hz": 0, -"lowpass_hz": 0, +"highpass_hz": 300, +"lowpass_hz": 3000, "bandreject_hz": 0, "bandreject_width_hz": 0, "loudnorm": true, -"loudnorm_two_pass": true, "loudnorm_i": -16.0, -"loudnorm_tp": -0.1, -"loudnorm_lra": 11.0, +"loudnorm_tp": -1.5, +"loudnorm_lra": 7.0, +"final_limiter": true, "ffmpeg_filter": "" } ``` @@ -342,74 +342,102 @@ Each system can optionally define an `audio_postprocess` object to control clean | Key | Required | Default Value | Type | Description | | ------------------- | :------: | ------------- | --------------------- | ----------- | -| enabled | | false | **true** / **false** | Enables the structured cleanup filter chain. This controls `highpass_hz`, `lowpass_hz`, `bandreject_hz`, `bandreject_width_hz`, and use of `ffmpeg_filter` as the base filter chain. It does **not** control loudnorm. | -| outputRawAudio | | false | **true** / **false** | When enabled, saves an additional `.raw.wav` file alongside the normal call output. This file is a verbatim concatenation of the raw transmission recordings with no filtering, resampling, or loudness normalization applied — the original sample rate and bit depth are preserved exactly. Useful for comparing the effect of post-processing settings or diagnosing audio quality issues. See **Raw Audio Output** below. | -| highpass_hz | | 0 | number | Adds an FFmpeg highpass filter when greater than 0. | -| lowpass_hz | | 0 | number | Adds an FFmpeg lowpass filter when greater than 0. | -| bandreject_hz | | 0 | number | Adds an FFmpeg bandreject filter center frequency when greater than 0. | -| bandreject_width_hz | | 0 | number | Width for the FFmpeg bandreject filter. Must be greater than 0 to be used. | -| loudnorm | | true | **true** / **false** | Enables built-in loudness normalization independently of `enabled`. | -| loudnorm_two_pass | | true | **true** / **false** | When `true`, attempts two-pass loudnorm and falls back to single-pass when unavailable. When `false`, uses single-pass loudnorm directly. | -| loudnorm_i | | -16.0 | number | FFmpeg loudnorm integrated loudness target. | -| loudnorm_tp | | -0.1 | number | FFmpeg loudnorm true peak target. | -| loudnorm_lra | | 11.0 | number | FFmpeg loudnorm loudness range target. | -| ffmpeg_filter | | "" | string | Optional custom FFmpeg filter chain used as the base filter chain when `enabled=true`. If this already includes `loudnorm`, built-in loudnorm settings are skipped to avoid duplicate normalization. | +| enabled | | true | **true** / **false** | **Master switch** for the post-processing pipeline. When `false`, cleanup, loudnorm, and final_limiter are all skipped and the call's main file is a stream-copy concat of the raw transmission WAVs. When `true`, the full pipeline runs. | +| outputRawAudio | | false | **true** / **false** | Always writes an additional `.raw.wav` side file (verbatim concat of raw transmissions). Honored regardless of `enabled`. When `enabled=false`, the main file and the `.raw.wav` will be byte-identical. | +| highpass_hz | | 300 | number | Per-transmission FFmpeg highpass cutoff in Hz. Set to 0 to disable. Default matches the bandpass that used to live inside the analog recorder. | +| lowpass_hz | | 3000 | number | Per-transmission FFmpeg lowpass cutoff in Hz. Set to 0 to disable. Default matches the bandpass that used to live inside the analog recorder. | +| bandreject_hz | | 0 | number | Per-transmission FFmpeg bandreject center frequency. Set to 0 to disable. | +| bandreject_width_hz | | 0 | number | Width for the bandreject filter. Must be greater than 0 for the filter to apply. | +| loudnorm | | true | **true** / **false** | Enables built-in loudness normalization. Loudnorm runs **per transmission** so individual transmissions land at the same target loudness regardless of capture level. Only meaningful when `enabled=true`. | +| loudnorm_i | | -16.0 | number | FFmpeg loudnorm integrated loudness target (LUFS). | +| loudnorm_tp | | -1.5 | number | FFmpeg loudnorm true peak target (dBFS). Also determines the final limiter ceiling, which sits 0.5 dB above this value. | +| loudnorm_lra | | 7.0 | number | FFmpeg loudnorm loudness range target (LU). 7 is voice-optimized; raise it (toward 11) to preserve more dynamics, lower it (toward 5) to crush them further. | +| final_limiter | | true | **true** / **false** | Applies a brick-wall `alimiter` inside each per-transmission chain as a safety net against clipping. Ceiling is derived from `loudnorm_tp + 0.5` dB. Only meaningful when `enabled=true`. | +| ffmpeg_filter | | "" | string | Optional custom FFmpeg filter chain that **replaces** the structured cleanup (highpass/lowpass/bandreject) when `enabled=true`. Applied per transmission. If this already includes `loudnorm`, built-in loudnorm settings are skipped to avoid duplicate normalization. | ### How it works -`audio_postprocess.enabled` only controls the base cleanup filter chain. It does **not** enable or disable loudness normalization. +Each call typically contains several transmissions, captured back-to-back as units key up and down on the channel. When `enabled=true` (the default), Trunk Recorder renders the call's audio in a single FFmpeg invocation that processes each transmission **independently** before joining them: + +``` +[tx1]→cleanup→loudnorm→limiter─┐ +[tx2]→cleanup→loudnorm→limiter─┼→ concat → final audio +[tx3]→cleanup→loudnorm→limiter─┘ +``` -When `enabled` is `true`, Trunk Recorder builds a base filter chain from the structured cleanup settings below: +Because every stage runs per transmission, a quiet transmission and a loud transmission in the same call end up at the same target loudness — listeners no longer hear "whisper then shout" within one call. -- `highpass_hz` -- `lowpass_hz` -- `bandreject_hz` -- `bandreject_width_hz` +When `enabled=false`, the entire pipeline is skipped. The call's main file is just `ffmpeg -f concat -c:a copy` of the raw transmission WAVs — no decoding, filtering, or normalization. This mirrors what `outputRawAudio=true` produces, just to the main file instead of a side file. -If `ffmpeg_filter` is provided and `enabled` is `true`, that string is used as the base filter chain instead of the structured cleanup filters. +### Voice-band bandpass -Loudness normalization is controlled separately by `loudnorm`. It defaults to `true`, even when `audio_postprocess.enabled` is `false`. +In older versions, analog recorders applied a hardcoded 300 Hz / 3000 Hz FIR bandpass inside the GNURadio chain. That bandpass now lives in post-processing as `highpass_hz=300` and `lowpass_hz=3000` (the defaults). This makes the bandpass user-tunable per system, lets you disable it for diagnostic listening, and means the original transmission WAV files written by the recorder are true post-demod/post-deemph **discriminator audio** — useful when `outputRawAudio` or `transmissionArchive` is enabled with `enabled=false`. + +The FFmpeg `highpass`/`lowpass` filters are 2nd-order biquads; the previous GR-chain filters were linear-phase FIRs. The difference is inaudible for voice content but technically detectable in critical A/B testing. ### Loudnorm behavior -When `loudnorm` is enabled, Trunk Recorder applies FFmpeg loudnorm using these defaults: +When `loudnorm` is enabled (and `enabled=true`), Trunk Recorder applies FFmpeg `loudnorm` to each transmission using these defaults: + +- `I=-16.0` (integrated loudness target, LUFS) +- `TP=-1.5` (true peak target, dBFS) +- `LRA=7.0` (loudness range target, LU — tuned for voice) -- `I=-16.0` -- `TP=-0.1` -- `LRA=11.0` +The defaults are voice-tuned: `TP=-1.5` leaves headroom for the final limiter, and `LRA=7` compresses the dynamic range more aggressively than the broadcast default (LRA=11) so quiet speech and loud speech end up closer together. -If `loudnorm_two_pass` is `true`, Trunk Recorder first attempts loudnorm analysis and then renders using two-pass loudnorm. +### Final limiter -If two-pass loudnorm cannot be used for a call, such as when the call is too short or the first-pass analysis fails, Trunk Recorder automatically falls back to single-pass loudnorm rendering. +When `final_limiter` is enabled (default, requires `enabled=true`), Trunk Recorder appends an `alimiter` to each per-transmission chain. The limit ceiling is `loudnorm_tp + 0.5 dB` in linear scale — for the default `TP=-1.5`, that's about -1.0 dBFS (linear 0.8913). Attack is 1 ms and release is 50 ms. -If `loudnorm_two_pass` is `false`, Trunk Recorder skips the analysis pass and uses single-pass loudnorm directly. +This catches any inter-sample peaks that loudnorm's true-peak estimator misses, guaranteeing the per-transmission output never clips. ### Filter order -The final audio filter chain is built in this order: +When `enabled=true`, the per-transmission chain is: -1. Base cleanup filter chain or `ffmpeg_filter` override -2. Built-in loudnorm, if enabled +1. **Cleanup** (structured highpass/lowpass/bandreject, or `ffmpeg_filter` override if set) +2. **Loudnorm** (if `loudnorm=true`) +3. **Limiter** (if `final_limiter=true`) +4. Concat all per-transmission chains ### Fallback behavior -If a render using loudnorm fails, Trunk Recorder retries using the cleanup-only filter chain. +If the full render fails, Trunk Recorder retries without loudnorm (cleanup → limiter → concat). If that fails too, it falls back to unfiltered rendering (concat only). Per-transmission archive outputs are still produced in fallback modes, just less processed. + +### Transmission archive (`transmissionArchive`) -If that also fails, Trunk Recorder falls back to unfiltered rendering. +When the system has `transmissionArchive: true`, each individual transmission is written to the capture directory alongside the call's main file. With `enabled=true`, those per-transmission files are **fully processed** (cleanup + loudnorm + limiter) so they're playable standalone. With `enabled=false`, they're copies of the recorder's raw transmission WAVs (now actual discriminator audio for analog systems). ### Important notes -- `audio_postprocess.enabled=false` does **not** disable loudnorm -- `ffmpeg_filter` may still be combined with built-in loudnorm -- if `ffmpeg_filter` already contains `loudnorm`, built-in loudnorm settings are skipped to avoid duplicate normalization -- the old implicit `dynaudnorm` fallback is no longer used +- `enabled` is now the **master switch** for *all* post-processing. Setting `enabled=false` skips cleanup, loudnorm, and the limiter. Previously this flag only disabled cleanup — see the **Migration** note below. +- `loudnorm` and `final_limiter` only take effect when `enabled=true`. +- `ffmpeg_filter` is applied **per transmission**. +- If `ffmpeg_filter` already contains `loudnorm`, the user's loudnorm runs per transmission and the built-in loudnorm settings are skipped (with a warning). +- The old implicit `dynaudnorm` fallback is no longer used. +- For analog recorders, the GR-chain 300/3000 Hz bandpass has been removed; it now runs in ffmpeg post-processing via the cleanup defaults. Existing analog recordings sound essentially unchanged; the underlying transmission WAVs are now raw discriminator audio. + +### Migration (from prior versions) + +If your config explicitly set `audio_postprocess.enabled: false`, the new semantics will skip loudnorm and the limiter too (not just cleanup). The daemon logs a one-line WARNING at startup when this case is detected. To restore the old behavior — cleanup off, loudnorm on — set: + +```json +"audio_postprocess": { +"enabled": true, +"highpass_hz": 0, +"lowpass_hz": 0, +"loudnorm": true +} +``` ### Raw Audio Output -When `outputRawAudio` is `true`, Trunk Recorder writes an additional file named `.raw.wav` to the capture directory after each call concludes. +When `outputRawAudio` is `true`, Trunk Recorder writes an additional file named `.raw.wav` to the capture directory after each call concludes — **regardless** of the `enabled` flag. If you ask for it, you get it. + +The debug file is a stream-copy concatenation of the raw transmission recordings: no decoding, re-encoding, filtering, resampling, or normalization. The audio is bit-for-bit what the recorder wrote, just joined into a single file. For analog systems this is now true discriminator audio (post-demod, post-deemph, post-decimation — but no bandpass). -The debug file is produced by concatenating the raw transmission recordings using an FFmpeg stream copy — no decoding, re-encoding, filtering, resampling, or loudness normalization is applied. The audio is bit-for-bit identical to what the recorder wrote, just joined into a single file. +If you set `enabled=false` AND `outputRawAudio=true`, you'll get two byte-identical files. That's intentional: the configuration was explicit. **File retention:** @@ -420,11 +448,12 @@ The debug file is produced by concatenating the raw transmission recordings usin - Comparing processed output against the unmodified source to evaluate filter or loudnorm settings. - Diagnosing audio artifacts introduced by post-processing. +- Inspecting actual discriminator audio for analog systems (CTCSS/DCS subaudible signaling, hum, hiss — content normally removed by the 300/3000 bandpass). - Retaining a pristine archive copy while still distributing the normalized version. ### Example configurations -#### Cleanup filters only +#### Cleanup filters only (no loudnorm, no limiter) ```json "audio_postprocess": { @@ -434,68 +463,61 @@ The debug file is produced by concatenating the raw transmission recordings usin "bandreject_hz": 4000, "bandreject_width_hz": 180, "loudnorm": false, -"loudnorm_two_pass": true, -"loudnorm_i": -16.0, -"loudnorm_tp": -0.1, -"loudnorm_lra": 11.0, +"final_limiter": false, "ffmpeg_filter": "" } ``` -#### Loudnorm only +#### Loudnorm + limiter only (no cleanup bandpass) + +Skip the default 300/3000 bandpass but keep loudnorm and the limiter. Useful when you want the wider frequency range (e.g. for evaluating raw discriminator audio) but still want consistent loudness. ```json "audio_postprocess": { -"enabled": false, +"enabled": true, "highpass_hz": 0, "lowpass_hz": 0, -"bandreject_hz": 0, -"bandreject_width_hz": 0, "loudnorm": true, -"loudnorm_two_pass": true, -"loudnorm_i": -16.0, -"loudnorm_tp": -0.1, -"loudnorm_lra": 11.0, -"ffmpeg_filter": "", -"outputRawAudio": false +"final_limiter": true } ``` -#### Custom filter chain plus built-in loudnorm +#### Disable all post-processing (raw mode) + +Main file is the byte-identical concat of raw transmission WAVs. Loudnorm and limiter are skipped. + +```json +"audio_postprocess": { +"enabled": false +} +``` + +#### Custom cleanup chain plus built-in loudnorm + +The custom `ffmpeg_filter` is applied per transmission, then built-in loudnorm runs per transmission, then transmissions are concatenated and the limiter is applied. ```json "audio_postprocess": { "enabled": true, -"highpass_hz": 0, -"lowpass_hz": 0, -"bandreject_hz": 0, -"bandreject_width_hz": 0, "loudnorm": true, -"loudnorm_two_pass": false, "loudnorm_i": -16.0, -"loudnorm_tp": -0.1, -"loudnorm_lra": 11.0, +"loudnorm_tp": -1.5, +"loudnorm_lra": 7.0, +"final_limiter": true, "ffmpeg_filter": "highpass=f=200,bandreject=f=4000:w=180" } ``` #### Fully custom loudnorm in `ffmpeg_filter` -If you include `loudnorm` directly in `ffmpeg_filter`, the built-in loudnorm settings are skipped. +If you include `loudnorm` directly in `ffmpeg_filter`, the built-in loudnorm settings are skipped — but your custom chain still runs per transmission, and the final limiter still applies (unless you disable it). ```json "audio_postprocess": { "enabled": true, -"highpass_hz": 0, -"lowpass_hz": 0, -"bandreject_hz": 0, -"bandreject_width_hz": 0, "loudnorm": true, -"loudnorm_two_pass": true, -"loudnorm_i": -16.0, -"loudnorm_tp": -0.1, -"loudnorm_lra": 11.0, -"ffmpeg_filter": "highpass=f=200,loudnorm=I=-16:TP=-0.1:LRA=11" +"ffmpeg_filter": "highpass=f=200,loudnorm=I=-16:TP=-1.5:LRA=7", +"final_limiter": true } ``` diff --git a/trunk-recorder/call_concluder/call_concluder.cc b/trunk-recorder/call_concluder/call_concluder.cc index 577429b0f..2b0c917b8 100644 --- a/trunk-recorder/call_concluder/call_concluder.cc +++ b/trunk-recorder/call_concluder/call_concluder.cc @@ -166,77 +166,6 @@ static int run_process_wait(const std::vector &args, return -1; } -static bool run_process_capture_combined_output(const std::vector &args, - const std::string &loghdr, - const std::string &friendly_name, - std::string &output, - int &exit_code) { - output.clear(); - exit_code = -1; - - if (args.empty()) { - BOOST_LOG_TRIVIAL(error) << loghdr << "\033[0;31mCannot execute empty command for " - << friendly_name << "\033[0m"; - return false; - } - - BOOST_LOG_TRIVIAL(trace) << loghdr << "Running " << friendly_name; - BOOST_LOG_TRIVIAL(trace) << loghdr << "Command: " << render_command_for_logging(args); - - int pipefd[2]; - if (pipe(pipefd) < 0) { - BOOST_LOG_TRIVIAL(error) << loghdr << "\033[0;31mFailed to create pipe for " - << friendly_name << ": " << std::strerror(errno) << "\033[0m"; - return false; - } - - auto argv = make_argv(args); - - const pid_t pid = fork(); - if (pid < 0) { - close(pipefd[0]); close(pipefd[1]); - BOOST_LOG_TRIVIAL(error) << loghdr << "\033[0;31mFailed to fork for " - << friendly_name << ": " << std::strerror(errno) << "\033[0m"; - return false; - } - if (pid == 0) { - close(pipefd[0]); - if (dup2(pipefd[1], STDOUT_FILENO) < 0 || dup2(pipefd[1], STDERR_FILENO) < 0) { - std::fprintf(stderr, "dup2 failed for %s: %s\n", friendly_name.c_str(), std::strerror(errno)); - _exit(127); - } - close(pipefd[1]); - execvp(argv[0], argv.data()); - std::fprintf(stderr, "execvp failed for %s '%s': %s\n", - friendly_name.c_str(), argv[0], std::strerror(errno)); - _exit(127); - } - - close(pipefd[1]); - char buf[4096]; - ssize_t nread; - while ((nread = read(pipefd[0], buf, sizeof(buf))) > 0) - output.append(buf, static_cast(nread)); - close(pipefd[0]); - - int status = 0; - if (waitpid(pid, &status, 0) < 0) { - BOOST_LOG_TRIVIAL(error) << loghdr << "\033[0;31mwaitpid failed for " - << friendly_name << ": " << std::strerror(errno) << "\033[0m"; - return false; - } - if (WIFEXITED(status)) { exit_code = WEXITSTATUS(status); return true; } - if (WIFSIGNALED(status)) { - exit_code = 128 + WTERMSIG(status); - BOOST_LOG_TRIVIAL(error) << loghdr << "\033[0;31m" << friendly_name - << " terminated by signal " << WTERMSIG(status) << "\033[0m"; - return true; - } - BOOST_LOG_TRIVIAL(error) << loghdr << "\033[0;31m" << friendly_name - << " ended in an unknown state\033[0m"; - return false; -} - // --------------------------------------------------------------------------- // Filename format expansion helpers // --------------------------------------------------------------------------- @@ -365,31 +294,13 @@ static std::string build_cleanup_filter(const Audio_Postprocess_Config &cfg) { return oss.str(); } -static bool is_invalid_loudnorm_value(const std::string &v) { - const char *p = v.data(), *e = p + v.size(); - while (p < e && std::isspace(static_cast(*p))) ++p; - while (e > p && std::isspace(static_cast(e[-1]))) --e; - if (p == e) return true; - - const std::ptrdiff_t len = e - p; - if (len > 4) return false; // longest invalid token is 4 chars - - char buf[5]; - for (std::ptrdiff_t j = 0; j < len; ++j) - buf[j] = static_cast(std::tolower(static_cast(p[j]))); - buf[len] = '\0'; - - const std::string_view sv(buf, static_cast(len)); - return sv == "-inf" || sv == "inf" || sv == "+inf" || - sv == "nan" || sv == "+nan" || sv == "-nan"; -} - static bool override_filter_contains_loudnorm(const Audio_Postprocess_Config &cfg) { const std::string f = trim_whitespace(cfg.ffmpeg_filter); return !f.empty() && lowercase_copy(f).find("loudnorm") != std::string::npos; } static bool should_apply_structured_loudnorm(const Audio_Postprocess_Config &cfg) { + if (!cfg.enabled) return false; if (!cfg.loudnorm) return false; if (override_filter_contains_loudnorm(cfg)) { @@ -416,127 +327,24 @@ static void append_ffmpeg_output_args(std::vector &args, } } -struct LoudnormMeasured { - std::string input_i, input_tp, input_lra, input_thresh, target_offset; - bool valid = false; -}; - -static std::string build_loudnorm_analysis_filter(const Audio_Postprocess_Config &cfg) { +static std::string build_loudnorm_filter(const Audio_Postprocess_Config &cfg) { std::ostringstream f; f << std::fixed << std::setprecision(1) << "loudnorm=I=" << cfg.loudnorm_i << ":TP=" << cfg.loudnorm_tp - << ":LRA=" << cfg.loudnorm_lra - << ":print_format=json"; - return f.str(); -} - -static bool analyze_loudnorm_from_concat(const Call_Data_t &call_info, - const std::string &list_filename, - const std::string &cleanup_filter, - LoudnormMeasured &measured) { - const std::string loghdr = - log_header(call_info.short_name, call_info.call_num, call_info.talkgroup_display, call_info.freq); - - const std::string analysis_filter = build_loudnorm_analysis_filter(call_info.audio_postprocess); - const std::string full_filter = - cleanup_filter.empty() ? analysis_filter : cleanup_filter + "," + analysis_filter; - - const std::vector args = { - "ffmpeg", "-y", "-hide_banner", "-nostats", - "-loglevel", "info", - "-f", "concat", "-safe", "0", "-i", list_filename, - "-af", full_filter, "-vn", "-f", "null", "-" - }; - - std::string output; - int exit_code = -1; - if (!run_process_capture_combined_output(args, loghdr, "ffmpeg loudnorm analysis", output, exit_code)) { - BOOST_LOG_TRIVIAL(error) << loghdr << "\033[0;31mFailed to start ffmpeg loudnorm analysis pass\033[0m"; - return false; - } - if (exit_code != 0) - BOOST_LOG_TRIVIAL(warning) << loghdr - << "\033[0;33mffmpeg loudnorm first pass returned non-zero exit status: " << exit_code << "\033[0m"; - - // Extract the last complete JSON object from ffmpeg's combined output. - const std::size_t json_end = output.rfind('}'); - const std::size_t json_start = (json_end != std::string::npos) - ? output.rfind('{', json_end) : std::string::npos; - - if (json_end == std::string::npos || json_start == std::string::npos || json_start >= json_end) { - BOOST_LOG_TRIVIAL(error) << loghdr - << "\033[0;31mFailed to parse loudnorm first-pass JSON: no valid JSON object found; " - << "two-pass loudnorm cannot be used for this call\033[0m"; - return false; - } - - try { - // Parse directly from existing buffer — no substr copy. - const nlohmann::json stats = - nlohmann::json::parse(output.data() + json_start, output.data() + json_end + 1); - - auto json_str = [](const nlohmann::json &v) -> std::string { - if (v.is_string()) return v.get(); - if (v.is_number_float()) { std::ostringstream o; o << v.get(); return o.str(); } - if (v.is_number_integer()) return std::to_string(v.get()); - if (v.is_number_unsigned()) return std::to_string(v.get()); - return v.dump(); - }; - - measured.input_i = json_str(stats.at("input_i")); - measured.input_tp = json_str(stats.at("input_tp")); - measured.input_lra = json_str(stats.at("input_lra")); - measured.input_thresh = json_str(stats.at("input_thresh")); - measured.target_offset = json_str(stats.at("target_offset")); - - if (is_invalid_loudnorm_value(measured.input_i) || - is_invalid_loudnorm_value(measured.input_tp) || - is_invalid_loudnorm_value(measured.input_lra) || - is_invalid_loudnorm_value(measured.input_thresh) || - is_invalid_loudnorm_value(measured.target_offset)) { - BOOST_LOG_TRIVIAL(warning) << loghdr - << "\033[0;33mLoudnorm first-pass returned unusable values " - << "(input_i=" << measured.input_i << ", input_tp=" << measured.input_tp - << ", input_lra=" << measured.input_lra << ", input_thresh=" << measured.input_thresh - << ", target_offset=" << measured.target_offset - << "); two-pass loudnorm is unavailable for this call and rendering will fall back to single-pass loudnorm\033[0m"; - - return false; - } - - measured.valid = true; - return true; - } catch (const std::exception &e) { - BOOST_LOG_TRIVIAL(error) << loghdr - << "\033[0;31mFailed to decode loudnorm first-pass JSON: " << e.what() - << "; two-pass loudnorm cannot be used for this call\033[0m"; - return false; - } -} - -static std::string build_loudnorm_render_filter(const Audio_Postprocess_Config &cfg, - const LoudnormMeasured &m) { - std::ostringstream f; - f << std::fixed << std::setprecision(1) - << "loudnorm=I=" << cfg.loudnorm_i - << ":TP=" << cfg.loudnorm_tp - << ":LRA=" << cfg.loudnorm_lra - << ":measured_I=" << m.input_i - << ":measured_TP=" << m.input_tp - << ":measured_LRA=" << m.input_lra - << ":measured_thresh=" << m.input_thresh - << ":offset=" << m.target_offset - << ":linear=true:dual_mono=true"; + << ":LRA=" << cfg.loudnorm_lra; return f.str(); } -static std::string build_loudnorm_single_pass_filter(const Audio_Postprocess_Config &cfg) { +// Brick-wall true-peak limiter applied after concat. Ceiling sits 0.5 dB above +// the loudnorm TP target so loudnorm has headroom to work and the limiter only +// catches accidental overshoots from intersample peaks. +static std::string build_limiter_filter(const Audio_Postprocess_Config &cfg) { + const double limit_dbfs = cfg.loudnorm_tp + 0.5; + const double limit_lin = std::pow(10.0, limit_dbfs / 20.0); std::ostringstream f; - f << std::fixed << std::setprecision(1) - << "loudnorm=I=" << cfg.loudnorm_i - << ":TP=" << cfg.loudnorm_tp - << ":LRA=" << cfg.loudnorm_lra; + f << std::fixed << std::setprecision(4) + << "alimiter=limit=" << limit_lin << ":attack=1:release=50"; return f.str(); } @@ -601,6 +409,114 @@ static void append_common_metadata_args(std::vector &args, }); } +// Build the filter_complex graph for per-transmission processing. +// per_tx_filter — cleanup + loudnorm + limiter applied to each input +// emit_archive_pads — when true, asplit each per-tx pad so both the +// concat path and an [archiveN] pad can consume it (caller maps +// [archiveN] to an archive output file) +// concat_tail — appended after the concat filter; either a single label +// like "[out]" (uncompressed) or a filter chain like +// ",asplit=2[awav][aaac]" (compressed dual-output) +// +// Example (3 inputs, archive on, uncompressed): +// [0:a],asplit=2[a0][archive0]; +// [1:a],asplit=2[a1][archive1]; +// [2:a],asplit=2[a2][archive2]; +// [a0][a1][a2]concat=n=3:v=0:a=1[out] +static std::string build_filter_complex(std::size_t n_inputs, + const std::string &per_tx_filter, + bool emit_archive_pads, + const std::string &concat_tail) { + std::ostringstream g; + // anull keeps each per-input chain non-empty when per_tx_filter is empty; + // ffmpeg filter_complex requires at least one filter between an input pad + // and a labeled output pad. + const std::string per_chain = per_tx_filter.empty() ? "anull" : per_tx_filter; + for (std::size_t i = 0; i < n_inputs; ++i) { + g << '[' << i << ":a]" << per_chain; + if (emit_archive_pads) + g << ",asplit=2[a" << i << "][archive" << i << ']'; + else + g << "[a" << i << ']'; + g << ';'; + } + for (std::size_t i = 0; i < n_inputs; ++i) g << "[a" << i << ']'; + g << "concat=n=" << n_inputs << ":v=0:a=1" << concat_tail; + return g.str(); +} + +// Compute per-transmission archive target paths: each raw tx file's basename +// dropped into the call's capture directory. Used when transmissionArchive=true. +static std::vector archive_target_paths(const Call_Data_t &call_info, + const std::vector &input_files) { + std::vector out; + out.reserve(input_files.size()); + const fs::path call_dir = fs::path(call_info.filename).parent_path(); + for (const auto &src : input_files) + out.push_back((call_dir / fs::path(src).filename()).string()); + return out; +} + +// enabled=false path: stream-copy concat of raw transmission wavs into the +// main output. For compressed mode we still need an AAC encode pass since +// PCM can't be stream-copied into m4a. transmissionArchive simply duplicates +// the raw tx files into the capture dir. +static int render_raw_concat(const Call_Data_t &call_info, + const std::vector &input_files, + const std::vector &archive_paths, + bool do_compress, + const std::string &date, + const std::string &short_name, + const std::string &talkgroup, + const std::string &loghdr) { + const fs::path transmission_dir = fs::path(input_files[0]).parent_path(); + const std::string list_filename = (transmission_dir / + (fs::path(call_info.filename).filename().string() + ".concat.txt")).string(); + if (!write_concat_list(input_files, list_filename)) return -1; + + std::vector args; + args.reserve(30); + args.insert(args.end(), { + "ffmpeg", "-y", "-hide_banner", "-loglevel", "error", + "-f", "concat", "-safe", "0", "-i", list_filename, "-vn" + }); + + // Main wav: stream-copy the raw transmissions joined together. + append_common_metadata_args(args, date, short_name, talkgroup); + args.insert(args.end(), {"-c:a", "copy"}); + args.push_back(call_info.filename); + + // Compressed mode: a second output spec encodes the same input as AAC. + if (do_compress) { + append_common_metadata_args(args, date, short_name, talkgroup); + append_ffmpeg_output_args(args, true, call_info.audio_bitrate); + args.push_back(call_info.converted); + } + + const int rc = run_process_wait(args, loghdr, "ffmpeg raw concat"); + std::remove(list_filename.c_str()); + + if (rc != 0) { + BOOST_LOG_TRIVIAL(error) << loghdr + << "\033[0;31mFailed to write raw-concat call audio. Make sure ffmpeg is installed.\033[0m"; + return -1; + } + + // Archive raw transmissions by simple copy — they're already in the format + // the user wants when post-processing is disabled. + for (std::size_t i = 0; i < archive_paths.size(); ++i) { + if (!checkIfFile(input_files[i])) continue; + try { + boost::filesystem::copy_file(input_files[i], archive_paths[i], + boost::filesystem::copy_options::overwrite_existing); + } catch (const boost::filesystem::filesystem_error &e) { + BOOST_LOG_TRIVIAL(error) << loghdr + << "\033[0;31mFailed to archive raw transmission: " << e.what() << "\033[0m"; + } + } + return 0; +} + static int render_call_audio_artifacts(const Call_Data_t &call_info, const std::vector &input_files, const std::string &date, @@ -611,72 +527,77 @@ static int render_call_audio_artifacts(const Call_Data_t &call_info, return -1; } - const fs::path transmission_dir = fs::path(input_files[0]).parent_path(); - const std::string list_filename = (transmission_dir / - (fs::path(call_info.filename).filename().string() + ".concat.txt")).string(); - if (!write_concat_list(input_files, list_filename)) return -1; - const std::string loghdr = log_header(call_info.short_name, call_info.call_num, call_info.talkgroup_display, call_info.freq); - const std::string cleanup_filter = build_cleanup_filter(call_info.audio_postprocess); const bool do_compress = call_info.compress_wav; - const bool loudnorm_requested = should_apply_structured_loudnorm(call_info.audio_postprocess); - bool apply_loudnorm_two_pass = false; - bool apply_loudnorm_single_pass = false; - - const bool too_short_for_two_pass = (call_info.length > 0.0 && call_info.length < 1.5); - - LoudnormMeasured measured; - if (loudnorm_requested) { - if (call_info.audio_postprocess.loudnorm_two_pass) { - if (too_short_for_two_pass) { - BOOST_LOG_TRIVIAL(debug) << loghdr - << "Call too short for reliable loudnorm first pass (" << call_info.length - << "s); falling back to single-pass loudnorm"; - apply_loudnorm_single_pass = true; - } else if (analyze_loudnorm_from_concat(call_info, list_filename, cleanup_filter, measured) && measured.valid) { - apply_loudnorm_two_pass = true; - BOOST_LOG_TRIVIAL(debug) << loghdr - << "Two-pass loudnorm analysis succeeded; using two-pass loudnorm rendering"; - } else { - BOOST_LOG_TRIVIAL(warning) << loghdr - << "\033[0;33mTwo-pass loudnorm analysis was not usable for this call; " - << "falling back to single-pass loudnorm rendering\033[0m"; - apply_loudnorm_single_pass = true; - } + // Only bother writing per-tx archives if they'll actually be kept after + // upload. transmission_archive on its own is meaningless if neither + // audio_archive nor archive_files_on_failure is set — remove_call_files + // would just delete them. Match that policy at render time so we avoid + // wasted disk writes. + const bool archive_transmissions = call_info.transmission_archive && + (call_info.audio_archive || call_info.archive_files_on_failure); + + std::vector archive_paths; + if (archive_transmissions) + archive_paths = archive_target_paths(call_info, input_files); + + // enabled=false → master switch off, skip the whole processing pipeline. + // Main file is the raw concat of transmissions; archived tx files are + // verbatim copies of the recorder's output. + if (!call_info.audio_postprocess.enabled) + return render_raw_concat(call_info, input_files, archive_paths, + do_compress, date, short_name, talkgroup, loghdr); + + // enabled=true path: full per-transmission filter chain. + const std::string cleanup_filter = build_cleanup_filter(call_info.audio_postprocess); + const bool apply_loudnorm = should_apply_structured_loudnorm(call_info.audio_postprocess); + const bool apply_limiter = call_info.audio_postprocess.final_limiter; + + // Each per-tx chain: cleanup → loudnorm → limiter. Asplit (when archiving) + // happens after the limiter so the archived per-tx files are fully processed + // and ready to play standalone. + auto build_per_tx = [&](bool include_loudnorm, bool include_limiter) -> std::string { + std::string chain = cleanup_filter; + auto append = [&](const std::string &filt) { + if (!chain.empty()) chain += ','; + chain += filt; + }; + if (include_loudnorm) append(build_loudnorm_filter(call_info.audio_postprocess)); + if (include_limiter) append(build_limiter_filter(call_info.audio_postprocess)); + return chain; + }; - } else { - BOOST_LOG_TRIVIAL(debug) << loghdr - << "audio_postprocess.loudnorm_two_pass is disabled; using single-pass loudnorm rendering"; - apply_loudnorm_single_pass = true; + const bool emit_archive_pads = archive_transmissions; + + auto run_render = [&](const std::string &per_tx) -> int { + std::vector args; + // Rough size: 5 fixed flags + 2*N inputs + filter_complex (2) + + // 4 maps + 2*(6 metadata + 10 output args) + 2*N archive outputs. + args.reserve(40 + 4 * input_files.size()); + + args.insert(args.end(), {"ffmpeg", "-y", "-hide_banner", "-loglevel", "error"}); + for (const auto &f : input_files) { + args.push_back("-i"); + args.push_back(f); } - } + args.push_back("-vn"); - std::string final_filter = cleanup_filter; - if (apply_loudnorm_two_pass) { - if (!final_filter.empty()) final_filter += ','; - final_filter += build_loudnorm_render_filter(call_info.audio_postprocess, measured); - } else if (apply_loudnorm_single_pass) { - if (!final_filter.empty()) final_filter += ','; - final_filter += build_loudnorm_single_pass_filter(call_info.audio_postprocess); - } + // Concat tail differs by output mode. Uncompressed gets a plain [out] + // label. Compressed splits the concat output into wav and aac feeds. + const std::string concat_tail = do_compress + ? ",asplit=2[awav][aaac]" + : "[out]"; - // Pre-reserve: compressed path ~36 args, uncompressed ~22. - auto run_render = [&](const std::string &filter) -> int { - std::vector args; - args.reserve(do_compress ? 36 : 22); - args.insert(args.end(), { - "ffmpeg", "-y", "-hide_banner", "-loglevel", "error", - "-f", "concat", "-safe", "0", "-i", list_filename, "-vn" - }); + const std::string graph = build_filter_complex( + input_files.size(), per_tx, emit_archive_pads, concat_tail); + args.insert(args.end(), {"-filter_complex", graph}); + // Main outputs (wav, optional m4a). if (do_compress) { - const std::string split = filter.empty() - ? "[0:a]asplit=2[awav][aaac]" - : "[0:a]" + filter + ",asplit=2[awav][aaac]"; - args.insert(args.end(), {"-filter_complex", split, "-map", "[awav]"}); + args.insert(args.end(), {"-map", "[awav]"}); append_common_metadata_args(args, date, short_name, talkgroup); append_ffmpeg_output_args(args, false, call_info.audio_bitrate); args.push_back(call_info.filename); @@ -686,31 +607,46 @@ static int render_call_audio_artifacts(const Call_Data_t &call_info, append_ffmpeg_output_args(args, true, call_info.audio_bitrate); args.push_back(call_info.converted); } else { - if (!filter.empty()) args.insert(args.end(), {"-af", filter}); + args.insert(args.end(), {"-map", "[out]"}); append_common_metadata_args(args, date, short_name, talkgroup); append_ffmpeg_output_args(args, false, call_info.audio_bitrate); args.push_back(call_info.filename); } + // Per-transmission archive outputs (uncompressed WAV, matching the + // recorder's original format). One [archiveN] mapping per input. + if (emit_archive_pads) { + for (std::size_t i = 0; i < input_files.size(); ++i) { + args.insert(args.end(), {"-map", "[archive" + std::to_string(i) + "]"}); + append_common_metadata_args(args, date, short_name, talkgroup); + append_ffmpeg_output_args(args, false, call_info.audio_bitrate); + args.push_back(archive_paths[i]); + } + } + return run_process_wait(args, loghdr, "ffmpeg render"); }; - int rc = run_render(final_filter); + // Primary attempt: full chain (cleanup + loudnorm + limiter). + int rc = run_render(build_per_tx(apply_loudnorm, apply_limiter)); - if (rc != 0 && final_filter != cleanup_filter) { + // First fallback: drop loudnorm. Catches loudnorm failures (extremely short + // input, malformed filter) without losing cleanup or the limiter. + if (rc != 0 && apply_loudnorm) { BOOST_LOG_TRIVIAL(warning) << loghdr - << "\033[0;33mLoudnorm render failed; retrying with cleanup-only filtering\033[0m"; - rc = run_render(cleanup_filter); + << "\033[0;33mLoudnorm render failed; retrying with cleanup-only per-transmission filtering\033[0m"; + rc = run_render(build_per_tx(false, apply_limiter)); } + // Second fallback: drop the per-tx filter entirely. Catches a bad user + // ffmpeg_filter override. Archived per-tx outputs in this mode are + // unprocessed (anull passthrough) — better than no archive at all. if (rc != 0 && !cleanup_filter.empty()) { BOOST_LOG_TRIVIAL(warning) << loghdr << "\033[0;33mCleanup-filter render failed; falling back to unfiltered rendering\033[0m"; rc = run_render(""); } - std::remove(list_filename.c_str()); - if (rc != 0) { BOOST_LOG_TRIVIAL(error) << loghdr << "\033[0;31mFailed to render call audio artifacts. Make sure ffmpeg is installed.\033[0m"; @@ -891,30 +827,37 @@ void remove_call_files(const Call_Data_t &call_info, bool plugin_failure) { const bool should_archive = call_info.audio_archive || (plugin_failure && call_info.archive_files_on_failure); + + // Per-tx archive files were already written into the capture dir during + // render_call_audio_artifacts (when transmission_archive=true AND the + // archive was going to be kept). Just clean them up here if the user + // doesn't actually want archives this time. if (should_archive) { - if (call_info.transmission_archive) { - for (const auto &t : call_info.transmission_list) { - if (!checkIfFile(t.filename)) continue; - const boost::filesystem::path target = - boost::filesystem::path(fs::path(call_info.filename) - .replace_filename(fs::path(t.filename).filename())); - try { - boost::filesystem::copy_file(t.filename, target); - } catch (const boost::filesystem::filesystem_error &e) { - BOOST_LOG_TRIVIAL(error) << loghdr << "\033[0;31mFailed to copy transmission file: " - << e.what() << "\033[0m"; - } - } - } + // Keep call main files + any per-tx archive files. Just clean up the + // recorder's tmp tx files now that they've been processed. for (const auto &t : call_info.transmission_list) if (checkIfFile(t.filename)) std::remove(t.filename.c_str()); } else { + // No archive wanted. Delete call main files, per-tx archive files in + // the capture dir (if any were written), and the recorder tmp tx files. for (const std::string &f : {call_info.filename, call_info.converted}) if (checkIfFile(f)) std::remove(f.c_str()); + + if (call_info.transmission_archive) { + const fs::path call_dir = fs::path(call_info.filename).parent_path(); + for (const auto &t : call_info.transmission_list) { + const std::string archived = + (call_dir / fs::path(t.filename).filename()).string(); + if (checkIfFile(archived)) std::remove(archived.c_str()); + } + } + for (const auto &t : call_info.transmission_list) if (checkIfFile(t.filename)) std::remove(t.filename.c_str()); // Raw audio output is intentionally kept on success (audio_archive=false) — it is + // the documented debug-keep file. Cleaned only on plugin failure with no + // archive_files_on_failure (handled by the catch-all path above). } const bool keep_json = call_info.call_log || (plugin_failure && call_info.archive_files_on_failure); @@ -1105,8 +1048,9 @@ Call_Data_t Call_Concluder::create_call_data(Call *call, System *sys, const Conf call_info.audio_postprocess.loudnorm_i = sys->get_audio_loudnorm_i(); call_info.audio_postprocess.loudnorm_tp = sys->get_audio_loudnorm_tp(); call_info.audio_postprocess.loudnorm_lra = sys->get_audio_loudnorm_lra(); + call_info.audio_postprocess.final_limiter = sys->get_audio_final_limiter(); call_info.audio_postprocess.ffmpeg_filter = sys->get_audio_ffmpeg_filter(); - call_info.audio_postprocess.output_raw_audio = sys->get_audio_output_raw_audio(); + call_info.audio_postprocess.output_raw_audio = sys->get_audio_output_raw_audio(); call_info.talkgroup = call->get_talkgroup(); call_info.talkgroup_display = call->get_talkgroup_display(); diff --git a/trunk-recorder/config.cc b/trunk-recorder/config.cc index 166eb02dd..e398db52e 100644 --- a/trunk-recorder/config.cc +++ b/trunk-recorder/config.cc @@ -395,36 +395,51 @@ bool load_config(string config_file, Config &config, gr::top_block_sptr &tb, std BOOST_LOG_TRIVIAL(info) << "Audio Archive: " << system->get_audio_archive(); system->set_transmission_archive(element.value("transmissionArchive", false)); BOOST_LOG_TRIVIAL(info) << "Transmission Archive: " << system->get_transmission_archive(); - bool audio_postprocess_enabled = false; - int audio_highpass_hz = 0; - int audio_lowpass_hz = 0; + bool audio_postprocess_enabled = true; + int audio_highpass_hz = 300; + int audio_lowpass_hz = 3000; int audio_bandreject_hz = 0; int audio_bandreject_width_hz = 0; bool audio_loudnorm = true; - bool audio_loudnorm_two_pass = true; double audio_loudnorm_i = -16.0; - double audio_loudnorm_tp = -0.1; - double audio_loudnorm_lra = 11.0; + double audio_loudnorm_tp = -1.5; + double audio_loudnorm_lra = 7.0; + bool audio_final_limiter = true; std::string audio_ffmpeg_filter = ""; bool audio_output_raw_audio = false; if (element.contains("audio_postprocess") && element["audio_postprocess"].is_object()) { const json &audio_post = element["audio_postprocess"]; - audio_postprocess_enabled = audio_post.value("enabled", false); - audio_highpass_hz = audio_post.value("highpass_hz", 0); - audio_lowpass_hz = audio_post.value("lowpass_hz", 0); + audio_postprocess_enabled = audio_post.value("enabled", true); + audio_highpass_hz = audio_post.value("highpass_hz", 300); + audio_lowpass_hz = audio_post.value("lowpass_hz", 3000); audio_bandreject_hz = audio_post.value("bandreject_hz", 0); audio_bandreject_width_hz = audio_post.value("bandreject_width_hz", 0); audio_loudnorm = audio_post.value("loudnorm", true); - audio_loudnorm_two_pass = audio_post.value("loudnorm_two_pass", true); audio_loudnorm_i = audio_post.value("loudnorm_i", -16.0); - audio_loudnorm_tp = audio_post.value("loudnorm_tp", -0.1); - audio_loudnorm_lra = audio_post.value("loudnorm_lra", 11.0); + audio_loudnorm_tp = audio_post.value("loudnorm_tp", -1.5); + audio_loudnorm_lra = audio_post.value("loudnorm_lra", 7.0); + audio_final_limiter = audio_post.value("final_limiter", true); audio_ffmpeg_filter = audio_post.value("ffmpeg_filter", ""); audio_output_raw_audio = audio_post.value("outputRawAudio", false); + + // Migration warning: `enabled` used to gate only the cleanup chain; + // loudnorm and the (new) final limiter had independent flags. It is + // now the master switch — false skips the entire post-processing + // pipeline. Warn loudly if the user explicitly set it false so they + // don't get silently wider-band, un-normalized audio they didn't + // expect. + if (audio_post.contains("enabled") && audio_post["enabled"].is_boolean() + && audio_post["enabled"].get() == false) { + BOOST_LOG_TRIVIAL(warning) + << "\033[0;33maudio_postprocess.enabled=false is now a master switch — " + << "cleanup, loudnorm, and final_limiter are ALL skipped. The recorded " + << "audio will be the raw concatenation of transmissions. Previously this " + << "flag only disabled cleanup. See CONFIGURE.md for the new semantics.\033[0m"; + } } if (audio_highpass_hz < 0) { @@ -453,10 +468,10 @@ bool load_config(string config_file, Config &config, gr::top_block_sptr &tb, std system->set_audio_bandreject_hz(audio_bandreject_hz); system->set_audio_bandreject_width_hz(audio_bandreject_width_hz); system->set_audio_loudnorm(audio_loudnorm); - system->set_audio_loudnorm_two_pass(audio_loudnorm_two_pass); system->set_audio_loudnorm_i(audio_loudnorm_i); system->set_audio_loudnorm_tp(audio_loudnorm_tp); system->set_audio_loudnorm_lra(audio_loudnorm_lra); + system->set_audio_final_limiter(audio_final_limiter); system->set_audio_ffmpeg_filter(audio_ffmpeg_filter); system->set_audio_output_raw_audio(audio_output_raw_audio); @@ -466,10 +481,10 @@ bool load_config(string config_file, Config &config, gr::top_block_sptr &tb, std BOOST_LOG_TRIVIAL(info) << "Audio Bandreject (Hz): " << system->get_audio_bandreject_hz(); BOOST_LOG_TRIVIAL(info) << "Audio Bandreject Width (Hz): " << system->get_audio_bandreject_width_hz(); BOOST_LOG_TRIVIAL(info) << "Audio Loudnorm: " << system->get_audio_loudnorm(); - BOOST_LOG_TRIVIAL(info) << "Audio Loudnorm Two Pass: " << system->get_audio_loudnorm_two_pass(); BOOST_LOG_TRIVIAL(info) << "Audio Loudnorm I: " << system->get_audio_loudnorm_i(); BOOST_LOG_TRIVIAL(info) << "Audio Loudnorm TP: " << system->get_audio_loudnorm_tp(); BOOST_LOG_TRIVIAL(info) << "Audio Loudnorm LRA: " << system->get_audio_loudnorm_lra(); + BOOST_LOG_TRIVIAL(info) << "Audio Final Limiter: " << system->get_audio_final_limiter(); if (!system->get_audio_ffmpeg_filter().empty()) { BOOST_LOG_TRIVIAL(info) << "Audio FFmpeg Filter Override: " << system->get_audio_ffmpeg_filter(); diff --git a/trunk-recorder/global_structs.h b/trunk-recorder/global_structs.h index e6952d596..36a102a33 100644 --- a/trunk-recorder/global_structs.h +++ b/trunk-recorder/global_structs.h @@ -58,19 +58,30 @@ struct Config { }; struct Audio_Postprocess_Config { - bool enabled = false; + // Master switch. When false, the entire post-processing pipeline is + // skipped — cleanup, loudnorm, and final_limiter are all bypassed — + // and the call's main file is the raw concat of transmission WAVs. + // Defaults to true, which preserves the analog audio character that + // used to come from the in-recorder bandpass (highpass_hz / lowpass_hz + // below default to 300/3000, matching the old hardcoded GR-chain FIR). + bool enabled = true; - int highpass_hz = 0; - int lowpass_hz = 0; + int highpass_hz = 300; + int lowpass_hz = 3000; int bandreject_hz = 0; int bandreject_width_hz = 0; bool loudnorm = true; - bool loudnorm_two_pass = true; double loudnorm_i = -16.0; - double loudnorm_tp = -0.1; - double loudnorm_lra = 11.0; + double loudnorm_tp = -1.5; + double loudnorm_lra = 7.0; + + // Brick-wall true-peak limiter applied after per-transmission loudnorm and + // the concat filter. Hardcoded ceiling is derived from loudnorm_tp + 0.5 dB + // of headroom so loudnorm has room to work and the limiter just catches + // accidental overshoots. + bool final_limiter = true; std::string ffmpeg_filter = ""; diff --git a/trunk-recorder/recorders/analog_recorder.cc b/trunk-recorder/recorders/analog_recorder.cc index e18786e1e..a600f3269 100644 --- a/trunk-recorder/recorders/analog_recorder.cc +++ b/trunk-recorder/recorders/analog_recorder.cc @@ -180,37 +180,26 @@ analog_recorder::analog_recorder(Source *src, System *system, Recorder_Type type decoder_sink = gr::blocks::decoder_wrapper_impl::make(wav_sample_rate, std::bind(&analog_recorder::decoder_callback_handler, this, std::placeholders::_1, std::placeholders::_2, std::placeholders::_3)); BOOST_LOG_TRIVIAL(info) << "\t Decoder sink created!" << std::endl; - // Analog audio band pass from 300 to 3000 Hz - // can't use gnuradio.filter.firdes.band_pass since we have different transition widths - // 300 Hz high pass (275-325 Hz): removes CTCSS/DCS and Type II 150 bps Low Speed Data (LSD), or "FSK wobble" -#if GNURADIO_VERSION < 0x030900 - high_f_taps = gr::filter::firdes::high_pass(1, wav_sample_rate, 300, 50, gr::filter::firdes::WIN_HANN); // Configurable - low_f_taps = gr::filter::firdes::low_pass(1, wav_sample_rate, 3250, 500, gr::filter::firdes::WIN_HANN); -#else - high_f_taps = gr::filter::firdes::high_pass(1, wav_sample_rate, 300, 50, gr::fft::window::WIN_HANN); // Configurable - low_f_taps = gr::filter::firdes::low_pass(1, wav_sample_rate, 3250, 500, gr::fft::window::WIN_HANN); -#endif - - high_f = gr::filter::fir_filter_fff::make(1, high_f_taps); - // 3000 Hz low pass (3000-3500 Hz) - - low_f = gr::filter::fir_filter_fff::make(1, low_f_taps); + // Voice-band cleanup (300/3000 Hz bandpass) used to live here as FIR + // blocks. It now runs in ffmpeg during call conclusion (see + // audio_postprocess in CONFIGURE.md), so wav_sink receives the + // post-demod / post-deemph signal directly — actual discriminator + // audio. This keeps the recording pipeline cheaper and lets users + // tune or disable the bandpass per system. // using squelch connect(self(), 0, prefilter, 0); connect(prefilter, 0, demod, 0); connect(demod, 0, deemph, 0); if (use_tone_squelch) { - connect(deemph, 0, tone_squelch, 0); - connect(tone_squelch, 0, decim_audio, 0); + connect(deemph, 0, tone_squelch, 0); + connect(tone_squelch, 0, decim_audio, 0); } else { connect(deemph, 0, decim_audio, 0); } connect(decim_audio, 0, decoder_sink, 0); - connect(decim_audio, 0, high_f, 0); - connect(high_f, 0, low_f, 0); - connect(low_f, 0, squelch_two, 0); + connect(decim_audio, 0, squelch_two, 0); connect(squelch_two, 0, levels, 0); connect(levels, 0, converter, 0); connect(converter, 0, wav_sink, 0); diff --git a/trunk-recorder/recorders/analog_recorder.h b/trunk-recorder/recorders/analog_recorder.h index 833903ed1..db30df781 100644 --- a/trunk-recorder/recorders/analog_recorder.h +++ b/trunk-recorder/recorders/analog_recorder.h @@ -127,8 +127,6 @@ class analog_recorder : public gr::hier_block2, public Recorder { std::vector lpf_taps; std::vector audio_resampler_taps; std::vector sym_taps; - std::vector high_f_taps; - std::vector low_f_taps; /* De-emph IIR filter taps */ std::vector d_fftaps; /*! Feed forward taps. */ std::vector d_fbtaps; /*! Feed back taps. */ @@ -150,8 +148,6 @@ class analog_recorder : public gr::hier_block2, public Recorder { gr::blocks::multiply_const_ff::sptr levels; gr::filter::pfb_arb_resampler_ccf::sptr arb_resampler; gr::filter::fir_filter_fff::sptr decim_audio; - gr::filter::fir_filter_fff::sptr high_f; - gr::filter::fir_filter_fff::sptr low_f; gr::analog::pwr_squelch_ff::sptr squelch_two; gr::analog::ctcss_squelch_ff::sptr tone_squelch; diff --git a/trunk-recorder/systems/system.h b/trunk-recorder/systems/system.h index 8b3e9ce10..25310ae2a 100644 --- a/trunk-recorder/systems/system.h +++ b/trunk-recorder/systems/system.h @@ -74,9 +74,6 @@ class System { virtual bool get_audio_loudnorm() = 0; virtual void set_audio_loudnorm(bool enabled) = 0; - virtual bool get_audio_loudnorm_two_pass() = 0; - virtual void set_audio_loudnorm_two_pass(bool enabled) = 0; - virtual double get_audio_loudnorm_i() = 0; virtual void set_audio_loudnorm_i(double value) = 0; @@ -86,6 +83,9 @@ class System { virtual double get_audio_loudnorm_lra() = 0; virtual void set_audio_loudnorm_lra(double value) = 0; + virtual bool get_audio_final_limiter() = 0; + virtual void set_audio_final_limiter(bool enabled) = 0; + virtual std::string get_audio_ffmpeg_filter() = 0; virtual void set_audio_ffmpeg_filter(std::string filter) = 0; diff --git a/trunk-recorder/systems/system_impl.cc b/trunk-recorder/systems/system_impl.cc index 7ded1f252..362cfc1c8 100644 --- a/trunk-recorder/systems/system_impl.cc +++ b/trunk-recorder/systems/system_impl.cc @@ -109,14 +109,6 @@ void System_impl::set_audio_loudnorm(bool enabled) { this->audio_loudnorm = enabled; } -bool System_impl::get_audio_loudnorm_two_pass() { - return this->audio_loudnorm_two_pass; -} - -void System_impl::set_audio_loudnorm_two_pass(bool enabled) { - this->audio_loudnorm_two_pass = enabled; -} - double System_impl::get_audio_loudnorm_i() { return this->audio_loudnorm_i; } @@ -141,6 +133,14 @@ void System_impl::set_audio_loudnorm_lra(double value) { this->audio_loudnorm_lra = value; } +bool System_impl::get_audio_final_limiter() { + return this->audio_final_limiter; +} + +void System_impl::set_audio_final_limiter(bool enabled) { + this->audio_final_limiter = enabled; +} + std::string System_impl::get_audio_ffmpeg_filter() { return this->audio_ffmpeg_filter; } @@ -207,16 +207,16 @@ System_impl::System_impl(int sys_num) { message_count = 0; decode_rate = 0; msg_queue = gr::msg_queue::make(100); - audio_postprocess_enabled = false; - audio_highpass_hz = 0; - audio_lowpass_hz = 0; + audio_postprocess_enabled = true; + audio_highpass_hz = 300; + audio_lowpass_hz = 3000; audio_bandreject_hz = 0; audio_bandreject_width_hz = 0; audio_loudnorm = false; - audio_loudnorm_two_pass = true; audio_loudnorm_i = -16.0; - audio_loudnorm_tp = -0.1; - audio_loudnorm_lra = 11.0; + audio_loudnorm_tp = -1.5; + audio_loudnorm_lra = 7.0; + audio_final_limiter = true; audio_ffmpeg_filter = ""; audio_output_raw_audio = false; } diff --git a/trunk-recorder/systems/system_impl.h b/trunk-recorder/systems/system_impl.h index b0ce1daf1..a4741e93c 100644 --- a/trunk-recorder/systems/system_impl.h +++ b/trunk-recorder/systems/system_impl.h @@ -100,10 +100,10 @@ class System_impl : public System { int audio_bandreject_hz; int audio_bandreject_width_hz; bool audio_loudnorm; - bool audio_loudnorm_two_pass; double audio_loudnorm_i; double audio_loudnorm_tp; double audio_loudnorm_lra; + bool audio_final_limiter; std::string audio_ffmpeg_filter; bool audio_output_raw_audio; @@ -153,9 +153,6 @@ class System_impl : public System { bool get_audio_loudnorm() override; void set_audio_loudnorm(bool enabled) override; - bool get_audio_loudnorm_two_pass() override; - void set_audio_loudnorm_two_pass(bool enabled) override; - double get_audio_loudnorm_i() override; void set_audio_loudnorm_i(double value) override; @@ -165,6 +162,9 @@ class System_impl : public System { double get_audio_loudnorm_lra() override; void set_audio_loudnorm_lra(double value) override; + bool get_audio_final_limiter() override; + void set_audio_final_limiter(bool enabled) override; + std::string get_audio_ffmpeg_filter() override; void set_audio_ffmpeg_filter(std::string filter) override;