use std::path::{Path, PathBuf};
use crate::audiobook::{self, ChapterAudio};
use crate::config::Config;
use crate::error::{Error, Result};
use crate::project::ProjectLayout;
use crate::store::Store;
use crate::store::hierarchy::Hierarchy;
use crate::store::node::{Node, NodeKind};
pub fn run(
project: &Path,
book_name: Option<&str>,
output: Option<&Path>,
title: Option<&str>,
author: Option<&str>,
) -> Result<()> {
if !audiobook::binary_on_path("ffmpeg") {
return Err(Error::Store(
"audiobook: `ffmpeg` not found on PATH. Install it \
(brew install ffmpeg / apt install ffmpeg / \
winget install ffmpeg) — it's required to mux the \
.m4b with chapter markers."
.into(),
));
}
if !audiobook::binary_on_path("ffprobe") {
return Err(Error::Store(
"audiobook: `ffprobe` not found on PATH (ships with \
ffmpeg) — required to measure chapter durations."
.into(),
));
}
let layout = ProjectLayout::new(project);
layout.require_initialized()?;
let cfg = Config::load_layered(&layout.config_path())?;
if !cfg.editor.tts.enabled {
return Err(Error::Store(
"audiobook: TTS is disabled. Set editor.tts.enabled = \
true in inkhaven.hjson + pick a voice (see Tutorial 56)."
.into(),
));
}
let store = Store::open(layout.clone(), &cfg)?;
let h = Hierarchy::load(&store)?;
let book = crate::cli::resolve_user_book(&h, book_name, "audiobook")
.map_err(Error::Store)?
.clone();
let mut engine =
crate::tui::tts::TtsEngine::resolve(&cfg.editor.tts, &layout.root);
engine
.is_ready()
.map_err(|e| Error::Store(format!("audiobook: TTS not ready — {e}")))?;
let voice = engine
.resolve_voice(&cfg.editor.tts.voice)
.unwrap_or_default();
let wpm = ((180.0 * cfg.editor.tts.speed.max(0.1)).round() as i32)
.clamp(80, 400) as u16;
eprintln!(
"audiobook: backend={} voice={}",
engine.label(),
if voice.is_empty() { "engine default" } else { &voice },
);
let chapter_texts = collect_chapter_texts(&store, &h, &book)?;
if chapter_texts.is_empty() {
return Err(Error::Store(format!(
"audiobook: `{}` has no chapters with prose to read",
book.title,
)));
}
let work_dir = std::env::temp_dir().join(format!(
"inkhaven-audiobook-{}",
book.slug,
));
let _ = std::fs::remove_dir_all(&work_dir);
std::fs::create_dir_all(&work_dir).map_err(Error::Io)?;
let total = chapter_texts.len();
let ext = engine.audio_extension();
let mut chapters: Vec<ChapterAudio> = Vec::new();
for (i, (title_text, prose)) in chapter_texts.iter().enumerate() {
eprintln!(" [{}/{}] synthesising `{}`…", i + 1, total, title_text);
let audio_path =
work_dir.join(format!("chapter-{:03}.{ext}", i + 1));
engine
.speak_to_file_blocking(
prose,
&voice,
Some(wpm),
&audio_path,
std::time::Duration::from_secs(1800),
)
.map_err(|e| {
Error::Store(format!(
"audiobook: synthesis failed on `{title_text}` — {e}"
))
})?;
let duration_secs =
audiobook::probe_duration_secs("ffprobe", &audio_path)
.map_err(|e| {
Error::Store(format!("audiobook: ffprobe — {e:#}"))
})?;
chapters.push(ChapterAudio {
title: title_text.clone(),
path: audio_path,
duration_secs,
});
}
let book_title = title
.map(str::to_string)
.unwrap_or_else(|| crate::cli::epub::clean_title(&book.title));
let author_name = author
.map(str::to_string)
.or_else(|| cfg.editor.comment_author.clone())
.unwrap_or_else(|| "Unknown Author".to_string());
let meta_chapters: Vec<(String, f64)> = chapters
.iter()
.map(|c| (c.title.clone(), c.duration_secs))
.collect();
let ffmeta = audiobook::build_ffmetadata(
&book_title,
&author_name,
&meta_chapters,
);
let ffmeta_path = work_dir.join("chapters.ffmeta");
std::fs::write(&ffmeta_path, ffmeta).map_err(Error::Io)?;
let dest = output
.map(PathBuf::from)
.unwrap_or_else(|| layout.root.join(format!("{}.m4b", book.slug)));
let chapter_paths: Vec<PathBuf> =
chapters.iter().map(|c| c.path.clone()).collect();
eprintln!("audiobook: muxing {} chapters → m4b…", chapters.len());
audiobook::mux_m4b("ffmpeg", &chapter_paths, &ffmeta_path, &dest, &work_dir)
.map_err(|e| Error::Store(format!("audiobook: {e:#}")))?;
let _ = std::fs::remove_dir_all(&work_dir);
let total_secs: f64 = chapters.iter().map(|c| c.duration_secs).sum();
let bytes = std::fs::metadata(&dest).map(|m| m.len()).unwrap_or(0);
println!(
"Audiobook: {} ({} chapters · {} · {} MB)",
dest.display(),
chapters.len(),
fmt_duration(total_secs),
bytes / 1_048_576,
);
Ok(())
}
fn fmt_duration(secs: f64) -> String {
let total = secs.round() as u64;
let h = total / 3600;
let m = (total % 3600) / 60;
let s = total % 60;
if h > 0 {
format!("{h}h{m:02}m{s:02}s")
} else {
format!("{m}m{s:02}s")
}
}
fn collect_chapter_texts(
store: &Store,
h: &Hierarchy,
book: &Node,
) -> Result<Vec<(String, String)>> {
let mut out = Vec::new();
for chapter in h.children_of(Some(book.id)) {
if chapter.kind != NodeKind::Chapter {
continue;
}
let mut prose = String::new();
append_branch_prose(store, h, chapter, &mut prose)?;
let prose = prose.trim().to_string();
if !prose.is_empty() {
out.push((crate::cli::epub::clean_title(&chapter.title), prose));
}
}
Ok(out)
}
fn append_branch_prose(
store: &Store,
h: &Hierarchy,
branch: &Node,
prose: &mut String,
) -> Result<()> {
for child in h.children_of(Some(branch.id)) {
match child.kind {
NodeKind::Paragraph => {
let bytes = store
.get_content(child.id)
.map_err(|e| {
Error::Store(format!(
"audiobook: read paragraph {}: {e}",
child.id
))
})?
.unwrap_or_default();
let raw = String::from_utf8_lossy(&bytes);
let plain = audiobook::typst_to_plain(&raw);
if !plain.trim().is_empty() {
if !prose.is_empty() {
prose.push('\n');
}
prose.push_str(&plain);
}
}
NodeKind::Subchapter => {
append_branch_prose(store, h, child, prose)?;
}
_ => {}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fmt_duration_under_an_hour() {
assert_eq!(fmt_duration(125.0), "2m05s");
}
#[test]
fn fmt_duration_over_an_hour() {
assert_eq!(fmt_duration(3725.0), "1h02m05s");
}
#[test]
fn fmt_duration_rounds() {
assert_eq!(fmt_duration(59.6), "1m00s");
}
}