Commit 98afaae587 for ffmpeg

commit 98afaae587865cfe451b3ff2f5be0b97190edefd
Author: Lynne <dev@lynne.ee>
Date:   Wed Sep 23 20:32:59 2026 +0900

    avcodec/aaccoder_nmr, aacenc, aacpsy: -aac_rc abr

    -aac_rc cbr|abr (default cbr). ABR runs the nd-target quality solve with
    the set-point owned by a slow rate servo, and joins VBR as a
    quality-target mode in the psy model (fixed-reference PE reduction,
    quality attack map, broadband mask floor).

    The servo integrates the log rate error but applies it to the nd
    set-point in rare, discrete, calm-gated steps. The set-point must be
    quasi-static: any drift on content timescales moves lambda, and with it
    band/PNS/scalefactor state, tracking sections in antiphase and
    coarsening exactly what needs bits - measured 2x worse than a fixed
    target at equal mean rate. Locally this mode IS fixed-target VBR; rate
    honesty converges on the minutes scale.

    Bootstrap. The set-point is seeded from the VBR calibration anchor (one
    constant, NMR_VBR_ANCHOR: the two are required to agree, and moving only
    one copy made ABR overshoot its rate target by up to 9%). After ~2s of
    fast rate measurement off a representative window (an all-transient
    head reads over-target and would coarsen the very content that needs
    bits), an open-loop jump over the measured loop gain (~ -0.24 log2 rate
    per target unit) corrects the seed's per-content error, applied at
    1.2x - the estimator's own inverse warm-up factor, since the EMA is
    seeded on target (a cold start must not read as a fake deficit) and so
    recovers only ~82% of the error. One correction is not enough whatever
    the gain estimate, because the 0.24 loop gain is only nominal: the boot
    re-arms while the rate is still off, up to a bounded number of times,
    re-measuring each time, and re-seeds the estimator on target rather than
    to zero (a zero estimate pins the integrator at its clip via log2f(0)
    and reads as a 100% deficit to the fill in the same frame). Its settle
    window sits below the stepper's hold, or the stepper resets the shared
    counter first and the re-arm never fires.

    Every set-point correction is glided in at ~0.5 log2-units/s rather than
    stepped: a one-frame noise-floor jump at a fixed stream time (the boot
    lands at ~2s in every stream) is audible against revealing content even
    at step sizes the corpus cannot see.

    Bits-fill. Under the long-run target, an easy frame is topped up toward
    the ask by moving lambda finer only - the quality target is a floor,
    never traded away. It spends the rate the user asked for on sub-mask
    margin the nd statistic cannot see (it clips at -4/band); without it,
    stat-transparent content caps the rate below any ask. The fill runs from
    frame one and fades continuously as the rate EMA converges (a boot-gated
    fill flipped regimes at ~2s, an audible quality step), follows the psy
    PE demand shape, and is slew-bounded, writing the lambda continuity
    state from long frames only instead of free-diving up to 64x per frame.
    The un-booted stream head behaves CBR-like until the servo takes over
    (love.flac: >6k energy had swung 0.61-1.83x vs the source at frame rate
    while the head ran 25% under the ask).

    The reachable-range clamps do not track the calibration anchor: their
    floors are set by the stat's -4/band sub-mask clip. A floor at -1.65
    railed the servo on easy content (male_speech at a 128k ask pinned at
    the clamp with a 10% standing deficit); -3.0 keeps 384k asks reachable
    without the stall. Easy content can still fall short of very high asks
    (main_theme: 196k at a 256k ask) - there is no padding. The coarse
    clamp is a quality guard: set-points past it buy little rate for a lot
    of quality (2.5 -> 6.0 at a 48k ask: -5% rate, +32% Zimtohrli), so asks
    below about 56 kbps stereo land above the ask (24k/32k asks: ~40k).
    The documentation points those rates at CBR.

    Rate accuracy against the -b:a ask:
      corpus       bias    mean|err|  worst
      IgorC-3      -1.3%   2.2%       -11.8%
      IgorC-1      +1.1%   2.0%        +5.0%
      32 random    -0.3%   2.2%       -10.0%
    A 4.5-minute concatenation of the IgorC-3 samples lands at 128.2 kbps
    on a 128k ask. castanets/velvet ABR beat CBR; ABR corpus 0.00060 at
    126k.

    -aac_rc is documented, and fate-aac-nmr-abr-encode pins the per-packet
    sizes through the servo's boot.

diff --git a/doc/encoders.texi b/doc/encoders.texi
index 98c5f22da5..fcb085f2b8 100644
--- a/doc/encoders.texi
+++ b/doc/encoders.texi
@@ -85,6 +85,22 @@ and much faster at higher bitrates.

 @end table

