ripl-tui 0.3.3

ripl — a living, breathing TUI framework for AI chat in the shell.
Documentation
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Child, Command};
use std::sync::mpsc::{self, Receiver};
use std::thread;
use std::time::{SystemTime, UNIX_EPOCH};

use crate::speech::fish;

pub fn stt_record_path() -> PathBuf {
    let ts = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0);
    let key = format!("{:04x}", ts & 0xffff);
    stt_record_dir().join(format!("stt_{}_{}.wav", ts, key))
}

pub fn spawn_stt_recorder(path: &Path) -> Result<Child, String> {
    let cmd = std::env::var("RIPL_STT_RECORDER").unwrap_or_else(|_| "sox".to_string());
    if let Some(dir) = path.parent() {
        fs::create_dir_all(dir).map_err(|e| format!("stt record dir error: {e}"))?;
    }
    let mut command = Command::new(&cmd);
    if cmd == "sox" {
        command.args(["-d", "-c", "1", "-r", "16000", "-b", "16", "-e", "signed-integer"]);
    } else if let Ok(args) = std::env::var("RIPL_STT_RECORDER_ARGS") {
        command.args(args.split_whitespace());
    }
    command.arg(path);
    command.spawn().map_err(|e| format!("stt record spawn error: {e}"))
}

pub fn spawn_stt_transcribe(path: PathBuf) -> Option<Receiver<Result<String, String>>> {
    let (tx, rx) = mpsc::channel();
    thread::spawn(move || {
        let result = whisper_transcribe(&path);
        let _ = tx.send(result);
    });
    Some(rx)
}

pub fn spawn_fish_transcribe(path: PathBuf) -> Option<Receiver<Result<String, String>>> {
    let (tx, rx) = mpsc::channel();
    thread::spawn(move || {
        let result = fish::fish_stt(&path);
        let _ = tx.send(result);
    });
    Some(rx)
}

fn whisper_transcribe(path: &Path) -> Result<String, String> {
    let cmd = whisper_cmd()?;
    let model_path = whisper_model_path()?;
    let out_dir = stt_transcript_dir();
    fs::create_dir_all(&out_dir).map_err(|e| format!("stt transcript dir error: {e}"))?;
    let key = format!("{:05x}", (SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis() % 0xfffff));
    let out_base = out_dir.join(format!("stt_{}", key));
    let mut command = Command::new(cmd);
    command
        .arg("-m")
        .arg(model_path)
        .arg("-f")
        .arg(path)
        .arg("-otxt")
        .arg("-of")
        .arg(&out_base);
    if let Ok(lang) = std::env::var("RIPL_WHISPER_LANG") {
        let lang = lang.trim();
        if !lang.is_empty() {
            command.arg("-l").arg(lang);
        }
    }
    let status = command.status().map_err(|e| format!("whisper spawn error: {e}"))?;
    if !status.success() {
        return Err(format!("whisper exited with status {}", status));
    }
    let out_txt = out_base.with_extension("txt");
    let text = fs::read_to_string(&out_txt).map_err(|e| format!("whisper output read error: {e}"))?;
    Ok(clean_transcript(&text))
}

fn whisper_cmd() -> Result<String, String> {
    if let Ok(cmd) = std::env::var("RIPL_WHISPER_CMD") {
        return Ok(cmd);
    }
    for candidate in ["whisper", "whisper-cpp"] {
        if Command::new(candidate).arg("--help").output().is_ok() {
            return Ok(candidate.to_string());
        }
    }
    Err("Whisper command not found. Install whisper.cpp or set RIPL_WHISPER_CMD.".to_string())
}

fn whisper_model_path() -> Result<PathBuf, String> {
    if let Ok(path) = std::env::var("RIPL_WHISPER_MODEL") {
        return Ok(PathBuf::from(path));
    }
    let mut candidates = Vec::new();
    candidates.push(PathBuf::from("/opt/homebrew/share/whisper.cpp/models/ggml-base.en.bin"));
    candidates.push(PathBuf::from("/usr/local/share/whisper.cpp/models/ggml-base.en.bin"));
    if let Ok(home) = std::env::var("HOME") {
        candidates.push(PathBuf::from(home).join(".local/share/whisper.cpp/models/ggml-base.en.bin"));
    }
    for path in candidates {
        if path.exists() {
            return Ok(path);
        }
    }
    Err("Whisper model not found. Set RIPL_WHISPER_MODEL to a ggml model path.".to_string())
}

fn stt_record_dir() -> PathBuf {
    if let Ok(dir) = std::env::var("RIPL_STT_DIR") {
        return PathBuf::from(dir);
    }
    let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
    PathBuf::from(home).join(".ripl").join("stt_recordings")
}

fn stt_transcript_dir() -> PathBuf {
    if let Ok(dir) = std::env::var("RIPL_STT_TRANSCRIPT_DIR") {
        return PathBuf::from(dir);
    }
    let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
    PathBuf::from(home).join(".ripl").join("stt_transcripts")
}

fn clean_transcript(text: &str) -> String {
    text.lines()
        .map(|line| line.trim())
        .filter(|line| !line.is_empty())
        .collect::<Vec<_>>()
        .join(" ")
}