arci_speak_cmd/
lib.rs

1#![doc = include_str!("../README.md")]
2#![warn(missing_docs, rust_2018_idioms)]
3// buggy: https://github.com/rust-lang/rust-clippy/issues?q=is%3Aissue+derive_partial_eq_without_eq
4#![allow(clippy::derive_partial_eq_without_eq)]
5
6use std::{io, process::Command};
7
8use arci::{Speaker, WaitFuture};
9
10/// A [`Speaker`] implementation using a local command.
11///
12/// Currently, this uses the following command:
13///
14/// - On macOS, use `say` command.
15/// - On Windows, call [SAPI] via PowerShell.
16/// - On others, use `espeak` command.
17///
18/// **Disclaimer**: These commands might change over time.
19///
20/// [SAPI]: https://en.wikipedia.org/wiki/Microsoft_Speech_API
21#[derive(Debug, Default)]
22#[non_exhaustive]
23pub struct LocalCommand {}
24
25impl LocalCommand {
26    /// Creates a new `LocalCommand`.
27    pub fn new() -> Self {
28        Self::default()
29    }
30}
31
32impl Speaker for LocalCommand {
33    fn speak(&self, message: &str) -> Result<WaitFuture, arci::Error> {
34        let (sender, receiver) = tokio::sync::oneshot::channel();
35        let message = message.to_string();
36
37        std::thread::spawn(move || {
38            let res = run_local_command(&message).map_err(|e| arci::Error::Other(e.into()));
39            let _ = sender.send(res);
40        });
41
42        Ok(WaitFuture::new(async move {
43            receiver.await.map_err(|e| arci::Error::Other(e.into()))?
44        }))
45    }
46}
47
48#[cfg(not(windows))]
49fn run_local_command(message: &str) -> io::Result<()> {
50    #[cfg(not(target_os = "macos"))]
51    const CMD_NAME: &str = "espeak";
52    #[cfg(target_os = "macos")]
53    const CMD_NAME: &str = "say";
54
55    let mut cmd = Command::new(CMD_NAME);
56    let status = cmd.arg(message).status()?;
57
58    if status.success() {
59        Ok(())
60    } else {
61        Err(io::Error::new(
62            io::ErrorKind::Other,
63            format!("failed to run `{CMD_NAME}` with message {message:?}"),
64        ))
65    }
66}
67
68#[cfg(windows)]
69fn run_local_command(message: &str) -> io::Result<()> {
70    // TODO: Ideally, it would be more efficient to use SAPI directly via winapi or something.
71    // https://stackoverflow.com/questions/1040655/ms-speech-from-command-line
72    let cmd = format!("PowerShell -Command \"Add-Type –AssemblyName System.Speech; (New-Object System.Speech.Synthesis.SpeechSynthesizer).Speak('{message}');\"");
73    let status = Command::new("powershell").arg(cmd).status()?;
74
75    if status.success() {
76        Ok(())
77    } else {
78        Err(io::Error::new(
79            io::ErrorKind::Other,
80            format!("failed to run `powershell` with message {message:?}"),
81        ))
82    }
83}
84
85#[cfg(test)]
86mod test {
87    use super::*;
88
89    #[test]
90    fn test_local_command() {
91        let local_command = LocalCommand::new();
92
93        let wait = local_command.speak("message");
94
95        assert!(wait.is_ok());
96    }
97}