Taking Lecture Notes in Emacs While Watching YouTube
How I set up mpv + org-mode for a split-screen note-taking workflow, and the rabbit hole of tq buffers and IPC sockets along the way
I watch a lot of YouTube lectures. ML papers, math walkthroughs, systems programming talks — the kind of content where you actually want to write things down. My usual workflow was embarrassingly bad: video in one window, a text file in another, constantly alt-tabbing and losing my place.
What I really wanted was to stay in Emacs the entire time. Write notes in org-mode, insert a timestamp that links back to the exact moment in the video, and control playback without touching the mouse. Turns out you can do all of this — it just took a few hours of debugging to get there.
The plan: mpv + mpv.el + org-mode
The core idea is straightforward. mpv is a video player that can stream YouTube URLs directly (via yt-dlp), and it exposes a JSON IPC socket so external programs can control it. The mpv.el Emacs package wraps that socket, letting you pause, seek, and query playback position from Emacs Lisp.
The workflow I had in mind:
- Press a keybinding, paste a YouTube URL, video opens in mpv
- Emacs splits into two panes — video on one side (mpv is a separate window), org notes on the other
- As the lecture plays, I press a key to insert the current timestamp as an org heading, then type my notes under it
- Pause, seek, adjust speed — all from the notes buffer
Setup: mpv and yt-dlp
First, get mpv and yt-dlp installed:
# macOS
brew install mpv yt-dlp
mpv can play YouTube URLs natively once yt-dlp is available — it uses it under the hood to extract the stream.
The macOS PATH problem
First snag: Emacs on macOS doesn’t inherit your shell’s PATH. So even though mpv was sitting in /opt/homebrew/bin, Emacs had no idea it existed.
The quick fix is two lines at the top of init.el:
(setenv "PATH" (concat "/opt/homebrew/bin:" (getenv "PATH")))
(setq exec-path (cons "/opt/homebrew/bin" exec-path))
setenv fixes subprocess PATH (so when Emacs launches mpv, it can find it). exec-path fixes executable-find, which is what packages use to check if a binary exists. You need both.
The cleaner long-term approach is exec-path-from-shell, which reads your full shell config and syncs it into Emacs:
(use-package exec-path-from-shell
:ensure t
:config
(when (memq window-system '(mac ns x))
(exec-path-from-shell-initialize)))
Setting up the keymap
I wanted all the mpv controls under a C-c y prefix. The trick is that the prefix command has to be defined before anything tries to bind keys to it:
;; Define this FIRST, before the use-package block
(define-prefix-command 'my/mpv-map)
(global-set-key (kbd "C-c y") 'my/mpv-map)
Getting the order wrong gives you C-c y o is undefined — the keybinding exists but points at nothing.
The initial setup
Here’s the function that opens a YouTube URL and sets up the split:
(defun my/youtube-notes (url)
"Open a YouTube URL in mpv and set up a split org-mode note buffer."
(interactive "sYouTube URL: ")
(ignore-errors (mpv-kill))
(sleep-for 0.5)
(let ((socket "/tmp/mpv-socket"))
(setq mpv--process
(start-process "mpv" "*mpv*"
"mpv"
(concat "--input-ipc-server=" socket)
"--script-opts=ytdl_hook-ytdl_path=/opt/homebrew/bin/yt-dlp"
"--no-terminal"
url))
(sleep-for 2)
;; connect mpv.el to the socket
(setq mpv--queue
(tq-create
(make-network-process
:name "mpv-socket"
:family 'local
:service socket
:coding '(utf-8 . utf-8)
:noquery t
:filter #'mpv--tq-filter))))
(delete-other-windows)
(split-window-right)
(other-window 1)
(let ((notes-file (expand-file-name
(format "lecture-notes-%s.org"
(format-time-string "%Y%m%d-%H%M"))
"~/notes/")))
(make-directory "~/notes/" t)
(find-file notes-file)
(insert (format "#+TITLE: Lecture Notes\n#+DATE: %s\n#+URL: %s\n\n* Notes\n\n"
(format-time-string "%Y-%m-%d")
url))
(org-mode))
(message "mpv playing! Use C-c y keybindings to control."))
This worked — the video played, the org file was created, the split happened. Then I noticed the *spurious* buffers.
The spurious buffer rabbit hole
Every few seconds, Emacs was creating new buffers named *spurious*, *spurious*<2>, *spurious*<3>, and showing errors like:
error in process filter: tq-process-buffer: Spurious communication from process mpv-socket
The problem is the tq (transaction queue) library, which mpv.el uses to talk to mpv over the socket. tq was designed for request/response protocols where you send a command and get back exactly one response. But mpv doesn’t work that way — it sends unsolicited event messages constantly (playback position updates, property change notifications, etc). Every time tq received one of these and had no pending request to match it to, it created a *spurious* buffer and logged an error.
No amount of advice-ing or burying fixed the root cause. The solution was to bypass tq entirely and write a custom process filter:
The final solution
There are three distinct pieces: launching mpv with a socket, connecting Emacs to that socket, and sending/receiving commands over it.
Piece 1: Launching mpv with an IPC socket
mpv supports a JSON IPC protocol over a Unix domain socket. You enable it with the --input-ipc-server flag. Once mpv is running with this flag, anything that can talk to a Unix socket can send it commands and receive responses as newline-delimited JSON.
(setq mpv--process
(start-process "mpv" "*mpv*"
"mpv"
"--input-ipc-server=/tmp/mpv-socket"
"--script-opts=ytdl_hook-ytdl_path=/opt/homebrew/bin/yt-dlp"
"--no-terminal"
url))
(sleep-for 2)
start-process launches mpv as a subprocess. The socket file appears at /tmp/mpv-socket once mpv finishes initializing — hence the sleep-for 2 to wait for it. --no-terminal suppresses mpv’s terminal output that would otherwise pollute the *mpv* buffer.
Piece 2: Connecting to the socket with a custom filter
The original code used tq (a transaction queue library) to wrap the socket connection. tq assumes a strict request/response pattern — you send one thing, you get back exactly one thing. mpv violates this: it sends unsolicited event messages on the same socket constantly (tick events, property changes, etc). tq couldn’t handle the unexpected messages, so it panicked into *spurious* buffers.
The fix is to connect to the socket directly with make-network-process and supply our own filter function that knows how to handle mpv’s mixed stream:
(defun my/mpv-filter (process output)
"Handle mpv IPC output, ignoring unsolicited events."
(dolist (line (split-string output "\n" t))
(when (string-prefix-p "{" (string-trim line))
(condition-case nil
(let* ((json (json-read-from-string line))
(request-id (alist-get 'request_id json)))
(when (and request-id mpv--queue)
(tq-queue-pop mpv--queue)))
(error nil)))))
;; In my/youtube-notes, after the sleep-for:
(setq mpv--queue
(make-network-process
:name "mpv-socket"
:family 'local ; Unix domain socket, not TCP
:service socket ; path to the socket file
:coding '(utf-8 . utf-8)
:noquery t ; don't ask to confirm on Emacs exit
:filter #'my/mpv-filter))
Emacs calls the filter function every time data arrives on the socket. Each call gets a chunk of output as a string. mpv sends one JSON object per line, so we split on newlines and parse each one. A response to our command looks like {"data": 142.3, "error": "success", "request_id": 1} — it has a request_id. An unsolicited event looks like {"event": "tick"} — no request_id. The filter only acts on messages with a request_id and silently drops everything else. condition-case nil means a malformed JSON line just gets swallowed rather than crashing the filter.
Piece 3: Sending commands
mpv’s IPC command format is simple JSON: {"command": ["command-name", "arg1", "arg2"]}. We encode that and write it to the socket process:
(defun my/mpv-send (command)
(when (processp mpv--queue)
(process-send-string
mpv--queue
(concat (json-encode `((command . ,command))) "\n"))))
(defun my/mpv-pause ()
(interactive)
(my/mpv-send '["cycle" "pause"])) ; "cycle pause" toggles the paused property
(defun my/mpv-seek-forward ()
(interactive)
(my/mpv-send '["seek" "5"])) ; seek 5 seconds forward
(defun my/mpv-seek-backward ()
(interactive)
(my/mpv-send '["seek" "-5"]))
These are fire-and-forget — we write the JSON command to the socket and don’t wait for a response. mpv handles it asynchronously.
Piece 4: Reading playback time for timestamps
Getting the current playback position is harder because it requires reading back a value — and our network process is asynchronous. Setting up a proper async callback just to insert a timestamp felt like overkill, so instead I shell out to socat to query the socket synchronously:
(defun my/mpv-insert-timestamp ()
(interactive)
(let* ((raw (shell-command-to-string
"echo '{\"command\":[\"get_property\",\"playback-time\"]}' | socat - /tmp/mpv-socket 2>/dev/null"))
(json (ignore-errors (json-read-from-string (string-trim raw))))
(time (when json (alist-get 'data json))))
(when (numberp time)
(let* ((secs (floor time))
(h (/ secs 3600))
(m (/ (mod secs 3600) 60))
(s (mod secs 60))
(ts (if (> h 0)
(format "%d:%02d:%02d" h m s)
(format "%d:%02d" m s))))
(insert (format "** [%s] " ts))))))
socat opens the socket, sends the JSON query, reads back the response, and exits. shell-command-to-string captures stdout synchronously, so by the time json-read-from-string runs, we already have the reply. The response is {"data": 142.3, "error": "success", "request_id": 0}, and (alist-get 'data json) pulls out the seconds as a float. The arithmetic at the end converts that to h:mm:ss or m:ss depending on length.
You’ll need socat installed: brew install socat.
The full my/youtube-notes function that wires all of this together:
Putting it all together
The full keybinding setup:
(define-prefix-command 'my/mpv-map)
(global-set-key (kbd "C-c y") 'my/mpv-map)
(define-key my/mpv-map (kbd "o") #'my/youtube-notes) ; Open URL
(define-key my/mpv-map (kbd "SPC") #'my/mpv-pause) ; Play/pause
(define-key my/mpv-map (kbd "t") #'my/mpv-insert-timestamp) ; Timestamp
(define-key my/mpv-map (kbd "f") #'my/mpv-seek-forward) ; +5s
(define-key my/mpv-map (kbd "b") #'my/mpv-seek-backward) ; -5s
(define-key my/mpv-map (kbd "q") #'mpv-kill) ; Quit
What the workflow looks like
Press C-c y o, paste a YouTube URL, and in a couple of seconds you have:
- mpv playing the video in a separate window
- Emacs split with a fresh org file on the right, pre-filled with the title, date, and URL
As the lecture plays, C-c y t drops in a heading like:
** [14:32]
…with the cursor right after it. Type your note, press C-c y t again when the next thing worth capturing comes up. C-c y SPC to pause when you need to think. C-c y f / C-c y b to skip forward or back.
The notes file ends up looking like:
#+TITLE: Lecture Notes
#+DATE: 2026-03-10
#+URL: https://www.youtube.com/watch?v=...
* Notes
** [4:32] Gradient descent intuition — loss surface analogy
** [12:07] Backpropagation — chain rule applied recursively
** [24:15] TODO: revisit this derivation, didn't fully follow it
Was it worth it?
The spurious buffer debugging was annoying, but the end result is genuinely useful. I stay in Emacs the whole time, the timestamps make it easy to go back to any moment in a lecture, and org-mode means the notes play nicely with everything else I have in there — agenda, TODOs, export to PDF.
The two things I’d still like to improve: the sleep-for 2 at startup is a rough approximation (it’s waiting for the socket to be created) and occasionally fails on slow connections, and the timestamp query via socat feels hacky. But it works well enough that I’ve stopped thinking about it.