diff --git a/Cargo.toml b/Cargo.toml index 9c7cf09..6ebb8e8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,5 +8,4 @@ edition = "2021" repository = "https://github.com/pykeio/ssml" [dependencies] -anyhow = "1.0" dyn-clone = "1.0" diff --git a/README.md b/README.md index 6f467d8..5852dd1 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,9 @@ Currently, `ssml-rs` focuses on supporting the subsets of SSML supported by majo let doc = ssml::speak(Some("en-US"), ["Hello, world!"]); use ssml::Serialize; -let str = doc.serialize_to_string(ssml::Flavor::AmazonPolly)?; -assert_eq!(str, r#"Hello, world!"#); +let str = doc.serialize_to_string(&ssml::SerializeOptions::default().flavor(Flavor::AmazonPolly))?; +assert_eq!( + str, + r#"Hello, world!"# +); ``` diff --git a/src/audio.rs b/src/audio.rs index 7f8c2b9..a2842cc 100644 --- a/src/audio.rs +++ b/src/audio.rs @@ -1,9 +1,6 @@ -use std::io::Write; - use crate::{ - speak::Element, unit::{Decibels, TimeDesignation}, - util, Flavor, Serialize + util, Element, Flavor, Serialize, SerializeOptions, XmlWriter }; /// Specify repeating an [`Audio`] element's playback for a certain number of times, or for a determined duration. @@ -148,52 +145,48 @@ impl Audio { } impl Serialize for Audio { - fn serialize(&self, writer: &mut W, flavor: Flavor) -> anyhow::Result<()> { - writer.write_all(b" elements to have a valid `src`."))?; + fn serialize_xml(&self, writer: &mut XmlWriter<'_>, options: &SerializeOptions) -> crate::Result<()> { + if options.perform_checks { + if options.flavor == Flavor::GoogleCloudTextToSpeech && self.src.is_empty() { + // https://cloud.google.com/text-to-speech/docs/ssml#attributes_1 + return Err(crate::error!("GCTTS requires ")?; + util::serialize_elements(writer, &self.alternate, options)?; + Ok(()) + })?; Ok(()) } } @@ -210,11 +203,16 @@ pub fn audio(src: impl ToString) -> Audio { #[cfg(test)] mod tests { use super::{Audio, AudioRepeat}; - use crate::{Flavor, Serialize}; + use crate::{Serialize, SerializeOptions}; #[test] fn non_negative_speed() { - assert!(Audio::default().with_speed(-1.0).serialize_to_string(Flavor::Generic).is_err()); + assert!( + Audio::default() + .with_speed(-1.0) + .serialize_to_string(&SerializeOptions::default()) + .is_err() + ); } #[test] @@ -222,7 +220,7 @@ mod tests { assert!( Audio::default() .with_repeat(AudioRepeat::Times(-1.0)) - .serialize_to_string(Flavor::Generic) + .serialize_to_string(&SerializeOptions::default()) .is_err() ); } diff --git a/src/custom.rs b/src/custom.rs deleted file mode 100644 index c8121d0..0000000 --- a/src/custom.rs +++ /dev/null @@ -1,33 +0,0 @@ -use std::fmt::Debug; - -use dyn_clone::DynClone; - -use crate::{Element, Flavor, Serialize}; - -pub trait CustomElement: Debug + DynClone + Send { - /// Serialize this element into a string of XML. - /// - /// See [`crate::util`] for serialization utilities. - fn serialize_to_string(&self, flavor: Flavor) -> anyhow::Result; - - fn tag_name(&self) -> Option<&str> { - None - } - - fn children(&self) -> Option<&Vec> { - None - } -} - -dyn_clone::clone_trait_object!(CustomElement); - -impl Serialize for Box { - fn serialize(&self, writer: &mut W, flavor: Flavor) -> anyhow::Result<()> { - writer.write_all(CustomElement::serialize_to_string(self.as_ref(), flavor)?.as_bytes())?; - Ok(()) - } - - fn serialize_to_string(&self, flavor: Flavor) -> anyhow::Result { - CustomElement::serialize_to_string(self.as_ref(), flavor) - } -} diff --git a/src/element.rs b/src/element.rs new file mode 100644 index 0000000..6543269 --- /dev/null +++ b/src/element.rs @@ -0,0 +1,156 @@ +use std::fmt::Debug; + +use dyn_clone::DynClone; + +use crate::{Audio, Meta, Serialize, SerializeOptions, Text, Voice, XmlWriter}; + +macro_rules! el { + ( + $(#[$outer:meta])* + pub enum $name:ident { + $( + $(#[$innermeta:meta])* + $variant:ident($inner:ty) + ),* + } + ) => { + $(#[$outer])* + pub enum $name { + $( + $(#[$innermeta])* + $variant($inner) + ),* + } + + $(impl From<$inner> for $name { + fn from(val: $inner) -> $name { + $name::$variant(val) + } + })* + + impl $crate::Serialize for $name { + fn serialize_xml(&self, writer: &mut $crate::XmlWriter<'_>, options: &$crate::SerializeOptions) -> crate::Result<()> { + match self { + $($name::$variant(inner) => inner.serialize_xml(writer, options),)* + } + } + } + }; +} + +el! { + /// Represents all SSML elements. + #[derive(Clone, Debug)] + #[non_exhaustive] + pub enum Element { + Text(Text), + Audio(Audio), + Voice(Voice), + Meta(Meta), + /// A dyn element can be used to implement your own custom elements outside of the `ssml` crate. See + /// [`DynElement`] for more information and examples. + Dyn(Box) + // Break(BreakElement), + // Emphasis(EmphasisElement), + // Lang(LangElement), + // Mark(MarkElement), + // Paragraph(ParagraphElement), + // Phoneme(PhonemeElement), + // Prosody(ProsodyElement), + // SayAs(SayAsElement), + // Sub(SubElement), + // Sentence(SentenceElement), + // Voice(VoiceElement), + // Word(WordElement) + } +} + +impl From for Element { + fn from(value: T) -> Self { + Element::Text(Text(value.to_string())) + } +} + +/// A dynamic element which can be used to implement non-standard SSML elements outside of the `ssml` crate. +/// +/// ``` +/// use ssml::{DynElement, Element, Serialize, SerializeOptions, XmlWriter}; +/// +/// #[derive(Debug, Clone)] +/// pub struct TomfooleryElement { +/// value: f32, +/// children: Vec +/// } +/// +/// impl TomfooleryElement { +/// // Increase the tomfoolery level of a section of elements. +/// // ... +/// pub fn new, I: IntoIterator>(value: f32, elements: I) -> Self { +/// Self { +/// value, +/// children: elements.into_iter().map(|f| f.into()).collect() +/// } +/// } +/// +/// // not required, but makes your code much cleaner! +/// pub fn into_dyn(self) -> Element { +/// Element::Dyn(Box::new(self)) +/// } +/// } +/// +/// impl DynElement for TomfooleryElement { +/// fn serialize_xml(&self, writer: &mut XmlWriter<'_>, options: &SerializeOptions) -> ssml::Result<()> { +/// writer.element("tomfoolery", |writer| { +/// writer.attr("influence", self.value.to_string())?; +/// ssml::util::serialize_elements(writer, &self.children, options) +/// }) +/// } +/// } +/// +/// # fn main() -> ssml::Result<()> { +/// let doc = ssml::speak( +/// Some("en-US"), +/// [TomfooleryElement::new(2.0, ["Approaching dangerous levels of tomfoolery!"]).into_dyn()] +/// ); +/// let str = doc.serialize_to_string(&ssml::SerializeOptions::default().pretty())?; +/// assert_eq!( +/// str, +/// r#" +/// +/// Approaching dangerous levels of tomfoolery! +/// +/// "# +/// ); +/// # Ok(()) +/// # } +/// ``` +pub trait DynElement: Debug + DynClone + Send { + /// Serialize this dynamic element into an [`XmlWriter`]. + /// + /// See [`Serialize::serialize_xml`] for more information. + fn serialize_xml(&self, writer: &mut XmlWriter<'_>, options: &SerializeOptions) -> crate::Result<()>; + + /// An optional tag representing this dynamic element. + fn tag_name(&self) -> Option<&str> { + None + } + + /// If this element has children, returns a reference to the vector containing the element's children. + fn children(&self) -> Option<&Vec> { + None + } + + /// If this element has children, returns a mutable reference to the vector containing the element's children. + fn children_mut(&mut self) -> Option<&mut Vec> { + None + } +} + +dyn_clone::clone_trait_object!(DynElement); + +impl Serialize for Box { + fn serialize_xml(&self, writer: &mut XmlWriter<'_>, options: &SerializeOptions) -> crate::Result<()> { + DynElement::serialize_xml(self.as_ref(), writer, options)?; + Ok(()) + } +} diff --git a/src/error.rs b/src/error.rs index f5aaf62..1a20b79 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,22 +1,57 @@ -use std::{error::Error, fmt::Display}; +use std::{error::Error as StdError, fmt::Display, io, str::Utf8Error}; + +use crate::{DecibelsError, TimeDesignationError}; #[derive(Debug)] -pub(crate) struct GenericError(pub String); +#[non_exhaustive] +pub enum Error { + IoError(io::Error), + TimeDesignationError(TimeDesignationError), + DecibelsError(DecibelsError), + AttributesInChildContext, + Generic(String), + Utf8Error(Utf8Error) +} + +unsafe impl Send for Error {} -impl Display for GenericError { +macro_rules! impl_from { + ($($variant:ident => $t:ty),*) => { + $(impl From<$t> for Error { + fn from(e: $t) -> Self { + Error::$variant(e) + } + })* + }; +} + +impl_from! { + IoError => io::Error, Utf8Error => Utf8Error, TimeDesignationError => TimeDesignationError, DecibelsError => DecibelsError +} + +impl Display for Error { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.0) + match self { + Error::IoError(e) => e.fmt(f), + Error::Utf8Error(e) => e.fmt(f), + Error::TimeDesignationError(e) => e.fmt(f), + Error::DecibelsError(e) => e.fmt(f), + Error::AttributesInChildContext => f.write_str("invalid ordering: attempted to write attributes after writing children"), + Error::Generic(s) => f.write_str(s) + } } } -impl Error for GenericError {} +impl StdError for Error {} + +pub type Result = std::result::Result; macro_rules! error { ($m:literal) => { - $crate::GenericError(format!($m)) + $crate::Error::Generic(format!($m)) }; ($fmt:expr, $($arg:tt)*) => { - $crate::GenericError(format!($fmt, $($arg)*)) + $crate::Error::Generic(format!($fmt, $($arg)*)) }; } pub(crate) use error; diff --git a/src/lib.rs b/src/lib.rs index 5c59217..f5c31a0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,10 +11,15 @@ //! //! ``` //! use ssml::Serialize; -//! # fn main() -> anyhow::Result<()> { +//! # fn main() -> ssml::Result<()> { //! # let doc = ssml::speak(Some("en-US"), ["Hello, world!"]); -//! let str = doc.serialize_to_string(ssml::Flavor::AmazonPolly)?; -//! assert_eq!(str, r#"Hello, world!"#); +//! let str = doc.serialize_to_string(&ssml::SerializeOptions::default().pretty())?; +//! assert_eq!( +//! str, +//! r#" +//! Hello, world! +//! "# +//! ); //! # Ok(()) //! # } //! ``` @@ -24,7 +29,7 @@ use std::{fmt::Debug, io::Write}; mod audio; -mod custom; +mod element; mod error; pub mod mstts; mod speak; @@ -34,14 +39,18 @@ pub mod util; pub mod visit; pub mod visit_mut; mod voice; +mod xml; -pub(crate) use self::error::{error, GenericError}; +pub(crate) use self::error::error; pub use self::{ audio::{audio, Audio, AudioRepeat}, - speak::{speak, Element, Speak}, + element::{DynElement, Element}, + error::{Error, Result}, + speak::{speak, Speak}, text::{text, Text}, unit::{Decibels, DecibelsError, TimeDesignation, TimeDesignationError}, - voice::{voice, Voice, VoiceConfig, VoiceGender} + voice::{voice, Voice, VoiceConfig, VoiceGender}, + xml::XmlWriter }; /// Vendor-specific flavor of SSML. Specifying this can be used to enable compatibility checks & add additional @@ -70,15 +79,75 @@ pub enum Flavor { PykeSongbird } +/// Configuration for elements that support [`Serialize`]. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct SerializeOptions { + /// The flavor of SSML to output; see [`Flavor`]. When `perform_checks` is enabled (which it is by default), this + /// can help catch compatibility issues with different speech synthesis providers. + pub flavor: Flavor, + /// Whether or not to format the outputted SSML in a human-readable format. + /// + /// Generally, this should only be used for debugging. Some providers may charge per SSML character (not just spoken + /// character), so enabling this option in production may significantly increase costs. + pub pretty: bool, + /// Whether or not to perform compatibility checks with the chosen flavor. This is enabled by default. + pub perform_checks: bool +} + +impl Default for SerializeOptions { + fn default() -> Self { + SerializeOptions { + flavor: Flavor::Generic, + pretty: false, + perform_checks: true + } + } +} + +impl SerializeOptions { + pub fn min(mut self) -> Self { + self.pretty = false; + self + } + + pub fn pretty(mut self) -> Self { + self.pretty = true; + self + } + + pub fn flavor(mut self, flavor: Flavor) -> Self { + self.flavor = flavor; + self + } + + pub fn perform_checks(mut self) -> Self { + self.perform_checks = true; + self + } + + pub fn no_checks(mut self) -> Self { + self.perform_checks = false; + self + } +} + /// Trait to support serializing SSML elements. pub trait Serialize { - /// Serialize this SSML element into a [`Write`]r. - fn serialize(&self, writer: &mut W, flavor: Flavor) -> anyhow::Result<()>; + /// Serialize this SSML element into an `std` [`Write`]r. + fn serialize(&self, writer: &mut W, options: &SerializeOptions) -> crate::Result<()> { + let mut writer = XmlWriter::new(writer, options.pretty); + self.serialize_xml(&mut writer, options)?; + Ok(()) + } + + /// Serialize this SSML element into an [`XmlWriter`]. + fn serialize_xml(&self, writer: &mut XmlWriter<'_>, options: &SerializeOptions) -> crate::Result<()>; /// Serialize this SSML element into a string. - fn serialize_to_string(&self, flavor: Flavor) -> anyhow::Result { + fn serialize_to_string(&self, options: &SerializeOptions) -> crate::Result { let mut write = Vec::new(); - self.serialize(&mut write, flavor)?; + self.serialize(&mut write, options)?; Ok(std::str::from_utf8(&write)?.to_owned()) } } @@ -115,12 +184,18 @@ impl Meta { } impl Serialize for Meta { - fn serialize(&self, writer: &mut W, flavor: Flavor) -> anyhow::Result<()> { - if let Some(flavors) = self.restrict_flavor.as_ref() { - if !flavors.iter().any(|f| f == &flavor) { - anyhow::bail!("{} cannot be used with {flavor:?}", if let Some(name) = &self.name { name } else { "this meta element" }); + fn serialize_xml(&self, writer: &mut XmlWriter<'_>, options: &SerializeOptions) -> crate::Result<()> { + if options.perform_checks { + if let Some(flavors) = self.restrict_flavor.as_ref() { + if !flavors.iter().any(|f| f == &options.flavor) { + return Err(crate::error!( + "{} cannot be used with {:?}", + if let Some(name) = &self.name { name } else { "this meta element" }, + options.flavor + )); + } } } - Ok(writer.write_all(self.raw.as_bytes())?) + writer.raw(&self.raw) } } diff --git a/src/mstts/express.rs b/src/mstts/express.rs index a40e177..00a30af 100644 --- a/src/mstts/express.rs +++ b/src/mstts/express.rs @@ -1,9 +1,7 @@ -use std::io::Write; - -use crate::{custom::CustomElement, util, Element, Flavor}; +use crate::{util, DynElement, Element, Flavor, SerializeOptions, XmlWriter}; /// A generic expression for use in [`Express`]. Contains the name of the expression and the intensity/degree (default -/// `1.0`) +/// `1.0`). #[derive(Debug, Clone, Copy)] pub struct Expression(&'static str, f32); @@ -11,6 +9,10 @@ macro_rules! define_expressions { ($($(#[$outer:meta])* $x:ident => $e:expr),*) => { $( $(#[$outer])* + /// + /// Some voices may not support this expression. See [the Azure docs][ms] for more information. + /// + /// [ms]: https://learn.microsoft.com/en-us/azure/ai-services/speech-service/language-support?tabs=tts#voice-styles-and-roles pub struct $x; impl From<$x> for Expression { @@ -113,9 +115,10 @@ define_expressions! { /// Change the speaking style for part of an SSML document, in ACSS/MSTTS. /// -/// Only some neural voices support [`Express`], see [the Azure docs][ms] for more information. +/// Not all neural voices support all expressions. See [the Azure docs][ms] for more information on which voices support +/// which expressions. /// -/// [ms]: https://learn.microsoft.com/en-us/azure/ai-services/speech-service/language-support?tabs=tts#voice-styles-and-roles] +/// [ms]: https://learn.microsoft.com/en-us/azure/ai-services/speech-service/language-support?tabs=tts#voice-styles-and-roles #[derive(Debug, Clone)] pub struct Express { expression: Expression, @@ -126,21 +129,27 @@ impl Express { /// Creates a new [`Express`] section to modify the speaking style of a section of elements. /// /// ``` - /// # use ssml::{Flavor, Serialize}; + /// # use ssml::Serialize; /// use ssml::mstts; /// - /// # fn main() -> anyhow::Result<()> { + /// # fn main() -> ssml::Result<()> { /// let doc = ssml::speak( /// Some("en-US"), /// [ssml::voice( /// "en-US-JaneNeural", - /// [mstts::express(mstts::express::Cheerful.with_degree(0.5), ["Good morning!"]).into_custom()] + /// [mstts::express(mstts::express::Cheerful.with_degree(0.5), ["Good morning!"]).into_dyn()] /// )] /// ); /// /// assert_eq!( - /// doc.serialize_to_string(Flavor::MicrosoftAzureCognitiveSpeechServices)?, - /// r#"Good morning!"# + /// doc.serialize_to_string(&ssml::SerializeOptions::default().flavor(ssml::Flavor::MicrosoftAzureCognitiveSpeechServices).pretty())?, + /// r#" + /// + /// + /// Good morning! + /// + /// + /// "# /// ); /// # Ok(()) /// # } @@ -172,26 +181,23 @@ impl Express { &mut self.children } - /// Converts this element into an [`Element::Custom`]. - pub fn into_custom(self) -> Element { - Element::Custom(Box::new(self)) + /// Converts this element into an [`Element::Dyn`]. + pub fn into_dyn(self) -> Element { + Element::Dyn(Box::new(self)) } } -impl CustomElement for Express { - fn serialize_to_string(&self, flavor: Flavor) -> anyhow::Result { - if flavor != Flavor::MicrosoftAzureCognitiveSpeechServices { - anyhow::bail!("`mstts::Express` is only supported in ACSS/MSTTS"); +impl DynElement for Express { + fn serialize_xml(&self, writer: &mut XmlWriter<'_>, options: &SerializeOptions) -> crate::Result<()> { + if options.perform_checks && options.flavor != Flavor::MicrosoftAzureCognitiveSpeechServices { + return Err(crate::error!("`mstts::Express` is only supported in ACSS/MSTTS")); } - let mut buf = Vec::new(); - buf.write_all(b"")?; - util::serialize_elements(&mut buf, &self.children, flavor)?; - buf.write_all(b"")?; - Ok(std::str::from_utf8(&buf)?.to_owned()) + writer.element("mstts:express-as", |writer| { + writer.attr("style", self.expression.0)?; + writer.attr("styledegree", self.expression.1.to_string())?; + util::serialize_elements(writer, &self.children, options) + }) } fn children(&self) -> Option<&Vec> { @@ -209,18 +215,24 @@ impl CustomElement for Express { /// # use ssml::{Flavor, Serialize}; /// use ssml::mstts; /// -/// # fn main() -> anyhow::Result<()> { +/// # fn main() -> ssml::Result<()> { /// let doc = ssml::speak( /// Some("en-US"), /// [ssml::voice( /// "en-US-JaneNeural", -/// [mstts::express(mstts::express::Cheerful.with_degree(0.5), ["Good morning!"]).into_custom()] +/// [mstts::express(mstts::express::Cheerful.with_degree(0.5), ["Good morning!"]).into_dyn()] /// )] /// ); /// /// assert_eq!( -/// doc.serialize_to_string(Flavor::MicrosoftAzureCognitiveSpeechServices)?, -/// r#"Good morning!"# +/// doc.serialize_to_string(&ssml::SerializeOptions::default().flavor(ssml::Flavor::MicrosoftAzureCognitiveSpeechServices).pretty())?, +/// r#" +/// +/// +/// Good morning! +/// +/// +/// "# /// ); /// # Ok(()) /// # } diff --git a/src/mstts/mod.rs b/src/mstts/mod.rs index be6e5d2..4097570 100644 --- a/src/mstts/mod.rs +++ b/src/mstts/mod.rs @@ -7,6 +7,9 @@ use crate::{voice::Voice, Flavor, Meta}; pub mod express; pub use self::express::{express, Express}; +/// Viseme configuration for MSTTS. +/// +/// See [`MicrosoftVoiceExt::with_mstts_viseme`]. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MicrosoftViseme { /// Receive visemes as an ID. (equivalent to ``) @@ -24,6 +27,15 @@ impl Display for MicrosoftViseme { } } +/// A voice effect to apply to speech. +/// +/// For some scenarios in production environments, the auditory experience might be degraded due to the playback +/// distortion on certain devices. For example, the synthesized speech from a car speaker might sound dull and +/// muffled due to environmental factors such as speaker response, room reverberation, and background noise. The +/// passenger might have to turn up the volume to hear more clearly. To avoid manual operations in such a scenario, +/// the audio effect processor can make the sound clearer by compensating the distortion of playback. +/// +/// See [`MicrosoftVoiceExt::with_mstts_viseme`]. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MicrosoftVoiceEffect { /// Optimize the auditory experience when providing high-fidelity speech in cars, buses, and other enclosed @@ -50,19 +62,24 @@ pub trait MicrosoftVoiceExt { /// /// ``` /// # use ssml::{Flavor, mstts::{MicrosoftVoiceExt, MicrosoftViseme}, Serialize}; - /// # fn main() -> anyhow::Result<()> { + /// # fn main() -> ssml::Result<()> { /// let doc = ssml::speak( /// Some("en-US"), /// [ssml::voice( /// "en-US-JennyNeural", - /// ["Rainbow has seven colors: Red, orange, yellow, green, blue, indigo, and violet."] + /// ["A rainbow has seven colors: Red, orange, yellow, green, blue, indigo, and violet."] /// ) /// .with_mstts_viseme(MicrosoftViseme::FacialExpression)] /// ); /// /// assert_eq!( - /// doc.serialize_to_string(Flavor::MicrosoftAzureCognitiveSpeechServices)?, - /// r#"Rainbow has seven colors: Red, orange, yellow, green, blue, indigo, and violet."# + /// doc.serialize_to_string(&ssml::SerializeOptions::default().flavor(ssml::Flavor::MicrosoftAzureCognitiveSpeechServices).pretty())?, + /// r#" + /// + /// + /// A rainbow has seven colors: Red, orange, yellow, green, blue, indigo, and violet. + /// + /// "# /// ); /// # Ok(()) /// # } @@ -80,7 +97,7 @@ pub trait MicrosoftVoiceExt { /// /// ``` /// # use ssml::{Flavor, mstts::{MicrosoftVoiceExt, MicrosoftVoiceEffect}, Serialize}; - /// # fn main() -> anyhow::Result<()> { + /// # fn main() -> ssml::Result<()> { /// let doc = ssml::speak( /// Some("en-US"), /// [ssml::voice( @@ -91,8 +108,12 @@ pub trait MicrosoftVoiceExt { /// ); /// /// assert_eq!( - /// doc.serialize_to_string(Flavor::MicrosoftAzureCognitiveSpeechServices)?, - /// r#"Your call is being transferred to a service representative."# + /// doc.serialize_to_string(&ssml::SerializeOptions::default().flavor(ssml::Flavor::MicrosoftAzureCognitiveSpeechServices).pretty())?, + /// r#" + /// + /// Your call is being transferred to a service representative. + /// + /// "# /// ); /// # Ok(()) /// # } diff --git a/src/speak.rs b/src/speak.rs index c478620..9e44153 100644 --- a/src/speak.rs +++ b/src/speak.rs @@ -1,70 +1,6 @@ -use std::{fmt::Debug, io::Write}; +use std::fmt::Debug; -use crate::{custom::CustomElement, util, Audio, Flavor, Meta, Serialize, Text, Voice}; - -macro_rules! el { - ( - $(#[$outer:meta])* - pub enum $name:ident { - $( - $(#[$innermeta:meta])* - $variant:ident($inner:ty) - ),* - } - ) => { - $(#[$outer])* - pub enum $name { - $( - $(#[$innermeta])* - $variant($inner) - ),* - } - - $(impl From<$inner> for $name { - fn from(val: $inner) -> $name { - $name::$variant(val) - } - })* - - impl $crate::Serialize for $name { - fn serialize(&self, writer: &mut W, flavor: $crate::Flavor) -> anyhow::Result<()> { - match self { - $($name::$variant(inner) => inner.serialize(writer, flavor),)* - } - } - } - }; -} - -el! { - #[derive(Clone, Debug)] - #[non_exhaustive] - pub enum Element { - Text(Text), - Audio(Audio), - Voice(Voice), - Meta(Meta), - Custom(Box) - // Break(BreakElement), - // Emphasis(EmphasisElement), - // Lang(LangElement), - // Mark(MarkElement), - // Paragraph(ParagraphElement), - // Phoneme(PhonemeElement), - // Prosody(ProsodyElement), - // SayAs(SayAsElement), - // Sub(SubElement), - // Sentence(SentenceElement), - // Voice(VoiceElement), - // Word(WordElement) - } -} - -impl From for Element { - fn from(value: T) -> Self { - Element::Text(Text(value.to_string())) - } -} +use crate::{util, Element, Flavor, Serialize, SerializeOptions, XmlWriter}; /// The root element of an SSML document. #[derive(Default, Debug)] @@ -105,13 +41,16 @@ impl Speak { /// /// ``` /// # use ssml::{Flavor, Serialize}; - /// # fn main() -> anyhow::Result<()> { + /// # fn main() -> ssml::Result<()> { /// let mut doc = ssml::speak(Some("en-US"), ["Hello, world!"]); /// doc.push("This is an SSML document."); /// /// assert_eq!( - /// doc.serialize_to_string(Flavor::AmazonPolly)?, - /// r#"Hello, world! This is an SSML document."# + /// doc.serialize_to_string(&ssml::SerializeOptions::default().pretty())?, + /// r#" + /// Hello, world! + /// This is an SSML document. + /// "# /// ); /// # Ok(()) /// # } @@ -124,13 +63,16 @@ impl Speak { /// /// ``` /// # use ssml::{Flavor, Serialize}; - /// # fn main() -> anyhow::Result<()> { + /// # fn main() -> ssml::Result<()> { /// let mut doc = ssml::speak(Some("en-US"), ["Hello, world!"]); /// doc.extend(["This is an SSML document."]); /// /// assert_eq!( - /// doc.serialize_to_string(Flavor::AmazonPolly)?, - /// r#"Hello, world! This is an SSML document."# + /// doc.serialize_to_string(&ssml::SerializeOptions::default().pretty())?, + /// r#" + /// Hello, world! + /// This is an SSML document. + /// "# /// ); /// # Ok(()) /// # } @@ -151,37 +93,28 @@ impl Speak { } impl Serialize for Speak { - fn serialize(&self, writer: &mut W, flavor: Flavor) -> anyhow::Result<()> { - writer.write_all(b", options: &SerializeOptions) -> crate::Result<()> { + if options.perform_checks && self.lang.is_none() && options.flavor == Flavor::MicrosoftAzureCognitiveSpeechServices { + return Err(crate::error!("{:?} requires a language to be set", options.flavor))?; } - if let Some(lang) = &self.lang { - util::write_attr(writer, "xml:lang", lang)?; - } else if flavor == Flavor::MicrosoftAzureCognitiveSpeechServices { - return Err(crate::error!("{flavor:?} requires a language to be set"))?; - } - - // Include `mstts` namespace for ACSS. - if flavor == Flavor::MicrosoftAzureCognitiveSpeechServices { - util::write_attr(writer, "xmlns:mstts", "http://www.w3.org/2001/mstts")?; - } - - if let Some(start_mark) = &self.marks.0 { - util::write_attr(writer, "startmark", start_mark)?; - } - if let Some(end_mark) = &self.marks.1 { - util::write_attr(writer, "endmark", end_mark)?; - } + writer.element("speak", |writer| { + if matches!(options.flavor, Flavor::Generic | Flavor::MicrosoftAzureCognitiveSpeechServices) { + writer.attr("version", "1.0")?; + writer.attr("xmlns", "http://www.w3.org/2001/10/synthesis")?; + } - writer.write_all(b">")?; + writer.attr_opt("xml:lang", self.lang.as_ref())?; + // Include `mstts` namespace for ACSS. + if options.flavor == Flavor::MicrosoftAzureCognitiveSpeechServices { + writer.attr("xmlns:mstts", "http://www.w3.org/2001/mstts")?; + } - util::serialize_elements(writer, &self.children, flavor)?; + writer.attr_opt("startmark", self.marks.0.as_ref())?; + writer.attr_opt("endmark", self.marks.1.as_ref())?; - writer.write_all(b"")?; - Ok(()) + util::serialize_elements(writer, &self.children, options) + }) } } @@ -192,11 +125,16 @@ impl Serialize for Speak { /// /// ``` /// # use ssml::Serialize; -/// # fn main() -> anyhow::Result<()> { +/// # fn main() -> ssml::Result<()> { /// let doc = ssml::speak(Some("en-US"), ["Hello, world!"]); /// -/// let str = doc.serialize_to_string(ssml::Flavor::AmazonPolly)?; -/// assert_eq!(str, r#"Hello, world!"#); +/// let str = doc.serialize_to_string(&ssml::SerializeOptions::default().pretty())?; +/// assert_eq!( +/// str, +/// r#" +/// Hello, world! +/// "# +/// ); /// # Ok(()) /// # } /// ``` diff --git a/src/text.rs b/src/text.rs index 365fdd1..3e0ed76 100644 --- a/src/text.rs +++ b/src/text.rs @@ -1,6 +1,4 @@ -use std::io::Write; - -use crate::{util, Flavor, Serialize}; +use crate::{Serialize, SerializeOptions, XmlWriter}; /// A non-marked-up string of text for use as a spoken element. #[derive(Default, Debug, Clone)] @@ -13,9 +11,8 @@ impl From for Text { } impl Serialize for Text { - fn serialize(&self, writer: &mut W, _: Flavor) -> anyhow::Result<()> { - writer.write_all(util::escape(&self.0).as_bytes())?; - Ok(()) + fn serialize_xml(&self, writer: &mut XmlWriter<'_>, _: &SerializeOptions) -> crate::Result<()> { + writer.text(&self.0) } } @@ -27,11 +24,11 @@ pub fn text(s: impl ToString) -> Text { #[cfg(test)] mod tests { use super::text; - use crate::{Flavor, Serialize}; + use crate::{Serialize, SerializeOptions}; #[test] - fn text_escapes() -> anyhow::Result<()> { - assert_eq!(text("One & two").serialize_to_string(Flavor::Generic)?, "One & two"); + fn text_escapes() -> crate::Result<()> { + assert_eq!(text("One & two").serialize_to_string(&SerializeOptions::default())?, "One & two"); Ok(()) } } diff --git a/src/unit.rs b/src/unit.rs index c93cd54..f854507 100644 --- a/src/unit.rs +++ b/src/unit.rs @@ -31,7 +31,7 @@ impl Error for TimeDesignationError {} /// Time designations can be provided in either seconds (`s`) or milliseconds (`ms`): /// ``` /// # use ssml::TimeDesignation; -/// # fn main() -> anyhow::Result<()> { +/// # fn main() -> ssml::Result<()> { /// assert_eq!("15s".parse::()?, TimeDesignation::from_millis(15_000.)); /// assert_eq!("750ms".parse::()?, TimeDesignation::from_millis(750.)); /// assert_eq!("+0.75s".parse::()?, TimeDesignation::from_millis(750.)); @@ -128,7 +128,7 @@ impl Error for DecibelsError {} /// /// ``` /// # use ssml::Decibels; -/// # fn main() -> anyhow::Result<()> { +/// # fn main() -> ssml::Result<()> { /// assert_eq!("+0.0dB".parse::()?, Decibels(0.)); /// assert_eq!("-6dB".parse::()?, Decibels(-6.)); /// assert_eq!("2dB".parse::()?, Decibels(2.)); diff --git a/src/util.rs b/src/util.rs index d32b8b4..2d7ed1f 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,34 +1,18 @@ //! Utilities for serializing XML. -use std::io::{self, Write}; - -use crate::{Element, Flavor, Serialize}; - -pub fn escape>(str: S) -> String { - let str = str.as_ref(); - str.replace('"', """) - .replace('\'', "'") - .replace('<', "<") - .replace('>', ">") - .replace('&', "&") -} - -pub fn write_attr(writer: &mut W, key: impl AsRef, val: impl AsRef) -> io::Result<()> { - write!(writer, " {}=\"{}\"", key.as_ref(), escape(val))?; - Ok(()) -} +use crate::{Element, Serialize, SerializeOptions, XmlWriter}; /// Serialize a slice of elements, inserting spaces between adjacent text elements where needed. -pub fn serialize_elements(writer: &mut W, elements: impl AsRef<[Element]>, flavor: Flavor) -> anyhow::Result<()> { +pub fn serialize_elements(writer: &mut XmlWriter<'_>, elements: impl AsRef<[Element]>, options: &SerializeOptions) -> crate::Result<()> { let elements = elements.as_ref(); for i in 0..elements.len() { let el = &elements[i]; - el.serialize(writer, flavor)?; + el.serialize_xml(writer, options)?; - if matches!(el, Element::Text(_)) { + if !writer.pretty && matches!(el, Element::Text(_)) { if let Some(x) = elements.get(i + 1) { if matches!(x, Element::Text(_)) { - writer.write_all(b" ")?; + writer.write.write_all(b" ")?; } } } diff --git a/src/visit.rs b/src/visit.rs index fe4deba..1c9be34 100644 --- a/src/visit.rs +++ b/src/visit.rs @@ -4,7 +4,7 @@ //! //! ``` //! # use ssml::{Flavor, Serialize}; -//! # fn main() -> anyhow::Result<()> { +//! # fn main() -> ssml::Result<()> { //! use ssml::visit::{self, Visit}; //! //! #[derive(Default)] @@ -32,7 +32,7 @@ //! # } //! ``` -use crate::{custom::CustomElement, Audio, Element, Meta, Speak, Text, Voice}; +use crate::{Audio, DynElement, Element, Meta, Speak, Text, Voice}; pub trait Visit<'s> { fn visit_speak(&mut self, node: &'s Speak) { @@ -55,8 +55,8 @@ pub trait Visit<'s> { self::visit_voice(self, node) } - fn visit_custom(&mut self, node: &'s dyn CustomElement) { - self::visit_custom(self, node) + fn visit_dyn(&mut self, node: &'s dyn DynElement) { + self::visit_dyn(self, node) } fn visit_speakable(&mut self, node: &'s Element) { @@ -80,7 +80,7 @@ pub fn visit_voice<'s, V: Visit<'s> + ?Sized>(v: &mut V, node: &'s Voice) { } } -pub fn visit_custom<'s, V: Visit<'s> + ?Sized>(_v: &mut V, _node: &'s dyn CustomElement) {} +pub fn visit_dyn<'s, V: Visit<'s> + ?Sized>(_v: &mut V, _node: &'s dyn DynElement) {} pub fn visit_speakable<'s, V: Visit<'s> + ?Sized>(v: &mut V, node: &'s Element) { match node { @@ -88,7 +88,7 @@ pub fn visit_speakable<'s, V: Visit<'s> + ?Sized>(v: &mut V, node: &'s Element) Element::Meta(node) => visit_meta(v, node), Element::Text(node) => visit_text(v, node), Element::Voice(node) => visit_voice(v, node), - Element::Custom(node) => visit_custom(v, node.as_ref()) + Element::Dyn(node) => visit_dyn(v, node.as_ref()) } } diff --git a/src/visit_mut.rs b/src/visit_mut.rs index fc9ac6d..67c7795 100644 --- a/src/visit_mut.rs +++ b/src/visit_mut.rs @@ -1,4 +1,4 @@ -use crate::{custom::CustomElement, Audio, Element, Meta, Speak, Text, Voice}; +use crate::{Audio, DynElement, Element, Meta, Speak, Text, Voice}; pub trait VisitMut<'s> { fn visit_speak_mut(&mut self, node: &'s mut Speak) { @@ -21,8 +21,8 @@ pub trait VisitMut<'s> { self::visit_voice_mut(self, node) } - fn visit_custom_mut(&mut self, node: &'s mut dyn CustomElement) { - self::visit_custom_mut(self, node) + fn visit_dyn_mut(&mut self, node: &'s mut dyn DynElement) { + self::visit_dyn_mut(self, node) } fn visit_speakable_mut(&mut self, node: &'s mut Element) { @@ -46,7 +46,7 @@ pub fn visit_voice_mut<'s, V: VisitMut<'s> + ?Sized>(v: &mut V, node: &'s mut Vo } } -pub fn visit_custom_mut<'s, V: VisitMut<'s> + ?Sized>(_v: &mut V, _node: &'s mut dyn CustomElement) {} +pub fn visit_dyn_mut<'s, V: VisitMut<'s> + ?Sized>(_v: &mut V, _node: &'s mut dyn DynElement) {} pub fn visit_speakable_mut<'s, V: VisitMut<'s> + ?Sized>(v: &mut V, node: &'s mut Element) { match node { @@ -54,7 +54,7 @@ pub fn visit_speakable_mut<'s, V: VisitMut<'s> + ?Sized>(v: &mut V, node: &'s mu Element::Meta(node) => visit_meta_mut(v, node), Element::Text(node) => visit_text_mut(v, node), Element::Voice(node) => visit_voice_mut(v, node), - Element::Custom(node) => visit_custom_mut(v, node.as_mut()) + Element::Dyn(node) => visit_dyn_mut(v, node.as_mut()) } } diff --git a/src/voice.rs b/src/voice.rs index f0d2ded..16246a5 100644 --- a/src/voice.rs +++ b/src/voice.rs @@ -1,6 +1,6 @@ -use std::{fmt::Display, io::Write}; +use std::fmt::Display; -use crate::{util, Element, Flavor, Serialize}; +use crate::{util, Element, Serialize, SerializeOptions, XmlWriter}; #[derive(Default, Debug, Clone, PartialEq, Eq)] pub enum VoiceGender { @@ -53,23 +53,12 @@ impl From for VoiceConfig { } impl Serialize for VoiceConfig { - fn serialize(&self, writer: &mut W, _: Flavor) -> anyhow::Result<()> { - if let Some(gender) = &self.gender { - util::write_attr(writer, "gender", gender.to_string())?; - } - if let Some(age) = &self.age { - util::write_attr(writer, "age", age.to_string())?; - } - if let Some(names) = &self.names { - util::write_attr(writer, "name", names.join(" "))?; - } - if let Some(variant) = &self.variant { - util::write_attr(writer, "variant", variant)?; - } - if let Some(languages) = &self.languages { - util::write_attr(writer, "language", languages.join(" "))?; - } - Ok(()) + fn serialize_xml(&self, writer: &mut XmlWriter<'_>, _: &SerializeOptions) -> crate::Result<()> { + writer.attr_opt("gender", self.gender.as_ref().map(|c| c.to_string()))?; + writer.attr_opt("age", self.age.as_ref().map(|c| c.to_string()))?; + writer.attr_opt("name", self.names.as_ref().map(|c| c.join(" ")))?; + writer.attr_opt("variant", self.variant.as_ref())?; + writer.attr_opt("language", self.languages.as_ref().map(|c| c.join(" "))) } } @@ -86,12 +75,18 @@ impl Voice { /// /// ``` /// # use ssml::{Flavor, Serialize}; - /// # fn main() -> anyhow::Result<()> { + /// # fn main() -> ssml::Result<()> { /// let doc = ssml::speak(None, [ssml::voice("en-US-Neural2-F", ["Hello, world!"])]); /// /// assert_eq!( - /// doc.serialize_to_string(Flavor::GoogleCloudTextToSpeech)?, - /// r#"Hello, world!"# + /// doc.serialize_to_string( + /// &ssml::SerializeOptions::default().flavor(ssml::Flavor::GoogleCloudTextToSpeech).pretty() + /// )?, + /// r#" + /// + /// Hello, world! + /// + /// "# /// ); /// # Ok(()) /// # } @@ -119,14 +114,21 @@ impl Voice { /// /// ``` /// # use ssml::{Flavor, Serialize}; - /// # fn main() -> anyhow::Result<()> { + /// # fn main() -> ssml::Result<()> { /// let mut voice = ssml::voice("en-US-Neural2-F", ["Hello, world!"]); /// voice.push("This is an SSML document."); /// let doc = ssml::speak(None, [voice]); /// /// assert_eq!( - /// doc.serialize_to_string(Flavor::GoogleCloudTextToSpeech)?, - /// r#"Hello, world! This is an SSML document."# + /// doc.serialize_to_string( + /// &ssml::SerializeOptions::default().flavor(ssml::Flavor::GoogleCloudTextToSpeech).pretty() + /// )?, + /// r#" + /// + /// Hello, world! + /// This is an SSML document. + /// + /// "# /// ); /// # Ok(()) /// # } @@ -139,14 +141,21 @@ impl Voice { /// /// ``` /// # use ssml::{Flavor, Serialize}; - /// # fn main() -> anyhow::Result<()> { + /// # fn main() -> ssml::Result<()> { /// let mut voice = ssml::voice("en-US-Neural2-F", ["Hello, world!"]); /// voice.extend(["This is an SSML document."]); /// let doc = ssml::speak(None, [voice]); /// /// assert_eq!( - /// doc.serialize_to_string(Flavor::GoogleCloudTextToSpeech)?, - /// r#"Hello, world! This is an SSML document."# + /// doc.serialize_to_string( + /// &ssml::SerializeOptions::default().flavor(ssml::Flavor::GoogleCloudTextToSpeech).pretty() + /// )?, + /// r#" + /// + /// Hello, world! + /// This is an SSML document. + /// + /// "# /// ); /// # Ok(()) /// # } @@ -172,16 +181,14 @@ impl Voice { } impl Serialize for Voice { - fn serialize(&self, writer: &mut W, flavor: Flavor) -> anyhow::Result<()> { - writer.write_all(b"")?; - util::serialize_elements(writer, &self.children, flavor)?; - writer.write_all(b"")?; - Ok(()) + fn serialize_xml(&self, writer: &mut XmlWriter<'_>, options: &SerializeOptions) -> crate::Result<()> { + writer.element("voice", |writer| { + self.config.serialize_xml(writer, options)?; + for attr in &self.attrs { + writer.attr(&attr.0, &attr.1)?; + } + util::serialize_elements(writer, &self.children, options) + }) } } @@ -189,12 +196,18 @@ impl Serialize for Voice { /// /// ``` /// # use ssml::{Flavor, Serialize}; -/// # fn main() -> anyhow::Result<()> { +/// # fn main() -> ssml::Result<()> { /// let doc = ssml::speak(None, [ssml::voice("en-US-Neural2-F", ["Hello, world!"])]); /// /// assert_eq!( -/// doc.serialize_to_string(Flavor::GoogleCloudTextToSpeech)?, -/// r#"Hello, world!"# +/// doc.serialize_to_string( +/// &ssml::SerializeOptions::default().flavor(ssml::Flavor::GoogleCloudTextToSpeech).pretty() +/// )?, +/// r#" +/// +/// Hello, world! +/// +/// "# /// ); /// # Ok(()) /// # } diff --git a/src/xml/mod.rs b/src/xml/mod.rs new file mode 100644 index 0000000..ea9b4db --- /dev/null +++ b/src/xml/mod.rs @@ -0,0 +1,2 @@ +mod writer; +pub use self::writer::XmlWriter; diff --git a/src/xml/writer.rs b/src/xml/writer.rs new file mode 100644 index 0000000..e1a65f3 --- /dev/null +++ b/src/xml/writer.rs @@ -0,0 +1,141 @@ +use std::io::Write; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum XmlState { + DocumentStart, + ElementUnclosed, + ElementClosed +} + +/// A utility for writing optionally formatted XML to a [`Write`] stream. +pub struct XmlWriter<'w> { + pub(crate) write: &'w mut dyn Write, + indent_level: u8, + pub(crate) pretty: bool, + state: XmlState +} + +impl<'w> XmlWriter<'w> { + /// Creates a new [`XmlWriter`] with the given backing [`Write`] stream. + pub fn new(writer: &'w mut dyn Write, pretty: bool) -> Self { + Self { + write: writer, + indent_level: 0, + pretty, + state: XmlState::DocumentStart + } + } + + fn pretty_break(&mut self) -> crate::Result<()> { + if self.pretty { + self.write.write_all(b"\n")?; + for _ in 0..self.indent_level { + self.write.write_all(b"\t")?; + } + } + Ok(()) + } + + /// Escape the given text for use in XML. + pub fn escape(text: impl AsRef) -> String { + let text = text.as_ref(); + let mut str = String::with_capacity(text.len() + 6); + for char in text.chars() { + match char { + '"' => str.push_str("""), + '\'' => str.push_str("'"), + '<' => str.push_str("<"), + '>' => str.push_str(">"), + '&' => str.push_str("&"), + _ => str.push(char) + } + } + str + } + + /// Starts an XML element context. + /// + /// Note that child elements **must** be written *after* any attributes. + pub fn element(&mut self, tag_name: impl AsRef, ctx: impl FnOnce(&mut Self) -> crate::Result<()>) -> crate::Result<()> { + let tag_name = tag_name.as_ref(); + + if self.state == XmlState::ElementUnclosed { + self.write.write_all(b">")?; + } + if self.state != XmlState::DocumentStart { + self.pretty_break()?; + } + + self.write.write_all(b"<")?; + self.write.write_all(tag_name.as_bytes())?; + + self.state = XmlState::ElementUnclosed; + self.indent_level = self.indent_level.saturating_add(1); + ctx(self)?; + + self.indent_level = self.indent_level.saturating_sub(1); + match self.state { + XmlState::ElementUnclosed => { + if self.pretty { + self.write.write_all(b" ")?; + } + self.write.write_all(b"/>")?; + } + XmlState::ElementClosed => { + self.pretty_break()?; + self.write.write_all(b"")?; + } + _ => {} + } + self.state = XmlState::ElementClosed; + + Ok(()) + } + + /// Starts an attribute context. + /// + /// Note that attributes **must** be written *before* any child elements. + pub fn attr(&mut self, attr_name: impl AsRef, attr_value: impl AsRef) -> crate::Result<()> { + if self.state == XmlState::ElementClosed { + return Err(crate::Error::AttributesInChildContext); + } + + self.write.write_all(b" ")?; + self.write.write_all(attr_name.as_ref().as_bytes())?; + self.write.write_all(b"=\"")?; + self.write.write_all(XmlWriter::escape(attr_value).as_bytes())?; + self.write.write_all(b"\"")?; + + Ok(()) + } + + /// Starts an attribute context if the given `attr_value` is `Some`. + /// + /// Note that attributes **must** be written *before* any child elements. + pub fn attr_opt(&mut self, attr_name: impl AsRef, attr_value: Option>) -> crate::Result<()> { + if let Some(attr_value) = attr_value { self.attr(attr_name, attr_value) } else { Ok(()) } + } + + /// Escapes and inserts the given text into the XML stream. + pub fn text(&mut self, contents: impl AsRef) -> crate::Result<()> { + self.raw(XmlWriter::escape(contents)) + } + + /// Inserts the given text into the XML stream verbatim, closing any open elements, with no escaping performed. + pub fn raw(&mut self, contents: impl AsRef) -> crate::Result<()> { + if self.state == XmlState::ElementUnclosed { + self.write.write_all(b">")?; + } + if self.state != XmlState::DocumentStart { + self.pretty_break()?; + } + + self.write.write_all(contents.as_ref().as_bytes())?; + + self.state = XmlState::ElementClosed; + + Ok(()) + } +}