Files
pluriwave/test/servicios/servicio_audio_eq_ganancia_test.dart
T
FreeTLab b1bf289e0d
Build & Deploy PluriWave / Análisis de código (push) Successful in 29s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m56s
fix(eq): entregar los decibelios que pide el usuario, sin estirarlos
La app promete decibelios en cuatro sitios y no los entregaba en ninguno. El
slider abarca un +/-12 fijo, escribe el numero con su unidad debajo de cada
banda, y `equalizerBandValue` le dice literalmente "decibelios" a TalkBack. El
modelo documenta las bandas como dB y los presets de fabrica estan escritos en
dB. just_audio documenta `setGain` en decibelios y multiplica por 1000 para
llegar a milibelios sin normalizar nada: `minDecibels`/`maxDecibels` son la
CAPACIDAD del dispositivo, no una escala a la que normalizar.

Pese a eso, la ganancia se estiraba por `maxDecibels/12`. Un +6 dB llegaba como
+10 en un movil de rango ancho.

El estiramiento nunca fue una decision de diseño

Antes de a9202c6 el codigo era `setGain(preset.bandas[i])`, decibelios
literales. Ese commit metio la normalizacion sin docstring, sin test y sin nota
de diseño, y traia un fallo: 0 dB caia en el punto medio del rango, asi que un
preset PLANO realzaba. 3449e2c corrigio exactamente eso y nada mas -- su propio
mensaje dice que el objetivo era "0 dB es siempre 0" -- heredando el
estiramiento sin discutirlo. No hay ADR, spec ni comentario que lo justifique.

Lo que de verdad rompia: la portabilidad

Lo que se persiste y se exporta son los dB del usuario, sin escalar; el escalado
ocurre solo al escribir en el efecto nativo. Asi que el mismo backup suena
distinto en cada telefono, y la interfaz informa de una restauracion perfecta
mientras el audio no lo es. Peor con el rango asimetrico habitual de Android
([-12, +19]): los realces se multiplican por 1.58 y los cortes por 1.0, de modo
que el preset no solo sube de nivel, CAMBIA DE FORMA. Jazz [3, -1, -1.5, 2, 4]
se entregaba como [4.75, -1, -1.5, 3.17, 6.33]. Con presets por dispositivo, el
mismo preset se deformaba distinto en el altavoz y en el Bluetooth.

No es un clamp a secas

`db.clamp(minDecibels, maxDecibels)` habria reintroducido el fallo de 3449e2c:
en un dispositivo que reporte [+3, +19], el cero se convierte en +3 y el preset
plano vuelve a realzar. La ventana se fuerza a contener el cero, asi que se
conservan todas las invariantes ganadas -- 0 siempre es 0, el signo nunca se
invierte, el resultado nunca escapa del rango nativo, un dispositivo sin margen
en un lado no puede realzar por ese lado -- y solo desaparece el estiramiento.

Que se oye distinto: en un movil de +/-12 dB, identico a hoy. En uno de rango
ancho los realces bajan, hasta un 40% menos en dB en uno de +/-20. Los cortes
apenas se mueven, porque la forma habitual es [-12, +N] y el lado negativo ya
iba practicamente 1:1. A cambio, los seis presets de fabrica suenan por fin
igual en cualquier telefono.

Ningun preset guardado necesita migracion: lo almacenado siempre fueron los dB
del usuario.

Suite completa: 1534 pasan, 2 omitidos. flutter analyze mantiene los 5 avisos
preexistentes.
2026-09-06 00:08:09 +02:00

202 lines
8.7 KiB
Dart

