diff --git a/frontend/src/arthur/audio/mix.cljs b/frontend/src/arthur/audio/mix.cljs index f97891a..f03710c 100644 --- a/frontend/src/arthur/audio/mix.cljs +++ b/frontend/src/arthur/audio/mix.cljs @@ -192,6 +192,23 @@ (.arrayBuffer response))) (.then decode-bytes!))) +(defn fit-buffer + "Fit fallback audio to the open timeline, padding with silence or trimming. + The buffer and clock must share a duration so looping wraps at the timeline's + end rather than repeating a short soundtrack underneath a longer animation." + [^js buffer seconds] + (let [rate (.-sampleRate buffer) + frames (max 1 (js/Math.ceil (* seconds rate)))] + (if (= frames (.-length buffer)) + buffer + (let [channels (.-numberOfChannels buffer) + fitted (.createBuffer (js/OfflineAudioContext. channels 1 rate) + channels frames rate)] + (dotimes [c channels] + (.set (.getChannelData fitted c) + (.subarray (.getChannelData buffer c) 0 (min frames (.-length buffer))))) + fitted)))) + (defn clock-source! "Promise of `{:buffer :seconds}` — the audio the transport runs its clock on while symbol `sid` is open, and how long that clock is. @@ -216,7 +233,9 @@ (and fallback-url (= sid (clip/opens-on document))) (-> (decode! fallback-url) - (.then (fn [^js b] {:buffer b :seconds (.-duration b)}))) + (.then (fn [b] + (let [seconds (/ (clip/output-frames document sid) (:fps document))] + {:buffer (fit-buffer b seconds) :seconds seconds})))) :else {:buffer nil @@ -236,12 +255,8 @@ symbol played against the whole take's soundtrack would run ten frames and then keep the clock going for minutes." [document sid fallback-url store] - (-> (buffer! document sid store) - (.then (fn [buffer] - (cond - buffer (wav-url buffer) - (and fallback-url (= sid (clip/opens-on document))) fallback-url - :else (let [rate 44100 - n (max 1 (js/Math.ceil (* rate (/ (clip/output-frames document sid) - (:fps document)))))] - (wav-url (.createBuffer (js/OfflineAudioContext. 1 1 rate) 1 n rate)))))))) + (-> (clock-source! document sid fallback-url store) + (.then (fn [{:keys [buffer seconds]}] + (wav-url (or buffer + (.createBuffer (js/OfflineAudioContext. 1 1 44100) + 1 (max 1 (js/Math.ceil (* 44100 seconds))) 44100))))))) diff --git a/frontend/test/arthur/audio/mix_test.cljs b/frontend/test/arthur/audio/mix_test.cljs new file mode 100644 index 0000000..d169575 --- /dev/null +++ b/frontend/test/arthur/audio/mix_test.cljs @@ -0,0 +1,25 @@ +(ns arthur.audio.mix-test + (:require [arthur.audio.mix :as mix] + [cljs.test :refer [deftest is]])) + +(defn- buffer [samples] + (let [data (js/Float32Array. (clj->js samples))] + #js {:sampleRate 12 :length (count samples) :numberOfChannels 1 + :getChannelData (fn [_] data)})) + +(deftest fallback-audio-follows-the-timeline-length + (let [original (.-OfflineAudioContext js/globalThis)] + (set! (.-OfflineAudioContext js/globalThis) + (fn [_ _ _] + #js {:createBuffer (fn [_ frames _] (buffer (repeat frames 0)))})) + (try + (let [short (buffer (repeat 92 0.5)) + fitted (mix/fit-buffer short (/ 5008 12)) + data (.getChannelData fitted 0)] + (is (= 5008 (.-length fitted))) + (is (= 0.5 (aget data 91))) + (is (= 0 (aget data 92))) + (is (= 0 (aget data 5007))) + (is (identical? short (mix/fit-buffer short (/ 92 12)))) + (is (= 20 (.-length (mix/fit-buffer short (/ 20 12)))))) + (finally (set! (.-OfflineAudioContext js/globalThis) original)))))