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 dea9202c6el 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.3449e2ccorrigio 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 de3449e2c: 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.
This commit is contained in:
@@ -687,44 +687,51 @@ DecisionToggleEq decidirToggleEq({
|
|||||||
requiereLlamadaNativa: eqDisponible,
|
requiereLlamadaNativa: eqDisponible,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Translates a gain on the app's fixed ±12 dB slider scale to the range the
|
/// Delivers a gain from the app's ±12 dB slider to the device's native
|
||||||
/// device's native equalizer actually reports
|
/// equalizer, clamped by what the device reports it can do
|
||||||
/// (`AndroidEqualizerParameters.min/maxDecibels`, itself derived from
|
/// (`AndroidEqualizerParameters.min/maxDecibels`, itself
|
||||||
/// `Equalizer.getBandLevelRange()`).
|
/// `Equalizer.getBandLevelRange()` in millibels divided by 1000).
|
||||||
///
|
///
|
||||||
/// Top-level and pure so the mapping is testable without a device.
|
/// Top-level and pure so the mapping is testable without a device.
|
||||||
///
|
///
|
||||||
/// THE DEFECT THIS REPLACES, and the likely source of the reported «suena muy
|
/// THE CONTRACT: the decibels the user reads are the decibels the device is
|
||||||
/// alto»: the previous implementation normalised across the whole range and
|
/// asked for. The native range BOUNDS the request; it is not a scale to
|
||||||
/// interpolated linearly,
|
/// normalise into. Both sides are already the same unit — `just_audio`
|
||||||
|
/// documents `setGain` as taking decibels and its Android bridge does
|
||||||
|
/// `setBandLevel(band, round(gain * 1000.0))`, plain dB to millibels with no
|
||||||
|
/// normalisation — so multiplying by the device's headroom was a unit error.
|
||||||
///
|
///
|
||||||
/// minDecibels + ((db + 12) / 24) * (maxDecibels - minDecibels)
|
/// WHY IT MATTERS, in the app's own terms. The slider is hard-coded
|
||||||
|
/// `min: -12.0, max: 12.0`, the label under each band prints
|
||||||
|
/// `'${banda.toStringAsFixed(1)}dB'`, and TalkBack reads `equalizerBandValue`
|
||||||
|
/// = "{value} decibels": one promise, made three ways. Presets are persisted
|
||||||
|
/// and exported as those same raw slider values (`PresetEcualizador.toJson`),
|
||||||
|
/// so scaling at this boundary made an exported backup mean a different SOUND
|
||||||
|
/// on a different phone while displaying identical numbers — and on the
|
||||||
|
/// common asymmetric shape [-12, +19] it multiplied boosts by 1.58 and cuts
|
||||||
|
/// by 1.0, deforming a preset's shape rather than just its depth.
|
||||||
///
|
///
|
||||||
/// which puts 0 dB at the MIDPOINT of the native range. That is only 0 when
|
/// WHAT IS DELIBERATELY KEPT from the mapping this replaces — every invariant
|
||||||
/// the range is symmetric, and Android guarantees no such thing — the
|
/// the «suena muy alto» fix earned. Note the clamp window is widened to
|
||||||
/// Equalizer contract only promises a min/max pair. On a device reporting,
|
/// always contain 0: a naive `db.clamp(minDecibels, maxDecibels)` would, on a
|
||||||
/// say, [-12, +19] dB, every band of a FLAT preset was pushed to +3.5 dB of
|
/// device reporting a wholly positive range such as [+3, +19], turn a FLAT
|
||||||
/// real boost: audibly louder, with the on/off button still reading "off"
|
/// preset's 0 dB into +3 dB of real boost on every band — exactly the bug
|
||||||
/// and nothing in the UI to explain it.
|
/// that was fixed. So 0 dB is always exactly 0, the sign of the user's intent
|
||||||
///
|
/// is never inverted, the result never escapes the native range, a device
|
||||||
/// The contract here instead: 0 dB is always exactly 0, and each side of the
|
/// with no headroom above unity can never boost, and a zero-width range
|
||||||
/// scale is stretched independently against its own end of the native range,
|
/// collapses to 0.
|
||||||
/// so a cut can never become a boost. A range with no headroom on one side
|
|
||||||
/// (or none at all) collapses that side to 0 rather than inverting it.
|
|
||||||
double mapearGananciaNativa(
|
double mapearGananciaNativa(
|
||||||
double db, {
|
double db, {
|
||||||
required double minDecibels,
|
required double minDecibels,
|
||||||
required double maxDecibels,
|
required double maxDecibels,
|
||||||
}) {
|
}) {
|
||||||
final limitado = db.clamp(-12.0, 12.0);
|
final limitado = db.clamp(-12.0, 12.0);
|
||||||
if (limitado == 0) return 0;
|
// The clamp window is the device's range widened to include 0, so that a
|
||||||
if (limitado > 0) {
|
// device reporting no headroom on one side collapses that side to "no
|
||||||
// Only genuine headroom above unity counts as boost.
|
// change" instead of forcing a gain the user never asked for.
|
||||||
final techo = maxDecibels > 0 ? maxDecibels : 0.0;
|
|
||||||
return (limitado / 12.0) * techo;
|
|
||||||
}
|
|
||||||
final suelo = minDecibels < 0 ? minDecibels : 0.0;
|
final suelo = minDecibels < 0 ? minDecibels : 0.0;
|
||||||
return (limitado.abs() / 12.0) * suelo;
|
final techo = maxDecibels > 0 ? maxDecibels : 0.0;
|
||||||
|
return limitado.clamp(suelo, techo);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Advances to the NEXT factory preset after [actual] in [presets] order
|
/// Advances to the NEXT factory preset after [actual] in [presets] order
|
||||||
@@ -2720,10 +2727,12 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
|||||||
try {
|
try {
|
||||||
final params = await _eq.parameters;
|
final params = await _eq.parameters;
|
||||||
_eqDisponible = params.bands.isNotEmpty;
|
_eqDisponible = params.bands.isNotEmpty;
|
||||||
// eq-estado-unico item E: the ONE number that decides whether
|
// eq-estado-unico item E: the ONE number that decides how much of the
|
||||||
// [mapearGananciaNativa] can be silently boosting a FLAT preset on
|
// ±12 dB slider [mapearGananciaNativa] can actually honour on this
|
||||||
// this device. `Equalizer.getBandLevelRange()` is not required to be
|
// device — anything past this range is clamped, so a report of "the
|
||||||
// symmetric, and nothing else in the app can observe what it returned.
|
// slider stops doing anything past N" is answered from this line.
|
||||||
|
// `Equalizer.getBandLevelRange()` is not required to be symmetric, and
|
||||||
|
// nothing else in the app can observe what it returned.
|
||||||
// `debugPrint` (never `dart:developer`'s `log`) so it reaches logcat in
|
// `debugPrint` (never `dart:developer`'s `log`) so it reaches logcat in
|
||||||
// the release build, which is the only one that ever runs in a car:
|
// the release build, which is the only one that ever runs in a car:
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -1,28 +1,43 @@
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:pluriwave/servicios/servicio_audio.dart';
|
import 'package:pluriwave/servicios/servicio_audio.dart';
|
||||||
|
|
||||||
/// eq-estado-unico item E — `mapearGananciaNativa`, the translation from the
|
/// `mapearGananciaNativa` — the hand-off from the app's ±12 dB slider to the
|
||||||
/// app's fixed ±12 dB slider scale to whatever range the device's native
|
/// device's native `Equalizer`, whose capability is reported as
|
||||||
/// `Equalizer.getBandLevelRange()` reports.
|
/// `AndroidEqualizerParameters.min/maxDecibels`
|
||||||
|
/// (`Equalizer.getBandLevelRange()` in millibels, divided by 1000).
|
||||||
///
|
///
|
||||||
/// This is the only source-plausible explanation for the reported «suena muy
|
/// WHY THIS CONTRACT CHANGED — the previous one stretched each side of the
|
||||||
/// alto» half of the bug. The original implementation normalised the input
|
/// slider against its own end of the native range, so `+6` on a device
|
||||||
/// across the WHOLE range and mapped it linearly:
|
/// reporting `[-12, +20]` was delivered as `+10`. Both sides of the mapping
|
||||||
|
/// are already the SAME unit, so that multiplication was a unit error:
|
||||||
///
|
///
|
||||||
/// normalizado = (db.clamp(-12, 12) + 12) / 24
|
/// * `just_audio` documents `setGain` as "Sets the gain for this band in
|
||||||
/// return minDecibels + normalizado * (maxDecibels - minDecibels)
|
/// 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.
|
||||||
///
|
///
|
||||||
/// which sends 0 dB to the MIDPOINT of the native range. That is only 0 when
|
/// So: the number the user reads is the number the device is asked for. The
|
||||||
/// the range happens to be symmetric. Android does not guarantee that: the
|
/// native range only CLAMPS it.
|
||||||
/// AudioEffect Equalizer contract only requires a min/max pair, and real
|
|
||||||
/// devices ship asymmetric ranges. On such a device a FLAT preset — every
|
|
||||||
/// band 0 dB — was silently pushing a positive boost into every band, which
|
|
||||||
/// is audibly louder while the on/off button still reads "off".
|
|
||||||
///
|
///
|
||||||
/// The contract asserted here: 0 dB always maps to exactly 0, and the two
|
/// What this deliberately KEEPS from the previous contract — every invariant
|
||||||
/// sides of the scale are stretched INDEPENDENTLY against their own end of
|
/// the «suena muy alto» fix actually earned. 0 dB is always exactly 0 (a
|
||||||
/// the native range, so the sign of the user's intent is never inverted and
|
/// naive `db.clamp(minDecibels, maxDecibels)` would regress that on a wholly
|
||||||
/// the extremes still reach the device's real limits.
|
/// 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() {
|
void main() {
|
||||||
group('mapearGananciaNativa — 0 dB is always exactly 0', () {
|
group('mapearGananciaNativa — 0 dB is always exactly 0', () {
|
||||||
test('symmetric range (the common case) is unchanged', () {
|
test('symmetric range (the common case) is unchanged', () {
|
||||||
@@ -43,6 +58,10 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('a wholly positive range still cannot boost a FLAT preset', () {
|
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);
|
expect(mapearGananciaNativa(0, minDecibels: 3, maxDecibels: 19), 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -51,36 +70,61 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('mapearGananciaNativa — the extremes reach the native limits', () {
|
group('mapearGananciaNativa — the slider dB reach the device literally', () {
|
||||||
test('+12 dB maps to the native maximum', () {
|
test('+12 dB is delivered as +12 dB, not stretched to the native max', () {
|
||||||
expect(mapearGananciaNativa(12, minDecibels: -12, maxDecibels: 19), 19);
|
// 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 maps to the native minimum', () {
|
test('-12 dB is delivered as -12 dB', () {
|
||||||
expect(mapearGananciaNativa(-12, minDecibels: -12, maxDecibels: 19), -12);
|
expect(mapearGananciaNativa(-12, minDecibels: -12, maxDecibels: 19), -12);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('values beyond the slider scale are clamped, not extrapolated', () {
|
test('values beyond the slider scale clamp to the slider limit', () {
|
||||||
expect(mapearGananciaNativa(40, minDecibels: -15, maxDecibels: 15), 15);
|
// CONTRACT CHANGE: these used to answer the NATIVE extremes (±15).
|
||||||
expect(mapearGananciaNativa(-40, minDecibels: -15, maxDecibels: 15), -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 — each side scales against its own end', () {
|
group('mapearGananciaNativa — the label is the value the device gets', () {
|
||||||
test('half boost is half of the positive headroom', () {
|
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(
|
expect(
|
||||||
mapearGananciaNativa(6, minDecibels: -12, maxDecibels: 20),
|
mapearGananciaNativa(6, minDecibels: -12, maxDecibels: 20),
|
||||||
closeTo(10, 1e-9),
|
closeTo(6, 1e-9),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('half cut is half of the negative headroom', () {
|
test('-6 dB on that same device is -6 dB', () {
|
||||||
expect(
|
expect(
|
||||||
mapearGananciaNativa(-6, minDecibels: -12, maxDecibels: 20),
|
mapearGananciaNativa(-6, minDecibels: -12, maxDecibels: 20),
|
||||||
closeTo(-6, 1e-9),
|
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', () {
|
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]) {
|
for (final db in [-12.0, -6.0, -1.0, 1.0, 6.0, 12.0]) {
|
||||||
final nativo = mapearGananciaNativa(
|
final nativo = mapearGananciaNativa(
|
||||||
@@ -97,12 +141,44 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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', () {
|
group('mapearGananciaNativa — degenerate ranges reported by the device', () {
|
||||||
test('a range with no headroom on one side clamps that side to 0', () {
|
test('a device with no headroom above unity can never boost', () {
|
||||||
// A device that reports max == 0 can only cut. Asking for a boost must
|
|
||||||
// resolve to "no change", never to a negative value.
|
|
||||||
expect(mapearGananciaNativa(12, minDecibels: -15, maxDecibels: 0), 0);
|
expect(mapearGananciaNativa(12, minDecibels: -15, maxDecibels: 0), 0);
|
||||||
expect(mapearGananciaNativa(-12, minDecibels: -15, maxDecibels: 0), -15);
|
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', () {
|
test('a zero-width range collapses everything to 0', () {
|
||||||
|
|||||||
Reference in New Issue
Block a user