Skip to content

Repository files navigation

Midnight Athenaeum

Midnight Athenaeum

A desktop audiobook player for Windows, built with Electron. Plays local files with real chapter navigation, per-book resume, per-book playback speed, bookmarks, skip-silence, volume normalization, and a sleep timer. A Continue listening shelf, library filters, and series grouping keep the book you're on one click away, even in a library of thousands. Light and dark themes, following the system by default.

Library view, with a Continue listening shelf, filters, and series grouping

(Shown with a synthetic demo library, not real book covers — a virtualized grid renders only what's on screen, so this scales cleanly to thousands of books; see Performance & architecture optimizations for details.)

Icons

The app icon is generated from assets/logo.png — the emblem is cropped out (dropping the wordmark) and written as assets/icon.png plus a multi-size build/icon.ico used for the window and taskbar. Regenerate with npm run icons (tweak the CROP constants in scripts/make-icons.cjs if the framing shifts).

Planned features, differentiators, and performance work are tracked in docs/ROADMAP.md. Released versions and what changed in each are tracked in CHANGELOG.md.

Running it

npm install
npm start

Note: if your shell has ELECTRON_RUN_AS_NODE=1 set (VS Code's integrated terminal does this), electron runs as plain Node and no window appears. Clear it first: $env:ELECTRON_RUN_AS_NODE=$null.

On first launch, click Folders in the top bar to add your audiobook directory. The same panel lists every folder currently in your library — each with a live book count — and lets you remove one, which drops its books from Midnight Athenaeum without touching anything on disk. Removing a folder that holds thousands of books asks for confirmation first, since there's no undo.

You can also just drag a folder onto the window — it shows a drop-zone overlay while you're dragging and adds the folder the same way "Folders → Add folder" would. Dropping something that isn't a folder (e.g. an individual file) is rejected with a toast rather than silently doing nothing.

Installer

The easiest way to install Midnight Athenaeum is to grab the prebuilt installer from Releases — download MidnightAthenaeum-Setup-<version>.exe and run it. No admin rights needed.

It's a per-user NSIS installer — the standard default install location (%LOCALAPPDATA%\Programs\Midnight Athenaeum), no admin rights needed. It adds Start Menu and Desktop shortcuts and registers an entry in Settings → Apps with its own uninstaller (Uninstall Midnight Athenaeum.exe, also reachable from Apps). The "Choose Install Location" page lets you pick a different folder if you'd rather — e.g. this developer's own machine installs everything to D:, a personal convention with no bearing on where it installs for anyone else. Uninstalling removes the installed program files and shortcuts only — it never touches the data folder described below, so your library, progress, and bookmarks survive an uninstall/reinstall.

The installer is unsigned (no code-signing certificate), so Windows SmartScreen will show an "unrecognized publisher" warning on first run — click More info → Run anyway.

Tested: a full cycle using the actual downloaded release asset (not just a local build) — downloaded MidnightAthenaeum-Setup-0.1.0.exe from the published Release, verified its SHA-256 against the digest GitHub recorded for it, installed it silently to the default %LOCALAPPDATA%\Programs\Midnight Athenaeum location, confirmed the installed app loads the real library over IPC exactly like the dev build, then uninstalled and confirmed the install directory, shortcuts, and registry entry were gone while the data folder was untouched.

Is this safe to install?

The SmartScreen warning is purely about the missing code-signing certificate (one costs money on an ongoing basis; this is a free hobby project) — it's not a signal that anything was found wrong with the installer, and there's no Microsoft/AV review behind it either way. Judge it on what you can actually check instead:

  • The source is all here. Every line that ends up in the installer is in this repo — nothing closed-source, no separate "pro" build. src/main/ is the entire main-process/IPC surface; there's no code path that isn't in this tree.
  • The installer is built in public, not on anyone's laptop. Every release is produced by .github/workflows/release.yml, a GitHub Actions job that checks out this exact tagged commit on a clean windows-latest runner and runs npm ci + electron-builder — you can open any release's Actions run and read the full build log. The only credential involved is GitHub's own auto-generated, repo-scoped token, used solely to attach the built files to the release; nothing else is injected into the build.
  • You can verify the file you downloaded matches what GitHub built, rather than trusting the download unverified — this checks it against the SHA-256 digest GitHub itself recorded for that exact asset when it was uploaded (from the same folder as the downloaded installer):
    $version = "<version>"   # e.g. "0.12.0"
    $asset = "MidnightAthenaeum-Setup-$version.exe"
    $expected = (Invoke-RestMethod "https://api.github.com/repos/cubezombies/MidnightAthenaeum/releases/tags/v$version").assets |
      Where-Object name -eq $asset | Select-Object -ExpandProperty digest
    $actual = "sha256:" + (Get-FileHash $asset -Algorithm SHA256).Hash.ToLower()
    $actual -eq $expected
    should print True. Electron's own auto-updater does an equivalent check (SHA-512 this time, from the release's latest.yml) automatically on every in-app update — a corrupted or tampered download is rejected before it's ever installed.
  • No telemetry, no analytics, no background network calls. The only things that ever touch the network, and only when you explicitly trigger them: Check for Updates (manual only, see below), the opt-in online metadata lookup (asks first, shows what it sends), and opt-in Discord Rich Presence. Everything else — scanning, playback, transcription — is entirely local; see Transcription & transcript search for the offline-transcription specifics. ab-media://, the protocol audio and covers are served over, validates every request against your actual library folders rather than disabling Electron's webSecurity — it can't be used to read files outside them (see Layout).
  • Standard GitHub repo protections are also on: secret scanning + push protection, and a ruleset blocking force-pushes/deletion on main, so the published history can't be silently rewritten.

If you'd still rather not run an unsigned .exe, npm start from source (see Running it) skips the installer/SmartScreen question entirely — you're running the exact same code either way.

Checking for updates

Help → Check for Updates… (or the ⬇ button in the top bar) checks GitHub Releases for a newer version. It's entirely manual — nothing is checked automatically on launch or in the background. If a newer version exists it shows what changed (pulled from that release's CHANGELOG.md section) and downloads it in the background; once the download finishes, Restart & Install quits and runs the new installer over the current one (or Later, and it installs automatically the next time you quit normally). A small dot on the ⬇ button marks a downloaded-and-ready update even if you dismissed the dialog. Update checks only work in an installed build — the dev copy (npm start) shows a message explaining that rather than erroring.

