Procházet zdrojové kódy

fix!: much better audio amplification

Sv443 před 1 rokem
rodič
revize
0db73b5
5 změnil soubory, kde provedl 138 přidání a 157 odebrání
  1. 13 0
      .changeset/curvy-needles-obey.md
  2. 42 41
      README.md
  3. 78 111
      lib/dom.ts
  4. 4 4
      package-lock.json
  5. 1 1
      package.json

+ 13 - 0
.changeset/curvy-needles-obey.md

@@ -0,0 +1,13 @@
+---
+"@sv443-network/userutils": major
+---
+
+Rewrote `amplifyMedia()` using a new approach for clear and undistorted audio.  
+Instead of just one post-amplifier GainNode and a DynamicsCompressorNode, there are now two GainNodes (one for pre-amp, one for post-amp) and ten BiquadFilterNodes in-between them for predetermined frequency bands.  
+  
+**Migration info:**
+- The methods `setGain()` and `getGain()` have been exchanged for `setPreampGain()`, `getPreampGain()`, `setPostampGain()` and `getPostampGain()`
+- A boolean property `enabled` has been added to check if the amplification is enabled or not
+- The property `gainNode` has been exchanged for `preampNode` and `postampNode`
+- The property `source` has been renamed to `sourceNode` to fit the naming of the other properties
+- A property `filterNodes` has been added to house an array of the ten BiquadFilterNodes

+ 42 - 41
README.md

@@ -585,36 +585,48 @@ interceptWindowEvent("beforeunload", (event) => {
 ### amplifyMedia()
 Usage:  
 ```ts
-amplifyMedia(mediaElement: HTMLMediaElement, initialMultiplier?: number): AmplifyMediaResult
+amplifyMedia(mediaElement: HTMLMediaElement, initialPreampGain?: number, initialPostampGain?: 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 bleeding eardrums.  
+Amplifies the gain of a media element (like `<audio>` or `<video>`) by the given values.  
+This is how you can increase the volume of a media element beyond the default maximum volume of 100%.  
+Make sure to limit the multiplier to a reasonable value ([clamp()](#clamp) is good for this), as it may otherwise cause bleeding eardrums.  
   
-This is the processing workflow applied to the media element:  
-`MediaElement (source)` => `DynamicsCompressorNode (limiter)` => `GainNode` => `AudioDestination (output)`  
+The default values are `0.02` for the pre-amplifier and `1.0` for the post-amplifier.  
+The values may be changed at any point by calling the `setPreampGain()` and `setPostampGain()` methods of the returned object.  
+The value of the pre-amplifier influences the scaling of the post-amplifier, so you will have to spend some time finding the right values for your use case.  
+  
+To activate the amplification for the first time, call the `enable()` method of the returned object.  
   
-A limiter (compression) is applied to the audio to prevent clipping.  
-Its properties can be changed by calling the returned function `setLimiterOptions()`  
-The limiter options set by default are `{ threshold: -12, knee: 30, ratio: 12, attack: 0.003, release: 0.25 }`  
+This is the processing workflow applied to the media element:  
+`MediaElement (source)` => `GainNode (pre-amplifier)` => 10x `BiquadFilterNode` => `GainNode (post-amplifier)` => `destination`  
   
 ⚠️ 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 object of the type `AmplifyMediaResult` has the following properties:
+The returned object of the type `AmplifyMediaResult` has the following properties:  
+**Important properties:**
 | Property | Description |
 | :-- | :-- |
-| `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 |
+| `setPreampGain()` | Used to change the pre-amplifier gain value from the default set by {@linkcode initialPreampGain} (0.02) |
+| `getPreampGain()` | Returns the current pre-amplifier gain value |
+| `setPostampGain()` | Used to change the post-amplifier gain value from the default set by {@linkcode initialPostampGain} (1.0) |
+| `getPostampGain()` | Returns the current post-amplifier gain value |
+| `enable()` | Call to enable the amplification for the first time or re-enable it 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: -12, knee: 30, ratio: 12, attack: 0.003, release: 0.25 }` |
+| `enabled` | Whether the amplification is currently enabled |
+  
+**Other properties:**
+| Property | Description |
+| :-- | :-- |
 | `context` | The AudioContext instance |
-| `source` | The MediaElementSourceNode instance |
-| `gainNode` | The GainNode instance used for actually boosting the gain |
-| `limiterNode` | The DynamicsCompressorNode instance used for limiting clipping and distortion |
+| `sourceNode` | A MediaElementSourceNode instance created from the passed {@linkcode mediaElement} |
+| `preampNode` | The pre-amplifier GainNode instance |
+| `postampNode` | The post-amplifier GainNode instance |
+| `filterNodes` | An array of BiquadFilterNode instances used for normalizing the audio volume |
   
+<br>
+
 <details><summary><b>Example - click to view</b></summary>
 
 ```ts
@@ -625,12 +637,16 @@ const audioElement = document.querySelector<HTMLAudioElement>("audio");
 
 let ampResult: AmplifyMediaResult | undefined;
 
-function setGain(newValue: number) {
+// I found this value to be a good starting point
+// and a good match if the postamp value is between 1 and 3
+const preampValue = 0.15;
+
+function updateGainValue(postampValue: 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());
+  ampResult.setPostampGain(clamp(postampValue, 0, 3));
+  console.log("Gain set to", ampResult.getPostampGain());
 }
 
 
@@ -641,15 +657,17 @@ 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);
+    // initialize amplification and set it to ~2x
+    ampResult = amplifyMedia(audioElement, preampValue, 2);
+  }
+  if(!ampResult.enabled) {
     // enable the amplification
     ampResult.enable();
   }
 
