[skottie] add audio layer support to skottie web player

Change-Id: I18d059cd24c1d6447adbdafbf65a22f59bca1a35
Reviewed-on: https://skia-review.googlesource.com/c/buildbot/+/369434
Commit-Queue: Jorge Betancourt <jmbetancourt@google.com>
Reviewed-by: Kevin Lubick <kjlubick@google.com>
Reviewed-by: Florin Malita <fmalita@chromium.org>
diff --git a/.gitignore b/.gitignore
index 6e8b531..6b1d47b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,5 @@
+# MacOS file system attributes
+*.DS_Store
 # Compiled python files.
 *.pyc
 # Files open in vi.
@@ -111,4 +113,4 @@
 
 # Ignore all bazel-* symlinks. There is no full list since this can change
 # based on the name of the directory bazel is cloned into.
-/bazel-*
\ No newline at end of file
+/bazel-*
diff --git a/skottie/modules/audio.js b/skottie/modules/audio.js
new file mode 100644
index 0000000..fa8f343
--- /dev/null
+++ b/skottie/modules/audio.js
@@ -0,0 +1,58 @@
+import {Howl, Howler} from 'howler';
+
+// seek tolerance in seconds, keeps the Howl player from seeking unnecessarily
+// if the number is too small, Howl.seek() is called too often and creates a popping noise
+// too large and audio layers may be skipped over
+const kTolerance = 0.75;
+
+// SoundMaps have string : player pairs
+export function SoundMap() {
+  this.map = new Map();
+  this.setPlayer = function(name, player) {
+    if (typeof name == 'string' && player.hasOwnProperty('seek')) {
+      this.map.set(name, player);
+    }
+  };
+  this.getPlayer = function(name) {
+    return this.map.get(name);
+  };
+  this.pause = function() {
+    for(const player of this.map.values()) {
+      player.seek(-1);
+    }
+  }
+}
+
+/**
+ * AudioPlayers wrap a howl and control playback through seek calls
+ *
+ * @param source - URL or base64 data URI pointing to audio data
+ * @param format - only needed if extension is not provided by source (inline URI)
+ *
+ */
+export function AudioPlayer(source) {
+  this.playing = false;
+  this.howl = new Howl({
+    src: [source],
+    preload: true
+  });
+  this.seek = function(t) {
+    if (!this.playing && t >=0) {
+      this.howl.play();
+      this.playing = true;
+    }
+
+    if (this.playing) {
+      if (t < 0) {
+        this.howl.pause();
+        this.playing = false;
+      } else {
+        const playerPos = this.howl.seek();
+
+        if (Math.abs(playerPos - t) > kTolerance) {
+          this.howl.seek(t);
+        }
+      }
+    }
+  };
+}
diff --git a/skottie/modules/skottie-player-sk/skottie-player-sk.js b/skottie/modules/skottie-player-sk/skottie-player-sk.js
index 5409864..051e788 100644
--- a/skottie/modules/skottie-player-sk/skottie-player-sk.js
+++ b/skottie/modules/skottie-player-sk/skottie-player-sk.js
@@ -183,7 +183,7 @@
       ck.setDecodeCacheLimitBytes(CACHE_SIZE);
 
       this._engine.kit = ck;
-      this._initializeSkottie(config.lottie, config.assets);
+      this._initializeSkottie(config.lottie, config.assets, config.soundMap);
       this._render();
     });
   }
@@ -223,7 +223,7 @@
     }
   }
 