Building it yourself

npm install
npm run dist

produces dist\MidnightAthenaeum-Setup-<version>.exe the same way the Release build does. npm run pack builds an unpacked dist\win-unpacked\ folder instead, for a quick smoke test without going through the installer.

Cutting a release

  1. Add a ## [x.y.z] - YYYY-MM-DD section to CHANGELOG.md (move the [Unreleased] items into it) describing what changed — this is what both the GitHub Release page and the in-app update dialog show.
  2. Bump version in package.json to match.
  3. Commit, then git tag vX.Y.Z && git push origin vX.Y.Z.

Pushing the tag runs .github/workflows/release.yml, which builds the installer on windows-latest, publishes it to a GitHub Release via electron-builder's GitHub provider, and sets the release's notes from the CHANGELOG.md section for that version (failing the build if that section is missing, so a release can't accidentally ship without notes). The workflow can also be re-run manually (workflow_dispatch) against an existing tag if a run needs retrying.

How a library is interpreted

Real libraries mix two conventions, so the scanner splits on file type:

Layout Treated as
One .m4b / .m4a file One book; chapters read from inside the file
A folder of .mp3 tracks One book; each track becomes a chapter
Disc 1/, Disc 2/ subfolders Merged into a single book

A folder is only merged as discs when it has several audio subfolders and at least one is disc-named — so a series folder holding separate books per subfolder stays separate.

Multi-track books play as one continuous timeline: the seek bar, chapter list and saved position are all in whole-book seconds, and playback rolls over file boundaries on its own.

Chapters

Chapters come from a hand-written MP4 parser (src/main/mp4-chapters.js) rather than from music-metadata.

music-metadata's includeChapters option does not surface QuickTime text chapter tracks — a second trak with handler text, linked from the audio track by a tref/chap reference — which is how essentially every .m4b in the wild stores chapters. On a real library it returned zero chapters for every file. The parser here reads that track directly (with a fallback to Nero chpl atoms), which is both more robust and roughly 50× faster, since it does targeted reads instead of decoding the whole file. It also recovers chapters from files music-metadata refuses to parse at all.

.cue fallback. When a single-file book has no embedded chapters (a lone .mp3, or an .m4b that omits them) but ships a sibling .cue sheet, the chapter titles and offsets are read from that instead (src/main/cue.js). This is a fallback only — real embedded chapters are never overridden. On the test library it gave real chapters to ~47 otherwise-unnavigable books (some 80–180 chapters long).

Searching a chapter list. Some books have a lot of chapters — Wind and Truth has 212 — so the book view has a search box above the list. It filters by title text or by chapter number (typing 150 jumps straight to chapter 150 without scrolling), updating as you type. Esc clears the search first, then un-focuses the box on a second press, rather than leaving the book view.

Where data lives

A fresh install starts completely empty — no bundled or sample library data — and everything you add lives under the standard per-user app-data folder, %APPDATA%\Midnight Athenaeum\ (C:\Users\<you>\AppData\Roaming\Midnight Athenaeum\). Paths are set in src/main/paths.js and can be redirected with the MIDNIGHT_ATHENAEUM_DATA_ROOT environment variable (this developer's own machine points it at a D: drive; that's a personal preference, not something the installer assumes for anyone else).

%APPDATA%\Midnight Athenaeum\
  userData\               Electron/Chromium profile and caches
  covers\                 extracted cover art
  covers-online\          covers fetched via the online metadata lookup
  library.json            scanned library
  progress.json           per-book listening position, speed
  bookmarks.json          per-book bookmarks
  normalization.json      per-book measured loudness gain
  metadata-overrides.json per-book online metadata corrections

Scanning happens in two phases. The first pass reads just tags and duration — enough to show the grid and start playing — deferring cover art and (for single-file .m4b/.m4a books) chapter extraction to a low-priority background pass afterward, which resumes automatically if interrupted and jumps the queue for whatever book you open first. Results are cached against each file's size and mtime, so rescans only reparse what changed.

Backup and restore

File → Backup data… bundles progress.json, bookmarks.json, normalization.json, and metadata-overrides.json into one timestamped JSON file and lets you choose where to save it — deliberately defaulting to %APPDATA%\Midnight Athenaeum Backups, a sibling of the data folder rather than something inside it, so deleting or corrupting the live data folder can't take the backup down with it too. Cached cover images from the online lookup aren't included (they're just re-fetchable), so a restored override may briefly show no cover until you look it up again.

File → Restore from backup… reads a backup file, shows what it contains (book counts, when it was made) in a confirmation dialog, and — only if you confirm — replaces your current progress, bookmarks, normalization, and online-metadata data with it. This can't be undone, so it's worth being sure before confirming.

To scan without launching the app (useful for a first run):

node prime.mjs "E:\Books"

probe.mjs dumps an MP4's box structure, which is the quickest way to see how a particular file stores its chapters:

node probe.mjs "path\to\book.m4b"

Layout

src/main/
  main.js            app lifecycle, window, IPC
  paths.js           where app data is written
  library.js         scanning, tag reading, cover extraction
  group.js           files -> books (disc merging, m4b vs mp3-folder)
  mp4-chapters.js    MP4/M4B chapter + duration parser
  media-protocol.js  ab-media:// with byte-range support
  metadata-lookup.js Open Library search/description/cover fetch
  updater.js         electron-updater wiring
  taskbar.js         thumbbar buttons + jump list
  store.js           atomic JSON persistence
  preload.js         contextBridge API
src/renderer/        UI (no framework)

Audio is served over a custom ab-media:// protocol with HTTP range support rather than by disabling webSecurity, so seeking inside a 40-hour file doesn't re-read from the start. The handler validates every request against the folders actually in your library, so it can't be used to read arbitrary files.

Known limitations

  • Series-tagged titles. Some publishers set every volume's album tag to the series name. For single-file books the more specific title tag is used instead, but a multi-track book (a folder of mp3s) has only the series name to show, so volumes of the same series can render with an identical title even though they are correctly kept as separate books. The folder names distinguish them on disk.
  • Box sets stay whole. A single file (or folder) containing several novels is one entry with one long chapter list; there is no reliable signal to split it into constituent books.
  • Merged m4b parts use one chapter per file. When a book shipped as numbered .m4b segments is merged, each segment becomes one chapter; any chapters embedded inside a segment are not surfaced.

Online metadata lookup

Scanned tags are sometimes wrong or incomplete — a garbage ripper tag as the author, a missing description, no embedded cover. The book view's Look up online button searches Open Library for the real title, author, description, and a higher-resolution cover, and lets you apply one as a correction on top of the scanned data.

It's opt-in and off by default — the first click shows what it does and what it sends before turning it on, and nothing is looked up automatically at any other time (no lookups on scan, on library load, or in the background). Everything fetched is cached locally in metadata-overrides.json and covers-online\, so once applied a book displays correctly offline. Revert to file tags on a corrected book removes the override (and its cached cover) and goes back to what was scanned from the file.

Source is Open Library only. Google Books' keyless tier returned a quota error on the very first request in testing, before shipping a single real query, which would have meant asking every user to obtain their own API key just to use the feature — rejected as too much friction for an opt-in convenience. Audible has no public API, and scraping it would be a ToS risk this app isn't taking on.

Series-splitting is out of scope. The original ask included using this lookup to split box sets / series-tagged titles apart. Open Library's series data turned out to be too sparse and inconsistent across this library's real books to build a reliable feature on — it's a metadata-correction tool (title/author/description/cover), not a re-grouping one. The existing series detection (parsed from the title/author already in Midnight Athenaeum) and the known series-tagged-title limitation above are unaffected by this feature.

Transcription & transcript search

Transcribe this book (book view) runs a book through whisper.cpp entirely offline — no audio or text ever leaves your machine. Each track is converted to 16kHz mono PCM with a bundled ffmpeg, then transcribed; GPU acceleration (Vulkan) is used automatically when available, falling back to CPU otherwise. The first transcription downloads a ~148MB English speech model once, into the data folder, then reuses it for every book after that.

Once transcribed, Search transcript finds every line matching a phrase and jumps straight to that moment — "find where they first mention the sword" — and a captions toggle shows the current line live while that book plays. Only one book transcribes at a time; a long audiobook can take anywhere from a few minutes (GPU) to a couple of hours (CPU-only), so it's opt-in per book rather than a whole-library job.

It's off by default in the sense that nothing transcribes until you ask — there's no separate opt-in gate beyond clicking the button, since (unlike the online metadata lookup) nothing here ever leaves the machine. Delete transcript removes a book's saved transcript if you want the disk space back or want to re-run it later.

Per-chapter summaries are out of scope for now — Whisper transcribes, it doesn't summarize, and doing that well would need its own model/approach decision rather than an extension of this feature.

Finding duplicate books

File → Find duplicate books… scans your already-scanned library for the same book filed in more than one place — no new file access, just grouping data that's already there. Books are grouped by title and author, then split into distinct recordings by matching audio length and track count: a recording with two or more copies is a real duplicate; a same-title recording with only one copy — a different narrator, say — is shown for context but never offered for removal. Both cases turn up in real libraries side by side (three different narrators of the same book is not the same thing as the same file copied into two category folders), so telling them apart correctly is the point of the feature, not an edge case.

Removing a copy sends its file(s) to the Recycle Bin, never a permanent delete, and only ever the files that book actually owns — never the whole containing folder, since a folder can hold other, unrelated books alongside it. The library updates immediately; no rescan needed.

Reorganizing your library by author

File → Reorganize library by author… moves every book on disk into <library folder>/<Author>/<Title>/. Nothing moves until you review the full plan and explicitly confirm — the preview shows exactly what would go where, plus how many books are already correctly filed or skipped (books outside any configured library folder).

A folder that's shared by more than one unrelated book — a real case in this library: several different audio dramas filed side by side in one folder — only has that specific book's own files moved out; the shared folder itself is never renamed out from under its other occupants.

Every individual move is journaled as it happens, so File → Undo last reorganization… can put everything back exactly where it was, including after an interruption or a cancelled run. Listening progress, bookmarks, loudness normalization, metadata overrides, and transcripts all move with their book, even though moving a book changes its internal id.

This is the highest-risk feature in the app, since a bug moves real files rather than just app data — consider File → Backup data… first if you'd like an extra safety net beyond the built-in undo.

Read along

The Read along button in the book view pairs an EPUB with the audiobook you're viewing and shows its current chapter's text in a side panel while you listen. Pairing is automatic when it can be (matched by folder proximity — same folder, or an epub/E-Books-style subfolder next to it), falling back to a native file picker when it can't confidently guess; a background pass gradually checks the whole library this way, powering a Has ebook library filter and a small book-icon badge on matching cards.

The panel auto-advances to the ebook chapter matching what's playing when it's confident — the audio and ebook have the same chapter count (direct mapping), or a weaker proportional-position signal when they don't — and never guesses when neither signal is strong; it just stays wherever you last navigated it, with manual chapter controls always available regardless. Within a chapter, the paragraph nearest your position gets a soft highlight, estimated from how far through the audio chapter you are — a genuinely useful approximation, not real synced highlighting (that would need the Whisper transcript aligned to text, a bigger future project).

Scoped to EPUB only for now — MOBI is a proprietary format Amazon itself deprecated in 2021, and PDF has no reliable chapter structure, so both would take considerably more work for considerably less reliable results.

Keyboard

Key Action
Space Play / pause
/ Back / forward (skip amount, default 30s — set via the Skip dropdown)
Shift + / Back / forward 5 min
B Add a bookmark at the current spot
S Toggle skip-silence
N Toggle volume normalization
V Toggle Voice Boost EQ
T Open the sleep-timer menu
Esc Close the sleep menu, or go back to the library

Windows media integration

Midnight Athenaeum shows up in the Windows media flyout (the popup on the volume overlay / Win+G-adjacent media panel) and the lock screen, with the current book's title, author, and cover, and the subtitle updates live to the current chapter as you listen. Hardware and keyboard media keys (play/pause, previous/next track) control playback the same way the in-app buttons do — "previous/next track" jumps a chapter, matching the ⏮/⏭ buttons. This is the standard Media Session web API, which Chromium wires up to Windows' System Media Transport Controls on its own — no extra setup needed.

The taskbar button also gets its own thumbnail-toolbar (hover over the taskbar icon) with previous chapter / play-pause / next chapter buttons, and right-clicking the taskbar icon (or the Start tile, if pinned) shows a Continue Listening jump list of your most recently-played books — click one to jump straight to it, launching Midnight Athenaeum if it isn't already running. The jump list depends on Windows' own "Show recently opened items in Start, Jump Lists, and File Explorer" setting (Settings → Personalization → Start); if that's off, the list just won't appear — nothing to configure on Midnight Athenaeum's side.

Finding your place

The library opens with a Continue listening shelf — the books you're partway through, most-recently-played first, one click from resuming. Filter tabs (All / In progress / Finished / Not started) narrow the grid below. Each book remembers its own playback speed, and resuming after a pause rewinds a few seconds (more the longer you were away) so you don't lose the thread.

The Skip dropdown in the player sets how far the ↺/↻ buttons and the plain / keys jump — 10/15/30/45/60s, 30 by default. The buttons' labels update to match, and it's remembered across restarts. Shift+/ is a separate, fixed 5-minute jump either way.

Finished status is normally automatic — a book counts as finished once you're within 30 seconds of the end — but the book view has a Mark as finished / Mark as not finished button for the two cases that can't detect on their own: a book you finished elsewhere (no listening position recorded here at all), or one you gave up on that's sitting at 99% and cluttering "In progress." The button always wins over the automatic guess until you toggle it again or use Reset progress, which clears the override along with everything else.

Series grouping

The Group series toggle collapses the volumes of a series into a single tile (e.g. all 15 Spellmonger books become one), so a large library reads as series rather than a wall of covers. Click a tile to open the series and see its volumes in reading order; opening a volume and pressing Back returns you to the series.

Series are detected from the book title and author — "Warmage: Spellmonger, Book 2"Spellmonger #2 — entirely in the app, with no re-scan. Grouping only forms a tile when two or more books share a series name and author, which keeps multi-author franchises (e.g. everything with "Star Wars" in the title) from collapsing into one meaningless pile. Books whose titles don't spell out a series (standalones, un-numbered novellas) stay as normal cards. On the real test library this groups ~360 series covering ~1,500 books; the rest show individually.

"NEW" badge

Books added since you last opened Midnight Athenaeum get a small NEW pill on their cover, Spotify/Netflix-style — useful for a library that keeps growing. It clears the next time you open the app, not the moment you glance at it, so it doesn't disappear before you've actually had a chance to notice it. A brand new install doesn't retroactively badge your whole library. With Group series on, a series tile shows NEW too if any volume inside it is new — otherwise a newly added volume of a series you already own would be invisible behind the tile.

Bookmarks

Press B or the 🔖 button in the player to drop a bookmark at the current spot; its label defaults to the current chapter. Bookmarks appear in the book's detail view, where you can rename them, add a note, jump back with one click, or delete them. Every manual pause also drops a single rolling "Last stop" marker so you can always find where you set the book down — editing it makes it permanent. Bookmarks are stored in bookmarks.json in the data folder.

Deleting one doesn't ask first — it shows a brief Undo toast instead, since a delete you didn't mean is a one-click fix rather than something worth interrupting you to confirm. The same applies to Reset progress in the book view. Undo restores the exact bookmark or the exact position/speed you had, not an approximation.

Skip silence

The ⏩ button (or S) shortens the dead air in a book. The audio is routed through a Web Audio AnalyserNode; when it detects a sustained quiet gap (≈0.7s, so a narrator pausing to breathe mid-sentence is left alone — a shorter threshold was audibly clipping into those) it briefly raises the playback rate so the gap passes quickly, then snaps back to your speed the instant speech returns — no hard cut. It stacks on your per-book speed (capped so playback stays intelligible) and keeps working while the app is in the background. On a long book with lots of pauses this reclaims a meaningful chunk of time.

Theme

Follows the OS light/dark preference until you choose otherwise. The ☀/☾ button in the top bar toggles between light and dark; once you click it, that choice is remembered (localStorage) and applied before the window paints, so there's no flash of the wrong theme on launch. To go back to following the system, clear it: localStorage.removeItem('theme') in DevTools, or delete Local Storage under the app's data folder.

Volume normalization

Books ripped from different sources sit at wildly different levels (~7 dB spread across the test library), so switching books used to mean reaching for the volume slider. The ⚖ button (on by default; N toggles) evens this out: the first time a book plays, its gated loudness is measured over ~30 seconds and a gain is computed to bring it to a common level, stored in normalization.json and applied instantly on every later play. The gain is clamped and peak-limited so boosting a quiet book never clips.

The audio runs through a small Web Audio graph (source → analyser → normalize gain → voice boost EQ → volume gain → output). Putting the analyser first means loudness is measured at full scale regardless of your volume setting; putting volume in the graph (rather than on the element) means the measurement — and skip-silence — are unaffected by where you set the slider.

Voice Boost

A speech-tuned EQ for fast listening, where deep-voiced narration turns muddy at 2.5–3×. The 🎚 button (off by default; V toggles) runs two BiquadFilterNodes: a highpass around 100Hz clears out low rumble that eats headroom without carrying intelligibility, and a peaking filter lifts the ~2.8kHz consonant/sibilance range that actually separates words at speed. Both ramp in and out smoothly rather than snapping, and — like skip-silence and normalization — live in the shared Web Audio graph rather than the <audio> element itself, so it stacks cleanly with both.

Sleep timer

The moon control in the player bar stops playback after a set time — a fixed duration (5–60 min), at the end of the current chapter, or at the end of the book. The volume fades gently over the last 20 seconds rather than cutting out. If you fall asleep, resuming rewinds 30 seconds so you don't lose your place, and +5 minutes extends (or restarts) the timer and picks playback back up. The duration countdown only runs while audio is playing, so pausing pauses it too.

About

A desktop audiobook player for Windows, built with Electron.

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Used by

Contributors

Languages