Soundcljoud gets more cloudy

A logo of a face wearing a red hoodie with orange sunglasses featuring the Soundcloud logo

Last time on "Soundcljoud, or a young man's Soundcloud clonejure", I promised to clone Soundcloud, but then got bogged down in telling the story of my life and never got around to the actual cloning part. ๐Ÿ˜ฌ

To be fair to myself, I did do a bunch of stuff to prepare for cloning, so now we can get to it with no further ado! (Skipping the ado bit is very out of character for me, I know. I'll just claim this parenthetical as my ado and thus fulfil your expectations of me as the most verbose writer in the Clojure community. You're welcome!)

Popping in a Scittle

If you've followed along with any of my other cloning adventures, you'll know where I'm going with this: straight to Scittle Town!

I'll start by creating a player directory and dropping a bb.edn into it:

{:deps {io.github.babashka/sci.nrepl
        {:git/sha "2f8a9ed2d39a1b09d2b4d34d95494b56468f4a23"}
        io.github.babashka/http-server
        {:git/sha "b38c1f16ad2c618adae2c3b102a5520c261a7dd3"}}
 :tasks {http-server {:doc "Starts http server for serving static files"
                      :requires ([babashka.http-server :as http])
                      :task (do (http/serve {:port 1341 :dir "public"})
                                (println "Serving static assets at http://localhost:1341"))}

         browser-nrepl {:doc "Start browser nREPL"
                        :requires ([sci.nrepl.browser-server :as bp])
                        :task (bp/start! {})}

         -dev {:depends [http-server browser-nrepl]}

         dev {:task (do (run '-dev {:parallel true})
                        (deref (promise)))}}}

In short, what's happening here is I'm setting up a Babashka project with a dev task that starts a webserver on port 1341 serving up the files in the public/ directory, starts an nREPL server on port 1339 that we can connect to with Emacs (or any inferior text editor of your choosing), and a websocket server on port 1340 that is connected to the nREPL server on one end and waiting for a ClojureScript app to connect to the other end.

Speaking of the public/ directory, I need a public/index.html file to serve up:

<!doctype html>
<html class="no-js" lang="">

<head>
    <meta charset="utf-8">
    <meta http-equiv="x-ua-compatible" content="ie=edge">
    <title>Soundcljoud</title>
    <meta name="description" content="">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="stylesheet" href="style.css">

    <script src="https://cdn.jsdelivr.net/npm/scittle@0.6.15/dist/scittle.js" type="application/javascript"></script>
    <script src="https://cdn.jsdelivr.net/npm/scittle@0.6.15/dist/scittle.promesa.js" type="application/javascript"></script>
    <script>var SCITTLE_NREPL_WEBSOCKET_PORT = 1340;</script>
    <script src="https://cdn.jsdelivr.net/npm/scittle@0.6.15/dist/scittle.nrepl.js"
        type="application/javascript"></script>
    <script type="application/x-scittle" src="soundcljoud.cljs"></script>
</head>

<body>
  <h1>Soundcljoud</h1>
  <div id="wrapper" style="display: none;">
    <div id="player">
      <div class="cover-image">
        <img src="" alt="" />
      </div>
      <div id="controls">
        <audio controls src=""></audio>
        <div id="tracks" style=""></div>
      </div>
    </div>
  </div>
</body>

</html>

The index.html file loads three JavaScript scripts:

  1. Scittle itself, which knows how to interpret ClojureScript scripts
  2. The Scittle Promesa plugin, which provides some niceties for dealing with promises
  3. The Scittle nREPL plugin, which will connect to that websocket server on port 1340 and complete the circuit that will allow us to REPL-drive our browser from Emacs (or the inferior text editor of your choosing)

Once this JavaScript is in place, index.html loads the soundcljoud.cljs ClojureScript file, which we'll come to in just a second.

For a (much) more detailed explanation, refer to the Popping in a Scittle section of my cljcastr, or a young man's Zencastr clonejure blog post.

The body of index.html is all about setting up a basic HTML page with this structure:

+----------------------+
| Soundcljoud          |
+-------+--------------+  <---
| Album | Audio player |      }
| cover +--------------+      } <div id="wrapper">
| image | Tracks list  |      }
+-------+--------------+  <---

Note that everything inside the wrapper div is hidden from the start:

  <div id="wrapper" style="display: none;">

We don't know anything about the album we want to display yet, and there's no point in showing a bunch of empty divs until we do.

Let's drop a public/style.css in as well:

body {
  font:
    1.2em Helvetica,
    Arial,
    sans-serif;
  margin: 20px;
  padding: 0;
}

img {
  max-width: 100%;
}

#wrapper {
  max-width: 960px;
  margin: 2em auto;
}

#controls {
  display: flex;
  flex-direction: column;
  gap: 5px;
}

#tracks {
  display: flex;
  flex-direction: column;
  gap: 3px;
}

@media screen and (min-width: 900px) {
  #wrapper {
    display: flex;
  }

  #player {
    display: flex;
    gap: 3%;
  }

  #cover-image {
    margin-right: 5%;
    max-width: 60%;
  }

  #controls {
    width: 25%;
  }
}

All of this stuff is about using screen real estate effectively. The first chunk of CSS applies universally, but the bit inside this:

@media screen and (min-width: 900px) {
  /* ... */
}

only applies to windows at least 900px wide. So our page defaults to a layout that's appropriate for phones (or really narrow browser windows), but then adjusts to move more content "above the fold" so you can probably see the entire UI without scrolling if you're viewing the page on a standard computer.

Now that we have all of the HTML and CSS plumbing in place, let's add a public/soundcljoud.cljs file to get started with some ClojureScripting:

(ns soundcljoud
  (:require [promesa.core :as p]))

Firing up the REPL

Before we can start REPL-driving, we need to put the key in the ignition and give it a right twist! In other words, we open up a terminal in the top-level player/ directory and invoke Babashka:

: jmglov@alhana; bb dev
Serving static assets at http://localhost:1341
nREPL server started on port 1339...
Websocket server started on 1340...

If we now connect to http://localhost:1341/, we'll be rewarded with a simple webpage:

Screenshot of a web browser window saying Soundcljoud

This by itself is of course monumentally boring, so let's inject some excitement into our lives by jumping into soundcljoud.cljs and pressing C-c l C (cider-connect-cljs), selecting localhost, port 1339, and nbb for the REPL type (assuming you're in Emacs; if you're using some other editor, perform the incantations necessary to connect your ClojureScript REPL to localhost:1339).

If everything went according to plan, you should see something like this in your terminal window:

