Selaa lähdekoodia

wip: simple-wave-record integration

Tienson Qin 1 kuukausi sitten
vanhempi
sitoutus
4139102c37
4 muutettua tiedostoa jossa 61 lisäystä ja 431 poistoa
  1. 1 1
      package.json
  2. 56 81
      src/main/mobile/components/recorder.cljs
  3. 0 345
      src/main/mobile/record.cljs
  4. 4 4
      yarn.lock

+ 1 - 1
package.json

@@ -138,7 +138,6 @@
         "@js-joda/timezone": "2.5.0",
         "@logseq/diff-merge": "^0.2.2",
         "@logseq/react-tweet-embed": "1.3.1-1",
-        "@xyhp915/simple-wave-record": "^0.0.2",
         "@radix-ui/colors": "^0.1.8",
         "@sentry/react": "^6.18.2",
         "@sentry/tracing": "^6.18.2",
@@ -146,6 +145,7 @@
         "@tabler/icons-react": "^2.47.0",
         "@tabler/icons-webfont": "^2.47.0",
         "@tippyjs/react": "4.2.5",
+        "@xyhp915/simple-wave-record": "^0.0.3",
         "bignumber.js": "^9.0.2",
         "check-password-strength": "2.0.7",
         "chokidar": "3.5.1",

+ 56 - 81
src/main/mobile/components/recorder.cljs

@@ -1,6 +1,7 @@
 (ns mobile.components.recorder
   "Audio record"
   (:require ["@capacitor/device" :refer [Device]]
+            ["@xyhp915/simple-wave-record" :refer [BeatsObserver Recorder renderWaveform]]
             [cljs-time.core :as t]
             [clojure.string :as string]
             [frontend.date :as date]
@@ -12,7 +13,6 @@
             [logseq.shui.hooks :as hooks]
             [logseq.shui.ui :as shui]
             [mobile.init :as init]
-            [mobile.record :as record]
             [mobile.state :as mobile-state]
             [promesa.core :as p]
             [rum.core :as rum]))