import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/servicios/servicio_audio.dart';
/// `mapearGananciaNativa` — the hand-off from the app's ±12 dB slider to the
/// device's native `Equalizer`, whose capability is reported as
/// `AndroidEqualizerParameters.min/maxDecibels`
/// (`Equalizer.getBandLevelRange()` in millibels, divided by 1000).
///
/// WHY THIS CONTRACT CHANGED — the previous one stretched each side of the
/// slider against its own end of the native range, so `+6` on a device
/// reporting `[-12, +20]` was delivered as `+10`. Both sides of the mapping
/// are already the SAME unit, so that multiplication was a unit error:
///
/// * `just_audio` documents `setGain` as "Sets the gain for this band in
/// decibels", and its Android bridge does `setBandLevel(band,
/// round(gain * 1000.0))` — plain dB to millibels, no normalisation.
/// `min/maxDecibels` are the device's absolute CAPABILITY in dB, i.e. a
/// bound on the control, not a scale to normalise into.
/// * The app makes the user a decibel promise in three places at once: the
/// slider is hard-coded `min: -12.0, max: 12.0`, the label under each
/// band prints `'${banda.toStringAsFixed(1)}dB'`, and TalkBack reads out
/// `equalizerBandValue` = "{value} decibels". Stretching made that label
/// a lie on every device whose range is not exactly ±12.
/// * Presets are persisted and EXPORTED as those same raw slider dB
/// (`PresetEcualizador.toJson`), so under the old mapping a backup
/// restored on a wider-range phone showed identical numbers and played
/// louder — and on the common asymmetric shape `[-12, +19]` boosts were
/// multiplied by 1.58 while cuts were not, deforming the preset's SHAPE
/// rather than merely its depth.
///
/// So: the number the user reads is the number the device is asked for. The
/// native range only CLAMPS it.
///
/// What this deliberately KEEPS from the previous contract — every invariant
/// the «suena muy alto» fix actually earned. 0 dB is always exactly 0 (a
/// naive `db.clamp(minDecibels, maxDecibels)` would regress that on a wholly
/// positive reported range, turning a FLAT preset into a boost again), the
/// sign of the user's intent is never inverted, the result never escapes the
/// native range, a device with no headroom above unity can never boost, and a
/// zero-width range collapses to 0.
void main() {
group('mapearGananciaNativa — 0 dB is always exactly 0', () {
test('symmetric range (the common case) is unchanged', () {
expect(
mapearGananciaNativa(0, minDecibels: -15, maxDecibels: 15),
0,
);
});
test('asymmetric range no longer boosts a FLAT preset', () {
// The reported symptom: on a device reporting [-12, +19] the old
// midpoint mapping turned every 0 dB band into +3.5 dB of real boost.
expect(
mapearGananciaNativa(0, minDecibels: -12, maxDecibels: 19),
0,
reason: 'a FLAT preset must be inaudible, on every device',
);
});
test('a wholly positive range still cannot boost a FLAT preset', () {
// This is precisely why the mapping cannot be a plain
// `db.clamp(minDecibels, maxDecibels)`: that would answer +3 here and
// bring the «suena muy alto» bug straight back. The clamp window has
// to be widened so that it always contains 0.
expect(mapearGananciaNativa(0, minDecibels: 3, maxDecibels: 19), 0);
});
test('a wholly negative range still cannot cut a FLAT preset', () {
expect(mapearGananciaNativa(0, minDecibels: -19, maxDecibels: -3), 0);
});
});
group('mapearGananciaNativa — the slider dB reach the device literally', () {
test('+12 dB is delivered as +12 dB, not stretched to the native max', () {
// CONTRACT CHANGE: this used to assert 19, i.e. the whole of the
// device's headroom. The slider says "12.0dB" and the accessibility
// label says "12.0 decibels", so 12 dB is what the device must be
// asked for. The 7 dB of extra hardware headroom is unreachable by
// design until the slider itself is widened and says so.
expect(mapearGananciaNativa(12, minDecibels: -12, maxDecibels: 19), 12);
});
test('-12 dB is delivered as -12 dB', () {
expect(mapearGananciaNativa(-12, minDecibels: -12, maxDecibels: 19), -12);
});
test('values beyond the slider scale clamp to the slider limit', () {
// CONTRACT CHANGE: these used to answer the NATIVE extremes (±15).
// The slider scale is the first bound; the device range is the second.
expect(mapearGananciaNativa(40, minDecibels: -15, maxDecibels: 15), 12);
expect(mapearGananciaNativa(-40, minDecibels: -15, maxDecibels: 15), -12);
});
});
group('mapearGananciaNativa — the label is the value the device gets', () {
test('+6 dB on a wide-range device is +6 dB, never 10', () {
// CONTRACT CHANGE: this used to assert closeTo(10) — "half boost is
// half of the positive headroom". A slider reading "6.0dB" that
// produced +10 dB of real boost is exactly what made a restored backup
// sound different on a different phone.
expect(
mapearGananciaNativa(6, minDecibels: -12, maxDecibels: 20),
closeTo(6, 1e-9),
);
});
test('-6 dB on that same device is -6 dB', () {
expect(
mapearGananciaNativa(-6, minDecibels: -12, maxDecibels: 20),
closeTo(-6, 1e-9),
);
});
test('the six factory presets keep their shape on an asymmetric device', () {
// Jazz, authored in true dB before any scaling existed. Under the old
// mapping [-12, +19] delivered it as [4.75, -1, -1.5, 3.17, 6.33]: a
// different tonal curve, not merely a louder one.
const jazz = [3.0, -1.0, -1.5, 2.0, 4.0];
final entregado = jazz
.map(
(db) =>
mapearGananciaNativa(db, minDecibels: -12, maxDecibels: 19),
)
.toList();
expect(entregado, jazz);
});
test('the sign of the user intent is never inverted', () {
for (final db in [-12.0, -6.0, -1.0, 1.0, 6.0, 12.0]) {
final nativo = mapearGananciaNativa(
db,
minDecibels: -12,
maxDecibels: 19,
);
expect(
nativo.sign,
db.sign,
reason: 'a cut must never become a boost ($db dB -> $nativo)',
);
}
});
});
group('mapearGananciaNativa — a device narrower than the slider', () {
test('a request that fits is still delivered literally', () {
// CONTRACT CHANGE: the old mapping shrank this to (3/12)*6 = 1.5 dB,
// so a modest device silently under-delivered every request too.
expect(mapearGananciaNativa(3, minDecibels: -6, maxDecibels: 6), 3);
expect(mapearGananciaNativa(-3, minDecibels: -6, maxDecibels: 6), -3);
});
test('a request beyond the device range clamps to the device limit', () {
expect(mapearGananciaNativa(12, minDecibels: -6, maxDecibels: 6), 6);
expect(mapearGananciaNativa(-12, minDecibels: -6, maxDecibels: 6), -6);
});
test('a very narrow device still gets a sane, in-range value', () {
for (final db in [-12.0, -5.0, 0.0, 5.0, 12.0]) {
final nativo = mapearGananciaNativa(
db,
minDecibels: -1.5,
maxDecibels: 1.5,
);
expect(nativo, greaterThanOrEqualTo(-1.5));
expect(nativo, lessThanOrEqualTo(1.5));
expect(nativo.sign, db.sign);
}
});
});
group('mapearGananciaNativa — degenerate ranges reported by the device', () {
test('a device with no headroom above unity can never boost', () {
expect(mapearGananciaNativa(12, minDecibels: -15, maxDecibels: 0), 0);
expect(mapearGananciaNativa(6, minDecibels: -15, maxDecibels: 0), 0);
});
test('a cut the device could honour exactly is not over-delivered', () {
// CONTRACT CHANGE: this used to answer -15, spending the device's whole
// range on a request for -12 dB. The user asked for -12; -12 is
// representable here, so -12 is what is sent.
expect(mapearGananciaNativa(-12, minDecibels: -15, maxDecibels: 0), -12);
});
test('a zero-width range collapses everything to 0', () {
expect(mapearGananciaNativa(12, minDecibels: 0, maxDecibels: 0), 0);
expect(mapearGananciaNativa(-12, minDecibels: 0, maxDecibels: 0), 0);
});
test('the result never escapes the native range', () {
for (final db in [-12.0, -3.0, 0.0, 3.0, 12.0]) {
final nativo = mapearGananciaNativa(
db,
minDecibels: -3,
maxDecibels: 19,
);
expect(nativo, greaterThanOrEqualTo(-3));
expect(nativo, lessThanOrEqualTo(19));
}
});
});
}