Bug Fixes
(52c3582) ~John Kauffman
Documentation
- Say what a Wine prefix needs before installing EQLogParser
The Linux section only mentioned the .NET Desktop Runtime, and never
described how to install at all. Added an Installing with Bottles and an
Installing with plain Wine path, both of which put vcredist2022 in the
prefix - without it Piper and Kokoro download their files and then stay
silent on "Unable to load DLL 'onnxruntime'". Known Issues gained that
symptom with its fix, a note that the pack itself verified fine so no
re-download is needed, and where the several hundred MB land.
(79f9756) ~John Kauffman
- Record the version bump steps and what InstallDelete really frees
The checklist covered signing and the installer file lists but not the
release numbers, which is how README's download link slips a version.
Added the list of places a version moves, with the build.py step that
regenerates the installer's release-notes RTF.
Corrected two pack claims while there: packs land in
%LOCALAPPDATA%\EQLogParser\<engine>-<version> (piper-1.0), not <engine>,
and "[InstallDelete] clears roughly 150MB" was wrong in both directions -
an ordinary install has nothing to reclaim, a pipertts install gives back
several hundred MB.
(4d894c2) ~John Kauffman
- Describe the timer name option as Dynamic, on by default
The Trigger Manager checkbox is "Dynamic Timer Name" and is checked by
default since it was reworded; triggers.md still documented the old,
inverted "Static Timer Name" and told people to check it to pin a name.
Rewritten for the control as shipped: what checked does, that unchecking
pins the captured value for one run, and that unchecking also keeps a
{variable} that vanishes from showing raw on the timer bar.
(a4f5e69) ~John Kauffman
- Write down what the visual studio cleanup enforces
The cleanup pass left style rules in the code that the standards doc
never had - and one it contradicted. Point the doc at .editorconfig as
the source of truth, list the non-obvious enforced choices (no space
after casts, var for built-in types and out args, static local
functions, parentheses for clarity, collection expressions), and fix
the stale brace line: every declaration in the codebase is Allman,
which csharp_new_line_before_open_brace = all enforces.
(3820c3d) ~John Kauffman
- Where the first utterance delay comes from and what warms it
(0b8787e) ~John Kauffman
- Explain the browse/Use split behind Remove Files
(96c2565) ~John Kauffman
- Write down that packs are the only speech path
Four places described the app-local Piper copy as still honored. They describe one location per engine now, and why a directory the app quietly adopts is worse than a re-download. The piper-tts README also says its ONNX copies have to stay the version Kokoro ships, because Windows holds one onnxruntime.dll per process.
(b8ee638) ~John Kauffman
- Record why the ntdll probe is trustworthy
The asymmetry argument, the System32 pinning and which wrappers turn out
to be Wine underneath, so nobody has to rederive any of it when this
check one day looks redundant.
(d1df2b0) ~John Kauffman
- Write down how an engine becomes available
The rules are none of them guessable from the call sites: Windows
availability is a verdict recorded by LoadVoicesAsync, unknown counts as
available because only the started engine gets probed, both WinRT and
legacy SAPI have to come back empty before it is called false, and an
engine with no voices is refused as a switch target rather than switched
to. Plus the log line that turns "no audio" bug reports into something
diagnosable, and what the picker greys out versus why a pack engine stays
clickable.
(c3fa348) ~John Kauffman
- Describe the packs the app now installs
The status sections said the loader was still to come; it is here, so
the notes moved from intention to mechanism: the pin table with the tag
and archive digest of each published pack, the install sequence and why
each step exists (digest before extraction, manifest after, staging
directory, marker file), which resolver answers what, and the two
version-skew traps that will bite on a future dependency bump — the
managed ONNX wrapper installs with the app while its native half comes
from the pack, and KokoroSharp runs on top of everything in the pack.
Piper's data no longer appears anywhere in this repo, so -Sync stopped
claiming it can supply espeak-ng-data and the Piper voices; it now finds
the Kokoro graph in an installed pack first. KokoroVoicePrefixes was
documented under a name it does not have.
(7f382c2) ~John Kauffman
Testing
- Expect the region, not the language, for pt_BR
RegionOf has always returned what comes after the underscore - that is
the region, per its own doc comment (zh_CN reads CN) and every sibling
assertion in this file. The one pt_BR line expected the language code
instead, so it failed on first run on a machine that can execute the
Wpf suite.
(4610bda) ~John Kauffman
- Pin what an unknown spell does in the counts pipeline
Ground truth for why EverQuest Legends casts never reached the spell
count table. An unknown name is one that parses on a cast line but is
not in the bundled spell data: the record keeps an IsUnknown stub, the
stub stays out of the real DB, and later casts reuse it.
Pinned now, with the real pipeline (RecordsStore -> QuerySpells ->
GetSpellCounts): an unknown cast during a fight counts exactly like any
non-damaging spell, so neither the core pipeline nor the default
category filters drop it. What can drop it: a cast outside the window
around every fight, and a spell whose casts were all interrupted, which
sits at zero under the Any Frequency floor.
(3712521) ~John Kauffman
Commits
(beb0cd5) ~John Kauffman
- Stop deleting app-local speech files on upgrade
The [InstallDelete] additions from commit # [ea7a46c](https://github.com/kauffman12/EQLogParser/commit/ea7a46c6) aimed at a state that never shipped. Six of the eight names (MisakiSharp, NumSharp, OpenTK x3, System.Numerics.Tensors) plus {app}\voices only ever existed in setups built from commit # [27213ac](https://github.com/kauffman12/EQLogParser/commit/27213ac2), the branch commit that bundled Kokoro for a day before the pack design replaced it: master has no KokoroSharp package reference and no release tag installs any of those files. No user can have them.
{app}\piper-tts is real — every release through 2.3.61 lays it down and 2.3.62 stops reading it — but users put their own Piper voices in {app}\piper-tts\voices, so leaving a few hundred inert MB beats deleting models they added. The uninstaller still clears {app}.
Section goes back to what it was: libraries dropped years ago and superseded docs under {app}\data.
(679ceec) ~John Kauffman
- Require vcredist2022 so the speech engines can speak
The Piper and Kokoro packs carry Microsoft's onnxruntime, which imports
msvcp140, msvcp140_1, vcruntime140 and vcruntime140_1 - none of them
supplied by Wine. A bottle without the redistributable downloads a pack
that verifies cleanly and then fails with "Unable to load DLL
'onnxruntime' ... Module not found", which is what a user reported for
Kokoro. Listed first because dotnetdesktop8 sits on the same runtime.
Bottles' own vcredist2022 installer is the right verb: it installs both
arches of VC_redist and overrides those dll names as native,builtin.
(4cfdf21) ~John Kauffman
- Order voice pickers by the label they show
Kokoro sorted its ids, which leads with accent and gender: af_nicole comes
before am_adam, so every American woman preceded Adam and "Yunxi (CN)"
filed itself between the Hindi and Japanese voices. Piper did not sort at
all and listed whatever order the pack's voices.json happened to be built
in. Both now go through TtsVoiceOrder, which orders by the label the engine
prints and keeps the id as a tie break so equal labels do not trade places
between calls.
Sorting is display only; the stored setting stays the engine's id. The
Windows list is left alone - it leads with the system default voice on
purpose, and Windows names its voices the way a person already would.
(c991ed4) ~John Kauffman
- Point the README download link at 2.3.62
It carried the previous version through the bump, so the top download link
on the repo front page offered the old installer. Goes live with the tag.
(540eeb2) ~John Kauffman
- Fix the wording of the 2.3.62 note
Two typos ("descritpions", "povided") in the entry the installer shows and
the feed publishes, and the Linux line now says to see above for where the
engine picker is instead of leaving people to find the Tools dropdown.
RTF regenerated with build.py so InfoBeforeFile matches.
(355a31f) ~John Kauffman
Updated version and release notes
(6f6e055) ~John Kauffman
- Stop warning about a saved engine whose name differs only in case
PlanHint compared this window's normalized engine name against the raw
TtsEngine setting, so a hand-edited "piper" produced "the saved choice,
piper, would not start" while Piper was speaking. Compare without regard
to case and cover it in the action button tests.
(61ad654) ~John Kauffman
- Log the voice label failure, mute repeated speak-path lines
VoiceNameConverter held the solution's last Debug.WriteLine. Logging is a
single RollingFileAppender with no Debug appender, so it reached nowhere a
user could hand over as a bug report; it now goes to Log.Debug with the
exception.
Piper and Kokoro raised Warn from paths that repeat by construction: an
empty synthesis once per callout, a voice load failure once per player
binding it, and the Kokoro cross-directory notice on every voice lookup.
The first occurrence still reaches Warn - it carries the whole message -
and the repeats drop to Debug, the way MarkUnavailable and the Wine probe
already behave.
(72a4568) ~John Kauffman
- Cover the TTS engine options in the FAQ, trim the Linux section
New FAQ question for the three speech engines - Windows (built-in
voices, nothing to download, absent under Wine), Piper (fast and
lightweight, lowest voice quality) and Kokoro (most natural, most
demanding) - with where to switch (Trigger Manager, Tools > TTS Engine),
how the Download/Use buttons behave, live switching, voices belonging
to their engine, and the packs living in %LOCALAPPDATA% (Remove Files).
The old Piper TTS section is no longer valid: the app does not read a
speech runtime from beside the executable anymore, TtsPackManager
downloads and verifies packs into local app data, and the installer
deletes legacy piper-tts copies. It is replaced by the FAQ entry, which
also tells anyone following old instructions that the Google Drive zip
and manual unzip are gone.
Linux is a core feature of the main installer now, so the Flatpak/
Bottles and manual wine install guides come out. What stays: a short
intro (same installer under 64-bit Wine) and the still-true known
issues; the WINE x64 item that Windows TTS does not work there now
points at the new FAQ entry instead of a manual Piper install.
"Callouts" is raid jargon, so the docs say triggers speak text out
loud, the index Linux CTA drops "installation guide", and getting
started links the new FAQ entry.
(b43c155) ~John Kauffman
- Offer the timer name option as Dynamic, on by default
Nobody has the old version yet, so instead of a migration the flag is
simply inverted: TimerNameDynamic defaults to true (the name follows
variable changes, which was always the default behaviour) and gets
unchecked to freeze the name at timer start. The runtime TimerData flag
renames with it - DynamicDisplayName, also true by default - and the
copy test now seeds the new non-default value.
(ded2b8b) ~John Kauffman
Updated tooltips and descriptions plus visual studio code cleanup
(fec6395) ~John Kauffman
- Call the trigger helper dropdown Tools, not Settings
Settings is not a word this app uses - Options means preferences and
Tools means helper windows you open to do something, which is exactly
what Dictionary, Quick Shares and TTS Engine are. Name it after the
category it matches and give the button a tooltip saying what it opens.
(6ad847d) ~John Kauffman
Merge branch 'master' into integrate-pr-406
(30df4f5) ~John Kauffman
- Tuck the three settings buttons behind one dropdown
The TTS Engine, Dictionary and Quick Shares buttons on the Triggers
toolbar were each a plain dialog opener with no state of their own, so
they become the items of one Settings dropdown in the house
DropDownButtonAdv pattern (Manage Layouts, New): no icon, tighter
padding, alphabetical. Each item keeps its old tooltip and reuses the
old Click handlers, so only the XAML and the DesignNotes pointer move.
(c0c5e71) ~John Kauffman
- Build the first engine off the UI thread and keep piper speaking
A Kokoro session over the 156 MB model graph takes seconds, and the
constructor ran it on whatever thread touched Instance first - the UI
thread at startup. The build now runs on the thread pool and
LoadValidVoicesAsync is the single point that waits for it.
A Piper player whose voice failed to load used to speak through an id
piper never received a model for: no audio, while GetVoice still
reported the default. It now speaks the default instead.
A Piper preference falls through to an installed Kokoro before the
Windows voices (TtsEngineFactory.FallbackOrder, now unit tested).
Stale onnxruntime version comment, misplaced locale comment, a few
brace-style slips and DesignNotes get tidied while we are here.
(6c040ca) ~John Kauffman
- Stop a half removed pack from looking like a choice
Three reports and one chain of them, with the dialog knowing less than it could each
time. Removing an engine used earlier in the session answered "restart first", which
is correct - that runtime keeps its libraries mapped until EQLogParser closes - but
not before Directory.Delete had walked partway through the tree. What remained was a
directory holding some of a runtime: too broken to speak with, not absent either, so
the next start offered Download Piper and Remove Files side by side under a line
saying Piper was not installed. Both buttons were right and nothing explained the
other.
Removal now moves the directory aside and empties that, which makes it one operation
instead of several hundred. If the move succeeds, whatever refuses to delete next is
unreachable and gets swept at the next start while nothing has it mapped; if the move
fails, nothing was touched, which is the state where the restart message is the whole
truth. The sweep runs before any engine is created, the one moment in a session that
files left behind are certain to be releasable.
The line under the picker is decided in one pure function beside the button's, and it
has a case for a directory that is not a runtime: files incomplete or damaged, Download
replaces them, Remove clears them away - warned, because those two buttons contradict
each other without it. Reclaiming no longer consults the pinned archive size either,
which answers how big a download is and nothing about what is on disk. And after a
removal the row you cleared stays selected instead of hopping to the engine in use:
that hop is what looked like a disabled In use with no download button anywhere, when
the button was on the row that had just been left.
(671b7af) ~John Kauffman
- Say a voice's name when somebody picks one
Choosing George (GB) was answered with "em george". The preview that fires when a
selection changes speaks the voice's own name, and it was handed the stored value to
do it with - which for Kokoro is an identifier, af_heart or bm_george, and a
synthesizer reads those as letters. Piper only looked fine because its pack names
voices in voices.json, so the stored value happens to be a name already.
An engine now answers twice. GetVoiceDisplayName is for the picker and carries the
accent somebody is choosing between; GetVoiceSpokenName is for the one place that
says a name aloud and carries neither the identifier's leading letters nor the locale
tag. Kokoro parses its ids into name and locale once, behind both, so the two cannot
drift apart, and anything not shaped like one of them - a voice kept from another
engine, an embedding named by hand - comes back untouched either way, since inventing
a name for something unrecognised is as wrong in a preview as in a label. What is
stored, matched against a config and spoken on air does not change.
(ffa4d94) ~John Kauffman
- Hand piper the slot it loaded, not the name of the voice
Changing to a voice nothing was speaking yet previewed as silence, while that same
voice a moment later - on a volume change, or picked again - spoke fine. A debug
log says it plainly: "Piper previews 'Cori' through the Default player" is the one
that played, and the attempt that failed logged "produced no speech for voice
'Cori'" - which is SynthesizeNative's id argument. The preview asked piper to speak
through 'Cori', a voice name, when the model it had just loaded lives under the
testSpeaker slot. An unknown id answers with -1, no audio and no exception, which
from the outside is indistinguishable from a dead sound card.
It is a regression from commit # [2b678ce](https://github.com/kauffman12/EQLogParser/commit/2b678cea): that commit added _preparedVoice so a voice it
had just built was not built again, and in the same line started handing the
recorded name back as the speaker id. Before it the synthesis went to AdHocVoiceId
like every other call. What is loaded stays recorded the same way; only the answer
changes, and both routes through this function now log which one spoke.
Reading that -1 as commit # [4294967](https://github.com/kauffman12/EQLogParser/commit/4294967295) was the other half of it. synthesize is declared to
return long while piperApi returns int, so a failure arrived as a positive number
and only the null buffer kept this from allocating eight gigabytes for audio. The
signature says int now, which makes the size <= 0 guard mean what it says.
(e14d4d0) ~John Kauffman
- Make a silent piper preview say where it died
Changing a Piper voice can still come back with no sound, so the failure is not
the native table race that was fixed earlier. It cannot be narrowed further from
here because every step between "voice selected" and "nothing heard" fails
quietly, and does so at Debug - which settings.txt switches off by default, so a
user's log says nothing about a voice that produced nothing.
Three anomalies now log on their own: piper's loadVoice answering -1 (it offers no
reason and throws no exception), synthesize answering with zero samples, and the
preview paths in AudioManager playing nothing because synthesis returned nothing,
naming the engine and voice that came back empty. Which slot spoke a preview -
the prepared one or a borrowed trigger player - stays at Debug, since that is
normal traffic rather than a fault.
(0b9dfd6) ~John Kauffman
- Give piper a dozen voices to pick from
The pack carried six, and two of those were dataset labels rather than names. It
now carries twelve English models named in voices.json by hand - Alan, Alba, Amy,
Bryce, Cori, Joe, Kristin, Lessac, Northern English and Ryan beside the two HFC
voices - each with the locale that gets printed beside it, so Piper's list reads
the way Kokoro's does now: "Ryan (US)", "Alba (GB)".
Rebuilt the way kokoro-1.0 was: unpack the published asset, replace only voices/,
regenerate manifest.json, rezip without laying a hand on a binary. All 361 files
that came back hashed identical to what went in, and every entry the new
voices.json names resolves and agrees with its model's sample rate, so nothing
carrying an Authenticode signature was rewritten.
Six voices to twelve is a 348 MB download become a 682 MB one, which is the price
of one archive holding every voice; the pinned digest, the size the button quotes,
the table in the docs and the free space checks all move with it. Hand written
names also mean -GenerateVoicesJson has to stay out of a piper pack from here on:
it takes names from each model's _meta.name, these models carry none, and it would
quietly trade "Alan" for "alan".
(859c692) ~John Kauffman
- Give Kokoro the British voices too
KokoroSharp has always shipped them; KokoroVoicePrefixes simply said af;am, which
left bf_* and bm_* out of the build output and therefore - through -Sync - out of
the published pack as well. Eight embeddings are 4 MB on a 228 MB download and
MisakiSharp's grapheme-to-phoneme data covers both accents, so nothing else had
to change for them to speak.
The pack changes with them but no binary inside it does. A signature covers the
bytes of one PE file rather than the archive holding it, so unpacking the
published zip, adding the voices, regenerating manifest.json and rezipping leaves
MisakiSharp, NumSharp and onnxruntime exactly as they were signed - verified by
hashing every file that came out against the file that went in. What does move is
the digest this table pins for the archive, along with its size. Swapping the
asset under its own tag is open only because nothing shipped pins the old value:
master carries no pack code at all, so no installed app is left comparing a
download against bytes that are gone. The new zip and its sidecar have to go up
with this commit, not before it.
(21b3a56) ~John Kauffman
- Name the voices the way people do
The voice pickers list what the engines call their voices, which for Kokoro is a
filing convention: af_nicole, bf_emma, am_fenrir. Choosing between them meant
reading a two letter grammar of accent and gender that nobody learns. They read
by name now, with the accent beside it - "Nicole (US)", "Emma (GB)" - while the
item behind the label stays the engine's id, so what gets saved to a character,
matched after an engine switch and handed to SetVoice is byte for byte what it
always was.
Each engine answers for its own names because each knows something different:
Kokoro's locale is in the voice id, Piper reads it from the model's metadata -
and voices.json may now declare it outright - and Windows already hands out
"Microsoft David Desktop", where even the "(Legacy) " marker is worth keeping in
sight. Anything an engine does not recognize comes back untouched rather than
dressed up: a picker row that stops matching its config is worse than a plain one.
(b0cc5dc) ~John Kauffman
- Stop a piper voice swap from eating the preview
Choosing a voice does two things: it speaks a preview straight away, and it
schedules the config reload that rebinds the character to that voice 750ms
later. That rebind removes the player's native voice before loading the new
one, a few hundred milliseconds inside piperApi.dll, and the preview resolves
and synthesizes through the very same table while it is being rewired. Piper
answers a synthesis against a voice that has been removed and not yet rebuilt
with no samples and no exception, so nothing played. Changing the rate worked
because by then the swap had finished; Kokoro and Windows only note a name when
bound, so they have nothing to pull out from under a call.
A lock inside the engine now covers every change to the native voice table and
every synthesis that reads it. It sits innermost on purpose - binding takes the
engine lock and speaking takes the synthesis gate, which is exactly why they
could overlap - and nothing under it reaches for either. A voice that answers
with no audio also gets logged now, since from outside that looks like a dead
output device.
(83f8f89) ~John Kauffman
- Give the Windows engine its Use button back
The single action button appeared only when an engine had a runtime pack to
download, which is the opposite of what the Windows voices look like: nothing
to fetch, ready to speak with. Once another engine was active, selecting
Windows left the dialog with Close as the only control and no way back to the
built-in voices short of editing the config by hand.
Visibility follows "there is something to do here" now rather than "there is a
pack", and the three answers - whether to show it, what it says, whether it
works - are settled together in one method kept away from the controls so they
cannot drift apart again. TtsEngineActionButtonTest covers the switch case,
the Wine case where no button is the right answer, and the busy case where the
button stays put and refuses presses.
(291545a) ~John Kauffman
- Keep the remove files prompt to a single question
The confirmation stacked the byte count and a promise that the engine can be
downloaded again onto the question. MessageWindow grows to fit its text rather
than wrapping it, so three sentences turned a short question into a banner
across the dialog. Everything else in this app that destroys something asks in
one line, so this does too now.
(cd26642) ~John Kauffman
- Keep timer names still when the variables move
Trigger variables are re-resolved on every full overlay render, so a timer
named after one follows whatever another trigger writes into it for the rest
of the run. That is usually what you want but not when the variable tracks
who last touched something: a buff bar naming its caster should stay with the
caster who applied that instance.
Adds **Static Timer Name** to Basic Timer Options, off by default so existing
configs keep the live behaviour. The frozen name was already there, the value
the processor resolved when it started the timer, so the overlay only had to
stop preferring the template for these timers. Static covers a single run: a
restart or the next loop of a Looping timer starts a new TimerData and picks
up the current value again. Built-in codes are unaffected because they come
from fields captured at that same moment. As a side effect a pinned name can
no longer strand a raw {variable} on the bar when the variable is cleared or
its time to live runs out.
(4c1ed8e) ~John Kauffman
- Fix engine switching, warm-up and pack install defects
An audit of the ITtsEngine refactor turned up races around the engine swap, a
warm-up that almost never ran, and download paths that could be neither stopped
nor rolled back. All of it is fixed here; nothing about the design changed.
AudioManager grew _engineLock, taken by every quick call into the engine (bind,
voice lookup, voice list, removal) as well as by a switch. Before that a callout
could be reading an engine while SwitchEngineAsync disposed it, which for Piper
means touching a process-wide native table that has been released - an access
violation rather than a caught exception. Warm-up was rebuilt as one bounded
worker queue that marks a voice prepared only on success and runs outside the
synthesis gate: called from inside the switch, it could not take the gate the
switch itself was holding, so every post-switch warm-up gave up and the first
callout paid for everything anyway. Cache hits now answer without the gate at
all, which is what the field comment always claimed, a zero sample rate is
rejected where the WaveFormat would have been built from it, SpeakFileAsync is
no longer async void, and engine names arriving from settings are normalized at
the boundary so a hand-edited "piper" means Piper everywhere downstream.
The engines each had their own hole. Piper kept the old voice bound to a player
when the native load failed after the previous one was removed, leaving a player
that reported a voice it could not speak: silent callouts, and a name the preview
path would happily borrow. Kokoro loaded its process-wide embeddings unguarded
and could be constructed with no voices at all, so it now guards and caches that
load and refuses to be created rather than fall through to another engine that
can talk. Windows re-proved every voice and re-enumerated SAPI on each instance;
both are cached across instances now.
Installing a pack can be cancelled, and can fail safely. The token reaches the
transfer, the archive digest, each extracted entry and each verified file -
hashing is chunked instead of one ComputeHash call, and entries are copied by
hand because ZipArchiveEntry.ExtractToFile neither reports bytes nor aborts
before an entry ends, and one entry is the 156MB model. A failed promotion puts
the retired copy back, so a pack that spoke this morning still starts; free space
is checked for the archive before the download and for the extracted tree, read
from the zip's central directory, before anything lands; the progress bar is
split across digest, unpack and verify instead of sitting at 90% for a minute.
The TTS dialog gained a Cancel button, resets its state in a finally so no
failure can leave it wedged, asks before deleting a couple hundred megabytes, and
tells "engine in use" apart from "native libraries still mapped, restart".
Also in here: the Kokoro voice folder is staged only in the app's own output
instead of every project that consumes KokoroSharp transitively; duplicated
helpers (IsInstalled, ReplaceDirectory, ComputeSha256, the dispose pair) are
deduplicated and dead surface removed; docs describe what the code now does; and
EQLogParser.Wpf.Test gains offline coverage for name normalization, the digest
helper and the pinned pack digests against docs/TtsPacks.md.
Built clean (0 warnings) and 849 unit tests pass. The new audio tests compile but
need Windows to run, as does the manual pass over download, cancel, rollback and
engine switching that this still deserves.
(a3771d0) ~John Kauffman
- Warm the voice before anything has to wait for it
Speaking with a voice costs time in places that have nothing to do with the
text: a Piper voice is an ONNX session over the model plus espeak-ng data, and
any engine's first synthesis warms the runtime behind it. That bill landed on
whichever trigger fired first after a change.
AudioManager.WarmUpVoice now pays it in the background when a player is
registered, when its voice changes - which is what the dropdown does, both for
a pick and for the default an engine switch selects - and after a switch, once
per distinct bound voice. Engines do what warming means for them: Piper builds
the native voice, Kokoro and Windows speak one short word into memory and drop
it, since their voices are already in the session made at creation.
Warm-up enters the synthesis gate only when nothing is holding it, retries
briefly and gives up rather than standing in line ahead of real speech:
choosing a voice also speaks an audible preview and that must not queue behind
a warm-up.
Piper also stops rebuilding and destroying a voice for every preview, which
meant paying for the model again on each different text. One prepared voice is
kept under the reserved id and replaced when the selection moves, a preview of
a voice some player already speaks uses that player's session instead of a
second copy, and rebinding a player removes its old native voice first because
replacing a table entry is not documented as releasing it.
(2b678ce) ~John Kauffman
- Let the picker be browsed, make Use apply
Selecting an engine applied it on the spot, and Remove Files refuses whichever
engine is speaking because its native libraries stay mapped until the app
closes. Every row on screen was therefore the active one, so the button could
never become available for anything you had just downloaded: no sequence of
clicks reached it.
Selection now only shows, and a single action button says what pressing it
would do - Download for a pack with nothing on disk, Use to start an installed
engine, In use when it already speaks. Downloads still apply themselves, since
that is why they were fetched, and the saved setting is written where using an
engine was asked for rather than merely highlighted, still unwound when a
switch fails. Removing a pack now also drops the saved setting off that engine
so the next start does not name a directory that is gone.
(1cc9068) ~John Kauffman
(e6cb8a6) ~John Kauffman
(18cce09) ~John Kauffman
- Grey out unusable engines without a container style
(eae4f60) ~John Kauffman
- Put the vendored ONNX runtime in step with the published pack
Piper-1.0 was republished with onnxruntime 1.22 standing in for the 1.14 Piper shipped, but the two binaries here never moved, so a pack built from this clone would put the old pair back into circulation. Both now hash exactly to what GitHub serves: commit # [579b636](https://github.com/kauffman12/EQLogParser/commit/579b6364)... and commit # [ba00ea1](https://github.com/kauffman12/EQLogParser/commit/ba00ea1e)..., which is also what the NuGet package for the managed wrapper drops into the build output.
This is agreement rather than an upgrade: whenever Kokoro is present Piper already runs on 1.22, because Windows keeps one onnxruntime.dll per process and the pack manager hands that name to the build matching the managed wrapper.
(292cb5d) ~John Kauffman
- Delete what installs before packs left behind
A bundled piper-tts, the Kokoro voices, and the support assemblies KokoroSharp pulled in come to about 150MB under the program folder of older installs. Nothing reads those paths any more, so this is reclamation rather than a behaviour change.
The one thing left alone is {app}\runtimes\win-x64: it also holds natives other packages need, this installer does not create the folder, and from here there is no way to say what an older install left inside it.
(ea7a46c) ~John Kauffman
- Read speech engines from their pack only
ResolveRoot had a second answer for Piper: a complete copy beside the executable, so installs predating the packs kept speaking without a re-download. A directory the app silently adopts cannot be updated, cannot be removed from the dialog, and cannot be matched against a pinned digest, and it made installed-ness depend on debris: espeak-ng data and voice models still sit in old build outputs long after leaving the repository, so a development run reported a working Piper nobody had downloaded.
One location per engine now, %LOCALAPPDATA%\EQLogParser\<engine>, owned end to end by the pack manager. What is left beside the executable is inert, and the installer deletes it.
(48a2623) ~John Kauffman
- Keep the engine dialog inside its theme
Text here inherited its colour from the window instead of asking for ContentForeground like every other window does, and no status line distinguished success from failure. Everything resolves through the theme dictionaries now: body text as the skin defines it, EQGoodForegroundBrush when something worked, EQStopForegroundBrush when it did not, EQWarnForegroundBrush when an engine failed to come up or has no voices to give. Switching to MaterialLight repaints instead of leaving colours chosen for a dark skin, and the ComboBox row style resolves its base through DynamicResource so it follows a theme change too.
The lines got shorter in the process, since the dialog is 420 wide and SizeToContent grows with every wrapped row. The status line is short and holds numbers: "128 MB of 224 MB", then "validating files...". It does not name the engine, because whoever pressed Download knows what they pressed.
(bb90543) ~John Kauffman
- Make the engine dialog show what is really happening
Two things were wrong when it opens. The picker rested on the saved setting instead of the engine actually speaking, so
any fallback -- pack missing, model refused, Wine with no voices -- left it naming something EQLogParser was not using,
and the line beneath it cheerfully promised that choice would take effect on the next callout. It opens on the live
engine now, and where setting and reality disagree it says so: "Kokoro is what EQLogParser speaks with. The saved
choice, Piper, would not start here." A switch that fails unwinds the setting as well, rather than leaving a preference
for an engine that just failed to initialize.
The status text shared a grid row with the progress bar, so white letters went unread under a fill growing through them.
The bar keeps its own row and the words go below it, with the byte count they were missing: "Kokoro: about 128 MB of
224 MB downloaded", turning into "checking the files and installing them..." for the last tenth of the run, which is
hashing and unpacking rather than the network. Selecting a row after a download now takes the object from the list
instead of building an equal one, so the drop down highlights it rather than holding a value its items do not contain.
(9613da4) ~John Kauffman
- Point the piper pin at the pack on GitHub
Piper-1.0 was rebuilt with onnxruntime 1.22 standing in for Piper's vendored 1.14, which moved the asset digest to
commit # [dc24d7f](https://github.com/kauffman12/EQLogParser/commit/dc24d7f9)... while TtsPackManager went on asking for commit # [059241c](https://github.com/kauffman12/EQLogParser/commit/059241c0).... Every download was then refused -- correctly, because a
pin that no longer matches really does mean "not the bytes we vouched for" -- but unhelpfully: one line, no numbers, and
nothing saying the pin was the thing to look at.
So quote expected and got, and put in TtsPacks.md where a pin comes from (GitHub's own asset digest), that the sidecar
next to an asset is for humans and -Verify rather than for the app, and that replacing an asset under its own tag is
only legitimate before some build pins it. Checked the published pack by range-reading it rather than pulling 348MB:
onnxruntime.dll at 12,418,080 bytes stamping 1.22.0, provider stub alongside, both matching manifest.json, six voices.
(94a3a42) ~John Kauffman
- Settle which onnxruntime the process gets
Piper vendors onnxruntime 1.14 and Kokoro's wrapper is 1.22, and Windows keys modules by base name: whoever loads
first serves both for the life of the process. Speak from Piper, then enable Kokoro, and Kokoro's graphs are refused
with "Unsupported model IR version: 9" over a download that is fine -- the 1.22 wrapper was talking to the 1.14
runtime. Restart with Kokoro first and everything works, because the newer runtime serves Piper's older native code
without complaint. That made which engine you used first decide whether the other one loaded.
Piper now hands the name to the build published with our managed wrapper before piperApi.dll can claim its neighbour,
so the pairing is chosen rather than raced. Both engines name the module that answered in their init failures: a model
refused for its IR version reads as "the download must be corrupt" otherwise, which is a long way from the truth.
(18dd65b) ~John Kauffman
- Say Wine out loud in the engine warning
"Not usable, add Windows voice packs" was bad advice for the case we can
now name. The line under the picker says the voices come from Windows
itself and that neither Wine nor a Windows image without the speech
runtime has any, which leaves "enable Piper or Kokoro" as the way out
instead of an errand that cannot succeed.
(59847b4) ~John Kauffman
- Recognise Wine before the voices fail
Ask ntdll for wine_get_version. Wine has exported it for over a decade,
real Windows never had it, and unlike build-number or registry heuristics
there is no service pack that can move it. Result cached; the load is
pinned to System32 so a planted ntdll.dll cannot decide the answer.
The reason to trust this one rather than only the runtime probe is that
the errors are not symmetric. A false "this is Wine" turns off the only
engine a machine has, and getting there needs Windows to grow an export
it does not have. A false "not Wine" costs what it costs today: the probe
finds nothing usable and says so. Whisky, Bottles and CrossOver are Wine
underneath; a real Windows VM on Linux keeps its voices, correctly.
(af631ba) ~John Kauffman
- Grey out an engine this machine cannot use
The picker's rows now carry whether they can be picked. An engine that is
neither usable nor downloadable goes grey where the choice is made,
rather than being selectable and then quietly refusing; in practice that
is the Windows voices on Wine, where there is nothing installed and no
download to offer either. Piper and Kokoro stay clickable while not
installed, because clicking them is how they get their runtime.
The unavailable line under the picker distinguishes those two cases --
"not installed, about 224 MB" versus "not usable on this machine, add
Windows voice packs" -- and the Windows description now says its voices
come from the operating system, so a Linux or Wine session usually has
none.
The ItemContainerStyle is BasedOn the themed style: a bare Style wins
over Syncfusion's and hands back a WPF default item, which disappears
into the dark theme.
(62d21df) ~John Kauffman
- Make the Windows voices earn being available
Windows is the one engine with nothing on disk to check, so the code
answered "available" without asking and swallowed the exception. On a
machine where the voices are not there -- Wine, a Linux emulator, a
stripped Windows image -- that leaves the app silent with no log line at
all, which is exactly what made the recent Linux experiment hard to read.
LoadVoicesAsync now records whether it could make anything speak, and
engine choice, the picker and switching all read that verdict. Unknown
still counts as available: only the engine that starts gets probed, so
hiding an unprobed one would silence people who are fine. Switching to
an engine that comes back with no voices is refused outright, which turns
"a successful switch into silence" into "Piper keeps speaking".
(180e0fa) ~John Kauffman
- Say what each speech engine costs
Three names in a dropdown was the whole decision, and it is a decision
about voice quality and about whether a ten year old machine keeps up
with callouts mid-fight. Now the line under the picker says which engine
is the fast one, which is the good sounding one, and what picking Kokoro
asks of the machine -- before the 224MB download, not after it turns out
the fight is being narrated half a second late.
It follows the selection rather than the applied engine, so arrowing
through the list reads like a menu. The state line above it stays about
availability (in use, takes effect now, or how much a download costs),
which is a separate question from what an engine sounds like.
(9b5fa53) ~John Kauffman
- Retire the bundled Piper escape hatch
IncludePiperTTS existed to keep speech working in a release cut before
the downloader did, and it produced a second installer name to keep
track of. Both are gone: the app can now only be packaged one way, and
the define would build a broken Piper anyway because the voice models
and espeak-ng data are no longer in the build output.
The installer keeps exactly the two assemblies EQLogParser.Audio.dll is
compiled against, KokoroSharp and Microsoft.ML.OnnxRuntime, so the seam
types resolve with no pack present and an engine reports itself
unavailable instead of failing at a type load.
(980f6e5) ~John Kauffman
- Drop the Piper data now that it ships as a pack
80MB of espeak-ng tables and voice models, deleted. They are data, not
build inputs: nothing compiles against them, and keeping them in git
meant every clone carried them whether or not anyone would ever speak
Piper. The EQLogParser-TTS data repo holds them now and users get them
through the runtime pack.
Five native SDK binaries stay (10MB), because two things need them at
build time and neither is the installer: sign.cmd signs
piper-tts\*.dll before a pack is zipped, and Build-TtsPack.ps1 -Sync
reads them out of the build output. piper-tts\README.md says exactly
that, so the next person does not re-add the voices out of caution.
Installs made by the old pipertts installer keep speaking: their copy
under {app}\piper-tts is complete and still honored as a fallback.
(08d0fa4) ~John Kauffman
- Install the speech engines from downloadable packs
Both optional engines now fetch their runtime on demand instead of
carrying it in the installer, which is what holds the package near 19MB
rather than 45MB. TtsPackManager pins one GitHub release asset per
engine, verifies the archive against its pinned digest, then checks
every file against the pack's own manifest before promoting the
directory into %LOCALAPPDATA%\EQLogParser\<engine>.
Two resolver hooks make a pack loadable: the default assembly load
context answers KokoroSharp's support assemblies from kokoro\bin, and
unmanaged imports answer onnxruntime from kokoro\native. Both decline
everything they do not have, so unrelated loads are untouched, and both
use the default context rather than a private one so types stay
identical to the assemblies that install beside the executable.
Kokoro reads its voice embeddings from the pack instead of next to the
executable, so KokoroVoiceManager.LoadVoicesFromPath gets a path; Piper
resolves its root per instance and re-runs initialize() when a different
pack directory comes along. The engine-specific Kokoro model downloader
is gone: the pack carries the graph, still verified against its own pin.
The TTS Engine dialog now lists all three engines and puts the download
where the choice is, including removing an engine's files again, because
224MB deserves a way back.
(c60a28b) ~John Kauffman
- Fix the voices.json path check and report inputs up front
Voices.json paths are relative to the voices directory -- "hfc_male/en_US-hfc_male-
medium.onnx" -- which is how PiperTtsEngine resolves them, but the packer validated
them against the pack root. Nothing could ever match, so a correct data dir failed
with "amy/en_US-amy-medium.onnx is not in the pack". Fixed, and the failure now
lists what that voice folder actually contains plus the closest name, because the
same message has to be useful when the data really is wrong.
Check every input before staging anything: a half-populated data dir used to blow up
from three functions deep, after the other engine's pack had already been written.
Inventory and the packing path now share one list of requirements per engine.
-Sync covers what first-run actually needs (both engines' binaries, espeak-ng-data,
Piper voices, Kokoro embeddings) and pulls kokoro-fp16.onnx from local app data or
-ModelSource, since that file is not in the build output. -GenerateVoicesJson writes
its result back into piper-tts\voices so the mapping that ships is the one in git.
(34f93c8) ~John Kauffman
- Drive the pack build from data dirs, not a build output
The packer moves into EQLogParser-TTS and reads its inputs as data two directories
over -- piper-tts/ with voices and espeak-ng-data, kokoro/ with bin, native, voices
and an optional model -- because that data outlives any particular app build and is
what actually needs a home. Only the runtime binaries come from a build: -Sync copies
them in, hash-comparing first so unchanged files are left alone. -Inventory reports
what is present and what is missing without packing anything, which is the check worth
running before publishing.
Single pack per engine, as decided: one download per engine keeps the loader simple,
at the cost of every Piper user fetching all voices. Drops the per-voice split mode
and its extra manifest plumbing.
Adds scripts/tts-repo-template/ with a README and .gitignore for the new repo, and
spells out what git can hold: GitHub refuses pushed files over 100 MB, so the 156 MB
Kokoro model is release-asset only, and voice models below that still do not belong
in history that every clone carries forever.
(89226b7) ~John Kauffman
- Build the downloadable speech runtime packs
Piper and Kokoro now live on GitHub instead of in the installer, so something has
to assemble them reproducibly and refuse to publish a pack that is missing a file.
Build-TtsPack.ps1 stages the two layouts (kokoro: bin/native/voices[/model], piper:
runtime dlls/espeak-ng-data/voices), writes a manifest.json holding every file's
size and SHA-256, zips it, emits a .sha256 sidecar and prints the tag and pin URL.
It checks three things the failure modes actually punish: files we are expected to
have signed that are still unsigned (a publisher-less DLL is what AV punishes, and
Microsoft's own files must keep their own signature), voices.json entries whose
model or config file is not in the pack (a typo there is silence on a user's
machine), and -Verify to re-check an asset after download.
-GenerateVoicesJson builds voices.json from the voice folders, reading each model's
sample rate and name, which matters now that Piper is growing past one voice;
-SplitVoices emits per-voice zips on top of a runtime pack so enabling speech does
not mean downloading every voice.
(673f2f3) ~John Kauffman
- Keep the speech runtimes out of the package
Piper was either 65MB compressed in the installer or absent, and Kokoro added
93MB more. Both engines are optional at runtime already, so only the two small
assemblies EQLogParser.Audio.dll is compiled against stay in {app}; the heavy
set moves to a per-user pack under %LOCALAPPDATA% that a user downloads when
they enable an engine.
sign.cmd gains its own pack section: the files are signed even though they no
longer install, because an unsigned downloaded DLL is what antivirus heuristics
punish, and vendor-signed files (Microsoft's onnxruntime) are left alone rather
than re-signed over. Manifests have to be generated after signing, which the
release checklist now says out loud.
Fresh installs get no Piper or Kokoro until the pack loader lands; existing
installs keep reading their {app} copies.
(a2c10c7) ~John Kauffman
- Switch the TTS engine without restarting
Picking another engine only took effect on the next start, which made trying
Kokoro a two minute affair of download, close, reopen, hope. Engines are
objects now, so SwitchEngineAsync builds the requested one, lets it find its
voices, re-binds every player and retires the old one under the synthesis gate.
A voice name from one engine means nothing to another, so the manager keeps
what each player was asked to speak with and replays it; an engine binds the
names it has and drops the rest, which sends that player to its default.
Kokoro now refuses to remember a name it does not have rather than quietly
speaking it as something else.
(b25c9fc) ~John Kauffman
- Synthesize off the caller's thread and cache the PCM
(c7410b3) ~John Kauffman
- Put each speech engine behind an ITtsEngine seam
AudioManager carried the engine choice as two booleans consulted at a dozen
sites, which meant every engine touched voice listing, defaults, per player
voice binding, synthesis and shutdown, and none of that was testable because
it all ran through statics into native code.
Windows, Piper and Kokoro now implement ITtsEngine and own their own per
player state; the manager holds one engine resolved by a factory that walks
the preference order. Same engines, same fallback order, same voices.
(ea299ae) ~John Kauffman
- Resolve piperApi.dll through an import resolver
(d6a7672) ~John Kauffman
- Verify the kokoro model against a pinned sha256
(6af3ec0) ~John Kauffman
- Keep Mandarin voices out of the package
Belt and braces next to KokoroVoicePrefixes: if a build ever produces the full
voice set again, voices-zh (51MB) still must not ride along.
(7c0ab55) ~John Kauffman
- Download the fp16 Kokoro model instead of fp32
156MB vs 310MB per user for a difference that is hard to hear on trigger
callouts; both graphs come from the same KokoroSharpBinaries release. The local
file is named after the graph so the two can never be mixed up (an existing
fp32 kokoro.onnx in local app data is simply ignored).
(a0b8e5f) ~John Kauffman
- Ship only the American English Kokoro voices
KokoroSharp's buildTransitive CopyContent target copies all 157 bundled voice
embeddings (79MB) into every project that consumes the package transitively,
which put 4x79MB into the build tree and 79MB into the installer. Redefining
the target in Directory.Build.targets replaces it (NuGet's g.targets is
imported before Directory.Build.targets, last definition wins) with a copy of
the prefixes listed in KokoroVoicePrefixes, plus the Apache-2.0 LICENSE.
KokoroVoiceManager lists whatever .npy files exist under <app>\voices, so an
unshipped voice is simply not offered and no app code changes. The target also
prunes anything outside the configured set so a changed mask heals old outputs;
it runs only where KokoroSharp.dll was actually copied locally.
(169c6f2) ~John Kauffman
- Drop the unused clr-namespace from TtsEngineWindow
(0a68e0e) ~John Kauffman
- Ship and sign the Kokoro TTS runtime payload
KokoroSharp.CPU adds managed assemblies (KokoroSharp, MisakiSharp,
NumSharp, Microsoft.ML.OnnxRuntime, System.Numerics.Tensors, the OpenTK
bindings it references) plus a native onnxruntime under
runtimes\win-x64\native that the host resolves from deps.json, and drops
the ~79MB voice pack in <app>\voices where KokoroVoiceManager looks for it.
The .iss and sign.cmd lists are curated minimum sets (docs/ReleaseChecklist.md),
so every one of those has to be listed explicitly.
Still to do on Windows: run scripts/MeasureLoadedAssemblies.ps1 with Kokoro
selected and reconcile the report against both lists.
(27213ac) ~John Kauffman
- Keep Windows voices listed when an engine is preferred
LoadValidVoicesAsync() short-circuited on PiperTts.Initialize(), so with
TtsEngine=Windows and a piper voice pack installed the Windows voice list
came back empty even though the Windows engine was running. Use the flags
resolved in the constructor instead; that also avoids Piper's Initialize()
side effects (SetDllDirectory + espeak init) on non-piper sessions.
(bc84311) ~John Kauffman
Merge PR #406: selectable TTS engine (Windows/Piper/Kokoro)
Pulls in willk's KokoroSharp-based Kokoro engine and the TTS Engine picker.
EQLogParser.Audio.csproj conflicted with develop's package bumps; kept the
newer log4net/Caching.Memory versions and added KokoroSharp.CPU.
(20bacf1) ~John Kauffman
- Pin the Contents heading and scroll only the link list
.toc carried overflow-y: auto, but the <h1>Contents</h1> lives inside .toc, so scrolling
the long two-level list scrolled the heading out of the sidebar as well. Make .toc a flex
column at desktop widths with the overflow on the inner <ul>: the heading stays put, the
list scrolls under it, and min-height: 0 lets the flex child shrink instead of growing the
sidebar past the viewport. scrollbar-width: thin plus a little padding keeps the scrollbar
off the longest link. CSS cache version -> 17.
(58f387a) ~John Kauffman
- Keep FAQ questions out of the TOC, scoped to that page
The FAQ questions are full sentences up to 100 characters, which wrapped to three or five
lines each in the 200px sidebar: the contents list measured 1322px tall against a 724px
viewport, so Feedback and everything below 'right-click Copy' was unreachable without
scrolling the sidebar twice. TOC_HIDE_CHILDREN drops the nested links under the F.A.Q
heading while leaving the heading itself listed; Linux Support and Feedback keep their
shorter sub-entries, and getting-started/documentation/policy are untouched.
(a98c21b) ~John Kauffman
- Real 404 page for missing URLs
Missing URLs currently return Amazon's raw NoSuchKey XML body with
Content-Type: application/xml, which is useless to a visitor and wastes the one chance
to redirect someone who followed an out-of-date link. Adds dist/404.html built from the
same head/nav/theme pipeline as every other page: download button plus links into the
docs and the release notes feed.
No AdSense unit on this page, because Google does not allow ads on error pages and a 404
that renders the ad rail is an invalid-traffic risk. Analytics stays, with a
file_not_found event carrying the path and referring URL, so rotted links become visible
in reports instead of silently disappearing. Marked noindex with no canonical, kept out of
the sitemap, and added to the upload script. build_head gained an indexable flag for this.
(74bd5e1) ~John Kauffman
- Site checks that fail the build on broken or shift-prone output
Adds website/sitecheck.py, run at the end of every build (also standalone:
"python sitecheck.py") and exit-non-zero worthy. It verifies each page has a nav bar,
a title and a description, that every local href/src resolves inside dist/ and every
in-page anchor exists, that images all carry width+height so nothing shifts while they
load, that ad slots stay fixed size with full-width resizing off, that the sitemap parses
and covers every indexable page, that feed.xml entries point at real anchors, and that
ads.txt still names the publisher used by the page slots.
Proven non-vacuous by deleting things from the built output: it reports missing nav,
dead anchors, dead page links and unreserved image boxes (exit 1). Writing it caught a
real mistake of mine in the process: ads.txt correctly lists "pub-…" rather than the
"ca-pub-…" the snippets use, so the publisher id is normalised before comparing.
(616a732) ~John Kauffman
- Atom feed for release notes (dist/feed.xml)
A desktop tool lives or dies on people hearing about new versions, and right now the
only way is watching the GitHub repo. Generates the latest 20 releases as an Atom feed,
advertised by rel="alternate" in every page head plus a visible "Subscribe (Atom / RSS)"
entry in the release notes TOC.
The feed is built from dist/releasenotes.html rather than the markdown so entry permalinks
reuse the anchors the page actually has (#2-3-61-08-31-26) instead of re-deriving slugs a
second time and drifting. Verified: 20 entries, XML parses, every entry anchor exists in
the built page, and the year-marker spans inserted for the by-year nav stay out of bodies.
Also regenerable on its own with "python build.py feed". status.html is the one page that
does not advertise the feed, which is fine since robots.txt disallows it anyway.
(2b5c990) ~John Kauffman
- Pin the AdSense unit to its declared size
Data-full-width-responsive="false" stops AdSense replacing the 160x600 skyscraper with a
wider unit that no longer fits the column it was placed in, which is how ad code normally
buys layout shift. The attribute is set explicitly rather than relying on the inline width
so a later edit cannot reintroduce it; index/status keep their own copy of the snippet in
their templates and were updated to match.
(2586259) ~John Kauffman
- Upload images, vendored scripts and favicon with real cache lifetimes
Nothing under img/ or assets/ carried a Cache-Control header at all (CloudFront fell back
to its 24h default) and the stylesheet was sent as no-store, so every page view refetched
the 15 KB CSS. Assets now get 30 days; the stylesheet gets a year immutable because it is
addressed as style.css?v=<CSS_VERSION> and the distribution's CacheWithVQuery policy keeps
'v' in the cache key, so bumping CSS_VERSION is a complete invalidation. Images are not
content-hashed, hence 30 days rather than immutable, with the invalidation command noted
in the script for the day a screenshot is swapped in place.
(02ff169) ~John Kauffman
- Complete the font stacks so macOS/Linux stop falling back to a generic face
The sheet asked for 'Inter', which nothing ever loads, so on anything without a local
Inter the only match was the generic sans-serif. Windows is unaffected (Segoe UI was
already the effective choice); this makes the other platforms use their UI font and
gives monospace a real fallback chain instead of Consolas/Monaco, neither of which
exist on Linux. CSS cache version 16.
(6e11fc3) ~John Kauffman
- Nest H2 sections under their H1 in the docs TOC
The TOC listed only H1s, so getting-started.html offered a single link for ten
sections and documentation.html two for twenty-three. Sub-entries now use the
.toc a.sub styling that already existed in style.css with no markup using it, so
this is presentation-only. Verified: 11/25/21 links generated and every anchor
resolves, layout columns unchanged at 1366px (200px 914px 160px), TOC still hidden
by default and click-to-open at <=900px.
Heading ids are now deduplicated (a numeric suffix on repeats) because two headings
with the same text produced identical ids and every link jumped to the first one.
At >=901px the TOC scrolls inside its own column so a long list cannot hide its
tail; narrow screens keep page-level scrolling. CSS cache version 15.
(6522454) ~John Kauffman
- SoftwareApplication JSON-LD on the home and download pages
Gives Google the structured facts it can show as a rich result: name, version,
operating system, free offer and the installer URL. Both are taken from the values
the build already derives (Inno Setup version, release asset URL), so the markup
stays correct on every release without anyone remembering to edit it. No aggregateRating,
since there are no ratings to describe and fabricated review markup is a penalty.
(64eff4b) ~John Kauffman
- Per-page <title> and meta description from one PAGE_META table
Every docs page shared one generic 240-character description and the home page
<title> was just "EQLogParser", so search results had nothing to match queries
against. Copy now lives in PAGE_META keyed by output file, is formatted with the
current version where relevant, and og:title reuses it (previously it prefixed the
brand a second time). index.html's title/description are applied at build time so
the template cannot drift from the table.
(2c882c2) ~John Kauffman
- Fix desktop CLS and narrow-screen TOC, add sitemap + SEO meta
Grouped because these all touch build.py's shared head/template pipeline.
- CLS: stamp real width/height on every built <img> so browsers reserve the box
before images load (the missing dimensions were the desktop CLS > 0.1 cause);
img { max-width: 100%; height: auto } keeps aspect ratio; hero logo is now
eager + fetchpriority=high because it is the LCP element.
- TOC: CSS owns the collapsed default (html:not(.toc-open)) at <=900px, so the
first paint never shifts; the toggle flips toc-open on <html>, persists it and
is restored pre-paint by the head script. Removes the dead .layout.toc-collapsed
rules that put <main> into an implicit 200px grid row at narrow widths.
- sitemap.xml generation (7 URLs, git-based <lastmod>) + Sitemap: in robots.txt,
XML/txt upload labels in update-aws.cmd, status.html excluded (robots disallows).
- SEO/social meta: canonical per page, theme-color light+dark, og:url/site_name/
image size, twitter:image, sitemap link; descriptive alt text and a build warning
for any <img> without alt.
- GA4: wire the real measurement id (was the G-XXXXXXXXXX placeholder).
(546d141) ~John Kauffman
Add selectable TTS engine (Windows/Piper/Kokoro) with Kokoro neural voices
(4f6d087) ~Will Kinderman