-  _initializeSkottie(lottieJSON, assets) {
+  _initializeSkottie(lottieJSON, assets, soundMap) {
     this._state.loading = false;
 
     // Rebuild the surface only if needed.
@@ -252,7 +252,7 @@
     }
 
     this._engine.animation = this._engine.kit.MakeManagedAnimation(
-      JSON.stringify(lottieJSON), assets,
+      JSON.stringify(lottieJSON), assets, null, soundMap
     );
     if (!this._engine.animation) {
       throw new Error('Could not parse Lottie JSON.');
diff --git a/skottie/modules/skottie-sk/skottie-sk.js b/skottie/modules/skottie-sk/skottie-sk.js
index f64d596..6989d84 100644
--- a/skottie/modules/skottie-sk/skottie-sk.js
+++ b/skottie/modules/skottie-sk/skottie-sk.js
@@ -21,6 +21,7 @@
 import { setupListeners, onUserEdit, reannotate} from '../lottie-annotations'
 import { stateReflector } from 'common-sk/modules/stateReflector'
 import '../skottie-text-editor'
+import { SoundMap, AudioPlayer } from '../audio'
 
 const JSONEditor = require('jsoneditor/dist/jsoneditor-minimalist.js');
 const bodymovin = require('lottie-web/build/player/lottie.min.js');
@@ -408,11 +409,12 @@
 
   _initializePlayer() {
     this._skottiePlayer.initialize({
-      width:  this._width,
-      height: this._height,
-      lottie: this._state.lottie,
-      assets: this._state.assets,
-      fps:    this._fps,
+      width:    this._width,
+      height:   this._height,
+      lottie:   this._state.lottie,
+      assets:   this._state.assets,
+      soundMap: this._state.soundMap,
+      fps:      this._fps,
     }).then(() => {
       this._duration = this._skottiePlayer.duration();
       // If the user has specified a value for FPS, we want to lock the
@@ -446,9 +448,12 @@
 
     Promise.all(toLoad).then((externalAssets) => {
       const assets = {};
+      const sounds = new SoundMap();
       for (const asset of externalAssets) {
-        if (asset) {
+        if (asset && asset.bytes) {
           assets[asset.name] = asset.bytes;
+        } else if (asset && asset.player) {
+          sounds.setPlayer(asset.name, asset.player);
         }
       }
 
@@ -460,6 +465,7 @@
       });
 
       this._state.assets = assets;
+      this._state.soundMap = sounds;
       this.render();
       this._initializePlayer();
       // Re-sync all players
@@ -517,25 +523,43 @@
   _loadAssets(assets) {
     const promises = [];
     for (const asset of assets) {
-      // asset.p is the filename, if it's an image.
-      // Don't try to load inline/dataURI images.
-      const should_load = asset.p && asset.p.startsWith && !asset.p.startsWith('data:');
-      if (should_load) {
-        promises.push(fetch(`${this._assetsPath}/${this._hash}/${asset.p}`)
-          .then((resp) => {
-            // fetch does not reject on 404
-            if (!resp.ok) {
-              console.error(`Could not load ${asset.p}: status ${resp.status}`)
-              return null;
-            }
-            return resp.arrayBuffer().then((buffer) => {
-              return {
-                'name': asset.p,
-                'bytes': buffer
-              };
-            });
-          })
-        );
+      if (asset.id.startsWith('audio_')) {
+        // Howler handles our audio assets, they don't provide a promise when making a new Howl.
+        // We push the audio asset as is and hope that it loads before playback starts.
+        const inline = asset.p && asset.p.startsWith && asset.p.startsWith('data:');
+        if (inline) {
+          promises.push({
+            'name': asset.id,
+            'player': new AudioPlayer(asset.p)
+          });
+        } else {
+          promises.push({
+            'name': asset.id,
+            'player': new AudioPlayer(`${this._assetsPath}/${this._hash}/${asset.p}`)
+          });
+        }
+      }
+      else {
+        // asset.p is the filename, if it's an image.
+        // Don't try to load inline/dataURI images.
+        const should_load = asset.p && asset.p.startsWith && !asset.p.startsWith('data:');
+        if (should_load) {
+          promises.push(fetch(`${this._assetsPath}/${this._hash}/${asset.p}`)
+            .then((resp) => {
+              // fetch does not reject on 404
+              if (!resp.ok) {
+                console.error(`Could not load ${asset.p}: status ${resp.status}`)
+                return null;
+              }
+              return resp.arrayBuffer().then((buffer) => {
+                return {
+                  'name': asset.p,
+                  'bytes': buffer
+                };
+              });
+            })
+          );
+        }
       }
     }
     return promises;
@@ -546,11 +570,14 @@
       this._wasmTimePassed = Date.now() - this._firstFrameTime;
       this._lottie && this._lottie.pause();
       this._live && this._live.pause();
+      this._state.soundMap && this._state.soundMap.pause();
       $$('#playpause').textContent = 'Play';
     } else {
       this._lottie && this._lottie.play();
       this._live && this._live.play();
       this._firstFrameTime = Date.now() - (this._wasmTimePassed || 0);
+      // There is no need call a soundMap.play() function here.
+      // Skottie invokes the play by calling seek on the needed audio track.
       $$('#playpause').textContent = 'Pause';
     }
     this._playing = !this._playing;
diff --git a/skottie/package-lock.json b/skottie/package-lock.json
index 142e879..c951bd1 100644
--- a/skottie/package-lock.json
+++ b/skottie/package-lock.json
@@ -3456,6 +3456,11 @@
       "integrity": "sha512-f/wzC2QaWBs7t9IYqB4T3sR1xviIViXJRJTWBlx2Gf3g0Xi5vI7Yy4koXQ1c9OYDGHN9sBy1DQ2AB8fqZBWhUg==",
       "dev": true
     },
+    "howler": {
+      "version": "2.2.1",
+      "resolved": "https://registry.npmjs.org/howler/-/howler-2.2.1.tgz",
+      "integrity": "sha512-0iIXvuBO/81CcrQ/HSSweYmbT50fT2mIc9XMFb+kxIfk2pW/iKzDbX1n3fZmDXMEIpYvyyfrB+gXwPYSDqUxIQ=="
+    },
     "hpack.js": {
       "version": "2.1.6",
       "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz",
diff --git a/skottie/package.json b/skottie/package.json
index 514d2e5..8e5f779 100644
--- a/skottie/package.json
+++ b/skottie/package.json
@@ -9,6 +9,7 @@
     "canvaskit-wasm": "0.5.0",
     "common-sk": "~3.1.0",
     "elements-sk": "^4.0.0",
+    "howler": "^2.2.1",
     "jsoneditor": "~5.24.3",
     "lit-html": "~1.0.0",
     "lottie-web": "^5.4.3"