use std::io::Write;
use std::path::PathBuf;
use std::time::Instant;
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use drama::engine::{Engine, SampleParams};
use drama::{codec, gpu, model, tts};
#[derive(Parser)]
#[command(name = "drama", version, about = "Fish Audio Drama 3 text-to-speech")]
struct Cli {
#[command(subcommand)]
cmd: Cmd,
}
#[derive(clap::ValueEnum, Clone, Copy, PartialEq)]
enum Experts {
Auto,
Q4,
#[cfg_attr(feature = "mlx", value(hide = true))]
Fp8,
#[cfg_attr(feature = "mlx", value(hide = true))]
Fp8Vram,
#[cfg_attr(feature = "mlx", value(hide = true))]
Fp8Ram,
}
impl From<Experts> for drama::download::Want {
fn from(e: Experts) -> Self {
match e {
Experts::Auto => Self::Auto,
Experts::Q4 => Self::Q4,
Experts::Fp8 => Self::Fp8,
Experts::Fp8Vram => Self::Fp8Vram,
Experts::Fp8Ram => Self::Fp8Ram,
}
}
}
#[derive(clap::Args)]
struct Common {
#[arg(long, env = "DRAMA_MODEL")]
model: Option<PathBuf>,
#[cfg_attr(feature = "mlx", arg(help = "Routed experts: auto and q4 use converted int4 weights on Apple Silicon"))]
#[arg(long, value_enum, default_value_t = Experts::Auto)]
experts: Experts,
#[cfg_attr(feature = "mlx", arg(hide = true))]
#[arg(long)]
fp8_vram_layers: Option<usize>,
#[cfg_attr(feature = "mlx", arg(hide = true))]
#[arg(long, default_value_t = 3.0)]
vram_reserve_gb: f64,
#[cfg_attr(feature = "mlx", arg(hide = true))]
#[arg(long, env = "DRAMA_FP8_MODEL")]
fp8_model: Option<PathBuf>,
#[arg(long, default_value_t = 4096)]
max_len: usize,
#[arg(long, default_value_t = 512)]
chunk: usize,
#[arg(long, default_value_t = 1.0)]
temperature: f32,
#[arg(long, default_value_t = 30)]
top_k: i32,
#[arg(long, default_value_t = 0.9)]
top_p: f32,
#[arg(long, default_value_t = 0)]
seed: u64,
}
impl Common {
fn resolve(&self) -> Result<drama::download::Resolved> {
#[cfg(feature = "mlx")]
anyhow::ensure!(matches!(self.experts, Experts::Auto | Experts::Q4) && self.fp8_vram_layers.is_none() && self.fp8_model.is_none(),
"Apple Silicon uses converted int4 experts: select --experts q4; FP8 placement is available with the CUDA build");
let choice = drama::download::Choice {
want: self.experts.into(),
vram_layers: self.fp8_vram_layers,
reserve_bytes: (self.vram_reserve_gb * (1u64 << 30) as f64) as u64,
};
let r = drama::download::resolve(self.model.as_deref(), self.fp8_model.as_deref(), choice)?;
eprintln!("experts: {} (dense weights from {})", r.summary, r.dir.display());
Ok(r)
}
fn params(&self) -> SampleParams {
SampleParams { temperature: self.temperature, top_k: self.top_k, top_p: self.top_p, pad: 0, seed: self.seed }
}
fn engine(&self) -> Result<Engine> {
let st = gpu::Stream::new()?;
let r = self.resolve()?;
let model = model::Model::load_with(&r.dir, &st, &r.experts)?;
let engine = Engine::new(model, self.max_len, self.chunk)?;
let (free, total) = gpu::mem_info()?;
eprintln!("GPU memory: {:.2} GiB used of {:.2} GiB", (total - free) as f64 / (1u64 << 30) as f64, total as f64 / (1u64 << 30) as f64);
Ok(engine)
}
}
#[derive(Subcommand)]
enum Cmd {
Speak {
#[command(flatten)]
common: Common,
#[arg(long)]
text: Option<String>,
#[arg(long)]
out: Option<PathBuf>,
#[arg(long)]
play: bool,
#[arg(long)]
interactive: bool,
#[arg(long)]
ref_wav: Vec<PathBuf>,
#[arg(long)]
ref_text: Vec<String>,
#[arg(long)]
codes_out: Option<PathBuf>,
#[arg(long)]
prompt_out: Option<PathBuf>,
#[arg(long, default_value_t = 2048)]
max_new: usize,
#[arg(long, default_value_t = 4)]
first_chunk: usize,
#[arg(long, default_value_t = 16)]
stream_chunk: usize,
#[arg(long)]
no_split: bool,
},
Trace {
#[command(flatten)]
common: Common,
#[arg(long)]
rows: PathBuf,
#[arg(long, default_value_t = 8)]
steps: usize,
#[arg(long)]
out: PathBuf,
},
Decode {
#[arg(long = "codec", env = "DRAMA_CODEC")]
codec_path_opt: Option<PathBuf>,
#[arg(long)]
codes: PathBuf,
#[arg(long)]
out: PathBuf,
#[arg(long, default_value_t = 0)]
stream_chunk: usize,
},
Encode {
#[arg(long = "codec", env = "DRAMA_CODEC")]
codec_path_opt: Option<PathBuf>,
#[arg(long)]
wav: PathBuf,
#[arg(long)]
out: PathBuf,
},
Serve {
#[command(flatten)]
common: Common,
#[arg(long, default_value = "127.0.0.1:8080")]
addr: String,
#[arg(long)]
voice: Vec<String>,
#[arg(long, default_value_t = 2048)]
max_new: usize,
#[arg(long, default_value_t = 4)]
first_chunk: usize,
#[arg(long, default_value_t = 16)]
stream_chunk: usize,
#[arg(long)]
no_split: bool,
},
Bench {
#[command(flatten)]
common: Common,
#[arg(long, default_value_t = 200)]
steps: usize,
#[arg(long, default_value_t = 64)]
prompt_rows: usize,
},
}
fn codec_file(p: Option<PathBuf>) -> Result<PathBuf> {
match p {
Some(p) => Ok(p),
None => Ok(drama::download::default_model()?.join("codec.safetensors")),
}
}
fn read_i32(path: &PathBuf) -> Result<Vec<i32>> {
let b = std::fs::read(path).with_context(|| format!("read {}", path.display()))?;
Ok(b.chunks_exact(4).map(|c| i32::from_le_bytes(c.try_into().unwrap())).collect())
}
fn write_le<T: Copy>(w: &mut impl Write, v: &[T]) -> Result<()> {
let bytes = unsafe { std::slice::from_raw_parts(v.as_ptr() as *const u8, std::mem::size_of_val(v)) };
w.write_all(bytes)?;
Ok(())
}
fn main() -> Result<()> {
let cli = Cli::parse();
match cli.cmd {
Cmd::Speak { common, text, out, play, interactive, ref_wav, ref_text, codes_out, prompt_out, max_new, first_chunk, stream_chunk, no_split } => {
if ref_wav.len() != ref_text.len() {
anyhow::bail!("give one --ref-text per --ref-wav");
}
if text.is_none() && !interactive {
anyhow::bail!("give --text, or --interactive to read lines from stdin");
}
let t_load = Instant::now();
let r = common.resolve()?;
let mut tts = tts::Tts::load_with(&r.dir, common.max_len, common.chunk, &r.experts)?;
let (free, total) = gpu::mem_info()?;
eprintln!(
"ready in {:.1}s, GPU memory {:.2} / {:.2} GiB",
t_load.elapsed().as_secs_f64(),
(total - free) as f64 / (1u64 << 30) as f64,
total as f64 / (1u64 << 30) as f64
);
let mut refs = Vec::new();
for (wav, transcript) in ref_wav.iter().zip(&ref_text) {
let t1 = Instant::now();
let r = tts.reference(wav, transcript)?;
eprintln!("reference {}: {} frames in {:.0} ms", wav.display(), r.codes[0].len(), t1.elapsed().as_secs_f64() * 1e3);
refs.push(r);
}
let opts = tts::SpeakOpts { params: common.params(), max_new, first_chunk, chunk: stream_chunk, split: !no_split };
let mut player = if play { Some(tts::Player::new()?) } else { None };
let lines: Box<dyn Iterator<Item = String>> = match text {
Some(t) if !interactive => Box::new(std::iter::once(t)),
_ => Box::new(std::io::stdin().lines().map_while(Result::ok)),
};
for (n, line) in lines.enumerate() {
let line = line.trim();
if line.is_empty() {
continue;
}
let spoken = tts.speak(line, &refs, &opts, |a| match player.as_mut() {
Some(p) => p.write(a),
None => Ok(()),
})?;
let audio_s = spoken.audio.len() as f64 / codec::SAMPLE_RATE as f64;
eprintln!(
"{} sentence(s), {} prompt rows -> {} frames ({audio_s:.2}s of audio) in {:.2}s ({:.1}x real time), first audio after {:.0} ms",
spoken.segments,
spoken.prompt_rows,
spoken.frames.len(),
spoken.elapsed.as_secs_f64(),
audio_s / spoken.elapsed.as_secs_f64(),
spoken.first_audio.map(|d| d.as_secs_f64() * 1e3).unwrap_or(f64::NAN),
);
if let Some(path) = &out {
let path = if interactive { path.with_file_name(format!("{}-{n}.wav", path.file_stem().unwrap().to_string_lossy())) } else { path.clone() };
codec::write_wav(&path, &spoken.audio)?;
eprintln!("wrote {}", path.display());
}
if let Some(path) = &prompt_out {
let mut f = std::io::BufWriter::new(std::fs::File::create(path)?);
write_le(&mut f, &spoken.prompt)?;
}
if let Some(path) = &codes_out {
let mut f = std::io::BufWriter::new(std::fs::File::create(path)?);
for fr in &spoken.frames {
write_le(&mut f, fr)?;
}
}
}
if let Some(p) = player {
p.finish()?;
}
}
Cmd::Serve { common, addr, voice, max_new, first_chunk, stream_chunk, no_split } => {
let defaults = drama::serve::Defaults { params: common.params(), max_new, first_chunk, chunk: stream_chunk, split: !no_split };
let r = common.resolve()?;
let (dir, experts, max_len, chunk) = (r.dir, r.experts, common.max_len, common.chunk);
let load = move || -> Result<_> {
let t = Instant::now();
let tts = tts::Tts::load_with(&dir, max_len, chunk, &experts)?;
let mut voices = std::collections::HashMap::new();
for v in &voice {
let mut it = v.splitn(3, '=');
let (Some(name), Some(wav), Some(text)) = (it.next(), it.next(), it.next()) else {
anyhow::bail!("--voice wants NAME=WAV=TRANSCRIPT, got {v:?}");
};
voices.insert(name.to_string(), tts.reference(wav.as_ref(), text)?);
}
eprintln!("ready in {:.1}s", t.elapsed().as_secs_f64());
Ok((tts, voices))
};
drama::serve::run(load, defaults, &addr)?;
}
Cmd::Decode { codec_path_opt, codes, out, stream_chunk } => {
let codec_path = codec_file(codec_path_opt)?;
let raw = read_i32(&codes)?;
let codec = codec::Codec::load(&codec_path)?;
let t1 = Instant::now();
let audio = if stream_chunk == 0 {
codec.decode(&raw)?
} else {
let mut ds = codec.stream()?;
let mut audio = Vec::new();
for part in raw.chunks(stream_chunk * 10) {
audio.extend(codec.decode_chunk(&mut ds, part)?);
}
audio
};
eprintln!("decoded {} samples in {:.0} ms", audio.len(), t1.elapsed().as_secs_f64() * 1e3);
codec::write_wav(&out, &audio)?;
}
Cmd::Encode { codec_path_opt, wav, out } => {
let codec_path = codec_file(codec_path_opt)?;
let codec = codec::Codec::load(&codec_path)?;
let (audio, sr) = codec::read_wav(&wav)?;
let audio = codec::resample(&audio, sr, codec::SAMPLE_RATE);
let t1 = Instant::now();
let codes = codec.encode(&audio)?;
eprintln!("encoded {} frames in {:.0} ms", codes.len() / 10, t1.elapsed().as_secs_f64() * 1e3);
let mut f = std::io::BufWriter::new(std::fs::File::create(&out)?);
write_le(&mut f, &codes)?;
}
Cmd::Trace { common, rows, steps, out } => {
let mut engine = common.engine()?;
engine.trace_logits = true;
let prompt = read_i32(&rows)?;
engine.prefill(&prompt, common.params())?;
let mut f = std::io::BufWriter::new(std::fs::File::create(&out)?);
for _ in 0..steps {
let (frame, trace) = engine.step()?;
let trace = trace.unwrap();
write_le(&mut f, &frame)?;
write_le(&mut f, &trace.main_logits)?;
write_le(&mut f, &trace.head_logits)?;
}
eprintln!("wrote {}", out.display());
}
Cmd::Bench { common, steps, prompt_rows } => {
let mut engine = common.engine()?;
let cfg = engine.model.cfg.clone();
let width = cfg.frame_width();
let mut rows = vec![0i32; prompt_rows * width];
for r in 0..prompt_rows {
rows[r * width] = 1000 + r as i32;
}
let t0 = Instant::now();
engine.prefill(&rows, common.params())?;
eprintln!("prefill {prompt_rows} rows: {:.1} ms", t0.elapsed().as_secs_f64() * 1e3);
for _ in 0..5 {
engine.step()?;
}
let t0 = Instant::now();
for _ in 0..steps {
engine.step()?;
}
let per = t0.elapsed().as_secs_f64() / steps as f64;
eprintln!("decode: {:.2} ms/frame, {:.1} frames/s, {:.1}x real time", per * 1e3, 1.0 / per, 1.0 / per / 21.533);
}
}
Ok(())
}