diff --git a/src/js/control-bar/volume-control/volume-bar.js b/src/js/control-bar/volume-control/volume-bar.js index 4e2a02c3fd..5be7ec3e8b 100644 --- a/src/js/control-bar/volume-control/volume-bar.js +++ b/src/js/control-bar/volume-control/volume-bar.js @@ -6,6 +6,7 @@ import Component from '../../component.js'; import * as Dom from '../../utils/dom.js'; import {clamp} from '../../utils/num.js'; import {IS_IOS, IS_ANDROID} from '../../utils/browser.js'; +import {LinearVolumeTransfer, LogarithmicVolumeTransfer} from '../../utils/volume-transfer.js'; /** @import Player from '../../player' */ @@ -31,6 +32,7 @@ class VolumeBar extends Slider { */ constructor(player, options) { super(player, options); + this.initVolumeTransfer_(); this.on('slideractive', (e) => this.updateLastVolume_(e)); this.on(player, 'volumechange', (e) => this.updateARIAAttributes(e)); player.ready(() => this.updateARIAAttributes()); @@ -98,7 +100,11 @@ class VolumeBar extends Slider { } this.checkMuted(); - this.player_.volume(this.calculateDistance(event)); + + const sliderPosition = this.calculateDistance(event); + const linearVolume = this.volumeTransfer_.sliderToVolume(sliderPosition); + + this.player_.volume(linearVolume); } /** @@ -120,7 +126,10 @@ class VolumeBar extends Slider { if (this.player_.muted()) { return 0; } - return this.player_.volume(); + + const linearVolume = this.player_.volume(); + + return this.volumeTransfer_.volumeToSlider(linearVolume); } /** @@ -128,7 +137,14 @@ class VolumeBar extends Slider { */ stepForward() { this.checkMuted(); - this.player_.volume(this.player_.volume() + 0.1); + + const currentSlider = this.getPercent(); + + const newSlider = Math.min(currentSlider + 0.1, 1); + + const linearVolume = this.volumeTransfer_.sliderToVolume(newSlider); + + this.player_.volume(linearVolume); } /** @@ -136,7 +152,19 @@ class VolumeBar extends Slider { */ stepBack() { this.checkMuted(); - this.player_.volume(this.player_.volume() - 0.1); + + const currentSlider = this.getPercent(); + + const newSlider = Math.max(currentSlider - 0.1, 0); + + if (newSlider < 0.05) { + this.player_.volume(0); + return; + } + + const linearVolume = this.volumeTransfer_.sliderToVolume(newSlider); + + this.player_.volume(linearVolume); } /** @@ -181,6 +209,20 @@ class VolumeBar extends Slider { }); } + /** + * Initialize the volume transfer function + * + * @private + */ + initVolumeTransfer_() { + if (this.player_.options_.logarithmicVolume) { + const dbRange = this.player_.options_.logarithmicVolumeRange; + + this.volumeTransfer_ = new LogarithmicVolumeTransfer(dbRange); + } else { + this.volumeTransfer_ = new LinearVolumeTransfer(); + } + } } /** diff --git a/src/js/utils/volume-transfer.js b/src/js/utils/volume-transfer.js new file mode 100644 index 0000000000..b56779d1df --- /dev/null +++ b/src/js/utils/volume-transfer.js @@ -0,0 +1,127 @@ +/** + * Base class for volume transfer functions. + * + * Volume transfer functions convert between slider position (UI space) and + * player volume (audio space). This allows different scaling behaviors like + * linear or logarithmic (decibel-based) volume control. + */ +class VolumeTransfer { + /** + * Convert slider position to player volume. + * + * @param {number} sliderPosition - Slider position from 0-1 + * @return {number} Player volume from 0-1 + */ + sliderToVolume(sliderPosition) { + throw new Error('Must be implemented by subclass'); + } + + /** + * Convert player volume to slider position. + * + * @param {number} volume - Player volume from 0-1 + * @return {number} Slider position from 0-1 + */ + volumeToSlider(volume) { + throw new Error('Must be implemented by subclass'); + } +} + +/** + * Linear volume transfer - direct 1:1 mapping between slider and volume. + * + * This is the default behavior where moving the slider linearly adjusts + * the volume linearly. Simple but may not match human perception of loudness. + */ +class LinearVolumeTransfer extends VolumeTransfer { + /** + * Convert slider position to player volume (1:1 mapping). + * + * @param {number} sliderPosition - Slider position from 0-1 + * @return {number} Player volume from 0-1 + */ + sliderToVolume(sliderPosition) { + return sliderPosition; + } + + /** + * Convert player volume to slider position (1:1 mapping). + * + * @param {number} volume - Player volume from 0-1 + * @return {number} Slider position from 0-1 + */ + volumeToSlider(volume) { + return volume; + } +} + +/** + * Logarithmic volume transfer using decibel scaling. + * + * Provides exponential volume changes as the slider moves linearly, which + * better matches human perception of loudness. Uses decibel (dB) scaling + * where volume = 10^(dB/20). + */ +class LogarithmicVolumeTransfer extends VolumeTransfer { + /** + * Creates a logarithmic volume transfer function. + * + * @param {number} [dbRange=50] - The decibel range for the transfer function. + * Larger values create a more dramatic curve. Typical range: 40-60 dB. + */ + constructor(dbRange = 50) { + super(); + this.dbRange = dbRange; + this.offset = Math.pow(10, -dbRange / 20); + } + + /** + * Convert slider position to player volume using logarithmic scaling. + * + * Applies exponential scaling so that linear slider movement produces + * logarithmic volume changes, matching human loudness perception. + * + * @param {number} sliderPosition - Slider position from 0-1 + * @return {number} Player volume from 0-1 + */ + sliderToVolume(sliderPosition) { + if (sliderPosition <= 0) { + return 0; + } + + if (sliderPosition >= 1) { + return 1; + } + + const dB = sliderPosition * this.dbRange - this.dbRange; + + return Math.pow(10, dB / 20) * (1 + this.offset); + } + + /** + * Convert player volume to slider position using logarithmic scaling. + * + * Inverse of sliderToVolume - converts linear volume back to the + * corresponding logarithmic slider position. + * + * @param {number} volume - Player volume from 0-1 + * @return {number} Slider position from 0-1 + */ + volumeToSlider(volume) { + if (volume <= 0) { + return 0; + } + + if (volume >= 1) { + return 1; + } + + const dB = 20 * Math.log10(volume); + const position = (dB + this.dbRange) / this.dbRange; + + return position; + } +} + +export default VolumeTransfer; +export { LinearVolumeTransfer, LogarithmicVolumeTransfer }; diff --git a/test/unit/controls.test.js b/test/unit/controls.test.js index e11d5409de..ab840c2d2e 100644 --- a/test/unit/controls.test.js +++ b/test/unit/controls.test.js @@ -791,3 +791,272 @@ QUnit.module('SmartTV UI Updates (Progress Bar & Time Display)', function(hooks) userSeekSpy.restore(); }); }); + +QUnit.test('VolumeBar initializes with LinearVolumeTransfer by default', function(assert) { + const player = TestHelpers.makePlayer(); + const volumeBar = player.controlBar.volumePanel.volumeControl.volumeBar; + + assert.ok(volumeBar.volumeTransfer_, 'volumeTransfer_ should be initialized'); + assert.equal( + volumeBar.volumeTransfer_.constructor.name, 'LinearVolumeTransfer', + 'should use LinearVolumeTransfer by default' + ); + + player.dispose(); +}); + +QUnit.test('VolumeBar initializes with LogarithmicVolumeTransfer when logarithmicVolume is true', function(assert) { + const player = TestHelpers.makePlayer({ + logarithmicVolume: true + }); + const volumeBar = player.controlBar.volumePanel.volumeControl.volumeBar; + + assert.ok(volumeBar.volumeTransfer_, 'volumeTransfer_ should be initialized'); + assert.equal( + volumeBar.volumeTransfer_.constructor.name, 'LogarithmicVolumeTransfer', + 'should use LogarithmicVolumeTransfer when logarithmicVolume is true' + ); + + player.dispose(); +}); + +QUnit.test('VolumeBar getPercent() uses volume transfer function with logarithmic mode', function(assert) { + const player = TestHelpers.makePlayer({ + logarithmicVolume: true + }); + const volumeBar = player.controlBar.volumePanel.volumeControl.volumeBar; + + player.volume(0); + assert.equal(volumeBar.getPercent(), 0, 'should return 0 for volume 0'); + + player.volume(1); + assert.equal(volumeBar.getPercent(), 1, 'should return 1 for volume 1'); + + player.volume(0.5); + const percent = volumeBar.getPercent(); + + assert.ok(percent > 0.5 && percent < 1, 'should return non-linear value for volume 0.5'); + + player.dispose(); +}); + +QUnit.test('VolumeBar handleMouseMove uses volume transfer with logarithmic mode', function(assert) { + const player = TestHelpers.makePlayer({ + logarithmicVolume: true + }); + const volumeBar = player.controlBar.volumePanel.volumeControl.volumeBar; + + const originalCalc = volumeBar.calculateDistance; + + volumeBar.calculateDistance = function() { + return 0.5; + }; + + volumeBar.handleMouseMove({ pageX: 100, pageY: 100 }); + + const volume = player.volume(); + + assert.ok(volume > 0 && volume < 0.5, 'logarithmic mode should set low volume for 50% position'); + + volumeBar.calculateDistance = originalCalc; + player.dispose(); +}); + +QUnit.test('VolumeBar stepForward increases volume with logarithmic transfer', function(assert) { + const player = TestHelpers.makePlayer({ + logarithmicVolume: true + }); + const volumeBar = player.controlBar.volumePanel.volumeControl.volumeBar; + + player.volume(0.5); + const initialVolume = player.volume(); + + volumeBar.stepForward(); + + assert.ok(player.volume() > initialVolume, 'should increase volume'); + assert.ok(player.volume() <= 1, 'should not exceed max volume'); + + player.dispose(); +}); + +QUnit.test('VolumeBar stepBack decreases volume with logarithmic transfer', function(assert) { + const player = TestHelpers.makePlayer({ + logarithmicVolume: true + }); + const volumeBar = player.controlBar.volumePanel.volumeControl.volumeBar; + + player.volume(0.5); + const initialVolume = player.volume(); + + volumeBar.stepBack(); + + assert.ok(player.volume() < initialVolume, 'should decrease volume'); + assert.ok(player.volume() >= 0, 'should not go below min volume'); + + player.dispose(); +}); + +QUnit.test('VolumeBar passes logarithmicVolumeRange option to LogarithmicVolumeTransfer', function(assert) { + const customRange = 60; + const player = TestHelpers.makePlayer({ + logarithmicVolume: true, + logarithmicVolumeRange: customRange + }); + + const volumeBar = player.controlBar.volumePanel.volumeControl.volumeBar; + + assert.equal( + volumeBar.volumeTransfer_.dbRange, customRange, + 'should use custom logarithmicVolumeRange value' + ); + + player.dispose(); +}); + +QUnit.test('VolumeBar getPercent() returns correct values with linear transfer', function(assert) { + const player = TestHelpers.makePlayer(); + + const volumeBar = player.controlBar.volumePanel.volumeControl.volumeBar; + + player.volume(0); + assert.equal(volumeBar.getPercent(), 0, 'should return 0 for volume 0'); + + player.volume(0.5); + assert.equal(volumeBar.getPercent(), 0.5, 'should return 0.5 for volume 0.5'); + + player.volume(1); + assert.equal(volumeBar.getPercent(), 1, 'should return 1 for volume 1'); + + player.dispose(); +}); + +QUnit.test('VolumeBar handleMouseMove() sets correct volume with linear transfer', function(assert) { + const player = TestHelpers.makePlayer(); + + const volumeBar = player.controlBar.volumePanel.volumeControl.volumeBar; + + const originalCalculateDistance = volumeBar.calculateDistance; + + volumeBar.calculateDistance = function() { + return 0.5; + }; + + const event = { + pageX: 100, + pageY: 100 + }; + + volumeBar.handleMouseMove(event); + + assert.equal(player.volume(), 0.5, 'should set volume to 0.5 for 50% position with linear transfer'); + + volumeBar.calculateDistance = originalCalculateDistance; + player.dispose(); +}); + +QUnit.test('VolumeBar handleMouseMove() sets correct volume with logarithmic transfer', function(assert) { + const player = TestHelpers.makePlayer({ + logarithmicVolume: true + }); + + const volumeBar = player.controlBar.volumePanel.volumeControl.volumeBar; + + const originalCalculateDistance = volumeBar.calculateDistance; + + volumeBar.calculateDistance = function() { + return 0.5; + }; + + const event = { + pageX: 100, + pageY: 100 + }; + + volumeBar.handleMouseMove(event); + + const volume = player.volume(); + + assert.ok(volume < 0.5, 'logarithmic transfer should set volume < 0.5 for 50% slider position'); + assert.ok(volume > 0, 'volume should be greater than 0'); + + volumeBar.calculateDistance = originalCalculateDistance; + player.dispose(); +}); + +QUnit.test('VolumeBar stepForward() increases volume correctly with linear transfer', function(assert) { + const player = TestHelpers.makePlayer(); + + const volumeBar = player.controlBar.volumePanel.volumeControl.volumeBar; + + player.volume(0.5); + volumeBar.stepForward(); + + assert.ok(player.volume() > 0.5, 'should increase volume'); + assert.ok(player.volume() <= 1, 'should not exceed max volume'); + + player.dispose(); +}); + +QUnit.test('VolumeBar stepForward() increases volume correctly with logarithmic transfer', function(assert) { + const player = TestHelpers.makePlayer({ + logarithmicVolume: true + }); + + const volumeBar = player.controlBar.volumePanel.volumeControl.volumeBar; + + const initialVolume = 0.5; + + player.volume(initialVolume); + volumeBar.stepForward(); + + assert.ok(player.volume() > initialVolume, 'should increase volume'); + assert.ok(player.volume() <= 1, 'should not exceed max volume'); + + player.dispose(); +}); + +QUnit.test('VolumeBar stepBack() decreases volume correctly with linear transfer', function(assert) { + const player = TestHelpers.makePlayer(); + + const volumeBar = player.controlBar.volumePanel.volumeControl.volumeBar; + + player.volume(0.5); + volumeBar.stepBack(); + + assert.ok(player.volume() < 0.5, 'should decrease volume'); + assert.ok(player.volume() >= 0, 'should not go below min volume'); + + player.dispose(); +}); + +QUnit.test('VolumeBar stepBack() decreases volume correctly with logarithmic transfer', function(assert) { + const player = TestHelpers.makePlayer({ + logarithmicVolume: true + }); + + const volumeBar = player.controlBar.volumePanel.volumeControl.volumeBar; + + const initialVolume = 0.5; + + player.volume(initialVolume); + volumeBar.stepBack(); + + assert.ok(player.volume() < initialVolume, 'should decrease volume'); + assert.ok(player.volume() >= 0, 'should not go below min volume'); + + player.dispose(); +}); + +QUnit.test('VolumeBar stepBack() sets volume to 0 when slider would go below threshold', function(assert) { + const player = TestHelpers.makePlayer(); + + const volumeBar = player.controlBar.volumePanel.volumeControl.volumeBar; + + player.volume(0.02); + + volumeBar.stepBack(); + + assert.equal(player.volume(), 0, 'volume is set to 0 when reduced slider is below threshold'); + + player.dispose(); +}); diff --git a/test/unit/tech/volume-transfer.test.js b/test/unit/tech/volume-transfer.test.js new file mode 100644 index 0000000000..d9b02098c0 --- /dev/null +++ b/test/unit/tech/volume-transfer.test.js @@ -0,0 +1,145 @@ +/* eslint-env qunit */ +import VolumeTransfer, { + LinearVolumeTransfer, + LogarithmicVolumeTransfer +} from '../../../src/js/utils/volume-transfer.js'; + +import QUnit from 'qunit'; + +QUnit.module('VolumeTransfer'); + +QUnit.test('VolumeTransfer base class throws errors', function(assert) { + const transfer = new VolumeTransfer(); + + assert.throws( + () => transfer.sliderToVolume(0.5), + /Must be implemented by subclass/, + 'sliderToVolume throws error on base class' + ); + + assert.throws( + () => transfer.volumeToSlider(0.5), + /Must be implemented by subclass/, + 'volumeToSlider throws error on base class' + ); +}); + +QUnit.test('LinearVolumeTransfer is identity function', function(assert) { + const transfer = new LinearVolumeTransfer(); + + [0, 0.1, 0.25, 0.5, 0.75, 0.9, 1.0].forEach(value => { + assert.strictEqual( + transfer.sliderToVolume(value), + value, + `sliderToVolume(${value}) returns ${value}` + ); + + assert.strictEqual( + transfer.volumeToSlider(value), + value, + `volumeToSlider(${value}) returns ${value}` + ); + }); +}); + +QUnit.test('LogarithmicVolumeTransfer constructor sets dbRange and offset', function(assert) { + const transfer1 = new LogarithmicVolumeTransfer(); + + assert.strictEqual(transfer1.dbRange, 50, 'default dbRange is 50'); + assert.ok(transfer1.offset > 0, 'offset is calculated and > 0'); + + const transfer2 = new LogarithmicVolumeTransfer(60); + + assert.strictEqual(transfer2.dbRange, 60, 'custom dbRange is set'); +}); + +QUnit.test('LogarithmicVolumeTransfer passes through (0,0)', function(assert) { + const transfer = new LogarithmicVolumeTransfer(50); + + assert.strictEqual( + transfer.sliderToVolume(0), + 0, + 'sliderToVolume(0) returns exactly 0' + ); + + assert.strictEqual( + transfer.volumeToSlider(0), + 0, + 'volumeToSlider(0) returns exactly 0' + ); +}); + +QUnit.test('LogarithmicVolumeTransfer passes through (1,1)', function(assert) { + const transfer = new LogarithmicVolumeTransfer(50); + + assert.strictEqual( + transfer.sliderToVolume(1), + 1, + 'sliderToVolume(1) returns exactly 1' + ); + + assert.strictEqual( + transfer.volumeToSlider(1), + 1, + 'volumeToSlider(1) returns exactly 1' + ); +}); + +QUnit.test('LogarithmicVolumeTransfer is invertible', function(assert) { + const transfer = new LogarithmicVolumeTransfer(50); + const tolerance = 0.001; + + [0, 0.1, 0.25, 0.5, 0.75, 0.9, 1.0].forEach(slider => { + const linear = transfer.sliderToVolume(slider); + const back = transfer.volumeToSlider(linear); + const diff = Math.abs(back - slider); + + assert.true( + diff < tolerance, + `Round trip for ${slider}: ${slider} -> ${linear.toFixed(4)} -> ${back.toFixed(4)} (diff: ${diff.toExponential(2)})` + ); + }); +}); + +QUnit.test('LogarithmicVolumeTransfer with different dbRanges', function(assert) { + const transfer40 = new LogarithmicVolumeTransfer(40); + const transfer50 = new LogarithmicVolumeTransfer(50); + const transfer60 = new LogarithmicVolumeTransfer(60); + + const linear40 = transfer40.sliderToVolume(0.5); + const linear50 = transfer50.sliderToVolume(0.5); + const linear60 = transfer60.sliderToVolume(0.5); + + assert.ok( + linear40 > linear50 && linear50 > linear60, + `Higher dbRange gives more control at low volumes: ${linear40.toFixed(4)} > ${linear50.toFixed(4)} > ${linear60.toFixed(4)}` + ); +}); + +QUnit.test('LogarithmicVolumeTransfer handles edge cases', function(assert) { + const transfer = new LogarithmicVolumeTransfer(50); + + assert.strictEqual( + transfer.sliderToVolume(-0.1), + 0, + 'Negative slider values return 0' + ); + + assert.strictEqual( + transfer.sliderToVolume(1.1), + 1, + 'Slider values > 1 return 1' + ); + + assert.strictEqual( + transfer.volumeToSlider(-0.1), + 0, + 'Negative linear values return 0' + ); + + assert.strictEqual( + transfer.volumeToSlider(1.1), + 1, + 'Linear values > 1 return 1' + ); +});