Vendor SAPI4 runtime/voices, Flextalk cabinet, NetTTS reference (standalone, no online deps)

This commit is contained in:
Fisto
2026-09-16 08:43:10 +02:00
parent 805c960a4a
commit 329ffe201f
25 changed files with 1882 additions and 10 deletions
+744
View File
@@ -0,0 +1,744 @@
#include "vox_parser.hpp"
#include <vector>
#include <regex>
#include <cwctype>
#include <sstream>
#include <algorithm>
static inline std::wstring trim(const std::wstring& s){
size_t a=0,b=s.size();
while(a<b && iswspace(s[a])) ++a;
while(b>a && iswspace(s[b-1])) --b;
return s.substr(a,b-a);
}
static inline std::wstring to_lower(std::wstring s){
for(auto& c: s) c=(wchar_t)towlower(c);
return s;
}
static inline bool is_all_digits(const std::wstring& t){
if(t.empty()) return false;
for(wchar_t c: t) if(!iswdigit(c)) return false;
return true;
}
static inline bool is_titlecase_word(const std::wstring& t){
if(t.empty()) return false;
if(!iswalpha(t[0]) || !iswupper(t[0])) return false;
return true;
}
static inline std::wstring strip_trailing_punct(std::wstring t){
while(!t.empty()){
wchar_t c = t.back();
if(c==L','||c==L'.'||c==L';'||c==L':'||c==L'!'||c==L'?'||c==L'"'||c==L'\'') t.pop_back();
else break;
}
return t;
}
static inline bool ends_with_br(const std::wstring& s){
size_t n = s.size();
return n >= 5 && s.compare(n-5, 5, L" \\!br") == 0;
}
// Sentence splitter (keeps terminator punctuation attached)
static std::vector<std::wstring> split_sentences(const std::wstring& in){
std::vector<std::wstring> out;
std::wstring cur;
for(size_t i=0;i<in.size();++i){
wchar_t c = in[i];
cur.push_back(c);
if(c==L'.' || c==L'!' || c==L'?'){
size_t j=i+1;
while(j<in.size() && (in[j]==L'"' || in[j]==L'\'')) { cur.push_back(in[j]); ++j; }
out.push_back(trim(cur));
cur.clear();
i = j-1;
}
}
std::wstring tail = trim(cur);
if(!tail.empty()) out.push_back(tail);
return out;
}
// Lead-in break after short opening phrases
static void apply_leadin_break(std::wstring& s){
static const std::wregex r(LR"(^\s*((?:Now|Please|A\s+reminder|On\s+behalf\s+of|Good\s+(?:morning|evening)|This\s+(?:automated\s+train|tram)))\b)",
std::regex_constants::icase);
std::wsmatch m;
if(std::regex_search(s, m, r)){
std::wstring head = trim(m.str(0));
std::wstring rest = trim(s.substr(m.length()));
s = head + L" \\!br " + rest;
}
}
// “the” -> “thee”
// - Backward rule: after preps (to/for/of/in/on/at) or "on behalf of"
// - NEW Forward rule: before TitleCase, single letter (X or X:), or a number
// Robust across \!br tags; preserves trailing punctuation.
static void apply_thee_rule(std::wstring& s){
// tokenize by whitespace; keep tags as tokens
std::wstringstream ss(s);
std::vector<std::wstring> tok; std::wstring t;
while (ss >> t) tok.push_back(t);
if (tok.empty()) return;
auto is_tag = [](const std::wstring& w){
return (w==L"\\!br" || w==L"\\!wH1" || w==L"\\!wH0");
};
auto to_lower_copy = [](std::wstring v){ for (auto& c: v) c=(wchar_t)towlower(c); return v; };
// split trailing punctuation; return (core, punctTail)
auto split_tail = [](const std::wstring& x)->std::pair<std::wstring,std::wstring>{
std::wstring core=x, tail;
while (!core.empty()){
wchar_t c = core.back();
if (c==L','||c==L'.'||c==L';'||c==L':'||c==L'!'||c==L'?'||c==L'"'||c==L'\''){
tail.insert(tail.begin(), c);
core.pop_back();
} else break;
}
return {core, tail};
};
auto clean_lower = [&](const std::wstring& x)->std::wstring{
auto p = split_tail(x);
return to_lower_copy(p.first);
};
auto bare = [&](const std::wstring& x)->std::wstring{
return split_tail(x).first;
};
auto prev_word = [&](int i, int nth)->std::wstring{
for (int k=i-1; k>=0; --k){
if (is_tag(tok[k])) continue;
if (--nth==0) return clean_lower(tok[k]);
}
return L"";
};
auto next_word = [&](int i, int nth)->std::wstring{
for (int k=i+1; k<(int)tok.size(); ++k){
if (is_tag(tok[k])) continue;
if (--nth==0) return tok[k]; // not lowered; we want case info
}
return L"";
};
auto is_prep = [&](const std::wstring& w)->bool{
std::wstring tl = to_lower_copy(w);
return (tl==L"to"||tl==L"for"||tl==L"of"||tl==L"in"||tl==L"on"||tl==L"at");
};
auto is_title = [&](const std::wstring& w)->bool{
auto b = bare(w);
return !b.empty() && iswalpha(b[0]) && iswupper(b[0]);
};
auto is_single_letter = [&](const std::wstring& w)->bool{
auto b = bare(w);
if (b.size()==1 && iswalpha(b[0]) && iswupper(b[0])) return true;
if (b.size()==2 && iswalpha(b[0]) && iswupper(b[0]) && b[1]==L':') return true;
return false;
};
auto is_number = [&](const std::wstring& w)->bool{
auto b = bare(w);
if (b.empty()) return false;
for (wchar_t c: b) if (!iswdigit(c)) return false;
return true;
};
for (int i=0; i<(int)tok.size(); ++i){
if (is_tag(tok[i])) continue;
auto [core, tail] = split_tail(tok[i]);
std::wstring cl = to_lower_copy(core);
if (cl != L"the") { tok[i] = core + tail; continue; }
bool make_thee = false;
// Backward (prepositions, incl. "on behalf of")
std::wstring p1 = prev_word(i, 1);
if (!p1.empty()){
if (is_prep(p1)) make_thee = true;
else if (to_lower_copy(p1) == L"of"){
std::wstring p2 = prev_word(i, 2);
std::wstring p3 = prev_word(i, 3);
if (to_lower_copy(p2)==L"behalf" && to_lower_copy(p3)==L"on") make_thee = true;
}
}
// Forward (before proper names / letters / numbers)
if (!make_thee){
std::wstring n1 = next_word(i, 1);
if (!n1.empty()){
if (is_title(n1) || is_single_letter(n1) || is_number(n1)){
make_thee = true;
}
}
}
tok[i] = (make_thee ? L"thee" : core) + tail;
}
// rebuild
std::wstring out;
for (size_t i=0;i<tok.size();++i){
if (!out.empty()) out.push_back(L' ');
out += tok[i];
}
s.swap(out);
}
// Times, degrees, and 3-digit decomposition
static void apply_time_numbers_degrees(std::wstring& s){
// 12h time like 8:47 AM / A.M. / PM … (eat any trailing dot after meridiem)
{
std::wregex t12(LR"(\b(\d{1,2})[:.](\d{2})\s*((?:A\.?M\.?)|(?:P\.?M\.?))(?:\.)?\b)", std::regex_constants::icase);
std::wstring out; out.reserve(s.size()+32);
std::wsregex_iterator it(s.begin(), s.end(), t12), end;
size_t last = 0;
for(; it!=end; ++it){
auto m = *it;
out.append(s, last, m.position()-last);
std::wstring hh = m.str(1);
std::wstring mm = m.str(2);
std::wstring ap = to_lower(m.str(3));
std::wstring ap1 = (ap[0]==L'p') ? L"P:" : L"Ay:";
out += hh + L" \\!br " + mm + L" \\!br " + ap1 + L" \\!br M:";
last = m.position() + m.length();
}
out.append(s, last, std::wstring::npos);
s.swap(out);
}
// “The time is 8 …” -> break before the hour
s = std::regex_replace(s,
std::wregex(LR"((\b[Tt]he\s+time\s+is)\s+(\d+))"),
L"$1 \\!br $2");
// 24h “HH00 hours” -> “HH \!br hundred \!br hours”
s = std::regex_replace(s,
std::wregex(LR"(\b(\d{2})00\s+hours\b)", std::regex_constants::icase),
L"$1 \\!br hundred \\!br hours");
// Three-digit non-round numbers: 105 -> “100 \!br and \!br 5” (strip leading zero in remainder)
{
std::wregex n3(LR"(\b([1-9])(\d)(\d)\b)");
std::wstring out; out.reserve(s.size()+32);
std::wsregex_iterator it(s.begin(), s.end(), n3), end;
size_t last = 0;
for(; it!=end; ++it){
auto m = *it;
std::wstring yz = m.str(2)+m.str(3);
if (yz == L"00") continue; // round, leave as-is
size_t nz = 0; while (nz < yz.size() && yz[nz]==L'0') ++nz;
std::wstring rem = yz.substr(nz);
out.append(s, last, m.position()-last);
out += m.str(1)+L"00 \\!br and \\!br " + (rem.empty()? yz : rem);
last = m.position() + m.length();
}
out.append(s, last, std::wstring::npos);
s.swap(out);
}
// Degrees: “93 degrees” -> “93 \!br degrees \!br”
s = std::regex_replace(s,
std::wregex(LR"(\b(\d+)\s+degrees\b)", std::regex_constants::icase),
L"$1 \\!br degrees \\!br");
// Fix any lingering "M:." -> "M:" (meridiem shouldn’t carry a period)
s = std::regex_replace(s, std::wregex(LR"(M:\s*\.)"), L"M:");
}
// Syllable heuristic
static int syllables(const std::wstring& w){
if(w.empty()) return 0;
std::wstring s; s.reserve(w.size());
for(wchar_t c: w) s.push_back((wchar_t)towlower(c));
auto isv=[&](wchar_t c){ return c==L'a'||c==L'e'||c==L'i'||c==L'o'||c==L'u'||c==L'y'; };
int count=0; bool in=false;
for(wchar_t c: s){ if(isv(c)){ if(!in){ ++count; in=true; } } else in=false; }
if(count>1 && s.size()>1 && s.back()==L'e' && !(s.size()>2 && s[s.size()-2]==L'l')) --count; // silent 'e' (not "...le")
if(s.size()>2 && s.back()==L'e' && s[s.size()-2]==L'l') ++count; // “…le”
return std::max(1,count);
}
enum Wt { LIGHT, MEDIUM, HEAVY };
static bool is_stop(const std::wstring& tl){
static const wchar_t* sw[] = {
L"the",L"thee",L"a",L"an",L"to",L"for",L"of",L"in",L"on",L"at",L"by",L"with",L"from",
L"as",L"is",L"are",L"was",L"were",L"this",L"that"
};
for(auto* w: sw) if(tl==w) return true;
return false;
}
static bool is_lightverb(const std::wstring& tl){
static const wchar_t* vw[] = {
L"welcome",L"wait",L"stand",L"provide",L"provided",L"return",L"remain",L"maintain",
L"maintained",L"verify",L"contact",L"board",L"arriving",L"inbound",L"bound",
L"commence",L"commences"
};
for(auto* w: vw) if(tl==w) return true;
return false;
}
static bool is_unit(const std::wstring& tl){
static const wchar_t* uw[] = { L"degrees", L"hours", L"percent", L"%" };
for(auto* w: uw) if(tl==w) return true;
return false;
}
static Wt weight(const std::wstring& tok){
if(tok.empty()) return LIGHT;
std::wstring pfree = strip_trailing_punct(tok);
std::wstring tl = to_lower(pfree);
if(is_stop(tl) || is_lightverb(tl)) return LIGHT;
if(is_all_digits(pfree)) return HEAVY;
if(pfree.find(L'-')!=std::wstring::npos) return HEAVY;
if(is_titlecase_word(pfree)) return HEAVY;
if(is_unit(tl)) return HEAVY;
int syl = syllables(pfree);
if(syl>=3 || pfree.size()>=8) return HEAVY; // long or many-syllable content words
return MEDIUM;
}
static bool is_and(const std::wstring& t){ return to_lower(strip_trailing_punct(t))==L"and"; }
static bool starts_with_punct(const std::wstring& t){
if(t.empty()) return false;
wchar_t c = t.front();
return (c==L'.'||c==L','||c==L';'||c==L':'||c==L'!'||c==L'?'||c==L')');
}
static bool is_open_punct(const std::wstring& t){
if(t.empty()) return false;
wchar_t c=t.front(); return (c==L'('||c==L'"'||c==L'\'');
}
// Tokenize on whitespace (keep punctuation with token)
static std::vector<std::wstring> tokenize_ws(const std::wstring& s){
std::wstringstream ss(s);
std::vector<std::wstring> v; std::wstring t;
while(ss >> t) v.push_back(t);
return v;
}
// Insert TitleCase run breaks and Area/Level/Sector blocks
static void emit_titlecase_run(const std::vector<std::wstring>& v, size_t i, size_t j, std::wstring& out){
// place a break before the run
if(!out.empty() && !ends_with_br(out)) out += L" \\!br";
for(size_t k=i;k<j;++k){
out += L" " + v[k] + L" \\!br";
}
}
// Core beat builder (single pass), generic prosody.
// Keeps: TitleCase runs, Area/Level/Sector, "and" join, soft head span,
// comma/semicolon boundary, symmetric linker isolation [BR] linker [BR],
// heavy-gate, 3-beat limiter.
// NEW: Split hyphenated/en-dash/em-dash compounds into parts with breaks.
// Standalone -, –, — act as hard breaks (not spoken).
static std::wstring build_beats(const std::wstring& sentence){
auto v = tokenize_ws(sentence);
if(v.empty()) return sentence;
std::wstring out;
bool since_break_all_light = true;
Wt prev_w = LIGHT;
int content_run = 0; // consecutive MEDIUM/HEAVY since last break
auto reset_after_break = [&](){
since_break_all_light = true;
content_run = 0;
prev_w = LIGHT;
};
auto bare = [](const std::wstring& w){ return strip_trailing_punct(w); };
auto is_area_head = [&](const std::wstring& w){ return w==L"Area"||w==L"Level"||w==L"Sector"; };
auto is_tag = [&](const std::wstring& w){ return (w==L"\\!br" || w==L"\\!wH1" || w==L"\\!wH0"); };
auto lower = [&](std::wstring s){ return to_lower(std::move(s)); };
auto is_single_letter = [&](const std::wstring& w)->bool{
std::wstring b = bare(w);
if (b.size()==1 && iswalpha(b[0]) && iswupper(b[0])) return true; // C
if (b.size()==2 && iswalpha(b[0]) && iswupper(b[0]) && b[1]==L':') return true; // C:
return false;
};
// split trailing punctuation; return (core, punctTail)
auto split_tail = [](const std::wstring& x)->std::pair<std::wstring,std::wstring>{
std::wstring core=x, tail;
while (!core.empty()){
wchar_t c = core.back();
if (c==L','||c==L'.'||c==L';'||c==L':'||c==L'!'||c==L'?'||c==L'"'||c==L'\''){
tail.insert(tail.begin(), c);
core.pop_back();
} else break;
}
return {core, tail};
};
// emit hyphen/dash-split parts with breaks between them; keep tail on last part
auto emit_dash_split = [&](const std::wstring& tok)->bool{
auto [core, tail] = split_tail(tok);
// find internal separators
std::vector<size_t> cuts;
for(size_t p=0;p<core.size();++p){
wchar_t c = core[p];
if (c==L'-' || c==0x2013 /*–*/ || c==0x2014 /*—*/){
// only split if letters on both sides (avoid leading/trailing)
if (p>0 && p+1<core.size() && iswalpha(core[p-1]) && iswalpha(core[p+1]))
cuts.push_back(p);
else
return false; // treat as punctuation case elsewhere
}
}
if (cuts.empty()) return false;
size_t start = 0;
for(size_t idx=0; idx<=cuts.size(); ++idx){
size_t end = (idx<cuts.size() ? cuts[idx] : core.size());
std::wstring part = core.substr(start, end-start);
// spacing before part
if(!out.empty() && !ends_with_br(out) && !starts_with_punct(part) && !is_open_punct(part))
out.push_back(L' ');
// last part gets the tail
if (idx == cuts.size()) part += tail;
out += part;
// break after each part
if(!ends_with_br(out)) out += L" \\!br";
reset_after_break();
start = end + (idx<cuts.size() ? 1 : 0);
}
return true;
};
for(size_t i=0;i<v.size();){
// ---- Proper-noun blocks ----
if (is_area_head(v[i])){
size_t j=i+1;
while(j<v.size()){
std::wstring wj = bare(v[j]);
if(is_all_digits(wj) || is_titlecase_word(wj)) ++j;
else break;
}
if(j>i+1){
emit_titlecase_run(v,i,j,out);
i=j;
reset_after_break();
continue;
}
}
if (is_titlecase_word(bare(v[i]))){
size_t j=i;
while(j<v.size() && is_titlecase_word(bare(v[j]))) ++j;
if(j>=i+2){
emit_titlecase_run(v,i,j,out);
i=j;
reset_after_break();
continue;
}
}
// ---- "and" between heavier items ----
if(is_and(v[i]) && i>0 && i+1<v.size()){
Wt lw = weight(v[i-1]);
Wt rw = weight(v[i+1]);
if(lw>=MEDIUM && rw>=MEDIUM){
if(!ends_with_br(out)) out += L" \\!br";
out += L" and \\!br";
++i;
reset_after_break();
continue;
}
}
// ---- Standalone dash ( -, –, — ) => hard boundary ----
if (v[i]==L"-" || v[i]==L"\u2013" || v[i]==L"\u2014"){
if(!ends_with_br(out)) out += L" \\!br";
reset_after_break();
++i;
continue;
}
// ---- Symmetric linker isolation: [BR] linker [BR] ----
{
std::wstring tok = v[i];
std::wstring tl = lower(bare(tok));
bool in_head_span = (i < 2);
if (!in_head_span && (tl==L"at" || tl==L"in" || tl==L"on" || tl==L"to"
|| tl==L"of" || tl==L"for"|| tl==L"from"|| tl==L"by")){
if(!ends_with_br(out)) out += L" \\!br"; // BEFORE linker
reset_after_break();
if(!out.empty() && !starts_with_punct(tok) && !is_open_punct(tok)) out.push_back(L' ');
out += tok;
if(!ends_with_br(out)) out += L" \\!br"; // AFTER linker
reset_after_break();
++i;
continue;
}
}
// ---- Pre-copula break: put a beat BEFORE "is" when followed by determiner/contenty ----
{
std::wstring tl_is = to_lower(bare(v[i]));
bool in_head_span = (i < 2);
auto is_tag_local = [&](const std::wstring& w){ return (w==L"\\!br" || w==L"\\!wH1" || w==L"\\!wH0"); };
auto is_single_letter = [&](const std::wstring& w)->bool{
std::wstring b = strip_trailing_punct(w);
if (b.size()==1 && iswalpha(b[0]) && iswupper(b[0])) return true; // C
if (b.size()==2 && iswalpha(b[0]) && iswupper(b[0]) && b[1]==L':') return true; // C:
return false;
};
auto is_determiner = [&](const std::wstring& w)->bool{
std::wstring b = to_lower(strip_trailing_punct(w));
return (b==L"the"||b==L"thee"||b==L"a"||b==L"an"||
b==L"this"||b==L"that"||b==L"these"||b==L"those"||
b==L"some"||b==L"any"||b==L"each"||b==L"every"||b==L"no");
};
if (!in_head_span && tl_is == L"is") {
// peek next non-tag token
size_t k = i + 1;
while (k < v.size() && is_tag_local(v[k])) ++k;
bool trigger = false;
if (k < v.size()) {
const std::wstring& n1 = v[k];
if (is_determiner(n1) ||
is_titlecase_word(strip_trailing_punct(n1)) ||
is_all_digits(strip_trailing_punct(n1)) ||
is_single_letter(n1) ||
weight(n1) == HEAVY)
{
trigger = true;
}
}
if (trigger && !ends_with_br(out)) {
out += L" \\!br";
// reset run state like a real boundary
since_break_all_light = true;
content_run = 0;
prev_w = LIGHT;
}
}
}
// ---- Hyphenated / en-dash / em-dash compounds inside token ----
if (emit_dash_split(v[i])) { ++i; continue; }
// ---- Generic token logic ----
std::wstring tok = v[i];
Wt w_eff = weight(tok); // effective weight; may be softened at sentence start
// Soft-start: first two tokens—avoid pre-breaks unless necessary.
bool in_head_span = (i < 2);
if (i==0 && is_titlecase_word(bare(tok))) {
bool next_is_title = (i+1 < v.size()) && is_titlecase_word(bare(v[i+1]));
if (!next_is_title && w_eff == HEAVY) w_eff = MEDIUM; // e.g., "Good evening"
}
// ---- HEAVY-GATE ----
if (w_eff==HEAVY && content_run >= 1){
int k = static_cast<int>(i) - 1;
while (k >= 0 && is_tag(v[k])) --k;
bool exempt = false;
if (k >= 0){
std::wstring prev = v[k];
if (is_titlecase_word(bare(prev)) || is_all_digits(bare(prev)) || is_single_letter(prev)) {
exempt = true;
}
}
if (!exempt){
if(!ends_with_br(out)) out += L" \\!br";
reset_after_break();
}
}
// 3-beat limiter
if (w_eff==HEAVY && content_run >= 2){
if(!ends_with_br(out)) out += L" \\!br";
reset_after_break();
}
// Heavy after only LIGHT (but not inside head span)
if (w_eff==HEAVY && since_break_all_light && !in_head_span){
if(!out.empty() && !ends_with_br(out)) out += L" \\!br";
reset_after_break();
}
// Heavy-Heavy adjacency (outside TitleCase run)
if (i>0 && prev_w==HEAVY && w_eff==HEAVY && !in_head_span){
if(!ends_with_br(out)) out += L" \\!br";
reset_after_break();
}
// Append token with spacing
if(!out.empty() && !starts_with_punct(tok) && !is_open_punct(tok)) out.push_back(L' ');
out += tok;
// Punctuation boundary: break after ',' or ';'
if (!tok.empty()){
wchar_t last = tok.back();
if ((last==L',' || last==L';') && !ends_with_br(out)){
out += L" \\!br";
reset_after_break();
++i;
continue;
}
}
// Update run stats
if (w_eff==LIGHT){
// remain in light span
}else{
++content_run; // MEDIUM or HEAVY
since_break_all_light = false;
}
prev_w = w_eff;
++i;
}
return trim(out);
}
// Tidy spaces and tags
static std::wstring tidy(const std::wstring& s){
std::wstring t = std::regex_replace(s, std::wregex(LR"(\s+)"), L" "); // collapse spaces
t = std::regex_replace(t, std::wregex(LR"(\s*\\!br\s*)"), L" \\!br "); // normalize tag spacing
t = std::regex_replace(t, std::wregex(LR"((\s*\\!br\s*){2,})"), L" \\!br "); // collapse multiple breaks
t = std::regex_replace(t, std::wregex(LR"(\s+([.,!?;:]))"), L"$1"); // no space before punctuation
// trimyou
size_t a=0,b=t.size();
while(a<b && iswspace(t[a])) ++a;
while(b>a && iswspace(t[b-1])) --b;
return t.substr(a,b-a);
}
// Convert standalone letters into stable "letter tokens" for FlexTalk.
// - "A:" / "a:" / bare "A"/"a" near a boundary -> "Ay:"
// - any single UPPERCASE letter near a boundary -> "X:" (exactly one colon)
// Boundaries are start/end or a \!br tag on either side. We preserve trailing punctuation.
static void normalize_letter_tokens(std::wstring& s){
// split by whitespace; keep tags as tokens
std::wstringstream ss(s);
std::vector<std::wstring> tok; std::wstring t;
while (ss >> t) tok.push_back(t);
if (tok.empty()) return;
auto is_tag = [](const std::wstring& w){
return (w==L"\\!br" || w==L"\\!wH1" || w==L"\\!wH0");
};
// strip trailing punctuation, but lift out a single trailing ':' as a flag
auto split_tail = [](const std::wstring& x)->std::tuple<std::wstring,bool,std::wstring>{
std::wstring core = x, punct;
bool had_colon = false;
// peel general punctuation, but remember a single trailing ':'
while(!core.empty()){
wchar_t c = core.back();
if(c==L':' && !had_colon){ had_colon = true; core.pop_back(); continue; }
if(c==L','||c==L'.'||c==L';'||c==L'!'||c==L'?'||c==L'"'||c==L'\''){
punct.insert(punct.begin(), c);
core.pop_back();
continue;
}
break;
}
return {core, had_colon, punct};
};
auto is_boundary = [&](int idx)->bool{
return (idx < 0 || idx >= (int)tok.size() || is_tag(tok[idx]));
};
for (int i=0;i<(int)tok.size();++i){
if (is_tag(tok[i])) continue;
auto [coreOrig, had_colon, tail] = split_tail(tok[i]);
std::wstring core = coreOrig;
const bool near_left = is_boundary(i-1);
const bool near_right = is_boundary(i+1);
const bool near_bound = (near_left || near_right);
// Normalize A / a cases to "Ay:" when isolated or already written as A:
if ((core==L"A" || core==L"a") && (near_bound || had_colon)){
core = L"Ay";
had_colon = true; // ensure exactly one colon added below
}
// Any other single UPPERCASE letter becomes a letter token near a boundary
else if (core.size()==1 && iswalpha(core[0]) && iswupper(core[0]) && near_bound){
// ensure exactly one colon
// (if there was already one as trailing punct, we won't add another)
// special "A" handled above
had_colon = true;
}
// Reassemble: add colon iff had_colon is true, then the rest of punctuation
std::wstring rebuilt = core;
if (had_colon) rebuilt.push_back(L':');
rebuilt += tail;
tok[i] = rebuilt;
}
// Rebuild s (tidy() will normalize spaces/tags later)
std::wstring out;
for (size_t i=0;i<tok.size();++i){
if (!out.empty()) out.push_back(L' ');
out += tok[i];
}
s.swap(out);
}
std::wstring vox_process(const std::wstring& in, bool wrap_vox_tags){
auto sents = split_sentences(in);
std::wstring out;
for (auto sent : sents){
if (sent.empty()) continue;
// ORDER: make "thee" first, then lead-in, then time/nums
apply_thee_rule(sent);
apply_leadin_break(sent);
apply_time_numbers_degrees(sent);
// beats + letter normalization
std::wstring with_beats = build_beats(sent);
normalize_letter_tokens(with_beats);
with_beats = tidy(with_beats);
// sentence-end cadence: add ~300ms pause, then a boundary
if (!with_beats.empty() && with_beats.back()!=L' ') with_beats.push_back(L' ');
with_beats += L"\\!sf500 \\!br";
if (!out.empty()) out.push_back(L' ');
out += with_beats;
}
out = trim(out);
// collapse accidental double breaks from joins, normalize commas before breaks, etc.
out = std::regex_replace(out, std::wregex(LR"((\s*\\!br\s*){2,})"), L" \\!br ");
out = std::regex_replace(out, std::wregex(LR"(\\!br\s*([,;:]))"), L"$1 \\!br");
if (!out.empty()) {
if (wrap_vox_tags) {
out = L"\\!wH1 " + out + L" \\!wH0 ";
} else {
out = out + L" ";
}
}
return out;
}
+8
View File
@@ -0,0 +1,8 @@
#pragma once
#include <string>
// Transform plain text into FlexTalk VOX style with vendor tags.
// - Wraps with \!wH1 ... \!wH0 (trailing space after \!wH0 for safety)
// - Inserts \!br at sentence ends & cadence points
// - Uses a generic prosody engine (no word-specific hacks)
std::wstring vox_process(const std::wstring& in, bool wrap_vox_tags);