-  setGain(2.5);  // set gain to 2.5x
+  updateGainValue(2.5); // set postamp gain to ~2.5x
 
-  console.log(ampResult.getGain()); // 2.5
+  console.log(ampResult.getPostampGain()); // 2.5
 });
 
 
@@ -661,23 +679,6 @@ disableButton.addEventListener("click", () => {
     ampResult.disable();
   }
 });
-
-
-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,
-    });
-  }
-});
 ```
 
 </details>

+ 78 - 111
lib/dom.ts

@@ -1,3 +1,5 @@
+import type { NonEmptyArray } from "./array";
+
 /**
  * Returns `unsafeWindow` if the `@grant unsafeWindow` is given, otherwise falls back to the regular `window`
  */
@@ -130,149 +132,114 @@ export function interceptWindowEvent<TEvtKey extends keyof WindowEventMap>(
   return interceptEvent(getUnsafeWindow(), eventName, predicate);
 }
 
+/** An object which contains the results of {@linkcode amplifyMedia()} */
+export type AmplifyMediaResult = ReturnType<typeof amplifyMedia>;
+
+const amplifyBands = [60, 170, 310, 600, 1000, 3000, 6000, 12000, 14000, 16000];
+
 /**
- * Amplifies the gain of the passed media element's audio by the specified multiplier.  
- * Also applies a limiter to prevent clipping and distortion.  
+ * Amplifies the gain of the passed media element's audio by the specified values.  
+ * Also applies biquad filters to prevent clipping and distortion.  
  * This function supports any MediaElement instance like `<audio>` or `<video>`  
  *   
  * This is the audio processing workflow:  
- * `MediaElement (source)` => `DynamicsCompressorNode (limiter)` => `GainNode` => `AudioDestinationNode (output)`  
+ * `MediaElement (source)` => `GainNode (pre-amplifier)` => 10x `BiquadFilterNode` => `GainNode (post-amplifier)` => `destination`  
  *   
  * ⚠️ 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:
+ * @param initialPreampGain The initial gain to apply to the pre-amplifier GainNode (floating point number, default is `0.02`)
+ * @param initialPostampGain The initial gain to apply to the post-amplifier GainNode (floating point number, default is `1.0`)
+ * @returns Returns an object with the following properties:  
+ * **Important properties:**
  * | Property | Description |
  * | :-- | :-- |
- * | `setGain()` | Used to change the gain multiplier from the default set by {@linkcode initialMultiplier} |
- * | `getGain()` | Returns the current gain multiplier |
- * | `enable()` | Call to enable the amplification for the first time or if it was disabled before |
+ * | `setPreampGain()` | Used to change the pre-amplifier gain value from the default set by {@linkcode initialPreampGain} (0.02) |
+ * | `getPreampGain()` | Returns the current pre-amplifier gain value |
+ * | `setPostampGain()` | Used to change the post-amplifier gain value from the default set by {@linkcode initialPostampGain} (1.0) |
+ * | `getPostampGain()` | Returns the current post-amplifier gain value |
+ * | `enable()` | Call to enable the amplification for the first time or re-enable it 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 }` |
+ * | `enabled` | Whether the amplification is currently enabled |
+ * 
+ * **Other properties:**
+ * | Property | Description |
+ * | :-- | :-- |
  * | `context` | The AudioContext instance |