@@ -82,33 +82,35 @@
 (rum/defc record-button
   []
   (let [*timer-ref (hooks/use-ref nil)
-        [*wavesurfer _] (hooks/use-state (atom nil))
-        [^js _wavesurfer set-wavesurfer!] (hooks/use-state nil)
-        [^js recorder set-recorder!] (hooks/use-state nil)]
+        [^js _waverecord set-waverecord!] (hooks/use-state nil)
+        [^js recorder set-recorder!] (hooks/use-state nil)
+        [*recorder _] (hooks/use-state (atom nil))
+        [locale set-locale!] (hooks/use-state nil)
+        [*locale] (hooks/use-state (atom nil))]
     (hooks/use-effect!
      (fn []
-       (let [dark? (= "dark" (state/sub :ui/theme))
-             ^js w (.create js/window.WaveSurfer
-                            #js {:container (js/document.getElementById "wave-container")
-                                 :waveColor "rgb(167, 167, 167)"
-                                 :progressColor (if dark? "rgb(219, 216, 216)" "rgb(10, 10, 10)")
-                                 :barWidth 2
-                                 :barRadius 6})
-             ^js r (.registerPlugin w
-                                    (.create js/window.WaveSurfer.Record
-                                             #js {:renderRecordedAudio false
-                                                  :scrollingWaveform true
-                                                  :scrollingWaveformWindow 5
-                                                  :mimeType "audio/mp4" ;; m4a
-                                                  :audioBitsPerSecond 128000}))]
-         (set-wavesurfer! w)
-         (reset! *wavesurfer w)
+       (let [;; dark? (= "dark" (state/sub :ui/theme))
+             node (js/document.getElementById "wave-container")
+             ^js beats (BeatsObserver.)
+             ^js w1 (renderWaveform node #js {:beatsObserver beats})
+             ^js w2 (renderWaveform node #js {})
+             ^js r (Recorder.create #js {:mimeType "audio/mp4"})]
+         (set-waverecord! w1)
          (set-recorder! r)
+         (reset! *recorder r)
+         (p/let [locale (get-locale)]
+           (set-locale! locale)
+           (reset! *locale locale))
 
          ;; events
          (doto r
+           (.on "record-start" (fn []
+                                 (.start w1)
+                                 (.start w2)))
            (.on "record-end" (fn [^js blob]
-                               (save-asset-audio! blob "en_US")
+                               (.stop w1)
+                               (.stop w2)
+                               (save-asset-audio! blob @*locale)
                                (mobile-state/close-popup!)))
            (.on "record-progress" (gfun/throttle
                                    (fn [time]
@@ -117,62 +119,40 @@
                                          (set! (. (rum/deref *timer-ref) -textContent) t))
                                        (catch js/Error e
                                          (js/console.warn "WARN: bad progress time:" e))))
-                                   33)))
+                                   33))
+           (.on "record-beat" (fn [value]
+                                (let [value' (cond
+                                               (= value 0) 10
+                                               (< value 20) (+ value 20)
+                                               (and (> value 0) (< value 50)) (+ value 30)
+                                               :else value)]
+                                  (.notify beats value')))))
          ;; auto start
          (.startRecording r)
-         #(some-> @*wavesurfer (.destroy))))
+         #(do
+            (some-> @*recorder (.destroy))
+            (.stop w1)
+            (.stop w2))))
      [])
-    [:div.p-6.flex.justify-between
-     [:div.flex.justify-between.items-center.w-full
-      [:span.flex.flex-col.timer-wrap
-       [:strong.timer {:ref *timer-ref} "00:00"]
-       [:small "05:00"]]
-      (shui/button {:variant :outline
-                    :class "record-ctrl-btn rounded-full recording"
-                    :on-click (fn []
-                                (.stopRecording recorder))}
-                   (shui/tabler-icon "player-stop" {:size 22}))]]))
-
-(rum/defc record-button-2
-  []
-  (let [[locale set-locale!] (hooks/use-state nil)
-        [*locale] (hooks/use-state (atom nil))]
-    (hooks/use-effect!
-     (fn []
-       (p/let [locale (get-locale)]
-         (set-locale! locale)
-         (reset! *locale locale)
-         (record/start
-          {:on-record-end (fn [^js blob]
-                            (save-asset-audio! blob @*locale)
-                            (mobile-state/close-popup!))})
-         (record/attach-visualizer!
-          (js/document.getElementById "wave-canvas")
-          {:mode :rolling
-           :fps 30
-           :fft-size 2048
-           :smoothing 0.8}))
-
-       #(record/destroy!))
-     [])
-    [:div.p-6.flex.justify-between
-     [:div.flex.justify-between.items-center.w-full
-      ;; [:span.flex.flex-col.timer-wrap
-      ;;  [:strong.timer "00:00"]
-      ;;  [:small "05:00"]]
-      (shui/button {:variant :outline
-                    :class "record-ctrl-btn rounded-full recording"
-                    :on-click (fn []
-                                (record/stop))}
-                   (shui/tabler-icon "player-stop" {:size 22}))
-
-      (when locale
-        (when-not (string/starts-with? locale "en_")
-          (shui/button {:variant :outline
-                        :on-click (fn []
-                                    (reset! *locale "en_US")
-                                    (set-locale! "en_US"))}
-                       "English transcribe")))]]))
+    [:div
+     [:div.p-6.flex.justify-between
+      [:div.flex.justify-between.items-center.w-full
+       [:span.flex.flex-col.timer-wrap
+        [:strong.timer {:ref *timer-ref} "00:00"]
+        [:small "05:00"]]
+       (shui/button {:variant :outline
+                     :class "record-ctrl-btn rounded-full recording"
+                     :on-click (fn []
+                                 (.stopRecording recorder))}
+                    (shui/tabler-icon "player-stop" {:size 22}))]]
+
+     (when locale
+       (when-not (string/starts-with? locale "en_")
+         (shui/button {:variant :outline
+                       :on-click (fn []
+                                   (reset! *locale "en_US")
+                                   (set-locale! "en_US"))}
+                      "English transcribe")))]))
 
 (rum/defc audio-recorder-aux < rum/static
   []
@@ -182,14 +162,9 @@
     [:small (date/get-date-time-string (t/now) {:formatter-str audio-file-format})]]
 
    [:div.px-6
-    [:div#wave-container.wave.border.rounded]
-    [:div.wave.border.rounded
-     [:canvas#wave-canvas
-      {:height 200
-       :width 320}]]]
+    [:div#wave-container.wave.border.rounded]]
 
-   ;; (record-button)
-   (record-button-2)])
+   (record-button)])
 
 (defn- show-recorder
   []

+ 0 - 345
src/main/mobile/record.cljs

@@ -1,345 +0,0 @@
-(ns mobile.record
-  "Web audio record"
-  {:clj-kondo/config {:linters {:unused-binding {:level :off}
-                                :unused-private-var {:level :off}}}}
-  (:require [lambdaisland.glogi :as log]
-            [promesa.core :as p]))
-
-;; ───────────────────────────────────────────────────────────────────────────────
-;; Existing atoms
-(defonce ^:private *stream   (atom nil))
-(defonce ^:private *recorder (atom nil))
-(defonce ^:private *chunks   (atom #js []))
-
-(defonce ^:private *agc-gain (atom 0.8))  ;; adaptive gain
-(defonce ^:private *agc-rms  (atom 0))  ;; smoothed rms (optional)
-
-(def mime "audio/mp4")
-
-;; ───────────────────────────────────────────────────────────────────────────────
-;; Visualizer state (constant-memory; safe for long sessions)
-
-(defonce ^:private *audio-ctx   (atom nil))
-(defonce ^:private *source-node (atom nil))
-(defonce ^:private *analyser    (atom nil))
-(defonce ^:private *raf-id      (atom nil))
-(defonce ^:private *last-ts     (atom 0)) ;; for FPS throttling
-
-(defonce ^:private *canvas-el   (atom nil))
-(defonce ^:private *canvas-ctx  (atom nil))
-(defonce ^:private *dpr         (atom 1))
-
-;; Reused typed arrays; we never reallocate every frame.
-(defonce ^:private *time-bytes  (atom nil))   ;; Uint8Array
-(defonce ^:private *time-floats (atom nil))   ;; Float32Array
-
-(defonce ^:private *vis-opts
-  (atom {:mode :rolling              ;; :rolling or :oscilloscope
-         :fps 30                     ;; throttle draw to this FPS
-         :fft-size 2048              ;; analyser.fftSize
-         :smoothing 1              ;; analyser.smoothingTimeConstant
-         :bg "#00000000"             ;; transparent by default
-         :stroke "rgb(167, 167, 167)"
-         :window-sec 5.0
-         :bar-width 2             ;; px width of each line
-         :bar-gap 2               ;; px gap between lines
-         :line-cap "round"}))
-
-(defonce ^:private *ring        (atom nil))  ;; Float32Array of bar amplitudes (0..1)
-(defonce ^:private *ring-head   (atom 0))    ;; next write index
-(defonce ^:private *accum-ms    (atom 0.0))
-(defonce ^:private *pending-max (atom 0.0))
-(defonce ^:private *agg-last-ts (atom 0.0))
-
-(defn- clamp [x a b] (-> x (max a) (min b)))
-
-(defn- now-ms [] (.now js/Date))
-
-(defn- ensure-buffer-size! [cap]
-  (let [^js arr @*ring]
-    (when (or (nil? arr) (not= (.-length arr) cap))
-      (reset! *ring (js/Float32Array. (max 1 cap)))
-      (reset! *ring-head 0)
-      (reset! *accum-ms 0.0)
-      (reset! *pending-max 0.0)
-      (reset! *agg-last-ts (now-ms)))))
-
-(defn- ring-advance! [steps amp]
-  (when-let [^js arr @*ring]
-    (let [n (.-length arr)
-          a (max 0.0 (min 1.0 amp))]
-      (loop [k (min steps n) head @*ring-head]
-        (if (pos? k)
-          (let [head' (let [h (inc head)] (if (= h n) 0 h))]
-            (aset arr head a)
-            (recur (dec k) head'))
-          (reset! *ring-head head))))))
-
-(defn- frame-peak [^js f32]           ;; max |sample| from current analyser frame
-  (let [len (.-length f32)
-        step (js/Math.max 1 (js/Math.floor (/ len 1024)))]
-    (loop [i 0 mx 0.0]
-      (if (< i len)
-        (let [v (js/Math.abs (aget f32 i))]
-          (recur (+ i step) (if (> v mx) v mx)))
-        mx))))
-
-(defn- ensure-canvas-size! []
-  (when-let [^js c @*canvas-el]
-    (let [dpr (or (.-devicePixelRatio js/window) 1)
-          css-w (.-clientWidth c)
-          css-h (.-clientHeight c)
-          px-w (js/Math.round (* css-w dpr))
-          px-h (js/Math.round (* css-h dpr))]
-      ;; (when (or (not= (.-width c) px-w)
-      ;;           (not= (.-height c) px-h))
-      ;;   (set! (.-width c) px-w)
-      ;;   (set! (.-height c) px-h))
-      (reset! *dpr dpr))))
-
-(declare draw-rolling!)
-(defn attach-visualizer!
-  "Attach a <canvas> for realtime waveform. Call before `start`.
-   opts:
-   - :fps         15..60 (default 30)
-   - :fft-size    512..32768 (default 2048)
-   - :smoothing   0.0..0.99 (default 0.8)
-   - :bg          CSS color (default transparent)
-   - :stroke      CSS color (default neutral gray)"
-  ([^js canvas] (attach-visualizer! canvas {}))
-  ([^js canvas opts]
-   (reset! *agc-gain 1.0)
-   (reset! *agc-rms  0.0)
-   (reset! *vis-opts (merge @*vis-opts opts))
-   (reset! *canvas-el canvas)
-   (ensure-canvas-size!)
-   (reset! *canvas-ctx (.getContext canvas "2d"))
-   ;; If analyser already exists (e.g., start called first), (re)start drawing.
-   (when @*analyser
-     (js/cancelAnimationFrame (or @*raf-id 0))
-     (reset! *raf-id nil)
-     (reset! *last-ts 0)
-     (when @*canvas-ctx
-       ((fn loop! []
-          (reset! *raf-id (js/requestAnimationFrame loop!))
-          (when (and @*canvas-ctx @*analyser)
-            (let [fps (:fps @*vis-opts)
-                  min-dt (/ 1000 (max 1 fps))
-                  t (now-ms)]
-              (when (>= (- t @*last-ts) min-dt)
-                (reset! *last-ts t)
-                (ensure-canvas-size!)
-                (draw-rolling!))))))))))
-
-(defn detach-visualizer!
-  "Detach and stop drawing (does NOT stop recording)."
-  []
-  (when @*raf-id
-    (js/cancelAnimationFrame @*raf-id)
-    (reset! *raf-id nil))
-  (reset! *canvas-el nil)
-  (reset! *canvas-ctx nil))
-
-(defn- setup-analyser! [^js stream]
-  ;; Create/reuse AudioContext. iOS requires resume on user gesture; we try anyway.
-  (let [ctx (or @*audio-ctx
-                (try
-                  (js/AudioContext. #js {:latencyHint "interactive"})
-                  (catch :default _ (js/AudioContext.))))]
-    (reset! *audio-ctx ctx)
-    (try
-      (.resume ctx)
-      (catch :default e
-        (fn [_] nil)))
-    (when @*source-node
-      (try (.disconnect ^js @*source-node) (catch :default _ nil)))
-    (let [src (.createMediaStreamSource ctx stream)
-          an  (.createAnalyser ctx)]
-      (set! (.-fftSize an) (max 512 (min 32768 (:fft-size @*vis-opts))))
-      (set! (.-smoothingTimeConstant an) (max 0 (min 0.99 (:smoothing @*vis-opts))))
-      (.connect src an)
-      (reset! *source-node src)
-      (reset! *analyser an)
-      ;; allocate (or resize) typed arrays once
-      (let [n (.-fftSize an)]
-        (when (or (nil? @*time-bytes) (not= (.-length ^js @*time-bytes) n))
-          (reset! *time-bytes  (js/Uint8Array. n))
-          (reset! *time-floats (js/Float32Array. n))))
-      ;; kick the paint loop if canvas already attached
-      (when @*canvas-el
-        (attach-visualizer! @*canvas-el @*vis-opts)))))
-
-(defn- teardown-analyser! []
-  (when @*raf-id
-    (js/cancelAnimationFrame @*raf-id)
-    (reset! *raf-id nil))
-  (when @*source-node
-    (try (.disconnect ^js @*source-node) (catch :default _ nil))
-    (reset! *source-node nil))
-  (reset! *analyser nil)
-  ;; Keep AudioContext open if you want; closing reduces battery usage after stop.
-  (when @*audio-ctx
-    (try (.close ^js @*audio-ctx) (catch :default _ nil))
-    (reset! *audio-ctx nil)))
-
-;; ───────────────────────────────────────────────────────────────────────────────
-;; Drawing
-
-(defn- clear! [^js ctx w h]
-  (let [bg (:bg @*vis-opts)]
-    (set! (.-fillStyle ctx) bg)
-    (.clearRect ctx 0 0 w h)
-    (when (and bg (not= bg "#00000000"))
-      (.fillRect ctx 0 0 w h))))
-
-(defn- stroke-style! [^js ctx]
-  (set! (.-lineWidth ctx) (or (:line-width @*vis-opts) 1))
-  (set! (.-strokeStyle ctx) (:stroke @*vis-opts))
-  (set! (.-lineCap ctx) "round"))
-
-(defn- draw-rolling! []
-  (when (and @*canvas-ctx @*analyser)
-    (let [^js ctx @*canvas-ctx
-          ^js an  @*analyser
-          ^js f32 @*time-floats
-          ^js c   @*canvas-el]
-      (ensure-canvas-size!)
-      (let [w (.-width c)  h (.-height c)]
-        (when (and (>= w 4) (>= h 4))
-          ;; layout
-          (let [{:keys [bar-width bar-gap direction window-sec gain auto-gain? line-cap]} @*vis-opts
-                bw   (max 1 (int (js/Math.round (or bar-width 2))))
-                gap  (max 0 (int (js/Math.round (or bar-gap 2))))
-                step (+ bw gap)
-                slots (max 1 (int (js/Math.floor (/ w step))))   ;; how many bars fit
-                dir  (or direction :rtl)]
-            (ensure-buffer-size! slots)
-
-            ;; 1) update ring from current audio frame
-            (.getFloatTimeDomainData an f32)
-            (let [p   (frame-peak f32)                      ;; 0..1
-                  _   (reset! *pending-max (max @*pending-max p))
-                  now (now-ms)
-                  last-ts @*agg-last-ts
-                  dt  (- now last-ts)
-                  win (double (or window-sec 5.0))
-                  bars-per-sec (/ slots (max 0.001 win))
-                  period-ms (/ 1000.0 (max 1e-6 bars-per-sec))
-                  total (+ @*accum-ms dt)
-                  steps (int (js/Math.floor (/ total period-ms)))
-                  accum   (- total (* steps period-ms))]
-              (when (pos? steps)
-                (ring-advance! steps @*pending-max)
-                (reset! *pending-max 0.0))
-              (reset! *accum-ms accum)
-              (reset! *agg-last-ts now))
-
-            ;; 2) render vertical lines
-            (let [halfH (/ h 2)
-                  g0  (or gain 1.0)
-                  agc (if (false? auto-gain?) 1.0 (or @*agc-gain 1.0))
-                  vScale (* g0 agc)
-                  ^js arr @*ring
-                  n   (.-length arr)
-                  head @*ring-head
-                  ;; crisp 1px alignment for odd widths
-                  odd-width? (== (bit-and bw 1) 1)
-                  x-center (fn [i] ;; i = 0..slots-1 oldest→newest on canvas depending on dir
-                             (let [offset (* i step)]
-                               (if (= dir :ltr)
-                                 (+ offset (/ bw 2))
-                                 (- w (/ bw 2) offset))))
-                  ring-at (fn [x-idx]
-                            (let [idx (if (= dir :ltr)
-                                        (mod (+ head x-idx) n)  ;; oldest→newest left→right
-                                        (mod (- head 1 x-idx) n))] ;; newest at right
-                              (aget arr idx)))]
-              (clear! ctx w h)
-              (set! (.-strokeStyle ctx) (:stroke @*vis-opts))
-              (set! (.-lineWidth ctx) bw)
-              (set! (.-lineCap ctx) (or line-cap "round"))
-              (set! (.-lineJoin ctx) "round")
-
-              (.beginPath ctx)
-              (loop [i 0]
-                (when (< i slots)
-                  (let [amp (max 0.0 (min 1.0 (ring-at i)))
-                        hpx (js/Math.max 1 (js/Math.round (* amp halfH vScale)))
-                        cx  (x-center i)
-                        x   (if odd-width? (+ (js/Math.round cx) 0.5) (js/Math.round cx))
-                        y1  (- halfH hpx)
-                        y2  (+ halfH hpx)]
-                    (.moveTo ctx x y1)
-                    (.lineTo ctx x y2)
-                    (recur (inc i)))))
-              (.stroke ctx)
-              (.closePath ctx))))))))
-
-;; ───────────────────────────────────────────────────────────────────────────────
-;; Recorder lifecycle (unchanged public API), but we hook the analyser in `start`
-
-(defn destroy!
-  []
-  ;; cleanup media stream & visualizer
-  (when @*stream
-    (doseq [t (.getTracks @*stream)] (.stop t)))
-  (reset! *stream nil)
-  (reset! *recorder nil)
-  (reset! *chunks #js [])
-  (teardown-analyser!))
-
-(defn- listen-on-stop
-  [recorder on-record-end]
-  (set! (.-onstop recorder)
-        (fn []
-          (p/do!
-            ;; some encoders flush async; small delay avoids truncated tails
-           (p/delay 200)
-           (let [blob (js/Blob. @*chunks #js {:type mime})]
-             (destroy!)
-             (on-record-end blob))))))
-
-(defn start
-  "Start recording mic to mp4. Also starts visualizer if a canvas is attached.
-   opts:
-   - :on-record-end (fn [blob])  (required)
-   - :timeslice ms (default 1000)"
-  [{:keys [on-record-end timeslice]
-    :or {timeslice 1000}}]
-  (-> (.getUserMedia js/navigator.mediaDevices #js {:audio true})
-      (p/then
-       (fn [s]
-         (reset! *chunks #js [])
-         (reset! *stream s)
-          ;; Hook analyser for realtime waveform (separate from MediaRecorder)
-         (setup-analyser! s)
-         (let [r (js/MediaRecorder. s #js {:mimeType mime
-                                           :audioBitsPerSecond 128000})]
-           (reset! *recorder r)
-           (listen-on-stop r on-record-end)
-           (set! (.-ondataavailable r)
-                 (fn [e]
-                   (when (and e (> (.-size (.-data e)) 0))
-                     (.push @*chunks (.-data e)))))
-           (set! (.-onerror r) (fn [e] (js/console.error "MediaRecorder error:" e)))
-           (.start r timeslice))))
-      (p/catch (fn [error]
-                 (log/error ::audio-record-failed error)))))
-
-(defn stop
-  []
-  (when-let [r @*recorder]
-    (when (= (.-state r) "recording")
-      (.stop r))))
-
-;; ───────────────────────────────────────────────────────────────────────────────
-;; Optional helpers
-
-(defn pause-visualizer! []
-  (when @*raf-id
-    (js/cancelAnimationFrame @*raf-id)
-    (reset! *raf-id nil)))
-
-(defn resume-visualizer! []
-  (when (and @*canvas-el @*analyser (nil? @*raf-id))
-    (attach-visualizer! @*canvas-el @*vis-opts)))

+ 4 - 4
yarn.lock

@@ -1672,10 +1672,10 @@
   resolved "https://registry.yarnpkg.com/@xtuc/long/-/long-4.2.2.tgz#d291c6a4e97989b5c61d9acf396ae4fe133a718d"
   integrity sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==
 
-"@xyhp915/simple-wave-record@^0.0.2":
-  version "0.0.2"
-  resolved "https://registry.yarnpkg.com/@xyhp915/simple-wave-record/-/simple-wave-record-0.0.2.tgz#cfedf0b37f4cb7e077e2be42253894e913bb2393"
-  integrity sha512-KSMSTii8D+tXPyrjg/kiPjNR1LPWFGWApUVdN2QLpfiM1O3LRxM7/oGS7ZZFFiH8wXtiCS3d5dGp4s7K5KZVGg==
+"@xyhp915/simple-wave-record@^0.0.3":
+  version "0.0.3"
+  resolved "https://registry.yarnpkg.com/@xyhp915/simple-wave-record/-/simple-wave-record-0.0.3.tgz#0ee8ebadf3c24a634fd65f9513907d077235a2ce"
+  integrity sha512-C+lXOh8TWkO41IQEz+NwbQomAoS3XSBtf2aogvmclR1Ss9/BYKJyHQVKO8SEYWqsMNbL4C2logwD1xGrZvOqdg==
 
 JSONStream@^1.0.4:
   version "1.3.5"