Sometimes I listen to albums in my music collection as a sort of timer. It’s pretty neat: I know about one hour has passed when the album Youthfull (koyori) is over (54 minutes), and I don’t have to have a clock on screen to be able to know that.
To do this, I want to sort the albums by duration so I can pick one that’s at the right length. However, (at least on Linux) seemingly no music player supports it. Some just don’t have a table view for sorting anything (GNOME Music), some don’t have a list view for albums (Lollypop, elementary Music), some allow sorting albums but not by duration (Elisa)…
So I ended up hacking it together myself in Emacs.
The whole thing:
Then, breaking it up:
Get the duration of one file
Goal: (k/song-duration "G4L - Instrumental.wav")
should return the length of the audio file G4L - Instrumental.wav
.
(defun k/song-duration (song-file)
"Return duration of SONG-FILE in seconds."
(with-temp-buffer
(call-process
"ffprobe" nil '(t nil) nil
"-v" "quiet"
"-print_format" "json"
"-show_streams"
song-file)
(goto-char (point-min))
(-some--> (json-parse-buffer
:object-type 'alist)
(map-elt it 'streams)
(seq-find (lambda (elem)
(equal (map-elt elem 'codec_type)
"audio"))
it)
(map-elt it 'duration)
string-to-number)))
First open a temporary buffer to do call-process
in…
(with-temp-buffer
Then I use ffprobe
to get information about an audio file. ffprobe
is able to return JSON, so I ask it to do so.
(The first nil is which file to use as stdin, nil for nothing; the second nil is specifying that we don’t want to display this buffer. The '(t nil)
means insert stdout into this buffer and toss stderr away. I have to check the docstring everytime I use it.)
(call-process
"ffprobe" nil '(t nil) nil
"-v" "quiet"
"-print_format" "json"
"-show_streams"
song-file)
The JSON output looks something like this (irrelevant fields are removed here)
{
"streams": [
{
"index": 0,
"codec_name": "mp3",
"codec_long_name": "MP3 (MPEG audio layer 3)",
"codec_type": "audio",
"duration_ts": 1136148480,
"duration": "80.509388"
},
{
"index": 1,
"codec_name": "mjpeg",
"codec_long_name": "Motion JPEG",
"codec_type": "video",
"duration_ts": 7245845,
"duration": "80.509389"
}
]
}
Before we parse the JSON, we have to first go back to the beginning of the buffer as json-parse-buffer
parses from the current cursor location, and call-process
has moved it.
(goto-char (point-min))
Now parse the JSON output, then extract the field I want.
I use -some-->
so that if we can’t find a valid field, it’ll just return nil.
I ask json-parse-buffer
to parse JSON objects into alists because I felt like this is faster. I’m not sure, though.
I also specify that I only want an audio stream. Selecting the image stream (used for embedding album art) in an audio file is fine as it will also have the same duration. However, this is still needed because ffprobe
also returns data from pure jpeg files, and it counts them as having a duration of 0.04 seconds if I remember correctly. Counting only audio streams prevents counting image files.
ffprobe
returns the duration as seconds in a string, so I use string-to-number
to finally convert it to a number.
(-some--> (json-parse-buffer
:object-type 'alist)
(map-elt it 'streams)
(seq-find (lambda (elem)
(equal (map-elt elem 'codec_type)
"audio"))
it)
(map-elt it 'duration)
string-to-number)
Get the duration of an entire album
Instead of trying to list albums by looking at metadata of each song, I simply rely on albums being represented by folders.
This allows the implementation to be quite simple:
(defun k/folder-duration (folder)
"Return duration of all songs in FOLDER."
(--> (directory-files folder t)
(mapcar #'k/song-duration it)
-non-nil
(apply #'+ it)))
This simply runs k/song-duration
(the function from the previous section) on every immediate member of folder
, removes the invalid values, then adds it all up.
Creating a listing based on tabulated-list-mode
The interactive
statement
(interactive (list (xdg-user-dir "MUSIC")))
This marks the function as an interactive command so that it shows up in M-x
and can be bound to a key chord.
The “input” to the interactive form tells Emacs that when run as an interactive command (through M-x
, a key bind, or call-interactively
), it should evaluate (list (xdg-user-dir "MUSIC"))
and use that as the argument list, making (xdg-user-dir "MUSIC")
the first and only argument.
Collecting duration
First collect the durations into an alist mapping folder paths to their total durations.
(dolist-with-progress-reporter (folder (f-directories dir))
"Probing folders..."
(push (cons folder (k/folder-duration folder)) folders))
dolist-with-progress-reporter
is a nicer version of dolist
that reports the progress as it goes. k/folder-duration
as defined in the previous section is actually quite slow, and because we're running it sequentially on all music files, it builds up.
The result (saved into the folders
variable, which has been defined by a surrounding let
) is an alist that looks like this:
(let (folders)
(dolist-with-progress-reporter (folder (f-directories (xdg-user-dir "MUSIC")))
"Probing folders..."
(push (cons folder (k/folder-duration folder)) folders))
folders)
Setting up a tabulated-list buffer
We need to:
- Enter a new buffer to display the data collected in
folders
- Start tabulated-list-mode. (Usually this is done by defining a new major mode.)
- Set up
tabulated-list-format
- Render the header with
tabulated-list-init-header
- Put our data into
tabulated-list-entries
in the right format - Run
revert-buffer
to trigger tabulated-list-mode's rendering mechanism
The most confusing thing for me has been figuring out what's the right shape for tabulated-list-format
and tabulated-list-entries
. Providing some examples probably helps:
tabulated-list-format
is a vector in this shape:[("folder" 70 t) ("duration" 20 my-sort-function) ("another column" 30 t :right-align t) ...]
Each element in this vector represents a column; each column is specified as
(<name> <width> <sort> [prop] [value] [prop] [value] …)
. So the third element above specifies a column with a name “another column”, a width of 30, sorting by string comparison, and aligns to the right. Its docstring describes it better:- NAME is a string describing the column. This is the label for the column in the header line. Different columns must have non-equal names.
- WIDTH is the width to reserve for the column. For the final element, its numerical value is ignored.
- SORT specifies how to sort entries by this column. If nil, this column cannot be used for sorting. If t, sort by comparing the string value printed in the column. Otherwise, it should be a predicate function suitable for sort, accepting arguments with the same form as the elements of tabulated-list-entries.
PROPS is a plist of additional column properties. Currently supported properties are:
- :right-align: If non-nil, the column should be right-aligned.
- :pad-right: Number of additional padding spaces to the right of the column (defaults to 1 if omitted).
SORT, if provided as a function, always accepts two entries that look like
(<id> [<column1> <column2> …])
, regardless of which column it is assigned to. As the docstring said, this is the same format that goes intotabulated-list-entries
.tabulated-list-entries
is a list in this shape:([("id1" ["column 1" "column 2" "column 3" …])] [("id2" ["column 1" "column 2" "column 3" …])])
The ID can also be nil, but providing it allows tabulated-list to keep the cursor on the current item when sorting. If it is provided, it can be any unique object (defined as not being
equal
to any other entries in this list).An entry's values for every column are called “column descriptors” in the docstring; they can be strings (as shown above) or a list like
("label" :key 123 :key2 456)
. The latter form is useful in sort functions, as you can store eg. numerical data there to compare with<
without needing to usestring-to-number
.
With that out of the way:
(with-current-buffer (get-buffer-create "*k/music folders*") ; Step 1
(when (= 0 (buffer-size))
(tabulated-list-mode) ; Step 2
;; Step 3
(setq tabulated-list-format
(vector
'("folder" 70 t) ; first column
(list "duration" 20 ; second column
(lambda (a b)
;; A and B look like
;; (ID [<folder> ("00:00:10" :seconds 10.0)])
;; - cadr -> grab the vector
;; - elt 1 -> skip <folder> and grab ("10.0" :seconds 10.0)
;; - cdr -> remove the label
;; - plist-get :seconds -> extract the numerical
;; value stored below, when we set up tabulated-list-entries
(< (-> (cadr a) (elt 1) cdr (plist-get :seconds))
(-> (cadr b) (elt 1) cdr (plist-get :seconds)))))))
(tabulated-list-init-header)) ; Step 4
;; Step 5
(dolist (folder folders)
;; Goal: each entry is something like
;; (nil ["deconstructing nature" ("00:46:28" :seconds 2788.2840810000002)])
(push (list nil (vector (f-base (car folder))
(list (format-seconds "%.2h:%.2m:%.2s" (cdr folder))
:seconds (cdr folder))))
tabulated-list-entries))
;; Step 6
(revert-buffer))