Changelog#
All notable changes to tanh-lib are documented in this file.
The format is based on Keep a Changelog, and the project adheres to Semantic Versioning.
Unreleased#
Added#
Netcomponent (tanh::Net,TANH_BUILD_NET, off by default) — delivery of versioned file sets over HTTPS, for shipping model or sample packs that are too large to bundle.thl::net::HttpClient— GET to a file or a string,Rangeresume, progress with cancellation, and cancel from another thread. Backends are platform-native (NSURLSessiontoday) rather than a vendored TLS stack, so certificate validation uses the OS trust store and there is no CA bundle to ship or rotate inside a released plugin. Platforms without a backend reportHttpStatus::Unsupported;HttpClient::supported()lets a caller check once rather than per transfer. Windows, Linux and Android backends are not written yet.thl::net::Sha256— FIPS 180-4 in plain C++, streaming and whole-file, withmatches_hexthat rejects malformed input rather than trusting it. Plain C++ rather than OS crypto so the component needs one platform matrix (HTTP) and not two.thl::net::AssetStore— verified, atomic install of a caller-described file set into<root>/<id>/<version>/. The completion marker is written last, so a directory without it is a partial install thatis_installed()refuses andprune_partial()clears. Ids, versions and file names from a server are validated against path traversal. A failed install resumes without re-fetching files that already verify.The component knows nothing about manifests, models or packs: the caller supplies
{id, version, files:[{url, name, size, sha256}]}.Off by default so nothing embedding
tanh::Coreinherits a network stack.
Tested against a loopback HTTP server rather than a live endpoint — the failures worth covering (a well-formed response carrying wrong bytes, a declared length that is never delivered, a server ignoring
Range, cancellation mid-transfer) cannot be produced on demand against a real bucket. OneDISABLED_test fetches over real TLS when run with--gtest_also_run_disabled_tests.
Fixed#
dsp::granular::GrainProcessorImpl: volume modulation stepped the output.VoiceParams::m_volumeis a per-sub-block constant and was applied raw, so it was the only unsmoothed term in the voice gain (the ADSR already moves per sample) — a hard modulation step, such as a square LFO swinging both rails in one sample, reached the output as a discontinuity. The voice gain now ramps volume overk_volume_smoothing_duration(5 ms), seeded to the current level inprepare()and again at note-on so a voice starts at its level instead of sliding up to it.
Changed#
TANH_WITH_DOCSnow does something — it adds thesphinx-docstarget — and therefore defaults to OFF (it was ON and inert). A docs-enabled configure requires Doxygen and Python 3; consumers that already set it OFF are unaffected.dsp::granular::SamplePlayer: the equal-power crossfade reads a table instead of callingsin/cosper frame — a block fading the live head plus four outgoing tails wanted five transcendentals per frame. Becausecos(t * pi/2) == sin((1 - t) * pi/2), one quarter-sine table sized to the fade length inprepare()serves both directions, indexed straight by the integer fade counter: exact at every index, so the crossfade values are unchanged bit for bit.dsp::granular::GrainProcessorImpl: the voice gain pass skips the mode-fade ramp when the fade is already parked on its target, which is every block outside a mode switch. Same output, two fewer compares and a store per frame.
Added#
Documentation: a Doxygen → Breathe → Sphinx site under
docs/(the same pipeline as anira), published to https://tanh-lab.github.io/tanh-lib/ by the newbuild_docs_and_deployworkflow on every push tomain.just docsbuilds it locally. The API reference is generated frominclude/tanh/; the README’s design notes (symbol visibility,InputEventQueueevent spreading, the Android Bluetooth SCO notes) moved into the docs and the README is now a short entry point. Doxygen comments in the audio-io and state headers were corrected on the way (@paramnames that no longer matched the parameters,@sectionlabels reused across classes, a@copydetailsthat copied the wrong overload).dsp::granular: reverse playback from the markers alone — End before Start makes Start the entry and End the exit, so the Sample head runs backwards (re-entering at Loop) and Loop-mode grains scan and play backwards.SampleRegionkeeps ascending bounds plusm_reverseand mirrors only the read position (physical()), so the loop floor, crossfade pool and region-shrink logic are unchanged. Grain reads wrap below zero.dsp::utils::MorphWindow: a bank of eight grain windows (Rectangle, Trapezoid, Half cosine, Triangle, Hann, Gaussian, Narrow, Impulse) morphed by a continuous shape value — integers land exactly on a shape, fractions blend the neighbours — and skewed by a tilt in [-1, 1] that moves the peak by warping time around it. One shared instance, 512-point tables, peak 1, silent end points.GrainProcessorImplreads two new parameters,GrainWindowShapeandGrainWindowTilt(subclasses must serve them); a grain keeps the window it was born with.dsp::granular::GrainProcessorImpl: engine modes —EngineMode::GranularPosition(grains sprayed around a parkedPositionwithSpray/Tilt),EngineMode::GranularLoop(previous scan behaviour) andEngineMode::Sample(one continuous varispeed head, no grains). New parametersEngineModeParam,Position,Spray,Tiltextend the subclassParameterenum; subclasses must serve them fromget_parameter_*. Mode switches on a sounding voice fade through zero (k_mode_change_fade_duration).Sample mode in
MonoToStereoputs half the mono sum in each channel, the level a centred grain has under the linear pan law, so a mode switch no longer steps the level by 6 dB.Sample mode crossfades every head discontinuity — loop wrap, pitch-bank switch and retrigger — with a small pool of outgoing heads, so a second discontinuity inside a fade never hard-cuts.
Changed#
dsp::granular::GrainEnginerender loop: bank pointers and pan gains are resolved once per block per grain, one channel-mode kernel is chosen per block, and the window comes from the newdsp::utils::MorphWindow. Roughly half the CPU at a full pool;test/dsp/benchmark_Granular.cppmeasures it. Idle voices no longer scan the pool and report to the visualiser every block.Position mode: a Spray window past either sample edge clips to the edge instead of wrapping to the other end — what the waveform band shows.
dsp::granularsplit into components:GrainProcessorImplis now the per-voice facade (parameter snapshotVoiceParams, master ADSR, mode fade) over a pre-allocatedGrainEngine(grain pool + scheduler, told where to start grains by aHeadPolicy:LoopScanHead/PositionSprayHead) and aSamplePlayer(the Sample-mode head).SampleReader,SampleRegionandchannel_mixerare the shared, header-only helpers. Constants and enums moved toGranularTypes.h(still reachable throughGrainProcessor.h). The subclass contract (Parameterenum, the threeget_parameter_*hooks) is unchanged. Behaviour differences: a Sustain change now retunes the decay slope at once (the per-setter path left the rate stale until another envelope parameter moved); lingering grains are always reported finished on reset / silence;prepare()starts the voice from silence.
Fixed#
ModulationMatrix/RCU: data race between a schedule rebuild and the audio thread.RCU::updateis copy-on-write, so it deep-copies the live value — including eachResolvedRouting’sm_held_voice_valuesand per-voice freshness vectors, which the audio thread writes in place through the const routing it is processing.rebuild_schedule_with_lockassigns everyProcessingConfigmember anyway, so that copy was discarded immediately. NewRCU::replace()publishes a freshly built value without reading the one the readers hold; the rebuild now uses it.update()is unchanged and documents when not to use it. Caught by TSan viaConcurrentRebuild.PolyReplaceContentionChurnDoesNotCrash— the existing concurrency tests route Additive only and never touch the held state.ModulationMatrix: crash on the audio thread when a second Replace routing is added to a polyphonic target. A rebuild publishes each target’s freshVoiceBuffersone step before the newProcessingConfig, so an in-flight audio block still running the old routings can load a buffer whosem_has_replace_priorityhas just gone false -> true. That sends a routing resolved as single-Replace down the multi-Replace branch ofapply_replace_sample_voice, where it indexes per-voice freshness vectors its own rebuild left unsized – a null dereference on the audio thread. The vectors are now sized for every polyphonic Replace routing (contended or not), and the multi-Replace branch bounds-checks the voice index. Reproduced byConcurrentRebuild.PolyReplaceContentionChurnDoesNotCrash.
0.3.0 - 2026-09-03#
First release with a changelog: earlier releases (v0.1.0, v0.2.0) are described only by their tag messages (git tag -n1 v0.1.0 v0.2.0).
Added#
Logger: configurable platform-sink identity.LoggerConfig::m_platform_tag(Android logcat tag, journaldSYSLOG_IDENTIFIER),m_platform_subsystemandm_platform_category(Appleos_log) name what the platform sink files records under, so an embedder (anira) shows up under its own name inadb logcat -s, Console.app andlog stream. Defaults"thl","thl","logger"keep the previous output; an empty string selects the default. Set them before the first record: a laterset_config()applies to records dispatched after it returns, and on Apple platforms creates a newos_log_t(which the system never releases).Logger: per-record flags.LogRecord::m_flagstravels unchanged from the emitting site to the sinks (the console, file and platform sinks ignore it; the callback sink sees it). New overloads take the flags right after the level:log_with_source(level, flags, source, group, message),logf(level, flags, group, fmt, ...),rt::logf/rt::log(level, flags, ...)andrt::Queue::logf/vlogf/log(level, flags, ...); the existing signatures pass 0. Reserved bits:k_flag_realtime(1, set byrt::Queue::drain()on every record it dispatches) andk_flag_contract_violation(2, only ever set by the caller). The real-time producers stay allocation- and lock-free (one moreuint32_tin the fixed-size record).Logger:LogRecord::m_dropped_before. Art::Queue::drain()pass takes the queue’s drop counter before it pops and puts the count on the first record it dispatches; every other record carries 0, so summing the field over all records received counts every drop exactly once.format_plain()appends[N real-time log message(s) dropped before this record]andformat_logfmt()adropped_before=Nfield to a record that carries a count.CMake:
TANH_LOG_COMPILED_MAX_LEVEL(AUTO, or1..4) chooses the most verbose log level compiled into the call sites.AUTO, the default, keeps the historical rule (Error only in Release builds, every level otherwise); an embedder that wants its runtime level to be the only filter in a shipped build sets4as a plain variable before the fetch.
Changed#
Logger: the synthetic drop warning ofrt::Queue::drain()(level Warning, sourcert, groupthl.logger) is only dispatched when a pass has no record to carry the count. Its message is nowreal-time log queue overflowedwith the count inm_dropped_before(and in the rendered suffix), instead ofN real-time log message(s) dropped (queue full)after every pass with drops.