#!/usr/bin/env python3 """VOX prosody encoder for FlexTalk, ported from h4rm0n1c/NetTTS. Transforms plain text into FlexTalk vendor-tagged text: \\!wH1 ... \\!wH0 staccato word-cadence wrapper \\!br beat boundary \\!sfNN final pause (centiseconds) Our host passes TTSDATAFLAG_TAGGED, so the engine honors these tags. Rule set studied from https://github.com/h4rm0n1c/NetTTS (vox_parser.cpp, no license declared; this is an independent reimplementation). Usage: vox_parser.py [--clean] [text] echo "text" | vox_parser.py --clean omits the \\!wH1/\\!wH0 wrapper (plain cadence, no word stutter). """ import re import sys BR = r'\!br' WH1 = r'\!wH1' WH0 = r'\!wH0' TAGS = (BR, WH1, WH0) RE_LEADIN = re.compile( r'^\s*((?:Now|Please|A\s+reminder|On\s+behalf\s+of|Good\s+(?:morning|evening)' r'|This\s+(?:automated\s+train|tram)))\b', re.IGNORECASE | re.ASCII) RE_TIME12 = re.compile( r'\b(\d{1,2})[:.](\d{2})\s*((?:A\.?M\.?)|(?:P\.?M\.?))(?:\.)?\b', re.IGNORECASE | re.ASCII) RE_TIME_IS = re.compile(r'(\b[Tt]he\s+time\s+is)\s+(\d+)', re.ASCII) RE_24H = re.compile(r'\b(\d{2})00\s+hours\b', re.IGNORECASE | re.ASCII) RE_N3 = re.compile(r'\b([1-9])(\d)(\d)\b', re.ASCII) RE_DEGREES = re.compile(r'\b(\d+)\s+degrees\b', re.IGNORECASE | re.ASCII) LIGHT, MEDIUM, HEAVY = 0, 1, 2 STOP_WORDS = { 'the', 'thee', 'a', 'an', 'to', 'for', 'of', 'in', 'on', 'at', 'by', 'with', 'from', 'as', 'is', 'are', 'was', 'were', 'this', 'that', } LIGHT_VERBS = { 'welcome', 'wait', 'stand', 'provide', 'provided', 'return', 'remain', 'maintain', 'maintained', 'verify', 'contact', 'board', 'arriving', 'inbound', 'bound', 'commence', 'commences', } UNITS = {'degrees', 'hours', 'percent', '%'} LINKERS = {'at', 'in', 'on', 'to', 'of', 'for', 'from', 'by'} DETERMINERS = {'the', 'thee', 'a', 'an', 'this', 'that', 'these', 'those', 'some', 'any', 'each', 'every', 'no'} PUNCT = '.,;:!?\'"' def trim(s): return s.strip() def lower(s): return s.lower() def bare(tok): return strip_trailing_punct(tok) def is_all_digits(t): return bool(t) and all('0' <= c <= '9' for c in t) def is_titlecase_word(t): return bool(t) and t[0].isalpha() and t[0].isupper() def strip_trailing_punct(t): while t and t[-1] in PUNCT: t = t[:-1] return t def split_tail(tok): """Return (core, trailing punctuation).""" core, tail = tok, '' while core and core[-1] in PUNCT: tail = core[-1] + tail core = core[:-1] return core, tail def ends_with_br(s): return s.endswith(' ' + BR) def is_tag(tok): return tok in TAGS def split_sentences(text): out, cur = [], '' i = 0 n = len(text) while i < n: c = text[i] cur += c if c in '.!?': j = i + 1 while j < n and text[j] in '"\'': cur += text[j] j += 1 if trim(cur): out.append(trim(cur)) cur = '' i = j - 1 i += 1 tail = trim(cur) if tail: out.append(tail) return out def apply_leadin_break(s): m = RE_LEADIN.search(s) if not m: return s head = trim(m.group(0)) rest = trim(s[m.end():]) return head + ' ' + BR + ' ' + rest def is_single_letter(tok): b = bare(tok) if len(b) == 1 and b[0].isalpha() and b[0].isupper(): return True return len(b) == 2 and b[0].isalpha() and b[0].isupper() and b[1] == ':' def is_number(tok): b = bare(tok) return is_all_digits(b) def apply_thee_rule(s): tok = s.split() if not tok: return s def prev_word(i, nth): for k in range(i - 1, -1, -1): if is_tag(tok[k]): continue nth -= 1 if nth == 0: return lower(split_tail(tok[k])[0]) return '' def next_word(i, nth): for k in range(i + 1, len(tok)): if is_tag(tok[k]): continue nth -= 1 if nth == 0: return tok[k] return '' for i in range(len(tok)): if is_tag(tok[i]): continue core, tail = split_tail(tok[i]) if lower(core) != 'the': tok[i] = core + tail continue make_thee = False p1 = prev_word(i, 1) if p1: if p1 in LINKERS: make_thee = True elif p1 == 'of': p2, p3 = prev_word(i, 2), prev_word(i, 3) if p2 == 'behalf' and p3 == 'on': make_thee = True if not make_thee: n1 = next_word(i, 1) if n1 and (is_titlecase_word(bare(n1)) or is_single_letter(n1) or is_number(n1)): make_thee = True tok[i] = ('thee' if make_thee else core) + tail return ' '.join(tok) def apply_time_numbers_degrees(s): def time12(m): hh, mm, ap = m.group(1), m.group(2), lower(m.group(3)) ap1 = 'P:' if ap.startswith('p') else 'Ay:' return hh + ' ' + BR + ' ' + mm + ' ' + BR + ' ' + ap1 + ' ' + BR + ' M:' s = RE_TIME12.sub(time12, s) s = RE_TIME_IS.sub(r'\1 ' + BR + r' \2', s) s = RE_24H.sub(r'\1 ' + BR + ' hundred ' + BR + ' hours', s) def n3(m): yz = m.group(2) + m.group(3) if yz == '00': return m.group(0) rem = yz.lstrip('0') or yz return m.group(1) + '00 ' + BR + ' and ' + BR + ' ' + rem s = RE_N3.sub(n3, s) s = RE_DEGREES.sub(r'\1 ' + BR + ' degrees ' + BR, s) s = re.sub(r'M:\s*\.', 'M:', s) return s def syllables(word): if not word: return 0 w = lower(word) vowels = set('aeiouy') count = 0 in_v = False for c in w: if c in vowels: if not in_v: count += 1 in_v = True else: in_v = False if count > 1 and len(w) > 1 and w[-1] == 'e' and not (len(w) > 2 and w[-2] == 'l'): count -= 1 if len(w) > 2 and w[-1] == 'e' and w[-2] == 'l': count += 1 return max(1, count) def weight(tok): if not tok: return LIGHT pfree = strip_trailing_punct(tok) tl = lower(pfree) if tl in STOP_WORDS or tl in LIGHT_VERBS: return LIGHT if is_all_digits(pfree): return HEAVY if '-' in pfree: return HEAVY if is_titlecase_word(pfree): return HEAVY if tl in UNITS: return HEAVY if syllables(pfree) >= 3 or len(pfree) >= 8: return HEAVY return MEDIUM def is_and(tok): return lower(strip_trailing_punct(tok)) == 'and' def starts_with_punct(tok): return bool(tok) and tok[0] in '.,;:!?)' def is_open_punct(tok): return bool(tok) and tok[0] in '("\'' def emit_titlecase_run(v, i, j, out): if out and not ends_with_br(out): out += ' ' + BR for k in range(i, j): out += ' ' + v[k] + ' ' + BR return out def build_beats(sentence): v = sentence.split() if not v: return sentence out = [''] state = {'since_break_all_light': True, 'prev_w': LIGHT, 'content_run': 0} def reset(): state['since_break_all_light'] = True state['content_run'] = 0 state['prev_w'] = LIGHT def ensure_break(): if not ends_with_br(out[0]): out[0] += ' ' + BR def append_token(tok): if out[0] and not starts_with_punct(tok) and not is_open_punct(tok): out[0] += ' ' out[0] += tok def emit_dash_split(tok): core, tail = split_tail(tok) cuts = [] for p, c in enumerate(core): if c in ('-', '\u2013', '\u2014'): if 0 < p < len(core) - 1 and core[p - 1].isalpha() and core[p + 1].isalpha(): cuts.append(p) else: return False if not cuts: return False start = 0 for idx in range(len(cuts) + 1): end = cuts[idx] if idx < len(cuts) else len(core) part = core[start:end] if out[0] and not ends_with_br(out[0]) and not starts_with_punct(part) and not is_open_punct(part): out[0] += ' ' if idx == len(cuts): part += tail out[0] += part ensure_break() reset() start = end + (1 if idx < len(cuts) else 0) return True i = 0 while i < len(v): # Proper-noun blocks if bare(v[i]) in ('Area', 'Level', 'Sector'): j = i + 1 while j < len(v): wj = bare(v[j]) if is_all_digits(wj) or is_titlecase_word(wj): j += 1 else: break if j > i + 1: out[0] = emit_titlecase_run(v, i, j, out[0]) i = j reset() continue if is_titlecase_word(bare(v[i])): j = i while j < len(v) and is_titlecase_word(bare(v[j])): j += 1 if j >= i + 2: out[0] = emit_titlecase_run(v, i, j, out[0]) i = j reset() continue # "and" between heavier items if is_and(v[i]) and 0 < i < len(v) - 1: if weight(v[i - 1]) >= MEDIUM and weight(v[i + 1]) >= MEDIUM: ensure_break() out[0] += ' and ' + BR i += 1 reset() continue # Standalone dash if v[i] in ('-', '\u2013', '\u2014'): ensure_break() reset() i += 1 continue # Symmetric linker isolation tl_tok = lower(bare(v[i])) if i >= 2 and tl_tok in LINKERS: ensure_break() reset() append_token(v[i]) ensure_break() reset() i += 1 continue # Pre-copula break if i >= 2 and lower(bare(v[i])) == 'is': k = i + 1 while k < len(v) and is_tag(v[k]): k += 1 trigger = False if k < len(v): n1 = v[k] if (lower(strip_trailing_punct(n1)) in DETERMINERS or is_titlecase_word(strip_trailing_punct(n1)) or is_all_digits(strip_trailing_punct(n1)) or is_single_letter(n1) or weight(n1) == HEAVY): trigger = True if trigger and not ends_with_br(out[0]): out[0] += ' ' + BR reset() # Hyphenated compounds if emit_dash_split(v[i]): i += 1 continue # Generic token logic tok = v[i] w_eff = weight(tok) in_head_span = i < 2 if i == 0 and is_titlecase_word(bare(tok)): next_is_title = (i + 1 < len(v)) and is_titlecase_word(bare(v[i + 1])) if not next_is_title and w_eff == HEAVY: w_eff = MEDIUM if w_eff == HEAVY and state['content_run'] >= 1: k = i - 1 while k >= 0 and is_tag(v[k]): k -= 1 exempt = False if k >= 0: prev = v[k] if (is_titlecase_word(bare(prev)) or is_all_digits(bare(prev)) or is_single_letter(prev)): exempt = True if not exempt: ensure_break() reset() if w_eff == HEAVY and state['content_run'] >= 2: ensure_break() reset() if w_eff == HEAVY and state['since_break_all_light'] and not in_head_span: if out[0] and not ends_with_br(out[0]): out[0] += ' ' + BR reset() if i > 0 and state['prev_w'] == HEAVY and w_eff == HEAVY and not in_head_span: ensure_break() reset() append_token(tok) if tok and tok[-1] in ',;' and not ends_with_br(out[0]): out[0] += ' ' + BR reset() i += 1 continue if w_eff == LIGHT: pass else: state['content_run'] += 1 state['since_break_all_light'] = False state['prev_w'] = w_eff i += 1 return trim(out[0]) def normalize_letter_tokens(s): tok = s.split() if not tok: return s def is_boundary(idx): return idx < 0 or idx >= len(tok) or is_tag(tok[idx]) for i in range(len(tok)): if is_tag(tok[i]): continue core, tail = tok[i], '' had_colon = False while core: c = core[-1] if c == ':' and not had_colon: had_colon = True core = core[:-1] continue if c in '.,;!?\'"': tail = c + tail core = core[:-1] continue break near_bound = is_boundary(i - 1) or is_boundary(i + 1) if core in ('A', 'a') and (near_bound or had_colon): core = 'Ay' had_colon = True elif len(core) == 1 and core.isalpha() and core.isupper() and near_bound: had_colon = True tok[i] = core + (':' if had_colon else '') + tail return ' '.join(tok) def tidy(s): t = re.sub(r'\s+', ' ', s) t = re.sub(r'\s*' + re.escape(BR) + r'\s*', ' ' + BR + ' ', t) t = re.sub(r'(\s*' + re.escape(BR) + r'\s*){2,}', ' ' + BR + ' ', t) t = re.sub(r'\s+([.,!?;:])', r'\1', t) return t.strip() def vox_process(text, wrap_vox_tags=True): out = '' for sent in split_sentences(text): if not sent: continue sent = apply_thee_rule(sent) sent = apply_leadin_break(sent) sent = apply_time_numbers_degrees(sent) beats = build_beats(sent) beats = normalize_letter_tokens(beats) beats = tidy(beats) if beats and not beats.endswith(' '): beats += ' ' beats += r'\!sf500 ' + BR if out: out += ' ' out += beats out = trim(out) out = re.sub(r'(\s*' + re.escape(BR) + r'\s*){2,}', ' ' + BR + ' ', out) out = re.sub(re.escape(BR) + r'\s*([,;:])', r'\1 ' + BR, out) if out: if wrap_vox_tags: out = WH1 + ' ' + out + ' ' + WH0 + ' ' else: out = out + ' ' return out def main(): args = sys.argv[1:] clean = False if '--clean' in args: clean = True args.remove('--clean') if args: lines = [' '.join(args)] else: lines = sys.stdin.read().splitlines() out_lines = [vox_process(line, wrap_vox_tags=not clean) for line in lines if line.strip()] sys.stdout.write('\n'.join(out_lines)) if out_lines: sys.stdout.write('\n') if __name__ == '__main__': main()