use std::fmt::Write as _;
use std::path::PathBuf;
use crate::cli::{Exit, grammar};
use crate::util::{print_stderr, print_stdout};
struct Flag {
name: &'static str,
required: bool,
purpose: &'static str,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Operation {
Transcribe,
Speak,
}
impl Operation {
const ALL: [Self; 2] = [Self::Transcribe, Self::Speak];
const fn word(self) -> &'static str {
match self {
Self::Transcribe => "transcribe",
Self::Speak => "speak",
}
}
const fn purpose(self) -> &'static str {
match self {
Self::Transcribe => "Turn a recording into text, printed on standard output",
Self::Speak => "Turn text into a spoken WAV file",
}
}
const fn usage(self) -> &'static str {
match self {
Self::Transcribe => "mahbot audio transcribe --input <file>",
Self::Speak => "mahbot audio speak --text <text> --output <path> [flags]",
}
}
const fn flags(self) -> &'static [Flag] {
match self {
Self::Transcribe => &[Flag {
name: "input",
required: true,
purpose: "The recording to turn into text — WAV, OGG/Opus or MP3, by name or by \
content",
}],
Self::Speak => &[
Flag {
name: "text",
required: true,
purpose: "The text to speak",
},
Flag {
name: "output",
required: true,
purpose: "Where to write the file: a name ending in '.wav', or an existing \
directory to have one named for you",
},
Flag {
name: "voice",
required: false,
purpose: "The voice to speak with — F1–F5 or M1–M5, with or without '.json'; \
the product's own voice when not given",
},
Flag {
name: "language",
required: false,
purpose: "The language to speak in — a code the engine knows ('en', 'ko', \
…), or 'na' for its language-agnostic default",
},
],
}
}
fn from_word(word: &str) -> Option<Self> {
Self::ALL
.into_iter()
.find(|operation| operation.word() == word)
}
}
enum Invocation {
Help,
OperationHelp(Operation),
Transcribe {
input: PathBuf,
},
Speak(Speak),
}
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
struct Speak {
text: String,
voice: Option<String>,
language: Option<String>,
output: PathBuf,
}
enum Tail {
Help,
Values(Vec<(&'static str, String)>),
}
fn parse_flags(operation: Operation, tokens: &[String]) -> Result<Tail, String> {
let flags = operation.flags();
let mut values: Vec<(&'static str, String)> = Vec::new();
let mut rest = tokens.iter();
while let Some(token) = rest.next() {
let (name, inline) = match grammar::read_token(token)? {
grammar::Token::Help => return Ok(Tail::Help),
grammar::Token::Flag { name, inline } => (name, inline),
};
let Some(flag) = flags.iter().find(|flag| flag.name == name) else {
return Err(grammar::unknown_flag(name, operation.word()));
};
if values.iter().any(|(given, _)| *given == flag.name) {
return Err(grammar::repeated_flag(flag.name));
}
let value = match inline {
Some(value) => value.to_string(),
None => rest
.next()
.cloned()
.ok_or_else(|| grammar::flag_value_missing(name))?,
};
values.push((flag.name, value));
}
Ok(Tail::Values(values))
}
fn parse(args: &[String]) -> Result<Invocation, String> {
let Some(word) = args.first() else {
return Err("no operation was given".to_string());
};
if grammar::is_help(word) {
return Ok(Invocation::Help);
}
let Some(operation) = Operation::from_word(word) else {
return Err(format!("unknown operation '{word}'"));
};
let values = match parse_flags(operation, &args[1..]) {
Ok(Tail::Help) => return Ok(Invocation::OperationHelp(operation)),
Ok(Tail::Values(values)) => values,
Err(reason) => return Err(reason),
};
let given = |name: &str| {
values
.iter()
.find(|(given, _)| *given == name)
.map(|(_, value)| value.clone())
};
if let Some(flag) = operation
.flags()
.iter()
.find(|flag| flag.required && given(flag.name).is_none())
{
return Err(format!(
"flag '--{}' is required for '{}'",
flag.name,
operation.word()
));
}
let required = |name: &str| given(name).expect("a required flag, checked just above");
match operation {
Operation::Transcribe => Ok(Invocation::Transcribe {
input: PathBuf::from(required("input")),
}),
Operation::Speak => Ok(Invocation::Speak(Speak {
text: required("text"),
output: PathBuf::from(required("output")),
voice: given("voice"),
language: given("language"),
})),
}
}
const ENGINE_HELP: &str = "\nA frontend onto the product's own speech engine: everything \
happens on this machine and nothing is sent anywhere. The engine needs the speech models \
under the product's own data folder and nothing else — not the product's settings, not its \
databases, and not the running product, so this works while it is stopped and running it \
while the product is up does not disturb it. The first use of an operation fetches the \
models it needs, once, saying so on standard error; if it cannot obtain them, it fails \
rather than trying again.\n\
\nThe product's voice switches govern what the app itself reads aloud and transcribes; \
these operations are explicit requests with their own voice, language and destination — \
they neither need those switches nor change them.\n";
const EXIT_CLASS_HELP: &str = "\nExit codes:\n 0 the operation was carried out\n 1 the engine \
could not do the work\n 2 the environment — there is no speech engine on this platform, \
or its models cannot be obtained\n 3 this command was misused — what was asked for cannot \
be done as asked\n";
const OUTPUT_HELP: &str = "\nOutput:\n stdout transcribe: the recognised text, and nothing at \
all when the recording holds no speech; speak: the path of the file, when the destination \
was a directory and this command named the file itself\n stderr everything this command \
itself has to say\n";
const PLATFORM_HELP: &str = "\nThe speech engine exists on macOS only: on a platform that has \
none, both operations say so and name it as the reason.\n\
\nThat is the one place where two of these classes meet, and which one is reported is \
settled the same way everywhere, because the words are read before anything else is. An \
invocation that is malformed — no operation, an operation no word names, an unknown or \
repeated flag, a required flag left without a value — is the caller's own misuse on every \
platform, since nothing about the engine was reached. Only once the invocation is well \
formed does the platform come into it: a platform with no engine is then the environment, \
and so is a value the engine would have judged, had there been one there to judge it.\n";
#[must_use]
fn top_help() -> String {
let mut out =
String::from("mahbot audio — the product's own speech engine on the command line\n");
out.push_str("\nUsage: mahbot audio <operation> [flags]\n\nOperations:\n");
let width = Operation::ALL
.iter()
.map(|operation| operation.word().len())
.max()
.unwrap_or_default();
for operation in Operation::ALL {
let _ = writeln!(
out,
" {:<width$} {}",
operation.word(),
operation.purpose()
);
}
out.push_str(
"\nA flag is `--<name> <value>` or `--<name>=<value>`, and its value is taken verbatim \
(so a text may begin with a dash). `mahbot audio <operation> -h` names one operation's \
flags in full.\n",
);
out.push_str(ENGINE_HELP);
out.push_str(EXIT_CLASS_HELP);
out.push_str(OUTPUT_HELP);
out.push_str(PLATFORM_HELP);
out
}
#[must_use]
fn operation_help(operation: Operation) -> String {
let mut out = String::new();
let _ = writeln!(
out,
"mahbot audio {} — {}",
operation.word(),
operation.purpose()
);
let _ = write!(out, "\nUsage: {}\n", operation.usage());
out.push_str("\nFlags:\n");
let flags = operation.flags();
let rows: Vec<(String, &str)> = flags
.iter()
.map(|flag| {
let required = if flag.required { " (required)" } else { "" };
(format!("--{} <value>{required}", flag.name), flag.purpose)
})
.chain(std::iter::once((
"-h, --help".to_string(),
"Print this help and exit",
)))
.collect();
let width = rows
.iter()
.map(|(spelling, _)| spelling.len())
.max()
.unwrap_or_default();
for (spelling, purpose) in rows {
let _ = writeln!(out, " {spelling:<width$} {purpose}");
}
out.push_str("\nA flag's value is the token right after it, taken verbatim.\n");
out.push_str(match operation {
Operation::Transcribe => {
"The product's own local recognition, and no language is named: the engine detects \
it. A recording the engine finds no speech in is not a failure — stdout then carries \
nothing at all, which is how a script tells it from an empty line. A recording \
longer than the engine will take fails, never a transcript with a part of it \
missing.\n"
}
Operation::Speak => {
"The file is written the way the product speaks: the same preparation of the text, \
the same splitting of long text at sentence boundaries, the same pause between the \
parts written into the audio. The destination is never overwritten, the directory \
it names must already exist, and nothing is written at all if any part of the text \
fails to render.\n"
}
});
out.push_str(ENGINE_HELP);
out.push_str(EXIT_CLASS_HELP);
out.push_str(OUTPUT_HELP);
out.push_str(PLATFORM_HELP);
out
}
pub async fn run(args: &[String]) -> i32 {
match parse(args) {
Ok(Invocation::Help) => {
print_stdout(&top_help());
Exit::Ok.code()
}
Ok(Invocation::OperationHelp(operation)) => {
print_stdout(&operation_help(operation));
Exit::Ok.code()
}
Ok(Invocation::Transcribe { input }) => engine::transcribe(&input).await,
Ok(Invocation::Speak(request)) => engine::speak(&request).await,
Err(reason) => misuse(&reason),
}
}
fn misuse(reason: &str) -> i32 {
print_stderr(&format!("mahbot audio: {reason}"));
print_stderr("run 'mahbot audio -h' for usage.");
Exit::Usage.code()
}
#[cfg(not(target_os = "macos"))]
fn no_engine() -> i32 {
print_stderr(&format!(
"mahbot audio: the speech engine is macOS-only — on {} there is none to run, so neither \
operation can be carried out here",
std::env::consts::OS
));
Exit::Environment.code()
}
#[cfg(not(target_os = "macos"))]
mod engine {
use super::{Speak, no_engine};
use std::path::Path;
#[expect(clippy::unused_async)] pub(super) async fn transcribe(_input: &Path) -> i32 {
no_engine()
}
#[expect(clippy::unused_async)] pub(super) async fn speak(_request: &Speak) -> i32 {
no_engine()
}
}
#[cfg(target_os = "macos")]
mod engine {
use super::{Speak, misuse};
use crate::audio::local_transcriber::{self, InferenceTimedOut};
use crate::audio::tts;
use crate::cli::Exit;
use crate::util::{print_stderr, print_stdout};
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
const CHOSEN_NAME_TRIES: usize = 3;
pub(super) async fn transcribe(input: &Path) -> i32 {
if let Some(code) = prepare() {
return code;
}
match std::fs::metadata(input) {
Ok(metadata) if metadata.is_file() => {}
Ok(_) => {
return misuse(&format!(
"'{}' is not a file to transcribe",
input.display()
));
}
Err(e) => return misuse(&cannot_read(input, &e)),
}
match local_transcriber::takes_audio(input) {
Err(e) => return misuse(&cannot_read(input, &e)),
Ok(true) => {}
Ok(false) => {
return misuse(&format!(
"'{}' is not a recording the local recogniser takes — it takes the names {}, \
and a name outside them only when the file's content is Ogg",
input.display(),
listed(
local_transcriber::AUDIO_FORMATS
.iter()
.map(|(name, _)| format!("'.{name}'")),
),
));
}
}
if let Err(e) = local_transcriber::ensure_ready_once(|dir| {
announce_fetch(dir, "recognition");
})
.await
{
print_stderr(&format!(
"mahbot audio: the recognition model cannot be obtained: {e:#}"
));
return Exit::Environment.code();
}
match local_transcriber::transcribe_file_async(input, local_transcriber::INFERENCE_TIMEOUT)
.await
{
Ok(text) => {
let text = text.trim();
if !text.is_empty() {
print_stdout(text);
}
Exit::Ok.code()
}
Err(e) if e.downcast_ref::<InferenceTimedOut>().is_some() => {
print_stderr(&format!(
"mahbot audio: the recording is longer than the local recogniser will take — \
it reached the engine's own {}-minute inference bound, and nothing was \
transcribed rather than a transcript with a part of it missing",
local_transcriber::INFERENCE_TIMEOUT.as_secs() / 60,
));
Exit::Failed.code()
}
Err(e) => {
print_stderr(&format!(
"mahbot audio: the recording could not be transcribed: {e:#}"
));
Exit::Failed.code()
}
}
}
pub(super) async fn speak(request: &Speak) -> i32 {
if let Some(code) = prepare() {
return code;
}
if request.text.trim().is_empty() {
return misuse("the text to speak says nothing — it is empty or only whitespace");
}
let Some(voice) = resolve_voice(request.voice.as_deref()) else {
return misuse(&format!(
"the speech engine has no voice '{}' — its voices are {}",
request.voice.as_deref().unwrap_or_default(),
listed(tts::ALL_VOICE_STYLE_NAMES),
));
};
let Some(language) = resolve_language(request.language.as_deref()) else {
return misuse(&format!(
"the speech engine has no language '{}' — it takes {}",
request.language.as_deref().unwrap_or_default(),
listed(tts::TTS_LANGUAGES),
));
};
let destination = match resolve_destination(&request.output) {
Ok(destination) => destination,
Err(reason) => return misuse(&reason),
};
let voice = match tts::ensure_ready_once(&voice, |dir| {
announce_fetch(dir, "speech synthesis");
})
.await
{
Ok(voice) => voice,
Err(e) => {
print_stderr(&format!(
"mahbot audio: the speech synthesis model cannot be obtained: {e:#}"
));
return Exit::Environment.code();
}
};
let wav = match tts::render_speech_wav(&request.text, voice, &language).await {
Ok(wav) => wav,
Err(e) => {
print_stderr(&format!(
"mahbot audio: nothing was written — the text could not be spoken: {e:#}"
));
return Exit::Failed.code();
}
};
match write_audio(&destination, &wav) {
Ok(Some(path)) => {
print_stdout(&path.display().to_string());
Exit::Ok.code()
}
Ok(None) => Exit::Ok.code(),
Err(reason) => misuse(&reason),
}
}
fn announce_fetch(dir: &Path, what: &str) {
print_stderr(&format!(
"mahbot audio: fetching the {what} model into {} — the product's own copy of it, \
fetched once",
dir.display()
));
}
fn prepare() -> Option<i32> {
match crate::config::default_config_dir() {
Ok(root) => {
if crate::config::CONFIG.try_storage_root().is_none() {
crate::config::CONFIG.set_storage_root(root);
}
None
}
Err(e) => {
print_stderr(&format!(
"mahbot audio: the product's data folder cannot be resolved: {e:#}"
));
Some(Exit::Environment.code())
}
}
}
pub(super) fn resolve_voice(named: Option<&str>) -> Option<String> {
let Some(named) = named else {
return Some(tts::DEFAULT_VOICE_NAME.to_string());
};
let asked = named.trim().to_ascii_lowercase();
let asked = bare_name(&asked);
tts::ALL_VOICE_STYLE_NAMES
.iter()
.find(|style| bare_name(style).eq_ignore_ascii_case(asked))
.map(|style| (*style).to_string())
}
fn bare_name(style: &str) -> &str {
style.strip_suffix(".json").unwrap_or(style)
}
pub(super) fn resolve_language(named: Option<&str>) -> Option<String> {
let Some(named) = named else {
return Some(crate::config::DEFAULT_TTS_LANGUAGE.to_string());
};
let asked = named.trim();
tts::TTS_LANGUAGES
.iter()
.find(|code| code.eq_ignore_ascii_case(asked))
.map(|code| (*code).to_string())
}
fn cannot_read(path: &Path, e: &std::io::Error) -> String {
format!("'{}' cannot be read: {e}", path.display())
}
pub(super) enum Destination {
Named(PathBuf),
Chosen(PathBuf),
}
pub(super) fn resolve_destination(output: &Path) -> Result<Destination, String> {
if output.is_dir() {
return Ok(Destination::Chosen(output.to_path_buf()));
}
if output
.extension()
.is_some_and(|extension| !extension.eq_ignore_ascii_case("wav"))
{
return Err(format!(
"'{}' is not a file this command can write — the spoken audio is uncompressed \
WAV, so name it '….wav', or name a directory to have one named for you",
output.display()
));
}
if output.extension().is_none() && !output.exists() {
return Err(format!(
"'{}' is not there — this command creates nothing, so name a '.wav' file in a \
directory that is already there, or an existing directory to have one named for \
you",
output.display()
));
}
if let Some(parent) = output.parent()
&& !parent.as_os_str().is_empty()
&& !parent.is_dir()
{
return Err(if parent.exists() {
format!(
"'{}' cannot be written: '{}' is not a directory, and this command does not \
create one",
output.display(),
parent.display()
)
} else {
format!(
"the destination directory {} does not exist — this command does not create it",
parent.display()
)
});
}
if output.exists() {
return Err(already_exists(output));
}
Ok(Destination::Named(output.to_path_buf()))
}
fn already_exists(path: &Path) -> String {
format!(
"{} already exists — this command never overwrites a file",
path.display()
)
}
fn cannot_write(path: &Path, e: &std::io::Error) -> String {
format!("{} could not be written: {e}", path.display())
}
fn write_audio(destination: &Destination, wav: &[u8]) -> Result<Option<PathBuf>, String> {
match destination {
Destination::Named(path) => match write_new(path, wav) {
Ok(()) => Ok(None),
Err(e) if e.kind() == ErrorKind::AlreadyExists => Err(already_exists(path)),
Err(e) => Err(cannot_write(path, &e)),
},
Destination::Chosen(dir) => {
for _ in 0..CHOSEN_NAME_TRIES {
let path = dir.join(format!("mahbot-speech-{}.wav", crate::generate_id()));
match write_new(&path, wav) {
Ok(()) => return Ok(Some(path)),
Err(e) if e.kind() == ErrorKind::AlreadyExists => {}
Err(e) => return Err(cannot_write(&path, &e)),
}
}
Err(format!("no free name could be picked in {}", dir.display()))
}
}
}
fn write_new(path: &Path, wav: &[u8]) -> std::io::Result<()> {
use std::io::Write as _;
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)?;
if let Err(e) = file.write_all(wav) {
drop(file);
let _ = std::fs::remove_file(path);
return Err(e);
}
Ok(())
}
fn listed<I, S>(names: I) -> String
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let names: Vec<String> = names
.into_iter()
.map(|name| name.as_ref().to_string())
.collect();
match names.split_last() {
Some((last, [])) => last.clone(),
Some((last, head)) => format!("{} or {last}", head.join(", ")),
None => String::new(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn flag_tails() {
let values = |tokens: &[&str]| match parse_flags(Operation::Speak, &owned(tokens)) {
Ok(Tail::Values(values)) => Ok(values),
Ok(Tail::Help) => Err("help".to_string()),
Err(reason) => Err(reason),
};
assert_eq!(
values(&["--text", "-h", "--output", "a.wav"]).unwrap(),
vec![("text", "-h".to_string()), ("output", "a.wav".to_string())],
"a value is taken verbatim, even one that looks like a flag"
);
assert_eq!(
values(&["--text=hi", "--voice=M1"]).unwrap(),
vec![("text", "hi".to_string()), ("voice", "M1".to_string())]
);
assert!(values(&["--nope", "x"]).is_err_and(|r| r.contains("--nope")));
assert!(
values(&["--text", "a", "--text", "b"]).is_err_and(|r| r.contains("more than once"))
);
assert!(values(&["--text"]).is_err_and(|r| r.contains("needs a value")));
assert!(values(&["text"]).is_err_and(|r| r.contains("--flag")));
assert_eq!(values(&["--text", "hi", "-h"]).unwrap_err(), "help");
}
#[test]
fn required_flags_are_named() {
let invocation = |tokens: &[&str]| parse(&owned(tokens));
assert!(invocation(&["speak", "--text", "hi"]).is_err_and(|r| r.contains("--output")));
assert!(invocation(&["transcribe"]).is_err_and(|r| r.contains("--input")));
assert!(invocation(&["frobnicate"]).is_err_and(|r| r.contains("unknown operation")));
assert!(invocation(&[]).is_err(), "no operation is misuse");
}
fn owned(tokens: &[&str]) -> Vec<String> {
tokens.iter().map(|token| (*token).to_string()).collect()
}
#[cfg(target_os = "macos")]
#[test]
fn voice_and_language_names() {
use super::engine::{resolve_language, resolve_voice};
assert_eq!(resolve_voice(None), Some("M1.json".to_string()));
for asked in ["M1", "m1", "M1.json", "m1.JSON"] {
assert_eq!(
resolve_voice(Some(asked)),
Some("M1.json".to_string()),
"{asked} names the product's M1.json"
);
}
assert_eq!(resolve_voice(Some("F5.json")), Some("F5.json".to_string()));
assert_eq!(resolve_voice(Some("M6")), None);
assert_eq!(resolve_voice(Some("")), None);
assert_eq!(resolve_voice(Some("M1.json.json")), None);
assert_eq!(resolve_language(None), Some("na".to_string()));
assert_eq!(resolve_language(Some("EN")), Some("en".to_string()));
assert_eq!(resolve_language(Some(" na ")), Some("na".to_string()));
assert_eq!(resolve_language(Some("xx")), None);
}
#[cfg(target_os = "macos")]
#[test]
fn destination_rules() {
use super::engine::{Destination, resolve_destination};
let dir = tempfile::tempdir().unwrap();
let existing = dir.path().join("taken.wav");
std::fs::write(&existing, b"x").unwrap();
assert!(matches!(
resolve_destination(dir.path()),
Ok(Destination::Chosen(_))
));
assert!(matches!(
resolve_destination(&dir.path().join("fresh.wav")),
Ok(Destination::Named(_))
));
assert!(resolve_destination(&existing).is_err_and(|r| r.contains("never overwrites")));
std::fs::write(dir.path().join("taken.ogg"), b"x").unwrap();
for unsupported in ["absent.ogg", "taken.ogg"] {
assert!(
resolve_destination(&dir.path().join(unsupported)).is_err_and(|reason| {
reason.contains("is not a file this command can write")
&& !reason.contains("is not there")
}),
"{unsupported} names a container the product does not write, and must be refused \
for that whether or not it is there"
);
}
for absent_dir in ["no-such-dir", "no-extension"] {
assert!(
resolve_destination(&dir.path().join(absent_dir)).is_err_and(|reason| {
reason.contains("is not there") && reason.contains(".wav")
}),
"{absent_dir} is an absent directory, not a file name"
);
}
assert!(
resolve_destination(&dir.path().join("absent").join("out.wav"))
.is_err_and(|r| r.contains("does not exist"))
);
assert!(
resolve_destination(&existing.join("out.wav")).is_err_and(|r| {
r.contains("is not a directory") && !r.contains("does not exist")
})
);
}
}