The fingerprint moves: 79f71f5821477353 -> 7cd75498ad4e7531. Changes
to sampling, postprocessing, chunking and text preparation are audible, so the same text, voice and seed render
differently than they did on 0.1.0. That is what a moved fingerprint means and
it is the only way this project is allowed to change a reading:
the identity contract lists what
loudkit-1 now includes, and every amendment below carries the measurement
that bought it.
Both model bundles now include all 28 voices: ten English voices and two for
each of the other nine languages. Henry, Oliver, Miles, Oscar, Clara, Emma,
Lucy and Sophie load by name without a separate profile download.
Python voice-profile saving now packs strided tensors into contiguous storage
before passing them to safetensors. Cropped enrollment mels could otherwise be
silently saved with incorrect values, changing the voice after reload in both
models. Already affected profiles must be regenerated from their source audio;
loading and resaving a corrupted file cannot restore the missing values.
Why a patch release moves it: before 1.0 the patch component tracks the API
surface and the fingerprint carries the audible contract, a clause
COMPATIBILITY.md states. This entry meets it.
All five SDKs read both checkpoint formats. Turbo uses format_version 2;
version-1 checkpoints retain their existing interpretation.
Source-breaking in Swift: TokenGenerator.generate now throws
(LoudKitError.cancelled at the poll that fires), so code that reaches the
generator through Engine.tokenGenerator must try. No compatibility shim.
Source-breaking for manually constructed voice profiles: Rust voice::Profile
now stores enrolment; add enrolment: "first-10s".into() to a struct literal
(or preserve the actual method if known). Go callers using positional voice.Profile
literals must add the new Enrolment field; keyed literals can omit it. Loading
and enrolling profiles use the same API and now preserve the enrollment method
when saved across all five SDKs.
Added
-
mp3andopusfrom every door:/v1/synthesize,/v1/audio/speech,
gRPC and MCP. Both come from the codecs of the libsndfile thatsoundfile
bundles, so neither adds a package. Hermes Agent and OpenClaw ask an
OpenAI-compatible server for mp3, or for Opus when the reply is a voice note,
and now get it fromloudkit serve.aacstays refused: libsndfile cannot
write it. -
The long-form conformance case is now driven through
engine.streamand
engine.synthesizeas well as chunk by chunk, so Python consumes the flat
long_form.cases[].tokenslist the four ports consume; the parity page's
seven weighted rows are compared by aslowtest that regenerates with
--checkpoint(theparityjob runs it); enrollment from a WAV file is held
to the enrollment fixture;lk.enrollandloudkit speakon a turbo release
directory have goldens;Result.save()is pinned to the shared quantise
rule; the production fingerprint is pinned weight-free; and the Swift
offline-receipt test asserts the stderr line the other four ports assert. -
loudkit textpreviews what will be spoken without loading a model: the
prepared text on stdout, a word diff of what the funnel changed on stderr.
loudkit --helplists eight commands. -
loudkit speak --playplays the WAV it just saved through the system
player (afplayon macOS,aplayorpaplayon Linux). -
Speed measured on 0.1.1 for both models: eight local paths on an Apple M3
Pro (PyTorch split CPU/MPS, native CoreML, ONNX Runtime CPU, the CPU
reference, and the Swift, Rust, Go and TypeScript ports) and six NVIDIA
parts (A100, L4, RTX 3090, T4, GTX 1080 Ti, Jetson Orin Nano), eager and
with CUDA graphs, plus batched throughput on four of them. loudr-1-turbo
runs 1.4x to 2.1x faster than loudr-1 end to end and 2.6x to 2.8x faster in
aggregate throughput (Benchmarks). -
A second model,
loudreader/loudr-1-turbo, behind the same one line.
lk.load("loudreader/loudr-1-turbo")is the whole difference: same voices,
same API, same seeds. It is thefusion_mtp2generator below beside a
renderer distilled to one Euler step, published as a second repository with
the same layout. Python, Swift, Go, Rust and TypeScript support both models,
using the matching ONNX graphs or CoreML packages. One enrolled voice
profile works with both models.
Choosing a model says which is which. -
A second decode mode,
fusion_mtp2, two speech tokens per transformer
forward: one head reads the hidden state, a second reads that state beside
the first token's embedding, and a fusion MLP folds both into the one KV
slot the pair occupies. Checkpoints that carry it declaredecode.modein
their manifest and ship asformat_version 2. The mode is a property of
the weights, not a runtime option: it is fingerprinted, and a mode/weights
disagreement is a load error. Measured with CUDA graphs against 0.1.0 on
the same lineage, RTF on the short / medium / long benchmark samples:0.1.0 0.1.1 RTX 3090 1.22 / 6.03 / 7.73 1.43 / 7.47 / 8.45 Jetson Orin Nano 0.53 / 1.60 / 1.84 0.63 / 1.82 / 2.16 Beside it, none fingerprinted: sampling runs on the device inside the
captured graph, graphs are kept across calls, the excitation noise is drawn
on the GPU, and the vocoder no longer vocodes padding it throws away
(ExecutionConfig.vocoder_ragged, on by default on CUDA renderers, in the
identity contract'sequivalentclass;--no-vocoder-raggedfor byte
agreement with a 0.1.0 build). End-to-end figures:docs/benchmarks.md. -
New release bundles use a 20 ms raised-cosine ramp on both window edges
to soften onset taps. The tap is a hot first mel frame from the flow decoder
on three of the 28 voices, not a vocoder edge. Measured on oscar, the hottest:
the residual burst in the first 12 ms of a window falls from 2.2 dB below the
window RMS with the old 5 ms ramp to 14.7 dB below with 20 ms, at a cost of
0.7 dB over the first 50 ms of a window that opens on speech; 50 ms would
cost 3 dB there.edge_fade_secondsis part of the algorithm and its
fingerprint, including the new 0.02 s default. Only the historical 0.005 s
spelling stays omitted for identity compatibility. -
loudkit cloneends the reference prompt in silence by default: the clip
is cut at its last pause before the ten-second limit, or at 9.6 s when no
pause fits, and padded with 0.4 s of silence, because the model speaks from
where the prompt ends. On one cloned voice the onset moved from 0 to 50 ms
and the first 60 ms from -32 to -51 dBFS.--no-end-in-silencekeeps the
recording as given, which is what the library'senrolldoes unless asked.
Profiles made with the cut carry the enrolment labelfirst-10s-pause. -
Every download directory carries a receipt,
.loudkit-release.json:
the repo, the revision asked for, the commit it resolved to, the digest of
SHA256SUMSand when it was fetched. A load whose revision still resolves
to that commit hashes nothing; any other receipt, or none, fetches and
verifies again, keeping every file that already hashes to the new manifest;
a load that cannot reach the hub uses the receipt and says so on stderr.
The same rules in Python (loudkit download --local-dir), Go, Rust, JS
and Swift, pinned by one fixture. One hub call per load; a hub answer
naming no commit is an error in all five. -
The JS binding no longer needs Python.
download(repo, dir)fetches
the ONNX release overfetchwith no new dependency, on the same plan and
rules asloudkit download --for onnx;examples/hello.mjsis the
quickstart.Enroller.enrolltakes a WAV path or bytes as well as samples. -
Go:
safetensors.Write,voice.Profile.Save,engine.Result.SaveWav.
Rust:safetensors::write,voice::Profile::save,hub::rejected_name,
hub::parse_sums,hub::is_official,Bundle::can_enroll,
execution::locate_runtime. JS:saveVoice,profileFrom,
writeSafetensors,hub.rejectedName,hub.parseSums,hub.canEnroll,
Enroller.close. Swift:Safetensors.write,VoiceProfile.save,
Engine.voiceNames,Engine.voice(named:),Engine.enroll(contentsOf:). -
export.jsonbeside every exported graph set, ONNX and CoreML, naming
what each file was traced from. The backends refuse a set whose members
disagree with each other or with the checkpoint; a release requires one.
Changed
-
Turbo CoreML uses an fp32 estimator: waveform correlation with ONNX now
exceeds 0.999 on both conformance cases. The stricter gate is shared. -
Python downloads the native generator and fusion graphs required by the
chosen backend. Legacy renderer-only CoreML bundles remain readable.
Complete CoreML bundles run without PyTorch; explicit fp16 token generation
retains the faster PyTorch/CoreML path. See the measured tradeoff in
benchmarks. -
Go, Rust, JS, Swift:
Enroll/enroll_wav/enrollon an engine loaded
by repo id fetches the three enrollment graphs (Swift: packages) into the
engine's own cache directory the first time, through the same verified
download, and reads them from there afterwards. An engine loaded from a
directory of your own still says how to fetch them (Cloning: true/
cloning: true/{ cloning: true }). -
Go, Rust, JS, Swift: one cache layout,
<user cache>/loudkit/<org>--<name>
(~/Library/Cacheson macOS,$XDG_CACHE_HOMEor~/.cacheon Linux,
%LocalAppData%on Windows);$LOUDKIT_CACHEreplaces<user cache>/loudkit
in all four. A release one port fetched is read by the other three. Pinned by
tests/data/conformance/cache_path.json. -
Rust:
hub::cache_dir()is nowhub::cache_dir(repo)and returns the
release directory; the layout moved from<root>/loudkit/<org>/<name>to
<root>/loudkit/<org>--<name>, so a release fetched by 0.1.0 is fetched
again once.hub::cache_path(root, repo)is the pure rule.hub::Bundle
gainedrepoandfetch_cloning()/fetch_cloning_with(&Options). -
JS: the cache moved from
$HF_HOME/loudkit/<org>--<name>to the platform
user cache;HF_HOMEis no longer read, so a release fetched by 0.1.0 is
fetched again once. -
Swift:
Engine.enroll(contentsOf:name:language:)is nowasync throws;
addawait.ModelBundle.fetchCloning()is public.
LoudKit.cacheDirectory(repo:revision:)is nowcacheDirectory(repo:)with
no per-revision subdirectory (the receipt carries the revision), and
LOUDKIT_HOMEis replaced byLOUDKIT_CACHE.LoudKit.cachePath(root:repo:)
is the pure rule. -
Go:
$LOUDKIT_CACHEis honoured. -
Docs: the four port READMEs and guides 07 to 10 clone in the same two lines
as Python, name the cache directory, and no longer show a second download
into a directory of your own as the way to clone. Guide 01 names the ports'
cache; guide 11 explains choosing between the two models. -
Go, Rust, JS, Swift: offline,
enrollon an engine whose cache holds the
synthesis set says which enrollment graphs (Swift: packages) are missing
and that one connection fetches them, instead of the transport error (Go,
Rust), "holds no verified release" (JS) or "cannot reach" (Swift). A plain
load offline still uses the cache. -
Python, Go, Rust, JS, Swift: the receipt reader reads nothing past 1 MiB;
a bigger file is no receipt. The shared fixture pins a JSON array, a
number and a string as receipts (28 cases). -
Go and Rust READMEs carry the one sentence about
Enroll/enroll_wav
on a repo engine that the JS and Swift READMEs had. -
One front door in five languages.
synthesize(text, voice, options)
speaks text of any length in every port and returns one result that saves
itself; the one-window call issynthesizeWindow(SynthesizeWindow,
synthesize_window). Options are named and default everywhere: seed 0,
the voice's own language, speed 1.0, no previous tokens. Go:Options
andResultingo/engine, re-exported by the root package. Rust:
engine::OptionswithDefault,engine::Synthesiswithsave_wav;
streamtakes&Options. JS:SynthesisOptionsgainsseed,language
andshouldCancel. -
load(ref)hands out voices in every port.engine.voices()and
engine.voice(name)(Swift:voiceNames,voice(named:)) read the
release the engine was opened from. RustEngine::load(ref)replaces
load_bundle(three-path form:load_paths). JSEngine.load(ref)is
one argument (three-path form:Engine.loadPaths). SwiftEngine.load(ref)
(async) replacesload(repoOrDirectory:);ModelBundlestays behind
Engine.load(bundle:). Python gainsEngine.voice(name),
Engine.voices()andEngine.checkpoint_path. -
Cloning is two lines in every port.
engine.enroll(...)takes a
recording, a name and a language (GoEnroll(name, wav, language), Rust
enroll_wav(path, name, language), JSenroll(wav, { name, language }),
Swiftenroll(contentsOf:name:language:)), and the profile saves itself
in one layout, so a voice cloned in one language loads in the other four. -
Engine.synthesizereads text of any length in Python: one window
when the text fits, sentence-boundary splits when it does not, one
Resulteither way, the same tokensstreamyields for the same seed.
synthesize(..., single_window=True)renders one window and raises
WindowOverflowErrorrather than splitting (long_form=falseon the wire
maps to it). One seed law in all five: chunk 0 draws the caller's seed
itself and only later chunks drawderive(seed, 16 + index), so a text
that fits one window reads the same through every call. -
One cancellation contract in five implementations.
synthesize
returns the whole passage or signals cancellation explicitly, never the
chunks that finished: Python raisesCancelledError(aLoudkitError,
codecancelled; theResult | Noneoverloads are gone), Go returns
ErrCancelled, RustErr(error::CANCELLED)(error::is_cancelled), JS
throwsCancelledError, Swift throwsLoudKitError.cancelled
(Result.cancelledis gone).streamdelivers the chunks that finished
and then ends without raising. RustOptionsgainsshould_cancel,
Python'sEngine.synthesizetakes it, and a departed HTTP or gRPC client
no longer holds the engine's slot to the last token (HTTP answers 499). -
Swift
TokenGenerator.generatethrowsLoudKitError.cancelledat the poll
that fires and is declaredthrows; every caller,Engine.tokenGenerator
included, musttry. A generator that could not cancel was the wrong API
to keep, so there is no shim. -
loudkit.__all__is 21 names, down from 46: the six verbs,Engine,
Result,VoiceProfile,MIN_SPEED,MAX_SPEED, the error classes and
__version__. Everything else stays importable from the module that owns
it, which COMPATIBILITY.md names. -
ExecutionOverridesis gone. EveryExecutionConfigfield defaults to
None, "the checkpoint's default for this device"; a named field wins even
when it equals the default;ExecutionConfig.resolved()fills the rest. -
Postprocess is a per-checkpoint preset.
PostprocessConfigand the
eight detector functions left the public surface;inspect,Inspection,
ReasonandPostprocessModeremain, andmodeis the one knob. -
The fingerprint is internal.
Resultcarries one
Result.provenance: Provenancevalue thatsave()writes into the C2PA
manifest, in place of eight fields.FINGERPRINT_SCHEMAand
DEFAULT_ALGORITHMleftconfig.__all__. -
Eight CLI commands.
speak,text,clone,voices,download,
serve,verify,doctor.serve --grpcandserve --mcpreplace thegrpcand
mcpcommands;doctor --describereplacesdescribe;benchand
profilemoved totools/.--checkpointdefaults to
$LOUDKIT_CHECKPOINT, elseloudreader/loudr-1. -
One name for the token-cap flag in every port.
Result.hit_token_cap
in Python,HitTokenCapin Go (wasTruncated),hit_token_capin Rust
(wastruncated),hitTokenCapin JS (washitCap) and in Swift. Go's
Engine.EnrollandEnrollPCMtake the recording first, as the other four
SDKs do. -
Python modules.
loudkit.engineis four modules (engine,result,
window,stream);loudkit.configis three (config,execution,
manifest);loudkit.hubis three (hub,release,checksums);
loudkit.transports.httpis three (http,limits,schemas); every
public name stays importable where it was.loudkit.frontend.polishis
loudkit.frontend.speechtext. -
lk.enroll(path)reads the file at its native rate (soundfile,
channels averaged) and resamples with the Hann-sinc law every port ships,
so a 44.1 or 48 kHz recording enrols the same profile from Python as from
any port.enroll(samples, ...)takessample_rate(default 24000). -
loudkit.hub.read_receipt(root, repo)returns a validatedReceiptor
Noneand is the only reader of the file;receipt_hitwraps it, and the
ports'readReceipt/read_receiptfollow the same shape. Python asks
the hub once per(repo, revision)per process. -
HTTP, gRPC and MCP refuse the same over-long request with the same code;
the MCPsynthesizetool's cap refusals carrycode: "invalid_request".
Every loopback address is "local" for all three transports. -
A second concurrent
generateon one engine is refused rather than
served. A checkpoint whosedecode.modeneeds aformat_versionabove
the one it declares is refused at load, in all five engines. -
Rust finds the ONNX Runtime library the way Go does:
ORT_DYLIB_PATH,
thenLOUDKIT_ONNXRUNTIME_LIB, then the usual install paths. -
JS
Engine.load(dir)no longer demandsmanifest.json; it reads the
manifest the checkpoint embeds, as the other four do. -
Swift
Result.save(to:)issaveFloat32Wav(to:);saveWav(to:)and
saveWav(_ path:)write 16-bit PCM. -
tools/build_release.pyandtools/build_turbo_release.pyare one
package,tools/release/, run aspython -m tools.release --model loudr-1|turbo. The two scripts stay as shims and refuse a user-supplied
--model.--skip-verifyunder--model turbois refused before any
copying. -
Documentation.
docs/README.mdlists the twelve pages a user needs;
everything else is underdocs/reference/,docs/platforms/and
docs/design/. The README is the five-language front door. Internal
vocabulary left every user page, and the C2PA promise is narrowed to
Python and the server.docs/parity-measured.mdis regenerated with the
release weights, names what the free-running row compares against (its own
stream, re-based at3960d30) and states that row's per-architecture
scope.
Security
- One verification law, at download, in all five.
download(and a
repo-idloadthat has to fetch) fetchesSHA256SUMSandrelease.json
before anything else; underloudreader/the record must say a release
profile (full-0.1orturbo-0.1) andverified: true, or the bundle is
refused before a weight moves. Every fetched file is hashed against the
manifest as it arrives; a mismatch is refused and the file removed; an
unlisted weight is refused. A listing name that escapes the directory is
refused in all four ports. - The
.loudkit-verifiedmarker and re-verification on every load are
gone from Python, Go, JS and Swift; the receipt replaces them.
Removed
- Go
Say,SynthesizeLongand the seven-argumentSynthesize; Rust
Engine::load_bundle,load_bundle_with,synthesize_long,
voice::from_bundleand the six-tuple; JSsynthesizeLong,
Engine.loadWithDefaults, the freevoice(name, dir)and the three-path
Engine.loadoverload; SwiftsynthesizeLongand
Engine.load(repoOrDirectory:). - Python
Engine.synthesize_long,ExecutionOverrides,
AlgorithmConfig.tokens_per_forward,DecodeGeometry.cache_shape,
loudkit.bench,loudkit.profile,loudkit.hub.receipt_valid,
loudkit.transports.mcp.main, and five CLI commands (see Changed).
Fixed: the silence campaign
The engine could park in silence and not come out. Measured over 120 passages
per arm in ten languages, paragraphs carrying a gap over one second ran at
33.0%, with 74 of 1705 passages rendering a chunk of no speech at all.
Six laws close it, each moving the fingerprint and re-basing the goldens.
- A new verdict,
stall, for a row the decoder spent trapped in silence.
The four rescues that existed all cut a tail, and this failure is a hole.
stallcuts nothing and condemns the row into the retry ladder, the way
dropoutdoes, and runs before every tail rescue. SeventhReason, and
every port runs it. - The repetition penalty applies to silence ids. Exempt from both the
penalty and themin_pcutoff, a silence run was absorbing: zero escapes
in 1,031 instrumented trap steps. The penalty exemption is gone and the
min_pone stays (removing it instead measured catastrophic). Holes
33.0% -> 4.3%, mute chunks 74 -> 1, WER flat or better. - A pause is not a loop (
repetition_silence: acoustic): a pause parked
on an id only the render census knows stalls and retries instead of being
cut. A loop the decoder resumed from is condemned, not cut
(repetition_resume: condemn): a tail cut would have removed the speech
that followed it. - A starved desperation rescue is condemned: a cap-hit trim that keeps
underdesperation_min_keep_per_text_tokentokens per text token goes to
the retry ladder, with the trim as the fallback. - The ladder ships the best attempt, not the last: the one with the
fewest tokens in the true-silence set.
Fixed: the text funnel
-
Every character class the five implementations disagreed about, closed
as one family (funnel-2->funnel-4). Each pass asks some version of
"is this a letter, a digit, or a space", and the five spelled those three
questions eleven different ways. Every difference was audible.- Numerals. A number character with no ASCII spelling (a superscript, a
vulgar fraction, a circled or Roman numeral, a digit from any script but
Latin) was deleted:Add ½ cupread as add cup.fold_numeralsnow
reads a generated table,models/data/numerals.json, cut from one pinned
UCD (16.0.0): a digit of any script becomes the ASCII digit of the same
value, every other numeral becomes the text the table names (½is
1/2). Detection comes from the table too, so a runtime's own Unicode
version no longer decides what is read aloud. - Letters, whitespace, boundaries.
\p{L}everywhere (Swift's
CharacterSet.lettersdeleted 6,145 Tangut ideographs), one written-out
whitespace class (Go read footnote markers aloud after a thin space), and
(?![\p{L}\p{Nd}_])written out rather than\b, whose ECMAScript and
Go spellings are ASCII.
- Numerals. A number character with no ASCII spelling (a superscript, a
-
A symbol is spoken in the language being read. The grammar carried a
word per language for the seven marks that are also currency or measure,
and the funnel carried an English/Polish pair for the other twenty-one, so
ten of the twelve languages heard English:≈said about in a German
render and in a Finnish one. Thirteen word-valued symbols
(× ÷ ≈ ≥ ≤ ≠ ± ✓ ✔ ✗ ✘ & @) gain a row per language; the eight that are
punctuation everywhere (→ ← ⇒ • · ▪ ◦ …) stay a rule in code. Measured
over every spoken symbol in three carriers in twelve languages, 360 of
1008 rows move; every conformance case read in its declared language is
unmoved. -
An uncovered symbol is left written in all five funnels. Four ports
fell back to the English row per symbol where the reference left the
symbol written. Measured on a grammar with eight rows cut, one per pass
the funnel runs, 46 of 576 cases diverged. Swift held the wording table
twice, as a literal beside the grammar file its own resources carry, and
reads the file now.
Fixed
-
Voices load from a release the Hub cache holds. The cache is one
symlink per file from the snapshot into its blob store, and both the voice
resolver and the server's voice library refused those links as escapes, so
engine.voice("joe"),loudkit speak --voice joeand/v1/voicesfound
nothing after a download. A link is now confined to the cache entry that
owns the snapshot; a link out of it, or out of a plain directory, is still
refused. -
CoreML generator exports use a weighted sum for single-query attention,
avoiding a CPU matrix-kernel error that changed Turbo tokens in longer windows.
Both model bundles include regenerated, token-verified generator graphs. -
An offline receipt could be forged by one field. All five hold
.loudkit-release.jsonto one reader before using it, offline or as an
online hit: every field present with its type (nullis the wrong type),
repothe one asked,commitforty lowercase hex,sha256sumsthe digest
of theSHA256SUMSon disk, every listed file the plan selects present.
The fixture holds twenty-five cases,nullin every field among them. -
Python never refreshed a moving
main, and the native caches were not
bound to a revision; a repo id always resolves throughdownloadnow. -
The one-window path refuses text that does not fit, in all five
implementations, instead of shipping the tail unspoken; none of the five
raised theWindowOverflowErrortheir docs promised. -
A streaming response is capped at ten minutes of wall clock, on HTTP as
on gRPC: a client that stopped reading held the engine slot indefinitely. -
The Swift snippet on the front page and the landing page did not compile
(saveWav(to:)takes aURL; the string form issaveWav("hello.wav")),
and the landing page's Go snippet never read itserr. Each port's suite
now compiles its page blocks.SUPPORTED.mdno longer advertises the old
CLI.
Fixed: the chunker
- Not every period ends a sentence (
ChunkConfig.mid_sentence_period: hold)."But Mr. Smith went home"produced the chunk"But Mr.", with
its own seed and a ceiling too short for a closing pause. A period is held
when the next word starts in lower case or the token before it is in
ChunkConfig.abbreviations;"break"names the old law. - A chunk the window could not hold is split, not shipped
(ChunkConfig.cap_resplit: word). 54 of 9920 chunks overran the character
budget, 30 still speaking when the cap closed, and the remainder was never
spoken. Such a chunk is replaced by its two halves, cut at the word
boundary nearest the middle. Windows at the cap 52 -> 2, still speaking
at the cap 29 -> 1."off"names the old law. - The word-boundary fallback matches the boundaries prose has, not
U+0020 alone: text whose every space is non-breaking was cut mid-word. - Six ways the five funnels disagreed, each now pinned in the shared
fixture: the reference dropped the inverted Spanish marks¿¡that four
ports kept, and Swift halved on grapheme clusters, not scalars.
Full Changelog: v0.1.0...v0.1.1