web: chapter audio player + render button
Chapter view now shows a narration card between title and prose
with three states:
- succeeded → HTML5 <audio> + voice + duration + download link
- running → 'rendering…' banner with relative start time
- none/failed → 'Render audio' POST button (spawns background
tokio task calling narrate::run)
ServeDir mounted at /audio serves WAVs from the f5-tts bind-mount
read-only. Range requests work, so 16-min chapters seek cleanly.
Deploy needs: compose mount /mnt/cache/appdata/f5-tts/audio:/audio:ro
on skald (already staged in /mnt/cache/appdata/skald/compose.yml on
Lucy).
This commit is contained in:
parent
c2bb12fdd0
commit
75a609d507
3 changed files with 217 additions and 6 deletions
15
Cargo.lock
generated
15
Cargo.lock
generated
|
|
@ -803,6 +803,12 @@ dependencies = [
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "http-range-header"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "httparse"
|
name = "httparse"
|
||||||
version = "1.10.1"
|
version = "1.10.1"
|
||||||
|
|
@ -1909,6 +1915,7 @@ dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"clawdforge",
|
"clawdforge",
|
||||||
"regex",
|
"regex",
|
||||||
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
|
|
@ -2393,11 +2400,19 @@ checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
|
"http-range-header",
|
||||||
|
"httpdate",
|
||||||
|
"mime",
|
||||||
|
"mime_guess",
|
||||||
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ repository = "http://192.168.0.5:3001/cobb/skald"
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
axum = "0.8"
|
axum = "0.8"
|
||||||
tower = "0.5"
|
tower = "0.5"
|
||||||
tower-http = { version = "0.6", features = ["trace", "limit"] }
|
tower-http = { version = "0.6", features = ["trace", "limit", "fs"] }
|
||||||
sqlx = { version = "0.8", default-features = false, features = [
|
sqlx = { version = "0.8", default-features = false, features = [
|
||||||
"postgres", "runtime-tokio", "tls-rustls",
|
"postgres", "runtime-tokio", "tls-rustls",
|
||||||
"chrono", "uuid", "macros", "migrate",
|
"chrono", "uuid", "macros", "migrate",
|
||||||
|
|
|
||||||
206
skald/src/web.rs
206
skald/src/web.rs
|
|
@ -14,12 +14,13 @@ use axum::Router;
|
||||||
use axum::extract::{Path, State};
|
use axum::extract::{Path, State};
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use axum::response::{Html, Redirect};
|
use axum::response::{Html, Redirect};
|
||||||
use axum::routing::get;
|
use axum::routing::{get, post};
|
||||||
use axum::Form;
|
use axum::Form;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use maud::{DOCTYPE, Markup, html};
|
use maud::{DOCTYPE, Markup, html};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
use tower_http::services::ServeDir;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
|
|
@ -28,13 +29,24 @@ pub struct WebState {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn router(state: WebState) -> Router {
|
pub fn router(state: WebState) -> Router {
|
||||||
|
// Audio dir is the f5-tts bind mount, exposed read-only inside
|
||||||
|
// skald via compose. ServeDir handles Range requests + correct
|
||||||
|
// MIME for .wav — important for HTML5 <audio> seeking on 16-min
|
||||||
|
// chapter renders.
|
||||||
|
let audio_dir = std::env::var("SKALD_AUDIO_DIR").unwrap_or_else(|_| "/audio".into());
|
||||||
|
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/", get(index))
|
.route("/", get(index))
|
||||||
.route("/stories/new", get(new_story_form).post(new_story_create))
|
.route("/stories/new", get(new_story_form).post(new_story_create))
|
||||||
.route("/stories/{id}", get(story_detail))
|
.route("/stories/{id}", get(story_detail))
|
||||||
.route("/stories/{id}/continue", get(continue_form).post(continue_create))
|
.route("/stories/{id}/continue", get(continue_form).post(continue_create))
|
||||||
.route("/stories/{id}/chapters/{n}", get(chapter_view))
|
.route("/stories/{id}/chapters/{n}", get(chapter_view))
|
||||||
|
.route(
|
||||||
|
"/stories/{id}/chapters/{n}/narrate",
|
||||||
|
post(chapter_narrate_fire),
|
||||||
|
)
|
||||||
.route("/stories/{id}/runs", get(runs_view))
|
.route("/stories/{id}/runs", get(runs_view))
|
||||||
|
.nest_service("/audio", ServeDir::new(audio_dir))
|
||||||
.with_state(Arc::new(state))
|
.with_state(Arc::new(state))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -400,9 +412,9 @@ async fn chapter_view(
|
||||||
) -> Result<Html<String>, StatusCode> {
|
) -> Result<Html<String>, StatusCode> {
|
||||||
let stories = fetch_stories(&state.pool).await;
|
let stories = fetch_stories(&state.pool).await;
|
||||||
|
|
||||||
let row: Option<(Option<String>, String, i32, Option<String>)> = sqlx::query_as(
|
let row: Option<(Uuid, Option<String>, String, i32, Option<String>)> = sqlx::query_as(
|
||||||
r#"
|
r#"
|
||||||
SELECT c.title, c.body_md, c.word_count, cs.body
|
SELECT c.id, c.title, c.body_md, c.word_count, cs.body
|
||||||
FROM chapters c
|
FROM chapters c
|
||||||
LEFT JOIN chapter_summaries cs ON cs.chapter_id = c.id
|
LEFT JOIN chapter_summaries cs ON cs.chapter_id = c.id
|
||||||
WHERE c.story_id = $1 AND c.n = $2
|
WHERE c.story_id = $1 AND c.n = $2
|
||||||
|
|
@ -415,14 +427,92 @@ async fn chapter_view(
|
||||||
.ok()
|
.ok()
|
||||||
.flatten();
|
.flatten();
|
||||||
|
|
||||||
let Some((title, body_md, word_count, summary)) = row else {
|
let Some((chapter_id, title, body_md, word_count, summary)) = row else {
|
||||||
return Err(StatusCode::NOT_FOUND);
|
return Err(StatusCode::NOT_FOUND);
|
||||||
};
|
};
|
||||||
|
|
||||||
let panel = chapter_panel(id, n, title.as_deref(), &body_md, word_count, summary.as_deref());
|
// Most-recent narration_run for the chapter — drives whether we
|
||||||
|
// show the audio player, the "rendering..." state, or the
|
||||||
|
// "Render audio" form.
|
||||||
|
let narration: Option<NarrationCard> = sqlx::query_as::<_, (String, Option<String>, Option<f32>, Option<String>, DateTime<Utc>, Option<DateTime<Utc>>, Option<String>)>(
|
||||||
|
r#"
|
||||||
|
SELECT nr.status, nr.output_path, nr.duration_seconds, v.display_name,
|
||||||
|
nr.started_at, nr.ended_at, nr.error
|
||||||
|
FROM narration_runs nr
|
||||||
|
JOIN voices v ON v.id = nr.voice_id
|
||||||
|
WHERE nr.chapter_id = $1
|
||||||
|
ORDER BY nr.started_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
.bind(chapter_id)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.map(|(status, output_path, duration_seconds, voice_display, started_at, ended_at, error)| {
|
||||||
|
NarrationCard {
|
||||||
|
status,
|
||||||
|
output_path,
|
||||||
|
duration_seconds,
|
||||||
|
voice_display,
|
||||||
|
started_at,
|
||||||
|
ended_at,
|
||||||
|
error,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let panel = chapter_panel(
|
||||||
|
id,
|
||||||
|
chapter_id,
|
||||||
|
n,
|
||||||
|
title.as_deref(),
|
||||||
|
&body_md,
|
||||||
|
word_count,
|
||||||
|
summary.as_deref(),
|
||||||
|
narration.as_ref(),
|
||||||
|
);
|
||||||
Ok(Html(render_shell(&stories, Some(id), panel).into_string()))
|
Ok(Html(render_shell(&stories, Some(id), panel).into_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct NarrationCard {
|
||||||
|
status: String,
|
||||||
|
output_path: Option<String>,
|
||||||
|
duration_seconds: Option<f32>,
|
||||||
|
voice_display: Option<String>,
|
||||||
|
started_at: DateTime<Utc>,
|
||||||
|
ended_at: Option<DateTime<Utc>>,
|
||||||
|
error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn chapter_narrate_fire(
|
||||||
|
State(state): State<Arc<WebState>>,
|
||||||
|
Path((id, n)): Path<(Uuid, i32)>,
|
||||||
|
) -> Result<Redirect, (StatusCode, String)> {
|
||||||
|
let chapter_id: Option<Uuid> =
|
||||||
|
sqlx::query_scalar("SELECT id FROM chapters WHERE story_id = $1 AND n = $2")
|
||||||
|
.bind(id)
|
||||||
|
.bind(n)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
let chapter_id =
|
||||||
|
chapter_id.ok_or((StatusCode::NOT_FOUND, "chapter not found".into()))?;
|
||||||
|
|
||||||
|
let database_url = std::env::var("DATABASE_URL")
|
||||||
|
.unwrap_or_else(|_| "postgresql://skald:skald@localhost:5432/skald".into());
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = crate::narrate::run(&database_url, chapter_id, None, 1.0).await {
|
||||||
|
tracing::error!(chapter_id = %chapter_id, error = %e, "background narrate failed");
|
||||||
|
} else {
|
||||||
|
tracing::info!(chapter_id = %chapter_id, "background narrate succeeded");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Redirect::to(&format!("/stories/{id}/chapters/{n}")))
|
||||||
|
}
|
||||||
|
|
||||||
async fn runs_view(
|
async fn runs_view(
|
||||||
State(state): State<Arc<WebState>>,
|
State(state): State<Arc<WebState>>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
|
|
@ -714,11 +804,13 @@ fn story_panel(
|
||||||
|
|
||||||
fn chapter_panel(
|
fn chapter_panel(
|
||||||
story_id: Uuid,
|
story_id: Uuid,
|
||||||
|
chapter_id: Uuid,
|
||||||
n: i32,
|
n: i32,
|
||||||
title: Option<&str>,
|
title: Option<&str>,
|
||||||
body_md: &str,
|
body_md: &str,
|
||||||
word_count: i32,
|
word_count: i32,
|
||||||
summary: Option<&str>,
|
summary: Option<&str>,
|
||||||
|
narration: Option<&NarrationCard>,
|
||||||
) -> Markup {
|
) -> Markup {
|
||||||
let display_title = title.map(|t| strip_chapter_prefix(t, n).to_string());
|
let display_title = title.map(|t| strip_chapter_prefix(t, n).to_string());
|
||||||
html! {
|
html! {
|
||||||
|
|
@ -733,6 +825,7 @@ fn chapter_panel(
|
||||||
div.metabar {
|
div.metabar {
|
||||||
span.meta-item { (kfmt(word_count)) " words" }
|
span.meta-item { (kfmt(word_count)) " words" }
|
||||||
}
|
}
|
||||||
|
(narration_card(story_id, chapter_id, n, narration))
|
||||||
@if let Some(s) = summary {
|
@if let Some(s) = summary {
|
||||||
aside.summary-box {
|
aside.summary-box {
|
||||||
h3 { "Summary" }
|
h3 { "Summary" }
|
||||||
|
|
@ -759,6 +852,91 @@ fn chapter_panel(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Narration card — the strip that lives between chapter title and
|
||||||
|
/// prose. Three shapes:
|
||||||
|
/// - succeeded with audio: <audio> player + voice + duration
|
||||||
|
/// - running: pending banner + auto-refresh meta-tag is not used;
|
||||||
|
/// user reloads when they want fresh state
|
||||||
|
/// - none / failed: "Render audio" button (POST to /narrate)
|
||||||
|
fn narration_card(
|
||||||
|
story_id: Uuid,
|
||||||
|
_chapter_id: Uuid,
|
||||||
|
n: i32,
|
||||||
|
narration: Option<&NarrationCard>,
|
||||||
|
) -> Markup {
|
||||||
|
let post_url = format!("/stories/{}/chapters/{}/narrate", story_id, n);
|
||||||
|
html! {
|
||||||
|
aside.narration {
|
||||||
|
@match narration {
|
||||||
|
Some(card) if card.status == "succeeded" && card.output_path.is_some() => {
|
||||||
|
@let path = card.output_path.as_ref().unwrap();
|
||||||
|
@let basename = path.rsplit('/').next().unwrap_or(path);
|
||||||
|
@let audio_url = format!("/audio/{}", basename);
|
||||||
|
h3 { "Audio" }
|
||||||
|
audio controls preload="metadata" src=(audio_url) {}
|
||||||
|
p.muted {
|
||||||
|
@if let Some(d) = card.duration_seconds {
|
||||||
|
(fmt_duration(d as f64))
|
||||||
|
}
|
||||||
|
@if let Some(v) = &card.voice_display {
|
||||||
|
" · " (v)
|
||||||
|
}
|
||||||
|
" · "
|
||||||
|
a href=(audio_url) download=(basename) { "download" }
|
||||||
|
}
|
||||||
|
form method="post" action=(post_url) {
|
||||||
|
button type="submit" .ghost { "Re-render" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(card) if card.status == "running" => {
|
||||||
|
h3 { "Audio — rendering…" }
|
||||||
|
p.muted {
|
||||||
|
"Started " (relative_time(card.started_at)) " ago. "
|
||||||
|
"Reload the page to check progress."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(card) if card.status == "failed" => {
|
||||||
|
h3.warn { "Audio render failed" }
|
||||||
|
@if let Some(e) = &card.error {
|
||||||
|
pre.error-pre { (e) }
|
||||||
|
}
|
||||||
|
form method="post" action=(post_url) {
|
||||||
|
button type="submit" { "Retry render" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
h3 { "Audio" }
|
||||||
|
p.muted {
|
||||||
|
"Not rendered yet. F5-TTS with the system default voice. "
|
||||||
|
"Expect ~6–8 min wall clock for a 3,000-word chapter on the GPU."
|
||||||
|
}
|
||||||
|
form method="post" action=(post_url) {
|
||||||
|
button type="submit" { "Render audio" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fmt_duration(s: f64) -> String {
|
||||||
|
let total = s as i64;
|
||||||
|
let mins = total / 60;
|
||||||
|
let secs = total % 60;
|
||||||
|
format!("{mins}m {secs:02}s")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn relative_time(t: DateTime<Utc>) -> String {
|
||||||
|
let elapsed = (Utc::now() - t).num_seconds().max(0);
|
||||||
|
if elapsed < 60 {
|
||||||
|
format!("{elapsed}s")
|
||||||
|
} else if elapsed < 3600 {
|
||||||
|
format!("{}m", elapsed / 60)
|
||||||
|
} else {
|
||||||
|
format!("{}h", elapsed / 3600)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn runs_panel(story_id: Uuid, runs: &[(Uuid, String, String, DateTime<Utc>, Option<DateTime<Utc>>, Option<String>)]) -> Markup {
|
fn runs_panel(story_id: Uuid, runs: &[(Uuid, String, String, DateTime<Utc>, Option<DateTime<Utc>>, Option<String>)]) -> Markup {
|
||||||
html! {
|
html! {
|
||||||
article.runs {
|
article.runs {
|
||||||
|
|
@ -1099,6 +1277,24 @@ code { font-family: var(--mono); font-size: 0.9em; background: var(--surface-2);
|
||||||
margin: 0 0 10px 0; font-weight: 700;
|
margin: 0 0 10px 0; font-weight: 700;
|
||||||
}
|
}
|
||||||
.summary-box p { margin: 0; color: var(--ink-muted); font-size: 14px; line-height: 1.7; font-family: var(--serif); }
|
.summary-box p { margin: 0; color: var(--ink-muted); font-size: 14px; line-height: 1.7; font-family: var(--serif); }
|
||||||
|
.narration {
|
||||||
|
background: var(--surface); border-left: 2px solid var(--accent);
|
||||||
|
padding: 16px 22px; margin: 24px 0; max-width: 75ch;
|
||||||
|
}
|
||||||
|
.narration h3 {
|
||||||
|
font-family: var(--display); font-size: 11px;
|
||||||
|
text-transform: uppercase; letter-spacing: 2.5px; color: var(--accent);
|
||||||
|
margin: 0 0 10px 0; font-weight: 700;
|
||||||
|
}
|
||||||
|
.narration h3.warn { color: var(--accent); }
|
||||||
|
.narration audio { width: 100%; margin: 4px 0 8px 0; }
|
||||||
|
.narration p.muted { margin: 0 0 10px 0; color: var(--ink-muted); font-size: 13px; font-family: var(--sans, var(--serif)); }
|
||||||
|
.narration p.muted a { color: var(--bronze); }
|
||||||
|
.narration form { margin: 0; }
|
||||||
|
.narration button { background: transparent; border: 1px solid var(--bronze); color: var(--bronze); padding: 6px 14px; font-family: var(--display); font-size: 11px; letter-spacing: 2px; text-transform: uppercase; cursor: pointer; }
|
||||||
|
.narration button:hover { background: var(--bronze); color: var(--bg); }
|
||||||
|
.narration button.ghost { border-color: var(--surface-2); color: var(--ink-muted); }
|
||||||
|
.narration .error-pre { background: var(--surface-2); padding: 8px 12px; font-size: 12px; overflow-x: auto; }
|
||||||
.prose {
|
.prose {
|
||||||
font-family: var(--serif); font-size: 18px; line-height: 1.8;
|
font-family: var(--serif); font-size: 18px; line-height: 1.8;
|
||||||
max-width: 68ch; color: var(--ink);
|
max-width: 68ch; color: var(--ink);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue