# Descended from https://github.com/dwiel/talon_community/blob/master/misc/dictation.py import re from typing import Callable, Optional from talon import Context, Module, actions, grammar, settings, ui from ..numbers.numbers import get_spoken_form_under_one_hundred mod = Module() mod.setting( "context_sensitive_dictation", type=bool, default=False, desc="Look at surrounding text to improve auto-capitalization/spacing in dictation mode. By default, this works by selecting that text & copying it to the clipboard, so it may be slow or fail in some applications.", ) mod.setting( "context_sensitive_dictation_peek_character", type=str, default=" ", desc="This is the character inserted during dictation_peek to ensure that some text is selected even if the cursor is at the start or end of the document. This should be a single character only.", ) mod.list("prose_modifiers", desc="Modifiers that can be used within prose") mod.list("prose_snippets", desc="Snippets that can be used within prose") mod.list("phrase_ender", "List of commands that can be used to end a phrase") mod.list("hours_twelve", desc="Names for hours up to 12") mod.list("hours", desc="Names for hours up to 24") mod.list("minutes", desc="Names for minutes, 01 up to 59") mod.list( "currency", desc="Currency types (e.g., dollars, euros) that can be used within prose", ) ctx = Context() ctx_dragon = Context() ctx_dragon.matches = r""" speech.engine: dragon """ ctx.lists["user.hours_twelve"] = get_spoken_form_under_one_hundred( 1, 12, include_oh_variant_for_single_digits=True, include_default_variant_for_single_digits=True, ) ctx.lists["user.hours"] = get_spoken_form_under_one_hundred( 1, 23, include_oh_variant_for_single_digits=True, include_default_variant_for_single_digits=True, ) ctx.lists["user.minutes"] = get_spoken_form_under_one_hundred( 1, 59, include_oh_variant_for_single_digits=True, include_default_variant_for_single_digits=False, ) @mod.capture(rule="{user.prose_modifiers}") def prose_modifier(m) -> Callable: return getattr(DictationFormat, m.prose_modifiers) @mod.capture( rule=" [(dot | point) ] percent [sign|sine]" ) def prose_percent(m) -> str: s = m.number_string if hasattr(m, "digit_string"): s += "." + m.digit_string return s + "%" @mod.capture( rule=" {user.currency} [[and] [cents|pence]]" ) def prose_currency(m) -> str: s = m.currency + m.number_string_1 if hasattr(m, "number_string_2"): s += "." + m.number_string_2 return s @mod.capture(rule="am|pm") def time_am_pm(m) -> str: return str(m) # this matches eg "twelve thirty-four" -> 12:34 and "twelve hundred" -> 12:00. hmmmmm. @mod.capture( rule="{user.hours} ({user.minutes} | o'clock | hundred hours) []" ) def prose_time_hours_minutes(m) -> str: t = m.hours + ":" if hasattr(m, "minutes"): t += m.minutes else: t += "00" if hasattr(m, "time_am_pm"): t += m.time_am_pm return t @mod.capture(rule="{user.hours_twelve} ") def prose_time_hours_am_pm(m) -> str: return m.hours_twelve + m.time_am_pm @mod.capture(rule=" | ") def prose_time(m) -> str: return str(m) @mod.capture(rule="({user.vocabulary} | | )") def word(m) -> str: """A single word, including user-defined vocabulary.""" if hasattr(m, "vocabulary"): return m.vocabulary elif hasattr(m, "abbreviation"): return m.abbreviation else: return " ".join( actions.dictate.replace_words(actions.dictate.parse_words(m.word)) ) @mod.capture(rule="({user.vocabulary} | | )+") def text(m) -> str: """A sequence of words, including user-defined vocabulary.""" return format_phrase(m) @mod.capture( rule=( "(" "{user.vocabulary}" "| {user.punctuation}" "| {user.prose_snippets}" "| " "| " "| " "| " "| " "| " "| " "| " ")+" ) ) def prose(m) -> str: """Mixed words and punctuation, auto-spaced & capitalized.""" # Straighten curly quotes that were introduced to obtain proper spacing. return apply_formatting(m).replace("“", '"').replace("”", '"') @mod.capture( rule=( "(" "{user.vocabulary}" "| {user.punctuation}" "| {user.prose_snippets}" "| " "| " "| " "| " "| " "| " "| " ")+" ) ) def raw_prose(m) -> str: """Mixed words and punctuation, auto-spaced & capitalized, without quote straightening and commands (for use in dictation mode).""" return apply_formatting(m) # For dragon, omit support for abbreviations and contacts @ctx_dragon.capture("user.text", rule="({user.vocabulary} | )+") def text_dragon(m) -> str: """A sequence of words, including user-defined vocabulary.""" return format_phrase(m) @ctx_dragon.capture( "user.prose", rule="( | {user.vocabulary} | {user.punctuation} | {user.prose_snippets} | | | | | )+", ) def prose_dragon(m) -> str: """Mixed words and punctuation, auto-spaced & capitalized.""" # Straighten curly quotes that were introduced to obtain proper spacing. return apply_formatting(m).replace("“", '"').replace("”", '"') @ctx_dragon.capture( "user.raw_prose", rule="( | {user.vocabulary} | {user.punctuation} | {user.prose_snippets} | | | | )+", ) def raw_prose_dragon(m) -> str: """Mixed words and punctuation, auto-spaced & capitalized, without quote straightening and commands (for use in dictation mode).""" return apply_formatting(m) # ---------- FORMATTING ---------- # def format_phrase(m): words = capture_to_words(m) result = "" for i, word in enumerate(words): if i > 0 and needs_space_between(words[i - 1], word): result += " " result += word return result def capture_to_words(m): words = [] for item in m: words.extend( actions.dictate.replace_words(actions.dictate.parse_words(item)) if isinstance(item, grammar.vm.Phrase) else [item] ) return words def apply_formatting(m): formatter = DictationFormat() formatter.state = None result = "" for item in m: # prose modifiers (cap/no cap/no space) produce formatter callbacks. if isinstance(item, Callable): item(formatter) else: words = ( actions.dictate.replace_words(actions.dictate.parse_words(item)) if isinstance(item, grammar.vm.Phrase) else [item] ) for word in words: result += formatter.format(word) return result # There must be a simpler way to do this, but I don't see it right now. no_space_after = re.compile( r""" (?: [\s\-_/#@([{‘“] # characters that never need space after them | (? bool: return not text or no_space_before.search(text) def omit_space_after(text: str) -> bool: return not text or no_space_after.search(text) def needs_space_between(before: str, after: str) -> bool: return not (omit_space_after(before) or omit_space_before(after)) # # TESTS, uncomment to enable # assert needs_space_between("a", "break") # assert needs_space_between("break", "a") # assert needs_space_between(".", "a") # assert needs_space_between("said", "'hello") # assert needs_space_between("hello'", "said") # assert needs_space_between("hello.", "'John") # assert needs_space_between("John.'", "They") # assert needs_space_between("paid", "$50") # assert needs_space_between("50$", "payment") # assert not needs_space_between("", "") # assert not needs_space_between("a", "") # assert not needs_space_between("a", " ") # assert not needs_space_between("", "a") # assert not needs_space_between(" ", "a") # assert not needs_space_between("a", ",") # assert not needs_space_between("'", "a") # assert not needs_space_between("a", "'") # assert not needs_space_between("and-", "or") # assert not needs_space_between("mary", "-kate") # assert not needs_space_between("$", "50") # assert not needs_space_between("US", "$") # assert not needs_space_between("(", ")") # assert not needs_space_between("(", "e.g.") # assert not needs_space_between("example", ")") # assert not needs_space_between("example", '".') # assert not needs_space_between("example", '."') # assert not needs_space_between("hello'", ".") # assert not needs_space_between("hello.", "'") no_cap_after = re.compile( r"""( e\.g\. | i\.e\. )$""", re.VERBOSE, ) def auto_capitalize(text, state=None): """ Auto-capitalizes text. Text must contain complete words, abbreviations, and formatted expressions. `state` argument means: - None: Don't capitalize initial word. - "sentence start": Capitalize initial word. - "after newline": Don't capitalize initial word, but we're after a newline. Used for double-newline detection. Returns (capitalized text, updated state). """ output = "" # Imagine a metaphorical "capitalization charge" travelling through the # string left-to-right. charge = state == "sentence start" newline = state == "after newline" sentence_end = False for c in text: # Sentence endings followed by space & double newlines create a charge. if (sentence_end and c in " \n\t") or (newline and c == "\n"): charge = True # Alphanumeric characters and commas/colons absorb charge & try to # capitalize (for numbers & punctuation this does nothing, which is what # we want). elif charge and (c.isalnum() or c in ",:"): charge = False c = c.capitalize() # Otherwise the charge just passes through. output += c newline = c == "\n" sentence_end = c in ".!?" and not no_cap_after.search(output) return output, ( "sentence start" if charge or sentence_end else "after newline" if newline else None ) # ---------- DICTATION AUTO FORMATTING ---------- # class DictationFormat: def __init__(self): self.reset() def reset(self): self.reset_context() self.force_no_space = False self.force_capitalization = None # Can also be "cap" or "no cap". def reset_context(self): self.before = "" self.state = "sentence start" def update_context(self, before): if before is None: return self.reset_context() self.pass_through(before) def pass_through(self, text): _, self.state = auto_capitalize(text, self.state) self.before = text or self.before def format(self, text, auto_cap=True): if not self.force_no_space and needs_space_between(self.before, text): text = " " + text self.force_no_space = False if auto_cap: text, self.state = auto_capitalize(text, self.state) if self.force_capitalization == "cap": text = format_first_letter(text, lambda s: s.capitalize()) self.force_capitalization = None if self.force_capitalization == "no cap": text = format_first_letter(text, lambda s: s.lower()) self.force_capitalization = None self.before = text or self.before return text # These are used as callbacks by prose modifiers / dictation_mode commands. def cap(self): self.force_capitalization = "cap" def no_cap(self): self.force_capitalization = "no cap" def no_space(self): # This is typically used after repositioning the cursor, so it is helpful to # reset capitalization as well. # # FIXME: this sets state to "sentence start", capitalizing the next # word. probably undesirable, since most places are not the start of # sentences? self.reset_context() self.force_no_space = True def format_first_letter(text, formatter): i = -1 for i, c in enumerate(text): if c.isalpha(): break if i >= 0 and i < len(text): text = text[:i] + formatter(text[i]) + text[i + 1 :] return text dictation_formatter = DictationFormat() ui.register("app_deactivate", lambda app: dictation_formatter.reset()) ui.register("win_focus", lambda win: dictation_formatter.reset()) def reformat_last_utterance(formatter): text = actions.user.get_last_phrase() actions.user.clear_last_phrase() text = formatter(text) actions.user.add_phrase_to_history(text) actions.insert(text) @mod.action_class class Actions: def dictation_format_reset(): """Resets the dictation formatter""" return dictation_formatter.reset() def dictation_format_cap(): """Sets the dictation formatter to capitalize""" dictation_formatter.cap() def dictation_format_no_cap(): """Sets the dictation formatter to not capitalize""" dictation_formatter.no_cap() def dictation_format_no_space(): """Sets the dictation formatter to not prepend a space""" dictation_formatter.no_space() def dictation_reformat_cap(): """Capitalizes the last utterance""" reformat_last_utterance( lambda s: format_first_letter(s, lambda c: c.capitalize()) ) def dictation_reformat_no_cap(): """Lowercases the last utterance""" reformat_last_utterance(lambda s: format_first_letter(s, lambda c: c.lower())) def dictation_reformat_no_space(): """Removes space before the last utterance""" reformat_last_utterance(lambda s: s[1:] if s.startswith(" ") else s) def dictation_insert_raw(text: str): """Inserts text as-is, without invoking the dictation formatter.""" actions.user.dictation_insert(text, auto_cap=False) def dictation_insert(text: str, auto_cap: bool = True) -> str: """Inserts dictated text, formatted appropriately.""" add_space_after = False if settings.get("user.context_sensitive_dictation"): # Peek left if we might need leading space or auto-capitalization; # peek right if we might need trailing space. NB. We peek right # BEFORE insertion to avoid breaking the undo-chain between the # inserted text and the trailing space. need_left = not omit_space_before(text) or ( auto_cap and text != auto_capitalize(text, "sentence start")[0] ) need_right = not omit_space_after(text) before, after = actions.user.dictation_peek(need_left, need_right) dictation_formatter.update_context(before) add_space_after = after is not None and needs_space_between(text, after) text = dictation_formatter.format(text, auto_cap) # Straighten curly quotes that were introduced to obtain proper # spacing. The formatter context still has the original curly quotes # so that future dictation is properly formatted. text = text.replace("“", '"').replace("”", '"') actions.user.add_phrase_to_history(text) actions.user.insert_between(text, " " if add_space_after else "") def dictation_peek(left: bool, right: bool) -> tuple[Optional[str], Optional[str]]: """ Gets text around the cursor to inform auto-spacing and -capitalization. Returns (before, after), where `before` is some text before the cursor, and `after` some text after it. Results are not guaranteed; `before` and/or `after` may be None, indicating no information. If `before` is the empty string, this means there is nothing before the cursor (we are at the beginning of the document); likewise for `after`. To optimize performance, pass `left = False` if you won't need `before`, and `right = False` if you won't need `after`. dictation_peek() is intended for use before inserting text, so it may delete any currently selected text. """ if not (left or right): return None, None before, after = None, None # Inserting a character ensures we select something even if we're at # document start; some editors 'helpfully' copy the current line if we # edit.copy() while nothing is selected. actions.insert(settings.get("user.context_sensitive_dictation_peek_character")) if left: # In principle the previous word should suffice, but some applications # have a funny concept of what the previous word is (for example, they # may only take the "`" at the end of "`foo`"). To be double sure we # take two words left. I also tried taking a line up + a word left, but # edit.extend_up() = key(shift-up) doesn't work consistently in the # Slack webapp (sometimes escapes the text box). actions.edit.extend_word_left() actions.edit.extend_word_left() before = actions.edit.selected_text()[:-1] # Unfortunately, in web Slack, if our selection ends at newline, # this will go right over the newline. Argh. actions.edit.right() if not right: actions.key("backspace") # remove the peek character else: actions.edit.left() # go left before the peek character # We want to select at least two characters to the right, plus the character # we inserted, because no_space_before needs two characters in the worst # case -- for example, inserting before "' hello" we don't want to add # space, while inserted before "'hello" we do. # # We use 2x extend_word_right() because it's fewer keypresses (lower # latency) than 3x extend_right(). Other options all seem to have # problems. For instance, extend_line_end() might not select all the way # to the next newline if text has been wrapped across multiple lines; # extend_line_down() sometimes escapes the current text box (eg. in a # browser address bar). 1x extend_word_right() _usually_ works, but on # Windows in Firefox it doesn't always select enough characters. actions.edit.extend_word_right() actions.edit.extend_word_right() after = actions.edit.selected_text()[1:] actions.edit.left() actions.user.delete_right() # remove peek character return before, after