From 516f0dbdf3b0c76171ddb265e75fa5794c4c781f Mon Sep 17 00:00:00 2001 From: Fisto Date: Tue, 15 Sep 2026 11:31:02 +0200 Subject: [PATCH] Winelib SAPI4 host: MSTTS (Sam/Mary/Mike) + AT&T Flextalk (Keith Bell/Kerry Watson) --- .gitignore | 8 + README.md | 84 ++++++++++ SCOPE.md | 26 +++ docs/NOTES.md | 77 +++++++++ host/sapi4host.c | 339 ++++++++++++++++++++++++++++++++++++++++ setup/inf2reg.py | 112 +++++++++++++ setup/install-deps.sh | 37 +++++ setup/run.sh | 28 ++++ setup/setup-flextalk.sh | 95 +++++++++++ setup/setup-prefix.sh | 51 ++++++ 10 files changed, 857 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 SCOPE.md create mode 100644 docs/NOTES.md create mode 100644 host/sapi4host.c create mode 100755 setup/inf2reg.py create mode 100755 setup/install-deps.sh create mode 100755 setup/run.sh create mode 100755 setup/setup-flextalk.sh create mode 100755 setup/setup-prefix.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..735b6e4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +build/ +*.o +*.a +*.so +*.exe +*.dll +setup/out-*.reg +*.wav diff --git a/README.md b/README.md new file mode 100644 index 0000000..34a79d4 --- /dev/null +++ b/README.md @@ -0,0 +1,84 @@ +# MicroslopSam + +Run SAPI4 text-to-speech voices on Linux. + +Primary approach: a **Winelib host** — a native Linux ELF that uses Wine's +libraries to load the original 32-bit SAPI4 runtime and TTS engine DLLs. + +Voice targets: + +- Microsoft Sam / Mary / Mike — MSTTS engine (`msttssyn.dll` + `.vce` data) +- AT&T Flextalk 2.1 — "Keith Bell" and "Kerry Watson" (`FlexTalk.dll`) + +Long-term: replace Wine-provided pieces with own shims (rehost) for a +pure-native build, and keep a path to a fully portable engine. + +## Status (working) + +- Winelib host builds and runs: `build/sapi4host.exe` (launcher) + + `build/sapi4host.exe.so` (native 32-bit ELF). +- Voices enumerate and synthesize: + - MSTTS: Sam, Mary, Mike (+ telephone and effect variants) + - AT&T Flextalk: Keith Bell, Kerry Watson +- Test output: `build/test_sam.wav` (22.05 kHz), `build/test_Keith_Bell.wav`, + `build/test_Kerry_Watson.wav` (11.025 kHz). + +## Build + +Dependencies (once, as root): + + sudo setup/install-deps.sh + +Runtime + MSTTS voices (once): + + setup/setup-prefix.sh + +Flextalk voices (once; needs the extracted installer, see `docs/NOTES.md`): + + setup/setup-flextalk.sh + +Host: + + # Winelib (native ELF + launcher) + winegcc -m32 -O2 -o build/sapi4host host/sapi4host.c -I../reference \ + -lole32 -luser32 -ladvapi32 + + # PE fallback (runs under wine): + i686-w64-mingw32-gcc -O2 -static -o build/sapi4host.exe host/sapi4host.c \ + -I../reference -lole32 -luser32 -ladvapi32 -luuid + +## Usage + + setup/run.sh -l + setup/run.sh -v Sam -t "Hello." -o sam.wav + setup/run.sh -v "Keith Bell" -t "Hello." -o keith.wav -p 100 -s 150 + +Options: `-v` voice, `-t` text, `-o` output WAV, `-p` pitch, `-s` speed. + +## Layout + +- `host/sapi4host.c` — host: voice select, pitch/speed, text -> WAV +- `setup/install-deps.sh` — Wine/Winelib/helpers (root) +- `setup/setup-prefix.sh` — prefix + SAPI4 runtime + MSTTS engine/voices +- `setup/setup-flextalk.sh` — Flextalk files + registry +- `setup/inf2reg.py` — INF AddReg -> `.reg` converter +- `setup/run.sh` — run helper (prefix + Xvfb) +- `docs/NOTES.md` — registry/CLSID facts, runtime RE notes, gotchas + +## Known issues + +- `xvfb-run` is flaky on this host. `setup/run.sh` starts a persistent Xvfb + (`:77`) instead; Wine needs a working X display because the runtime creates + windows. +- Flextalk's default pitch/speed report as maximum (250/390) through + `ITTSAttributes`; values are still adjustable with `-p`/`-s`. +- The Flextalk archive lacks `data2.cab`; `unshield -O` extracts `data1.cab` + but one tail file (`WatsonUNInstall.dll`) is incomplete. + +## Reference material (not in this repo) + +- `../reference/speech.h` — SAPI4 SDK header +- `../reference/SAPI4-CLI`, `../reference/TETYYS-SAPI4` — host examples +- `../Microsoft-Sam-Mary-Mike-TruVoice-WSAPI4` — installers/payloads +- `../flextalk` — Flextalk installer archive + extraction +- `/mnt/ghidra/decomp/{msttssyn.dll,speech.dll}/` — decompiled corpora diff --git a/SCOPE.md b/SCOPE.md new file mode 100644 index 0000000..c9baa5e --- /dev/null +++ b/SCOPE.md @@ -0,0 +1,26 @@ +# Scope + +## Goals + +1. Synthesize speech with the MSTTS voices (Sam, Mary, Mike) on Linux. +2. Synthesize speech with the AT&T Flextalk 2.1 voices ("Keith Bell", + "Kerry Watson") on Linux, through the same host. +3. Ship as a native Linux binary (Winelib first, pure-native later). +4. Keep the engine contract limited to what `speech.h` defines, so other + SAPI4 engines can be added without host changes. +5. Text in, WAV out. Pitch and speed adjustable. + +## Approach + +- Phase 1 (current): Winelib host + Wine-provided runtime. Fastest path to + working audio, and it doubles as the reference oracle for later work. +- Phase 2: replace Wine-provided parts with own shims one at a time + (file I/O, threads, OLE2, window/message pump, finally MSVC EH/SEH). +- Phase 3: pure-native rehost of `msttssyn.dll`; no Wine libraries. + +## Non-goals (for now) + +- GUI, SAPI4 telephony (`spchtel.dll`), Voice Command (`vcmd`), Agent support. +- TruVoice voices (possible later; same L&H engine family). +- 64-bit build (the engines are 32-bit). +- Multi-platform audio (PipeWire/ALSA output comes after WAV works). diff --git a/docs/NOTES.md b/docs/NOTES.md new file mode 100644 index 0000000..a3ad793 --- /dev/null +++ b/docs/NOTES.md @@ -0,0 +1,77 @@ +# Notes + +## Registry facts + +From `spchapi/SPCHAPI.INF` (`%49000%` = `%25%\speech` = `C:\windows\speech`): + +- `CLSID_TTSEnumerator` `{D67C0280-C743-11cd-80E5-00AA003E4B50}` -> `Speech.dll` +- "Audio Destination to File Object" `{D4623720-E4B9-11cf-8D56-00A0C9034A7E}` -> `Speech.dll` +- "Audio Destination Object" `{CB96B400-C743-11cd-80E5-00AA003E4B50}` -> `Speech.dll` +- Lexicon dialog `{92655FB1-ADF9-11d1-BEB9-006008317CE8}` -> `Speech.dll` +- Phoneme converter `{B9F11A96-90E3-11d0-8D77-00A0C9034A7E}` -> `Speech.dll` + +From `msttsl/mstts.inf` (`[TTSReg]`): + +- Engine `MSTTSSyn` = `{E0725551-286F-11d0-8E73-00A0C9083363}` -> `MSTTSSYN.dll` +- Engine install dir: `Common Files\microsoft shared\SpeechEngines\TTS`. +- Enumerates voices by scanning `*.vce` in its own directory. + +## Flextalk registration (from the installer's `setup.ins`) + +- Engine value: `HKLM\Software\Voice\TextToSpeech\Engine` + `"AT&T FlexTalk" = "{FFE78E00-C69C-11CE-AEC1-0000C0E9CB87}"` +- Engine subkey (forces mode enumeration): `...\Engine\{FFE78E00-...}` with + `Dirty = 1` (REG_DWORD). The runtime rewrites `Count` + `Mode0..N` and + clears `Dirty` after enumerating. +- `HKCR\CLSID\{FFE78E00-C69C-11CE-AEC1-0000C0E9CB87}\InprocServer32` = + `\EngineDLLs\FlexTalk.dll`, `ThreadingModel = Apartment`. + Also registered: `{778EAAE2-499F-11CF-B0BA-444553540000}` -> same DLL. +- Directories: `HKLM\Software\AT&T\Watson\2.0\FlexTalk\2.0\Directories` + `RootDirectory = C:\FlexTalk\`. +- Dictionaries: `...\FlexTalk\2.0\Dictionaries`: + `StandardDictionary="2 ft_sdict.dll"`, `ExceptionDictionary="0 ft_udict.dll"`, + `HomographDictionary="1 ft_hgrap.dll"`, `ProperNameDictionary="3 ft_namsa.dll"`, + `LetterToSoundDictionary="4 ft_l2s.dll"`. +- Options: `...\FlexTalk\2.0\Options` `Gender=Male`, `SampleRate=11025`. +- Speakers (per-voice settings; names come from these values): + - `{11260610-C84A-11ce-AEC4-0000C0E9CB87}`: Mode Name / Speaker Name + "Keith Bell", Pitch 27, Speaking Rate 33, Head Size 46, Expression 59, + Lowering 40, Prominences 17, Age 30, Volume 100. + - `{000031CB-3699-551F-AED0-6B4893382D57}`: "Kerry Watson", Pitch 65, + Speaking Rate 32, Head Size 45, Expression 100, Lowering 40, + Prominences 17, Age 30, Volume 100. +- Voice data: `EngineData\AE1M08LC.*`, `AE1M11LC.*` (male, 8/11 kHz) and + `AE2F08LC.*`, `AE2F11LC.*` (female); `.INV` inventory, `.NDX` index, + `.COD` codebook, plus shared `DICT_CI.*`, `phondef`, `AMPRULES`, `ENDINGS`. + +Extraction: `unshield -O -d extracted x installer/flextalk/data1.cab` +(`-O` = old compression; the archive.org package has no `data2.cab`, so the +final file `WatsonUNInstall.dll` is incomplete). + +## SAPI4 runtime engine discovery (`speech.dll`) + +- `FUN_635747a4` reads `HKLM\Software\Voice\TextToSpeech\Engine` values + (value name = engine name, data = CLSID string), validates the GUID with + `FUN_635712e4`, then opens a *subkey named by the CLSID string*. +- `FUN_635758b1` (TTS mode-list ctor) reads `Dirty`; when non-zero/missing it + calls `CoCreateInstance(CLSID, ..., IID @635791c0)` and pulls modes via + vtable+0xC into 0xaf0-byte records, writes `Mode%d` (REG_BINARY), `Count`, + then clears `Dirty`. When `Dirty=0` it reads the cached `Mode%d` blobs. +- Practical rule: to add an engine, register the `Engine` value + the CLSID + `InprocServer32` + an engine subkey with `Dirty=1` (a missing subkey also + triggers enumeration). + +## Environment gotchas + +- Wine needs a working X display (the runtime creates windows). `xvfb-run -a` + proved flaky here; `setup/run.sh` starts a persistent `Xvfb :77` instead. +- Wine reports `err:ole:apartment_add_dll couldn't load in-process dll` when + the `InprocServer32` path does not exist. Check the file copy first + (this bit us: a bad `unshield` run created empty directories). +- 32-bit engines require a 32-bit prefix (`WINEARCH=win32`). +- `sudo` on this machine requires a password; installs are manual. + +## Decompiled corpora (Ghidra) + +- `/mnt/ghidra/decomp/msttssyn.dll/` (786 functions, engine) +- `/mnt/ghidra/decomp/speech.dll/` (1030 functions, SAPI4 runtime) diff --git a/host/sapi4host.c b/host/sapi4host.c new file mode 100644 index 0000000..26623ce --- /dev/null +++ b/host/sapi4host.c @@ -0,0 +1,339 @@ +/* + * sapi4host.c — minimal SAPI4 text-to-speech host. + * + * Builds as: + * - Winelib (native Linux ELF): winegcc -m32 -o sapi4host sapi4host.c ... + * - PE (runs under wine): i686-w64-mingw32-gcc -o sapi4host.exe ... + * + * Usage: + * sapi4host -l + * sapi4host -v -t -o [-p ] [-s ] + * + * Flow (see reference/speech.h): + * CLSID_TTSEnumerator -> ITTSFind::Find/Select + * CLSID_AudioDestFile -> IAudioFile::Set (WAV) + * ITTSAttributes::PitchSet/SpeedSet + * ITTSNotifySink -> wait for AudioStop + * ITTSCentral::AudioReset / TextData + * + * Derived from the documented SAPI4 flow used by GaryOderNichts/SAPI4-CLI + * and TETYYS/SAPI4 (both small, permissive examples). + */ + +#define WIN32_LEAN_AND_MEAN +#include +#include +#include +#include +#include +#include +#include +#include + +static volatile LONG g_done = 0; +static volatile LONG g_started = 0; + +/* ------------------------------------------------------------------ */ +/* ITTSNotifySink */ +/* ------------------------------------------------------------------ */ + +static HRESULT STDMETHODCALLTYPE N_QueryInterface(ITTSNotifySinkA *This, + REFIID riid, LPVOID *ppv) +{ + *ppv = NULL; + if (IsEqualIID(riid, &IID_IUnknown) || + IsEqualIID(riid, &IID_ITTSNotifySink)) { + *ppv = (LPVOID)This; + return S_OK; + } + return E_NOINTERFACE; +} + +static ULONG STDMETHODCALLTYPE N_AddRef(ITTSNotifySinkA *This) +{ + (void)This; + return 1; +} + +static ULONG STDMETHODCALLTYPE N_Release(ITTSNotifySinkA *This) +{ + (void)This; + return 1; +} + +static HRESULT STDMETHODCALLTYPE N_AttribChanged(ITTSNotifySinkA *This, DWORD id) +{ + (void)This; (void)id; + return S_OK; +} + +static HRESULT STDMETHODCALLTYPE N_AudioStart(ITTSNotifySinkA *This, QWORD ts) +{ + (void)This; (void)ts; + InterlockedExchange(&g_started, 1); + return S_OK; +} + +static HRESULT STDMETHODCALLTYPE N_AudioStop(ITTSNotifySinkA *This, QWORD ts) +{ + (void)This; (void)ts; + InterlockedExchange(&g_done, 1); + return S_OK; +} + +static HRESULT STDMETHODCALLTYPE N_Visual(ITTSNotifySinkA *This, QWORD ts, + CHAR ipa, CHAR engine, + DWORD hints, PTTSMOUTH mouth) +{ + (void)This; (void)ts; (void)hints; (void)mouth; + if (ipa) + fprintf(stderr, "phoneme ipa=0x%02x engine=0x%02x\n", + (unsigned char)ipa, (unsigned char)engine); + return S_OK; +} + +static ITTSNotifySinkAVtbl g_notifyVtbl = { + N_QueryInterface, + N_AddRef, + N_Release, + N_AttribChanged, + N_AudioStart, + N_AudioStop, + N_Visual +}; + +static ITTSNotifySinkA g_notify = { &g_notifyVtbl }; + +/* ------------------------------------------------------------------ */ +/* helpers */ +/* ------------------------------------------------------------------ */ + +static void list_voices(void) +{ + PITTSENUM e = NULL; + TTSMODEINFO info; + HRESULT hr = CoCreateInstance(&CLSID_TTSEnumerator, NULL, CLSCTX_ALL, + &IID_ITTSEnum, (void **)&e); + if (FAILED(hr) || !e) { + fprintf(stderr, "cannot create TTS enumerator (0x%08lx)\n", + (unsigned long)hr); + return; + } + for (;;) { + memset(&info, 0, sizeof(info)); + if (e->lpVtbl->Next(e, 1, &info, NULL) != S_OK) + break; + printf("%s\n", info.szModeName); + } + e->lpVtbl->Release(e); +} + +static HRESULT find_voice(const char *name, TTSMODEINFO *out) +{ + PITTSFIND f = NULL; + TTSMODEINFO want, got; + HRESULT hr; + + hr = CoCreateInstance(&CLSID_TTSEnumerator, NULL, CLSCTX_ALL, + &IID_ITTSFind, (void **)&f); + if (FAILED(hr) || !f) + return hr; + + memset(&want, 0, sizeof(want)); + strncpy(want.szModeName, name, sizeof(want.szModeName) - 1); + memset(&got, 0, sizeof(got)); + + hr = f->lpVtbl->Find(f, &want, NULL, &got); + f->lpVtbl->Release(f); + if (FAILED(hr)) + return hr; + + *out = got; + return S_OK; +} + +static void usage(void) +{ + fprintf(stderr, + "usage: sapi4host -l\n" + " sapi4host -v -t -o " + " [-p pitch] [-s speed]\n"); +} + +/* ------------------------------------------------------------------ */ +/* main */ +/* ------------------------------------------------------------------ */ + +int main(int argc, char **argv) +{ + const char *voice = NULL, *text = NULL, *out = NULL; + int pitch = -1, speed = -1; + int i; + TTSMODEINFO mode; + + for (i = 1; i < argc; i++) { + if (!strcmp(argv[i], "-l") || !strcmp(argv[i], "--list")) { + CoInitialize(NULL); + list_voices(); + CoUninitialize(); + return 0; + } else if (!strcmp(argv[i], "-v")) voice = argv[++i]; + else if (!strcmp(argv[i], "-t")) text = argv[++i]; + else if (!strcmp(argv[i], "-o")) out = argv[++i]; + else if (!strcmp(argv[i], "-p")) pitch = atoi(argv[++i]); + else if (!strcmp(argv[i], "-s")) speed = atoi(argv[++i]); + else { usage(); return 2; } + } + if (!voice || !text || !out) { usage(); return 2; } + + if (FAILED(CoInitialize(NULL))) { + fprintf(stderr, "CoInitialize failed\n"); + return 1; + } + + if (FAILED(find_voice(voice, &mode))) { + fprintf(stderr, "voice not found: %s\navailable:\n", voice); + list_voices(); + CoUninitialize(); + return 1; + } + printf("voice: %s\n", mode.szModeName); + + PIAUDIOFILE af = NULL; + HRESULT hr = CoCreateInstance(&CLSID_AudioDestFile, NULL, CLSCTX_ALL, + &IID_IAudioFile, (void **)&af); + if (FAILED(hr) || !af) { + fprintf(stderr, "cannot create audio destination (0x%08lx)\n", + (unsigned long)hr); + CoUninitialize(); + return 1; + } + + PITTSCENTRAL central = NULL; + { + PITTSFIND f = NULL; + hr = CoCreateInstance(&CLSID_TTSEnumerator, NULL, CLSCTX_ALL, + &IID_ITTSFind, (void **)&f); + if (FAILED(hr) || !f) { + fprintf(stderr, "cannot create TTS find (0x%08lx)\n", + (unsigned long)hr); + af->lpVtbl->Release(af); + CoUninitialize(); + return 1; + } + hr = f->lpVtbl->Select(f, mode.gModeID, ¢ral, (LPUNKNOWN)af); + f->lpVtbl->Release(f); + if (FAILED(hr) || !central) { + fprintf(stderr, "voice select failed (0x%08lx)\n", + (unsigned long)hr); + af->lpVtbl->Release(af); + CoUninitialize(); + return 1; + } + } + + af->lpVtbl->RealTimeSet(af, (WORD)TTSATTR_MAXREALTIME); + + WCHAR wout[MAX_PATH]; + MultiByteToWideChar(CP_ACP, 0, out, -1, wout, MAX_PATH); + hr = af->lpVtbl->Set(af, wout, 1); + if (FAILED(hr)) { + fprintf(stderr, "cannot open output file (0x%08lx)\n", + (unsigned long)hr); + central->lpVtbl->Release(central); + af->lpVtbl->Release(af); + CoUninitialize(); + return 1; + } + + PITTSATTRIBUTES attr = NULL; + hr = central->lpVtbl->QueryInterface(central, &IID_ITTSAttributes, + (void **)&attr); + if (FAILED(hr) || !attr) { + fprintf(stderr, "no ITTSAttributes (0x%08lx)\n", (unsigned long)hr); + central->lpVtbl->Release(central); + af->lpVtbl->Release(af); + CoUninitialize(); + return 1; + } + + WORD pmin = 0, pmax = 0, pdef = 0; + DWORD smin = 0, smax = 0, sdef = 0; + attr->lpVtbl->PitchGet(attr, &pdef); + attr->lpVtbl->SpeedGet(attr, &sdef); + attr->lpVtbl->PitchSet(attr, TTSATTR_MINPITCH); + attr->lpVtbl->PitchGet(attr, &pmin); + attr->lpVtbl->PitchSet(attr, TTSATTR_MAXPITCH); + attr->lpVtbl->PitchGet(attr, &pmax); + attr->lpVtbl->SpeedSet(attr, TTSATTR_MINSPEED); + attr->lpVtbl->SpeedGet(attr, &smin); + attr->lpVtbl->SpeedSet(attr, TTSATTR_MAXSPEED); + attr->lpVtbl->SpeedGet(attr, &smax); + + if (pitch < 0) pitch = pdef; + if (speed < 0) speed = sdef; + if (pitch < (int)pmin) pitch = pmin; + if (pitch > (int)pmax) pitch = pmax; + if (speed < (int)smin) speed = smin; + if (speed > (int)smax) speed = smax; + printf("pitch: %d (%d..%d) speed: %d (%lu..%lu)\n", + pitch, pmin, pmax, speed, (unsigned long)smin, (unsigned long)smax); + + attr->lpVtbl->PitchSet(attr, (WORD)pitch); + attr->lpVtbl->SpeedSet(attr, (DWORD)speed); + + { + DWORD regkey = 0; + hr = central->lpVtbl->Register(central, &g_notify, + IID_ITTSNotifySink, ®key); + if (FAILED(hr)) { + fprintf(stderr, "Register failed (0x%08lx)\n", (unsigned long)hr); + attr->lpVtbl->Release(attr); + central->lpVtbl->Release(central); + af->lpVtbl->Release(af); + CoUninitialize(); + return 1; + } + } + + { + SDATA data; + data.dwSize = (DWORD)(strlen(text) + 1); + data.pData = (PVOID)text; + + central->lpVtbl->AudioReset(central); + hr = central->lpVtbl->TextData(central, CHARSET_TEXT, + TTSDATAFLAG_TAGGED, data, NULL, + IID_ITTSBufNotifySink); + if (FAILED(hr)) { + fprintf(stderr, "TextData failed (0x%08lx)\n", (unsigned long)hr); + } else { + DWORD start = GetTickCount(); + while (!g_done) { + MSG msg; + while (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE)) { + TranslateMessage(&msg); + DispatchMessage(&msg); + } + if (!g_started && GetTickCount() - start > 30000) { + fprintf(stderr, "timed out waiting for audio start\n"); + break; + } + if (GetTickCount() - start > 120000) { + fprintf(stderr, "timed out waiting for audio stop\n"); + break; + } + Sleep(10); + } + } + } + + af->lpVtbl->Flush(af); + + attr->lpVtbl->Release(attr); + central->lpVtbl->Release(central); + af->lpVtbl->Release(af); + CoUninitialize(); + printf("done%s\n", g_done ? "" : " (incomplete)"); + return g_done ? 0 : 1; +} diff --git a/setup/inf2reg.py b/setup/inf2reg.py new file mode 100755 index 0000000..24eea96 --- /dev/null +++ b/setup/inf2reg.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""Convert INF AddReg sections into a Windows/Wine .reg file. + +Example: + inf2reg.py --section APIReg --define 49000='C:\\windows\\speech' \ + spchapi/SPCHAPI.INF > spchapi.reg +""" +import argparse +import re +import sys + +ROOTS = { + "HKCR": "HKEY_CLASSES_ROOT", + "HKLM": "HKEY_LOCAL_MACHINE", + "HKCU": "HKEY_CURRENT_USER", + "HKU": "HKEY_USERS", +} + + +def expand(text, defines): + def repl(m): + key = m.group(1) + if key == "25": + return defines.get("WINDOWS", r"C:\windows") + return defines.get(key, m.group(0)) + + return re.sub(r"%(\w+)%", repl, text) + + +def reg_type(flags, data): + if not flags: + return "REG_SZ" + try: + f = int(flags, 16) if flags.lower().startswith("0x") else int(flags) + except ValueError: + return "REG_SZ" + if f & 0x00020000: + return "REG_EXPAND_SZ" + if f & 0x00010000: + return "REG_MULTI_SZ" + if f & 0x00000001: + return "REG_BINARY" + return "REG_SZ" + + +def reg_escape(value): + return value.replace("\\", "\\\\").replace('"', '\\"') + + +def unquote(token): + token = token.strip() + if len(token) >= 2 and token[0] == '"' and token[-1] == '"': + return token[1:-1] + return token + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("inf") + ap.add_argument("--section", action="append", required=True, + help="AddReg section name (repeatable)") + ap.add_argument("--define", action="append", default=[], + help="ID=value, e.g. 49000=C:\\windows\\speech") + args = ap.parse_args() + + defines = {"WINDOWS": r"C:\windows"} + for item in args.define: + k, v = item.split("=", 1) + defines[k] = v + + wanted = set(args.section) + section = None + out = ["REGEDIT4", ""] + + with open(args.inf, "r", encoding="latin-1") as fh: + for raw in fh: + line = raw.strip() + if not line or line.startswith(";"): + continue + m = re.match(r"^\[([^\]]+)\]$", line) + if m: + section = m.group(1) + continue + if section not in wanted: + continue + parts = [p.strip() for p in line.split(",")] + if len(parts) < 4: + continue + root, key, name, flags = parts[0], parts[1], parts[2], parts[3] + data = ",".join(parts[4:]) if len(parts) > 4 else "" + if root.upper() not in ROOTS: + continue + data = expand(unquote(data), defines) + name = expand(unquote(name), defines) + if "%InstallPrompt%" in name: + continue + if not data: + continue + rtype = reg_type(flags.strip(), data) + keypath = "\\".join( + p for p in (ROOTS[root.upper()], expand(key, defines)) if p + ) + value = data if rtype == "REG_BINARY" else '"%s"' % reg_escape(data) + out.append("[%s]" % keypath) + out.append("%s=%s" % ('@' if not name else '"%s"' % name, value)) + out.append("") + + sys.stdout.write("\n".join(out) + "\n") + + +if __name__ == "__main__": + main() diff --git a/setup/install-deps.sh b/setup/install-deps.sh new file mode 100755 index 0000000..bbaba46 --- /dev/null +++ b/setup/install-deps.sh @@ -0,0 +1,37 @@ +#!/bin/sh +# Install Wine + 32-bit Winelib toolchain + helpers. Debian/Ubuntu. +# Run as root: sudo setup/install-deps.sh +set -eu + +[ "$(id -u)" = "0" ] || { echo "run as root: sudo $0" >&2; exit 1; } + +export DEBIAN_FRONTEND=noninteractive + +if ! dpkg --print-foreign-architectures | grep -qx i386; then + echo "== enabling i386 architecture" + dpkg --add-architecture i386 +fi + +apt-get update + +for p in \ + wine wine64 wine32:i386 \ + wine64-tools \ + libwine-dev libwine-dev:i386 \ + gcc-multilib libc6-dev-i386 \ + mingw-w64 \ + xvfb unshield +do + if apt-cache policy "$p" 2>/dev/null | grep -q "Candidate: [0-9]"; then + echo "== install $p" + apt-get install -y --no-install-recommends "$p" || echo "!! FAILED: $p" + else + echo "-- unavailable, skipped: $p" + fi +done + +echo +echo "versions:" +wine --version 2>/dev/null || echo "wine missing" +winegcc --version 2>/dev/null | head -1 || echo "winegcc missing" +i686-w64-mingw32-gcc --version 2>/dev/null | head -1 || echo "mingw missing" diff --git a/setup/run.sh b/setup/run.sh new file mode 100755 index 0000000..c2e297f --- /dev/null +++ b/setup/run.sh @@ -0,0 +1,28 @@ +#!/bin/sh +# Run the SAPI4 host with the project's wine prefix and a working X display. +# Usage: setup/run.sh [sapi4host args...] +set -eu + +HERE=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +ROOT=$(CDPATH= cd -- "$HERE/.." && pwd) +export WINEPREFIX=${WINEPREFIX:-$HOME/.wine-microslopsam} +export WINEARCH=win32 + +if [ -z "${DISPLAY:-}" ]; then + if ! [ -S /tmp/.X11-unix/X77 ]; then + Xvfb :77 -screen 0 1024x768x24 >/dev/null 2>&1 & + sleep 1 + fi + export DISPLAY=:77 +fi + +HOST="$ROOT/build/sapi4host" +if [ ! -x "$HOST" ]; then + HOST="$ROOT/build/sapi4host.exe" +fi +if [ ! -x "$HOST" ]; then + echo "no host binary; build first (see README)" >&2 + exit 1 +fi + +exec "$HOST" "$@" diff --git a/setup/setup-flextalk.sh b/setup/setup-flextalk.sh new file mode 100755 index 0000000..f3d5ac9 --- /dev/null +++ b/setup/setup-flextalk.sh @@ -0,0 +1,95 @@ +#!/bin/sh +# Install AT&T FlexTalk 2.1 (Keith Bell, Kerry Watson) into the wine prefix. +# +# Source: extracted InstallShield cabinet, e.g. +# flextalk/extracted/TTS_Executables +# Usage: setup/setup-flextalk.sh [source-dir] +set -eu + +HERE=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +ROOT=$(CDPATH= cd -- "$HERE/.." && pwd) +SRC=${1:-$ROOT/../flextalk/extracted/TTS_Executables} + +[ -d "$SRC/EngineDLLs" ] || { echo "source not found: $SRC" >&2; exit 1; } + +export WINEPREFIX=${WINEPREFIX:-$HOME/.wine-microslopsam} +export WINEARCH=win32 + +if [ -z "${DISPLAY:-}" ]; then + WRUN="xvfb-run -a wine" +else + WRUN="wine" +fi + +DEST="$WINEPREFIX/drive_c/FlexTalk" +echo "== files -> $DEST" +mkdir -p "$DEST" +cp -a "$SRC/." "$DEST/" + +REG="$HERE/out-flextalk.reg" +cat > "$REG" <<'EOF' +REGEDIT4 + +[HKEY_LOCAL_MACHINE\Software\Voice\TextToSpeech\Engine] +"AT&T FlexTalk"="{FFE78E00-C69C-11CE-AEC1-0000C0E9CB87}" + +[HKEY_LOCAL_MACHINE\Software\Voice\TextToSpeech\Engine\{FFE78E00-C69C-11CE-AEC1-0000C0E9CB87}] +"Dirty"=dword:00000001 + +[HKEY_CLASSES_ROOT\CLSID\{FFE78E00-C69C-11CE-AEC1-0000C0E9CB87}] +@="AT&T FlexTalk" + +[HKEY_CLASSES_ROOT\CLSID\{FFE78E00-C69C-11CE-AEC1-0000C0E9CB87}\InprocServer32] +@="C:\\FlexTalk\\EngineDLLs\\FlexTalk.dll" +"ThreadingModel"="Apartment" + +[HKEY_CLASSES_ROOT\CLSID\{778EAAE2-499F-11CF-B0BA-444553540000}\InprocServer32] +@="C:\\FlexTalk\\EngineDLLs\\FlexTalk.dll" +"ThreadingModel"="Apartment" + +[HKEY_LOCAL_MACHINE\Software\AT&T\Watson\2.0\FlexTalk\2.0\Directories] +"RootDirectory"="C:\\FlexTalk\\" + +[HKEY_LOCAL_MACHINE\Software\AT&T\Watson\2.0\FlexTalk\2.0\Dictionaries] +"StandardDictionary"="2 ft_sdict.dll" +"ExceptionDictionary"="0 ft_udict.dll" +"HomographDictionary"="1 ft_hgrap.dll" +"ProperNameDictionary"="3 ft_namsa.dll" +"LetterToSoundDictionary"="4 ft_l2s.dll" + +[HKEY_LOCAL_MACHINE\Software\AT&T\Watson\2.0\FlexTalk\2.0\Options] +"Gender"="Male" +"SampleRate"="11025" + +[HKEY_LOCAL_MACHINE\Software\AT&T\Watson\2.0\FlexTalk\2.0\Speakers\{11260610-C84A-11ce-AEC4-0000C0E9CB87}] +"Age"="30" +"Expression"="59" +"Gender"="" +"Head Size"="46" +"Lowering"="40" +"Mode Name"="Keith Bell" +"Pitch"="27" +"Prominences"="17" +"Speaker Name"="Keith Bell" +"Speaking Rate"="33" +"Volume"="100" + +[HKEY_LOCAL_MACHINE\Software\AT&T\Watson\2.0\FlexTalk\2.0\Speakers\{000031CB-3699-551F-AED0-6B4893382D57}] +"Age"="30" +"Expression"="100" +"Gender"="" +"Head Size"="45" +"Lowering"="40" +"Mode Name"="Kerry Watson" +"Pitch"="65" +"Prominences"="17" +"Speaker Name"="Kerry Watson" +"Speaking Rate"="32" +"Volume"="100" +EOF + +echo "== registry" +$WRUN regedit /S "$REG" 2>/dev/null || true + +echo "== done; list voices:" +echo " $WRUN $ROOT/host/sapi4host.exe -l" diff --git a/setup/setup-prefix.sh b/setup/setup-prefix.sh new file mode 100755 index 0000000..9729a62 --- /dev/null +++ b/setup/setup-prefix.sh @@ -0,0 +1,51 @@ +#!/bin/sh +# Build a 32-bit Wine prefix, install the SAPI4 runtime, the MSTTS engine and +# the MSTTS voices, and register everything. +# +# Usage: setup/setup-prefix.sh [path-to-asset-repo] +# Default asset repo: ../Microsoft-Sam-Mary-Mike-TruVoice-WSAPI4 +set -eu + +HERE=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +ROOT=$(CDPATH= cd -- "$HERE/.." && pwd) +ASSETS=${1:-$ROOT/../Microsoft-Sam-Mary-Mike-TruVoice-WSAPI4} + +[ -d "$ASSETS/spchapi" ] || { echo "asset dir not found: $ASSETS" >&2; exit 1; } + +export WINEPREFIX=${WINEPREFIX:-$HOME/.wine-microslopsam} +export WINEARCH=win32 + +if [ -z "${DISPLAY:-}" ]; then + WRUN="xvfb-run -a wine" +else + WRUN="wine" +fi + +echo "== prefix: $WINEPREFIX" +mkdir -p "$WINEPREFIX" +$WRUN wineboot -u + +SPEECH_DIR="$WINEPREFIX/drive_c/windows/speech" +ENGINE_DIR="$WINEPREFIX/drive_c/Program Files/Common Files/microsoft shared/SpeechEngines/TTS" +mkdir -p "$SPEECH_DIR" "$ENGINE_DIR" + +echo "== runtime -> $SPEECH_DIR" +cp "$ASSETS"/spchapi/SPEECH.DLL "$SPEECH_DIR/" + +echo "== engine + voices -> $ENGINE_DIR" +cp "$ASSETS"/msttsl/msttssyn.dll "$ENGINE_DIR/" +cp "$ASSETS"/msttsl/*.vce "$ASSETS"/msttsl/*.cfg "$ENGINE_DIR/" + +echo "== registry" +python3 "$HERE/inf2reg.py" --section APIReg \ + --define "49000=C:\\windows\\speech" \ + "$ASSETS/spchapi/SPCHAPI.INF" > "$HERE/out-spchapi.reg" +python3 "$HERE/inf2reg.py" --section TTSReg \ + --define "49000=C:\\Program Files\\Common Files\\microsoft shared\\SpeechEngines\\TTS" \ + "$ASSETS/msttsl/mstts.inf" > "$HERE/out-mstts.reg" + +$WRUN regedit /S "$HERE/out-spchapi.reg" +$WRUN regedit /S "$HERE/out-mstts.reg" + +echo "== done" +echo "list voices: $WRUN $ROOT/host/sapi4host.exe -l"