From cdfb84f19a97f404fa43fd21b63c0c5dc39167f9 Mon Sep 17 00:00:00 2001 From: RumovZ Date: Fri, 17 Feb 2023 03:26:07 +0100 Subject: [PATCH] Implement TTS using windows crate (#2371) * Implement TTS using windows crate * Use API calls instead of SSML * Properly stop player in case of TTS error * Add context to WindowsErrors * Validate available voices * Remove TTS text from synthesize error * Limit maximum buffer size * Make validation optional and list it in tts filter * We no longer need the winrt module (dae) * Use a separate request object so the meaning of the bool is clear (dae) * Slightly shorten runtime error message (dae) The default message appears to clip slightly. * Alternate buffer implementation (dae) * Use array instead of vec * Drop the max buffer size to 128k (dae) --- .pylintrc | 2 +- Cargo.lock | 25 +++++ ftl/qt/errors.ftl | 2 +- proto/anki/backend.proto | 2 + proto/anki/card_rendering.proto | 23 +++++ pylib/anki/collection.py | 1 + python/requirements.aqt.in | 1 - python/requirements.win.in | 1 - python/requirements.win.txt | 8 -- qt/aqt/tts.py | 124 ++++++++++++------------ rslib/Cargo.toml | 4 + rslib/src/backend/cardrendering.rs | 22 +++++ rslib/src/backend/error.rs | 2 + rslib/src/card_rendering/mod.rs | 1 + rslib/src/card_rendering/tts/mod.rs | 20 ++++ rslib/src/card_rendering/tts/other.rs | 13 +++ rslib/src/card_rendering/tts/windows.rs | 106 ++++++++++++++++++++ rslib/src/error/mod.rs | 11 ++- rslib/src/error/windows.rs | 32 ++++++ 19 files changed, 323 insertions(+), 77 deletions(-) create mode 100644 rslib/src/card_rendering/tts/mod.rs create mode 100644 rslib/src/card_rendering/tts/other.rs create mode 100644 rslib/src/card_rendering/tts/windows.rs create mode 100644 rslib/src/error/windows.rs diff --git a/.pylintrc b/.pylintrc index 21918469b..d55abc6cd 100644 --- a/.pylintrc +++ b/.pylintrc @@ -44,4 +44,4 @@ good-names = ip, [IMPORTS] -ignored-modules = anki.*_pb2, anki.sync_pb2, win32file,pywintypes,socket,win32pipe,winrt,pyaudio,anki.scheduler_pb2 +ignored-modules = anki.*_pb2, anki.sync_pb2, win32file,pywintypes,socket,win32pipe,pyaudio,anki.scheduler_pb2 diff --git a/Cargo.lock b/Cargo.lock index 2a81d69aa..800fe4af1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -144,6 +144,7 @@ dependencies = [ "unicode-normalization", "utime", "which", + "windows", "wiremock", "workspace-hack", "zip", @@ -4580,6 +4581,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e745dab35a0c4c77aa3ce42d595e13d2003d6902d6b08c9ef5fc326d08da12b" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.42.0" @@ -4595,6 +4605,21 @@ dependencies = [ "windows_x86_64_msvc", ] +[[package]] +name = "windows-targets" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2522491fbfcd58cc84d47aeb2958948c4b8982e9a2d8a2a35bbaed431390e7" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.42.1" diff --git a/ftl/qt/errors.ftl b/ftl/qt/errors.ftl index 5e88d4fea..e438d3b09 100644 --- a/ftl/qt/errors.ftl +++ b/ftl/qt/errors.ftl @@ -39,7 +39,7 @@ errors-unable-open-collection = Anki was unable to open your collection file. If problems persist after restarting your computer, please use the Open Backup button in the profile manager. Debug info: -errors-windows-tts-runtime-error = The TTS service failed. Please ensure Windows updates are installed, try restarting your computer, and try using a different voice. +errors-windows-tts-runtime-error = The TTS service failed. Please ensure Windows updates are installed, try restarting your computer, or try a different voice. ## OBSOLETE; you do not need to translate this diff --git a/proto/anki/backend.proto b/proto/anki/backend.proto index f87ca224a..89c5f1f1e 100644 --- a/proto/anki/backend.proto +++ b/proto/anki/backend.proto @@ -66,6 +66,8 @@ message BackendError { DELETED = 17; CARD_TYPE_ERROR = 18; ANKIDROID_PANIC_ERROR = 19; + // Originated from and usually specific to the OS. + OS_ERROR = 20; } // error description, usually localized, suitable for displaying to the user diff --git a/proto/anki/card_rendering.proto b/proto/anki/card_rendering.proto index aaa8132ac..1b5128fcb 100644 --- a/proto/anki/card_rendering.proto +++ b/proto/anki/card_rendering.proto @@ -29,6 +29,8 @@ service CardRenderingService { rpc CompareAnswer(CompareAnswerRequest) returns (generic.String); rpc ExtractClozeForTyping(ExtractClozeForTypingRequest) returns (generic.String); + rpc AllTtsVoices(AllTtsVoicesRequest) returns (AllTtsVoicesResponse); + rpc WriteTtsStream(WriteTtsStreamRequest) returns (generic.Empty); } message ExtractAVTagsRequest { @@ -145,3 +147,24 @@ message ExtractClozeForTypingRequest { string text = 1; uint32 ordinal = 2; } + +message AllTtsVoicesRequest { + bool validate = 1; +} + +message AllTtsVoicesResponse { + message TtsVoice { + string id = 1; + string name = 2; + string language = 3; + optional bool available = 4; + } + repeated TtsVoice voices = 1; +} + +message WriteTtsStreamRequest { + string path = 1; + string voice_id = 2; + float speed = 3; + string text = 4; +} diff --git a/pylib/anki/collection.py b/pylib/anki/collection.py index 0992cabf1..f3b58ae4b 100644 --- a/pylib/anki/collection.py +++ b/pylib/anki/collection.py @@ -39,6 +39,7 @@ ImportCsvRequest = import_export_pb2.ImportCsvRequest CsvMetadata = import_export_pb2.CsvMetadata DupeResolution = CsvMetadata.DupeResolution Delimiter = import_export_pb2.CsvMetadata.Delimiter +TtsVoice = card_rendering_pb2.AllTtsVoicesResponse.TtsVoice import copy import os diff --git a/python/requirements.aqt.in b/python/requirements.aqt.in index c17e196d2..3a9a01eb8 100644 --- a/python/requirements.aqt.in +++ b/python/requirements.aqt.in @@ -7,4 +7,3 @@ send2trash waitress>=2.0.0 psutil; sys.platform == "win32" pywin32; sys.platform == "win32" -winrt; sys.platform == "win32" diff --git a/python/requirements.win.in b/python/requirements.win.in index 33bb7aa08..24fd9cb64 100644 --- a/python/requirements.win.in +++ b/python/requirements.win.in @@ -1,3 +1,2 @@ pywin32 -winrt diff --git a/python/requirements.win.txt b/python/requirements.win.txt index eaadf1a07..65c2b6e3e 100644 --- a/python/requirements.win.txt +++ b/python/requirements.win.txt @@ -14,11 +14,3 @@ pywin32==305 \ --hash=sha256:9dd98384da775afa009bc04863426cb30596fd78c6f8e4e2e5bbf4edf8029504 \ --hash=sha256:a55db448124d1c1484df22fa8bbcbc45c64da5e6eae74ab095b9ea62e6d00496 # via -r requirements.win.in -winrt==1.0.21033.1 \ - --hash=sha256:224e13eb172435aaabdc7066752898a61dae0fcc3022f6f8cbd1ce953be3358c \ - --hash=sha256:9d7b7d2e48c301855afd3280aaf51ea0d3c683450f46de2db813f71ee1cd5937 \ - --hash=sha256:ad4afd1c7b041a6b770256d70e07093920fa83eecd80e42cac2704cd03902243 \ - --hash=sha256:d035570ce2cf7e8caa785abb43f25a6ede600c2cde0378c931495bdbeaf1a075 \ - --hash=sha256:da3ca3626fb992f2efa4528993d4760b298f399a7f459f7e070a2f8681d82106 \ - --hash=sha256:f5ab502117da4777ab49b846ad1919fbf448bd5e49b4aca00cc59667bae2c362 - # via -r requirements.win.in diff --git a/qt/aqt/tts.py b/qt/aqt/tts.py index c3f4d36bb..7f3e55d17 100644 --- a/qt/aqt/tts.py +++ b/qt/aqt/tts.py @@ -28,11 +28,9 @@ expose the name of the engine, which would mean the user could write from __future__ import annotations -import asyncio import os import re import subprocess -import threading from concurrent.futures import Future from dataclasses import dataclass from operator import attrgetter @@ -40,7 +38,9 @@ from typing import Any, cast import anki import anki.template +import aqt from anki import hooks +from anki.collection import TtsVoice as BackendVoice from anki.sound import AVTag, TTSTag from anki.utils import checksum, is_win, tmpdir from aqt import gui_hooks @@ -52,6 +52,16 @@ from aqt.utils import tooltip, tr class TTSVoice: name: str lang: str + available: bool | None + + def __str__(self) -> str: + out = f"{{{{tts {self.lang} voices={self.name}}}}}" + if self.unavailable(): + out += " (unavailable)" + return out + + def unavailable(self) -> bool: + return self.available is False @dataclass @@ -124,10 +134,9 @@ def all_tts_voices() -> list[TTSVoice]: all_voices: list[TTSVoice] = [] for p in av_player.players: - getter = getattr(p, "voices", None) - if not getter: - continue - all_voices.extend(getter()) + getter = getattr(p, "validated_voices", getattr(p, "voices", None)) + if getter: + all_voices.extend(getter()) return all_voices @@ -137,14 +146,13 @@ def on_tts_voices( if filter != "tts-voices": return text voices = all_tts_voices() - voices.sort(key=attrgetter("name")) - voices.sort(key=attrgetter("lang")) + voices.sort(key=attrgetter("lang", "name")) buf = "
TTS voices available:
" - buf += "
".join( - f"{{{{tts {v.lang} voices={v.name}}}}}" # pylint: disable=no-member - for v in voices - ) + buf += "
".join(map(str, voices)) + if any(v.unavailable() for v in voices): + buf += "
One or more voices are unavailable." + buf += " Installing a Windows language pack may help.
" return f"{buf}
" @@ -205,7 +213,9 @@ class MacTTSPlayer(TTSProcessPlayer): original_name = m.group(1).strip() tidy_name = f"Apple_{original_name.replace(' ', '_')}" - return MacVoice(name=tidy_name, original_name=original_name, lang=m.group(2)) + return MacVoice( + name=tidy_name, original_name=original_name, lang=m.group(2), available=None + ) class MacTTSFilePlayer(MacTTSPlayer): @@ -509,7 +519,10 @@ if is_win: # some voices may not have a name name = "unknown" name = self._tidy_name(name) - return [WindowsVoice(name=name, lang=lang, handle=voice) for lang in langs] + return [ + WindowsVoice(name=name, lang=lang, handle=voice, available=None) + for lang in langs + ] def _play(self, tag: AVTag) -> None: assert isinstance(tag, TTSTag) @@ -546,35 +559,36 @@ if is_win: @dataclass class WindowsRTVoice(TTSVoice): - id: Any + id: str - class WindowsRTTTSFilePlayer(TTSProcessPlayer): - voice_list: list[Any] = [] - tmppath = os.path.join(tmpdir(), "tts.wav") - - def import_voices(self) -> None: - import winrt.windows.media.speechsynthesis as speechsynthesis # type: ignore - - try: - self.voice_list = speechsynthesis.SpeechSynthesizer.get_all_voices() # type: ignore - except Exception as e: - print("winrt tts voices unavailable:", e) - self.voice_list = [] - - def get_available_voices(self) -> list[TTSVoice]: - t = threading.Thread(target=self.import_voices) - t.start() - t.join() - return list(map(self._voice_to_object, self.voice_list)) - - def _voice_to_object(self, voice: Any) -> TTSVoice: - return WindowsRTVoice( + @classmethod + def from_backend_voice(cls, voice: BackendVoice) -> WindowsRTVoice: + return cls( id=voice.id, - name=voice.display_name.replace(" ", "_"), + name=voice.name.replace(" ", "_"), lang=voice.language.replace("-", "_"), + available=voice.available, ) + class WindowsRTTTSFilePlayer(TTSProcessPlayer): + tmppath = os.path.join(tmpdir(), "tts.wav") + + def validated_voices(self) -> list[TTSVoice]: + self._available_voices = self._get_available_voices(validate=True) + return self._available_voices + + @classmethod + def get_available_voices(cls) -> list[TTSVoice]: + return cls._get_available_voices(validate=False) + + @staticmethod + def _get_available_voices(validate: bool) -> list[TTSVoice]: + assert aqt.mw + voices = aqt.mw.backend.all_tts_voices(validate=validate) + return list(map(WindowsRTVoice.from_backend_voice, voices)) + def _play(self, tag: AVTag) -> None: + assert aqt.mw assert isinstance(tag, TTSTag) match = self.voice_for_tag(tag) assert match @@ -583,13 +597,18 @@ if is_win: self._taskman.run_on_main( lambda: gui_hooks.av_player_did_begin_playing(self, tag) ) - asyncio.run(self.speakText(tag, voice.id)) + aqt.mw.backend.write_tts_stream( + path=self.tmppath, + voice_id=voice.id, + speed=tag.speed, + text=tag.field_text, + ) def _on_done(self, ret: Future, cb: OnDoneCallback) -> None: - try: - ret.result() - except RuntimeError: + if exception := ret.exception(): + print(str(exception)) tooltip(tr.errors_windows_tts_runtime_error()) + cb() return # inject file into the top of the audio queue @@ -599,26 +618,3 @@ if is_win: # then tell player to advance, which will cause the file to be played cb() - - async def speakText(self, tag: TTSTag, voice_id: Any) -> None: - import winrt.windows.media.speechsynthesis as speechsynthesis # type: ignore - import winrt.windows.storage.streams as streams # type: ignore - - synthesizer = speechsynthesis.SpeechSynthesizer() - - voices = speechsynthesis.SpeechSynthesizer.get_all_voices() # type: ignore - voice_match = next(filter(lambda v: v.id == voice_id, voices)) - - assert voice_match - - synthesizer.voice = voice_match - synthesizer.options.speaking_rate = tag.speed - - stream = await synthesizer.synthesize_text_to_stream_async(tag.field_text) - inputStream = stream.get_input_stream_at(0) - dataReader = streams.DataReader(inputStream) - dataReader.load_async(stream.size) - f = open(self.tmppath, "wb") - for x in range(stream.size): - f.write(bytes([dataReader.read_byte()])) - f.close() diff --git a/rslib/Cargo.toml b/rslib/Cargo.toml index 3593c68d7..fdea94d4c 100644 --- a/rslib/Cargo.toml +++ b/rslib/Cargo.toml @@ -109,3 +109,7 @@ utime = "0.3.1" workspace-hack = { version = "0.1", path = "../tools/workspace-hack" } zip = { version = "0.6.3", default-features = false, features = ["deflate", "time"] } zstd = { version = "0.12.2", features = ["zstdmt"] } + +[target.'cfg(windows)'.dependencies.windows] +version = "0.44.0" +features = ["Media_SpeechSynthesis", "Foundation_Collections", "Storage_Streams"] diff --git a/rslib/src/backend/cardrendering.rs b/rslib/src/backend/cardrendering.rs index e09a691a2..b947f824d 100644 --- a/rslib/src/backend/cardrendering.rs +++ b/rslib/src/backend/cardrendering.rs @@ -4,6 +4,7 @@ use super::Backend; use crate::card_rendering::extract_av_tags; use crate::card_rendering::strip_av_tags; +use crate::card_rendering::tts; use crate::cloze::extract_cloze_for_typing; use crate::latex::extract_latex; use crate::latex::extract_latex_expanding_clozes; @@ -175,6 +176,27 @@ impl CardRenderingService for Backend { .to_string() .into()) } + + fn all_tts_voices( + &self, + input: pb::card_rendering::AllTtsVoicesRequest, + ) -> Result { + tts::all_voices(input.validate) + .map(|voices| pb::card_rendering::AllTtsVoicesResponse { voices }) + } + + fn write_tts_stream( + &self, + request: pb::card_rendering::WriteTtsStreamRequest, + ) -> Result { + tts::write_stream( + &request.path, + &request.voice_id, + request.speed, + &request.text, + ) + .map(Into::into) + } } fn rendered_nodes_to_proto( diff --git a/rslib/src/backend/error.rs b/rslib/src/backend/error.rs index 7f670d14d..800019bfd 100644 --- a/rslib/src/backend/error.rs +++ b/rslib/src/backend/error.rs @@ -40,6 +40,8 @@ impl AnkiError { AnkiError::FileIoError { .. } => Kind::IoError, AnkiError::MediaCheckRequired => Kind::InvalidInput, AnkiError::InvalidId => Kind::InvalidInput, + #[cfg(windows)] + AnkiError::WindowsError { .. } => Kind::OsError, }; pb::backend::BackendError { diff --git a/rslib/src/card_rendering/mod.rs b/rslib/src/card_rendering/mod.rs index f9c9b8e3d..e9a41df8d 100644 --- a/rslib/src/card_rendering/mod.rs +++ b/rslib/src/card_rendering/mod.rs @@ -7,6 +7,7 @@ use crate::pb; use crate::prelude::*; mod parser; +pub mod tts; mod writer; pub fn strip_av_tags + AsRef>(txt: S) -> String { diff --git a/rslib/src/card_rendering/tts/mod.rs b/rslib/src/card_rendering/tts/mod.rs new file mode 100644 index 000000000..19253c4b7 --- /dev/null +++ b/rslib/src/card_rendering/tts/mod.rs @@ -0,0 +1,20 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use crate::pb::card_rendering::all_tts_voices_response::TtsVoice; +use crate::prelude::*; + +#[cfg(windows)] +#[path = "windows.rs"] +mod inner; +#[cfg(not(windows))] +#[path = "other.rs"] +mod inner; + +pub fn all_voices(validate: bool) -> Result> { + inner::all_voices(validate) +} + +pub fn write_stream(path: &str, voice_id: &str, speed: f32, text: &str) -> Result<()> { + inner::write_stream(path, voice_id, speed, text) +} diff --git a/rslib/src/card_rendering/tts/other.rs b/rslib/src/card_rendering/tts/other.rs new file mode 100644 index 000000000..d68ce5e36 --- /dev/null +++ b/rslib/src/card_rendering/tts/other.rs @@ -0,0 +1,13 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use crate::pb::card_rendering::all_tts_voices_response::TtsVoice; +use crate::prelude::*; + +pub(super) fn all_voices(_validate: bool) -> Result> { + invalid_input!("not implemented for this OS"); +} + +pub(super) fn write_stream(_path: &str, _voice_id: &str, _speed: f32, _text: &str) -> Result<()> { + invalid_input!("not implemented for this OS"); +} diff --git a/rslib/src/card_rendering/tts/windows.rs b/rslib/src/card_rendering/tts/windows.rs new file mode 100644 index 000000000..dc30b8921 --- /dev/null +++ b/rslib/src/card_rendering/tts/windows.rs @@ -0,0 +1,106 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use std::fs::File; +use std::io::Write; + +use futures::executor::block_on; +use windows::core::HSTRING; +use windows::Media::SpeechSynthesis::SpeechSynthesisStream; +use windows::Media::SpeechSynthesis::SpeechSynthesizer; +use windows::Media::SpeechSynthesis::VoiceInformation; +use windows::Storage::Streams::DataReader; + +use crate::error::windows::WindowsErrorDetails; +use crate::error::windows::WindowsSnafu; +use crate::pb::card_rendering::all_tts_voices_response::TtsVoice; +use crate::prelude::*; + +const MAX_BUFFER_SIZE: usize = 128 * 1024; + +pub(super) fn all_voices(validate: bool) -> Result> { + SpeechSynthesizer::AllVoices()? + .into_iter() + .map(|info| TtsVoice::from_voice_information(info, validate)) + .collect() +} + +pub(super) fn write_stream(path: &str, voice_id: &str, speed: f32, text: &str) -> Result<()> { + let voice = find_voice(voice_id)?; + let stream = synthesize_stream(&voice, speed, text)?; + write_stream_to_path(stream, path)?; + Ok(()) +} + +fn find_voice(voice_id: &str) -> Result { + SpeechSynthesizer::AllVoices()? + .into_iter() + .find(|info| { + info.Id() + .map(|id| id.to_string_lossy().eq(voice_id)) + .unwrap_or_default() + }) + .or_invalid("voice id not found") +} + +fn to_hstring(text: &str) -> HSTRING { + let utf16: Vec = text.encode_utf16().collect(); + HSTRING::from_wide(&utf16).expect("Strings are valid Unicode") +} + +fn synthesize_stream( + voice: &VoiceInformation, + speed: f32, + text: &str, +) -> Result { + let synthesizer = SpeechSynthesizer::new()?; + synthesizer.SetVoice(voice).with_context(|_| WindowsSnafu { + details: WindowsErrorDetails::SettingVoice(voice.clone()), + })?; + synthesizer + .Options()? + .SetSpeakingRate(speed as f64) + .context(WindowsSnafu { + details: WindowsErrorDetails::SettingRate(speed), + })?; + let async_op = synthesizer.SynthesizeTextToStreamAsync(&to_hstring(text))?; + let stream = block_on(async_op).context(WindowsSnafu { + details: WindowsErrorDetails::Synthesizing, + })?; + Ok(stream) +} + +fn write_stream_to_path(stream: SpeechSynthesisStream, path: &str) -> Result<()> { + let input_stream = stream.GetInputStreamAt(0)?; + let date_reader = DataReader::CreateDataReader(&input_stream)?; + let stream_size = stream.Size()?.try_into().or_invalid("stream too large")?; + date_reader.LoadAsync(stream_size)?; + let mut file = File::create(path)?; + write_reader_to_file(date_reader, &mut file, stream_size as usize) +} + +fn write_reader_to_file(reader: DataReader, file: &mut File, stream_size: usize) -> Result<()> { + let mut bytes_remaining = stream_size; + let mut buf = [0u8; MAX_BUFFER_SIZE]; + while bytes_remaining > 0 { + let chunk_size = bytes_remaining.min(MAX_BUFFER_SIZE); + reader.ReadBytes(&mut buf[..chunk_size])?; + file.write_all(&buf[..chunk_size])?; + bytes_remaining -= chunk_size; + } + Ok(()) +} + +impl TtsVoice { + fn from_voice_information(info: VoiceInformation, validate: bool) -> Result { + Ok(Self { + id: info.Id()?.to_string_lossy(), + name: info.DisplayName()?.to_string_lossy(), + language: info.Language()?.to_string_lossy(), + // Windows lists voices that fail when actually trying to use them. This has been + // observed with voices from an uninstalled language pack. + // Validation is optional because it may be slow. + available: validate.then(|| synthesize_stream(&info, 1.0, "").is_ok()), + }) + } +} diff --git a/rslib/src/error/mod.rs b/rslib/src/error/mod.rs index 1f595289d..743b4164b 100644 --- a/rslib/src/error/mod.rs +++ b/rslib/src/error/mod.rs @@ -8,6 +8,8 @@ mod invalid_input; pub(crate) mod network; mod not_found; mod search; +#[cfg(windows)] +pub mod windows; pub use db::DbError; pub use db::DbErrorKind; @@ -33,7 +35,7 @@ use crate::links::HelpPage; pub type Result = std::result::Result; -#[derive(Debug, PartialEq, Eq, Snafu)] +#[derive(Debug, PartialEq, Snafu)] pub enum AnkiError { #[snafu(context(false))] InvalidInput { @@ -105,6 +107,11 @@ pub enum AnkiError { source: ImportError, }, InvalidId, + #[cfg(windows)] + #[snafu(context(false))] + WindowsError { + source: windows::WindowsError, + }, } // error helpers @@ -154,6 +161,8 @@ impl AnkiError { AnkiError::FileIoError { source } => source.message(), AnkiError::InvalidInput { source } => source.message(), AnkiError::NotFound { source } => source.message(tr), + #[cfg(windows)] + AnkiError::WindowsError { source } => format!("{source:?}"), } } diff --git a/rslib/src/error/windows.rs b/rslib/src/error/windows.rs new file mode 100644 index 000000000..5a757ecde --- /dev/null +++ b/rslib/src/error/windows.rs @@ -0,0 +1,32 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use snafu::Snafu; + +use super::AnkiError; + +#[derive(Debug, PartialEq, Snafu)] +#[snafu(visibility(pub))] +pub struct WindowsError { + details: WindowsErrorDetails, + source: windows::core::Error, +} + +#[derive(Debug, PartialEq)] +pub enum WindowsErrorDetails { + SettingVoice(windows::Media::SpeechSynthesis::VoiceInformation), + SettingRate(f32), + Synthesizing, + Other, +} + +impl From for AnkiError { + fn from(source: windows::core::Error) -> Self { + AnkiError::WindowsError { + source: WindowsError { + source, + details: WindowsErrorDetails::Other, + }, + } + } +}