Add MicroslopSam TTS, Flextalk voice profiles, effects, and engine controls
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
build/
|
||||
*.o
|
||||
*.a
|
||||
*.so
|
||||
*.exe
|
||||
*.dll
|
||||
setup/out-*.reg
|
||||
*.wav
|
||||
@@ -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
|
||||
@@ -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).
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
@@ -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, ¢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;
|
||||
}
|
||||
|
||||
/* 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, ®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;
|
||||
}
|
||||
Executable
+112
@@ -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()
|
||||
Executable
+37
@@ -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"
|
||||
Executable
+84
@@ -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
|
||||
Executable
+28
@@ -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" "$@"
|
||||
Executable
+95
@@ -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"
|
||||
Executable
+51
@@ -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"
|
||||
Executable
+308
@@ -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"
|
||||
Reference in New Issue
Block a user