From c197e26ff42d30aa82ce7c7f9dbbe480dcc8a822 Mon Sep 17 00:00:00 2001 From: gdwengf Date: Sat, 24 Oct 2026 00:29:17 +0800 Subject: [PATCH] feat(player): volume boost above 100% through the PCM gain stage Browser media elements cap volume at 1, but live channels here carry MP2 audio that the page cannot decode natively: it goes through the PCM/WebAudio path, which already owns a GainNode feeding the speakers. Loudness above unity therefore only needs gain on that node plus a limiter, because the extra gain would otherwise clip and clipping is audible as crackle on speech. The PCM player gains a boost factor composed with volume (1x..6x, +15.6dB at the ceiling) and a limiter after the gain stage (threshold -6dB, ratio 12:1, 3ms attack) so peaks are compressed instead of clipped. The level is held by the playback controller rather than the player, because the player is created lazily and rebuilt on every audio recovery. Both volume controls drive the same two ranges: the lower half of the slider is 0..100% element volume, the upper half is the gain multiplier up to 6x, and the on-picture vertical gesture uses the identical mapping so it can continue past 100% too. A readout shows the applied level (e.g. 250%) while dragging, and the gesture indicator shows the same percentage with the bar split 50/50 between the two ranges. --- .../src/components/player/player-controls.tsx | 49 +++++++++++++++++-- .../player/player-gesture-overlay.tsx | 16 +++--- web-ui/src/components/player/video-player.tsx | 27 +++++++++- web-ui/src/hooks/use-player-touch-gestures.ts | 37 +++++++++++--- web-ui/src/lib/player-storage.ts | 1 + .../playback-engine/audio/pcm-audio-player.ts | 43 +++++++++++++--- .../backends/mse-playback-backend.ts | 3 ++ .../mse/playback-controller.ts | 9 ++++ web-ui/src/playback-engine/types.ts | 3 ++ 9 files changed, 164 insertions(+), 24 deletions(-) diff --git a/web-ui/src/components/player/player-controls.tsx b/web-ui/src/components/player/player-controls.tsx index 187a10e3..f4f12f28 100644 --- a/web-ui/src/components/player/player-controls.tsx +++ b/web-ui/src/components/player/player-controls.tsx @@ -52,6 +52,9 @@ interface PlayerControlsProps { onPlayPause: () => void; volume: number; onVolumeChange: (volume: number) => void; + /** Extra gain above unity, 1 = 100% (up to 3). */ + volumeBoost: number; + onVolumeBoostChange: (boost: number) => void; /** False where the platform makes volume read-only (iOS): the slider is hidden, mute stays. */ canControlVolume: boolean; isMuted: boolean; @@ -405,6 +408,8 @@ function PlayerControlsComponent({ isPlaying, onPlayPause, volume, + volumeBoost, + onVolumeBoostChange, onVolumeChange, canControlVolume, isMuted, @@ -422,6 +427,39 @@ function PlayerControlsComponent({ }: PlayerControlsProps) { const t = usePlayerTranslation(locale); const isEffectivelyMuted = isMuted || volume <= 0; + + // One slider covers both ranges: the lower half is 0..100% element volume, the + // upper half is gain above unity up to 6x (+15.6dB) through the PCM gain stage. + const VOLUME_BOOST_MAX = 6; + const sliderValue = isMuted + ? 0 + : volume < 1 + ? volume * 0.5 + : 0.5 + ((Math.max(1, volumeBoost) - 1) / (VOLUME_BOOST_MAX - 1)) * 0.5; + // Readout while dragging: element volume below unity, the gain multiplier + // above it, so "150%" means 1.5x instead of a level the element cannot hold. + const [volumeHint, setVolumeHint] = useState(null); + const volumeHintTimerRef = useRef | null>(null); + const showVolumeHint = (percent: number) => { + setVolumeHint(percent); + if (volumeHintTimerRef.current) clearTimeout(volumeHintTimerRef.current); + volumeHintTimerRef.current = setTimeout(() => setVolumeHint(null), 1200); + }; + const handleSliderChange = (pos: number) => { + if (isMuted && pos > 0) { + onMuteToggle(); + } + if (pos <= 0.5) { + if (volumeBoost !== 1) onVolumeBoostChange(1); + onVolumeChange(Math.min(1, pos / 0.5)); + showVolumeHint(Math.round((pos / 0.5) * 100)); + } else { + if (volume < 1) onVolumeChange(1); + const boost = 1 + ((pos - 0.5) / 0.5) * (VOLUME_BOOST_MAX - 1); + onVolumeBoostChange(Math.round(boost * 10) / 10); + showVolumeHint(Math.round(boost * 100)); + } + }; const isCatchupSupported = channel.sources.some((s) => s.catchup && s.catchupSource); const hasEpgTimeline = epgPrograms.length > 0; const hasTimeline = isCatchupSupported || Boolean(currentProgram) || hasEpgTimeline; @@ -486,6 +524,11 @@ function PlayerControlsComponent({ {/* Volume */}
+ {volumeHint !== null && ( +
+ {volumeHint}% +
+ )}
diff --git a/web-ui/src/components/player/player-gesture-overlay.tsx b/web-ui/src/components/player/player-gesture-overlay.tsx index 8d0ea664..3a800a65 100644 --- a/web-ui/src/components/player/player-gesture-overlay.tsx +++ b/web-ui/src/components/player/player-gesture-overlay.tsx @@ -22,13 +22,17 @@ function formatSeekDelta(deltaSeconds: number): string { return `${sign}${minutes}:${String(seconds).padStart(2, "0")}`; } -function VolumeIndicator({ volume }: { volume: number }) { - const percent = Math.round(volume * 100); +const VOLUME_LEVEL_MAX = 6; + +function VolumeIndicator({ level }: { level: number }) { + const percent = Math.round(level * 100); + // Half the bar is 0..100%, the other half the gain range above it. + const fill = level <= 1 ? level * 50 : 50 + ((level - 1) / (VOLUME_LEVEL_MAX - 1)) * 50; return ( <> - {volume <= 0 ? ( + {level <= 0 ? ( - ) : volume < 0.5 ? ( + ) : level < 0.5 ? ( ) : ( @@ -36,7 +40,7 @@ function VolumeIndicator({ volume }: { volume: number }) {
@@ -109,7 +113,7 @@ export function PlayerGestureIndicatorOverlay({ >
- {shown?.kind === "volume" && } + {shown?.kind === "volume" && } {shown?.kind === "channel" && ( (null); const [volume, setVolume] = useState(() => getVolume()); + const [volumeBoost, setVolumeBoost] = useState(() => getVolumeBoost()); const [isMuted, setIsMuted] = useState(() => getMuted()); const [isPlaying, setIsPlaying] = useState(false); const [liveSessionAnchor, setLiveSessionAnchor] = useState(null); @@ -842,6 +850,7 @@ function VideoPlayerComponent({ pictureEnhancement, }); p.setVolume(volume); + p.setVolumeBoost?.(volumeBoost); p.setMuted(isMuted); p.on("error", (e) => { if (slotPlayerRef(slotId).current === p) { @@ -1508,6 +1517,18 @@ function VideoPlayerComponent({ }; }, [isDocumentPiP]); + const handleVolumeBoostChange = useEffectEvent((level: number) => { + setVolumeBoost(level); + saveVolumeBoost(level); + getActivePlayer()?.setVolumeBoost?.(level); + // The hidden slot carries the same programme and becomes audible on switch, + // so it has to follow the same level. + for (const sid of ["a", "b"] as SlotId[]) { + const player = slotPlayerRef(sid).current; + if (player) player.setVolumeBoost?.(level); + } + }); + const handleVolumeChange = useEffectEvent((newVolume: number) => { const player = getActivePlayer(); if (player) { @@ -1527,10 +1548,12 @@ function VideoPlayerComponent({ enableSeekGesture: isCatchupSupported, enableVolumeGesture: canControlVolume, volume, + volumeBoost, isMuted, prevChannel, nextChannel, onVolumeChange: handleVolumeChange, + onVolumeBoostChange: handleVolumeBoostChange, onChannelNavigate, onRelativeSeek: handleRelativeSeek, onTogglePlayPause: togglePlayPause, @@ -2015,6 +2038,8 @@ function VideoPlayerComponent({ onPlayPause={togglePlayPause} volume={volume} onVolumeChange={handleVolumeChange} + volumeBoost={volumeBoost} + onVolumeBoostChange={handleVolumeBoostChange} canControlVolume={canControlVolume} isMuted={isMuted} onMuteToggle={handleMuteToggle} diff --git a/web-ui/src/hooks/use-player-touch-gestures.ts b/web-ui/src/hooks/use-player-touch-gestures.ts index a4afc144..41d98644 100644 --- a/web-ui/src/hooks/use-player-touch-gestures.ts +++ b/web-ui/src/hooks/use-player-touch-gestures.ts @@ -14,8 +14,15 @@ const ACTIVATION_THRESHOLD_PX = 12; const CHANNEL_COMMIT_RATIO = 0.15; const CHANNEL_COMMIT_MIN_PX = 48; const CHANNEL_COMMIT_MAX_PX = 120; -/** Vertical travel that spans the whole 0..1 volume range, as a ratio of the surface height. */ +/** Vertical travel that spans the whole volume gesture range, as a ratio of the surface height. */ const VOLUME_FULL_SWING_RATIO = 0.6; +/** Highest level the gesture reaches: 0..1 is element volume, above it gain. */ +const VOLUME_LEVEL_MAX = 6; +/** Travel mapping shared with the control-bar slider: lower half 0..100%, upper half gain. */ +const levelToPos = (level: number): number => + level <= 1 ? level * 0.5 : 0.5 + ((level - 1) / (VOLUME_LEVEL_MAX - 1)) * 0.5; +const posToLevel = (pos: number): number => + pos <= 0.5 ? pos * 2 : 1 + ((pos - 0.5) / 0.5) * (VOLUME_LEVEL_MAX - 1); /** Seconds seeked when dragging across the full surface width. */ const SEEK_FULL_SWING_SECONDS = 120; /** Ignore sub-second seeks so a sloppy tap-drag does not nudge playback. */ @@ -26,7 +33,7 @@ const DOUBLE_TAP_SLOP_PX = 40; const INDICATOR_LINGER_MS = 700; export type PlayerGestureIndicator = - | { kind: "volume"; volume: number } + | { kind: "volume"; level: number } | { kind: "channel"; direction: "prev" | "next"; target: Channel | null } | { kind: "seek"; deltaSeconds: number }; @@ -43,7 +50,8 @@ interface GestureState { startedOnLeftHalf: boolean; width: number; height: number; - startVolume: number; + /** Slider position the drag started from (see levelToPos). */ + startVolumePos: number; /** Direction armed by the channel gesture, or null while below the commit threshold. */ channelDirection: "prev" | "next" | null; seekDeltaSeconds: number; @@ -57,10 +65,13 @@ interface UsePlayerTouchGesturesOptions { /** Right-half volume gesture; off where the platform makes volume read-only (iOS). */ enableVolumeGesture: boolean; volume: number; + /** Gain above unity, 1 = 100%. The volume gesture continues into this range. */ + volumeBoost: number; isMuted: boolean; prevChannel: Channel | null; nextChannel: Channel | null; onVolumeChange: (volume: number) => void; + onVolumeBoostChange: (boost: number) => void; onChannelNavigate: ((target: "prev" | "next") => void) | undefined; onRelativeSeek: (deltaSeconds: number) => void; onTogglePlayPause: () => void; @@ -85,10 +96,12 @@ export function usePlayerTouchGestures({ enableSeekGesture, enableVolumeGesture, volume, + volumeBoost, isMuted, prevChannel, nextChannel, onVolumeChange, + onVolumeBoostChange, onChannelNavigate, onRelativeSeek, onTogglePlayPause, @@ -180,7 +193,7 @@ export function usePlayerTouchGestures({ startedOnLeftHalf: event.clientX - rect.left < rect.width / 2, width: rect.width, height: rect.height, - startVolume: isMuted ? 0 : volume, + startVolumePos: levelToPos(isMuted ? 0 : volume < 1 ? volume : volumeBoost), channelDirection: null, seekDeltaSeconds: 0, }; @@ -209,10 +222,18 @@ export function usePlayerTouchGestures({ } if (gesture.mode === "volume") { - // Up is louder, hence the negated dy. - const nextVolume = clamp01(gesture.startVolume - dy / (gesture.height * VOLUME_FULL_SWING_RATIO)); - onVolumeChange(nextVolume); - showIndicator({ kind: "volume", volume: nextVolume }); + // Up is louder, hence the negated dy. Past the midpoint the same drag drives + // the gain stage, since element volume cannot exceed 1. + const nextPos = clamp01(gesture.startVolumePos - dy / (gesture.height * VOLUME_FULL_SWING_RATIO)); + const level = posToLevel(nextPos); + if (level <= 1) { + if (volumeBoost !== 1) onVolumeBoostChange(1); + onVolumeChange(level); + } else { + if (volume < 1) onVolumeChange(1); + onVolumeBoostChange(Math.round(level * 10) / 10); + } + showIndicator({ kind: "volume", level }); return; } diff --git a/web-ui/src/lib/player-storage.ts b/web-ui/src/lib/player-storage.ts index 0483bd7e..f2545fef 100644 --- a/web-ui/src/lib/player-storage.ts +++ b/web-ui/src/lib/player-storage.ts @@ -56,6 +56,7 @@ export const [getPictureEnhancement, savePictureEnhancement] = createStore("rtp2 ); export const [getVolume, saveVolume] = createStore("rtp2httpd-player-volume", 1); export const [getMuted, saveMuted] = createStore("rtp2httpd-player-muted", false); +export const [getVolumeBoost, saveVolumeBoost] = createStore("rtp2httpd-player-volume-boost", 1); // Per-channel source index uses a JSON object map, so it needs custom logic const [getSourceIndexMap, saveSourceIndexMap] = createStore>( diff --git a/web-ui/src/playback-engine/audio/pcm-audio-player.ts b/web-ui/src/playback-engine/audio/pcm-audio-player.ts index e9e72d03..dee5a5da 100644 --- a/web-ui/src/playback-engine/audio/pcm-audio-player.ts +++ b/web-ui/src/playback-engine/audio/pcm-audio-player.ts @@ -129,6 +129,9 @@ export class PCMAudioPlayer { private config: PlayerConfig; private context: AudioContext | null = null; private gainNode: GainNode | null = null; + private limiterNode: DynamicsCompressorNode | null = null; + /** Extra gain above unity, 1 = 100%. */ + private boost = 1; private volume: number = 1.0; private muted: boolean = false; @@ -222,10 +225,20 @@ export class PCMAudioPlayer { this.context = new AudioContext(); this.gainNode = this.context.createGain(); + // Limiter after the gain stage: anything above unity would clip, and clipping + // is audible as crackle on speech, so peaks are compressed instead of clipped. + this.limiterNode = this.context.createDynamicsCompressor(); + this.limiterNode.threshold.value = -6; + this.limiterNode.knee.value = 6; + this.limiterNode.ratio.value = 12; + this.limiterNode.attack.value = 0.003; + this.limiterNode.release.value = 0.25; + this.gainNode.connect(this.limiterNode); + if (isIOS()) { try { this.mediaStreamDestination = this.context.createMediaStreamDestination(); - this.gainNode.connect(this.mediaStreamDestination); + this.limiterNode.connect(this.mediaStreamDestination); this.audioElement = document.createElement("audio"); this.audioElement.srcObject = this.mediaStreamDestination.stream; @@ -236,10 +249,10 @@ export class PCMAudioPlayer { Log.v(TAG, "iOS detected: using MediaStream bypass for Silent Mode"); } catch (_e) { Log.w(TAG, "Failed to create MediaStream destination, falling back to default output"); - this.gainNode.connect(this.context.destination); + this.limiterNode.connect(this.context.destination); } } else { - this.gainNode.connect(this.context.destination); + this.limiterNode.connect(this.context.destination); } this.updateGain(); @@ -1019,7 +1032,7 @@ export class PCMAudioPlayer { if (smooth && ctx && this.gainNode && this.scheduledSpans.length > 0 && ctx.state === "running") { // Quick gain dip so already-playing audio doesn't click when stopped const now = ctx.currentTime; - const target = this.muted ? 0 : this.volume; + const target = this.currentGain(); const g = this.gainNode.gain; g.cancelScheduledValues(now); g.setValueAtTime(target, now); @@ -1412,14 +1425,28 @@ export class PCMAudioPlayer { this.updateGain(); } + /** Extra gain above unity: 1 = 100%, up to 6 = 600%. Composes with `volume`. */ + setBoost(boost: number): void { + this.boost = Math.max(1, Math.min(6, boost)); + this.updateGain(); + } + + /** Gain actually applied: volume composed with the boost, muted to zero. */ + private currentGain(): number { + return this.muted ? 0 : Math.min(this.volume * this.boost, 6); + } + private updateGain(): void { if (this.gainNode && this.context) { const g = this.gainNode.gain; + const level = this.currentGain(); g.cancelScheduledValues(this.context.currentTime); - g.value = this.muted ? 0 : this.volume; + g.value = level; } if (this.audioElement) { - this.audioElement.volume = this.muted ? 0 : this.volume; + // The element's own volume caps at 1; whatever exceeds it is already applied + // upstream by the gain stage. + this.audioElement.volume = this.muted ? 0 : Math.min(1, this.volume * this.boost); } } @@ -1443,6 +1470,10 @@ export class PCMAudioPlayer { this.mediaStreamDestination = null; } + if (this.limiterNode) { + this.limiterNode.disconnect(); + this.limiterNode = null; + } if (this.gainNode) { this.gainNode.disconnect(); this.gainNode = null; diff --git a/web-ui/src/playback-engine/backends/mse-playback-backend.ts b/web-ui/src/playback-engine/backends/mse-playback-backend.ts index dcd5a517..e24cfb85 100644 --- a/web-ui/src/playback-engine/backends/mse-playback-backend.ts +++ b/web-ui/src/playback-engine/backends/mse-playback-backend.ts @@ -91,6 +91,9 @@ export function createMSEPlaybackBackend(video: HTMLVideoElement, config?: Parti setMuted: (muted) => { video.muted = muted; }, + setVolumeBoost: (level) => { + controller?.setVolumeBoost(level); + }, getState: () => ({ currentTime: video.currentTime, diff --git a/web-ui/src/playback-engine/mse/playback-controller.ts b/web-ui/src/playback-engine/mse/playback-controller.ts index 8ee1dbf0..d9adecf8 100644 --- a/web-ui/src/playback-engine/mse/playback-controller.ts +++ b/web-ui/src/playback-engine/mse/playback-controller.ts @@ -52,6 +52,9 @@ export function createMSEPlaybackController( // PCM audio player for software-decoded audio (MP2) let pcmPlayer: PCMAudioPlayer | null = null; let pcmPlayerInitPromise: Promise | null = null; + // Kept outside the PCM player: it is created lazily, long after the UI + // applies the level, and is rebuilt on every audio recovery. + let volumeBoostLevel = 1; let startupRateControlActive = false; function ensurePCMPlayer(): PCMAudioPlayer { @@ -81,6 +84,7 @@ export function createMSEPlaybackController( }; pcmPlayerInitPromise = pcmPlayer.init(); pcmPlayer.attachVideo(video); + pcmPlayer.setBoost(volumeBoostLevel); } return pcmPlayer; } @@ -500,6 +504,11 @@ export function createMSEPlaybackController( goLiveTo(targetMseSeconds); }, + setVolumeBoost(level: number) { + volumeBoostLevel = level; + pcmPlayer?.setBoost(level); + }, + setLiveSessionAnchor(anchor: LiveSessionAnchor) { liveSessionAnchor = anchor; updateLiveState(); diff --git a/web-ui/src/playback-engine/types.ts b/web-ui/src/playback-engine/types.ts index eaea7431..63d487cc 100644 --- a/web-ui/src/playback-engine/types.ts +++ b/web-ui/src/playback-engine/types.ts @@ -83,6 +83,8 @@ export interface PlaybackBackend { play(): Promise; pause(): void; setVolume(volume: number): void; + /** Extra gain above unity, 1 = 100% (up to 6). No-op on backends without a gain stage. */ + setVolumeBoost?(level: number): void; setMuted(muted: boolean): void; getState(): PlaybackBackendState; seek(seconds: number): void; @@ -111,6 +113,7 @@ export interface MSEPlaybackController { loadSegments(segments: PlayerSegment[]): void; seek(seconds: number): void; goLive(targetMseSeconds: number): void; + setVolumeBoost(level: number): void; setLiveSessionAnchor(anchor: LiveSessionAnchor): void; setLiveSync(enabled: boolean): void; /** Release the video element (stop feeding, detach source) but keep reusable resources (worker) alive. */