:msg "{:versions
       {\"scittle-nrepl\"
        {\"major\" \"0\", \"minor\" \"0\", \"incremental\" \"1\"}},
       :ops
       {\"complete\" {}, \"info\" {}, \"lookup\" {}, \"eval\" {},
        \"load-file\" {}, \"describe\" {}, \"close\" {}, \"clone\" {},
        \"eldoc\" {}},
       :status [\"done\"],
       :id \"3\",
       :session \"3264dc1e-1b46-48a6-b11a-f606fea032b7\",
       :ns \"soundcljoud\"}"
:msg "{:value \"nil\",
       :id \"5\",
       :session \"3264dc1e-1b46-48a6-b11a-f606fea032b7\",
       :ns \"soundcljoud\"}"
:msg "{:status [\"done\"],
       :id \"5\",
       :session \"3264dc1e-1b46-48a6-b11a-f606fea032b7\",
       :ns \"soundcljoud\"}"

And something like this in your editor's REPL window:

;; Connected to nREPL server - nrepl://localhost:1339
;; CIDER 1.12.0 (Split)
;;
;; ClojureScript REPL type: nbb
;;
nil> 

Let's prove that it works by evaluating the buffer with C-c C-k (cider-load-buffer), adding a Rich comment, putting some ClojureScript in there that grabs our wrapper div, positioning our cursor at the end of the form, and evaluating that sucker with C-c C-v f c e (cider-pprint-eval-last-sexp-to-comment):

(ns soundcljoud
  (:require [promesa.core :as p]))

(comment

  (js/document.querySelector "#wrapper")
  ;; => #object[HTMLDivElement [object HTMLDivElement]]

)

We've proven that we can evaluate ClojureScript code in the running browser process from our REPL buffer, which is nifty for sure, but our page still bores us, and the result of evaluating that code is pretty useless:

#object[HTMLDivElement [object HTMLDivElement]]

Let's actually do something with the div we've pulled down, and whilst we're at it, provide a useful way of logging stuff:

(ns soundcljoud
  (:require [promesa.core :as p]))

(defn log
  ([msg]
   (log msg nil))
  ([msg obj]
   (if obj
     (js/console.log msg obj)
     (js/console.log msg))
   obj))

(comment

  (let [div (js/document.querySelector "#wrapper")]
    (set! (.-style div) "display: flex")
    (log "All is revealed!" div))
  ;; => #object[HTMLDivElement [object HTMLDivElement]]

)

Screenshot of a web browser window with an audio player

Fantastic! By using js/document.log (by the way, that js/ prefix is the way you instruct ClojureScript to do some JavaScript interop; it's basically saying "look for the next symbol in the top-level scope in JavaScript land"), we now get the fancy inspection tools in the browser's JavaScript console so we can expand parts of the object and drill down to see stuff we're interested in.

Now that we've established a baseline, we can get stuck in and do some real work. ๐Ÿ’ช๐Ÿป

Reading some RSS

Do you remember the MP3 files and RSS feed we prepared in the previous blog post? Let's plop those down in our public/ directory so we can access them from the webapp we're slowly constructing:

: jmglov@alhana; mkdir -p 'public/Garth Brooks/Fresh Horses'

: jmglov@alhana; cp /tmp/soundcljoud.12524185230907219576/*.{rss,mp3} !$

: jmglov@alhana; ls -1 !$
album.rss
'Garth Brooks - Cowboys and Angels.mp3'
'Garth Brooks - Ireland.mp3'
"Garth Brooks - It's Midnight Cinderella.mp3"
"Garth Brooks - Rollin'.mp3"
"Garth Brooks - She's Every Woman.mp3"
"Garth Brooks - That Ol' Wind.mp3"
'Garth Brooks - The Beaches of Cheyenne.mp3'
'Garth Brooks - The Change.mp3'
'Garth Brooks - The Fever.mp3'
'Garth Brooks - The Old Stuff.mp3'

Now that our files are in place, let's see about loading the RSS feed from ClojureScript:

(comment

  (def base-path "/Garth+Brooks/Fresh+Horses")
  ;; => #'soundcljoud/base-path

  (p/->> (js/fetch (js/Request. (str base-path "/album.rss")))
         (.text)
         (log "Fetched XML:"))
  ;; => #<Promise[~]>

)

In our console, we can see what we fetched:

Fetched XML: <?xml version='1.0' encoding='UTF-8'?>
<rss version="2.0"
     xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"
     xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <atom:link
        href="http://localhost:1341/Garth+Brooks/Fresh+Horses/album.rss"
        rel="self"
        type="application/rss+xml"/>
    <title>Garth Brooks - Fresh Horses</title>
    <link>https://api.discogs.com/masters/212114</link>
    <pubDate>Sun, 01 Jan 1995 00:00:00 +0000</pubDate>
    <itunes:subtitle>Album: Garth Brooks - Fresh Horses</itunes:subtitle>
    <itunes:author>Garth Brooks</itunes:author>
    <itunes:image href="https://i.discogs.com/0eLXmM1tK1grkH8cstgDT6eV2TlL0NvgWPZBoyScJ_8/rs:fit/g:sm/q:90/h:600/w:600/czM6Ly9kaXNjb2dz/LWRhdGFiYXNlLWlt/YWdlcy9SLTY4NDcx/Ny0xNzE3NDU5MDIy/LTMxNjguanBlZw.jpeg"/>
    
    <item>
      <itunes:title>The Old Stuff</itunes:title>
      <title>The Old Stuff</title>
      <itunes:author>Garth Brooks</itunes:author>
      <enclosure
          url="http://localhost:1341/Garth+Brooks/Fresh+Horses/Garth+Brooks+-+The+Old+Stuff.mp3"
          length="5943424" type="audio/mpeg" />
      <pubDate>Sun, 01 Jan 1995 00:00:00 +0000</pubDate>
      <itunes:duration>252</itunes:duration>
      <itunes:episode>1</itunes:episode>
      <itunes:episodeType>full</itunes:episodeType>
      <itunes:explicit>false</itunes:explicit>
    </item>
   ... 
    <item>
      <itunes:title>Ireland</itunes:title>
      <title>Ireland</title>
      <itunes:author>Garth Brooks</itunes:author>
      <enclosure
          url="http://localhost:1341/Garth+Brooks/Fresh+Horses/Garth+Brooks+-+Ireland.mp3"
          length="6969472" type="audio/mpeg" />
      <pubDate>Sun, 01 Jan 1995 00:00:00 +0000</pubDate>
      <itunes:duration>301</itunes:duration>
      <itunes:episode>10</itunes:episode>
      <itunes:episodeType>full</itunes:episodeType>
      <itunes:explicit>false</itunes:explicit>
    </item>
    
  </channel>
</rss>

That looks quite familiar! That also looks like a bunch of text, which is not the nicest thing to extract data from. Luckily, that's a bunch of structured text, and more luckily, it's XML (XML is great, and don't let anyone tell you otherwise! And don't get me started on how we've reinvented XML but poorly with JSON Schema and all of this other nonsense we've built up around JSON because we realised that things like data validation are important when exchanging data between machines. ๐Ÿคฆ๐Ÿผโ€โ™‚๏ธ), and most luckily of all, browsers know how to parse XML (which makes sense, as modern HTML is in fact XML):

(defn parse-xml [xml-str]
  (.parseFromString (js/window.DOMParser.) xml-str "text/xml"))

(comment

  (p/->> (js/fetch (js/Request. (str base-path "/album.rss")))
         (.text)
         parse-xml
         (log "Fetched XML:"))
  ;; => #<Promise[~]>

)

Screenshot of a web browser window with an XML document in the JS console

Let's do the right thing and make a function out of this:

(defn fetch-xml [path]
  (p/->> (js/fetch (js/Request. path))
         (.text)
         parse-xml
         (log "Fetched XML:")))

Now that we know how to fetch and parse XML, let's see how to extract useful information from it. Looking at the log output, we can see that the parsed XML is of type #document, just like our good friend js/document (the current webpage that the browser is displaying). That's right, we have a Document Object Model, which means we can use all the tasty DOM functions we're used to, such as document.querySelector() to grab a node using an XPATH query.

Let's start with the album title:

(comment

  (p/let [title (p/-> (fetch-xml (str base-path "/album.rss"))
                      (.querySelector "title")
                      (.-innerHTML))]
    (set! (.-innerHTML (js/document.querySelector "h1")) title))
  ;; => #<Promise[~]>

)

Cool! We now see "Garth Brooks - Fresh Horses" as our page heading! Let's see about grabbing the album art next:

(comment

  (p/let [xml (fetch-xml (str base-path "/album.rss"))
          title (p/-> xml
                      (.querySelector "title")
                      (.-innerHTML))
          image (p/-> xml
                      (.querySelector "image")
                      (.getAttribute "href"))]
    (set! (.-innerHTML (js/document.querySelector "h1")) title)
    (set! (.-src (js/document.querySelector ".cover-image > img")) image)
    (set! (.-style (js/document.querySelector "#wrapper")) "display: flex;"))
  ;; => #<Promise[~]>

)

Screenshot of a web browser window with the album art for Fresh Horses and an audio player

Before we go any further, let's create some functions from this big blob of code. At the moment, we're complecting two things:

  1. Extracting data from the XML DOM
  2. Updating the HTML DOM to display the data

Let's do the functional programming thing and create a purely functional core and a mutable shell. Instead of extracting and updating, we'll create a function that transforms the XML DOM representation of an album into a ClojureScript representation:

(defn xml-get [el k]
  (-> el
      (.querySelector k)
      (.-innerHTML)))

(defn xml-get-attr [el k attr]
  (-> el
      (.querySelector k)
      (.getAttribute attr)))

(defn ->album [xml]
  {:title (xml-get xml "title")
   :image (xml-get-attr xml "image" "href")})

(defn load-album [path]
  (p/-> (fetch-xml path) ->album))

(comment

  (p/let [{:keys [title image] :as album} (load-album (str base-path "/album.rss"))]
    (set! (.-innerHTML (js/document.querySelector "h1")) title)
    (set! (.-src (js/document.querySelector ".cover-image > img")) image)
    (set! (.-style (js/document.querySelector "#wrapper")) "display: flex;"))
  ;; => #<Promise[~]>

)

Now that we have a nice ClojureScript data structure to represent our album, let's tackle the DOM mutations we need to do to display the album:

(defn get-el [selector]
  (if (instance? js/HTMLElement selector)
    selector  ; already an element; just return it
    (js/document.querySelector selector)))

(defn set-styles! [el styles]
  (set! (.-style el) styles))

(defn display-album! [{:keys [title image] :as album}]
  (let [header (get-el "h1")
        cover (get-el ".cover-image > img")
        wrapper (get-el "#wrapper")]
    (set! (.-innerHTML header) title)
    (set! (.-src cover) image)
    (set-styles! wrapper "display: flex;")
    album))

(comment

  (p/-> (load-album (str base-path "/album.rss")) display-album!)
  ;; => #<Promise[~]>

)

Tracking down the tracks

Displaying the album title and cover art is all well and good, but in order to complete our Soundcloud clone, we need some way of actually listening to the music on the album. If you recall, our RSS feed contains a series of <item> tags representing the tracks:

    <item>
      <itunes:title>The Old Stuff</itunes:title>
      <title>The Old Stuff</title>
      <itunes:author>Garth Brooks</itunes:author>
      <enclosure
          url="http://localhost:1341/Garth+Brooks/Fresh+Horses/Garth+Brooks+-+The+Old+Stuff.mp3"
          length="5943424" type="audio/mpeg" />
      <pubDate>Sun, 01 Jan 1995 00:00:00 +0000</pubDate>
      <itunes:duration>252</itunes:duration>
      <itunes:episode>1</itunes:episode>
      <itunes:episodeType>full</itunes:episodeType>
      <itunes:explicit>false</itunes:explicit>
    </item>
   ... 
    <item>
      <itunes:title>Ireland</itunes:title>
      <title>Ireland</title>
      <itunes:author>Garth Brooks</itunes:author>
      <enclosure
          url="http://localhost:1341/Garth+Brooks/Fresh+Horses/Garth+Brooks+-+Ireland.mp3"
          length="6969472" type="audio/mpeg" />
      <pubDate>Sun, 01 Jan 1995 00:00:00 +0000</pubDate>
      <itunes:duration>301</itunes:duration>
      <itunes:episode>10</itunes:episode>
      <itunes:episodeType>full</itunes:episodeType>
      <itunes:explicit>false</itunes:explicit>
    </item>

What we need from each item in order to display and play the track is:

Let's write an aspirational function that assumes it will be called with a DOM element representing an <item> and transforms it into a ClojureScript map, just as we did for the item itself:

(defn ->track [item-el]
  {:artist (xml-get item-el "author")
   :title (xml-get item-el "title")
   :number (-> (xml-get item-el "episode") js/parseInt)
   :src (xml-get-attr item-el "enclosure" "url")})

For the track number, we need to convert it to an integer, since the text contents of an XML elements are, well, text, and we'll want to sort our tracks numerically.

Now that we have a function to convert an <item> into a track, let's plug that into our ->album function to add a list of tracks to the album:

(defn ->album [xml]
  {:title (xml-get xml "title")
   :image (xml-get-attr xml "image" "href")
   :tracks (->> (.querySelectorAll xml "item")
                (map ->track)
                (sort-by :number))})

OK, we have data representing a list of tracks, so we need to consider how we want to display it. If we cast our mind back to our HTML, we have a div where the tracks should go:

<body>
  ...
  <div id="wrapper" style="display: none;">
    <div id="player">
      ...
      <div id="controls">
        ...
        <div id="tracks" style=""></div>
      </div>
    </div>
  </div>
</body>

What we can do is create a <span> for each track, something like this:

<span>1. The Old Stuff</span>

Let's go ahead and write that function:

(defn track->span [{:keys [number artist title] :as track}]
  (let [span (js/document.createElement "span")]
    (set! (.-innerHTML span) (str number ". " title))
    span))

(comment

  (p/->> (load-album (str base-path "/album.rss"))
         :tracks
         first
         track->span
         (log "The first track is:"))
  ;; => #<Promise[~]>

)

In the JavaScript console, we see:

The first track is: <span>1. The Old Stuff</span>

This is cool, because the track->span function is still pureโ€”there's no mutation occurring there. We have one and only one place where that's doing mutation, and that's display-album!, which is where we can hook into our functional core and display the tracks. In order to do that, we'll take our list of tracks, turn them into a list of <span> elements, and then set them as the children of the #tracks div.

(defn set-children! [el children]
  (.replaceChildren el)
  (doseq [child children]
    (.appendChild el child))
  el)

(defn display-album! [{:keys [title image tracks] :as album}]
  (let [header (get-el "h1")
        cover (get-el ".cover-image > img")
        wrapper (get-el "#wrapper")]
    (set! (.-innerHTML header) title)
    (set! (.-src cover) image)
    (->> tracks
         (map track->span)
         (set-children! (get-el "#tracks")))
    (set-styles! wrapper "display: flex;")
    album))

(comment

  (p/-> (load-album (str base-path "/album.rss")) display-album!)
  ;; => #<Promise[~]>

)

Screenshot of a web browser window with the album art for Fresh Horses, an audio player, and a list of tracks

This is fantastic... if all we want to do is know what's on an album. But of course my initial problem was wanting to listen to Garth and not having a way to do that. Now I have written much Clojure and ClojureScript, and still cannot listen to Garth. ๐Ÿค”

Play it again, Sam

Of course what I do have is an HTML <audio> element and an MP3 file with a source URL, and I bet if I can just put these two things together, my ears will soon be filled with the sweet sweet sounds of 90s country music.

Let's start out with the simplest thing we can do, which is to activate the first track on the album once it's loaded. Since display-album! returns the album, we can just add some code to the end of the pipeline:

(comment

  (def base-path "http://localhost:1341/Garth+Brooks/Fresh+Horses")
  ;; => #'soundcljoud/base-path

  (p/->> (load-album (str base-path "/album.rss"))
         display-album!
         :tracks
         first
         :src
         (set! (.-src (get-el "audio"))))
  ;; => #<Promise[~]>

)

As soon as we evaluate this code, the <audio> element comes to life, displaying a duration and activating the play button. Pressing the play button, we do in fact hear some Garth! ๐ŸŽ‰

However, our UX is quite poor, since there's no visual representation of which track is playing. We can fix this by emboldening the active track:

(comment

  (p/let [{:keys [number src] :as track}
          (p/->> (load-album (str base-path "/album.rss"))
                 display-album!
                 :tracks
                 first)]
    (-> (get-el "#tracks")
        (.-children)
        seq
        (nth (dec number))
        (set-styles! "font-weight: bold;"))
    (set! (.-src (get-el "audio")) src))
  ;; => #<Promise[~]>

)

Screenshot of our UI with the first track highlighted and loaded in the audio element

Speaking of UX, though, one would imagine that they'd be able to change to a track by clicking on it. At the moment, clicking does nothing, but that's easy enough to fix by adding an event handler to our span for each track that activates the track. Let's create a function and shovel our track activating code in there:

(defn activate-track! [{:keys [number src] :as track}]
  (log "Activating track:" (clj->js track))
  (let [track-spans (seq (.-children (get-el "#tracks")))]
    (-> track-spans
        (nth (dec number))
        (set-styles! "font-weight: bold;")))
  (set! (.-src (get-el "audio")) src)
  track)

By the way, that clj->js function takes a ClojureScript data structure (in this case, our track map) and recursively transforms it into a JavaScript object so it can be printed nicely in the JS console.

OK, now that we have activate-track! as a function, we can use it in a click handler:

(defn track->span [{:keys [number title] :as track}]
  (let [span (js/document.createElement "span")]
    (set! (.-innerHTML span) (str number ". " title))
    (.addEventListener span "click" (partial activate-track! track))
    span))

(comment

  (p/-> (load-album (str base-path "/album.rss"))
        display-album!
        :tracks
        first
        activate-track!)
  ;; => #<Promise[~]>

)

Evaluating this code activates the first track on the album as before, and then clicking another track highlights it in bold and loads it into the <audio> element. That's good, but what isn't so good is that the first track stays bold. ๐Ÿ˜ฌ

Luckily, there's an easy fix for this. All we need to do is reset the weight of all the track spans before bolding the active one in activate-track!:

(defn activate-track! [{:keys [number src] :as track}]
  (log "Activating track:" (clj->js track))
  (let [track-spans (seq (.-children (get-el "#tracks")))]
    (doseq [span track-spans]
      (set-styles! span "font-weight: normal;"))
    (-> track-spans
        (nth (dec number))
        (set-styles! "font-weight: bold;")))
  (set! (.-src (get-el "audio")) src)
  track)

Amazing!

Whilst we're ticking off UX issues, let's think about what should happen when our user clicks on a different track. At the moment, we load the track into the player and then the user has to click the play button to start listening to it. That is perfectly reasonable when first loading the album, but if I'm listening to a track and then select another one, I would kinda expect the new track to start playing automatically instead of me having to click play manually.

Let's see how we can do this. According to the HTMLMediaElement documentation, our <audio> element should have paused attribute telling us whether playback is happening. Let's try it out:

(comment

  (p/-> (load-album (str base-path "/album.rss"))
        display-album!
        :tracks
        first
        activate-track!)
  ;; => #<Promise[~]>

  (-> (get-el "audio")
      (.-paused))
  ;; => true

)

Now if we click the play button and check the value of the paused attribute again:

(comment

  (-> (get-el "audio")
      (.-paused))
  ;; => false

)

Excellent! Now let's see how we programatically start playing a newly loaded track. Referring back to the documentation, we discover a HTMLMediaElement.play() method. Let's try that out:

(comment

  (p/-> (load-album (str base-path "/album.rss"))
        display-album!
        :tracks
        second
        activate-track!)
  ;; => #<Promise[~]>

  (-> (get-el "audio")
      (.play))
  ;; => #<Promise[~]>

)

Evaluating this code results in "Cowboys and Angels" starting to play!

Now we can use what we've learned to teach activate-track! to start playing the track when appropriate:

(defn activate-track! [{:keys [number src] :as track}]
  (log "Activating track:" (clj->js track))
  (let [track-spans (seq (.-children (get-el "#tracks")))
        audio-el (get-el "audio")
        paused? (.-paused audio-el)]
    (doseq [span track-spans]
      (set-styles! span "font-weight: normal;"))
    (-> track-spans
        (nth (dec number))
        (set-styles! "font-weight: bold;"))
    (set! (.-src audio-el) src)
    (when-not paused?
      (.play audio-el)))
  track)

(comment

  (p/-> (load-album (str base-path "/album.rss"))
        display-album!
        :tracks
        first
        activate-track!)
  ;; => #<Promise[~]>

)

When the album loads, the first track is activated but doesn't start playing. Clicking on another track activates it but doesn't start playing it. However, if we click the play button and start listening to the active track, then click on another track, the new track is activated and immediately starts playing.

This, my friends, is some seriously good UX! Of course, we can improve it further.

Keep playing it, Sam

The next UX nit that we should pick is the fact that when a track ends, our poor user has to manually click on the next track and then manually click the play button just to keep listening to the album. This seems a bit mean of us, so let's see what we can do in order to be the nice people that we know we are, deep down inside.

Our good friend HTMLMediaElement has a bunch of events that tell us useful things about what's happening with the media, and one of these events is ended:

Fired when playback stops when end of the media (

This seems like it will fit the bill quite nicely. Hopping back in our hammock for a minute, we think about what should happen when the end of a track is reached:

We can of course add a ended event listener to the <audio> element every time a new track is activated, but this is problematic because we would then want to remove the previous event listener, and it turns out that removing event listeners is a bit complicated. What if we instead had an event listener that knew what track was currently playing, where that track comes in the album, and what track (if any) is next? Then we'd only have to attach a listener once, right after we load the album. Let's think through how we could do that.

So far, we've been relying on the state of the DOM to tell us things like if the track is paused. A much more functional approach would be to control the state ourselves using immutable data structures and so on. A nice side effect of this (sorry, Haskell folks, Clojurists are just fine with uncontrolled side effects) is that it actually makes REPL-driven development easier as well! ๐Ÿคฏ

Let's start by extracting a function to handle the tedium of loading the album, displaying it, and then activating the first track:

(defn load-ui! [dir]
  (p/->> (load-album (str dir "/album.rss"))
         display-album!
         :tracks
         first
         activate-track!))

Now that we have this, we'll define a top-level atom to hold the state, then update our load-ui! function to stuff the album into the atom once it's loaded:

(def state (atom nil))

(defn load-ui! [dir]
  (p/->> (load-album (str dir "/album.rss"))
         display-album!
         (assoc {} :album)
         (reset! state)
         :album
         :tracks
         first
         activate-track!))

What we're doing here is creating a map to hold the state, then assoc-ing the loaded album into the map under the :album key, then putting that map into the state atom with reset!, which returns the new value saved in the atom, which is the one we just put in there, which will look like this:

{:title "Garth Brooks - Fresh Horses",
 :image "https://i.discogs.com/.../LTMxNjguanBlZw.jpeg",
 :tracks
 ({:artist "Garth Brooks",
   :title "The Old Stuff",
   :number 1,
   :src "http://localhost:1341/Garth+Brooks/Fresh+Horses/Garth+Brooks+-+The+Old+Stuff.mp3"}
  ...
  {:artist "Garth Brooks",
   :title "Ireland",
   :number 10,
   :src "http://localhost:1341/Garth+Brooks/Fresh+Horses/Garth+Brooks+-+Ireland.mp3"}), :paused? true}

We'll then grab the album back out of the map and proceed as before to activate the first track. This is a little gross, but we'll clean it up as we go.

Oh yeah, and remember when I promised this would make debugging easier? Check this out:

(comment

  (load-ui! "http://localhost:1341/Garth+Brooks/Fresh+Horses")
  ;; => #<Promise[~]>

  @state
  ;; => {:album {:title "Garth Brooks - Fresh Horses",
  ;;             :image "https://i.discogs.com/.../LTMxNjguanBlZw.jpeg",
  ;;             :tracks
  ;;             ({:artist "Garth Brooks",
  ;;               :title "The Old Stuff",
  ;;               :number 1,
  ;;               :src "http://localhost:1341/Garth+Brooks/Fresh+Horses/Garth+Brooks+-+The+Old+Stuff.mp3"}
  ;;               ...
  ;;               {:artist "Garth Brooks",
  ;;                :title "Ireland",
  ;;                :number 10,
  ;;                :src "http://localhost:1341/Garth+Brooks/Fresh+Horses/Garth+Brooks+-+Ireland.mp3"})}}

)

That's right, we no longer have to rely on logging stuff to the JS console in our promise chains!

OK, but we haven't really changed anything other than making the load-ui! function more complicated. Let's add a little more to our state atom so we can actually tackle the problem of auto-advancing tracks. First, we'll add a :paused? key:

(defn load-ui! [dir]
  (p/->> (load-album (str dir "/album.rss"))
         display-album!
         (assoc {:paused? true} :album)
         (reset! state)
         :album
         :tracks
         first
         activate-track!))

(comment

  (load-ui! "http://localhost:1341/Garth+Brooks/Fresh+Horses")
  ;; => #<Promise[~]>

  @state
  ;; => {:paused? true, :album {...}}

)

Now let's add an event listener to the <audio> element that updates the state when the play button is pressed, doing a little cleanup of the load-ui! function whilst we're at it:

(defn load-ui! [dir]
  (p/let [album (load-album (str dir "/album.rss"))]
    (display-album! album)
    (reset! state {:paused? true, :album album})
    (->> album
         :tracks
         first
         activate-track!)
    (.addEventListener (get-el "audio") "play"
                       #(swap! state assoc :paused? false))))

(comment

  (load-ui! "http://localhost:1341/Garth+Brooks/Fresh+Horses")
  ;; => #<Promise[~]>

  (:paused? @state)
  ;; => true

  ;; Click the play button and...
  (:paused? @state)
  ;; => false

)

If you're not familiar with swap!, it takes an atom and a function which will be called with the current value of the atom, then sets the next value of the atom to whatever the function returns, just like update does for plain old maps. And also just like update, it has a shorthand form so that instead of writing this:

(swap! state #(assoc % :paused? false))

you can write this:

(swap! state assoc :paused? false)

in which case swap! will treat the arg after the atom as a function which will be called with the current value first, then the rest of the args to swap!. You can imagine that swap! is written something like this:

(defn swap!
  ([atom f]
   (reset! atom (f @atom)))
  ([atom f & args]
   (reset! atom (apply f @atom args))))

It's obviously not written like that, even though that would technically probably maybe work. It's actually written like this:

(defn swap!
  "Atomically swaps the value of atom to be:
  (apply f current-value-of-atom args). Note that f may be called
  multiple times, and thus should be free of side effects.  Returns
  the value that was swapped in."
  {:added "1.0"
   :static true}
  ([^clojure.lang.IAtom atom f] (.swap atom f))
  ([^clojure.lang.IAtom atom f x] (.swap atom f x))
  ([^clojure.lang.IAtom atom f x y] (.swap atom f x y))
  ([^clojure.lang.IAtom atom f x y & args] (.swap atom f x y args)))

But you get the point.

Aaaaaanyway, I seem to have digressedโ€”which is firmly on brand for this blog, so I apologise for nothing!

But yeah, at this point, we're back to the functionality that we had before. If we click on a track whilst the player is paused, the new track is selected but doesn't start playing, and if we click on a new track whilst the player is playing, the player plays on by playing the new track. Got it?

However, activate-track! is still relying on the DOM to keep track of whether the player is paused. Let's fix this by checking the state atom instead:

(defn activate-track! [{:keys [number src] :as track}]
  (log "Activating track:" (clj->js track))
  (let [track-spans (seq (.-children (get-el "#tracks")))
        audio-el (get-el "audio")
        ;; Instead of this ๐Ÿ‘‡
        ;; paused? (.-paused audio-el)
        ;; Do this! ๐Ÿ‘‡
        {:keys [paused?]} @state]
      ;; ...
    )
  track)

Next, let's write a function to advance to the next track:

(defn advance-track! []
  (let [{:keys [active-track album]} @state
        {:keys [tracks]} album
        last-track? (= active-track (count tracks))]
    (when-not last-track?
      (activate-track! (nth tracks active-track)))))

Oops, this is relying on :active-track being present in the state atom. Let's put it there in activate-track!

(defn activate-track! [{:keys [number src] :as track}]
  (log "Activating track:" (clj->js track))
  (let [track-spans (seq (.-children (get-el "#tracks")))
        audio-el (get-el "audio")
        {:keys [paused?]} @state]
    ;; ...
    )
  ;; Swappity swap swap! ๐Ÿ‘‡
  (swap! state assoc :active-track number)
  track)

(comment

  (load-ui! "http://localhost:1341/Garth+Brooks/Fresh+Horses")
  ;; => #<Promise[~]>

  @state
  ;; => {:paused? true,
  ;;     :active-track 1,
  ;;     :album {...}}

)

Now we should be able to actually call advance-track! to, well, advance the track:

(comment

  (load-ui! "http://localhost:1341/Garth+Brooks/Fresh+Horses")
  ;; => #<Promise[~]>

  (:active-track @state)
  ;; => 1

  (advance-track!)
  ;; => {:artist "Garth Brooks", :title "Cowboys And Angels", :number 2, :src "http://localhost:1341/Garth+Brooks/Fresh+Horses/Garth+Brooks+-+Cowboys+and+Angels.mp3"}

  (:active-track @state)
  ;; => 2

)

We will have also seen the highlighted track change when we evaluated the (advance-track!) form! ๐ŸŽ‰

Is this the end?

What we're building up to is of course the ability to play our album continuously. When one track ends, the next should begin. And our good friend <audio> has just what we need, in the form of the ended event. If we add one line of code to register advance-track! as the listener for the ended event:

(defn load-ui! [dir]
  (p/let [album (load-album (str dir "/album.rss"))]
    (display-album! album)
    (reset! state {:paused? true, :album album})
    (->> album
         :tracks
         first
         activate-track!)
    (.addEventListener (get-el "audio") "play"
                       #(swap! state assoc :paused? false))
    (.addEventListener (get-el "audio") "ended"
                       advance-track!)))

(comment

  (load-ui! "http://localhost:1341/Garth+Brooks/Fresh+Horses")
  ;; => #<Promise[~]>

  ;; Click โ–ถ๏ธ and witness the glory!
)

We win!

Screenshot of our UI with the last track highlighted and the console showing activating track for all previous tracks

Winners who have won before and know how to win will of course know that the best thing to do after winning is to stride triumphantly to the podium, receive your ๐Ÿฅ‡, wave to your adoring public, soak up the applause like warm sunshine on a July day (unless you're in the southern hemisphere, in which case the warm sunshine is best appreciated in December, unless you're close enough to the equator to appreciate warm sunshine whenever you damn well please, unless you're too close to the equator and that sunshine is too warm to appreciate because you're sweating like wild), and then head home, find a comfy chair and open a bottle of champagne or fizzy water or tasty whiskey or whatever.

I, of course, am no such winner, so instead of retiring to my comfy chair with a glass of Lagavulin, I want to jump ahead in a track, so I confidently reach for the audio control and click ahead in the timeline, and... nothing happens WTF?

Reading more documentation, I discover that I can see the current time in seconds in the track by reading its currentTime property, and I can seek to an arbitrary time by setting currentTime, so let's give that a try, shall we? (Spoiler: we shall.)

(comment

  (.-currentTime (get-el "audio"))
  ;; => 37.010544

  (set! (.-currentTime (get-el "audio")) 50)
  ;; => nil

  ;; Why did my track start over? ๐Ÿคฌ
  
  (.-currentTime (get-el "audio"))
  ;; => 2.006649

)

To make a long story short, this all boils down to how the browser actually implements seeking. When it first loads the audio track, it issues a request like this:

GET /Garth+Brooks/Fresh+Horses/Garth+Brooks+-+The+Old+Stuff.mp3 HTTP/1.1
Range: bytes=0-

and expects a response like this:

HTTP/1.1 206 Partial Content
Accept-Ranges: bytes
Content-length: 5943424
Content-Range: bytes 0-1024000/5943424
Content-Type: audio/mpeg

It will then buffer the bytes it got back and make the track seekable within those bytes, as described here. You can peer under the hood by inspecting the buffered and seekable properties of the <audio> element:

audio.buffered.length; // returns 2
audio.buffered.start(0); // returns 0
audio.buffered.end(0); // returns 5
audio.buffered.start(1); // returns 15
audio.buffered.end(1); // returns 19

But if we do this in our player, we experience a deep feeling of melancholy:

(comment

  (let [b (-> (get-el "audio")
              (.-buffered))]
    [(.start b 0) (.end b 0)])
  ;; => [0 144.758]

  (-> (get-el "audio")
      (.-seekable)
      (.-length))
  ;; => 1

  (let [s (-> (get-el "audio")
              (.-seekable))]
    [(.start s 0) (.end s 0)])
  ;; => [0 0]

)

The buffering looks fine, but it seems that we can only seek between 0 seconds and 0 seconds in the track, which kinda explains why attempting to set currentTime to any number that isn't 0 results in seeking back to 0. ๐Ÿ˜ญ

Seeking apparently only works if we get that blessed 206 Partial Content response from the webserver, so the browser knows how to make subsequent range requests to buffer more data, and unfortunately, the built-in babashka.http-server that we're using to serve up files in public/ responds like this:

HTTP/1.1 200 OK
Content-length: 5943424
Content-Type: audio/mpeg
Server: http-kit

No partial content?

Screenshot of a chef saying no seek for you, come back 1 year

We may attempt to fix this next time on "Soundcljoud, or a young man's Soundcloud clonejure", that is if there is a next time.

Part 1: Soundcljoud, or a young man's Soundcloud clonejure

Soundcljoud, or a young man's Soundcloud clonejure

A stack of CDs. Photo by Brett Jordan on Unsplash.

๐Ÿ˜ฑ Warning!

This blog post is ostensibly about Clojure (of the Babashka variety), but not long after starting to write it, I found myself some 3100 words into some rambling exposition about the history of audio technology and how it intersected with my life, and had not typed the word "ClojureScript" even once (though it may appear that I've now typed it twice, I actually wrote this bit post scriptum, but decided to attach it before the post, which I suppose makes it a prelude and not a postscript, but I digress).

Whilst this won't surprise returning readers, I thought it worth warning first-timers, and offering all readers the chance to skip over all the stage-setting and other self-indulgent nonsense, simply by clicking the link that says "skip over".

If you'd like to delay your gratification, you are in luck! Read on, my friend!

Rambling exposition

Once upon a time there were vinyl platters into which the strategic etching of grooves could encode sound waves. If one placed said platter on a table and turned it say, 78 times a minute, and attached a needle to an arm and dropped it onto the rotating platter, one could use the vibrations in the arm caused by the needle moving left and right in the grooves to decode the sound waves, which you then turn into a fluctuating electric current and cause it to flow through a coil which vibrates some fabric and reproduces the sound waves. And this was good, for we could listen to music.

The only problem with these "records", as they were called, is that they were kinda big and you couldn't fit them in your backpack. So some Dutch people and some Japanese people teamed up and compacted the records, and renamed them discs because a record is required by law (in Japan) to be a certain diameter or you can't call it a record. They decided to make them out of plastic instead of vinyl, and then realised that they couldn't cut grooves into plastic because they kept breaking the discsโ€”perhaps the choice of hammer and chisel to cut the grooves wasn't ideal, but who am I to judge? "ใ ใฃใฆใ•ใ€" said one of the Japanese engineers, "้‡ใงใƒ‡ใ‚ฃใ‚นใ‚ฏใซใกใฃใกใ‚ƒใ„็ฉดใ‚„ใฃใŸใ‚‰ใ€ใฉใ†ใชใ‚‹ใ‹ใช๏ผŸ" No one knew, so they just tried it, and lo! the disc didn't break! But also lo! poking at the disc with a needle made little bumps on the other side of the disc, because of the law of conservation of mass or something... I don't know, I had to drop out of physics in uni because it apparently takes me 20 hours to solve one simple orbital mechanics problem; I mean, come on, how hard is it to calculate the orbit of a planet around three stars? Jeez.

But anyway, they made some bumps, which was annoying at first but then turned out to be a very good thing indeed when someone had the realisation that if squinted at the disc with a binary way of thinking, you could consider a bump to be a 1, and a flat place on the disc to be a 0, and then if you were to build a digital analyser that sampled the position of a sound wave, say, 44,100 times a second and wrote down the results in binary, you could encode the resulting string of 1s and 0s onto the disc with a series of bumps.

But how to decode the bumps when trying to play the thing back? The solution was super obvious this time: a frickin' laser beam! (Frickin' laser beams were on everyone's mind back in the early 80s because of Star Warsโ€”the movies; the missile defence system wouldn't show up until a few years later). If they just fired a frickin' laser beam continuously whilst rotating the disc and added a photodiode next to the laser, the light bouncing back off a bump would knock the wavelength of the light 1/2 out of phase, which would partially cancel the reflected light, lowering the intensity, which the photodiode would pick up and interpret as a 1. Obviously.

Except for one thing. Try as they might, the engineers couldn't make the frickin' laser beam bounce off the frickin' surface of the frickin' polycarbonate. If the plastic was too dark, it just absorbed the light, and if it was too light, it certainly reflected it, but not with high enough intensity for the photodiode to tell the difference between a 1 and a 0. ๐Ÿ˜ข

This was a real head-scratcher, and they were well and truly stuck until one day one of the Dutch engineers was enjoying a beer from a frosty glass at a table at an outdoor cafe on Museumplein on a hot day and the condensation on the glass made the coaster stick to the bottom of the glass in the annoying way it does when one doesn't put a little table salt on the coaster firstโ€”amateur!โ€”and the coaster fell into a nearby ashtray (people used to put these paper tubes stuffed with tobacco in their mouths, light them on fire, and suck the smoke deep into their lungs; ask your parents, kids) and got all coated in ash. The engineer wrinkled their nose in disgust before having an amazing insight. "What if," they thought to themselves, "we coated one side of the polycarbonate with something shiny that would reflect the frickin' laser?" Their train of thought then continued thusly: "And what is both reflective and cheap? Why, this selfsame aluminium of which this here ashtray is constructed!"

And thus the last engineering challenge was overcome, and there was much rejoicing!

The first test of the technology was a recording of Richard Strauss's "An Alpine Symphony" made in the beginning of December 1981, which was then presented to the world the following spring. It took a whole year before the first commercial compact disc was released, and by 1983, the technology had really taken off, thus introducing digital music to the world and ironically sowing the seeds of the format's demise. But I'm getting ahead of myself again.

Sometime around 1992, give or take, my parents got me a portable CD player (by this time, people, being ~lazy~ efficient by nature, had stopped saying "compact disc" and started abbreviating it to "CD") and one disc: Aerosmith's tour de force "Get a Grip". Thus began a period of intense musical accumulation by yours truly.

But remember when I said the CD format contained within it the seeds of its own demise? Quoth Wikipedia, and verily thus:

In 1894, the American physicist Alfred M. Mayer reported that a tone could be rendered inaudible by another tone of lower frequency. In 1959, Richard Ehmer described a complete set of auditory curves regarding this phenomenon. Between 1967 and 1974, Eberhard Zwicker did work in the areas of tuning and masking of critical frequency-bands, which in turn built on the fundamental research in the area from Harvey Fletcher and his collaborators at Bell Labs

You see where this is going, right? Good, because I wouldn't want to condescend to you by mentioning things like space-efficient compression with transforming Fouriers into Fast Fouriers modifying discrete cosines and other trivia that would bore any 3rd grade physics student.

So anyway, some Germans scribbled down an algorithm and convinced the Motion Picture Experts Group to standardise it as the MPEG-1 Audio Layer III format, and those Germans somehow patented this "innovation" that no one had even bothered to write down because it was so completely obvious to anyone who bothered to think about it for more than the time a CD takes to revolve once or twice. This patent enraged such people as Richard Stallman (who, to be fair, is easily enraged by such minor things as people objecting to his mysogyny and opinions on the acceptability of romantic relationships with minors), leading some people to develop a technically superior and free as in beer audio coding format that they named after a Terry Pratchett character and a clone of a clone of Spacewar!. The name, if you haven't guessed it by now from the copious amount of clues I've dropped here (just call me Colonel Mustard) was Ogg Vorbis.

By early summer 2005, I had accumulated a large quantity of CDs, which weighed roughly a metric shit-tonne. In addition to the strain they placed on my poor second- or third-hand bookshelves, I was due to move to Japan in the fall, and suspected that the sheer mass of my collection would interfere with the ability of whatever plane I would be taking to Tokyo to become airborne, which would be a real bummer. However, a solution presented itself, courtesy of one of the technical shortcomings of the compact disc technology itself.

Remember how CDs have this metallic layer that reflects the laser back at the sensor? Turns out that this layer is quite vulnerable, and a scratch that removes even a tiny bit of the metal results in the laser not being reflected as the disc rotates past the missing metal, which causes that block of data to be discarded by the player as faulty. To recover from this, the player would do one of the following#Basic_players):

  1. Repeat the previous block of audio
  2. Skip the faulty block
  3. Try and retry to read it, causing a stopping and starting of the music

For the listener, this is a sub-optimal auditory experience, and most listeners don't like any sub mixed in with their optimal.

Luckily, consumer-grade CD recorders started appearing in the mid-90s, when HP released the first sub-$1000 model. As a teenager in the 90s, I certainly couldn't afford $1000, but in 1997, I started working as a PC repair technician, and we had a CD "burner" (as they were known back then, not to be confused with a "burner" phone, which didn't exist back then, at least not in the cultural zeitgeist of the time) for such uses as device drivers which were too big to fit on a 3.5 inch "floppy" disk (those disks weren't actually floppy, but their 5.25 inch predecessors certainly were). I sensed an opportunity to protect my investment in digital music by "ripping" my discs (transferring the data on a CD onto the computer) and then burning them back to recordable CDs, at which point I could leave the original CD in its protective case and only expose my copy to the harsh elements.

Of course, one could also leave the ripped audio on one's computer and listen to it at any time of one's choosing, which was really convenient since you didn't have to change CDs when the disc ended or you were just in the mood to listen to something different. The problem is that the raw audio from the CDs (encoded in the WAV format that even modern people are probably familiar with) was fairly large, with a single CD taking up as much as 700MB of space. That may not seem like much until you know that most personal computers in the late 90s had somewhere between 3 and 16 GB of storage, which was enough to store between 20 and 220 CDs, assuming you had nothing else on the drive, which was unlikely since you needed to have software for playing back the files which meant you needed an operating system such as Windows...

To move somewhat more rapidly to the point, one solution to the issue of space was rooted in an even older technology than the compact disc (though younger than the venerable phonograph record): the cassette tape! A cassette tape was... OK, given that I've written nigh upon 2000 words at this point without mentioning Soundcloud or ClojureScript, perhaps I'll just link you to the Wikipedia article on the cassette tape instead of attempting to explain how it works in an amusing (to me) fashion. Interesting (to me) sidenote, though: the cassette tape was also invented by our intrepid Dutch friends over at Philips! ๐Ÿคฏ

And my point was... oh yeah, mixtapes! Cassette tapes were one of the first media that gave your average consumer access to a recorder at an affordable price (the earliest such media that I know of was the reel-to-reel tape, which was like a giant cassette tape without the plastic bit that protects the tape), and in addition to stuffing tissue in the top of a tape just to record Marley Marl that we borrowed from our friend down the street, we also made "mixtapes", an alchemical process whereby we boiled down our tape collection and extracted only the bangers (or tear-jerkers, or hopelessly optimistic love songs, or whatever mood we were trying to capture) and recorded those onto a tape, giving us 60 minutes of magic to blare in our cars or hand to that cutie in chemistry class to try and win their affection.

With the invention of the CD and the burner, we were back in the mixtape business, and this time we had up to 80 minutes to express ourselves. By the time I entered university back in 19*cough*, I had saved up enough from my job as a PC technician to buy my own burner, and at university, I gained somewhat of a reputation as a mixtape maestro. People would bring me a stack of CDs and ask me to produce a mixtape to light up the dancefloor or get heads nodding along to the dope-ass DJs of the time (I'm looking at you, Premo!), and also pick a cheeky title to scrawl onto the recordable CD in Sharpie. The one that sticks in my memory was called "The Wu Tang Clan ain't Nothin' to Fuck With"...

OK, but anyway, what if 80 minutes wasn't enough? Remember several minutes of rambling ago when I mentioned the MPEG-1 Audio Layer III format, and you may (or may not) have been like, "WTF is that?" What if I told you that MPEG-1 Audio Layer III is usually referred to by its initials (kinda): MP3? Now you see where I'm going, right? By taking raw CD audio and compressing it with the MP3 encoding algorithm, one could now fit something like 140 songs onto a recordable CD (assuming 5MB per song and 700MB of capacity on the CD), or roughly 10 albums.

So back to the summer of 2005, when I'm getting ready to move to Japan and I realise I can't realistically take all of my CDs with me. What do I do? I rip them onto my computer, encode them as not as MP3s but as Ogg Vorbis files because, y'know, freedom and stuff, burn them onto a recordable CD along with ~9 of their compatriots, and pack them in a box, write their names on a bill of lading which I tape to the box once it gets full, and then store the box in my parents' basement. The freshly recorded backup CD goes into once of those big CD case thingies that we used to have:

A black Case Logic 200 CD capacity case

My CD ripping frenzy was concluded in time for my move to Japan, but did not end there, because I ended up getting a job at this bookstore that also sold CDs and other stuff, and publishers would send books and CDs to the buyers that worked at said bookstore, who would then decided if and how many copies of said books and CDs to buy for stock, and then usually put the books and CDs on a shelf in a printer room, where random bookstore employees such as myself were welcome to take them. So I got some cool books, and loads and loads of CDs, many of them from Japanese artists, which were promptly ripped, Ogg Vorbisified, and written to a 500GB USB hard drive that I had bought from the bookstore with my employee discount. Hurrah!

And thus when 2008 rolled around and I left Tokyo for Dublin, I did so with the vast majority of my music safely encoded into Ogg Vorbis format and written to spinning platters. Sadly, my sojourn on the shamrock shores of the Emerald Isle didn't last long, but happily, my next stop in Stockholm has been of the more permanent variety. By the time I moved here in 2010, Apple and Amazon's MP3 stores were starting to become passรฉ, with streaming services replacing them as the Cool New Thingโ„ข, led a brash young Swedish startup called Spotify. And lo! did my collection of Ogg Vorbis files seem unnecessary, since I could now play every song ever recorded whenever I wanted to without having to lug around a hard drive full of files.

Except, at some point, some artists decided that they didn't want their music on Spotify, some for admirable reasons and others for, um, other rea$on$, and now I couldn't listen to every song ever recorded whenever I wanted to without having to lug around a hard drive full of files. Plus Spotify never had a lot of the Japanese music that I had on file. This was suboptimal to be sure, but my laziness overwhelmed my desire to listen to all of my music, until one fateful day that I was sad about something and decided that I absolutely had to listen to some really sad country music, and the first song that came to mind was Garth Brook's "Much too Young to Feel this Damn Old". Much to my dismay, Garth was one of those artists who had withheld their catalogue from Spotify, meaning I had to resort to a cover of the song instead.

My sadness was replaced by rage, and I turned to Clojure to exact my revenge on Spotify for not having reached terms to licence music from one of the greatest Country & Western recording artists of all time!

OMG finally stuff about Clojure

If you wisely clicked the link at the beginning to skip my rambling exposition, welcome to a discussion of how I solved a serious problem caused by a certain Country & Western super-duper star (much like VA Beach legend Magooโ€”RIPโ€”on every CD, he spits 48 bars) wisely flicking the V at the odious Daniel Ek and the terrible Tim Cook but somehow being A-OK with an even more repulsive billionaire's streaming service.

To briefly recap, I really wanted to listen to some tear jerkin' country, and Spotify doesn't carry Garth, but I had purchased all of his albums on CD back in the day (all the ones recorded before 1998, anyway) and ripped them into Ogg Vorbis format. Which is great, because I can listen to Garth anytime I want, as long as that desire occurs whilst I happen to be sitting within reach of the laptop onto which I copied all of those files. However, I like to do such things as not sit within reach of my laptop all the time, so now I'm back to square almost one.

One day, as I was bemoaning my fate, I had a flash of inspiration! What if I put those files somewhere a web browser could reach them, and then I could listen to them anytime I happened to be sitting within reach of a web browser, which is basically always, since I have a web browser that fits in my pocket (I think it can also make "phone calls", whatever those are). For example, I could upload them to Soundcloud. The only problem with that is that Soundcloud would claim that I was infringing on Garth's copyright, and they'd kinda have a point, since not only could I listen to "The Beaches of Cheyenne" anytime I wanted to, having obtained a licence to do so by virtue of forking over $15 back in 1996 for a piece of plastic dipped in metal, but so could any random person with an internet connection.

This left me with only one option: clone Soundcloud! With Clojure! And call it Soundcljoud because I just can't help myself! And write a long and frankly absurdly self-indulgent blog post about it!

OK really Clojure now I promise

As I mentioned, I have a bunch of Ogg Vorbis files on my laptop:

: jmglov@alhana; ls -1 ~/Music/g/Garth\ Brooks/
'Beyond the Season'
'Double Live (Disc 1)'
'Double Live (Disc 2)'
'Fresh Horses'
'Garth Brooks'
'In Pieces'
'No Fences'
"Ropin' the Wind"
Sevens
'The Chase'
'The Hits'

I also have Babashka:

A logo of a face wearing a red hoodie with orange sunglasses featuring the Soundcloud logo

So let's get to cloning!

The basic idea is to turn these Ogg Vorbis files into MP3 files, which the standard knows how to play, and then wrap a little ClojureScript around that element to stuff my sweet sweet country music into the <audio> element and then call it a day.

We'll accomplish the first part with Babashka and some command-line tools. I'll start by creating a new directory and dropping a bb.edn into it:

{:paths ["src" "resources"]}

Now I can create a src/soundcljoud/main.clj like this:

(ns soundcljoud.main
  (:require [babashka.fs :as fs]
            [babashka.process :as p]
            [clojure.string :as str]))

Firing up a REPL in my trusty Emacs with C-c M-j and then evaluating the buffer with C-c C-k, let me introduce Babashka to good ol' Garth:

(comment

  (def dir (fs/file (fs/home) "Music/g/Garth Brooks/Fresh Horses")) ; C-c C-v f c e
  ;; => #'soundcljoud.main/dir

)

If you're a returning reader, you'll of course have translated C-c C-k to Control + c Control + k in your head and C-c C-v f c e to Control + c Control + v f c e and understood that they mean cider-load-buffer and cider-pprint-eval-last-sexp-to-comment, respectively. If you're a first-timer, what's happening here is that I'm using a so-called Rich comment (which protects the code within the (comment) form from evaluation when the buffer is evaluated) to evaluate forms one at a time as I REPL-drive my way towards a working program, for this is The Lisp Way.

Let's take a look at the Ogg Vorbis files in this directory:

(comment

  (->> (fs/glob dir "*.ogg")
       (map str))
  ;; => ("~/Music/g/Garth Brooks/Fresh Horses/Garth Brooks - Ireland.ogg"
  ;;     "~/Music/g/Garth Brooks/Fresh Horses/Garth Brooks - The Fever.ogg"
  ;;     "~/Music/g/Garth Brooks/Fresh Horses/Garth Brooks - She's Every Woman.ogg"
  ;;     "~/Music/g/Garth Brooks/Fresh Horses/Garth Brooks - The Old Stuff.ogg"
  ;;     "~/Music/g/Garth Brooks/Fresh Horses/Garth Brooks - Rollin'.ogg"
  ;;     "~/Music/g/Garth Brooks/Fresh Horses/Garth Brooks - The Beaches of Cheyenne.ogg"
  ;;     "~/Music/g/Garth Brooks/Fresh Horses/Garth Brooks - That Ol' Wind.ogg"
  ;;     "~/Music/g/Garth Brooks/Fresh Horses/Garth Brooks - It's Midnight Cinderella.ogg"
  ;;     "~/Music/g/Garth Brooks/Fresh Horses/Garth Brooks - The Change.ogg"
  ;;     "~/Music/g/Garth Brooks/Fresh Horses/Garth Brooks - Cowboys and Angels.ogg")

)

Knowing my fastidious nature, I bet I wrote some useful tags into those Ogg files. Let's use vorbiscomment to check:

(comment

  (->> (fs/glob dir "*.ogg")
       (map str)
       first
       (p/shell {:out :string} "vorbiscomment")
       :out
       str/split-lines)
  ;; => ["title=Ireland" "artist=Garth Brooks" "album=Fresh Horses"]

)

Most excellent! With a tiny bit more work, we can turn these strings into a map:

(comment

  (->> (fs/glob dir "*.ogg")
       (map str)
       first
       (p/shell {:out :string} "vorbiscomment")
       :out
       str/split-lines
       (map #(let [[k v] (str/split % #"=")] [(keyword k) v]))
       (into {}))
  ;; => {:title "Ireland", :artist "Garth Brooks", :album "Fresh Horses"}

)

And now I think we're ready to write a function that takes a filename and returns this info:

(defn track-info [filename]
  (->> (p/shell {:out :string} "vorbiscomment" filename)
       :out
       str/split-lines
       (map #(let [[k v] (str/split % #"=")] [(keyword k) v]))
       (into {})
       (merge {:filename filename})))

Now that we've established that we have some Ogg Vorbis files with appropriate metadata, let's jump in the hammock for a second and think about how we want to proceed. What we're actually trying to accomplish is to make these tracks playable on the web. What if we create a podcast RSS feed per album, then we can use any podcast app to play the album?

Faking a podcast with Selmer

Let's go this route, since it seems like very little work! We'll start by creating a Selmer template in resources/album-feed.rss:

<?xml version='1.0' encoding='UTF-8'?>
<rss version="2.0"
     xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"
     xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <atom:link
        href="{{base-url}}/{{album|urlescape}}/album.rss"
        rel="self"
        type="application/rss+xml"/>
    <title>{{artist}} - {{album}}</title>
    <link>{{link}}</link>
    <pubDate>{{date}}</pubDate>
    <lastBuildDate>{{date}}</lastBuildDate>
    <ttl>60</ttl>
    <language>en</language>
    <copyright>All rights reserved</copyright>
    <webMaster>{{owner-email}}</webMaster>
    <description>Album: {{artist}} - {{album}}</description>
    <itunes:subtitle>Album: {{artist}} - {{album}}</itunes:subtitle>
    <itunes:owner>
      <itunes:name>{{owner-name}}</itunes:name>
      <itunes:email>{{owner-email}}</itunes:email>
    </itunes:owner>
    <itunes:author>{{artist}}</itunes:author>
    <itunes:explicit>no</itunes:explicit>
    <itunes:image href="{{image}}"/>
    <image>
      <url>{{image}}</url>
      <title>{{artist}} - {{album}}</title>
      <link>{{link}}</link>
    </image>
    {% for track in tracks %}
    <item>
      <itunes:title>{{track.title}}</itunes:title>
      <title>{{track.title}}</title>
      <itunes:author>{{artist}}</itunes:author>
      <enclosure
          url="{{base-url}}/{{album|urlescape}}/{{track.mp3-filename|urlescape}}"
          length="{{track.mp3-size}}" type="audio/mpeg" />
      <pubDate>{{date}}</pubDate>
      <itunes:duration>{{track.duration}}</itunes:duration>
      <itunes:episode>{{track.number}}</itunes:episode>
      <itunes:episodeType>full</itunes:episodeType>
      <itunes:explicit>false</itunes:explicit>
    </item>
    {% endfor %}
  </channel>
</rss>

If you're not familiar with Selmer, the basic idea is that anything inside {{}} tags is a variable, and you also have some looping constructs like {% for %} and so on. So let's look at the variables that we slapped in that template:

General info:

Album-specific stuff:

Track-specific stuff:

OK, so where are we going to get all this? The general info is easy; we can just decide what we want it to be and slap it in a variable:

(comment

  (def opts {:base-url "http://localhost:1341"
             :owner-name "Josh Glover"
             :owner-email "jmglov@jmglov.net"})
  ;; => #'soundcljoud.main/opts

)

The album-specific stuff is a little more challenging. album and artist we can get from our track-info function, and link can be something like base-url + artist + album, but what about date (the date the album was released) and image (the cover image of the album)? Well, for this we can use a music database that offers API access, such as Discogs. Let's start by creating an account and then visiting the Developers settings page to generate a personal access token, which we'll save in resources/discogs-token.txt. With this in hand, let's try searching for an album. We'll need to add an HTTP client (luckily, Babashka ships with one), a JSON parser (luckily, Babashka ships with one) and a way to load the resources/discogs-token.txt to our namespace, then we can use the API.

(ns soundcljoud.main
  (:require [babashka.fs :as fs]
            [babashka.process :as p]
            [clojure.string :as str]
            ;; โฌ‡โฌ‡โฌ‡ New stuff โฌ‡โฌ‡โฌ‡
            [babashka.http-client :as http]
            [cheshire.core :as json]
            [clojure.java.io :as io]))

(comment

  (def discogs-token (-> (io/resource "discogs-token.txt")
                         slurp
                         str/trim-newline))
  ;; => #'soundcljoud.main/discogs-token

  (def album-info (->> (fs/glob dir "*.ogg")
                       (map str)
                       first
                       track-info))
  ;; => #'soundcljoud.main/album-info

  (-> (http/get "https://api.discogs.com/database/search"
                {:query-params {:artist (:artist album-info)
                                :release_title (:album album-info)
                                :token discogs-token}
                 :headers {:User-Agent "SoundCljoud/0.1 +https://jmglov.net"}})
      :body
      (json/parse-string keyword)
      :results
      first)
  ;;  {:format ["CD" "Album"],
  ;;   :master_url "https://api.discogs.com/masters/212114",
  ;;   :cover_image
  ;;   "https://i.discogs.com/0eLXmM1tK1grkH8cstgDT6eV2TlL0NvgWPZBoyScJ_8/rs:fit/g:sm/q:90/h:600/w:600/czM6Ly9kaXNjb2dz/LWRhdGFiYXNlLWlt/YWdlcy9SLTY4NDcx/Ny0xNzE3NDU5MDIy/LTMxNjguanBlZw.jpeg",
  ;;   :title "Garth Brooks - Fresh Horses",
  ;;   :style ["Country Rock" "Pop Rock"],
  ;;   :year "1995",
  ;;   :id 212114,
  ;;   ...
  ;;  }

)

This looks very promising indeed! We now have the release year, which we can put in our RSS feed as date, and the cover image, which we can put in image. Now let's grab info for the tracks:

(comment

  (def master-url (:master_url *1))
  ;; => #'soundcljoud.main/master-url

)

That (:master_url *1) thing might be new to you, so let me explain before we continue. The REPL keeps track of the result of the last three evaluations, and binds them to *1, *2, and *3. So (:master_url *1) says "give me the :master_url key of the result of the last evaluation, which I assume is a map or I'm SOL".

OK, back to the fetching track info:

(comment

  (def master-url (:master_url *1))
  ;; => #'soundcljoud.main/master-url

  (-> (http/get master-url
                {:query-params {:token discogs-token}
                 :headers {:User-Agent "SoundCljoud/0.1 +https://jmglov.net"}})
      :body
      (json/parse-string keyword)
      :tracklist)
  ;; => [{:position "1",
  ;;      :title "The Old Stuff",
  ;;      :duration "4:12"}
  ;;     {:position "2",
  ;;      :title "Cowboys And Angels",
  ;;      :duration "3:16"}
  ;;     ...
  ;;    ]

)

We now have all the pieces, so let's clean this up by turning it into a series of functions:

(def discogs-base-url "https://api.discogs.com")
(def user-agent "SoundCljoud/0.1 +https://jmglov.net")

(defn load-token []
  (-> (io/resource "discogs-token.txt")
      slurp
      str/trim-newline))

(defn api-get
  ([token path]
   (api-get token path {}))
  ([token path opts]
   (let [url (if (str/starts-with? path discogs-base-url)
               path
               (str discogs-base-url path))]
     (-> (http/get url
                   (merge {:headers {:User-Agent user-agent}}
                          opts))
         :body
         (json/parse-string keyword)))))

(defn search-album [token {:keys [artist album]}]
  (api-get token "/database/search"
           {:query-params {:artist artist
                           :release_title album
                           :token token}}))

(defn album-info [token {:keys [artist album] :as metadata}]
  (let [{:keys [cover_image master_url year]}
        (->> (search-album token metadata)
             :results
             first)
        {:keys [tracklist]} (api-get token master_url)]
    (merge metadata {:link master_url
                     :image cover_image
                     :year year
                     :tracks (map (fn [{:keys [title position]}]
                                    {:title title
                                     :artist artist
                                     :album album
                                     :number position
                                     :year year})
                                  tracklist)})))

Putting it all together, let's load all the album info in a format that's amenable to stuffing into our RSS template:

(comment

  (let [tracks (->> (fs/glob dir "*.ogg")
                    (map (comp track-info fs/file)))]
    (album-info (load-token) (first tracks))))
  ;; => {:title "Ireland",
  ;;     :artist "Garth Brooks",
  ;;     :album "Fresh Horses",
  ;;     :link "https://api.discogs.com/masters/212114",
  ;;     :image "https://i.discogs.com/0eLXmM1tK1grkH8cstgDT6eV2TlL0NvgWPZBoyScJ_8/rs:fit/g:sm/q:90/h:600/w:600/czM6Ly9kaXNjb2dz/LWRhdGFiYXNlLWlt/YWdlcy9SLTY4NDcx/Ny0xNzE3NDU5MDIy/LTMxNjguanBlZw.jpeg",
  ;;     :year "1995",
  ;;     :tracks
  ;;     ({:title "The Old Stuff",
  ;;       :artist "Garth Brooks",
  ;;       :album "Fresh Horses",
  ;;       :year "1995",
  ;;       :number 1}
  ;;      {:title "Cowboys and Angels",
  ;;       :artist "Garth Brooks",
  ;;       :album "Fresh Horses",
  ;;       :year "1995",
  ;;       :number 2}
  ;;      ...
  ;;      {:title "Ireland",
  ;;       :artist "Garth Brooks",
  ;;       :album "Fresh Horses",
  ;;       :year "1995",
  ;;       :number 10})}

)

Now that we have a big ol' map containing all the metadata an RSS feed could possibly desire, let's use Selmer to turn our template into some actual RSS! We'll need to add Selmer itself to our namespace, and also grab some java.time stuff in order to produce the RFC 2822 datetime required by the podcast RSS format, then we can get onto the templating itself.

(ns soundcljoud.main
  (:require ...
            [selmer.parser :as selmer])
  (:import (java.time ZonedDateTime)
           (java.time.format DateTimeFormatter)))

(def dt-formatter
  (DateTimeFormatter/ofPattern "EEE, dd MMM yyyy HH:mm:ss xxxx"))

(defn ->rfc-2822-date [date]
  (-> (Integer/parseInt date)
      (ZonedDateTime/of 1 1 0 0 0 0 java.time.ZoneOffset/UTC)
      (.format dt-formatter)))

(defn album-feed [opts album-info]
  (let [template (-> (io/resource "album-feed.rss") slurp)]
    (->> (update album-info :tracks
                 (partial map #(update % :mp3-filename fs/file-name)))
         (merge opts {:date (->rfc-2822-date (:year album-info))})
         (selmer/render template))))

(comment

  (let [tracks (->> (fs/glob dir "*.ogg")
                    (map (comp track-info fs/file)))]
    (->> (album-info (load-token) (first tracks))
         (album-feed opts)))
  ;; => java.lang.NullPointerException soundcljoud.main
  ;; {:type :sci/error, :line 3, :column 53, ...}
  ;;  at sci.impl.utils$rethrow_with_location_of_node.invokeStatic (utils.cljc:135)
  ;;  ...
  ;; Caused by: java.lang.NullPointerException: null
  ;;  at babashka.fs$file_name.invokeStatic (fs.cljc:182)
  ;;  ...

)

Oops! It appears that fs/file-name is angry at us. Searching for it, we identify the culprit:

(partial map #(update % :mp3-filename fs/file-name))

Nowhere in our album-info map have we mentioned :mp3-filename, which actually makes sense given that we only have an Ogg Vorbis file and not an MP3. Let's see what we can do about that, shall we? (Spoiler: we shall.)

Converting from Ogg to MP3

We'll honour Rich Hickey by decomplecting this problem into two problems:

  1. Converting an Ogg Vorbis file into a WAV
  2. Converting a WAV into an MP3

Let's start with problem #1 by taking a look at what we get back from album-info:

(comment

  (let [tracks (->> (fs/glob dir "*.ogg")
                    (map (comp track-info fs/file)))]
    (album-info (load-token) (first tracks))))
  ;; => {:title "Ireland",
  ;;     :artist "Garth Brooks",
  ;;     :album "Fresh Horses",
  ;;     :link "https://api.discogs.com/masters/212114",
  ;;     :image "https://i.discogs.com/0eLXmM1tK1grkH8cstgDT6eV2TlL0NvgWPZBoyScJ_8/rs:fit/g:sm/q:90/h:600/w:600/czM6Ly9kaXNjb2dz/LWRhdGFiYXNlLWlt/YWdlcy9SLTY4NDcx/Ny0xNzE3NDU5MDIy/LTMxNjguanBlZw.jpeg",
  ;;     :year "1995",
  ;;     :tracks
  ;;     ({:title "The Old Stuff",
  ;;       :artist "Garth Brooks",
  ;;       :album "Fresh Horses",
  ;;       :year "1995",
  ;;       :number 1}
  ;;      {:title "Cowboys and Angels",
  ;;       :artist "Garth Brooks",
  ;;       :album "Fresh Horses",
  ;;       :year "1995",
  ;;       :number 2}
  ;;      ...
  ;;      {:title "Ireland",
  ;;       :artist "Garth Brooks",
  ;;       :album "Fresh Horses",
  ;;       :year "1995",
  ;;       :number 10})}

)

The problem here is that we've lost the filename that came from fs/glob, so we have no idea which files we need to convert. Let's fix this by tweaking album-info to take the token and directory, rather than just the track info of the first file in the directory:

(defn normalise-title [title]
  (-> title
      str/lower-case
      (str/replace #"[^a-z]" "")))

(defn album-info [token tracks]
  (let [{:keys [artist album] :as track} (first tracks)
        track-filename (->> tracks
                            (map (fn [{:keys [filename title]}]
                                   [(normalise-title title) filename]))
                            (into {}))
        {:keys [cover_image master_url year]}
        (->> (search-album token track)
             :results
             first)
        {:keys [tracklist]} (api-get token master_url)]
    (merge track {:link master_url
                  :image cover_image
                  :year year
                  :tracks (map (fn [{:keys [title position]}]
                                 {:title title
                                  :artist artist
                                  :album album
                                  :number position
                                  :year year
                                  :filename (track-filename (normalise-title title))})
                               tracklist)})))

(comment

  (->> (fs/glob dir "*.ogg")
       (map (comp track-info fs/file))
       (album-info (load-token)))
  ;; => {:artist "Garth Brooks",
  ;;     :album "Fresh Horses",
  ;;     :link "https://api.discogs.com/masters/212114",
  ;;     :image
  ;;     "https://i.discogs.com/0eLXmM1tK1grkH8cstgDT6eV2TlL0NvgWPZBoyScJ_8/rs:fit/g:sm/q:90/h:600/w:600/czM6Ly9kaXNjb2dz/LWRhdGFiYXNlLWlt/YWdlcy9SLTY4NDcx/Ny0xNzE3NDU5MDIy/LTMxNjguanBlZw.jpeg",
  ;;     :year "1995",
  ;;     :tracks
  ;;     ({:title "The Old Stuff",
  ;;       :artist "Garth Brooks",
  ;;       :album "Fresh Horses",
  ;;       :number "1",
  ;;       :year "1995",
  ;;       :filename
  ;;       #object[java.io.File 0x96d79f0 "~/Music/g/Garth Brooks/Fresh Horses/Garth Brooks - The Old Stuff.ogg"]}
  ;; ...
  ;;      {:title "Ireland",
  ;;       :artist "Garth Brooks",
  ;;       :album "Fresh Horses",
  ;;       :number "10",
  ;;       :year "1995",
  ;;       :filename
  ;;       #object[java.io.File 0x13968577 "~/Music/g/Garth Brooks/Fresh Horses/Garth Brooks - Ireland.ogg"]})}

)

Much better! Given this, let's convert this file into a WAV:

(comment

  (def info (->> (fs/glob dir "*.ogg")
                 (map (comp track-info fs/file))
                 (album-info (load-token))))
  ;; => #'soundcljoud.main/info

  (def tmpdir (fs/create-dirs "/tmp/soundcljoud"))
  ;; => #'soundcljoud.main/tmpdir

  (let [{:keys [filename] :as track} (->> info :tracks first)
        out-filename (fs/file tmpdir (str/replace (fs/file-name filename)
                                                  ".ogg" ".wav"))]
    (p/shell "oggdec" "-o" out-filename filename)
    (assoc track :wav-filename out-filename))
  ;; => {:title "The Old Stuff",
  ;;     :artist "Garth Brooks",
  ;;     :album "Fresh Horses",
  ;;     :number "1",
  ;;     :year "1995",
  ;;     :filename
  ;;     #object[java.io.File 0x96d79f0 "~/Music/g/Garth Brooks/Fresh Horses/Garth Brooks - The Old Stuff.ogg"],
  ;;     :wav-filename
  ;;     #object[java.io.File 0x4221dcb2 "/tmp/soundcljoud/Garth Brooks - The Old Stuff.wav"]}

)

Lovely! Let's make a nice function out of this:

(defn ogg->wav [{:keys [filename] :as track} tmpdir]
  (let [out-filename (fs/file tmpdir (str/replace (fs/file-name filename)
                                                  ".ogg" ".wav"))]
    (println (format "Converting %s -> %s" filename out-filename))
    (p/shell "oggdec" "-o" out-filename filename)
    (assoc track :wav-filename out-filename)))

Now let's see if problem #2 is equally tractable.

(comment

  (let [{:keys [filename artist album title year number] :as track}
        (->> info :tracks first)
        wav-file (fs/file tmpdir
                          (-> (fs/file-name filename)
                              (str/replace #"[.][^.]+$" ".wav")))
        mp3-file (str/replace wav-file ".wav" ".mp3")
        ffmpeg-args ["ffmpeg" "-i" wav-file
                     "-vn"  ; no video
                     "-q:a" "2"  ; dynamic bitrate averaging 192 KB/s
                     "-y"  ; overwrite existing files without prompting
                     mp3-file]]
    (p/shell "ffmpeg" "-i" wav-file
             "-vn"       ; no video
             "-q:a" "2"  ; dynamic bitrate averaging 192 KB/s
             "-y"        ; overwrite existing files without prompting
             mp3-file))
  ;; => {:exit 0,
  ;;     ...
  ;;     }

  (fs/size "/tmp/soundcljoud/Garth Brooks - The Old Stuff.mp3")
  ;; => 5941943

)

Nice! There's one annoying thing about this, though. My Ogg Vorbis file had metadata tags telling me stuff and also things about the contents of the file, whereas my MP3 is inscrutable, save for the filename. Let's ameliorate this with our good friend id3v2:

(comment

  (let [{:keys [filename artist album title year number] :as track}
        (->> info :tracks first)
        wav-file (fs/file tmpdir
                          (-> (fs/file-name filename)
                              (str/replace #"[.][^.]+$" ".wav")))
        mp3-file (str/replace wav-file ".wav" ".mp3")
        ffmpeg-args ["ffmpeg" "-i" wav-file
                     "-vn"  ; no video
                     "-q:a" "2"  ; dynamic bitrate averaging 192 KB/s
                     "-y"  ; overwrite existing files without prompting
                     mp3-file]]
    (p/shell "id3v2"
             "-a" artist "-A" album "-t" title "-y" year "-T" number
             mp3-file))
  ;; => {:exit 0,
  ;;     ...
  ;;     }

  (->> (p/shell {:out :string}
                "id3v2" "--list"
                "/tmp/soundcljoud/Garth Brooks - The Old Stuff.mp3")
       :out
       str/split-lines)
  ;; => ["id3v1 tag info for /tmp/soundcljoud/Garth Brooks - The Old Stuff.mp3:"
  ;;     "Title  : The Old Stuff                   Artist: Garth Brooks"
  ;;     "Album  : Fresh Horses                    Year: 1995, Genre: Unknown (255)"
  ;;     "Comment:                                 Track: 1"
  ;;     "id3v2 tag info for /tmp/soundcljoud/Garth Brooks - The Old Stuff.mp3:"
  ;;     "TPE1 (Lead performer(s)/Soloist(s)): Garth Brooks"
  ;;     "TALB (Album/Movie/Show title): Fresh Horses"
  ;;     "TIT2 (Title/songname/content description): The Old Stuff"
  ;;     "TRCK (Track number/Position in set): 1"]

)

There's an awful lot of copy and paste code here, so let's consolidate MP3 conversion and tag writing into a single function. We should also make sure that function returns a track info map that contains all the good stuff that our RSS template needs. Casting our mind back to the track-specific stuff, we need:

mp3-filename we have, and m3-size we can get with the same fs/size call that we previously used to check if the MP3 file existed. duration is a little more interesting. What the RSS feed standard is looking for is a duration in one of the following formats:

We can use the ffprobe tool that ships with FFmpeg to get some info about the MP3:

(comment

  (-> (p/shell {:out :string}
               "ffprobe -v quiet -print_format json -show_format -show_streams"
               "/tmp/soundcljoud/01 - Garth Brooks - The Old Stuff.mp3")
      :out
      (json/parse-string keyword)
      :streams
      first)
  ;; => {:tags {:encoder "Lavc60.3."},
  ;;     :r_frame_rate "0/0",
  ;;     :sample_rate "44100",
  ;;     :channel_layout "stereo",
  ;;     :channels 2,
  ;;     :duration "252.473469",
  ;;     :codec_name "mp3",
  ;;     :bit_rate "188278",
  ;;     ...
  ;;     :codec_tag "0x0000"}

)

Cool! ffprobe reports duration in seconds (with some extra nanoseconds that we don't need), so let's write a function that grabs the duration and chops off everything after the decimal place, then we can consolidate the WAV -> MP3 conversion and ID3 tag writing in another function:

(defn mp3-duration [filename]
  (-> (p/shell {:out :string}
               "ffprobe -v quiet -print_format json -show_format -show_streams"
               filename)
      :out
      (json/parse-string keyword)
      :streams
      first
      :duration
      (str/replace #"[.]\d+$" "")))

(defn wav->mp3 [{:keys [filename artist album title year number] :as track} tmpdir]
  (let [wav-file (fs/file tmpdir
                          (-> (fs/file-name filename)
                              (str/replace #"[.][^.]+$" ".wav")))
        mp3-file (str/replace wav-file ".wav" ".mp3")
        ffmpeg-args ["ffmpeg" "-i" wav-file
                     "-vn"  ; no video
                     "-q:a" "2"  ; dynamic bitrate averaging 192 KB/s
                     "-y"  ; overwrite existing files without prompting
                     mp3-file]
        id3v2-args ["id3v2"
                    "-a" artist "-A" album "-t" title "-y" year "-T" number
                    mp3-file]]
    (println (format "Converting %s -> %s" wav-file mp3-file))
    (apply println (map str ffmpeg-args))
    (apply p/shell ffmpeg-args)
    (println "Writing ID3 tag")
    (apply println id3v2-args)
    (apply p/shell (map str id3v2-args))
    (assoc track
           :mp3-filename mp3-file
           :mp3-size (fs/size mp3-file)
           :duration (mp3-duration mp3-file))))

(comment

  (-> info :tracks first (wav->mp3 tmpdir))
  ;; => {:number "1",
  ;;     :duration "252",
  ;;     :artist "Garth Brooks",
  ;;     :title "The Old Stuff",
  ;;     :year "1995",
  ;;     :filename
  ;;     #object[java.io.File 0x96d79f0 "~/Music/g/Garth Brooks/Fresh Horses/Garth Brooks - The Old Stuff.ogg"],
  ;;     :mp3-filename "/tmp/soundcljoud/Garth Brooks - The Old Stuff.mp3",
  ;;     :album "Fresh Horses",
  ;;     :mp3-size 5943424}

)

Looking good! Now we should have everything we need for the RSS feed, so let's try to put it all together:

(defn process-track [track tmpdir]
  (-> track
      (ogg->wav tmpdir)
      (wav->mp3 tmpdir)))

(defn process-album [opts dir]
  (let [info (->> (fs/glob dir "*.ogg")
                  (map (comp track-info fs/file))
                  (album-info (load-token)))
        tmpdir (fs/create-temp-dir {:prefix "soundcljoud."})]
    (spit (fs/file tmpdir "album.rss") (rss/album-feed opts info))
    (assoc info :out-dir tmpdir)))

(comment

  (process-album opts dir)
  ;; => {:out-dir "/tmp/soundcljoud.12524185230907219576"
  ;;     :artist "Garth Brooks",
  ;;     :album "Fresh Horses",
  ;;     :link "https://api.discogs.com/masters/212114",
  ;;     :image
  ;;     "https://i.discogs.com/0eLXmM1tK1grkH8cstgDT6eV2TlL0NvgWPZBoyScJ_8/rs:fit/g:sm/q:90/h:600/w:600/czM6Ly9kaXNjb2dz/LWRhdGFiYXNlLWlt/YWdlcy9SLTY4NDcx/Ny0xNzE3NDU5MDIy/LTMxNjguanBlZw.jpeg",
  ;;     :year "1995",
  ;;     :tracks
  ;;     ({:number "1",
  ;;       :duration "252",
  ;;       :artist "Garth Brooks",
  ;;       :title "The Old Stuff",
  ;;       :year "1995",
  ;;       :filename
  ;;       #object[java.io.File 0x344bc92b "~/Music/g/Garth Brooks/Fresh Horses/Garth Brooks - The Old Stuff.ogg"],
  ;;       :mp3-filename
  ;;       "/tmp/soundcljoud.12524185230907219576/Garth Brooks - The Old Stuff.mp3",
  ;;       :album "Fresh Horses",
  ;;       :wav-filename
  ;;       #object[java.io.File 0x105830d2 "/tmp/soundcljoud.12524185230907219576/Garth Brooks - The Old Stuff.wav"],
  ;;       :mp3-size 5943424}
  ;;      ...
  ;;      {:number "10",
  ;;       :duration "301",
  ;;       :artist "Garth Brooks",
  ;;       :title "Ireland",
  ;;       :year "1995",
  ;;       :filename
  ;;       #object[java.io.File 0x59ba6e31 "~/Music/g/Garth Brooks/Fresh Horses/Garth Brooks - Ireland.ogg"],
  ;;       :mp3-filename
  ;;       "/tmp/soundcljoud.12524185230907219576/Garth Brooks - Ireland.mp3",
  ;;       :album "Fresh Horses",
  ;;       :wav-filename
  ;;       #object[java.io.File 0x4de1472 "/tmp/soundcljoud.12524185230907219576/Garth Brooks - Ireland.wav"],
  ;;       :mp3-size 6969472})}
)

We also have a /tmp/soundcljoud.12524185230907219576/album.rss file containing:

<?xml version='1.0' encoding='UTF-8'?>
<rss version="2.0"
     xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"
     xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>Garth Brooks - Fresh Horses</title>
    <link>https://api.discogs.com/masters/212114</link>
    <pubDate>Sun, 01 Jan 1995 00:00:00 +0000</pubDate>
    <itunes:subtitle>Album: Garth Brooks - Fresh Horses</itunes:subtitle>
    <itunes:author>Garth Brooks</itunes:author>
    <itunes:image href="https://i.discogs.com/0eLXmM1tK1grkH8cstgDT6eV2TlL0NvgWPZBoyScJ_8/rs:fit/g:sm/q:90/h:600/w:600/czM6Ly9kaXNjb2dz/LWRhdGFiYXNlLWlt/YWdlcy9SLTY4NDcx/Ny0xNzE3NDU5MDIy/LTMxNjguanBlZw.jpeg"/>
    
    <item>
      <itunes:title>The Old Stuff</itunes:title>
      <title>The Old Stuff</title>
      <itunes:author>Garth Brooks</itunes:author>
      <enclosure
          url="http://localhost:1341/albums/Fresh+Horses/01+-+Garth+Brooks+-+The+Old+Stuff.mp3"
          length="5943424" type="audio/mpeg" />
      <pubDate>Sun, 01 Jan 1995 00:00:00 +0000</pubDate>
      <itunes:duration>252</itunes:duration>
      <itunes:episode>1</itunes:episode>
      <itunes:episodeType>full</itunes:episodeType>
      <itunes:explicit>false</itunes:explicit>
    </item>

    ...
    
    <item>
      <itunes:title>Ireland</itunes:title>
      <title>Ireland</title>
      <itunes:author>Garth Brooks</itunes:author>
      <enclosure
          url="http://localhost:1341/albums/Fresh+Horses/Garth+Brooks+-+Ireland.mp3"
          length="6969472" type="audio/mpeg" />
      <pubDate>Sun, 01 Jan 1995 00:00:00 +0000</pubDate>
      <itunes:duration>301</itunes:duration>
      <itunes:episode>10</itunes:episode>
      <itunes:episodeType>full</itunes:episodeType>
      <itunes:explicit>false</itunes:explicit>
    </item>
    
  </channel>
</rss>

In theory, if we put this RSS file and our MP3 somewhere a podcast player can find them, we should be able to listen to some Garth Brooks! However, http://localhost:1341/ is not likely to be reachable by a podcast player, so perhaps we should put a webserver there and whilst we're at it, just write our own little Soundcloud clone webapp. Seems reasonable, right?

We'll get into that in the next instalment of "Soundcljoud, or a young man's Soundcloud clonejure."

cljcastr, or a young man's Zencastr clonejure

A man with the Babashka logo for a face sits in front of a laptop and a mic

Who amongst us hasn't wanted to make a podcast? And of those who, who amongst them hasn't drooled over Zencastr, which is a web-based podcast recording studio thingy?

I don't even know how to answer that question, as convoluted as it got, but what I'm trying to say is that I got to see Zencastr's cool interface, and have been meaning to play around with the browser's audio / video API anyway, so why not see if I can whip up a quick Zencastr clone in Clojure? I mean, how hard can it be?

Popping in a Scittle

You may recall from my adventures cloning Flickr that I love ClojureScript but feel sad at my own lack of knowledge when trying to use shadow-cljs. You may also recall that the sweet sweet antidote to this was Scittle, which allows you to "execute Clojure(Script) directly from browser script tags via SCI". Since I'm now an expert Scittler, I figured that's the obvious place to start a Zencastr clone. So let's start a project!

$ mkdir cljcastr && cd cljcastr

Then we need a bb.edn, which we can just steal from Scittle's nrepl demo and modify ever so slightly to serve resources out of the public/ directory:

{:deps {io.github.babashka/sci.nrepl
        {:git/sha "2f8a9ed2d39a1b09d2b4d34d95494b56468f4a23"}
        io.github.babashka/http-server
        {:git/sha "b38c1f16ad2c618adae2c3b102a5520c261a7dd3"}}
 :tasks {http-server {:doc "Starts http server for serving static files"
                      :requires ([babashka.http-server :as http])
                      :task (do (http/serve {:port 1341 :dir "public"})
                                (println "Serving static assets at http://localhost:1341"))}

         browser-nrepl {:doc "Start browser nREPL"
                        :requires ([sci.nrepl.browser-server :as bp])
                        :task (bp/start! {})}

         -dev {:depends [http-server browser-nrepl]}

         dev {:task (do (run '-dev {:parallel true})
                        (deref (promise)))}}}

Given this, let's create a public/index.html to bootstrap our ClojureScript:

<!doctype html>
<html class="no-js" lang="">

<head>
    <meta charset="utf-8">
    <meta http-equiv="x-ua-compatible" content="ie=edge">
    <title>cljcastr</title>
    <meta name="description" content="">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="stylesheet" href="style.css">

    <link rel="apple-touch-icon" href="/apple-touch-icon.png">
    <!-- Place favicon.ico in the root directory -->

    <script src="https://cdn.jsdelivr.net/npm/scittle@0.6.15/dist/scittle.js" type="application/javascript"></script>
    <script>var SCITTLE_NREPL_WEBSOCKET_PORT = 1340;</script>
    <script src="https://cdn.jsdelivr.net/npm/scittle@0.6.15/dist/scittle.nrepl.js"
        type="application/javascript"></script>
    <script type="application/x-scittle" src="cljcastr.cljs"></script>
</head>

<body>
    <!--[if lt IE 8]>
      <p class="browserupgrade">
      You are using an <strong>outdated</strong> browser. Please
      <a href="http://browsehappy.com/">upgrade your browser</a> to improve
      your experience.
      </p>
    <![endif]-->
</body>

</html>

And of course we need to get stylish with a public/style.css:

body {
  font-family: Proxima Nova,helvetica neue,helvetica,arial,sans-serif;
}

And finally, we need public/cljcastr.clj to script some Clojure:

;; To start a REPL:
;;
;; bb dev
;;
;; Then connect to it in Emacs:
;;
;; C-c l C (cider-connect-cljs), host: localhost; port: 1339; REPL type: nbb

(ns cljcastr)

I always forget how to start the REPL and connect to it, so I left myself some nice explicit instructions, which we shall now follow. In the terminal:

$ bb dev
Serving static assets at http://localhost:1341
nREPL server started on port 1339...
Websocket server started on 1340...

We'll then visit http://localhost:1341 in the browser and open up the JavaScript console, which should say:

   :ws #object[WebSocket [object WebSocket]]
> 

Finally, back in Emacs, hitting C-c l C (cider-connect-cljs), selecting localhost for the host, 1339 for the port, and nbb for the REPL type, then C-c C-k (cider-load-buffer) shows us this in the terminal:

:msg "{:versions {\"scittle-nrepl\" {\"major\" \"0\", \"minor\" \"0\", \"incremental\" \"1\"}}, :ops {\"complete\" {}, \"info\" {}, \"lookup\" {}, \"eval\" {}, \"load-file\" {}, \"describe\" {}, \"close\" {}, \"clone\" {}, \"eldoc\" {}}, :status [\"done\"], :id \"3\", :session \"5e3f1fb0-1f13-4db0-a25a-b63a9e7d7d72\", :ns \"cljcastr\"}"
:msg "{:value \"nil\", :id \"5\", :session \"5e3f1fb0-1f13-4db0-a25a-b63a9e7d7d72\", :ns \"cljcastr\"}"
:msg "{:status [\"done\"], :id \"5\", :session \"5e3f1fb0-1f13-4db0-a25a-b63a9e7d7d72\", :ns \"cljcastr\"}"

Exciting! Let's prove we're connected with a Rich comment:

(comment

  (println "Now we're cooking with Scittle!")  ; <- C-c C-v f c e (cider-pprint-eval-last-sexp-to-comment)
  ;; => nil

  )

If all went well, we should see glorious things in the JavaScript console:

Screenshot of a browser window with the JavaScript console displaying: Now we're cooking with Scittle!

Left to our own devices

Now that we have a solid platform to stand on (namely: the REPL), let's get on with the cljcasting! We'll start by asking ourselves what audio and video devices we have at our disposal.

Modern browsers implement the Media Capture and Streams API, which provides support for streaming audio and video data. You can read a bit about the backstory of the API in a nice little article by Eric Bidelman and Sam Dutton: Capture audio and video in HTML5. This article points to a great demo of A/V capture that Sam Dutton did.

I am relating all this because Sam Dutton's demo comes with source code that shows not tells how to use this API, and like any great artist, I stole that code and used it for my own nefarious purposes. Well, "nefarious" might be a bit of a stretch, but c'mon, I've got a reputation to uphold over here. ๐Ÿ˜…

Our entrypoint into the wonderful world of browser-based A/V is the MediaDevices interface, which is exposed as navigator.mediaDevices. MediaDevices has an instance method enumerateDevices(), which we can use to, well, enumerate the audio and video devices availabile to our browser:

(comment

  (.enumerateDevices js/navigator.mediaDevices)
  ;; => #object[Promise [object Promise]]

  )

Blergh, looks like it returns a promise instead of an actual value (OK, OK, a promise is a value, but you know what I mean). That means that we need to feed a function to the promise that actually does the thing. We do this by using Promise.then(), which calls a function when the promise is fulfilled and returns a promise which wraps the return value of the function, allowing us to chain calls in a very similar way to Clojure's threading operators, -> and ->>.

Now, before we do this, I discovered during the writing of this post that my browser hides all devices from me until I give it permission to use my audio and video devices. We can trigger that permission request with this incantation:

(comment

  (.getUserMedia js/navigator.mediaDevices #js {:video true, :audio true})
  ;; => #object[Promise [object Promise]]

  )

What should happen is that we're presented with a dialog asking for permission to use our video camera and microphone. Assuming we trust ourselves this far, we can accept and get back to seeing what mediaDevices.enumerateDevices() returns:

(comment

  (-> (.enumerateDevices js/navigator.mediaDevices)
      (.then println))
  ;; => #object[Promise [object Promise]]

  )

This results in some awesome stuff being printed to the JS console:

#js [#object[InputDeviceInfo [object InputDeviceInfo]]
     #object[InputDeviceInfo [object InputDeviceInfo]]
     #object[InputDeviceInfo [object InputDeviceInfo]]
     #object[InputDeviceInfo [object InputDeviceInfo]]
     #object[InputDeviceInfo [object InputDeviceInfo]]
     #object[InputDeviceInfo [object InputDeviceInfo]]
     #object[MediaDeviceInfo [object MediaDeviceInfo]]
     #object[MediaDeviceInfo [object MediaDeviceInfo]]
     #object[MediaDeviceInfo [object MediaDeviceInfo]]
     #object[MediaDeviceInfo [object MediaDeviceInfo]]
     #object[MediaDeviceInfo [object MediaDeviceInfo]]
     #object[MediaDeviceInfo [object MediaDeviceInfo]]]

OK, so we have an array of MediaDeviceInfo and InputDeviceInfo objects, which have convenient .label and .kind properties that we can avail ourselves of:

(comment

  (-> (.enumerateDevices js/navigator.mediaDevices)
      (.then (fn [devices]
               (->> devices
                    (group-by #(.-kind %))
                    (sort-by key)
                    (map (fn [[kind ds]]
                           (str kind ":\n  "
                                (str/join "\n  " (map #(.-label %) ds)))))
                    (str/join "\n")
                    println))))
  ;; => #object[Promise [object Promise]]

  )

This gives us something much more reasonable in the console:

audioinput:
  Default
  Tiger Lake-LP Smart Sound Technology Audio Controller Digital Microphone
  HD Webcam B910 Analog Stereo
  Yeti X Analog Stereo
audiooutput:
  Default
  Tiger Lake-LP Smart Sound Technology Audio Controller HDMI / DisplayPort 3 Output
  Tiger Lake-LP Smart Sound Technology Audio Controller HDMI / DisplayPort 2 Output
  Tiger Lake-LP Smart Sound Technology Audio Controller HDMI / DisplayPort 1 Output
  Tiger Lake-LP Smart Sound Technology Audio Controller Speaker + Headphones
  Yeti X Analog Stereo
videoinput:
  Integrated Camera (04f2:b6ea)
  UVC Camera (046d:0823) (046d:0823)

This looks like useful information indeed! Let's extract some functions out of the mess we made in our REPL:

(ns cljcastr
  (:require [clojure.string :as str]))

(defn log-devices [devices]
  (->> devices
       (group-by #(.-kind %))
       (sort-by key)
       (map (fn [[kind ds]]
              (str kind ":\n  "
                   (str/join "\n  " (map #(.-label %) ds)))))
       (str/join "\n")
       println)
  devices)

(defn get-devices []
  (.enumerateDevices js/navigator.mediaDevices))

Now, taking inspiration from Sam Dutton's demo, let's make a UI that lets you choose your video source and stream from it into a window:

Screenshot of a browser window showing Sam Dutton's mediaDevices demo

We'll start by opening up our public/index.html and sprinkling in some UI elements:

<body>
    <!--[if lt IE 8]>
      ...
    <![endif]-->

  <div id="container">
    <h1>cljcastr</h1>
    <div class="select">
      <label for="videoSource">Video source:</label>
      <select id="videoSource"></select>
    </div>
    <video autoplay muted playsinline></video>
  </div>
</body>

Since the labels of my devices were quite lengthy, let's make the select quite widthy by dropping the following in public/style.css:

select {
  width: 300px;
}

After doing this, we'll sadly have to refresh the browser to get it to pick up the changes to index.html and style.css. We could of course add in some awesome watching and live reloading like quickblog does, but that smacks of effort and we don't have any useful state in the REPL to mourn anyway, so we'll bite our tongue and hope we don't have too many HTML or CSS changes left to make.

Now that we have the bones of a UI, let's actually populate the select element with the video devices that we've detected. We can try grabbing all of the video input devices:

(comment

  (-> (get-devices)
      (.then (fn [devices]
               (->> devices
                    (filter #(= "videoinput" (.-kind %)))
                    log-devices))))
  ;; => #object[Promise [object Promise]]

  )

The JS console now reads:

videoinput:
  Integrated Camera (04f2:b6ea)
  UVC Camera (046d:0823) (046d:0823)

Stuffing these in the select should be fairly straightforward:

(def video-select (.querySelector js/document "select#videoSource"))

(comment

  (-> (get-devices)
      (.then (fn [devices]
               (doseq [device
                       (->> devices
                            (filter #(= "videoinput" (.-kind %)))
                            log-devices)]
                 (let [option (.createElement js/document "option")]
                   (set! (.-value option) (.-deviceId device))
                   (set! (.-text option)
                         (or (.-label device)
                             (str "Camera " (inc (.-length video-select)))))
                   (.appendChild video-select option))))))
  ;; => #object[Promise [object Promise]]

  )

Et voilร ! The browser now shows our cameras:

Screenshot of a browser window showing a selection box containing two video input sources

Having proven this works, let's make a function out of it:

(defn populate-device-selects! [devices]
  (doseq [device
          (->> devices
               (filter #(= "videoinput" (.-kind %)))
               log-devices)]
    (let [option (.createElement js/document "option")]
      (set! (.-value option) (.-deviceID device))
      (set! (.-text option)
            (or (.-label device)
                (str "Camera " (inc (.-length video-select)))))
      (.appendChild video-select option))))

In case you haven't come across this convention before, adding a ! to the end of a function name indicates that the function is mutating something, in this case, adding options to the select element.

<video> killed the Adobe Flash star

Having given ourselves a way to select a video input device, we just need to actually display the video being input into said device. For this, we'll need to avail ourselves of the MediaDevices.getUserMedia() method. Given a device ID, it will "prompt the user for permission to use a media input which produces a MediaStream".

Let's check which video input device is selected:

(comment

  (.-value video-select)
  ;; => "d9862f4684c6b3f21bf95436a09b58dfa1b7a442e79aff225314e5e9bab45217"

  )

If we feed this ID to getUserMedia(), we should get a stream back:

(comment

  (-> js/navigator.mediaDevices
      (.getUserMedia (clj->js {:video {:deviceId {:exact (.-value video-select)}}}))
      (.then #(println (.getVideoTracks %))))
  ;; => #object[Promise [object Promise]]

  )

This clj->js business is taking a ClojureScript hashmap and turning it into a JavaScript object with nested objects. You have to remember to use it whenever you're calling JavaScript functions that take "maps" as arguments, lest those functions basically ignore your arguments. Don't ask me how I know! ๐Ÿ˜…

As an interesting aside, ClojureScript also has a #js reader tag, which says "turn the following ClojureScript literal into the JavaScript equivalent". As an interesting aside to the aside, this is not recursive. Don't ask me how I know! ๐Ÿ˜…

(comment

  #js {:video "killed the radio star"}
  ;; => #js {:video "killed the radio star"}

  #js {:video {:deviceId {:exact (.-value video-select)}}}
  ;; => #js {:video
  ;;         {:deviceId
  ;;          {:exact
  ;;           "24705d21befb46ac4b2596716eee02c2fecb819447ef3edb91562aad41d2db50"}}}

  (clj->js {:video {:deviceId {:exact (.-value video-select)}}})
  ;; => #js {:video
  ;;         #js {:deviceId
  ;;              #js {:exact
  ;;                   "24705d21befb46ac4b2596716eee02c2fecb819447ef3edb91562aad41d2db50"}}}

  )

OK, getting back to our code:

(comment

  (-> js/navigator.mediaDevices
      (.getUserMedia (clj->js {:video {:deviceId {:exact (.-value video-select)}}}))
      (.then #(println (.getVideoTracks %))))
  ;; => #object[Promise [object Promise]]

  )

When we evaluated this, two interesting things should have happened. First, the JS console should say something like this:

#js [#object[MediaStreamTrack [object MediaStreamTrack]]]

And second, the recording light on your webcam should light up. OMG we're getting somewhere! ๐ŸŽ‰

Of course, our goal isn't simply to turn on the webcam, but rather to turn it on and then start streaming video to our webpage. This is actually pretty straightforward, compared to what we've done to get to this point.

(def video-element (.querySelector js/document "video"))

(comment

  (-> js/navigator.mediaDevices
      (.getUserMedia (clj->js {:video {:deviceId {:exact (.-value video-select)}}}))
      (.then #(set! (.-srcObject video-element) %)))
  ;; => #object[Promise [object Promise]]

  )

The results are stunning...ly bad. Unless of course you're more photogenic than I am, in which case, congrats!

Screenshot of a browser window showing a video of me

Now that we're streaming video, it looks pretty ugly to have the video pressed right up against the bottom of the select element, so let's add some margin in our style.css:

select {
  width: 300px;
  margin-bottom: 10px;
}

With all of this plumbing, we can hook it up to the actual select box so it automatically starts playing video when we make a camera selection, rather than requiring us to go all 1337 h4ckZ0r in the REPL.

(def active-stream (atom nil))

(def video-element (.querySelector js/document "video"))

(defn log-error [e]
  (.error js/console e))

(defn log-devices [devices]
  ;; ...
  )

(defn get-devices []
  ;; ...
  )

(defn populate-device-selects! [devices]
  ;; ...
  )

(defn select-device! [select-element tracks]
  (let [label (-> tracks first (.-label))
        index (->> (.-options select-element)
                   (zipmap (range))
                   (some (fn [[i option]]
                           (and (= label (.-text option)) i))))]
    (when index
      (println "Setting selected video source to index" index)
      (set! (.-selectedIndex select-element) index))))

(defn start-video! [stream]
  (reset! active-stream stream)
  (select-device! video-select (.getVideoTracks stream))
  (set! (.-srcObject video-element) stream))

(defn stop-video! []
  (when @active-stream
    (println "Stopping currently playing video")
    (doseq [track (.getTracks @active-stream)]
      (.stop track))))

(defn set-video-stream! []
  (stop-video!)
  (let [video-source (.-value video-select)
        constraints {:video {:deviceId (when (not-empty video-source)
                                         {:exact video-source})}}]
    (println "Getting media with constraints:" constraints)
    (-> js/navigator.mediaDevices
        (.getUserMedia (clj->js constraints))
        (.then start-video!)
        (.catch log-error))))

(defn load-ui! []
  (set! (.-onchange video-select) set-video-stream!)
  (-> (set-video-stream!)
      (.then get-devices)
      (.then log-devices)
      (.then populate-device-selects!)))

(comment

  (load-ui!)
  ;; => #object[Promise [object Promise]]

  )

Let's break these functions down to see what's going on here:

(defn load-ui! []
  (set! (.-onchange video-select) set-video-stream!)
  (-> (set-video-stream!)
      ;; ...
      ))

First, we set the change handler for the video select element to the set-video-stream! function, then we call set-video-stream!.

(defn set-video-stream! []
  (stop-video!)
  ;; ...
  )

set-video-stream! calls stop-video!:

(defn stop-video! []
  (when @active-stream
    (println "Stopping currently playing video")
    (doseq [track (.getTracks @active-stream)]
      (.stop track))))

stop-video! checks to see if we have a truthy value in our active-stream atom, which we won't at this point, since we initiatise the atom with a nil value:

(def active-stream (atom nil))

Back to set-video-stream!:

(defn set-video-stream! []
  ;; ...
  (let [video-source (.-value video-select)
        constraints {:video {:deviceId (when (not-empty video-source)
                                         {:exact video-source})}}]
    ;; ...
    ))

Since we haven't yet populated the video select element with video sources, video-select.value will be "", which is not not empty (in other words, it's empty), so our constraints map will look like this:

(comment

  (let [video-source (.-value video-select)
        constraints {:video {:deviceId (when (not-empty video-source)
                                         {:exact video-source})}}]
    constraints)
  ;; => {:video {:deviceId nil}}

  )

Feeding this to navigator.mediaDevices.getUserMedia() will result in prompting the user for permission to access whichever of their cameras the browser considers the default, then turning on that camera and providing a MediaStream containing a video track with the input, which we then feed to start-video!.

(defn set-video-stream! []
  ;; ...
  (let [ ;; ...
       ]
    (println "Getting media with constraints:" constraints)
    (-> js/navigator.mediaDevices
        (.getUserMedia (clj->js constraints))
        (.then start-video!)
        (.catch log-error))))

start-video! is fairly simple:

(defn start-video! [stream]
  (reset! active-stream stream)
  (select-device! video-select (.getVideoTracks stream))
  (set! (.-srcObject video-element) stream))

The first thing it does is reset the value of the active-stream atom to the stream returned by getUserMedia(), then calls select-device! with the video select DOM element and the video tracks of the stream, then finally sets the srcObject property of the <video> element to the stream, which results in us seeing ourselves (or whatever our default camera is aimed at).

select-device! is responsible for setting the value of a select element to the device corresponding to the first of the MediaStreamTrack objects we passed it:

(defn select-device! [select-element tracks]
  (let [label (-> tracks first (.-label))
        index (->> (.-options select-element)
                   (zipmap (range))
                   (some (fn [[i option]]
                           (and (= label (.-text option)) i))))]
    (when index
      (println "Setting selected video source to index" index)
      (set! (.-selectedIndex select-element) index))))

In this case, that will be the video select element and the video tracks from the default camera.

The tracks are labelled with the name of the device they correspond to:

(comment

  (->> (.getVideoTracks @active-stream)
       (map #(.-label %)))
  ;; => ("Integrated Camera (04f2:b6ea)")

  )

Which are the same names we used to populate our video select options:

(comment

  (->> (.-options video-select)
       (map #(.-text %)))
  ;; => ("Integrated Camera (04f2:b6ea)"
  ;;     "UVC Camera (046d:0823) (046d:0823)")

  )

To select an option, we need to set the selectedIndex property of the select element to the index corresponding to the option we want. We can turn the list of options into a map of index to option using zipmap, which takes a list of keys and a list of values and returns a map with the keys mapped to the corresponding values:

(comment

  (->> (.-options video-select)
       (zipmap (range)))
  ;; => {0 #object[HTMLOptionElement [object HTMLOptionElement]]
  ;;     1 #object[HTMLOptionElement [object HTMLOptionElement]]}

  )

Finally, we need to return the index of first option where the value of the text property matches the label we're looking for:

(comment

  (->> (.-options video-select)
       (zipmap (range))
       (some (fn [[i option]]
               (and (= label (.-text option)) i)))))
  ;; => 0

  )

Note that the some function returns the first truthy value return by the predicate function, so we can use a neat little trick to return the index:

(and (= label (.-text option)) i)

When the text matches the label, the first clause of the and will be truthy (a literal true), and the second clause, the index, will also be truthy because only false and nil are not truthy in Clojure, and and returns the last truthy value, which is the index, so the return value of some is the index corresponding to the label. Without this trick, we'd have to resort to something like this:

(comment

  (let [label "Integrated Camera (04f2:b6ea)"]
    (->> (.-options video-select)
         (zipmap (range))
         (filter (fn [[i option]]
                   (= label (.-text option))))
         ffirst))
  ;; => 0

  )

I hope we can all agree that this is gross! ๐Ÿคฎ

So that's what happens on the initial load of the page. If we have more than one camera, we can select it, which results in set-video-stream! being called again:

(defn set-video-stream! []
  (stop-video!)
  (let [video-source (.-value video-select)
        constraints {:video {:deviceId (when (not-empty video-source)
                                         {:exact video-source})}}]
    (println "Getting media with constraints:" constraints)
    (-> js/navigator.mediaDevices
        (.getUserMedia (clj->js constraints))
        (.then start-video!)
        (.catch log-error))))

This time, the video select element will have a value:

(comment

  (let [video-source (.-value video-select)
        constraints {:video {:deviceId (when (not-empty video-source)
                                         {:exact video-source})}}]
    constraints)
  ;; => {:video
  ;;     {:deviceId
  ;;      {:exact
  ;;       "24705d21befb46ac4b2596716eee02c2fecb819447ef3edb91562aad41d2db50"}}}

  )

Hence .getUserMedia() will return a MediaStream for that specific camera, and then start-video! and the rest of it work as before.

OK, that was a lot! ๐Ÿ˜…

Audioimmolation

Now that we have video on lockdown, let's see if we can add some sweet sweet audio. We'll start with the HTML:

<!doctype html>
<html class="no-js" lang="">
<!-- ... -->
<body>
  <!-- ... -->
  <div id="container">
    <h1>cljcastr</h1>
    <div id="sources">
      <div class="select">
        <label for="videoSource">Video source:</label>
        <select id="videoSource"></select>
      </div>
      <div class="select">
        <label for="audioSource">Audio source:</label>
        <select id="audioSource"></select>
      </div>
    </div>
    <video autoplay muted playsinline></video>
  </div>
</body>

</html>

Note that we're wrapping the two .select divs in another div. In our style.css, we can move the margin down to this div instead of directly on the <select> elements:

div#sources {
  margin-bottom: 10px;
}

If we refresh the page, we'll now see a select element labelled "Audio source" pop up.

Now back to cljcastr.cljs! First we add a binding for the audio select to the top of the file alongside the video select:

(ns cljcastr
  (:require [clojure.string :as str]))

(def video-element (.querySelector js/document "video"))
(def video-select (.querySelector js/document "select#videoSource"))
(def audio-select (.querySelector js/document "select#audioSource"))

Now, let's walk through the UI flow, starting with load-ui!, and see where to sprinkle in audio stuff:

(defn load-ui! []
  (set! (.-onchange video-select) set-video-stream!)
  (-> (set-video-stream!)
      (.then get-devices)
      (.then log-devices)
      (.then populate-device-selects!)))

Digging into set-video-stream!, it looks like we can grab the audio source in exactly the same way as we do the video one, so let's add that in:

(defn set-video-stream! []
  (stop-video!)
  (let [audio-source (.-value audio-select)
        video-source (.-value video-select)
        constraints {:audio {:deviceId (when (not-empty audio-source)
                                         {:exact audio-source})}
                     :video {:deviceId (when (not-empty video-source)
                                         {:exact video-source})}}]
    (println "Getting media with constraints:" constraints)
    (-> js/navigator.mediaDevices
        (.getUserMedia (clj->js constraints))
        (.then start-video!)
        (.catch log-error))))

We should also rename the function, now that it's responsible for audio as well. set-media-stream! seems like a pretty decent name, so let's go for that! Whilst we're at the renaming, we can rename stop-video! to stop-media! as well. The contents of the function itself look pretty good, except the log statement, so we can fix that:

(defn stop-media! []
  (when @active-stream
    (println "Stopping currently playing media")
    (doseq [track (.getTracks @active-stream)]
      (.stop track))))

If we keep going in set-media-stream!, the .getUserMedia() call is fine, since we've added an audio constraint. The next thing that happens is the call to start-video!, which we can rename to start-media! and then have a look at:

(defn start-media! [stream]
  (reset! active-stream stream)
  (select-device! video-select (.getVideoTracks stream))
  (set! (.-srcObject video-element) stream))

It looks like we can use select-device! to handle the audio as well, so let's try that out:

(defn start-media! [stream]
  (reset! active-stream stream)
  (select-device! audio-select (.getAudioTracks stream))
  (select-device! video-select (.getVideoTracks stream))
  (set! (.-srcObject video-element) stream))

OK, it looks like we're in pretty good shape in set-media-stream! now, so let's keep walking through load-ui!:

(defn load-ui! []
  (set! (.-onchange video-select) set-media-stream!)
  (-> (set-media-stream!)
      (.then get-devices)
      (.then log-devices)
      (.then populate-device-selects!)))

Next up after the call to set-media-stream! is the call to get-devices, so let's dig in there:

(defn get-devices []
  (.enumerateDevices js/navigator.mediaDevices))

That looks pretty reasonable, so let's look at the final function called from load-ui!, which is populate-device-selects!.

(defn populate-device-selects! [devices]
  (doseq [device
          (->> devices
               (filter #(= "videoinput" (.-kind %)))
               (log-devices "Populating video inputs with devices"))]
    (let [option (.createElement js/document "option")]
      (set! (.-value option) (.-deviceId device))
      (set! (.-text option)
            (or (.-label device)
                (str "Camera " (inc (.-length video-select)))))
      (.appendChild video-select option))))

Yikes! ๐Ÿ˜ฑ Looks like we have a little refactoring to do here. After diving into the closest phonebooth (they still have those, right?) to replace our nerdy glasses with our LISP superhero cape, we can do a top-down design move and rewrite the function the way we wish it worked:

(defn populate-device-selects! [devices]
  (populate-device-select! audio-select (audio-devices devices))
  (populate-device-select! video-select (video-devices devices)))

Looks quite nice, doesn't it? Given this, let's write populate-device-select!:

(defn populate-device-select! [select-element devices]
  (let [select-label (->> (.-labels select-element) first .-textContent)]
    (doseq [device (log-devices (str "Populating options for " select-label) devices)]
      (let [option (.createElement js/document "option")]
        (set! (.-value option) (.-deviceId device))
        (set! (.-text option) (.-label device))
        (.appendChild select-element option)))))

It's nice to specify in the log output which select we're populating, and since we specified a label in our HTML:

<label for="videoSource">Video source:</label>
<select id="videoSource"></select>

we can access the label through the labels property on the select element. Since we know that we only have one label, we can take the first one and grab the value of its textContent property:

(let [select-label (->> (.-labels select-element) first .-textContent)]
  ;; ...
  )

Pretty neat!

OK, now that we have populate-device-select!, the last two functions we need to write are audio-devices and video-devices. Well, it turns out that we've more or less already written video-devices in the original populate-device-selects! code:

(->> devices
     (filter #(= "videoinput" (.-kind %)))
     (log-devices "Populating video inputs with devices"))

Let's transform this into a function:

(defn video-devices [devices]
  (filter #(= "videoinput" (.-kind %)) devices))

Given this, writing audio-devices is just some copy / paste / query-replace:

(defn audio-devices [devices]
  (filter #(= "audioinput" (.-kind %)) devices))

We can test this out:

(comment

  (doseq [f [audio-devices video-devices]]
    (-> (get-devices)
        (.then (comp log-devices f))))
  ;; => #object[Promise [object Promise]]

  )

We should now see something like this in the JavaScript console:

audioinput:
  Default (default)
  Tiger Lake-LP Smart Sound Technology Audio Controller Digital Microphone (94edc85f1f91926d1e9f9da6995188d6263dee15e8a45a6d1add28f64f74c13b)
  HD Webcam B910 Analog Stereo (f78a32bacbfbe6ffe238b0d3b046f11bf4ed8e5ad8ce6cf25f18d431be3cd9af)
  Yeti X Analog Stereo (5af7607e641d0c8061291e648a5bec4958a588147bf0ffcc61a1ef5f2afb6cb6)
videoinput:
  Integrated Camera (04f2:b6ea) (d9862f4684c6b3f21bf95436a09b58dfa1b7a442e79aff225314e5e9bab45217)
  UVC Camera (046d:0823) (046d:0823) (24705d21befb46ac4b2596716eee02c2fecb819447ef3edb91562aad41d2db50)

Amazing!

At this point, we should be able to call load-ui! and see both the audio and video sources in their respective select dropdowns:

(comment

  (load-ui!)
  ;; => #object[Promise [object Promise]]

  )

Screenshot of a browser window showing a selection box containing four audio input sources

As a brief aside, I'm annoyed by that 404 (Not Found) when trying to get favicon.ico, so let's fix that, using lessons learned in Hacking the blog: favicon!

An iconic favicon

We'll just pop over to RealFaviconGenerator and supply a logo such as this one:

A microphone with the Clojure logo for the top part

Then download the favicon package and unzip it into our webserver root:

$ cd public
$ unzip ~/Downloads/cljcastr-favicon_package_v0.16.zip
Archive:  /home/jmglov/Downloads/cljcastr-favicon_package_v0.16.zip
  inflating: android-chrome-192x192.png
  inflating: mstile-150x150.png
  inflating: favicon-16x16.png
  inflating: safari-pinned-tab.svg
  inflating: favicon.ico
  inflating: site.webmanifest
  inflating: android-chrome-512x512.png
  inflating: apple-touch-icon.png
  inflating: browserconfig.xml
  inflating: favicon-32x32.png

And finally, drop this goodness into the <head> section of public/index.html:

<head>
    <!-- ... -->
    <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
    <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
    <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
    <link rel="manifest" href="/site.webmanifest">
    <link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5">
    <meta name="msapplication-TileColor" content="#da532c">
    <meta name="theme-color" content="#ffffff">
    <!-- ... -->
</head>

Reloading the cljcastr page should now show a delightful little icon in the tab.

Hey Mr. Selector

The only thing lacking at this point is adding an onchange event handler to the audio select. We can do that in load-ui!, and then we might as well call load-ui! on page load whilst we're at it:

(defn load-ui! []
  (set! (.-onchange audio-select) set-media-stream!)
  (set! (.-onchange video-select) set-media-stream!)
  (-> (set-media-stream!)
      (.then get-devices)
      (.then log-devices)
      (.then populate-device-selects!)))

(load-ui!)

Evaluating the buffer results in seeing ourselves, and we seem to be able to switch video and audio sources happily, but there's one annoying thing happening when switching audio source. Quoth the JS console:

Getting media with constraints:
  {:audio
   {:deviceId
    {:exact
     5af7607e641d0c8061291e648a5bec4958a588147bf0ffcc61a1ef5f2afb6cb6}},
   :video
   {:deviceId
    {:exact
     24705d21befb46ac4b2596716eee02c2fecb819447ef3edb91562aad41d2db50}}}
Setting selected video source to index 3
Setting selected video source to index 1

It looks like that first "video source" is actually an audio source. ๐Ÿ˜ฌ

Looking at select-device!, it's obvious why this is:

(defn select-device! [select-element tracks]
  (let [label (-> tracks first (.-label))
        index (->> (.-options select-element)
                   (zipmap (range))
                   (some (fn [[i option]]
                           (and (= label (.-text option)) i))))]
    (when index
      (println "Setting selected video source to index" index)
      (set! (.-selectedIndex select-element) index))))

Since we have the select DOM element here, we can use the same trick as in populate-device-select! to get its label. Let's extract that stuff to a function of its very own, then update populate-device-select! and select-device! to use it:

(defn label-for [element]
  (->> (.-labels element) first .-textContent))

(defn populate-device-select! [select-element devices]
  (doseq [device (log-devices (str "Populating options for "
                                   (label-for select-element))
                              devices)]
    ;; ...
    ))

(defn select-device! [select-element tracks]
  (let [
        ;; ...
       ]
    (when index
      (println "Setting index for" (label-for select-element) index)
      (set! (.-selectedIndex select-element) index))))

This looks much better now!

Getting media with constraints:
  {:audio
   {:deviceId
    {:exact
     5af7607e641d0c8061291e648a5bec4958a588147bf0ffcc61a1ef5f2afb6cb6}},
   :video
   {:deviceId
    {:exact
     24705d21befb46ac4b2596716eee02c2fecb819447ef3edb91562aad41d2db50}}}
Setting index for Audio source: 3
Setting index for Video source: 1

Stop, in the name of privacy, before you break my heart

This is all wonderful, but if you're anything like me, you probably don't like your webcam and mic surveilling you when you're not actively using them. Let's add a stop button that shuts down this whole dog and pony show. First, we can add the button in index.html:

  <div id="container">
    <h1>cljcastr</h1>
    <div id="sources">
      <div id="selects">
        <div class="select"> <!-- ... --> </div>
        <div class="select"> <!-- ... --> </div>
      </div>
      <div id="stop">
        <input id="stop" type="button" value="Stop" />
      </div>
    </div>
    <video autoplay muted playsinline></video>
  </div>

Yes, yes, I added another <div>. Listen, I never claimed to actually know what I was doing with this whole new-fangled HTML thing, OK? Back in my day, we had Gopher and counted ourselves lucky! Also, we FTP'd files uphill both ways in a blizzard and so on.

Speaking of not knowing what I'm doing, lemme sprinkle some CSS on the top of this lovely cake:

div#sources {
  display: flex;
  margin-bottom: 10px;
}

div#stop {
  padding-left: 10px;
}

Now that we have a button, let's make it do stuff and things:

(ns cljcastr
  (:require [clojure.string :as str]))

(def video-element (.querySelector js/document "video"))
(def video-select (.querySelector js/document "select#videoSource"))
(def audio-select (.querySelector js/document "select#audioSource"))
(def stop-button (.querySelector js/document "input#stop"))

;; ...

(defn load-ui! []
  (set! (.-onclick stop-button) stop-media!)
  ;; ...
  )

Reload the page, click the button, and watch your face disappear!

Screenshot of a browser window showing a black video screen

And there you have it! A fully functional Zencastr clone!

*Ahem*

Perhaps we're missing recording and connecting to other people and transcription and so on, but those are just bonus features that people don't really need for podcasting, right? Anyway, I'm quite proud of what we accomplished in 106 lines of ClojureScript! ๐Ÿ†