diff --git a/Cargo.toml b/Cargo.toml index cb8d5c1..8175876 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ speech-dispatcher = "0.4" [target.'cfg(target_os = "macos")'.dependencies] cocoa-foundation = "0.1" +libc = "0.2" objc = "0.2" [target.wasm32-unknown-unknown.dependencies] diff --git a/build.rs b/build.rs index d32fa79..eb44096 100644 --- a/build.rs +++ b/build.rs @@ -10,5 +10,6 @@ fn main() { if std::env::var("TARGET").unwrap().contains("-apple") { println!("cargo:rustc-link-lib=framework=AppKit"); + println!("cargo:rustc-link-lib=framework=AVFoundation"); } } diff --git a/src/backends/appkit.rs b/src/backends/appkit.rs index 8bb8db5..6bfbf06 100644 --- a/src/backends/appkit.rs +++ b/src/backends/appkit.rs @@ -13,7 +13,7 @@ pub struct AppKit(*mut Object, *mut Object); impl AppKit { pub fn new() -> Self { - info!("Initializing NSSpeechSynthesizer backend"); + info!("Initializing AppKit backend"); unsafe { let obj: *mut Object = msg_send![class!(NSSpeechSynthesizer), new]; let mut decl = diff --git a/src/backends/av_foundation.rs b/src/backends/av_foundation.rs new file mode 100644 index 0000000..13ea05c --- /dev/null +++ b/src/backends/av_foundation.rs @@ -0,0 +1,145 @@ +#[cfg(target_os = "macos")] +#[link(name = "AVFoundation", kind = "framework")] +use cocoa_foundation::base::{id, nil}; +use cocoa_foundation::foundation::NSString; +use log::{info, trace}; +use objc::runtime::*; +use objc::*; + +use crate::{Backend, Error, Features}; + +pub struct AvFoundation { + synth: *mut Object, + rate: f32, + volume: f32, + pitch: f32, +} + +impl AvFoundation { + pub fn new() -> Self { + info!("Initializing AVFoundation backend"); + unsafe { + let synth: *mut Object = msg_send![class!(AVSpeechSynthesizer), new]; + AvFoundation { + synth: synth, + rate: 0.5, + volume: 1., + pitch: 1., + } + } + } +} + +impl Backend for AvFoundation { + fn supported_features(&self) -> Features { + Features { + stop: true, + rate: true, + pitch: true, + volume: true, + is_speaking: true, + } + } + + fn speak(&mut self, text: &str, interrupt: bool) -> Result<(), Error> { + trace!("speak({}, {})", text, interrupt); + if interrupt { + self.stop()?; + } + unsafe { + let str = NSString::alloc(nil).init_str(text); + let utterance: id = msg_send![class!(AVSpeechUtterance), alloc]; + let _: () = msg_send![utterance, initWithString: str]; + let _: () = msg_send![utterance, setRate: self.rate]; + let _: () = msg_send![utterance, setVolume: self.volume]; + let _: () = msg_send![utterance, setPitchMultiplier: self.pitch]; + let _: () = msg_send![self.synth, speakUtterance: utterance]; + } + Ok(()) + } + + fn stop(&mut self) -> Result<(), Error> { + trace!("stop()"); + unsafe { + let _: () = msg_send![self.synth, stopSpeakingAtBoundary: 0]; + } + Ok(()) + } + + fn min_rate(&self) -> f32 { + 0.1 + } + + fn max_rate(&self) -> f32 { + 2. + } + + fn normal_rate(&self) -> f32 { + 0.5 + } + + fn get_rate(&self) -> Result { + Ok(self.rate) + } + + fn set_rate(&mut self, rate: f32) -> Result<(), Error> { + trace!("set_rate({})", rate); + self.rate = rate; + Ok(()) + } + + fn min_pitch(&self) -> f32 { + 0.5 + } + + fn max_pitch(&self) -> f32 { + 2.0 + } + + fn normal_pitch(&self) -> f32 { + 1.0 + } + + fn get_pitch(&self) -> Result { + Ok(self.pitch) + } + + fn set_pitch(&mut self, pitch: f32) -> Result<(), Error> { + self.pitch = pitch; + Ok(()) + } + + fn min_volume(&self) -> f32 { + 0. + } + + fn max_volume(&self) -> f32 { + 1. + } + + fn normal_volume(&self) -> f32 { + 1. + } + + fn get_volume(&self) -> Result { + Ok(self.volume) + } + + fn set_volume(&mut self, volume: f32) -> Result<(), Error> { + self.volume = volume; + Ok(()) + } + + fn is_speaking(&self) -> Result { + let is_speaking: i8 = unsafe { msg_send![self.synth, isSpeaking] }; + Ok(is_speaking == YES) + } +} + +impl Drop for AvFoundation { + fn drop(&mut self) { + unsafe { + let _: Object = msg_send![self.synth, release]; + } + } +} diff --git a/src/backends/mod.rs b/src/backends/mod.rs index 792a9b4..7971db1 100644 --- a/src/backends/mod.rs +++ b/src/backends/mod.rs @@ -13,6 +13,9 @@ mod web; #[cfg(target_os = "macos")] mod appkit; +#[cfg(target_os = "macos")] +mod av_foundation; + #[cfg(target_os = "linux")] pub use self::speech_dispatcher::*; @@ -24,3 +27,6 @@ pub use self::web::*; #[cfg(target_os = "macos")] pub use self::appkit::*; + +#[cfg(target_os = "macos")] +pub use self::av_foundation::*; diff --git a/src/lib.rs b/src/lib.rs index 44d49ae..bd30af8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,8 +8,14 @@ * * WebAssembly */ -use std::boxed::Box; +use std::{boxed::Box, ffi::CStr}; +#[cfg(target_os = "macos")] +use cocoa_foundation::base::id; +#[cfg(target_os = "macos")] +use libc::c_char; +#[cfg(target_os = "macos")] +use objc::{class, msg_send, sel, sel_impl}; use thiserror::Error; mod backends; @@ -25,6 +31,8 @@ pub enum Backends { WinRT, #[cfg(target_os = "macos")] AppKit, + #[cfg(target_os = "macos")] + AvFoundation, } pub struct Features { @@ -110,6 +118,8 @@ impl TTS { } #[cfg(target_os = "macos")] Backends::AppKit => Ok(TTS(Box::new(backends::AppKit::new()))), + #[cfg(target_os = "macos")] + Backends::AvFoundation => Ok(TTS(Box::new(backends::AvFoundation::new()))), } } @@ -125,7 +135,23 @@ impl TTS { #[cfg(target_arch = "wasm32")] let tts = TTS::new(Backends::Web); #[cfg(target_os = "macos")] - let tts = TTS::new(Backends::AppKit); + let tts = unsafe { + // Needed because the Rust NSProcessInfo structs report bogus values, and I don't want to pull in a full bindgen stack. + let pi: id = msg_send![class!(NSProcessInfo), new]; + let version: id = msg_send![pi, operatingSystemVersionString]; + let str: *const c_char = msg_send![version, UTF8String]; + let str = CStr::from_ptr(str); + let str = str.to_string_lossy(); + let version: Vec<&str> = str.split(" ").collect(); + let version = version[1]; + let version_parts: Vec<&str> = version.split(".").collect(); + let minor_version: i8 = version_parts[1].parse().unwrap(); + if minor_version >= 14 { + TTS::new(Backends::AvFoundation) + } else { + TTS::new(Backends::AppKit) + } + }; tts }