Commit 03ce7af84b for ffmpeg

commit 03ce7af84be7926d5c12112bec6a8c705e6c2ad9
Author: Lynne <dev@lynne.ee>
Date:   Wed Sep 23 20:29:41 2026 +0900

    avcodec/aacenc: M/S balance gate, perceptual intensity stereo

    M/S balance gate. M/S has no coding gain on a hard-panned band
    (|S| ~ |M|) and its quantization noise decorrelates across the unfold,
    smearing the panned source into the far channel; require channel
    balance within ~6dB for M/S candidacy.

    Perceptual intensity stereo, on by default. Zimtohrli penalizes I/S
    reconstruction even when the image error sits below the masks, so the
    metric cannot govern it; ears above ~8kHz do not hear interaural fine
    structure, and they can. Engage I/S on perceptual criteria alone: above
    8kHz, inter-channel correlation over 0.85 (sticky), long windows only,
    with the image-error budget opened for qualifying bands regardless of
    rate pressure. The gates select content like a scalpel - Angels 9.3% of
    bands, Enola 6.7%, trumpet 3.0% - and exactly 0% on decorrelated-HF
    material (velvet, Berling) where I/S would damage the image. Corpus cost
    is metric noise (+0.6% Zim at 128k, +1.4% at 64k - Zimtohrli penalizes
    I/S reconstruction the ear does not hear); freed bits improve the
    sub-8kHz spectrum 10% on engagement-heavy content. -aac_is 0 disables
    I/S altogether.

    Measured and not included: lifting the both-channels-noise-like
    requirement for stereo PNS (engagement moves less than 0.2% even at
    15-18% band usage, corpus flat, and noise in one ear against coded
    texture in the other is an imaging risk), and M/S threshold, BMLD side
    reduction, M/S equivalence and I/S floor/surgical-candidacy sweeps.

    The id3v2 re-encode refs follow the default NMR output; the fast-coder
    encode tests are unaffected.

diff --git a/libavcodec/aacenc.c b/libavcodec/aacenc.c
index efd3b4aba9..9398467bc7 100644
--- a/libavcodec/aacenc.c
+++ b/libavcodec/aacenc.c
@@ -753,6 +753,15 @@ static void apply_intensity_stereo(ChannelElement *cpe)
 /* PNS-stereo gate: substitute only clearly-decorrelated (wide) bands. */
 #define NMR_PNS_STEREO_DECORR 0.6f

+/* M/S balance gate: no M/S on bands panned harder than this energy ratio */
+#define NMR_MS_BALANCE 0.25f
+
+/* Perceptual I/S: highly correlated long-window bands above this frequency
+ * take I/S at this image-error budget regardless of rate pressure */
+#define NMR_IS_PERC_FREQ 8000.0f
+#define NMR_IS_PERC_CORR 0.85f
+#define NMR_IS_PERC_GATE 50.0f
+
 /* Recode one band's window group as mid+side in place. */
 static void nmr_apply_ms_band(AACEncContext *s, ChannelElement *cpe,
                               int w, int g, int start, int len, int gl)
@@ -872,7 +881,8 @@ static void nmr_decide_stereo(AACEncContext *s, ChannelElement *cpe)
      * not admit it). Unengaged candidates fall back to M/S. */
     float is_ramp = s->nmr ? s->nmr->press *
         av_clipf((s->nmr->lam_floor - 40.0f) / (120.0f - 40.0f), 0.0f, 1.0f) : 0.0f;
-    const int allow_is = s->options.intensity_stereo && is_ramp > 0.0f;
+    /* perceptual I/S (below) makes candidacy pressure-independent */
+    const int allow_is = s->options.intensity_stereo;

     const int pidx = (s->cur_channel >> 1) & 15;
     const int decoupled = s->psy.pair_decoupled[pidx];
@@ -931,6 +941,18 @@ static void nmr_decide_stereo(AACEncContext *s, ChannelElement *cpe)
             float eqgate = NMR_MS_EQUIV * (prev == 1 ? 1.5f : 1.0f);   /* stay-until es>0.75em */
             /* I/S = lossy economy: image-error budget scales with pressure */
             float imgate = NMR_IS_IMG_GATE * is_ramp * (prev == 2 ? NMR_STICKY : 1.0f);
+            /* Perceptual I/S (metric-blind by design - Zimtohrli penalizes
+             * even sub-mask image error, ears above ~8k do not hear
+             * interaural fine structure): engage on genuinely intensity-
+             * panned HF - high inter-channel correlation, long windows,
+             * sticky - regardless of rate pressure. Freed bits are judged
+             * by the sub-8k spectrum; the image itself is judged by ears. */
+            if (cpe->ch[0].ics.num_windows != 8 && ener0 > FLT_MIN && ener1 > FLT_MIN) {
+                float corr = dot / sqrtf(ener0 * ener1);
+                if (start * freq_mult > NMR_IS_PERC_FREQ &&
+                    fabsf(corr) > NMR_IS_PERC_CORR * (prev == 2 ? 0.9f : 1.0f))
+                    imgate = FFMAX(imgate, NMR_IS_PERC_GATE * (prev == 2 ? NMR_STICKY : 1.0f));
+            }
             float es_d = es_tot, em_d = em_tot;
             if (s->nmr) {
                 float *ees = &s->nmr->sema_es[pi][sidx];
@@ -942,10 +964,14 @@ static void nmr_decide_stereo(AACEncContext *s, ChannelElement *cpe)
                 }
                 es_d = *ees; em_d = *eem;
             }