+@item aac_rc
+Rate-control mode of the @samp{nmr} coder when a bitrate is set with @option{b}
+(other coders ignore it; @option{q} takes precedence). Possible values:
+
+@table @samp
+@item cbr
+Constant bitrate within the decoder's bit reservoir. This is the default.
+
+@item abr
+Average bitrate: a constant-quality target, as in VBR, whose set-point a slow
+servo moves to meet @option{b} over the length of the stream. Local quality
+stays constant, so short files and individual passages can deviate from the
+requested rate; the average converges over minutes rather than seconds. Below
+roughly 56 kbps stereo the rate lands above the request; use @samp{cbr} there.
+@end table
+
 @item aac_ms
 Sets mid/side coding mode. The default value of "auto" will automatically use
 M/S with bands which will benefit from such coding. Can be forced for all bands
diff --git a/libavcodec/aaccoder_nmr.h b/libavcodec/aaccoder_nmr.h
index 398cc91059..fd7d6e1cbc 100644
--- a/libavcodec/aaccoder_nmr.h
+++ b/libavcodec/aaccoder_nmr.h
@@ -91,18 +91,45 @@

 /* Quality-target calibration anchor: the nd set-point of -q:a 1, which lands
  * near 128-136 kbps stereo on the tuning corpora with the q ladder's own
- * bandwidth. It moved -2.1 -> -0.75 with the quality-target mask floor
- * (PSY_THRFL_QUALITY), which lowers thr_real and so lifts the whole statistic.
- * The reachable-range clamp is set by the stat's -4/band sub-mask clip, which
- * the mask floor does not touch: targets below it are asymptotically
- * unreachable - the bisect rails at the finest lambda, the frame exceeds the
- * decoder buffer and the outer re-encode loop never converges. */
+ * bandwidth. The ABR servo seeds off the same constant (referenced to 81.5
+ * kbps/ch, where it measured before the bandwidth retune; the boot corrects
+ * the per-content seed error), so they are one constant. It moved
+ * -2.1 -> -0.75 with the quality-target mask floor (PSY_THRFL_QUALITY), which
+ * lowers thr_real and so lifts the whole statistic.
+ * The reachable-range clamps did NOT move
+ * with it: their floors are set by the stat's -4/band sub-mask clip, which the
+ * mask floor does not touch. Targets below them are asymptotically unreachable
+ * - the bisect rails at the finest lambda, the frame exceeds the decoder buffer
+ * and the outer re-encode loop never converges (the 384k stall). Raising them
+ * with the anchor railed the ABR servo instead: it wanted a finer target to
+ * meet its rate ask on easy content and could not ask for one. */
 #define NMR_VBR_ANCHOR (-0.75f)
 #define NMR_VBR_TMIN   (-3.8f)
+/* ABR set-point range. The coarse end is a quality guard: set-points past it
+ * buy little rate for a lot of quality (2.5 -> 6.0 at a 48k ask: -5% rate,
+ * +32% Zimtohrli), so asks below ~56 kbps stereo land above the ask - CBR
+ * is the mode for those rates. At the fine end, easy content can fall short
+ * of very high asks (no padding). */
+#define NMR_ABR_TMIN   (-3.0f)
+#define NMR_ABR_TMAX     2.5f
+
+/* ABR servo: integrator gain on the log rate error, rate EMA weight after the
+ * boot, set-point step size and minimum frames between steps */
+#define NMR_ABR_K         0.003f
+#define NMR_ABR_EMA       0.0023f
+#define NMR_ABR_STEP      0.15f
+#define NMR_ABR_HOLD      120
+/* ABR boot: open-loop correction gain over the nominal loop gain, the rate
+ * error that re-arms it, the re-arm budget and the settle time before one */
+#define NMR_ABR_BOOT_GAIN 1.2f
+#define NMR_ABR_TOL       0.02f
+#define NMR_ABR_BOOTS     6
+#define NMR_ABR_SETTLE    100

 /* VBR shorts: the measured stat inflation counts fully once this share of
  * the frame's energy sits above 6 kHz */
 #define NMR_INFL_HF 0.10f
