app
Adom TTS — Text to Speech
Public Made by Adomby adom
Adom's shared voice service. Reads your AI's answers back to you while you drive, and renders perfectly-timed voiceover tracks for your demo videos — so you never record your own voice.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162
//! History store for the TTS playground webview.
//!
//! One directory per user under `~/.adom/tts/history/`. Each clip is two
//! files: `<id>.mp3` and `<id>.json` (the metadata sidecar). The webview
//! UI scans this dir every few seconds so clips dropped in externally
//! (via `adom-tts push` or `adom-tts say --history`) show up without
//! restarting the server.
//!
//! ID format: `YYYYMMDD-HHMMSS-<sha8>`. Sortable by timestamp, unique
//! on collision via the first 8 hex chars of the audio hash.
use anyhow::{Context, Result};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Clip {
pub id: String,
pub text: String,
pub voice: String,
pub rate: String,
pub created_at: String,
pub size_bytes: u64,
pub source: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tts_hash: Option<String>,
}
pub fn history_dir() -> Result<PathBuf> {
let home = std::env::var("HOME").context("no $HOME")?;
let dir = PathBuf::from(home).join(".adom").join("tts").join("history");
std::fs::create_dir_all(&dir)?;
Ok(dir)
}
pub fn save(
audio: &[u8],
text: &str,
voice: &str,
rate: &str,
source: &str,
tts_hash: Option<String>,
) -> Result<Clip> {
let dir = history_dir()?;
let mut hasher = Sha256::new();
hasher.update(audio);
let hash = hex8(&hasher.finalize());
let id = format!("{}-{}", Utc::now().format("%Y%m%d-%H%M%S"), hash);
let mp3 = dir.join(format!("{id}.mp3"));
let meta = dir.join(format!("{id}.json"));
std::fs::write(&mp3, audio)?;
let clip = Clip {
id: id.clone(),
text: text.to_string(),
voice: voice.to_string(),
rate: rate.to_string(),
created_at: Utc::now().to_rfc3339(),
size_bytes: audio.len() as u64,
source: source.to_string(),
tts_hash,
};
std::fs::write(&meta, serde_json::to_vec_pretty(&clip)?)?;
Ok(clip)
}
pub fn list() -> Result<Vec<Clip>> {
let dir = history_dir()?;
let mut clips = Vec::new();
for entry in std::fs::read_dir(&dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("json") {
continue;
}
let bytes = match std::fs::read(&path) {
Ok(b) => b,
Err(_) => continue,
};
let clip: Clip = match serde_json::from_slice(&bytes) {
Ok(c) => c,
Err(_) => continue,
};
// Skip orphans (json with no matching mp3).
if !dir.join(format!("{}.mp3", clip.id)).exists() {
continue;
}
clips.push(clip);
}
clips.sort_by(|a, b| b.created_at.cmp(&a.created_at));
Ok(clips)
}
/// Newest mp3 in the history dir (by mtime). Used by `adom-tts play last`.
pub fn latest_mp3() -> Result<Option<PathBuf>> {
let dir = history_dir()?;
let mut best: Option<(std::time::SystemTime, PathBuf)> = None;
for entry in std::fs::read_dir(&dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("mp3") {
continue;
}
let meta = match entry.metadata() { Ok(m) => m, Err(_) => continue };
let mtime = meta.modified().unwrap_or(std::time::UNIX_EPOCH);
match &best {
Some((t, _)) if *t >= mtime => {}
_ => best = Some((mtime, path)),
}
}
Ok(best.map(|(_, p)| p))
}
pub fn audio_path(id: &str) -> Result<PathBuf> {
let dir = history_dir()?;
let p = dir.join(format!("{id}.mp3"));
if !p.exists() {
anyhow::bail!("clip not found: {id}");
}
// Reject path traversal — id must not escape history dir.
let canon = p.canonicalize()?;
if !canon.starts_with(dir.canonicalize()?) {
anyhow::bail!("clip id out of history dir");
}
Ok(p)
}
pub fn delete(id: &str) -> Result<()> {
let dir = history_dir()?;
let mp3 = dir.join(format!("{id}.mp3"));
let meta = dir.join(format!("{id}.json"));
// Path-traversal guard (same as audio_path).
if let Ok(c) = mp3.canonicalize() {
if !c.starts_with(dir.canonicalize()?) {
anyhow::bail!("clip id out of history dir");
}
}
let _ = std::fs::remove_file(&mp3);
let _ = std::fs::remove_file(&meta);
Ok(())
}
/// Adopt an arbitrary .mp3 from disk into the history store. Used by
/// `adom-tts push <path>` so clips synthesized by other tools show up
/// in the playground.
pub fn push_file(src: &Path, text: &str, voice: &str, rate: &str) -> Result<Clip> {
let audio = std::fs::read(src).with_context(|| format!("read {}", src.display()))?;
save(&audio, text, voice, rate, "external", None)
}
fn hex8(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut s = String::with_capacity(8);
for b in bytes.iter().take(4) {
s.push(HEX[(b >> 4) as usize] as char);
s.push(HEX[(b & 0xf) as usize] as char);
}
s
}