+            /* Balance gate: M/S has no coding gain on a hard-panned band
+             * (|S| ~ |M|), and M/S quantization noise decorrelates across
+             * the unfold, smearing the panned source into the far channel. */
+            int bal_ok = FFMIN(ener0, ener1) > NMR_MS_BALANCE * FFMAX(ener0, ener1);
             int ms_would = s->options.mid_side &&
                            (s->options.mid_side == 1 ||
-                            es_d < eqgate * em_d ||
-                            es_tot < NMR_MS_MASK  * thr_g);
+                            ((es_d < eqgate * em_d ||
+                              es_tot < NMR_MS_MASK  * thr_g) && bal_ok));
             int ms_ok = ms_would && !decoupled;
             float scale, sr_, imgratio; int p;
             /* I/S competes with M/S above the frequency limit (candidacy must
diff --git a/tests/ref/fate/id3v2-reenc-delete-metadata b/tests/ref/fate/id3v2-reenc-delete-metadata
index 866aea13ad..5da30d724d 100644
--- a/tests/ref/fate/id3v2-reenc-delete-metadata
+++ b/tests/ref/fate/id3v2-reenc-delete-metadata
@@ -1,5 +1,5 @@
-d551ef153999cb3c8a304768dbce9b80 *tests/data/fate/id3v2-reenc-delete-metadata.nut
-2699 tests/data/fate/id3v2-reenc-delete-metadata.nut
+b8a8450e6cca1ac5097a41f081584618 *tests/data/fate/id3v2-reenc-delete-metadata.nut
+2716 tests/data/fate/id3v2-reenc-delete-metadata.nut
 [FORMAT]
 TAG:title=7rk
 [/FORMAT]
diff --git a/tests/ref/fate/id3v2-reenc-delete-metadata-keep b/tests/ref/fate/id3v2-reenc-delete-metadata-keep
index 4c031cad82..a2edc8e1ff 100644
--- a/tests/ref/fate/id3v2-reenc-delete-metadata-keep
+++ b/tests/ref/fate/id3v2-reenc-delete-metadata-keep
@@ -1,5 +1,5 @@
-8000a5d46a648126fc1c2d1b6240b44d *tests/data/fate/id3v2-reenc-delete-metadata-keep.nut
-2839 tests/data/fate/id3v2-reenc-delete-metadata-keep.nut
+dc08127e67c9284a11dde0cefd31177e *tests/data/fate/id3v2-reenc-delete-metadata-keep.nut
+2856 tests/data/fate/id3v2-reenc-delete-metadata-keep.nut
 [FORMAT]
 TAG:title=7rk
 TAG:comment-iTunSMPB-eng= 00000000 00000210 0000086A 0000000000066486 00000000 0002DA9D 00000000 00000000 00000000 00000000 00000000 00000000
diff --git a/tests/ref/fate/id3v2-reenc-delete-metadata-keep-format b/tests/ref/fate/id3v2-reenc-delete-metadata-keep-format
index 40410ca2b6..1b0e6ec891 100644
--- a/tests/ref/fate/id3v2-reenc-delete-metadata-keep-format
+++ b/tests/ref/fate/id3v2-reenc-delete-metadata-keep-format
@@ -1,5 +1,5 @@
-8000a5d46a648126fc1c2d1b6240b44d *tests/data/fate/id3v2-reenc-delete-metadata-keep-format.nut
-2839 tests/data/fate/id3v2-reenc-delete-metadata-keep-format.nut
+dc08127e67c9284a11dde0cefd31177e *tests/data/fate/id3v2-reenc-delete-metadata-keep-format.nut
+2856 tests/data/fate/id3v2-reenc-delete-metadata-keep-format.nut
 [FORMAT]
 TAG:title=7rk
 TAG:comment-iTunSMPB-eng= 00000000 00000210 0000086A 0000000000066486 00000000 0002DA9D 00000000 00000000 00000000 00000000 00000000 00000000
diff --git a/tests/ref/fate/id3v2-reenc-delete-metadata-keep-stream b/tests/ref/fate/id3v2-reenc-delete-metadata-keep-stream
index 29b487cf79..d9988c16b7 100644
--- a/tests/ref/fate/id3v2-reenc-delete-metadata-keep-stream
+++ b/tests/ref/fate/id3v2-reenc-delete-metadata-keep-stream
@@ -1,5 +1,5 @@
-d551ef153999cb3c8a304768dbce9b80 *tests/data/fate/id3v2-reenc-delete-metadata-keep-stream.nut
-2699 tests/data/fate/id3v2-reenc-delete-metadata-keep-stream.nut
+b8a8450e6cca1ac5097a41f081584618 *tests/data/fate/id3v2-reenc-delete-metadata-keep-stream.nut
+2716 tests/data/fate/id3v2-reenc-delete-metadata-keep-stream.nut
 [FORMAT]
 TAG:title=7rk
 [/FORMAT]
diff --git a/tests/ref/fate/id3v2-reenc-delete-metadata-map-metadata b/tests/ref/fate/id3v2-reenc-delete-metadata-map-metadata
index 5658e4d250..2baca102ed 100644
--- a/tests/ref/fate/id3v2-reenc-delete-metadata-map-metadata
+++ b/tests/ref/fate/id3v2-reenc-delete-metadata-map-metadata
@@ -1,5 +1,5 @@
-d551ef153999cb3c8a304768dbce9b80 *tests/data/fate/id3v2-reenc-delete-metadata-map-metadata.nut
-2699 tests/data/fate/id3v2-reenc-delete-metadata-map-metadata.nut
+b8a8450e6cca1ac5097a41f081584618 *tests/data/fate/id3v2-reenc-delete-metadata-map-metadata.nut
+2716 tests/data/fate/id3v2-reenc-delete-metadata-map-metadata.nut
 [FORMAT]
 TAG:title=7rk
 [/FORMAT]