filmcraft/crates/engine/src/narration.rs

534 lines
25 KiB
Rust

//! Text to Speech (narrations): write what the narrator says, pick a voice, and get an audio clip.
//!
//! | command | does |
//! |---|---|
//! | `tts.voices` | languages, voices, vocal pitches, pace limits and the pause marker |
//! | `tts.preview` | synthesize without changing the project (`sample: true` speaks the voice's sample sentence); the host plays [`Session::tts_preview`]; `path` also writes a WAV |
//! | `tts.create` | synthesize, write `Narration <n>.wav`, import it and place it at the playhead (one undo step) |
//! | `tts.edit` | change a narration clip's script or voice settings and regenerate it in place (one undo step) |
//! | `tts.inspect` | the narration settings of a clip or item |
//! | `tts.render` | synthesize in a background job into the cache, so the UI's create / edit / preview return at once |
//! | `tts.downloadVoices` | download the natural voices (Kokoro-82M, 9 US English voices, CMUdict; 336 MB, SHA-256 checked) as a background job |
//!
//! **Where a new narration goes.** At `time` (default: the playhead) on `track` if given (it must
//! be free there), else on the first targeted audio track that is free for the narration's length,
//! else the first free audio track, else on a new audio track. Existing clips are never overwritten.
//!
//! **Editing keeps the clip's length** (user decision, 2026-10-07). The clip keeps its start,
//! duration and speed. When the new speech is longer than the clip, the clip still ends where it
//! did but the file holds the whole speech, so trimming the clip's end out reveals the rest. When it
//! is shorter, the file is padded with silence to the clip's length. The clip's source In is reset
//! to the start of the new speech. Other clips (including other clips of the old narration) are not
//! changed. Each edit writes a new file; the old item and its narration stay, so undo is exact.
//!
//! Files are named `Narration <n>.wav` (mono 32-bit float at the voice's rate, 24 kHz) and saved
//! where voice-over recordings go (Scratch Disks ▸ Captured, else next to the project, else
//! `Narrations` in the data or temporary directory); an edit writes next to the previous file.
use std::sync::{Arc, Mutex, PoisonError};
use serde_json::{Value, json};
use filmcraft_project::{ClipId, ItemId, ItemKind, MediaRef, Narration, TrackId, TrackKind, VocalPitch};
use filmcraft_time::{Tick, TimeRange};
use filmcraft_tts as tts;
use crate::commands::{CommandSpec, always, bad, has_seq, str_p, time_p, u64_p};
use crate::{EngineError, Result, Session};
/// Item name prefix of generated narrations.
const BASE_NAME: &str = "Narration";
/// The narration of a project item.
pub fn narration_of(s: &Session, item: ItemId) -> Option<&Narration> {
s.project.narrations.get(&item)
}
/// The narration clip a command works on: `clip`, else the first selected clip whose item is a
/// narration.
pub fn target_clip(s: &Session, p: &Value) -> Option<ClipId> {
let seq = s.active_sequence()?;
let is_narration = |c: ClipId| seq.find_item(c).is_some_and(|(_, it)| s.project.narrations.contains_key(&it.item));
match u64_p(p, "clip") {
Some(c) => Some(ClipId(c)).filter(|c| is_narration(*c)),
None => s.state.selection.iter().copied().find(|c| is_narration(*c)),
}
}
fn has_narration_clip(s: &Session) -> std::result::Result<(), String> {
has_seq(s)?;
target_clip(s, &Value::Null).map(|_| ()).ok_or_else(|| "select a narration clip".into())
}
/// Script and settings from params, on top of `base`.
fn settings(cmd: &str, p: &Value, base: Option<&Narration>) -> Result<Narration> {
// a setting of the wrong type is an error, never silently replaced by a default
for key in ["text", "voice", "pitch"] {
if p.get(key).is_some_and(|v| !v.is_string()) {
return Err(bad(cmd, format!("`{key}` must be a string")));
}
}
let text = match str_p(p, "text") {
Some(t) => t.to_string(),
None => base.map(|b| b.text.clone()).ok_or_else(|| bad(cmd, "`text` is required"))?,
};
let voice = str_p(p, "voice").map(str::to_string).or_else(|| base.map(|b| b.voice.clone())).unwrap_or_else(|| tts::default_voice_id().to_string());
let info = tts::voices_in(None).into_iter().find(|v| v.id == voice).ok_or_else(|| bad(cmd, format!("unknown voice `{voice}` (see tts.voices)")))?;
let pitch = match str_p(p, "pitch") {
Some(v) => VocalPitch::from_id(v).ok_or_else(|| bad(cmd, format!("unknown pitch `{v}` (extraLow, low, default, high, extraHigh)")))?,
None => base.map(|b| b.pitch).unwrap_or_default(),
};
let pace = match p.get("pace") {
Some(v) => v.as_f64().ok_or_else(|| bad(cmd, "`pace` must be a number"))?,
None => base.map_or(1.0, |b| b.pace),
};
let n = Narration { text, language: info.language.to_string(), voice, pitch, pace, speech_duration: Tick::ZERO };
n.check().map_err(|e| bad(cmd, e))?;
Ok(n)
}
/// Synthesized narrations by settings, newest last (`tts.render` fills it in the background so
/// the UI's create / edit / preview don't block).
pub type SynthCache = Arc<Mutex<Vec<(String, Arc<tts::Audio>)>>>;
const CACHE_ENTRIES: usize = 6;
fn cache_key(n: &Narration) -> String {
format!("{}\u{1f}{}\u{1f}{}\u{1f}{}", n.voice, n.pitch.id(), n.pace.to_bits(), n.text)
}
fn tts_err(cmd: &str, e: tts::TtsError) -> EngineError {
match e {
tts::TtsError::NotInstalled => bad(cmd, "the natural voices are not downloaded yet: Text to Speech ▸ Download natural voices (tts.downloadVoices)"),
e => bad(cmd, e.to_string()),
}
}
/// Synthesize `n` (no cache).
fn render(n: &Narration, models_dir: Option<&std::path::Path>) -> std::result::Result<tts::Audio, tts::TtsError> {
let voice = tts::voice_in(&n.voice, models_dir)?;
voice.synthesize(&n.text, &tts::Params { semitones: n.pitch.semitones(), pace: n.pace })
}
fn synthesize(s: &Session, cmd: &str, n: &Narration) -> Result<Arc<tts::Audio>> {
let key = cache_key(n);
if let Some(a) = s.tts_cache.lock().unwrap_or_else(PoisonError::into_inner).iter().find(|(k, _)| *k == key).map(|(_, a)| a.clone()) {
return Ok(a);
}
let a = Arc::new(render(n, crate::transcript::models_dir().as_deref()).map_err(|e| tts_err(cmd, e))?);
cache_put(&s.tts_cache, key, a.clone());
Ok(a)
}
fn cache_put(c: &SynthCache, key: String, a: Arc<tts::Audio>) {
let mut c = c.lock().unwrap_or_else(PoisonError::into_inner);
c.retain(|(k, _)| *k != key);
c.push((key, a));
let extra = c.len().saturating_sub(CACHE_ENTRIES);
c.drain(..extra);
}
fn package_json() -> Value {
let dir = crate::transcript::models_dir();
json!({
"id": tts::catalog::PACKAGE_ID,
"name": tts::catalog::PACKAGE_NAME,
"installed": dir.as_deref().is_some_and(tts::catalog::installed),
"size": tts::catalog::size(),
"missingBytes": dir.as_deref().map_or(tts::catalog::size(), tts::catalog::missing_bytes),
"license": tts::catalog::LICENSE,
"source": tts::catalog::SOURCE,
"available": cfg!(feature = "neural-voices"),
})
}
fn voices(_s: &mut Session, _p: &Value) -> Result<Value> {
let dir = crate::transcript::models_dir();
Ok(json!({
"package": package_json(),
"languages": tts::LANGUAGES.iter().map(|(id, name)| json!({"id": id, "name": name})).collect::<Vec<_>>(),
"voices": tts::voices_in(dir.as_deref()),
"defaultVoice": tts::default_voice_id(),
"pitches": VocalPitch::ALL.iter().map(|p| json!({"id": p.id(), "label": p.label(), "semitones": p.semitones()})).collect::<Vec<_>>(),
"pace": {"min": tts::MIN_PACE, "max": tts::MAX_PACE, "default": 1.0},
"pauseMarker": tts::script::PAUSE_MARKER,
"maxTextBytes": tts::MAX_TEXT_BYTES,
"sampleSentence": tts::SAMPLE_SENTENCE,
}))
}
fn preview(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "tts.preview";
let base = target_clip(s, p).and_then(|c| s.active_sequence()?.find_item(c).map(|(_, it)| it.item)).and_then(|i| s.project.narrations.get(&i).cloned());
let mut q = p.clone();
if q.get("sample").and_then(Value::as_bool).unwrap_or(false) {
q["text"] = json!(tts::SAMPLE_SENTENCE);
}
let n = settings(cmd, &q, base.as_ref())?;
let audio = synthesize(s, cmd, &n)?;
let seconds = audio.seconds();
let samples = audio.samples.len();
let rate = audio.sample_rate;
let mut path = Value::Null;
if let Some(dest) = str_p(p, "path") {
let bytes = crate::voiceover::write_wav_f32(&audio.samples, rate);
s.services.write_file(dest, &bytes).map_err(|e| EngineError::Other(format!("{dest}: {e}")))?;
path = json!(dest);
}
s.tts_preview = Some(audio);
Ok(json!({"seconds": seconds, "samples": samples, "sampleRate": rate, "voice": n.voice, "path": path}))
}
/// Write `samples` as a new WAV and import it (its own undo step; callers collapse history).
/// `near`: an edit writes next to the narration's previous file unless `dir` is given.
fn write_and_import(s: &mut Session, p: &Value, samples: &[f32], rate: u32, near: Option<ItemId>) -> Result<(ItemId, String)> {
let beside = near.filter(|_| p.get("dir").is_none()).and_then(|i| match &s.project.items.get(&i)?.kind {
ItemKind::Media(m) => match &m.media {
MediaRef::File { path } => std::path::Path::new(path).parent().filter(|d| !d.as_os_str().is_empty()).map(|d| d.to_string_lossy().into_owned()),
MediaRef::Generator(_) => None,
},
_ => None,
});
let dir = beside.unwrap_or_else(|| crate::voiceover::media_dir(s, p, "Narrations"));
let path = crate::voiceover::unique_wav_path(s, &dir, BASE_NAME);
if !cfg!(target_arch = "wasm32") {
std::fs::create_dir_all(&dir).map_err(|e| EngineError::Other(format!("{dir}: {e}")))?;
}
let bytes = crate::voiceover::write_wav_f32(samples, rate);
s.services.write_file(&path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let item = crate::commands::import_bytes(s, &path, bytes.into(), None)?;
Ok((item, path))
}
fn track_is_free(s: &Session, track: TrackId, range: TimeRange) -> bool {
s.active_sequence()
.and_then(|q| q.audio_tracks.iter().find(|t| t.id == track))
.is_some_and(|t| !t.locked && !t.items.iter().any(|it| it.start < range.end() && range.start < it.end()))
}
/// The track a new narration of `range` goes to (None: add a track).
fn placement(s: &Session, p: &Value, range: TimeRange) -> Result<Option<TrackId>> {
let cmd = "tts.create";
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
if let Some(v) = p.get("track") {
let id =
crate::mixer::strip_ref(seq, v).filter(|id| seq.audio_tracks.iter().any(|t| t.id == *id)).ok_or_else(|| bad(cmd, format!("no audio track {v}")))?;
if !track_is_free(s, id, range) {
return Err(bad(cmd, format!("audio track {v} is locked or has clips there")));
}
return Ok(Some(id));
}
let targeted = s.targeting().targeted;
let order = seq.audio_tracks.iter().filter(|t| targeted.contains(&t.id)).chain(seq.audio_tracks.iter().filter(|t| !targeted.contains(&t.id)));
Ok(order.map(|t| t.id).find(|id| track_is_free(s, *id, range)))
}
fn create(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "tts.create";
let seq_id = s.state.active_sequence.ok_or(EngineError::NoSequence)?;
let mut n = settings(cmd, p, None)?;
let at = time_p(s, p, "").unwrap_or(s.playhead());
if at < Tick::ZERO {
return Err(bad(cmd, "the start time cannot be negative"));
}
let audio = synthesize(s, cmd, &n)?;
let rate = audio.sample_rate.max(1);
let dur = Tick::from_units(audio.samples.len() as i64, i64::from(rate));
n.speech_duration = dur;
let range = TimeRange::new(at, dur);
let track = placement(s, p, range)?;
let n0 = s.history.undo.len();
let result = (|| -> Result<Value> {
let track = match track {
Some(t) => t,
None => {
let before: Vec<TrackId> = s.active_sequence().map(|q| q.audio_tracks.iter().map(|t| t.id).collect()).unwrap_or_default();
s.execute("sequence.addTracks", json!({"video": 0, "audio": 1, "audioAfter": "last"}))?;
s.active_sequence()
.and_then(|q| q.audio_tracks.iter().map(|t| t.id).find(|id| !before.contains(id)))
.ok_or_else(|| EngineError::Other("could not add an audio track".into()))?
}
};
let (item, path) = write_and_import(s, p, &audio.samples, rate, None)?;
let seq_rate = s.project.sequence(seq_id).map(|q| q.settings.frame_rate).unwrap_or_default();
let narration = n.clone();
let clip = s.edit("New Narration", |pr, st| {
let mut it = pr
.make_track_item(item, TrackKind::Audio, at, TimeRange::new(Tick::ZERO, dur), seq_rate)
.ok_or(EngineError::Other("the narration is not importable".into()))?;
it.duration = dur;
let id = it.id;
let seq = pr.sequence_mut(seq_id).ok_or(EngineError::NoSequence)?;
let t = seq.audio_tracks.iter_mut().find(|t| t.id == track).ok_or_else(|| bad(cmd, "the narration track was deleted"))?;
let pos = t.items.partition_point(|x| x.start <= at);
t.items.insert(pos, it);
seq.check().map_err(EngineError::Other)?;
pr.narrations.insert(item, narration);
st.selection = vec![id];
Ok(id)
})?;
let seq = s.project.sequence(seq_id).ok_or(EngineError::NoSequence)?;
Ok(json!({
"item": item.0,
"clip": clip.0,
"track": crate::mixer::strip_label(seq, track),
"start": at.0,
"duration": dur.0,
"speechDuration": dur.0,
"path": path,
"sampleRate": rate,
}))
})();
crate::clip_ops::collapse_history(s, n0, "New Narration");
if result.is_err() && s.history.undo.len() > n0 {
// a step failed after the track was added or the file imported: roll back to before
let _ = s.undo();
s.history.redo.clear();
}
result
}
/// Samples of media a clip of `duration` at `speed` plays, rounded up (exact for 100 %).
fn needed_samples(duration: Tick, speed: f64, rate: u32) -> usize {
let rate = i64::from(rate.max(1));
let media = if speed.is_finite() && speed != 0.0 && (speed.abs() - 1.0).abs() > 1e-12 {
// speed is a ratio, not edit time: scale the exact tick count
Tick::from_seconds_f64(duration.seconds() * speed.abs())
} else {
duration
};
let mut n = media.to_units_floor(rate);
if Tick::from_units(n, rate) < media {
n = n.saturating_add(1);
}
let cap = (tts::MAX_SECONDS * 2.0) as i64 * rate;
n.clamp(0, cap) as usize
}
fn edit(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "tts.edit";
let seq_id = s.state.active_sequence.ok_or(EngineError::NoSequence)?;
let clip = target_clip(s, p).ok_or_else(|| bad(cmd, "select a narration clip (or pass `clip`)"))?;
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
let (track, it) = seq.find_item(clip).ok_or_else(|| bad(cmd, "no such clip"))?;
if seq.audio_tracks.iter().any(|t| t.id == track && t.locked) {
return Err(bad(cmd, "the clip's track is locked"));
}
let (old_item, clip_dur, speed) = (it.item, it.duration, it.speed);
let base = s.project.narrations.get(&old_item).cloned().ok_or_else(|| bad(cmd, "the clip is not a narration"))?;
let mut n = settings(cmd, p, Some(&base))?;
let audio = synthesize(s, cmd, &n)?;
let rate = audio.sample_rate.max(1);
let speech = audio.samples.len();
n.speech_duration = Tick::from_units(speech as i64, i64::from(rate));
// the clip keeps its length: pad the file with silence to what the clip plays
let needed = needed_samples(clip_dur, speed, rate);
let mut samples = audio.samples.clone();
if needed > samples.len() {
samples.try_reserve(needed - samples.len()).map_err(|e| bad(cmd, format!("unable to allocate the narration: {e}")))?;
samples.resize(needed, 0.0);
}
let file_len = samples.len();
let n0 = s.history.undo.len();
let result = (|| -> Result<Value> {
let (item, path) = write_and_import(s, p, &samples, rate, Some(old_item))?;
let name = s.project.items.get(&item).map(|i| i.name.clone()).unwrap_or_default();
let narration = n.clone();
s.edit("Edit Narration", |pr, _| {
let seq = pr.sequence_mut(seq_id).ok_or(EngineError::NoSequence)?;
let (_, it) = seq.find_item_mut(clip).ok_or_else(|| bad(cmd, "the clip was deleted"))?;
it.item = item;
it.name = name;
it.source_in = Tick::ZERO;
seq.check().map_err(EngineError::Other)?;
pr.narrations.insert(item, narration);
Ok(())
})?;
Ok(json!({
"clip": clip.0,
"item": item.0,
"previousItem": old_item.0,
"duration": clip_dur.0,
"speechDuration": n.speech_duration.0,
"fileDuration": Tick::from_units(file_len as i64, i64::from(rate)).0,
"path": path,
"sampleRate": rate,
}))
})();
crate::clip_ops::collapse_history(s, n0, "Edit Narration");
if result.is_err() && s.history.undo.len() > n0 {
let _ = s.undo();
s.history.redo.clear();
}
result
}
fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "tts.inspect";
let (item, clip) = match u64_p(p, "item") {
Some(i) => (ItemId(i), None),
None => {
let c = target_clip(s, p).ok_or_else(|| bad(cmd, "pass `item` or `clip`, or select a narration clip"))?;
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
(seq.find_item(c).map(|(_, it)| it.item).ok_or_else(|| bad(cmd, "no such clip"))?, Some(c.0))
}
};
let n = s.project.narrations.get(&item).ok_or_else(|| bad(cmd, "not a narration"))?;
Ok(json!({"item": item.0, "clip": clip, "narration": n}))
}
fn new_job(s: &mut Session, label: String) -> crate::Job {
let id = s.jobs.iter().map(|j| j.id).max().unwrap_or(0) + 1;
crate::Job { id, label, progress: Default::default(), result: Default::default() }
}
fn spawn(s: &mut Session, job: crate::Job, wait: bool, name: &str, run: impl FnOnce() + Send + 'static) -> Result<u64> {
let id = job.id;
let run = crate::export_tools::guard_job(job.progress.clone(), job.result.clone(), run);
s.jobs.push(job);
if wait || cfg!(target_arch = "wasm32") {
run();
} else {
std::thread::Builder::new().name(name.into()).spawn(run).map_err(|e| EngineError::Other(e.to_string()))?;
}
Ok(id)
}
type JobResult = std::sync::Mutex<Option<std::result::Result<filmcraft_export::Report, String>>>;
fn finish(prog: &filmcraft_export::Progress, res: &JobResult, r: std::result::Result<String, String>, t0: std::time::Instant) {
use std::sync::atomic::Ordering;
let secs = t0.elapsed().as_secs_f64();
let r = match r {
Ok(status) => {
*prog.status.lock().unwrap_or_else(PoisonError::into_inner) = status;
Ok(filmcraft_export::Report { path: String::new(), frames: 0, seconds: secs, bytes: 0, render_fps: 0.0, extra_files: Vec::new() })
}
Err(e) => {
*prog.status.lock().unwrap_or_else(PoisonError::into_inner) = e.clone();
*prog.error.lock().unwrap_or_else(PoisonError::into_inner) = Some(e.clone());
Err(e)
}
};
prog.finished.store(true, Ordering::Relaxed);
*res.lock().unwrap_or_else(PoisonError::into_inner) = Some(r);
}
/// Synthesize in the background into the cache, so `tts.create` / `tts.edit` / `tts.preview` with
/// the same settings return at once.
fn render_job(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "tts.render";
let base = target_clip(s, p).and_then(|c| s.active_sequence()?.find_item(c).map(|(_, it)| it.item)).and_then(|i| s.project.narrations.get(&i).cloned());
let mut q = p.clone();
if q.get("sample").and_then(Value::as_bool).unwrap_or(false) {
q["text"] = json!(tts::SAMPLE_SENTENCE);
}
let n = settings(cmd, &q, base.as_ref())?;
let key = cache_key(&n);
if s.tts_cache.lock().unwrap_or_else(PoisonError::into_inner).iter().any(|(k, _)| *k == key) {
return Ok(json!({"job": null, "cached": true}));
}
let job = new_job(s, "Synthesizing narration".into());
let (prog, res, cache) = (job.progress.clone(), job.result.clone(), s.tts_cache.clone());
let wait = p.get("wait").and_then(Value::as_bool).unwrap_or(false);
let id = spawn(s, job, wait, "filmcraft-tts", move || {
let t0 = std::time::Instant::now();
*prog.status.lock().unwrap_or_else(PoisonError::into_inner) = "Synthesizing…".into();
let r = match render(&n, crate::transcript::models_dir().as_deref()) {
Ok(a) => {
let secs = a.seconds();
cache_put(&cache, key, Arc::new(a));
Ok(format!("{secs:.1} s of speech"))
}
Err(tts::TtsError::NotInstalled) => Err("the natural voices are not downloaded yet".to_string()),
Err(e) => Err(e.to_string()),
};
finish(&prog, &res, r, t0);
})?;
Ok(json!({"job": id, "cached": false}))
}
/// Download the natural voices (Kokoro-82M, its voices and CMUdict) in the background.
fn download_voices(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "tts.downloadVoices";
let dir = crate::transcript::models_dir().ok_or_else(|| bad(cmd, "no data directory for voice models"))?;
if tts::catalog::installed(&dir) {
return Ok(json!({"job": null, "installed": true}));
}
if !cfg!(feature = "neural-voices") {
return Err(bad(cmd, "natural voices are not available in this build (built without the `neural-voices` feature)"));
}
let wait = p.get("wait").and_then(Value::as_bool).unwrap_or(false);
let job = new_job(s, format!("Downloading {}", tts::catalog::PACKAGE_NAME));
job.progress.total.store(tts::catalog::missing_bytes(&dir), std::sync::atomic::Ordering::Relaxed);
let (prog, res) = (job.progress.clone(), job.result.clone());
let id = spawn(s, job, wait, "filmcraft-tts-download", move || {
let t0 = std::time::Instant::now();
let r = download_package(&dir, &prog);
finish(&prog, &res, r.map(|()| "Natural voices installed".to_string()), t0);
})?;
Ok(json!({"job": id, "installed": false}))
}
#[cfg(feature = "neural-voices")]
fn download_package(dir: &std::path::Path, prog: &filmcraft_export::Progress) -> std::result::Result<(), String> {
use std::sync::atomic::Ordering;
let files: Vec<filmcraft_speech::models::ModelFile> =
tts::catalog::FILES.iter().map(|f| filmcraft_speech::models::ModelFile { name: f.name, url: f.url, sha256: f.sha256, size: f.size }).collect();
let mut progress = |done: u64, total: u64, file: &str| {
prog.done.store(done, Ordering::Relaxed);
prog.total.store(total, Ordering::Relaxed);
*prog.status.lock().unwrap_or_else(PoisonError::into_inner) = format!("Downloading {file}");
!prog.cancel.load(Ordering::Relaxed)
};
filmcraft_speech::models::download_files(&tts::catalog::package_dir(dir), &files, &mut progress).map_err(|e| e.to_string())
}
#[cfg(not(feature = "neural-voices"))]
fn download_package(_: &std::path::Path, _: &filmcraft_export::Progress) -> std::result::Result<(), String> {
Err("natural voices are not available in this build".into())
}
fn spec(
id: &'static str,
label: &'static str,
params: &'static str,
enabled: fn(&Session) -> std::result::Result<(), String>,
run: fn(&mut Session, &Value) -> Result<Value>,
journal: bool,
) -> CommandSpec {
CommandSpec { id, label, menu: &[], shortcut: None, params, enabled, run, journal }
}
pub fn commands() -> Vec<CommandSpec> {
vec![
spec("tts.voices", "List Narration Voices", "{}", always, voices, false),
spec(
"tts.preview",
"Preview Narration",
r#"{"text":str?,"voice":str?,"pitch":"default"?,"pace":f64?,"sample":bool?,"clip":id?,"path":str?}"#,
always,
preview,
false,
),
spec(
"tts.create",
"New Narration",
r#"{"text":str,"voice":str?,"pitch":"extraLow|low|default|high|extraHigh"?,"pace":0.5..2?,"time":ticks?,"track":"A1"|id?,"dir":str?}"#,
has_seq,
create,
true,
),
spec("tts.edit", "Edit Narration", r#"{"clip":id?,"text":str?,"voice":str?,"pitch":str?,"pace":f64?,"dir":str?}"#, has_narration_clip, edit, true),
spec("tts.inspect", "Inspect Narration", r#"{"clip":id?,"item":id?}"#, always, inspect, false),
spec(
"tts.render",
"Synthesize Narration",
r#"{"text":str?,"voice":str?,"pitch":str?,"pace":f64?,"sample":bool?,"clip":id?,"wait":bool?}"#,
always,
render_job,
false,
),
spec("tts.downloadVoices", "Download Natural Voices", r#"{"wait":bool?}"#, always, download_voices, false),
]
}