- * | `source` | The MediaElementSourceNode instance |
- * | `gainNode` | The GainNode instance |
- * | `limiterNode` | The DynamicsCompressorNode instance used for limiting clipping and distortion |
+ * | `sourceNode` | A MediaElementSourceNode instance created from the passed {@linkcode mediaElement} |
+ * | `preampNode` | The pre-amplifier GainNode instance |
+ * | `postampNode` | The post-amplifier GainNode instance |
+ * | `filterNodes` | An array of BiquadFilterNode instances used for normalizing the audio volume |
  */
-export function amplifyMedia<TElem extends HTMLMediaElement>(mediaElement: TElem, initialMultiplier = 1.0) {
-  /*
-  // Globals:
-
-  bands = [60, 170, 310, 600, 1000, 3000, 6000, 12000, 14000, 16000]
-
-
-  // Audio Processing Nodes:
-
-  <HTMLMediaElement (source)>
-
-  // connect to:
-
-  <GainNode (preamp)>
-    attr gain = ?
-
-  // connect to:
-
-  // only for panning L/R I think
-  // <StereoPannerNode (balance)>
-  //   attr pan
-
-  // connect to:
-
-  [foreach band of bands] <BiquadFilterNode (filter)>
-    attr frequency = band
-    attr gain = ?
-    attr type = (index === 0 ? "lowshelf" : "highshelf) || "peaking"   // "peaking" is unreachable??
-
-  // connect all but the first filter to the first filter, then first filter to destination:
-
-  <AudioContext.destination>
-
-  */
-
+export function amplifyMedia<TElem extends HTMLMediaElement>(mediaElement: TElem, initialPreampGain = 0.02, initialPostampGain = 1.0) {
   // @ts-ignore
   const context = new (window.AudioContext || window.webkitAudioContext)();
   const props = {
     context,
-    source: context.createMediaElementSource(mediaElement),
-    gainNode: context.createGain(),
-    limiterNode: context.createDynamicsCompressor(),
-    /** Sets the gain multiplier */
-    setGain(multiplier: number) {
-      props.gainNode.gain.setValueAtTime(multiplier, props.context.currentTime);
+    sourceNode: context.createMediaElementSource(mediaElement),
+    preampNode: context.createGain(),
+    postampNode: context.createGain(),
+    filterNodes: amplifyBands.map((band, i) => {
+      const node = context.createBiquadFilter();
+      node.type = (i === 0 ? "lowshelf" : "highshelf");
+      node.frequency.setValueAtTime(band, context.currentTime);
+      return node;
+    }) as NonEmptyArray<BiquadFilterNode>,
+    /** Sets the gain of the pre-amplifier GainNode */
+    setPreampGain(gain: number) {
+      props.preampNode.gain.setValueAtTime(gain, context.currentTime);
+    },
+    /** Returns the current gain of the pre-amplifier GainNode */
+    getPreampGain() {
+      return props.preampNode.gain.value;
+    },
+    /** Sets the gain of the post-amplifier GainNode */
+    setPostampGain(multiplier: number) {
+      props.postampNode.gain.setValueAtTime(multiplier, context.currentTime);
     },
-    /** Returns the current gain multiplier */
-    getGain() {
-      return props.gainNode.gain.value;
+    /** Returns the current gain of the post-amplifier GainNode */
+    getPostampGain() {
+      return props.postampNode.gain.value;
     },
+    /** Whether the amplification is currently enabled */
+    enabled: false,
     /** 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);
+      if(props.enabled)
+        return;
+      props.enabled = true;
+      props.sourceNode.connect(props.preampNode);
+      props.filterNodes.slice(1).forEach(filterNode => {
+        props.preampNode.connect(filterNode);
+        filterNode.connect(props.filterNodes[0]);
+      });
+      props.filterNodes[0].connect(props.postampNode);
+      props.postampNode.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: -12, knee: 30, 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);
+      if(!props.enabled)
+        return;
+      props.enabled = false;
+      props.sourceNode.disconnect(props.preampNode);
+      props.filterNodes.slice(1).forEach(filterNode => {
+        props.preampNode.disconnect(filterNode);
+        filterNode.disconnect(props.filterNodes[0]);
+      });
+      props.filterNodes[0].disconnect(props.postampNode);
+      props.postampNode.disconnect(props.context.destination);
+
+      props.sourceNode.connect(props.context.destination);
     },
   };
 
-  // TODO: better limiter options
-  // - https://www.reddit.com/r/edmproduction/comments/ssi4sx/explain_limitingclippingcompression_like_im_8/hx5kukj/
-  // - https://blog.landr.com/how-to-use-a-compressor/
-  // - https://youtu.be/72rtkuk9Gb0?t=120
-
-  props.setLimiterOptions(limiterPresets.default);
-  props.setGain(initialMultiplier);
+  props.setPreampGain(initialPreampGain);
+  props.setPostampGain(initialPostampGain);
 
   return props;
 }
 
-/** Presets for the `setLimiterOptions()` function returned by {@linkcode amplifyMedia()} */
-export const limiterPresets = {
-  /** The default limiter options */
-  default: {
-    threshold: -16,
-    knee: 15,
-    ratio: 5,
-    attack: 0.004,
-    release: 0.01,
-  },
-  /** A limiter preset for a more aggressive compression */
-  aggressive: {
-    threshold: -12,
-    knee: 30,
-    ratio: 12,
-    attack: 0.003,
-    release: 0.25,
-  },
-  /** A limiter preset for a more subtle compression */
-  subtle: {
-    threshold: -24,
-    knee: 5,
-    ratio: 2,
-    attack: 0.005,
-    release: 0.05,
-  },
-};
-
-/** An object which contains the results of {@linkcode 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);

+ 4 - 4
package-lock.json

@@ -20,7 +20,7 @@
         "express": "^4.18.2",
         "ts-node": "^10.9.1",
         "tslib": "^2.6.1",
-        "tsup": "^7.2.0",
+        "tsup": "^7.1.0",
         "typescript": "^5.1.6"
       }
     },
@@ -6080,9 +6080,9 @@
       "dev": true
     },
     "node_modules/tsup": {
-      "version": "7.2.0",
-      "resolved": "https://registry.npmjs.org/tsup/-/tsup-7.2.0.tgz",
-      "integrity": "sha512-vDHlczXbgUvY3rWvqFEbSqmC1L7woozbzngMqTtL2PGBODTtWlRwGDDawhvWzr5c1QjKe4OAKqJGfE1xeXUvtQ==",
+      "version": "7.1.0",
+      "resolved": "https://registry.npmjs.org/tsup/-/tsup-7.1.0.tgz",
+      "integrity": "sha512-mazl/GRAk70j8S43/AbSYXGgvRP54oQeX8Un4iZxzATHt0roW0t6HYDVZIXMw0ZQIpvr1nFMniIVnN5186lW7w==",
       "dev": true,
       "dependencies": {
         "bundle-require": "^4.0.0",

+ 1 - 1
package.json

@@ -48,7 +48,7 @@
     "express": "^4.18.2",
     "ts-node": "^10.9.1",
     "tslib": "^2.6.1",
-    "tsup": "^7.2.0",
+    "tsup": "^7.1.0",
     "typescript": "^5.1.6"
   },
   "files": [