Add MicroslopSam TTS, Flextalk voice profiles, effects, and engine controls

This commit is contained in:
Fisto
2026-09-15 15:09:23 +02:00
parent eab94132bb
commit 302436f95f
42 changed files with 4675 additions and 180 deletions
+8
View File
@@ -0,0 +1,8 @@
build/
*.o
*.a
*.so
*.exe
*.dll
setup/out-*.reg
*.wav
+97
View File
@@ -0,0 +1,97 @@
# 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
Everything in one command (idempotent; sudo only for system packages):
setup/install.sh
Manual steps, if needed:
sudo setup/install-deps.sh # Wine/Winelib/helpers
setup/setup-prefix.sh # prefix + SAPI4 runtime + MSTTS voices
setup/setup-flextalk.sh # Flextalk extraction + registry
# host build:
winegcc -m32 -O2 -o build/sapi4host-winlib host/sapi4host.c -I../reference \
-lole32 -luser32 -ladvapi32
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, `-V` volume
(volume is engine-specific; not used by the Veskslop wrapper).
## Veskslop integration
export VESKSLOP_TTS_CMD="$PWD/setup/veskslop-tts.sh {out} {text_file}"
export VESKSLOP_TTS_VOICES="$(setup/veskslop-tts.sh --voices)"
The wrapper maps voice IDs (`sam`, `mary`, `mike`, `keith-bell`, `kerry-watson`, plus the
MSTTS variants), reads `VESKSLOP_TTS_VOICE`, `VESKSLOP_TTS_SPEED` (multiplier),
`VESKSLOP_TTS_PITCH` (semitones) and `VESKSLOP_TTS_EXPRESSION` (0-100, Flextalk voices
only), starts Xvfb when `DISPLAY` is unset, and writes a 48 kHz mono s16le WAV trimmed,
faded and normalized to about -16 LUFS (-3 dBFS true peak).
Speed and pitch apply to every voice (Flextalk's numeric settings must be `REG_DWORD`,
which the setup script writes). Expression is a Flextalk profile value applied at voice
load: the wrapper writes it to the Wine registry before synthesis. `--voices` marks those
voices with `expression=NN`, where NN is the registry default. Mapping details are in
`docs/TTS-DESIGN.md`.
## Layout
- `host/sapi4host.c` — host: voice select, pitch/speed, text -> WAV
- `setup/install.sh` — one-shot installer (runs all steps below)
- `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)
- `setup/veskslop-tts.sh` — Veskslop command-provider wrapper (post-processing included)
- `docs/NOTES.md` — registry/CLSID facts, runtime RE notes, gotchas
- `docs/TTS-DESIGN.md` — integration contract with Veskslop plus engine clarifications
## 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 numeric speaker settings must be `REG_DWORD` (the installer script handles
this); `REG_SZ` values cause a monotone buzz. Pitch and speed work on all voices.
- 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
+26
View File
@@ -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).
+99
View File
@@ -0,0 +1,99 @@
# 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` =
`<root>\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). **All numeric
values must be REG_DWORD**: with REG_SZ the engine reads garbage and
synthesizes a monotone buzz (pitch fixed at max, pitch control dead).
- `{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).
- Generate registry `.reg` files with `printf`, never `echo`: dash's `echo`
interprets `\2` in `...\Watson\2.0\...` as 0x02, corrupting the key so that
`wine reg import` is a silent no-op (it still returns 0). The wrapper rejects
a generated profile containing control characters.
- 32-bit engines require a 32-bit prefix (`WINEARCH=win32`).
- `sudo` on this machine requires a password; installs are manual.
## Engine units (measured)
- speed: MSTTS range 30-450, Flextalk 50-390; the natural rate is about 150 for every
voice (duration at 100/200/300 fits a natural value near 140-150).
- pitch: engine units, roughly linear in F0 near the natural point (~1.1 Hz per unit).
Natural values: about 100 (MSTTS), 106 (Keith Bell), 186 (Kerry Watson); Flextalk max 250.
Verified with the corrected DWORD registry: Kerry 0 st = 209 Hz, -6 st = 148 Hz,
+6 st = 250 Hz (clamped at max); Keith 0 st = 119 Hz, +6 st = 170 Hz.
- The wrapper maps semitones as `natural * 2^(st/12)` and clamps to the range.
- volume: available through `ITTSAttributes` (host flag `-V`). Flextalk is linear
(0-65535, default 65535; half is about -6 dB). MSTTS is non-linear (0 = silence,
4096 about -24 dB, 65535+ full scale); treat it as on/off.
- Flextalk per-speaker profile values are applied at voice load; `Expression` was
verified audible (0/59/100 produce different waveforms at equal duration). Changing
them requires a registry edit and re-selecting the voice.
## Decompiled corpora (Ghidra)
- `/mnt/ghidra/decomp/msttssyn.dll/` (786 functions, engine)
- `/mnt/ghidra/decomp/speech.dll/` (1030 functions, SAPI4 runtime)
+477
View File
@@ -0,0 +1,477 @@
# TTS-DESIGN.md — building a TTS engine for Veskslop
This document is the integration contract for a text-to-speech engine that Veskslop will use. It is
written to be self-contained: an engine developer can work from this file alone.
## How Veskslop uses TTS
1. A line of text is queued (`POST /api/audio/speak` or the MCP `speak` tool).
2. The daemon asks the engine for an audio file (command mode) or audio bytes (HTTP mode).
3. `ffplay` decodes the file and plays it into the PulseAudio null sink `veskslop_tts`.
4. `veskslop_tts.monitor` is exposed as the source `veskslop_mic`.
5. The Vesktop client uses `veskslop_mic` as its microphone, so the call hears the speech.
6. While TTS plays and another person speaks, the daemon lowers the `veskslop_tts` sink volume to
30% and restores it after. This is "ducking". The engine does not implement ducking.
Lines are serialized: one synthesis, then playback, then the next line. There is no streaming
playback path and no partial-audio protocol. The engine returns a complete audio object.
## Integration modes
### HTTP provider (recommended)
Set `VESKSLOP_TTS_API_URL`. Select the API provider with `VESKSLOP_TTS_PROVIDER=api` or the
**API TTS** choice in the TTS panel. The daemon sends:
```
POST <VESKSLOP_TTS_API_URL>
Content-Type: application/json
Authorization: Bearer <VESKSLOP_TTS_API_KEY> (only when the key is set)
{ "text": "Hello there.", "voice": "anna" }
```
The response body must be the audio bytes. `Content-Type` selects the file extension: `audio/mpeg`
or `audio/mp3` → `.mp3`, `audio/ogg` → `.ogg`, `audio/wav` or `audio/x-wav` → `.wav`, anything else
is treated as `.mp3`. Prefer `audio/wav`.
A non-2xx response is an error. Return a short JSON or text body with the reason; the daemon logs
it and drops the line.
Use the HTTP mode for any engine with a model load time. It keeps the model warm and avoids the
per-line process start cost.
### Local command provider
Set `VESKSLOP_TTS_CMD`. It takes precedence over the built-in espeak-ng engine. Veskslop replaces
these placeholders, splits the string on whitespace, and runs the result directly (no shell):
| Placeholder | Replaced with |
| --- | --- |
| `{out}` | Output file path, extension `.wav`. Required. |
| `{text_file}` | A UTF-8 text file holding the line. |
| `{voice}` | The selected voice ID, or an empty string. |
| `{speed}` | The rate multiplier, or an empty string when unset. |
| `{pitch}` | The pitch offset in semitones, or an empty string when unset. |
| `{expression}` | The Flextalk Expression profile value, or an empty string when unset. |
The process also receives `VESKSLOP_TTS_VOICE`, `VESKSLOP_TTS_SPEED`,
`VESKSLOP_TTS_PITCH`, and `VESKSLOP_TTS_EXPRESSION` when those are set. Write the audio to
`{out}` and exit 0. Non-zero exits are errors; stderr is captured into the log message, so keep it
short.
Command mode is best for fast, stateless engines. It has no shell quoting: paths with spaces and
shell tricks cannot work. Use a small wrapper script if you need options.
## Audio output requirements
| Property | Recommendation | Notes |
| --- | --- | --- |
| Container | WAV (RIFF) | Any format ffmpeg decodes works, but WAV avoids decoder startup cost. |
| Codec | PCM signed 16-bit little-endian | `pcm_s16le`. |
| Sample rate | 48000 Hz | 44100 Hz is fine. Discord re-encodes to 48 kHz Opus; a 48 kHz source avoids one resample. |
| Channels | Mono | Discord voice is effectively mono. Stereo is accepted but adds no value. |
| Duration | One line of speech | No intro/outro music, no chimes. |
| Peak level | About -3 dBFS | Leave headroom; ducking and Discord gain must not clip. |
| Loudness | Consistent across lines | Aim for a stable integrated loudness (roughly -16 to -20 LUFS) so lines do not jump in volume. |
| Silence | Trim start to under 100 ms, keep roughly 20-50 ms of padding | Long leading silence delays the first audible word. |
| Edges | 5-10 ms fade in and out | Prevents clicks. |
| DC offset | None | Center the waveform. |
Veskslop does not normalize, limit, or re-encode the audio. Playback is `ffplay` at 100% sink
volume, so the engine's levels are what Discord hears.
## Voices
A voice ID is an opaque string to Veskslop. Prefer short, stable, lowercase ASCII identifiers such
as `anna`, `sv-anna`, or `en-male-2`. The ID can contain letters, digits, `-`, `_`, and `+`.
If the engine has multiple voices, expose them so a UI can list them:
```
GET /voices
[
{
"id": "anna",
"name": "Anna",
"language": "sv",
"gender": "female",
"speed": { "min": 0.5, "max": 2.0, "default": 1.0 },
"pitch": { "min": -12, "max": 12, "default": 0, "unit": "semitones" }
}
]
```
Note: Veskslop currently lists voices from espeak-ng only. A custom engine's voices appear after
Veskslop adds support for reading this endpoint (see "Veskslop changes needed"). Until then, set
the voice through the engine's own configuration and leave the Veskslop voice setting empty.
## Speed and pitch
Speed and pitch are engine settings, not Veskslop concepts today. Veskslop passes only `text`,
`voice`, and `provider`. No UI or preference exists for speed or pitch.
Recommended semantics for the engine:
- **speed**: a multiplier, `1.0` is normal, useful range `0.5` to `2.0`. Do not change pitch when
changing speed unless the user asks for it.
- **pitch**: semitones, `0` is the voice's natural pitch, useful range `-12` to `+12`. State the
unit in the `/voices` response so clients do not guess.
Recommended HTTP shape (extras are optional; an engine may ignore them):
```
{ "text": "...", "voice": "anna", "speed": 1.15, "pitch": -2 }
```
Recommended command shape: `{speed}` and `{pitch}` are substituted when set, and the same values
are exported as `VESKSLOP_TTS_SPEED` and `VESKSLOP_TTS_PITCH`. Read either; the environment form
survives placeholder-free commands.
### How Veskslop should add first-class settings
Implemented on the Veskslop side (2026-09-15):
- `tts` preferences carry `speed` (0.5–2.0) and `pitch` (-12–+12, semitones), both nullable.
- `/api/audio/speak` and the MCP `speak` tool accept optional `speed` and `pitch` that override
the preferences for that line.
- The command provider substitutes `{speed}`/`{pitch}`/`{expression}` and exports
`VESKSLOP_TTS_SPEED` / `VESKSLOP_TTS_PITCH` / `VESKSLOP_TTS_EXPRESSION` only when the value is set.
- The HTTP provider adds `speed`, `pitch`, and `expression` to the request body only when set.
- The TTS panel has speed presets, speed/pitch sliders, and an Expression slider that appears only
for voices that declare it (`expression` / `expression=NN` flags). Changes save to preferences.
- Command mode advertises voices through `VESKSLOP_TTS_VOICES`. Entries are separated by commas or
newlines and use `id`, `id|Name`, `id|Name|gender`, `id|Name|gender|language`, or
`id|Name|gender|language|flags`, where flags are `+`-separated. Flags: `no-pitch` (engine ignores
pitch) and `expression` / `expression=NN` (Flextalk profile parameter with its registry default).
`/api/audio/tts/voices` reports `supportsPitch` and `supportsExpression` /
`expressionDefault`; the TTS panel hides controls the voice does not support. While a custom
command is set, the engine's declared voices replace the espeak list. Reading `/voices` for the
HTTP provider is still future work.
Do not encode settings into the voice string (for example `anna+speed1.2`). It breaks voice lists
and is hard to validate.
## Text handling
- UTF-8 input. Lines arrive trimmed and are at most 1000 characters.
- Do not parse or require SSML. Plain text is the contract. SSML support can be additive.
- Emoji, URLs, markdown, and code blocks can appear. Veskslop does not sanitize them. The engine
should not fail on an unknown character; skip or verbalize it as it prefers.
- Keep punctuation-based prosody. A period, comma, question mark, and newline are the only hints
for pacing.
## Errors, limits, and behavior
- One request = one complete audio object. No chunked playback, no callbacks.
- Synthesis blocks the queue. Target under about 500 ms for a short line; state a realistic budget
if the engine is slower. A warm HTTP server is the way to meet this.
- Requests are serialized by Veskslop, so the engine does not need a request queue, but it should
tolerate repeated back-to-back requests.
- The daemon never retries a failed line. Make failures visible in the response and stderr.
- Do not write to the `veskslop_tts` sink or the `veskslop_mic` source directly. The daemon owns
playback routing.
## Optional: caching
Veskslop does not cache audio. The engine may cache by `hash(text + voice + speed + pitch)` and
return the cached file or bytes. This is useful for repeated lines and for expensive models.
## Veskslop changes needed
Status as of 2026-09-15:
1. `speed` and `pitch` are first-class: preferences, per-request overrides on `/api/audio/speak`
and MCP `speak`, env exports and placeholders for the command provider, request fields for the
HTTP provider, and two sliders in the TTS panel.
2. Command-mode voices come from `VESKSLOP_TTS_VOICES`. Reading the engine's `GET /voices` for the
HTTP provider is not implemented yet. Until then, declare voices with the env var or configure
the voice inside the wrapper.
3. No changes are needed for the audio format; the engine post-processes to the required output.
Until `/voices` is wired, use defaults configured in the engine.
## Acceptance checklist
- [ ] Engine starts and answers on a stable URL or command.
- [ ] Output is mono PCM WAV at 48 kHz (or 44.1 kHz), peak near -3 dBFS.
- [ ] First audible word starts within about 500 ms of the request.
- [ ] Speech is clean: no clicks, no clipping, no long leading silence.
- [ ] Lines are consistent in loudness.
- [ ] `voice` accepts stable ASCII IDs; unknown IDs fall back to a default instead of failing.
- [ ] `speed` and `pitch` are optional and documented in `/voices`.
- [ ] Errors return a short reason and do not leave the engine wedged.
- [ ] No files are written outside the engine's own cache or the `{out}` path.
---
## Clarifications from the engine side (MicroslopSam)
_Added 2026-09-15 by the engine implementer. The contract above stands; these points
record what the engine needs to interpret, add, or hide._
### Output pipeline (WAV requirements)
- Raw SAPI4 output is mono PCM s16le WAV but not at the target rate: the MSTTS voices
(Sam/Mary/Mike) render at 22050 Hz, the Flextalk voices at 11025 Hz.
- Raw Flextalk output **clips** (peak 32768, RMS about 22000) and MSTTS is much quieter,
so one shared level target is required. The engine post-processes every line:
resample to 48000 Hz, trim leading silence (<100 ms), 5-10 ms fades, DC removal, and
level normalization (peak about -3 dBFS, integrated loudness -16 to -20 LUFS).
- Implemented in `setup/veskslop-tts.sh` with ffmpeg: high-pass at 40 Hz, silence trim at
both ends (trim-start, reverse, trim-start, reverse), 8 ms fades, then a **fixed gain**
computed from an EBU R128 measurement (`ebur128=peak=true`):
`gain = min(-16 LUFS target, -3 dBFS true-peak clamp)`, then 48 kHz mono s16le.
- `loudnorm` was rejected: on short lines (under about 3 s) the loudness range is 0 and
dynamic mode attenuated a 2.8 s line to -37.9 LUFS instead of about -18 LUFS; two-pass
linear mode showed the same result. The fixed gain is steady for one-line utterances,
as Veskslop suggested.
- Verified: Keith Bell -18.4 LUFS, Kerry Watson -17.9 LUFS, Sam -20.6 LUFS; every file is
48 kHz mono s16le at -3.0 dBFS true peak. The WAV handed to Veskslop is compliant; the
"Veskslop does not normalize" rule holds.
### Voice IDs
- Engine voice names are "Sam", "Mary", "Mike", "Keith Bell", "Kerry Watson", plus MSTTS
variants (telephone, hall, stadium, space) and TruVoice names that the engine lists but
has no installed data for.
- Assigned stable IDs: `sam`, `mary`, `mike`, `keith-bell`, `kerry-watson`; variants get
`mike-telephone`, `mike-hall`, `mary-stadium`, `mike-space`, and so on. Unknown IDs fall
back to `sam`.
- `/voices` (HTTP mode) reports `language: "en"`, gender, and the speed/pitch ranges below.
### Speed and pitch mapping (measured 2026-09-15)
- SAPI4 uses engine units, not multipliers or semitones. Measured on the dev host with a
correctly typed Flextalk install:
- MSTTS Sam: speed 30-450, natural about 150; pitch 50-200 with a real audible effect.
- Flextalk: speed 50-390, natural about 150 (durations at 100/200/300 fit a natural rate
near 140-150); pitch 50-250 works. Measured F0: Kerry 0 st = 209 Hz, -6 st = 148 Hz,
+6 st = 250 Hz (clamped); Keith 0 st = 119 Hz, +6 st = 170 Hz.
- **Correction to the earlier note**: the previous "Flextalk ignores pitch / natural 300"
finding was an artifact of the speaker registry values being stored as `REG_SZ`. The
engine requires `REG_DWORD` for numeric speaker values; with strings it synthesizes a
monotone buzz at maximum pitch. Fixed in `setup/setup-flextalk.sh`; no `no-pitch` flag
is needed.
- Mapping implemented in `setup/veskslop-tts.sh`: `speed 1.0` = natural rate 150 for every
voice, scaled by the multiplier and clamped to the engine range. `pitch` semitones scale
the voice's natural pitch unit by `2^(st/12)` and clamp to the range. Natural units are
about 100 (MSTTS), 106 (Keith Bell) and 186 (Kerry Watson). The engines' F0 response is
approximate, not semitone-calibrated.
- `{setting}` is unused; the wrapper reads `VESKSLOP_TTS_SPEED` and
`VESKSLOP_TTS_PITCH` from the environment when Veskslop later exports them.
### Text handling
- SAPI4 is ANSI/CP1252. The wrapper converts UTF-8 to CP1252 and maps emoji and
unmappable characters to spaces. The host passes `TTSDATAFLAG_TAGGED`, so SAPI4 control
tags in the text are enabled at the engine level; tagged input is not part of this
contract and is untested. Plain text (no SSML) is the contract.
- Lines up to 1000 characters are well within engine limits.
### Other voice adjustments (beyond speed)
All voices implement SAPI4 `ITTSAttributes`, so **pitch, speed and volume** exist at the
API level. The Veskslop contract exposes only speed and pitch; volume is available in the
host as `sapi4host -V <value>` and is not sent by Veskslop.
- **Volume** (measured 2026-09-15):
- Flextalk: linear, range 0-65535, default 65535; half (32767) is about -6 dB, 0 is
silence.
- MSTTS: default is 0xFFFFFFFF; 0 is silence, but the response is not linear (for
example 4096 gives about -24 dB while 65535 and larger are full scale). Treat volume
as effectively on/off for MSTTS.
- **Flextalk per-speaker profile** (applied at voice load, not runtime-adjustable):
values live in `HKLM\Software\AT&T\Watson\2.0\FlexTalk\2.0\Speakers\{GUID}` (see
`docs/NOTES.md` for the full list and defaults). `Pitch`/`Speaking Rate` set the natural
voice; **Expression** was verified to change the synthesis audibly (Keith Bell at
Expression 0 / 59 / 100 yields different waveforms with the same duration). Changing a
profile requires a registry edit and re-selecting the voice.
- **MSTTS voice character** is fixed by the `.vce`/`.cfg` data; the engine exposes no
per-voice profile in the registry. Its effect variants (telephone, in Hall, in Stadium,
in Space) are separate voice modes with separate data, exposed as distinct voice IDs
(`mike-telephone`, `mary-hall`, ...), not runtime effects.
- Not exposed by this integration: SSML, prosody/style tags, or SAPI4 attribute
extensions beyond pitch/speed/volume.
### Flextalk profile parameters (how to implement)
Each Flextalk speaker has a profile the engine reads when the voice is loaded. It lives in
the registry; **every numeric value must be `REG_DWORD`**:
HKLM\Software\AT&T\Watson\2.0\FlexTalk\2.0\Speakers\{11260610-C84A-11ce-AEC4-0000C0E9CB87} # Keith Bell
HKLM\Software\AT&T\Watson\2.0\FlexTalk\2.0\Speakers\{000031CB-3699-551F-AED0-6B4893382D57} # Kerry Watson
| Value (REG_DWORD unless noted) | Keith Bell | Kerry Watson | Measured effect |
| --- | --- | --- | --- |
| Pitch | 27 | 65 | Natural pitch (F0); the same scale `ITTSAttributes` shifts at runtime |
| Speaking Rate | 33 | 32 | Natural speech rate (about 150 wpm equivalent) |
| Volume | 100 | 100 | Output gain; 0 = silence |
| Expression | 59 | 100 | Audible; changes the waveform at equal duration |
| Prominences | 17 | 17 | Audible; strong prosody/emphasis effect (F0 median 122 -> 197 Hz at 100) |
| Head Size | 46 | 45 | Audible; brightness/level (peak 20001 -> 32205 at 100) |
| Lowering | 40 | 40 | Audible but subtle |
| Age | 30 | 30 | No measured effect on synthesis (informational) |
| Gender (REG_SZ) | "" | "" | No measured effect on synthesis (informational) |
| Mode Name / Speaker Name (REG_SZ) | "Keith Bell" | "Kerry Watson" | Mode name SAPI4 shows for the voice |
How to apply a change:
1. `setup/setup-flextalk.sh` writes these defaults, so re-running it restores them.
2. Profiles are read at voice load. The host loads the engine per request, so the next
synthesis after a registry change uses the new profile; no engine restart is needed.
3. Example (lower and slow Keith Bell), with `WINEPREFIX` pointing at the project prefix:
wine reg add 'HKLM\Software\AT&T\Watson\2.0\FlexTalk\2.0\Speakers\{11260610-C84A-11ce-AEC4-0000C0E9CB87}' \
/v Pitch /t REG_DWORD /d 20 /f
wine reg add '...' /v "Speaking Rate" /t REG_DWORD /d 28 /f
Never use `REG_SZ` for numeric values: the engine reads garbage and synthesizes a
monotone buzz at maximum pitch.
4. Not exposed by the Veskslop contract (which sends only text/voice/speed/pitch). If
profile control is wanted later, the clean place is the wrapper: read an env var such
as `VESKSLOP_TTS_PROFILE` (`key=value` list or JSON), write the registry values before
synthesis, then restore. That keeps the Veskslop interface unchanged.
5. Runtime `ITTSAttributes` pitch/speed/volume layer on top of the profile: the profile
defines the natural values, the attributes shift them per request.
### Transport and latency
- Installation is one command: `setup/install.sh` (system dependencies with sudo, wine
prefix + SAPI4 runtime + MSTTS voices, Flextalk extraction/registration, host build).
Re-running is safe; a fresh machine needs nothing else.
- Command provider, implemented as `setup/veskslop-tts.sh`:
`VESKSLOP_TTS_CMD="<repo>/setup/veskslop-tts.sh {out} {text_file}"` and
`VESKSLOP_TTS_VOICES="$(<repo>/setup/veskslop-tts.sh --voices)"`. The wrapper reads the
text file, transliterates UTF-8 to CP1252, starts or reuses an Xvfb when `DISPLAY` is
unset, calls the engine, then post-processes (trim, fades, measured fixed gain, 48 kHz
mono s16le). A stale private Xvfb is retried once.
- Measured on the dev host: engine-only synthesis is about 290 ms wall for Sam and about
40 ms for Flextalk, including process start; the full wrapper adds the ffmpeg
measure/apply passes, about 630-880 ms per short line. That is above the 500 ms target;
the known optimization is a per-voice static gain (skip the measurement pass), expected
to land around 400-500 ms.
- Manual checks: `setup/run.sh -l`, `setup/run.sh -v "Keith Bell" -t "Hello." -o out.wav`
(raw engine WAV), and the wrapper above for normalized output.
- HTTP provider (later): a persistent process returning `audio/wav` bytes and `/voices`,
with optional caching by `hash(text + voice + speed + pitch)`.
### Quirks the wrapper must hide
- Flextalk numeric speaker settings must be `REG_DWORD`; `REG_SZ` values make the engine
synthesize a buzz. `setup/setup-flextalk.sh` writes them correctly.
- The mode list includes TruVoice names with no installed voice data; filter those out of
`/voices` (or mark them `unavailable`).
- Wine needs a working X display; the project helper starts a persistent Xvfb.
### Licensing
- The engines and voice data are proprietary Microsoft/AT&T/L&H artifacts. Keep this a
local integration; do not redistribute the DLLs or voice data with Veskslop.
### Extra acceptance items for the engine
- [x] Post-processed WAV is 48 kHz mono s16le, peak about -3 dBFS, no clipping, fades present.
- [x] Leading silence under 100 ms.
- [x] Voice ID fallback works; TruVoice entries filtered.
- [x] Latency recorded (Sam about 288 ms, Flextalk about 41 ms on the dev host).
## Responses from the Veskslop side
_Added 2026-09-15._
- **Env vars and placeholders**: `VESKSLOP_TTS_SPEED` and `VESKSLOP_TTS_PITCH` are exported only
when a value is set; `{speed}` and `{pitch}` substitute the same values. Reading the environment
is the safer option because it works even when the command has no placeholders.
- **Defaults**: a missing value means "engine default". Leave the sliders at 1.00x and 0 st in
Veskslop to keep the fields out of the request entirely.
- **`loudnorm` caution**: single-pass `loudnorm` can pump or over-amplify noise on short lines.
Set the target explicitly (`loudnorm=I=-16:TP=-1.5:LRA=11`) and verify a few lines with
`ebur128`. A fixed gain that hits about -3 dBFS peak with a consistent RMS is often steadier for
one-line utterances. Ducking lowers the sink to 30%, so keep peaks conservative and loudness
even between lines.
- **Display**: the daemon does not export `DISPLAY` to TTS children. The wrapper must start or
find its own Xvfb and set `DISPLAY` itself, as the project helper already does. Do not rely on
the client's display.
- **Engine selection**: the Speech settings and the TTS panel list every configured engine. The
command engine appears under `VESKSLOP_TTS_NAME` (default "custom engine") and is selectable
directly; Auto uses the command engine first. Selecting it hides the language row and shows the
`VESKSLOP_TTS_VOICES` list.
- **Start engine**: the Speech settings have a **Start engine** button (`POST /api/audio/tts/warm`,
MCP `start_tts_engine`) that runs one throwaway synthesis to warm the command engine (Xvfb, Wine,
host). Keep the wrapper able to handle a short sentence; the warm-up uses "The quick brown fox
jumps over the lazy dog." and reports the elapsed milliseconds. Two-pass `loudnorm` must not fail
on that input.
- **Lead-in silence**: Veskslop prepends a short silence at playback time (`VESKSLOP_TTS_LEAD_MS`,
default 150 ms) so Discord's voice pipeline is ready before the first syllable. It is added with
`adelay` in the player, not in the audio file. The engine needs no change and can keep its
5–10 ms edge fades.
- **Voice declarations**: list only usable IDs in `VESKSLOP_TTS_VOICES`. Veskslop hides the
espeak list while a custom command is set, so a missing declaration means an empty voice list,
not a fallback. The list accepts commas or newlines. Optional flags: `no-pitch` (hide the pitch
control) and `expression` / `expression=NN` (show the Expression slider and use NN as its
default).
- **Flextalk pitch**: works now that the speaker registry values are `REG_DWORD`; no flag is
needed and Veskslop shows pitch for Keith Bell and Kerry Watson.
- **Expression profile**: implemented for the Flextalk voices. Veskslop stores `tts.expression`
(0-100, nullable), sends it per line (`{expression}` / `VESKSLOP_TTS_EXPRESSION` / the HTTP body),
and shows the slider only for voices flagged `expression=NN`. A null value leaves the registry
untouched, so the voice keeps its engine default.
- **Licensing**: keep the engine DLLs and voice data local and out of any repository; Veskslop
never bundles or redistributes them.
## Current integration path (Veskslop side, 2026-09-15)
Everything below is implemented. The engine stays unchanged; the wrapper and the daemon do the work.
### Voice metadata
`VESKSLOP_TTS_VOICES` entries may carry flags after the language field:
id|Name|gender|language|flags
Flags are `+`-separated:
- `no-pitch` - hide the pitch control for this voice.
- `expression=NN` - Flextalk profile voice; NN is the default Expression value.
- `defaults=pitch:27:rate:33:prominences:17:head_size:46:lowering:40:expression:59` -
per-voice engine defaults. The UI shows "default" at these values and snaps to them
within +/-2 units.
### Wrapper contract (`microslopsam/setup/veskslop-tts.sh`)
Inputs: `{out}`, `{text_file}`, optional positional voice/speed/pitch/expression, and:
| Env | Meaning |
| --- | --- |
| `VESKSLOP_TTS_PROFILE` | `pitch:27:...`/`key=value,...` overrides; the wrapper writes the full profile (defaults + overrides) to the Wine registry with `wine reg import` before every synthesis. |
| `VESKSLOP_TTS_HIGHPASS` | High-pass Hz, 40-400 (default 40). |
| `VESKSLOP_TTS_ECHO` | Mono echo 0-100 (`aecho`), default 0. |
| `VESKSLOP_TTS_WORDS` / `VESKSLOP_TTS_WORD_GAP_MS` | Word-by-word synthesis with silence gaps (Flextalk only). |
Placeholders `{profile}`, `{highpass}`, `{echo}`, `{words}`, `{word_gap_ms}` mirror the env vars.
`wine regedit` fails headlessly but returns 0: use `wine reg import`. A missing profile value
must always write the engine default so no stale registry state survives.
### Daemon and UI path
1. Preferences (`tts`): `profile` (changed keys only), `profilePresets` (5 named slots),
`highpass`, `echo`, `words`, `wordGapMs`, plus `speed`, `pitch`, `expression`, `voice`.
2. `/api/audio/speak` and MCP `speak` accept all of them per line; the request overrides the
preferences and empty fields are omitted so the engine default applies.
3. The TTS panel opens the **Flextalk voice profile** window (movable, recentered on open) with
profile sliders, named presets, radio-kit controls, and test lines (Attention, Laboratory,
Critical, Health, Radiation, plus two persisted custom lines). Test buttons post the same
options as `speak`, so they audition the current profile.
### Known pitfalls
- The daemon runs from `tsx`: restart it after protocol, preference, or metadata changes.
- Static web files can be cached; hard-reload the browser after UI changes.
- `Number(null)` is 0: preference normalizers must treat null/undefined/"" as unset.
- The renderer log spam on a dead client is throttled in the bridge; background polls fail with
`CORE_UNAVAILABLE` and retry.
- Generate the Flextalk profile `.reg` with `printf`, never `echo`: dash's `echo` interprets
`\2` in `...\Watson\2.0\...` as an escape (0x02), so the key is corrupted and
`wine reg import` becomes a silent no-op (it still returns 0). The wrapper now rejects a
generated profile that contains control characters. This was the 2026-09-15 profile bug.
+352
View File
@@ -0,0 +1,352 @@
/*
* 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 <voice> -t <text> -o <out.wav> [-p <pitch>] [-s <speed>]
*
* 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 <initguid.h>
#include <windows.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <mmsystem.h>
#include <objbase.h>
#include <speech.h>
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 <voice> -t <text> -o <out.wav>"
" [-p pitch] [-s speed] [-V volume]\n");
}
/* ------------------------------------------------------------------ */
/* main */
/* ------------------------------------------------------------------ */
int main(int argc, char **argv)
{
const char *voice = NULL, *text = NULL, *out = NULL;
int pitch = -1, speed = -1;
long volume = -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 if (!strcmp(argv[i], "-V")) volume = strtol(argv[++i], NULL, 0);
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, &central, (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;
}
/* Pitch, speed and volume are optional: when unset, leave the engine's own
voice profile untouched (probing/setting would override it). */
if (pitch >= 0 || speed >= 0 || volume >= 0) {
WORD pmin = 0, pmax = 0;
DWORD smin = 0, smax = 0;
if (pitch >= 0) {
attr->lpVtbl->PitchSet(attr, TTSATTR_MINPITCH);
attr->lpVtbl->PitchGet(attr, &pmin);
attr->lpVtbl->PitchSet(attr, TTSATTR_MAXPITCH);
attr->lpVtbl->PitchGet(attr, &pmax);
if (pitch < (int)pmin) pitch = pmin;
if (pitch > (int)pmax) pitch = pmax;
attr->lpVtbl->PitchSet(attr, (WORD)pitch);
printf("pitch: %d (%d..%d)\n", pitch, pmin, pmax);
}
if (speed >= 0) {
attr->lpVtbl->SpeedSet(attr, TTSATTR_MINSPEED);
attr->lpVtbl->SpeedGet(attr, &smin);
attr->lpVtbl->SpeedSet(attr, TTSATTR_MAXSPEED);
attr->lpVtbl->SpeedGet(attr, &smax);
if (speed < (int)smin) speed = smin;
if (speed > (int)smax) speed = smax;
attr->lpVtbl->SpeedSet(attr, (DWORD)speed);
printf("speed: %d (%lu..%lu)\n",
speed, (unsigned long)smin, (unsigned long)smax);
}
if (volume >= 0) {
DWORD vdef = 0;
attr->lpVtbl->VolumeGet(attr, &vdef);
attr->lpVtbl->VolumeSet(attr, (DWORD)volume);
printf("volume: %ld (default %lu)\n",
volume, (unsigned long)vdef);
}
}
{
DWORD regkey = 0;
hr = central->lpVtbl->Register(central, &g_notify,
IID_ITTSNotifySink, &regkey);
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;
}
+112
View File
@@ -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()
+37
View File
@@ -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"
+84
View File
@@ -0,0 +1,84 @@
#!/bin/sh
# One-shot installer for MicroslopSam. Runs all setup steps, idempotently:
# 1. system dependencies (Wine, Winelib, helpers; needs sudo)
# 2. wine prefix + SAPI4 runtime + MSTTS voices + registration
# 3. Flextalk extraction + voices + registration
# 4. host build (Winelib and PE)
# 5. quick voice check
#
# Usage: setup/install.sh
# Re-running is safe.
set -eu
HERE=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
ROOT=$(CDPATH= cd -- "$HERE/.." && pwd)
ASSETS=${ASSETS:-$ROOT/../Microsoft-Sam-Mary-Mike-TruVoice-WSAPI4}
FLEXTALK_DIR=${FLEXTALK_DIR:-$ROOT/../flextalk}
log() { printf '\n== %s\n' "$*"; }
# 1) system dependencies -----------------------------------------------------
if [ "$(id -u)" = "0" ]; then
log "installing system dependencies"
"$HERE/install-deps.sh"
else
log "installing system dependencies (sudo)"
sudo "$HERE/install-deps.sh"
fi
# 2) SAPI4 runtime + MSTTS voices --------------------------------------------
log "setting up wine prefix, SAPI4 runtime and MSTTS voices"
"$HERE/setup-prefix.sh" "$ASSETS"
# 3) Flextalk voices ----------------------------------------------------------
EXTRACTED="$FLEXTALK_DIR/extracted/TTS_Executables"
if [ ! -d "$EXTRACTED/EngineDLLs" ]; then
CAB=""
for c in "$FLEXTALK_DIR/installer/flextalk/data1.cab" \
"$ASSETS/flextalk/data1.cab"; do
if [ -f "$c" ]; then CAB="$c"; break; fi
done
if [ -z "$CAB" ]; then
echo "Flextalk data1.cab not found; set FLEXTALK_DIR to the archive dir" >&2
exit 1
fi
command -v unshield >/dev/null 2>&1 || {
echo "unshield is required to extract the Flextalk installer" >&2
exit 1
}
log "extracting Flextalk installer ($CAB)"
rm -rf "$FLEXTALK_DIR/extracted"
unshield -O -d "$FLEXTALK_DIR/extracted" x "$CAB" >/dev/null
fi
log "installing Flextalk voices"
"$HERE/setup-flextalk.sh" "$EXTRACTED"
# 4) host build ---------------------------------------------------------------
log "building the host"
mkdir -p "$ROOT/build"
winegcc -m32 -O2 -o "$ROOT/build/sapi4host-winlib" "$ROOT/host/sapi4host.c" \
-I"$ROOT/../reference" -lole32 -luser32 -ladvapi32
if command -v i686-w64-mingw32-gcc >/dev/null 2>&1; then
i686-w64-mingw32-gcc -O2 -static -o "$ROOT/build/sapi4host.exe" \
"$ROOT/host/sapi4host.c" -I"$ROOT/../reference" \
-lole32 -luser32 -ladvapi32 -luuid
fi
# 5) check --------------------------------------------------------------------
log "installed voices"
"$HERE/run.sh" -l || true
cat <<EOF
Done.
Veskslop configuration:
VESKSLOP_TTS_CMD="$HERE/veskslop-tts.sh {out} {text_file}"
VESKSLOP_TTS_VOICES="\$($HERE/veskslop-tts.sh --voices)"
Manual test:
$HERE/run.sh -v Sam -t "Hello." -o /tmp/hello.wav
Normalized 48 kHz output (what Veskslop hears):
VESKSLOP_TTS_VOICE=keith-bell $HERE/veskslop-tts.sh /tmp/keith.wav /tmp/text.txt
EOF
+28
View File
@@ -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-winlib.exe"
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" "$@"
+95
View File
@@ -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"=dword:0000001e
"Expression"=dword:0000003b
"Gender"=""
"Head Size"=dword:0000002e
"Lowering"=dword:00000028
"Mode Name"="Keith Bell"
"Pitch"=dword:0000001b
"Prominences"=dword:00000011
"Speaker Name"="Keith Bell"
"Speaking Rate"=dword:00000021
"Volume"=dword:00000064
[HKEY_LOCAL_MACHINE\Software\AT&T\Watson\2.0\FlexTalk\2.0\Speakers\{000031CB-3699-551F-AED0-6B4893382D57}]
"Age"=dword:0000001e
"Expression"=dword:00000064
"Gender"=""
"Head Size"=dword:0000002d
"Lowering"=dword:00000028
"Mode Name"="Kerry Watson"
"Pitch"=dword:00000041
"Prominences"=dword:00000011
"Speaker Name"="Kerry Watson"
"Speaking Rate"=dword:00000020
"Volume"=dword:00000064
EOF
echo "== registry"
$WRUN regedit /S "$REG" 2>/dev/null || true
echo "== done; list voices:"
echo " $WRUN $ROOT/host/sapi4host.exe -l"
+51
View File
@@ -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"
+308
View File
@@ -0,0 +1,308 @@
#!/bin/sh
# Veskslop TTS command-provider wrapper for MicroslopSam.
#
# Recommended Veskslop configuration:
# VESKSLOP_TTS_CMD="<repo>/setup/veskslop-tts.sh {out} {text_file}"
# VESKSLOP_TTS_VOICES="$(setup/veskslop-tts.sh --voices)"
#
# Inputs:
# $1 {out} output WAV path
# $2 {text_file} UTF-8 text file
# $3... optional: voice id, speed multiplier, pitch semitones, expression
# env VESKSLOP_TTS_VOICE, VESKSLOP_TTS_SPEED, VESKSLOP_TTS_PITCH,
# VESKSLOP_TTS_EXPRESSION
# VESKSLOP_TTS_PROFILE Flextalk profile: key=value,key=value
# keys: pitch, rate, volume, expression,
# prominences, head_size, lowering
# VESKSLOP_TTS_HIGHPASS high-pass Hz (40-400, default 40)
# VESKSLOP_TTS_ECHO mono echo amount 0-100 (default 0)
# VESKSLOP_TTS_WORDS 1 = synthesize each word, merge (Flextalk)
# VESKSLOP_TTS_WORD_GAP_MS silence between words in ms (0-500)
#
# Output: 48 kHz mono s16le WAV, trimmed, faded, loudness-normalized.
set -eu
HERE=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
ROOT=$(CDPATH= cd -- "$HERE/.." && pwd)
export WINEPREFIX=${WINEPREFIX:-$HOME/.wine-microslopsam}
export WINEARCH=win32
# VESKSLOP_TTS_VOICES entries: id|Name|gender|language[|flags]
# `expression=NN` marks a Flextalk voice with an Expression profile parameter and
# its registry default. Expression is applied at voice load, so the wrapper writes
# it into the Wine registry before synthesis.
VOICES="sam|Sam|male|en,mary|Mary|female|en,mike|Mike|male|en,keith-bell|Keith Bell|male|en|expression=59+defaults=pitch:27:rate:33:prominences:17:head_size:46:lowering:40:expression:59,kerry-watson|Kerry Watson|female|en|expression=100+defaults=pitch:65:rate:32:prominences:17:head_size:45:lowering:40:expression:100,mike-telephone|Mike (for Telephone)|male|en,mary-telephone|Mary (for Telephone)|female|en,mike-hall|Mike in Hall|male|en,mike-stadium|Mike in Stadium|male|en,mike-space|Mike in Space|male|en,mary-hall|Mary in Hall|female|en,mary-stadium|Mary in Stadium|female|en,mary-space|Mary in Space|female|en"
if [ "${1:-}" = "--voices" ]; then
echo "$VOICES"
exit 0
fi
OUT=${1:?missing out path}
TEXTFILE=${2:?missing text file}
VOICE=${VESKSLOP_TTS_VOICE:-${3:-sam}}
SPEED=${VESKSLOP_TTS_SPEED:-${4:-}}
PITCH=${VESKSLOP_TTS_PITCH:-${5:-}}
EXPRESSION=${VESKSLOP_TTS_EXPRESSION:-${6:-}}
PROFILE=${VESKSLOP_TTS_PROFILE:-}
HIGHPASS=${VESKSLOP_TTS_HIGHPASS:-40}
ECHO=${VESKSLOP_TTS_ECHO:-0}
WORDS=${VESKSLOP_TTS_WORDS:-0}
WORD_GAP=${VESKSLOP_TTS_WORD_GAP_MS:-120}
case "$VOICE" in
sam) ENGVOICE="Sam" ;;
mary) ENGVOICE="Mary" ;;
mike) ENGVOICE="Mike" ;;
keith-bell) ENGVOICE="Keith Bell" ;;
kerry-watson) ENGVOICE="Kerry Watson" ;;
mike-telephone) ENGVOICE="Mike (for Telephone)" ;;
mary-telephone) ENGVOICE="Mary (for Telephone)" ;;
mike-hall) ENGVOICE="Mike in Hall" ;;
mike-stadium) ENGVOICE="Mike in Stadium" ;;
mike-space) ENGVOICE="Mike in Space" ;;
mary-hall) ENGVOICE="Mary in Hall" ;;
mary-stadium) ENGVOICE="Mary in Stadium" ;;
mary-space) ENGVOICE="Mary in Space" ;;
*) ENGVOICE="Sam" ;;
esac
# Flextalk speakers have a registry profile; MSTTS voices do not.
case "$ENGVOICE" in
"Keith Bell") SPEAKER_GUID="{11260610-C84A-11ce-AEC4-0000C0E9CB87}"; DEF_PITCH=27; DEF_RATE=33; DEF_HEAD=46 ;;
"Kerry Watson") SPEAKER_GUID="{000031CB-3699-551F-AED0-6B4893382D57}"; DEF_PITCH=65; DEF_RATE=32; DEF_HEAD=45 ;;
*) SPEAKER_GUID=""; DEF_PITCH=100; DEF_RATE=150; DEF_HEAD=45 ;;
esac
# Engine units (measured). Speed is words/min, natural rate near 150 for every
# voice. Pitch units differ per voice: the engine's natural pitch corresponds to
# about 100 (MSTTS), 106 (Keith Bell) and 186 (Kerry Watson). Semitones scale
# that natural value by 2^(st/12) and clamp to the engine range.
case "$ENGVOICE" in
"Keith Bell") SMAX=390; PMAX=250; NAT_PITCH=106 ;;
"Kerry Watson") SMAX=390; PMAX=250; NAT_PITCH=186 ;;
*) SMAX=450; PMAX=200; NAT_PITCH=100 ;;
esac
SET_ARGS=""
if [ -n "$SPEED" ]; then
S=$(awk -v m="$SPEED" 'BEGIN{printf "%d", 150*m+0.5}')
[ "$S" -lt 50 ] && S=50
[ "$S" -gt "$SMAX" ] && S=$SMAX
SET_ARGS="$SET_ARGS -s $S"
fi
if [ -n "$PITCH" ]; then
P=$(awk -v st="$PITCH" -v nat="$NAT_PITCH" 'BEGIN{printf "%d", nat*exp(log(2)*st/12)+0.5}')
[ "$P" -lt 50 ] && P=50
[ "$P" -gt "$PMAX" ] && P=$PMAX
SET_ARGS="$SET_ARGS -p $P"
fi
# The daemon does not export DISPLAY; keep a private Xvfb for the engine.
start_xvfb() {
if ! [ -S /tmp/.X11-unix/X77 ]; then
Xvfb :77 -screen 0 1024x768x24 >/dev/null 2>&1 &
sleep 1
fi
}
if [ -z "${DISPLAY:-}" ]; then
start_xvfb
export DISPLAY=:77
fi
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
RAW="$TMP/raw.wav"
TXT="$TMP/text.cp1252"
# Apply the Flextalk profile before synthesis. Profiles are read at voice load and
# the host loads the engine per request, so the values are written every call: the
# engine defaults plus any VESKSLOP_TTS_PROFILE overrides. A missing value therefore
# restores the default.
if [ -n "$SPEAKER_GUID" ]; then
P_PITCH=$DEF_PITCH
P_RATE=$DEF_RATE
P_VOLUME=100
P_PROM=17
P_HEAD=$DEF_HEAD
P_LOWER=40
P_EXPR=""
if [ -n "$PROFILE" ]; then
OLDIFS=$IFS
IFS=','
for pair in $PROFILE; do
IFS='='
# shellcheck disable=SC2086
set -- $pair
IFS=','
case "$1" in
pitch) P_PITCH=$2 ;;
rate) P_RATE=$2 ;;
volume) P_VOLUME=$2 ;;
expression) P_EXPR=$2 ;;
prominences) P_PROM=$2 ;;
head_size|headSize) P_HEAD=$2 ;;
lowering) P_LOWER=$2 ;;
esac
done
IFS=$OLDIFS
fi
if [ -n "$EXPRESSION" ]; then P_EXPR=$EXPRESSION; fi
if [ -z "$P_EXPR" ]; then
case "$ENGVOICE" in
"Keith Bell") P_EXPR=59 ;;
*) P_EXPR=100 ;;
esac
fi
for value in "$P_PITCH" "$P_RATE" "$P_VOLUME" "$P_EXPR" "$P_PROM" "$P_HEAD" "$P_LOWER"; do
case "$value" in
*[!0-9]*|"")
echo "profile values must be numbers" >&2
exit 1
;;
esac
done
REG_KEY="HKLM\\Software\\AT&T\\Watson\\2.0\\FlexTalk\\2.0\\Speakers\\$SPEAKER_GUID"
# Use printf, not echo: dash's echo interprets \2 in the key path as an
# escape (STX), which corrupts the key and makes the import a silent no-op.
{
printf 'Windows Registry Editor Version 5.00\n\n'
printf '[%s]\n' "$REG_KEY"
printf '"Pitch"=dword:%08x\n' "$P_PITCH"
printf '"Speaking Rate"=dword:%08x\n' "$P_RATE"
printf '"Volume"=dword:%08x\n' "$P_VOLUME"
printf '"Expression"=dword:%08x\n' "$P_EXPR"
printf '"Prominences"=dword:%08x\n' "$P_PROM"
printf '"Head Size"=dword:%08x\n' "$P_HEAD"
printf '"Lowering"=dword:%08x\n' "$P_LOWER"
} > "$TMP/profile.reg"
# Guard against silent reg-import failures (a malformed key would corrupt
# only the import, and reg.exe can still return 0).
if LC_ALL=C grep -q '[[:cntrl:]]' "$TMP/profile.reg"; then
echo "generated Flextalk profile is malformed" >&2
exit 1
fi
if ! command -v wine >/dev/null 2>&1; then
echo "wine is required to set the Flextalk profile" >&2
exit 1
fi
if ! wine reg import "$TMP/profile.reg" >/dev/null 2>&1; then
echo "could not write the Flextalk profile" >&2
exit 1
fi
fi
# SAPI4 is ANSI/CP1252; transliterate UTF-8 (emoji become '?').
if command -v iconv >/dev/null 2>&1; then
iconv -f UTF-8 -t WINDOWS-1252//TRANSLIT "$TEXTFILE" > "$TXT" 2>/dev/null \
|| cp "$TEXTFILE" "$TXT"
else
cp "$TEXTFILE" "$TXT"
fi
HOST="$ROOT/build/sapi4host-winlib.exe"
if [ ! -x "$HOST" ]; then HOST="$ROOT/build/sapi4host.exe"; fi
if [ ! -x "$HOST" ]; then echo "engine host not built" >&2; exit 1; fi
if ! command -v ffmpeg >/dev/null 2>&1; then echo "ffmpeg not found" >&2; exit 1; fi
run_host() {
# shellcheck disable=SC2086
"$HOST" -v "$ENGVOICE" -t "$2" -o "$1" $SET_ARGS >/dev/null 2>"$TMP/err"
}
# A stale private Xvfb can break audio-destination creation; retry once.
try_run_host() {
if run_host "$1" "$2"; then return 0; fi
if [ "${DISPLAY:-}" = ":77" ]; then
pkill -f "Xvfb :77" 2>/dev/null || true
rm -f /tmp/.X11-unix/X77
start_xvfb
if run_host "$1" "$2"; then return 0; fi
fi
head -c 400 "$TMP/err" >&2
exit 1
}
# Word mode (Flextalk): synthesize each word and merge with silence gaps, which
# gives the staccato radio cadence the Half-Life VOX/HEV voices are known for.
if [ "$WORDS" = "1" ] && [ -n "$SPEAKER_GUID" ]; then
GAP=$WORD_GAP
case "$GAP" in *[!0-9]*|"") GAP=120 ;; esac
[ "$GAP" -gt 500 ] && GAP=500
: > "$TMP/list.txt"
INDEX=0
for word in $(cat "$TXT"); do
INDEX=$((INDEX + 1))
try_run_host "$TMP/w$INDEX.wav" "$word"
echo "file '$TMP/w$INDEX.wav'" >> "$TMP/list.txt"
if [ "$GAP" -gt 0 ]; then
ffmpeg -hide_banner -loglevel error -f lavfi -i "anullsrc=r=11025:cl=mono" \
-t "$(awk -v g="$GAP" 'BEGIN{printf "%.3f", g/1000}')" -c:a pcm_s16le -y "$TMP/sil.wav" \
>/dev/null 2>&1
echo "file '$TMP/sil.wav'" >> "$TMP/list.txt"
fi
done
[ "$INDEX" -gt 0 ] || { echo "no words to speak" >&2; exit 1; }
if ! ffmpeg -hide_banner -loglevel error -f concat -safe 0 -i "$TMP/list.txt" -c copy -y "$RAW" \
>/dev/null 2>&1; then
echo "could not merge the words" >&2
exit 1
fi
else
try_run_host "$RAW" "$(cat "$TXT")"
fi
[ -s "$RAW" ] || { echo "engine produced no audio" >&2; exit 1; }
# Filter chain: adjustable high-pass, silence trim, fades, optional mono echo.
case "$HIGHPASS" in *[!0-9]*|"") HIGHPASS=40 ;; esac
[ "$HIGHPASS" -lt 40 ] && HIGHPASS=40
[ "$HIGHPASS" -gt 400 ] && HIGHPASS=400
case "$ECHO" in *[!0-9]*|"") ECHO=0 ;; esac
[ "$ECHO" -gt 100 ] && ECHO=100
SIL="silenceremove=start_periods=1:start_duration=0.05:start_silence=0.03:start_threshold=-50dB"
PRE="highpass=f=$HIGHPASS,$SIL,afade=t=in:st=0:d=0.008,areverse,$SIL,afade=t=in:st=0:d=0.008,areverse"
if [ "$ECHO" -gt 0 ]; then
DECAY=$(awk -v e="$ECHO" 'BEGIN{printf "%.2f", 0.2 + e * 0.006}')
PRE="$PRE,aecho=0.8:0.85:60:${DECAY}"
fi
# Fixed-gain normalization: single-pass loudnorm misbehaves on short lines
# (LRA=0 causes heavy attenuation), so measure EBU R128 integrated loudness and
# true peak, then apply one gain clamped by the -3 dBFS peak target.
M=$(ffmpeg -hide_banner -nostats -i "$RAW" -af "$PRE,ebur128=peak=true" \
-f null - 2>&1 || true)
I=$(printf '%s' "$M" | sed -n 's/ *I: *\(-\{0,1\}[0-9.]*\) *LUFS.*/\1/p' | tail -1)
TP=$(printf '%s' "$M" | sed -n 's/ *Peak: *\(-\{0,1\}[0-9.]*\) *dBFS.*/\1/p' | tail -1)
if [ -n "$I" ] && [ -n "$TP" ]; then
GAIN=$(awk -v i="$I" -v tp="$TP" 'BEGIN{
g = -16 - i;
lim = -3 - tp;
if (lim < g) g = lim;
if (g > 30) g = 30;
if (g < -20) g = -20;
printf "%.2f", g
}')
else
GAIN=0
fi
TMPOUT="$TMP/out.wav"
if ! ffmpeg -hide_banner -nostats -y -i "$RAW" -af "$PRE,volume=${GAIN}dB" \
-ar 48000 -ac 1 -c:a pcm_s16le "$TMPOUT" >/dev/null 2>"$TMP/ff"; then
head -c 400 "$TMP/ff" >&2
exit 1
fi
[ -s "$TMPOUT" ] || { echo "post-processing produced no audio" >&2; exit 1; }
mv -f "$TMPOUT" "$OUT"