Prechádzať zdrojové kódy

feat: add compression to amplifyMedia()

Sven 1 rok pred
rodič
commit
b53a946
4 zmenil súbory, kde vykonal 141 pridanie a 38 odobranie
  1. 5 0
      .changeset/curvy-jokes-laugh.md
  2. 71 20
      README.md
  3. 63 16
      lib/dom.ts
  4. 2 2
      package.json

+ 5 - 0
.changeset/curvy-jokes-laugh.md

@@ -0,0 +1,5 @@
+---
+"@sv443-network/userutils": major
+---
+
+Added compression to `amplifyMedia()` to prevent audio clipping and distortion and modified return type accordingly

+ 71 - 20
README.md

@@ -478,47 +478,98 @@ interceptWindowEvent("beforeunload", (event) => {
 ### amplifyMedia()
 Usage:  
 ```ts
-amplifyMedia(mediaElement: HTMLMediaElement, multiplier?: number): AmplifyMediaResult
+amplifyMedia(mediaElement: HTMLMediaElement, initialMultiplier?: number): AmplifyMediaResult
 ```
   
 Amplifies the gain of a media element (like `<audio>` or `<video>`) by a given multiplier (defaults to 1.0).  
 This is how you can increase the volume of a media element beyond the default maximum volume of 1.0 or 100%.  
-Make sure to limit the multiplier to a reasonable value ([clamp()](#clamp) is good for this), as it may cause clipping or bleeding eardrums.  
+Make sure to limit the multiplier to a reasonable value ([clamp()](#clamp) is good for this), as it may cause bleeding eardrums.  
+  
+This is the processing workflow applied to the media element:  
+`MediaElement (source)` => `DynamicsCompressorNode (limiter)` => `GainNode` => `AudioDestination (output)`  
+  
+A limiter (compression) is applied to the audio to prevent clipping.  
+Its properties can be changed by calling the returned function `setLimiterOptions()`  
+The default props are `{ threshold: -2, knee: 40, ratio: 12, attack: 0.003, release: 0.25 }`  
   
 ⚠️ This function has to be run in response to a user interaction event, else the browser will reject it because of the strict autoplay policy.  
+⚠️ Make sure to call the returned function `enable()` after calling this function to actually enable the amplification.  
   
-The returned AmplifyMediaResult object has the following properties:
+The returned object of the type `AmplifyMediaResult` has the following properties:
 | Property | Description |
 | :-- | :-- |
-| `mediaElement` | The passed media element |
-| `amplify()` | A function to change the amplification level |
-| `getAmpLevel()` | A function to return the current amplification level |
+| `setGain()` | Used to change the gain multiplier |
+| `getGain()` | Returns the current gain multiplier |
+| `enable()` | Call to enable the amplification for the first time or if it was disabled before |
+| `disable()` | Call to disable amplification |
+| `setLimiterOptions()` | Used for changing the [options of the DynamicsCompressorNode](https://developer.mozilla.org/en-US/docs/Web/API/DynamicsCompressorNode/DynamicsCompressorNode#options) - the default is `{ threshold: -2, knee: 40, ratio: 12, attack: 0.003, release: 0.25 }` |
 | `context` | The AudioContext instance |
 | `source` | The MediaElementSourceNode instance |
-| `gain` | The GainNode instance |
+| `gainNode` | The GainNode instance used for actually boosting the gain |
+| `limiterNode` | The DynamicsCompressorNode instance used for limiting clipping and distortion |
   
 <details><summary><b>Example - click to view</b></summary>
 
 ```ts
-import { amplifyMedia } from "@sv443-network/userutils";
+import { amplifyMedia, clamp } from "@sv443-network/userutils";
+import type { AmplifyMediaResult } from "@sv443-network/userutils";
+
+const audioElement = document.querySelector<HTMLAudioElement>("audio");
+
+let ampResult: AmplifyMediaResult | undefined;
+
+function setGain(newValue: number) {
+  if(!ampResult)
+    return;
+  // constrain the value to between 0 and 3 for safety
+  ampResult.setGain(clamp(newValue, 0, 3));
+  console.log("Gain set to", ampResult.getGain());
+}
+
+
+const amplifyButton = document.querySelector<HTMLButtonElement>("button#amplify");
 
-const audio = document.querySelector<HTMLAudioElement>("audio");
-const button = document.querySelector<HTMLButtonElement>("button");
+// amplifyMedia() needs to be called in response to a user interaction event:
+amplifyButton.addEventListener("click", () => {
+  // only needs to be initialized once, afterwards the returned object
+  // can be used to change settings and enable/disable the amplification
+  if(!ampResult) {
+    // initialize amplification and set gain to 2x
+    ampResult = amplifyMedia(audioElement, 2);
+    // enable the amplification
+    ampResult.enable();
+  }
+
+  setGain(2.5);  // set gain to 2.5x
+
+  console.log(ampResult.getGain()); // 2.5
+});
 
-// amplifyMedia needs to be called in response to a user interaction event:
-button.addEventListener("click", () => {
-  const { amplify, getAmpLevel } = amplifyMedia(audio);
 
-  const setGain = (value: number) => {
-    // constrain the value to between 0 and 5
-    amplify(clamp(value, 0, 5));
-    console.log("Gain set to", getAmpLevel());
+const disableButton = document.querySelector<HTMLButtonElement>("button#disable");
+
+disableButton.addEventListener("click", () => {
+  if(ampResult) {
+    // disable the amplification
+    ampResult.disable();
   }
+});
 
-  setGain(2);    // set gain to 2x
-  setGain(3.5);  // set gain to 3.5x
 
-  console.log(getAmpLevel()); // 3.5
+const limiterButton = document.querySelector<HTMLButtonElement>("button#limiter");
+
+limiterButton.addEventListener("click", () => {
+  if(ampResult) {
+    // change the limiter options to a more aggressive setting
+    // see https://developer.mozilla.org/en-US/docs/Web/API/DynamicsCompressorNode/DynamicsCompressorNode#options
+    ampResult.setLimiterOptions({
+      threshold: -10,
+      knee: 20,
+      ratio: 20,
+      attack: 0.001,
+      release: 0.1,
+    });
+  }
 });
 ```
 

+ 63 - 16
lib/dom.ts

@@ -132,39 +132,86 @@ export function interceptWindowEvent<TEvtKey extends keyof WindowEventMap>(
 
 /**
  * Amplifies the gain of the passed media element's audio by the specified multiplier.  
- * This function supports any media element like `<audio>` or `<video>`  
+ * Also applies a limiter to prevent clipping and distortion.  
+ * This function supports any MediaElement instance like `<audio>` or `<video>`  
  *   
- * This function has to be run in response to a user interaction event, else the browser will reject it because of the strict autoplay policy.  
+ * This is the audio processing workflow:  
+ * `MediaElement (source)` => `DynamicsCompressorNode (limiter)` => `GainNode` => `AudioDestinationNode (output)`  
  *   
+ * ⚠️ This function has to be run in response to a user interaction event, else the browser will reject it because of the strict autoplay policy.  
+ * ⚠️ Make sure to call the returned function `enable()` after calling this function to actually enable the amplification.  
+ *   
+ * @param mediaElement The media element to amplify (e.g. `<audio>` or `<video>`)
+ * @param initialMultiplier The initial gain multiplier to apply (floating point number, default is `1.0`)
  * @returns Returns an object with the following properties:
  * | Property | Description |
  * | :-- | :-- |
- * | `mediaElement` | The passed media element |
- * | `amplify()` | A function to change the amplification level |
- * | `getAmpLevel()` | A function to return the current amplification level |
+ * | `setGain()` | Used to change the gain multiplier |
+ * | `getGain()` | Returns the current gain multiplier |
+ * | `enable()` | Call to enable the amplification for the first time or if it was disabled before |
+ * | `disable()` | Call to disable amplification |
+ * | `setLimiterOptions()` | Used for changing the [options of the DynamicsCompressorNode](https://developer.mozilla.org/en-US/docs/Web/API/DynamicsCompressorNode/DynamicsCompressorNode#options) - the default is `{ threshold: -2, knee: 40, ratio: 12, attack: 0.003, release: 0.25 }` |
  * | `context` | The AudioContext instance |
  * | `source` | The MediaElementSourceNode instance |
- * | `gain` | The GainNode instance |
+ * | `gainNode` | The GainNode instance |
+ * | `limiterNode` | The DynamicsCompressorNode instance used for limiting clipping and distortion |
  */
-export function amplifyMedia<TElem extends HTMLMediaElement>(mediaElement: TElem, multiplier = 1.0) {
+export function amplifyMedia<TElem extends HTMLMediaElement>(mediaElement: TElem, initialMultiplier = 1.0) {
   // @ts-ignore
   const context = new (window.AudioContext || window.webkitAudioContext);
-  const result = {
-    mediaElement,
-    amplify: (multiplier: number) => { result.gain.gain.value = multiplier; },
-    getAmpLevel: () => result.gain.gain.value,
+  const props = {
+    /** Sets the gain multiplier */
+    setGain(multiplier: number) {
+      props.gainNode.gain.setValueAtTime(multiplier, props.context.currentTime);
+    },
+    /** Returns the current gain multiplier */
+    getGain() {
+      return props.gainNode.gain.value;
+    },
+    /** Enable the amplification for the first time or if it was disabled before */
+    enable() {
+      props.source.connect(props.limiterNode);
+      props.limiterNode.connect(props.gainNode);
+      props.gainNode.connect(props.context.destination);
+    },
+    /** Disable the amplification */
+    disable() {
+      props.source.disconnect(props.limiterNode);
+      props.limiterNode.disconnect(props.gainNode);
+      props.gainNode.disconnect(props.context.destination);
+
+      props.source.connect(props.context.destination);
+    },
+    /**
+     * Set the options of the [limiter / DynamicsCompressorNode](https://developer.mozilla.org/en-US/docs/Web/API/DynamicsCompressorNode/DynamicsCompressorNode#options)  
+     * The default is `{ threshold: -2, knee: 40, ratio: 12, attack: 0.003, release: 0.25 }`
+     */
+    setLimiterOptions(options: Partial<Record<"threshold" | "knee" | "ratio" | "attack" | "release", number>>) {
+      for(const [key, val] of Object.entries(options))
+        props.limiterNode[key as keyof typeof options]
+          .setValueAtTime(val, props.context.currentTime);
+    },
     context: context,
     source: context.createMediaElementSource(mediaElement),
-    gain: context.createGain(),
+    gainNode: context.createGain(),
+    limiterNode: context.createDynamicsCompressor(),
   };
 
-  result.source.connect(result.gain);
-  result.gain.connect(context.destination);
-  result.amplify(multiplier);
+  props.setLimiterOptions({
+    threshold: -2,
+    knee: 40,
+    ratio: 12,
+    attack: 0.003,
+    release: 0.25,
+  });
+  props.setGain(initialMultiplier);
 
-  return result;
+  return props;
 }
 
+/** An object which contains the results of `amplifyMedia()` */
+export type AmplifyMediaResult = ReturnType<typeof amplifyMedia>;
+
 /** Checks if an element is scrollable in the horizontal and vertical directions */
 export function isScrollable(element: Element) {
   const { overflowX, overflowY } = getComputedStyle(element);

+ 2 - 2
package.json

@@ -9,10 +9,10 @@
     "lint": "tsc --noEmit && eslint .",
     "build-types": "tsc --emitDeclarationOnly --declaration --outDir dist",
     "build-common": "tsup lib/index.ts --format cjs,esm --clean --treeshake",
-    "build-global": "tsup lib/index.ts --format cjs,esm,iife --treeshake --onSuccess \"npm run post-build-global\"",
+    "build-global": "tsup lib/index.ts --format cjs,esm,iife --treeshake --onSuccess \"npm run post-build-global && echo Finished building.\"",
     "build": "npm run build-common -- && npm run build-types",
     "post-build-global": "npm run node-ts -- ./tools/post-build-global.mts",
-    "dev": "npm run build-common -- --sourcemap --watch --onSuccess \"npm run build-types\"",
+    "dev": "npm run build-common -- --sourcemap --watch --onSuccess \"npm run build-types && echo Finished building.\"",
     "publish-package": "changeset publish",
     "node-ts": "node --no-warnings=ExperimentalWarning --enable-source-maps --loader ts-node/esm"
   },