+
 /* Reservoir half-window (bits/ch); swept 512/1536/3072, 1536 optimal. */
 #define NMR_CBR_BUF   1536
 /* Slew limit on the FINAL operating lambda per frame; bits deviate instead,
@@ -754,9 +781,14 @@ static void nmr_solve_group(AVCodecContext *avctx, AACEncContext *s,
     }

     int vbr = (avctx->flags & AV_CODEC_FLAG_QSCALE) && s->nmr;
+    int abr = !vbr && s->options.rc == 1 && avctx->bit_rate > 0 && s->nmr;
     int vbr_subst = 1;
     float vbr_t = 0.0f;
-    if (vbr) {
+    if (abr) {
+        /* ABR: same quality-target solve, set-point owned by the rate servo */
+        vbr_t = s->nmr->abr_t;
+        vbr   = 1;
+    } else if (vbr) {
         /* nd-target VBR: constant achieved noise-to-mask, bits float. */
         /* target in log2(dist/real-mask), anchored so -q:a 1 lands near
          * 128 kbps stereo on the tuning corpus; higher q is finer, each q
@@ -1011,6 +1043,48 @@ static void nmr_solve_group(AVCodecContext *avctx, AACEncContext *s,
             hardcap = FFMAX(hardcap - (int)(FFMAX(s->nmr->side_ema, 0.0f) * chans / s->channels), 256);
         for (int k = 0; k < nsl; k++)
             tot += nmr_slot_bits(sl[k], s->nmr->nb[sl[k]->si], NMR_STEP);
+        if (abr && s->nmr->abr_ema < rc_rate_frame) {
+            /* bits-fill: under the long-run target, top an easy frame up
+             * toward the ask by moving lambda FINER only - the quality
+             * target is a floor, never traded away. Spends the rate the
+             * user asked for on sub-mask margin the nd statistic cannot
+             * see (it clips at -4/band); without this, stat-transparent
+             * content caps the rate below any ask. Active from frame one
+             * (the immature EMA reads as a deficit, so the stream head is
+             * held near the ask) and fades continuously as the EMA
+             * converges - a boot-gated fill flips regimes at ~2s, an
+             * audible quality step.
+             * The fill target follows the psy PE demand shape: per-frame
+             * demand carries perceptual information the nd model does not
+             * (the constant-lambda lesson) - and routing it through the
+             * fill keeps the quality floor intact. */
+            float dshape = 1.0f;
+            int fill;
+            if (s->psy.bitres.alloc > 0) {
+                float *aema = &s->nmr->abr_alloc_ema;
+                if (*aema <= 0.0f) *aema = s->psy.bitres.alloc;
+                else *aema += 0.01f * (s->psy.bitres.alloc - *aema);
+                dshape = av_clipf(s->psy.bitres.alloc / *aema, 0.6f, 1.7f);
+            }
+            fill = (int)((rc_rate_frame + (rc_rate_frame - (int)s->nmr->abr_ema)) * dshape) *
+                   chans / s->channels;
+            fill = FFMIN3(fill, 3 * rc_rate_frame * chans / s->channels / 2, hardcap);
+            if (tot < fill) {
+                /* the fill dive is the easy-frame operating point: slew it
+                 * like any other lambda move and let it carry the
+                 * continuity state, or per-frame depth variation reads as
+                 * frame-rate HF wobble */
+                float flo = lam / 64.0f;
+                if (*vslew_st > 0.0f)
+                    flo = FFMIN(FFMAX(flo, *vslew_st / NMR_SLEW), lam);
+                lam = nmr_solve_slots(s, sl, nsl, NMR_STEP, fill, flo, lam, NMR_RC_ITERS);
+                if (!is8_any)
+                    *vslew_st = s->nmr->lam_slew = lam;
+                tot = 0;
+                for (int k = 0; k < nsl; k++)
+                    tot += nmr_slot_bits(sl[k], s->nmr->nb[sl[k]->si], NMR_STEP);
+            }
+        }
         if (tot > hardcap) {
             lam = nmr_solve_slots(s, sl, nsl, NMR_STEP, hardcap, lam, 1e4f, NMR_RC_ITERS);
             tot = 0;
@@ -1280,7 +1354,7 @@ static void search_for_quantizers_nmr(AVCodecContext *avctx,
     /* Global-lambda RC: one solve per frame at a servoed centre lambda; the reservoir
      * holds the long-run mean rate. Bypassed for VBR (-q:a) and the bootstrap frame. */
     int rc_eligible = !(avctx->flags & AV_CODEC_FLAG_QSCALE) && avctx->bit_rate > 0 &&
-                      avctx->bit_rate_tolerance != 0;
+                      avctx->bit_rate_tolerance != 0 && s->options.rc == 0;
     /* Signed reservoir; soft steering (bounded repay + rc_off), hard cap =
      * legality only. */
     int rc_rate_frame = avctx->bit_rate * 1024.0 / avctx->sample_rate;
@@ -1291,6 +1365,108 @@ static void search_for_quantizers_nmr(AVCodecContext *avctx,

     s->nmr->counted[s->cur_channel] = 0;

+    if (s->options.rc == 1 && !(avctx->flags & AV_CODEC_FLAG_QSCALE) &&
+        avctx->bit_rate > 0 && avctx->frame_num != n->abr_frame_num) {
+        /* ABR servo: integrate the log rate error, but apply it to the nd
+         * set-point in RARE, DISCRETE steps. The set-point must be
+         * quasi-static: any drift on content timescales moves lambda, and
+         * with it band/PNS/scalefactor state - measured 2x worse than a
+         * fixed target at equal mean rate. Locally this mode IS fixed-target
+         * VBR; rate honesty converges on the minutes scale. */
+        if (n->abr_frame_num == 0 && n->abr_ema <= 0.0f) {
+            /* seed the set-point from the VBR calibration anchor (see
+             * NMR_VBR_ANCHOR for the 81.5 kbps/ch reference); the servo
+             * trims the rest */
+            n->abr_t   = av_clipf(NMR_VBR_ANCHOR - 2.5f * log2f(avctx->bit_rate /
+                                  (81500.0f * s->channels)), NMR_ABR_TMIN, NMR_ABR_TMAX);
+            n->abr_ema = rc_rate_frame;
+        } else if (s->last_frame_pb_count > 0) {
+            /* bootstrap: fast rate measurement for ~2s, then one open-loop
+             * jump over the measured loop gain (~ -0.24 log2 rate per target
+             * unit) corrects the seed's per-content error; the quasi-static
+             * stepper handles drift from there */
+            /* seed on-target: an EMA warming up from zero reads as a fake
+             * deficit and the fill overspends the whole stream head */
+            if (n->abr_ema <= 0.0f)
+                n->abr_ema = rc_rate_frame;
+            n->abr_ema += (n->abr_booted ? NMR_ABR_EMA : 0.02f) *
+                          (s->last_frame_pb_count - n->abr_ema);
+            n->abr_hold++;
+            if (!n->abr_booted) {
+                n->abr_longs += sce->ics.window_sequence[0] != EIGHT_SHORT_SEQUENCE;
+                /* boot only off a REPRESENTATIVE window: an all-transient
+                 * head (castanets roll) reads over-target and would coarsen
+                 * the very content that needs bits. Time out eventually. */
+                if ((n->abr_hold >= 86 && n->abr_longs >= n->abr_hold / 2) ||
+                    n->abr_hold >= 400) {
+                    /* glide the seed correction in, never step it: a
+                     * one-frame noise-floor jump at a fixed stream time is
+                     * audible against revealing content */
+                    n->abr_glide = NMR_ABR_BOOT_GAIN * log2f(n->abr_ema / rc_rate_frame) / 0.24f;
+                    n->abr_booted = 1;
+                    n->abr_boots++;
+                    n->abr_hold   = 0;
+                }
+            } else {
+                /* Re-arm the open-loop correction while the rate is still
+                 * off. One boot leaves a residual whenever the real loop gain
+                 * differs from the nominal 0.24, and the estimator is biased
+                 * toward the ask by construction (the EMA is seeded ON target
+                 * so a cold start cannot read as a deficit), so it understates
+                 * the error it is correcting. The quasi-static stepper cannot
+                 * drain that inside a track - measured, male_speech ended a
+                 * 774-frame file 23% short and still moving. Re-measuring and
+                 * firing again converges regardless of the gain estimate, and
+                 * keeps the set-point quasi-static: corrections stay rare,
+                 * glided, and bounded in number. */
+                if (n->abr_boots < NMR_ABR_BOOTS && n->abr_glide == 0.0f &&
+                    n->abr_hold >= NMR_ABR_SETTLE &&
+                    fabsf(log2f(n->abr_ema / rc_rate_frame)) > NMR_ABR_TOL) {
+                    /* Re-seed the estimator ON target, exactly as the cold
+                     * start does - never to zero. The value is read again
+                     * before anything re-seeds it: by the integrator below
+                     * (log2f(0) is -inf, which pinned the accumulator at its
+                     * clip) and by the bits-fill later in this same frame
+                     * (a zero EMA reads as a 100% deficit and authorises a
+                     * one-frame overspend). Drop the residual integral with
+                     * it: it describes the regime being left, and the boot
+                     * is about to re-measure that same error open-loop. */
+                    n->abr_booted = 0;
+                    n->abr_ema    = rc_rate_frame;
+                    n->abr_acc    = 0.0f;
+                    n->abr_hold   = 0;
+                    n->abr_longs  = 0;
+                } else {
+                    n->abr_acc = av_clipf(n->abr_acc + NMR_ABR_K * log2f(n->abr_ema / rc_rate_frame),
+                                          -4.0f * NMR_ABR_STEP, 4.0f * NMR_ABR_STEP);
+                    /* step in calm stretches (a set-point move during a transient
+                     * section coarsens exactly what needs the bits) - but dense
+                     * content must not deadlock the servo: after 3x the hold the
+                     * step fires regardless. Large errors step at double size;
+                     * the remainder carries over instead of being discarded. */
+                    if (fabsf(n->abr_acc) >= NMR_ABR_STEP && n->abr_hold >= NMR_ABR_HOLD &&
+                        (n->frames_since_short >= 12 || n->abr_hold >= 3 * NMR_ABR_HOLD)) {
+                        float ms   = NMR_ABR_STEP * (fabsf(n->abr_acc) > 3.0f * NMR_ABR_STEP ? 2.0f : 1.0f);
+                        float step = av_clipf(n->abr_acc, -ms, ms);
+                        n->abr_glide += step;
+                        n->abr_acc  -= step;
+                        n->abr_hold  = 0;
+                    }
+                }
+            }
+            if (n->abr_glide != 0.0f) {
+                /* drain pending set-point corrections smoothly (~0.5
+                 * log2-units/s): the decision stays quasi-static and
+                 * calm-gated, only the application glides */
+                float d = av_clipf(n->abr_glide, -0.012f, 0.012f);
+                n->abr_t = av_clipf(n->abr_t + d, NMR_ABR_TMIN, NMR_ABR_TMAX);
+                n->abr_glide -= d;
+                if (fabsf(n->abr_glide) < 1e-4f)
+                    n->abr_glide = 0.0f;
+            }
+        }
+        n->abr_frame_num = avctx->frame_num;
+    }
     if (rc_eligible && !n->rc_fill_seeded) {
         /* the decoder bit reservoir starts FULL: seed it so the head may frontload */
         n->rc_fill = rc_bmax;
diff --git a/libavcodec/aacenc.c b/libavcodec/aacenc.c
index e415f54247..b6d72e9368 100644
--- a/libavcodec/aacenc.c
+++ b/libavcodec/aacenc.c
@@ -1692,12 +1692,12 @@ static int aac_encode_frame(AVCodecContext *avctx, AVPacket *avpkt,
     avpkt->size            = put_bytes_output(&s->pb);

     /* NMR reports its real operating lambda: the corridor centre in CBR,
-     * the quality-mode slew state in VBR - the lambda of the last long
+     * the quality-mode slew state in VBR/ABR - the lambda of the last long
      * operating-point solve, which short frames and legality re-solves do
      * not update (the outer-loop s->lambda is never touched for this coder
      * and would pin Qavg at its 120 init) */
     s->lambda_sum += (s->nmr && s->nmr->lam_slew > 0.0f &&
-                      (avctx->flags & AV_CODEC_FLAG_QSCALE)) ?
+                      ((avctx->flags & AV_CODEC_FLAG_QSCALE) || s->options.rc == 1)) ?
                      s->nmr->lam_slew :
                      (s->nmr && s->nmr->lam_rc > 0.0f) ? s->nmr->lam_rc : s->lambda;
     s->lambda_count++;
@@ -1964,7 +1964,7 @@ static av_cold int aac_encode_init(AVCodecContext *avctx)
     lengths[1] = ff_aac_num_swb_128[s->samplerate_index];
     for (i = 0; i < s->chan_map[0]; i++)
         grouping[i] = s->chan_map[i + 1] == TYPE_CPE;
-    s->psy.unbounded_pe = (avctx->flags & AV_CODEC_FLAG_QSCALE) &&
+    s->psy.unbounded_pe = ((avctx->flags & AV_CODEC_FLAG_QSCALE) || s->options.rc == 1) &&
                           s->options.coder == AAC_CODER_NMR;
     if ((ret = ff_psy_init(&s->psy, avctx, 2, sizes, lengths,
                            s->chan_map[0], grouping, s->bandwidth)) < 0)
@@ -1991,6 +1991,9 @@ static const AVOption aacenc_options[] = {
     {"aac_pns", "Perceptual noise substitution", offsetof(AACEncContext, options.pns), AV_OPT_TYPE_BOOL, {.i64 = 1}, -1, 1, AACENC_FLAGS},
     {"aac_tns", "Temporal noise shaping", offsetof(AACEncContext, options.tns), AV_OPT_TYPE_BOOL, {.i64 = 1}, -1, 1, AACENC_FLAGS},
     {"aac_pce", "Forces the use of PCEs", offsetof(AACEncContext, options.pce), AV_OPT_TYPE_BOOL, {.i64 = 0}, -1, 1, AACENC_FLAGS},
+    {"aac_rc", "Rate-control mode (NMR coder)", offsetof(AACEncContext, options.rc), AV_OPT_TYPE_INT, {.i64 = 0}, 0, 1, AACENC_FLAGS, .unit = "aac_rc"},
+        {"cbr", "Constant bitrate (corridor + leaky bucket)", 0, AV_OPT_TYPE_CONST, {.i64 = 0}, INT_MIN, INT_MAX, AACENC_FLAGS, .unit = "aac_rc"},
+        {"abr", "Average bitrate (constant-quality target, slow rate servo)", 0, AV_OPT_TYPE_CONST, {.i64 = 1}, INT_MIN, INT_MAX, AACENC_FLAGS, .unit = "aac_rc"},
     {"aac_nmr_speed", "NMR coder speed level: 0 = slowest/best, higher trades quality for speed", offsetof(AACEncContext, options.nmr_speed), AV_OPT_TYPE_INT, {.i64 = 0}, 0, 4, AACENC_FLAGS},
     {"aac_allow_71wide", "Allow non-PCE use of 7.1(wide) channel layout", offsetof(AACEncContext, options.allow_71wide), AV_OPT_TYPE_BOOL, {.i64 = 0}, 0, 1, AACENC_FLAGS},
     FF_AAC_PROFILE_OPTS
diff --git a/libavcodec/aacenc.h b/libavcodec/aacenc.h
index 9d2ba89056..057c2a59d4 100644
--- a/libavcodec/aacenc.h
+++ b/libavcodec/aacenc.h
@@ -72,6 +72,7 @@ typedef struct AACEncOptions {
     int intensity_stereo;
     int nmr_speed;          ///< NMR coder speed level: 0 = slowest/best, higher is faster
     int allow_71wide;
+    int rc;                 ///< rate-control mode: 0 = cbr (corridor+bucket), 1 = abr (nd-target + slow servo)
 } AACEncOptions;

 /**
@@ -248,6 +249,18 @@ typedef struct AACNMRCurves {
     float   lam_short_ema;                       ///< smoothed operating lambda of short frames
     float   lam_long_ema;                        ///< smoothed operating lambda of long frames
     float   lam_floor;                           ///< lambda min-tracker (snaps down, +2%/frame up): sustained-strain floor; bursty spikes at a comfortable rate cannot raise it
+
+    /* ABR: nd-target set-point servoed to hold the long-run mean rate */
+    float   abr_t;                     ///< current nd target (log2 dist/mask)
+    float   abr_glide;                 ///< pending set-point correction, drained per-frame (no discrete quality steps)
+    float   abr_ema;                   ///< EMA of real frame bits
+    float   abr_acc;                   ///< accumulated set-point correction
+    int     abr_hold;                  ///< frames since the target last stepped
+    int     abr_booted;                ///< seed correction applied (re-armed while the rate is still off)
+    int     abr_boots;                 ///< open-loop corrections fired so far
+    float   abr_alloc_ema;             ///< EMA of psy bit demand (fill shaping)
+    int     abr_longs;                 ///< long frames seen during bootstrap
+    int64_t abr_frame_num;             ///< once-per-frame servo guard
 } AACNMRCurves;

 typedef struct AACPCEInfo {
diff --git a/libavcodec/aacpsy.c b/libavcodec/aacpsy.c
index 0fd0c87b85..ac0e17de5b 100644
--- a/libavcodec/aacpsy.c
+++ b/libavcodec/aacpsy.c
@@ -332,14 +332,16 @@ static float lame_calc_attack_threshold(int bitrate)
 /**
  * LAME psy model specific initialization
  */
-static av_cold void lame_window_init(AacPsyContext *ctx, AVCodecContext *avctx)
+static av_cold void lame_window_init(FFPsyContext *fctx, AacPsyContext *ctx, AVCodecContext *avctx)
 {
     int i, j;

     for (i = 0; i < avctx->ch_layout.nb_channels; i++) {
         AacPsyChannel *pch = &ctx->ch[i];

-        if (avctx->flags & AV_CODEC_FLAG_QSCALE)
+        if ((avctx->flags & AV_CODEC_FLAG_QSCALE) || fctx->unbounded_pe)
+            /* quality-target coders (VBR and ABR) switch windows for quality,
+             * not rate: use the quality attack map regardless of bit_rate */
             pch->attack_threshold = psy_vbr_map[av_clip(avctx->global_quality / FF_QP2LAMBDA, 0, 10)].st_lrm;
         else
             pch->attack_threshold = lame_calc_attack_threshold(avctx->bit_rate / avctx->ch_layout.nb_channels / 1000);
@@ -456,7 +458,7 @@ static av_cold int psy_3gpp_init(FFPsyContext *ctx) {
     for (i = 0; i < ctx->avctx->ch_layout.nb_channels; i++)
         pctx->ch[i].rc_frame_num = -1;

-    lame_window_init(pctx, ctx->avctx);
+    lame_window_init(ctx, pctx, ctx->avctx);

     return 0;
 }
@@ -953,7 +955,7 @@ static void psy_3gpp_analyze_channel(FFPsyContext *ctx, int channel,
      * it cannot fail. A band we still choose to code must never be allowed
      * noise within PSY_THRFL_* dB of its own energy, whatever the hole logic said. */
     {
-        int qmode = !!(ctx->avctx->flags & AV_CODEC_FLAG_QSCALE);
+        int qmode = ctx->unbounded_pe || (ctx->avctx->flags & AV_CODEC_FLAG_QSCALE);
         float lim  = qmode ? PSY_THRFL_QUALITY : PSY_THRFL_CBR;
         float knee = qmode ? 0.0f : PSY_THRFL_CBR_KNEE;
         float lo   = ff_exp10f(-lim / 10.0f);
diff --git a/tests/fate/aac.mak b/tests/fate/aac.mak
index b1f49aff2a..7da94cf30b 100644
--- a/tests/fate/aac.mak
+++ b/tests/fate/aac.mak
@@ -268,6 +268,10 @@ fate-aac-yoraw-encode: FUZZ = 17
 FATE_AAC_ENCODE_TRANSCODE += fate-aac-nmr-vbr-encode
 fate-aac-nmr-vbr-encode: CMD = transcode wav $(TARGET_SAMPLES)/audio-reference/luckynight_2ch_44kHz_s16.wav adts "-af aresample -c:a aac -q:a 8 -t 2" "-c copy"

+# ABR through the rate servo's boot (~2s)
+FATE_AAC_ENCODE_TRANSCODE += fate-aac-nmr-abr-encode
+fate-aac-nmr-abr-encode: CMD = transcode wav $(TARGET_SAMPLES)/audio-reference/luckynight_2ch_44kHz_s16.wav adts "-af aresample -c:a aac -aac_rc abr -b:a 96k -t 3" "-c copy"
+
 tests/data/fate/aac-5_1_2.adts: TAG = GEN
 tests/data/fate/aac-5_1_2.adts: tests/data/asynth-44100-8.wav
 tests/data/fate/aac-5_1_2.adts: ffmpeg$(PROGSSUF)$(EXESUF) | tests/data/fate
diff --git a/tests/ref/fate/aac-nmr-abr-encode b/tests/ref/fate/aac-nmr-abr-encode
new file mode 100644
index 0000000000..9418f60149
--- /dev/null
+++ b/tests/ref/fate/aac-nmr-abr-encode
@@ -0,0 +1,138 @@
+8a93613d6b0acab3fc863fda864a48e5 *tests/data/fate/aac-nmr-abr-encode.adts
+31792 tests/data/fate/aac-nmr-abr-encode.adts
+#tb 0: 1/28224000
+#media_type 0: audio
+#codec_id 0: aac
+#sample_rate 0: 44100
+#channel_layout_name 0: stereo
+0,          0,          0,   655360,      269, 0xfa9b83e0
+0,     655360,     655360,   655360,      464, 0xa230e9b1
+0,    1310720,    1310720,   655360,      260, 0x549a7ba3
+0,    1966080,    1966080,   655360,      262, 0xe5ce7d83
+0,    2621440,    2621440,   655360,      259, 0x26067677
+0,    3276800,    3276800,   655360,      261, 0x2bdc7bc4
+0,    3932160,    3932160,   655360,      244, 0xc16d750e
+0,    4587520,    4587520,   655360,      252, 0xb09d7762
+0,    5242880,    5242880,   655360,      263, 0x91157965
+0,    5898240,    5898240,   655360,      259, 0xb3e972f4
+0,    6553600,    6553600,   655360,      268, 0xcbe9813d
+0,    7208960,    7208960,   655360,      252, 0xf9c076f3
+0,    7864320,    7864320,   655360,      398, 0x2851be3c
+0,    8519680,    8519680,   655360,      320, 0x166d991d
+0,    9175040,    9175040,   655360,      408, 0xca4cd36f
+0,    9830400,    9830400,   655360,      239, 0x78c2742b
+0,   10485760,   10485760,   655360,      246, 0xca377334
+0,   11141120,   11141120,   655360,      240, 0x635e7250
+0,   11796480,   11796480,   655360,      220, 0x3bbd66ae
+0,   12451840,   12451840,   655360,      210, 0xf244638d
+0,   13107200,   13107200,   655360,      209, 0x64a6563b
+0,   13762560,   13762560,   655360,      221, 0xa96762cd
+0,   14417920,   14417920,   655360,      226, 0x749f66c4
+0,   15073280,   15073280,   655360,      226, 0xd0eb6387
+0,   15728640,   15728640,   655360,      223, 0xc3826574
+0,   16384000,   16384000,   655360,      220, 0x78a86d47
+0,   17039360,   17039360,   655360,      230, 0xb4446dc3
+0,   17694720,   17694720,   655360,      215, 0x306f7104
+0,   18350080,   18350080,   655360,      229, 0x91de69c3
+0,   19005440,   19005440,   655360,      219, 0x078e672d
+0,   19660800,   19660800,   655360,      214, 0xb5b766a4
+0,   20316160,   20316160,   655360,      220, 0x7023610b
+0,   20971520,   20971520,   655360,      210, 0x182a5ee8
+0,   21626880,   21626880,   655360,      203, 0xdf3655a9
+0,   22282240,   22282240,   655360,      231, 0x13d565d0
+0,   22937600,   22937600,   655360,      229, 0x5e0063f5
+0,   23592960,   23592960,   655360,      232, 0xf03468e7
+0,   24248320,   24248320,   655360,      244, 0x882a7785
+0,   24903680,   24903680,   655360,      236, 0xf8bd7012
+0,   25559040,   25559040,   655360,      228, 0x1956677a
+0,   26214400,   26214400,   655360,      266, 0x3e6b81a9
+0,   26869760,   26869760,   655360,      233, 0x5ab66d73
+0,   27525120,   27525120,   655360,      244, 0x25b56b66
+0,   28180480,   28180480,   655360,      184, 0xd9695100
+0,   28835840,   28835840,   655360,      229, 0x22f771cd
+0,   29491200,   29491200,   655360,      260, 0xc6fa8312
+0,   30146560,   30146560,   655360,      256, 0x48197e58
+0,   30801920,   30801920,   655360,      247, 0xea997935
+0,   31457280,   31457280,   655360,      239, 0xa22f7015
+0,   32112640,   32112640,   655360,      259, 0x8b2384c0
+0,   32768000,   32768000,   655360,      252, 0xd23e790d
+0,   33423360,   33423360,   655360,      240, 0x25056b8c
+0,   34078720,   34078720,   655360,      256, 0xab1077f8
+0,   34734080,   34734080,   655360,      238, 0xd99a6cc5
+0,   35389440,   35389440,   655360,      223, 0x4ffe6c3f
+0,   36044800,   36044800,   655360,      222, 0x8c7c5f7a
+0,   36700160,   36700160,   655360,      231, 0xcc3172bf
+0,   37355520,   37355520,   655360,      224, 0xc23b68cf
+0,   38010880,   38010880,   655360,      222, 0xf0986a4d
+0,   38666240,   38666240,   655360,      233, 0x92a36bc9
+0,   39321600,   39321600,   655360,      251, 0x11516fb9
+0,   39976960,   39976960,   655360,      263, 0x0f0b7e11
+0,   40632320,   40632320,   655360,      408, 0xbf1ccc26
+0,   41287680,   41287680,   655360,      531, 0xa5690ccd
+0,   41943040,   41943040,   655360,      308, 0x5eed9716
+0,   42598400,   42598400,   655360,      277, 0x3668903e
+0,   43253760,   43253760,   655360,      239, 0x2bab729b
+0,   43909120,   43909120,   655360,      227, 0x3c8b7095
+0,   44564480,   44564480,   655360,      190, 0x6c3257ab
+0,   45219840,   45219840,   655360,      209, 0x19125f77
+0,   45875200,   45875200,   655360,      196, 0xcec25cec
+0,   46530560,   46530560,   655360,      250, 0x3e2875d4
+0,   47185920,   47185920,   655360,      276, 0x37138362
+0,   47841280,   47841280,   655360,      210, 0x77d55f66
+0,   48496640,   48496640,   655360,      202, 0x88c06283
+0,   49152000,   49152000,   655360,      201, 0x364a5f76
+0,   49807360,   49807360,   655360,      218, 0x38ee6916
+0,   50462720,   50462720,   655360,      205, 0x017c6140
+0,   51118080,   51118080,   655360,      205, 0xba40628e
+0,   51773440,   51773440,   655360,      211, 0x3ab1628d
+0,   52428800,   52428800,   655360,      208, 0x9b636846
+0,   53084160,   53084160,   655360,      207, 0xcbd06506
+0,   53739520,   53739520,   655360,      207, 0x487f6291
+0,   54394880,   54394880,   655360,      218, 0x7aa870d9
+0,   55050240,   55050240,   655360,      256, 0x3495754e
+0,   55705600,   55705600,   655360,      335, 0xd95aa995
+0,   56360960,   56360960,   655360,      196, 0x54e75268
+0,   57016320,   57016320,   655360,      209, 0x04de5e1e
+0,   57671680,   57671680,   655360,      206, 0xa2f463a5
+0,   58327040,   58327040,   655360,      204, 0xad526074
+0,   58982400,   58982400,   655360,      209, 0xde2e5aec
+0,   59637760,   59637760,   655360,      209, 0x8c6d6905
+0,   60293120,   60293120,   655360,      202, 0x44ca5964
+0,   60948480,   60948480,   655360,      206, 0xd8685ef3
+0,   61603840,   61603840,   655360,      209, 0xff6360ce
+0,   62259200,   62259200,   655360,      207, 0xa9fc61b1
+0,   62914560,   62914560,   655360,      209, 0x312d6796
+0,   63569920,   63569920,   655360,      218, 0x4c9f695f
+0,   64225280,   64225280,   655360,      224, 0x34996723
+0,   64880640,   64880640,   655360,      234, 0x02096ec5
+0,   65536000,   65536000,   655360,      246, 0xa63a70c2
+0,   66191360,   66191360,   655360,      277, 0x8559914c
+0,   66846720,   66846720,   655360,      240, 0xa2087438
+0,   67502080,   67502080,   655360,      245, 0xc61a826b
+0,   68157440,   68157440,   655360,      248, 0xd92a7776
+0,   68812800,   68812800,   655360,      244, 0xa4517d63
+0,   69468160,   69468160,   655360,      258, 0x79cb75e2
+0,   70123520,   70123520,   655360,      274, 0x98708776
+0,   70778880,   70778880,   655360,      239, 0xcd83750c
+0,   71434240,   71434240,   655360,      232, 0x05336d82
+0,   72089600,   72089600,   655360,      234, 0x55847ac8
+0,   72744960,   72744960,   655360,      234, 0x76da6e4c
+0,   73400320,   73400320,   655360,      221, 0x919969bf
+0,   74055680,   74055680,   655360,      223, 0xfbfb69c2
+0,   74711040,   74711040,   655360,      228, 0xf9e373d7
+0,   75366400,   75366400,   655360,      224, 0xd0975f50
+0,   76021760,   76021760,   655360,      234, 0xa66d6e10
+0,   76677120,   76677120,   655360,      249, 0x2a276d92
+0,   77332480,   77332480,   655360,      239, 0x53516e65
+0,   77987840,   77987840,   655360,      270, 0xa6b77886
+0,   78643200,   78643200,   655360,      274, 0x076d7c58
+0,   79298560,   79298560,   655360,      269, 0x93fc811b
+0,   79953920,   79953920,   655360,      239, 0x9dfc73de
+0,   80609280,   80609280,   655360,      255, 0x6aa47d01
+0,   81264640,   81264640,   655360,      236, 0x755271f0
+0,   81920000,   81920000,   655360,      241, 0x8bab73ea
+0,   82575360,   82575360,   655360,      246, 0xa0ac7c7a
+0,   83230720,   83230720,   655360,      251, 0x74447d8a
+0,   83886080,   83886080,   655360,      223, 0xefc36a30
+0,   84541440,   84541440,   655360,      241, 0x88ab6e54
+0,   85196800,   85196800,   655360,      191, 0x89b45c00