Documentation
¶
Overview ¶
Package text normalizes written text into a form better suited to text-to-speech: it strips Markdown, expands numbers, currency, percentages, dates, units and acronyms into spoken words, spaces out phone-number digits, and spells email addresses. The transforms are plain string functions; a VoiceFormatter bundles a configurable ordered pipeline of them, in the shape the TTS base's text transformers take, so it is registered with SetTextTransformers and reshapes each unit on its way to the provider.
Reshaping for the provider is not the same as filtering the text: what a synthesizer needs to see ("A P I", "forty-two dollars") is not what the conversation should record. The Filter interface below is for the other job, where the change belongs to the text itself.
The expansions target English (the num2words "en" conventions): most TTS providers already normalize other languages server-side.
Index ¶
- Variables
- func Concatenate(parts []Part) string
- func EmailToSpeech(text string) string
- func ExpandCurrency(text string) string
- func ExpandPercentages(text string) string
- func ExpandPhoneNumbers(text string) string
- func ExpandUnits(text string) string
- func IsSentenceEnding(r rune) bool
- func LongestTrailingPartialMatch(text string, candidates []string) int
- func NormalizeAcronyms(text string) string
- func NormalizeDates(text string) string
- func StripMarkdown(text string) string
- type Aggregation
- type Aggregator
- type Filter
- type FormatterOptions
- type InterruptibleFilter
- type MarkdownFilter
- type MarkdownFilterOptions
- type MatchAction
- type Part
- type PatternHandler
- type PatternPairAggregator
- func (a *PatternPairAggregator) AddPattern(typ frames.AggregationType, start, end string, action MatchAction) error
- func (a *PatternPairAggregator) Aggregate(text string) []Aggregation
- func (a *PatternPairAggregator) Flush() (Aggregation, bool)
- func (a *PatternPairAggregator) OnPatternMatch(typ frames.AggregationType, h PatternHandler)
- func (a *PatternPairAggregator) Reset()
- func (a *PatternPairAggregator) Text() Aggregation
- type PunktTokenizer
- type SentenceTokenizer
- type SimpleAggregator
- type SkipTagsAggregator
- type StartEndTags
- type Transform
- type VoiceFormatter
Constants ¶
This section is empty.
Variables ¶
var ErrReservedAggregationType = errors.New("aggregation type is reserved")
ErrReservedAggregationType is returned by AddPattern for a type that names how text is grouped by default, which cannot also name a pattern.
Functions ¶
func Concatenate ¶ added in v0.1.0
Concatenate joins parts into one string, supplying the spacing the parts do not carry themselves.
Text reaching a conversation arrives as pieces of two kinds, and they mix within a single turn: what a model streams carries its own spacing, and what a synthesizer reports as spoken words does not. Joining them all with a space doubles the spaces of the first kind; joining them all without one runs the words of the second kind together. So each piece says which it is, a run of one kind is joined the way that kind wants, and the boundary between two runs gets a space only where neither side already has one.
The logic assumes a run of parts that include their own spacing is well-formed: not several separate runs with a space-less boundary between them, as in the sequence "hello ", "world.", "goodnight ", "moon" would be.
func EmailToSpeech ¶
EmailToSpeech rewrites email addresses into a spoken form, e.g. "user@example.com" → "user at example dot com".
func ExpandCurrency ¶
ExpandCurrency expands currency amounts into spoken form, e.g. "$42.50" → "forty-two dollars and fifty cents".
func ExpandPercentages ¶
ExpandPercentages expands percentage expressions into spoken form, e.g. "50%" → "fifty percent".
func ExpandPhoneNumbers ¶
ExpandPhoneNumbers spaces out phone-number digits so a synthesizer reads them one by one, e.g. "123-456-7890" → "1 2 3 4 5 6 7 8 9 0". A run bordered by another digit is left alone, so digits inside a longer number are not split.
func ExpandUnits ¶
ExpandUnits expands unit abbreviations after a number into their spoken form, e.g. "5km" → "5 kilometers", "100kph" → "100 kilometers per hour". A quantity of exactly one takes the singular form, "1km" → "1 kilometer".
func IsSentenceEnding ¶ added in v0.1.0
IsSentenceEnding reports whether r can end a sentence.
func LongestTrailingPartialMatch ¶ added in v0.1.0
LongestTrailingPartialMatch returns the length of the longest suffix of text that is a proper prefix of one of the candidates.
It is what lets a delimiter split across two pieces of text still be recognized. Text ending in "<spe" of "<spell>" is the start of a tag that has not arrived in full, so holding those characters back until the next piece says whether they open a tag keeps the delimiter from being emitted as if it were ordinary text.
func NormalizeAcronyms ¶
NormalizeAcronyms spaces out the letters of an all-caps acronym so each is pronounced individually, e.g. "API" → "A P I".
func NormalizeDates ¶
NormalizeDates expands ISO (YYYY-MM-DD) and US (MM/DD/YYYY or MM-DD-YYYY) dates into spoken form, e.g. "2023-05-10" → "May 10th, two thousand and twenty-three". A string that parses to no valid calendar date is left unchanged.
func StripMarkdown ¶
StripMarkdown removes Markdown formatting symbols that have no spoken equivalent: fenced and inline code, bold/italic markers, ATX headers, blockquote markers and horizontal rules. Link and image syntax is left intact, since the label text is meaningful.
Types ¶
type Aggregation ¶ added in v0.1.0
type Aggregation struct {
// Text is the aggregated text.
Text string
// Type is how it was aggregated.
Type frames.AggregationType
// RawText is the text this unit was cut from, delimiters and all, set when
// the unit came from a matched pattern such as a code block. Empty for an
// ordinary aggregation, where Text is already the written form.
RawText string
}
Aggregation is a run of text an aggregator has completed, and how it was aggregated.
func (Aggregation) Original ¶ added in v0.1.0
func (a Aggregation) Original() string
Original returns the text to record as written: RawText when the unit was cut from a larger match, and Text otherwise.
type Aggregator ¶ added in v0.1.0
type Aggregator interface {
// Type is how this aggregator groups text. A caller reads it to know what to
// expect before any text has been aggregated: grouping by token, say, means
// no unit ever completes a sentence.
Type() frames.AggregationType
// Aggregate folds text into the buffer and returns every unit it completed.
Aggregate(text string) []Aggregation
// Flush returns whatever is left in the buffer, for the end of a response.
Flush() (Aggregation, bool)
// Reset clears the buffer.
Reset()
}
Aggregator groups streamed text into the units a synthesizer is given.
type Filter ¶
Filter transforms text before it is handed to a speech synthesizer. The TTS base calls Filter on each complete sentence just before synthesizing it.
type FormatterOptions ¶
type FormatterOptions struct {
// StripMarkdown removes Markdown formatting symbols.
StripMarkdown bool
// EmailToSpeech spells email addresses ("a@b.com" → "a at b dot com").
EmailToSpeech bool
// ExpandPhoneNumbers spaces out phone-number digits.
ExpandPhoneNumbers bool
// NormalizeDates expands ISO and US dates into spoken form.
NormalizeDates bool
// ExpandCurrency expands currency amounts ("$5" → "five dollars").
ExpandCurrency bool
// ExpandPercentages expands percentages ("50%" → "fifty percent").
ExpandPercentages bool
// ExpandUnits expands unit abbreviations ("5km" → "5 kilometers").
ExpandUnits bool
// NormalizeAcronyms letter-spaces all-caps acronyms ("API" → "A P I").
NormalizeAcronyms bool
// ExpandNumbers spells numeric digits as words. Off by default, since many
// numbers (versions, codes, IDs) read better as digits.
ExpandNumbers bool
// NumberDigitCutoff is passed to ExpandNumbers when it is enabled: numbers
// above it are read digit-by-digit. Zero or less spells every number.
NumberDigitCutoff int
// CustomReplacements are {pattern, replacement} regex rules applied last.
CustomReplacements [][2]string
}
FormatterOptions configures which transforms a VoiceFormatter applies. The zero value enables nothing; use DefaultFormatterOptions for the recommended set and toggle individual fields from there.
func DefaultFormatterOptions ¶
func DefaultFormatterOptions() FormatterOptions
DefaultFormatterOptions returns the recommended set: every transform except ExpandNumbers (which can mangle numbers better left as digits).
type InterruptibleFilter ¶ added in v0.1.0
type InterruptibleFilter interface {
Filter
HandleInterruption()
ResetInterruption()
}
InterruptibleFilter is a Filter that carries state between calls and needs to be told when speech is cut off. Markdown structures such as code blocks and tables arrive split across several sentences, so a filter tracking one has to abandon it when the text that would have closed it never arrives. The TTS base calls HandleInterruption on an interruption, and ResetInterruption before filtering resumes.
type MarkdownFilter ¶ added in v0.1.0
type MarkdownFilter struct {
// contains filtered or unexported fields
}
MarkdownFilter converts Markdown to plain text for speech, preserving the structure around it: leading and trailing spaces survive, so text streamed word by word still joins up, and numbered list markers are spoken.
It is stateful. Code blocks and tables arrive split across several calls, so the filter remembers that it is inside one; an interruption abandons whatever was half-read. Build one with NewMarkdownFilter.
func NewMarkdownFilter ¶ added in v0.1.0
func NewMarkdownFilter(opts MarkdownFilterOptions) *MarkdownFilter
NewMarkdownFilter builds a MarkdownFilter from opts.
func (*MarkdownFilter) Filter ¶ added in v0.1.0
func (f *MarkdownFilter) Filter(text string) string
Filter converts one chunk of Markdown into the plain text to speak.
func (*MarkdownFilter) HandleInterruption ¶ added in v0.1.0
func (f *MarkdownFilter) HandleInterruption()
HandleInterruption abandons any code block or table the filter was reading through, since the text that would have closed it is never coming.
func (*MarkdownFilter) ResetInterruption ¶ added in v0.1.0
func (f *MarkdownFilter) ResetInterruption()
ResetInterruption returns the filter to normal operation once the interruption has been dealt with.
type MarkdownFilterOptions ¶ added in v0.1.0
type MarkdownFilterOptions struct {
// Enabled turns the filter on. When false, Filter returns its input as
// written, which is the switch to reach for to see what the model actually
// produced.
Enabled bool
// FilterCode drops fenced code blocks instead of speaking their contents.
// Code blocks span several chunks of streamed text, so the filter tracks
// whether it is inside one across calls.
FilterCode bool
// FilterTables drops table contents. It also turns on table parsing, without
// which a table is only ever a run of pipes and dashes.
//
// Pair it with FilterRepeatedSequences off. That rule runs first and flattens
// a delimiter row of five or more dashes ("|--------|") to bare pipes, and a
// table is not recognized without a well-formed delimiter row. A short row
// ("|---|") is under the threshold and comes through either way.
FilterTables bool
// FilterRepeatedSequences drops a run of five or more of the same character,
// the shape of an ASCII rule or a keyboard smash rather than of speech.
FilterRepeatedSequences bool
}
MarkdownFilterOptions configures a MarkdownFilter. The zero value filters nothing at all (Enabled is false); use DefaultMarkdownFilterOptions for the recommended set and toggle individual fields from there.
func DefaultMarkdownFilterOptions ¶ added in v0.1.0
func DefaultMarkdownFilterOptions() MarkdownFilterOptions
DefaultMarkdownFilterOptions returns the recommended set: filtering on, with repeated sequences dropped, and code and tables spoken rather than skipped.
type MatchAction ¶ added in v0.1.0
type MatchAction string
MatchAction is what an aggregator does with a pattern once both its delimiters have arrived.
const ( // MatchRemove drops the match and its delimiters from the text. Aggregation // carries on as though the run had never been there, which is what a model // asked to mark up its own reasoning wants: the markup is read by the // handler and never spoken. MatchRemove MatchAction = "remove" // MatchKeep drops the delimiters and keeps what was between them, which then // aggregates as ordinary text. A handler still sees the match, so this is // how to be told about a run without changing what is spoken. MatchKeep MatchAction = "keep" // MatchAggregate drops the delimiters and returns what was between them as a // unit of its own, whole rather than cut into sentences. Any text before the // opening delimiter is returned first, complete sentence or not. MatchAggregate MatchAction = "aggregate" )
type Part ¶ added in v0.1.0
type Part struct {
// Text is the piece itself.
Text string
// IncludesInterPartSpaces reports whether the spacing that separates this
// piece from the ones around it is already part of Text. A model streaming a
// sentence sends " world" with the space; a stream of spoken words sends
// "world" without one, and the join has to supply it.
IncludesInterPartSpaces bool
}
Part is one piece of text waiting to be joined into a whole by Concatenate.
type PatternHandler ¶ added in v0.1.0
type PatternHandler func(match Aggregation)
PatternHandler is called with each completed match of the pattern it was registered for.
type PatternPairAggregator ¶ added in v0.1.0
type PatternPairAggregator struct {
*SimpleAggregator
// contains filtered or unexported fields
}
PatternPairAggregator groups text into sentences the way SimpleAggregator does, and additionally recognizes runs delimited by a registered pair.
It is for a model whose output carries structure the speech should not: a reasoning block to be dropped, a code block to be spoken as one unit rather than cut at every period, a speaker tag that should switch voices. A handler registered for a pattern is called as each match completes, and the action the pattern was registered with decides whether what matched is dropped, kept as ordinary text, or returned as a unit of its own.
A pattern split across several pieces of arriving text is still recognized.
func NewPatternPairAggregator ¶ added in v0.1.0
func NewPatternPairAggregator( aggregateBy frames.AggregationType, tokenizer SentenceTokenizer, ) *PatternPairAggregator
NewPatternPairAggregator builds an aggregator that groups text by aggregateBy and finds sentence boundaries with tokenizer. Register the delimiter pairs to recognize with AddPattern.
func (*PatternPairAggregator) AddPattern ¶ added in v0.1.0
func (a *PatternPairAggregator) AddPattern( typ frames.AggregationType, start, end string, action MatchAction, ) error
AddPattern registers a delimiter pair to recognize, under a type naming what it delimits ("code", "speaker", "reasoning"). The type is what a handler is registered against and what the resulting unit is labeled with, so it cannot be one of the built-in aggregation types.
func (*PatternPairAggregator) Aggregate ¶ added in v0.1.0
func (a *PatternPairAggregator) Aggregate(text string) []Aggregation
Aggregate implements Aggregator.
func (*PatternPairAggregator) Flush ¶ added in v0.1.0
func (a *PatternPairAggregator) Flush() (Aggregation, bool)
Flush implements Aggregator.
A run whose closing delimiter never arrived is degraded the way a closed one would have been. What was to be removed or returned on its own is dropped, since neither is ever spoken as it stands, along with the opening delimiter and everything after it. What was to be kept is kept, since a closed run of that kind is spoken too. A buffer ending partway through an opening delimiter is trimmed on the same grounds, which also trims a stray character that merely looks like the start of one.
func (*PatternPairAggregator) OnPatternMatch ¶ added in v0.1.0
func (a *PatternPairAggregator) OnPatternMatch(typ frames.AggregationType, h PatternHandler)
OnPatternMatch registers the handler called each time a pattern of the given type completes.
func (*PatternPairAggregator) Reset ¶ added in v0.1.0
func (a *PatternPairAggregator) Reset()
Reset implements Aggregator. The registered patterns and their handlers survive it: they describe the text this aggregator reads, not the response it is partway through.
func (*PatternPairAggregator) Text ¶ added in v0.1.0
func (a *PatternPairAggregator) Text() Aggregation
Text reports what is buffered but not yet complete, labeled with the pattern it is partway through when it is inside one.
type PunktTokenizer ¶ added in v0.1.0
type PunktTokenizer struct {
// contains filtered or unexported fields
}
PunktTokenizer finds sentence boundaries with the Punkt algorithm, an unsupervised model trained per language. Punkt is what tells a sentence ending from an abbreviation: it keeps "Dr. Smith is here." and "M. Dupont est la." whole rather than breaking after the title.
func NewPunkt ¶ added in v0.1.0
func NewPunkt(training []byte) (*PunktTokenizer, error)
NewPunkt builds a tokenizer over a model trained for another language. The training data is the JSON the Punkt project publishes per language; embed the one the bot speaks and pass its bytes.
func NewPunktEnglish ¶ added in v0.1.0
func NewPunktEnglish() (*PunktTokenizer, error)
NewPunktEnglish builds a tokenizer over the embedded English model.
func (*PunktTokenizer) MatchEndOfSentence ¶ added in v0.1.0
func (p *PunktTokenizer) MatchEndOfSentence(text string) int
MatchEndOfSentence implements SentenceTokenizer.
The model's answer is verified before it is trusted: asked about a single word it returns that word as a whole sentence, and LLM output arrives a token at a time, so a boundary is only reported when the text actually ends on sentence-ending punctuation.
type SentenceTokenizer ¶ added in v0.1.0
type SentenceTokenizer interface {
// MatchEndOfSentence returns the byte offset just past the end of the first
// complete sentence in text, or 0 when text does not yet hold one.
MatchEndOfSentence(text string) int
}
SentenceTokenizer finds sentence boundaries in a stream of text.
This interface has no counterpart upstream, where the boundary function is called directly. It exists so a caller can supply a different segmenter, or avoid carrying a trained model, without the rest of the pipeline changing. The default is Punkt, which is what the behavior is defined against.
type SimpleAggregator ¶ added in v0.1.0
type SimpleAggregator struct {
// contains filtered or unexported fields
}
SimpleAggregator groups text into sentences, or passes each token straight through when aggregating by token.
A sentence boundary is confirmed by lookahead: the mark that could end a sentence is not acted on until the first non-whitespace character after it has arrived. That is what tells "$29." apart from "$29. Next", since only the character following the period says whether the text ended there. Whitespace alone says nothing, because it appears in both.
func NewSimpleAggregator ¶ added in v0.1.0
func NewSimpleAggregator(aggregateBy frames.AggregationType, tokenizer SentenceTokenizer) *SimpleAggregator
NewSimpleAggregator builds an aggregator that groups text by aggregateBy, finding sentence boundaries with tokenizer.
func NewTokenAggregator ¶ added in v0.1.0
func NewTokenAggregator() *SimpleAggregator
NewTokenAggregator builds an aggregator that hands text on as it arrives, grouping nothing. It takes no tokenizer because it never looks for a sentence boundary: a service that streams tokens sends each one as the model wrote it, trading the naturalness sentence-sized synthesis gives for the latency of not waiting for one to finish.
func (*SimpleAggregator) Aggregate ¶ added in v0.1.0
func (a *SimpleAggregator) Aggregate(text string) []Aggregation
Aggregate implements Aggregator. Aggregating by token returns the text as it arrives; aggregating by sentence walks it a character at a time, so a chunk carrying more than one boundary completes more than one sentence.
func (*SimpleAggregator) Buffer ¶ added in v0.1.0
func (a *SimpleAggregator) Buffer() string
Buffer reports the raw buffered text, untrimmed. A caller running channels of text in parallel compares against it to tell whether its own buffer still mirrors this one.
func (*SimpleAggregator) Flush ¶ added in v0.1.0
func (a *SimpleAggregator) Flush() (Aggregation, bool)
Flush implements Aggregator. Aggregating by token buffers nothing, so there is never anything left.
func (*SimpleAggregator) Reset ¶ added in v0.1.0
func (a *SimpleAggregator) Reset()
Reset implements Aggregator.
func (*SimpleAggregator) Text ¶ added in v0.1.0
func (a *SimpleAggregator) Text() Aggregation
Text reports what is buffered but not yet complete.
func (*SimpleAggregator) Type ¶ added in v0.1.0
func (a *SimpleAggregator) Type() frames.AggregationType
Type implements Aggregator.
type SkipTagsAggregator ¶ added in v0.1.0
type SkipTagsAggregator struct {
*SimpleAggregator
// contains filtered or unexported fields
}
SkipTagsAggregator groups text into sentences the way SimpleAggregator does, except between a pair of tags, where it never looks for a sentence boundary.
It is for text carrying delimited runs that must reach the synthesizer whole. A pronunciation tag such as <spell>A.B.C.</spell> is full of periods that end no sentence, and splitting it would hand the synthesizer half a tag. Once a start tag is seen the aggregator keeps accumulating unconditionally until the matching end tag arrives, whatever punctuation it passes on the way.
The tags themselves are pass-through markers. They are not removed, because the synthesizer is what interprets them.
func NewSkipTagsAggregator ¶ added in v0.1.0
func NewSkipTagsAggregator( aggregateBy frames.AggregationType, tokenizer SentenceTokenizer, tags []StartEndTags, ) *SkipTagsAggregator
NewSkipTagsAggregator builds an aggregator that groups text by aggregateBy, finding sentence boundaries with tokenizer, and holds off on the boundaries between any of the given tag pairs.
func (*SkipTagsAggregator) Aggregate ¶ added in v0.1.0
func (a *SkipTagsAggregator) Aggregate(text string) []Aggregation
Aggregate implements Aggregator.
Aggregating by token passes text straight through, except that a run inside a tag is held until the closing tag arrives, so the tag is never handed over in pieces. Text ending in what could be the start of a tag ("<spe" of "<spell>") is held back too, until the next piece says whether it opens one.
func (*SkipTagsAggregator) Flush ¶ added in v0.1.0
func (a *SkipTagsAggregator) Flush() (Aggregation, bool)
Flush implements Aggregator.
Aggregating by sentence returns what is buffered as it stands. The tags mark text rather than replacing it, so a run whose closing tag never arrived is still spoken. Aggregating by token returns whatever is still held behind an unclosed tag, rather than dropping it.
func (*SkipTagsAggregator) Reset ¶ added in v0.1.0
func (a *SkipTagsAggregator) Reset()
Reset implements Aggregator. It clears the buffer and the tag the scan was inside, so the next response starts outside any tag.
func (*SkipTagsAggregator) Text ¶ added in v0.1.0
func (a *SkipTagsAggregator) Text() Aggregation
Text reports what is buffered but not yet complete.
type StartEndTags ¶ added in v0.1.0
StartEndTags is a pair of delimiters marking a run of text: an opening tag and the closing tag that ends it.
func ParseStartEndTags ¶ added in v0.1.0
func ParseStartEndTags( text string, tags []StartEndTags, current *StartEndTags, index int, ) (*StartEndTags, int)
ParseStartEndTags reports which tag pair the text is currently inside, and the offset a later call should resume scanning from.
Text arrives a piece at a time, so a tag can be split across pieces and the buffer has to be rescanned as it grows. The returned offset is how much of the buffer this call settled: pass it back with the next call, along with the tag returned, and only what is new gets scanned again.
A nil current means no tag is open, and the scan looks for one to open. A non-nil current means one is, and the scan looks only for its closing tag.
type Transform ¶
Transform is a single text-normalization step. All the package's transforms have this shape, so they compose directly and a VoiceFormatter is just an ordered list of them.
func ExpandNumbers ¶
ExpandNumbers returns a transform that expands numbers into spoken form. A number whose whole part exceeds digitCutoff is read digit-by-digit ("2026" → "2 0 2 6"); at or below the cutoff it is spelled as a quantity ("42" → "forty-two"). A digitCutoff of zero or less disables the cutoff, so every number is spelled as a quantity.
Decimals are read as the whole part followed by their fractional digits, one at a time, so every written digit is spoken ("1.0" → "one point zero").
func ReplaceText ¶
ReplaceText returns a transform that applies a list of {pattern, replacement} rules in order. Patterns are regular expressions compiled up front, so an invalid pattern returns an error here rather than failing during synthesis.
type VoiceFormatter ¶
type VoiceFormatter struct {
// contains filtered or unexported fields
}
VoiceFormatter applies an ordered pipeline of text transforms. Build one with NewVoiceFormatter and register it with the TTS base's SetTextTransformers, against frames.AnyAggregation to reshape every unit or against one aggregation type to reshape only those.
It belongs among the transforms rather than the text filters because what it does is for the provider alone: an acronym letter-spaced so it is spelled out, an amount written in words. The conversation should record what the model wrote, not the shape the synthesizer needed it in.
func NewVoiceFormatter ¶
func NewVoiceFormatter(opts FormatterOptions) (*VoiceFormatter, error)
NewVoiceFormatter builds a VoiceFormatter from opts. The transforms run in a deliberate order (structural cleanup, then language expansions, then user replacements) chosen so earlier steps do not hide patterns later ones match: email before phone and acronyms, units before acronyms. It returns an error only if a CustomReplacements pattern fails to compile.
func (*VoiceFormatter) Transform ¶ added in v0.1.0
func (f *VoiceFormatter) Transform( _ context.Context, text string, _ frames.AggregationType, ) (string, error)
Transform applies every configured transform to text, in order. Its shape is the one the TTS base's text transformers take, so a VoiceFormatter can be registered as one directly. It never fails: every transform it holds is a plain rewrite of the text.