Flow, Indexed. Your data, under precise control.
English · 简体中文 · 繁體中文 · 日本語 · 한국어 · Deutsch · Français · Español · Русский
A type-safe stream processing library that unifies JavaScript generators, Java Stream and
index-based data addressing. Every element carries a bigint index, every pipeline is lazy, and
every terminal operation is an explicit opt-in.
npm install semantic-typescript- Zero runtime dependencies, pure ESM, ships its own types.
- 684 regression tests:
npm test. - Full change history: CHANGELOG.md.
- Design Philosophy
- Architecture
- Quick Start
- Factory Functions
- Transformation Operators
- Collectables and Terminal Operations
- Statistics and Windows
- The Index Model
- The Asynchronous Model
- Periodic Analysis: Rings, Fields and Scores
- Utility Helpers
- Performance and Pitfalls
- Phase Rings: An Exact Cycle of Positions
- Signal Analysis: Windows, Spectra and Time
- License
Three ideas, one pipeline:
Index is data. Every element is addressed by a bigint. Reversal, rotation, shuffling and
partitioning are index arithmetic, not buffer operations. Nothing is materialised until a
collectable asks for it.
Laziness is the default. A factory wraps a generator; an operator wraps another generator. No element is requested until a terminal operation runs, so an infinite source is perfectly legal as long as something downstream stops it.
Terminals are explicit. map returns a Semantic, not an array. Values appear only when you
name a collectable — toUnordered(), toOrdered(), toNumericStatistics(), toWindow() — and each
one states what it costs and what order it produces.
The same discipline applies to the mathematical half of the library. A Complex, an Angle or a
Frequency is an immutable value whose invariants are checked at construction; an Optional
replaces null; and a phase ring states exactly how many elements it holds, what period it has and
how balanced it is.
factory ──▶ Semantic ──▶ Semantic ──▶ Collectable ──▶ result
(source) (map) (filter) (terminal)
▲
└── every operator returns a new Semantic wrapping a generator
| Layer | Module | Responsibility |
|---|---|---|
| Sources | synchronous/collectors, asynchronous/collectors |
Wrap an iterable, a generator, a promise, a blob, an event target |
| Pipelines | synchronous/semantic, asynchronous/semantic |
Lazy operators and index rewriting |
| Collectables | synchronous/semantic, asynchronous/semantic |
Ordered, unordered, statistics, window and custom terminals |
| Collectors | synchronous/collector, asynchronous/collector |
The fold primitives every terminal is built from |
| Mathematics | mathematics/* |
Angles, complexes, frequencies, spectra, windows, estimators, epicycles, phase rings |
| Structures | structure/* |
Lists, sets, maps, trees, forests, rings, fields |
| Errors | structure/error |
The typed SemanticError hierarchy and its brand guards |
Synchronous and asynchronous families share one vocabulary: the same operator names, the same index semantics, the same collectables. The asynchronous variants simply return promises, and they queue writes so that a read issued after a write always observes it.
Three modules changed location or shape, and a set of defaults changed meaning. Every old behaviour is still reachable through an explicit option; nothing was removed without a replacement.
| Change | Before | Now |
|---|---|---|
| Forests | structure/tree/rootedForest, structure/tree/unionFindForest |
structure/forest/rootedForest, structure/forest/unionFindForest; tree/index.ts no longer re-exports them |
| Errors | ad-hoc native errors | a fifteen-class hierarchy in structure/error.ts, with one brand guard per class and the SemanticRangeError alias |
| Estimator options | fixed definitions | mathematics/quantile.ts exports QuantileMethod, QuantileOptions, SkewnessEstimator, KurtosisEstimator and VarianceOptions |
quantile |
Hyndman–Fan type 1 | type 7 — R quantile, NumPy linear, Excel PERCENTILE.INC |
skewness / kurtosis |
g1 / g2 |
the bias-corrected G1 / G2 |
variance / standardDeviation |
ddof = 0 |
ddof = 1, wrapped in an Optional |
decibel |
one 20·log10 function |
removed; amplitudeDecibel (20·log10) and powerDecibel (10·log10) |
Spectrum.flatness() |
magnitudes over both halves | powers over the one-sided half |
Spectrum.bandEnergy() |
raw two-sided sum | one-sided by default, folding the conjugate half |
shortTimeFourierTransform / spectrogram |
symmetric Hann, complete frames | periodic Hann, boundary: "zeros", padded: true |
Chapters 6, 7 and 10 document the replacements; the measured consequences are in CHANGELOG.md.
import {
useOf, useFrom, useRange, useText, useStringify, usePromise
} from "semantic-typescript";
// Numeric aggregation
const sum: number = useOf(10, 20, 30, 40)
.map(n => n * 2)
.toNumericStatistics()
.summate(); // 200
// BigInt aggregation
const bigSum: bigint = useOf(10n, 20n, 30n, 40n)
.map(n => n * 2n)
.toBigintStatistics()
.summate(); // 200n
// Reordering is index arithmetic — collect with toOrdered() for it to take effect
const reversed: number[] = useFrom([1, 2, 3, 4, 5]).reverse().toOrdered().toArray(); // [5,4,3,2,1]
const rotated: number[] = useFrom([1, 2, 3, 4, 5]).translate(2).toOrdered().toArray(); // [4,5,1,2,3]
// Lazy evaluation over a large range
const multiples: bigint[] = useRange(0n, 1_000_000n)
.filter(n => n % 17n === 0n)
.limit(10n)
.toUnordered()
.toArray(); // [0n,17n,…,153n]
// Unicode-safe string processing (code points, not UTF-16 units)
useText("semantic 👍").toUnordered().forEach(character => console.log(character));
// Circular-safe serialisation
const cyclic: any = { a: 1 }; cyclic.self = cyclic;
useStringify(cyclic); // '{"a":1}'
// Terminals hand the elements to real structures, not only to arrays
useOf(3, 1, 4, 1, 5).toCollectable().toOrderedSet().toArray(); // [1,3,4,5]
useFrom([3, 1, 4, 1, 5]).toCollectable().toBinarySearchTree().size(); // 5n
// Async sources
const total = await usePromise(Promise.resolve(7)).toNumericStatistics().summate(); // 7Construction is always O(1) — a factory only wraps a generator. The table states the cost of
consuming n elements, plus the extra space the source itself needs.
| Factory | Signature (result) | Time (consume n) | Space (source) | Notes |
|---|---|---|---|---|
useOf |
useOf<E>(...elements) → SynchronousSemantic<E> |
O(n) | O(n) | Indices are 0..n−1; the spread argument is already materialised |
useFrom |
useFrom<E>(Iterable<E>) → SynchronousSemantic<E>useFrom<E>(AsyncIterable<E>) → AsynchronousSemantic<E> |
O(n) | O(1) | Wraps any iterable; iterated once on collect |
useRange |
useRange<N>(start, end, step?) → SynchronousSemantic<N> |
O(n) | O(1) | end is exclusive; step must be non-zero; number and bigint both work |
useFill |
useFill<E>(element | supplier, count) → SynchronousSemantic<E> |
O(n) | O(1) | A supplier is called once per element |
useEmpty |
useEmpty<E>() → SynchronousSemantic<E> |
O(1) | O(1) | Identity element for concat |
useText |
useText(text, delimiter?) |
O(n) | O(n) for the text | Iterates code points, so emoji and CJK stay single elements |
useAttribute |
useAttribute<T>(object) |
O(v + e) | O(d + v) | Recursively walks nested objects with cycle protection |
useGenerate |
useGenerate<E>(supplier, interrupt) |
O(n) × supplier cost | O(1) | Must have a stopping interrupt or a downstream limit |
useIterate |
useIterate<E>(Iterable<E>) / (AsyncIterable<E>) |
O(n) per collection | O(1) | Replays the iterator on each collection |
useInterval |
useInterval(period, delay?) → AsynchronousSemantic<number> |
n × period (real time) | O(1) | Emits elapsed time |
usePromise |
usePromise<T>(promise) → AsynchronousSemantic<T> |
O(1) | O(1) | A single element at index 0n |
useBlob |
useBlob(blob, chunk?) → AsynchronousSemantic<Uint8Array> |
O(blob size) | O(chunk) per copy | Default chunk is 64 KiB |
useSubscription |
useSubscription(target, subscriber, event) → AsynchronousSemantic<Event> |
one element per event | O(1) | limit() / takeWhile() define the unsubscribe boundary |
useOf(1, 2, 3).toUnordered().toArray(); // [1,2,3]
useFrom(new Set([1, 2, 2])).toUnordered().toArray(); // [1,2]
useRange(0, 5).toUnordered().toArray(); // [0,1,2,3,4]
useRange(10n, 0n, -5n).toUnordered().toArray(); // [10n,5n]
useFill("x", 3n).toUnordered().toArray(); // ["x","x","x"]
useText("A😊B").toUnordered().toArray(); // ["A","😊","B"]
useGenerate(() => 7, (_e, index) => index >= 3n).toUnordered().toArray(); // [7,7,7]Every operator returns a new Semantic; none touches an element until a collectable runs. Time is
the extra cost per element; space is what the operator keeps while the pipeline runs.
| Operator | Purpose | Index effect | Time | Space |
|---|---|---|---|---|
map(mapper) |
1:1 transform | forwarded | O(1) | O(1) |
filter(predicate) |
keep matching elements | forwarded | O(1) | O(1) |
flat(mapper) / flatMap(mapper) |
1:N flatten | re-indexed from 0 | O(1) per emit | O(1) |
distinct(keyExtractor?) |
drop repeated keys, keep the first | forwarded | O(1) average | O(n) |
peek(consumer) |
side effect, element passes through | forwarded | O(1) | O(1) |
limit(count) |
take the first n | re-indexed from 0 | O(1) | O(1) |
skip(count) |
drop the first n | forwarded | O(1) | O(1) |
sub(start, end) |
positional slice [start, end) |
forwarded | O(1) | O(1) |
takeWhile(predicate) |
take while the predicate holds | forwarded | O(1) | O(1) |
dropWhile(predicate) |
drop while the predicate holds | re-indexed from 0 | O(1) | O(1) |
concat(other) |
append a semantic or iterable | continues the index | O(1) | O(1) |
redirect(redirector) |
compute a new index however you like | yours | O(1) | O(1) |
reverse() |
redirect((_e, i) => -i - 1n) |
negated and shifted | O(1) | O(1) |
translate(offset | mapper) |
shift every index | i + offset |
O(1) | O(1) |
shuffle(mapper?) |
randomise the index | random | O(1) | O(1) |
pipe(conversion) |
hand the generator to a custom function | up to you | yours | yours |
semantic() |
lift a collectable back into a semantic | unchanged | O(1) | O(1) |
reverse, translate and shuffle only rewrite indices. Nothing is buffered or rotated until
toOrdered() normalises the indices modulo the element count:
useFrom([1, 2, 3, 4, 5]).reverse().toOrdered().toArray(); // [5,4,3,2,1]
useFrom([1, 2, 3, 4, 5]).translate(2).toOrdered().toArray(); // [4,5,1,2,3] (rotation)
useFrom([1, 2, 3, 4, 5]).translate(7).toOrdered().toArray(); // [4,5,1,2,3] (7 ≡ 2 mod 5)
useFrom([1, 2, 3]).shuffle((_e, i) => -i - 1n).toOrdered().toArray(); // [3,2,1]The collectable owns both the order and the index bookkeeping. n is the element count.
| Collectable | Structure | Time | Order | Use when |
|---|---|---|---|---|
toUnordered() |
Map<bigint, E> keyed by index |
O(n) | insertion | maximum throughput; raw indices preserved untouched |
toOrdered() |
array sorted by element index | O(n log n) | by normalised index | reversal/rotation must take effect |
toOrdered(comparator) |
sorted by your comparator | O(n log n) | by your comparator | sorted views |
toNumericStatistics() |
value-sorted buffer | O(n log n) | by value | statistics over number |
toBigintStatistics() |
value-sorted buffer | O(n log n) | by value | statistics over bigint |
toWindow() |
value-sorted buffer | O(n log n) | by value | slide / tumble |
toCollectable() |
the collectable itself, wired to the generator | O(1) | — | the cheapest way to reach a terminal |
toCollectable(mapper) |
whatever the mapper returns | — | — | custom collectables |
Every terminal lives on the collectable, not on the Semantic. That is what "terminals are
explicit" means in practice: a pipeline produces nothing until a collectable is named, and
toCollectable() with no mapper is the cheapest one to name.
| Terminal | Returns | Time | Notes |
|---|---|---|---|
toArray() / toSet() |
Array<E> / Set<E> |
O(n) | Drains the buffer; toSet deduplicates by identity |
toMap(key, value?) |
Map<K, V> |
O(n) average | Last write wins |
count() / isEmpty() |
bigint / boolean |
O(1) | The buffer already knows |
forEach(action) |
void |
O(n) | action(element, index) |
join(…) |
string |
O(n) | join(), join(delimiter), join(prefix, delimiter, suffix) |
reduce(…) |
Optional<E> / E / R |
O(n) | Empty stream → empty Optional |
group(classifier) / groupBy(key, value) |
Map<K, E[]> |
O(n) average | — |
partition(count) |
Array<Array<E>> |
O(n) | Round-robin by index |
partitionBy(classifier) |
Array<Array<E>> |
O(n) | The bucket number comes from the classifier |
anyMatch / allMatch / nonMatch |
boolean |
O(n) worst, short-circuits | — |
findAny / findFirst / findLast |
Optional<E> |
O(1)–O(n) | Positional in materialised order |
findAt(k) / findNegativeAt(k) |
Optional<E> |
O(k) / O(n−k) | Counts from the front / the end |
findMaximum(cmp?) / findMinimum(cmp?) |
Optional<E> |
O(n) | Defaults to a structural comparison |
frequency() |
Map<E, bigint> |
O(n) average | Statistics collectables only |
write(stream, accumulator?) |
WritableStream<S> |
O(n) | IO-like terminal |
collect(collector) |
R |
O(n) | The generic fold behind every terminal above |
toArray, toSet and toMap end a pipeline in a JavaScript container. Twelve more terminals end it
in one of the library's own structures, so the result carries the invariants that structure
guarantees rather than the ones an array happens to have. They are available on every collectable,
including the bare one from toCollectable().
| Terminal | Returns | Order | Cost |
|---|---|---|---|
toArray() |
Array<E> |
traversal | O(n) |
toSet() |
Set<E> |
insertion, SameValueZero | O(n) average |
toMap(key, value?) |
Map<K, V> |
insertion; the last write wins | O(n) average |
toOrderedSet(comparator?) |
SynchronousOrderedSet<E> |
comparator-ascending | O(n²) worst case |
toUnorderedSet() |
SynchronousUnorderedSet<E> |
unspecified (hash buckets) | O(n) expected |
toOrderedMap(key, value?) |
SynchronousOrderedMap<K, V> |
insertion; the last write wins | O(n) expected |
toUnorderedMap(key, value?) |
SynchronousUnorderedMap<K, V> |
unspecified | O(n) expected |
toList() |
SynchronousList<E> |
traversal, duplicates kept | O(n) |
toRing(span?) |
SynchronousRing<E> |
traversal fixes ring positions | O(n) |
toLRUSet(capacity) |
SynchronousUnorderedLRUSet<E> |
recency; the last capacity survive |
O(n²) worst case |
toLRUMap(capacity, key, value?) |
SynchronousUnorderedLRUMap<K, V> |
recency | O(n) expected |
toSkipSet(comparator?) |
SynchronousSkipSet<E> |
comparator-ascending, navigable | O(n²) worst case |
toSkipMap(key, value?) |
SynchronousSkipMap<K, V> |
key comparator, navigable | O(n) expected |
toBinarySearchTree(comparator?) |
SynchronousBinarySearchTree<E> |
comparator-ascending, height-balanced | O(n log n) |
toRootedForest() |
SynchronousRootedForest<E> |
encounter order becomes structure | O(n) |
The result type follows the collectable's own element type, so no terminal needs a type argument: a
SynchronousCollectable<E> hands you Array<E>, SynchronousList<E> or SynchronousOrderedSet<E>
directly. Only the business arguments remain — the comparator of toOrderedSet / toSkipSet /
toBinarySearchTree, the capacity of the LRU pair, the span of toRing, and the key and value
extractors of the map family.
A ring is a set, so repeated elements keep their first position; every other structure above keeps
duplicates unless its own contract says otherwise (toOrderedSet, toUnorderedSet, toSkipSet are
sets; toBinarySearchTree and toList are not).
const numbers = useOf(3, 1, 4, 1, 5).toCollectable();
numbers.toOrderedSet().toArray(); // [1,3,4,5]
numbers.toUnorderedSet().size(); // 4n — the duplicate 1 collapses
numbers.toList().toArray(); // [3,1,4,1,5]
numbers.toSkipSet().first().get(); // 1 — navigable by comparator
numbers.toBinarySearchTree().size(); // 5n — duplicates retained
numbers.toRootedForest().getRoots().length; // 1
useOf(1, 2, 3).toCollectable().toRing().phaseAt(3n).get().toDegreeValue(); // 0
useOf(3, 1, 3).toCollectable().toOrderedMap(n => n, n => `v${n}`).get(3).get(); // "v3"The asynchronous collectables expose the same terminals and resolve each in a promise, so the call
shape only gains an await.
useOf(3, 1, 4, 1, 5).toNumericStatistics().summate(); // 14
useOf(3, 1, 4, 1, 5).toNumericStatistics().average(); // 2.8
useOf(3, 1, 4, 1, 5).toNumericStatistics().median(); // 3
useOf(3, 1, 4, 1, 5).toNumericStatistics().mode(); // 1
useOf(3, 1, 4, 1, 5).toNumericStatistics().variance().get(); // 3.2 (n − 1)
useOf(3, 1, 4, 1, 5).toNumericStatistics().standardDeviation().get(); // 1.7889 (n − 1)Both statistics families expose the same surface — summate, average, median, mode,
variance, standardDeviation, frequency, findMaximum, findMinimum — with bigint arithmetic
for toBigintStatistics(). The dispersion measures and the quantile family return Optional,
because a sample can be too small for them to be defined; unwrap them with .get(), .orElse(…) or
.isPresent().
The defaults are the definitions R, NumPy, pandas and Excel use, so a value computed here can be compared with those tools without a correction factor.
| Statistic | Default | Previous | Restore the old value with |
|---|---|---|---|
quantile(probability) |
Hyndman–Fan type 7 | type 1 | { method: "type-1" } |
interquartileRange() |
Hyndman–Fan type 7 | type 1 | { method: "type-1" } |
skewness() |
bias-corrected G1 |
g1 |
{ estimator: "g1" } |
kurtosis() |
bias-corrected excess G2 |
g2 |
{ estimator: "g2" } |
variance() |
ddof = 1 |
ddof = 0 |
{ ddof: 0 } |
standardDeviation() |
ddof = 1 |
ddof = 0 |
{ ddof: 0 } |
All nine Hyndman–Fan types are available as "type-1" … "type-9", and { excess: false } adds
three back to a kurtosis, reporting the non-excess fourth standardised moment. The option types —
QuantileMethod, QuantileOptions, SkewnessEstimator, KurtosisEstimator, VarianceOptions —
are exported from mathematics/quantile.ts.
const sample = useOf(1, 2, 3, 4).toNumericStatistics();
sample.variance().get(); // 1.6667 (5/3, ddof = 1)
sample.variance({ ddof: 0 }).get(); // 1.25
sample.standardDeviation().get(); // 1.2910
sample.skewness().get(); // 0
sample.kurtosis().get(); // -1.2 (G2)
sample.kurtosis({ estimator: "g2" }).get(); // -1.36
sample.kurtosis({ excess: false }).get(); // 1.8
const quartiles = useOf(1, 2, 3, 4, 5, 6, 7, 8).toNumericStatistics();
quartiles.quantile(0.75).get(); // 6.25 (type 7)
quartiles.quantile(0.75, { method: "type-1" }).get(); // 6 (nearest rank)
quartiles.interquartileRange().get(); // 3.5G1 and G2 are undefined below three and four observations, and the sample variance and standard
deviation are undefined below two under ddof = 1. That is reported as an empty Optional rather
than as a misleading zero:
useOf(1, 2, 3).toNumericStatistics().kurtosis().isEmpty(); // true (G2 needs four observations)
useOf(1, 2).toNumericStatistics().skewness().isEmpty(); // true (G1 needs three)
useOf(1).toNumericStatistics().variance().isEmpty(); // true (ddof = 1 needs two)
useOf(1).toNumericStatistics().variance({ ddof: 0 }).get(); // 0The bigint views accept the same options and round every result back to a whole bigint.
Windows re-expose the buffered elements as a pipeline of pipelines, so a window can be consumed without materialising it into an array first. Both forms include a final partial window.
useFrom([1, 2, 3, 4]).toWindow().slide(2n, 1n).toUnordered().toArray()
.map(window => window.toUnordered().toArray()); // [[1,2],[2,3],[3,4],[4]]
useFrom([1, 2, 3, 4]).toWindow().tumble(2n).toUnordered().toArray()
.map(window => window.toUnordered().toArray()); // [[1,2],[3,4]]An index is a bigint attached to every element. Operators either forward it, re-index it
from zero, or let you compute it.
Ordered collection normalises each index modulo the element count, then sorts by the result. That is
why translate(7) on five elements equals translate(2): 7 ≡ 2 (mod 5).
Unordered collection keeps whatever index the pipeline produced and returns elements in arrival order. Negative indices are legal and simply sort before non-negative ones once normalised.
AsynchronousSemantic mirrors the synchronous surface, returning promises. Writes are queued, so a
read issued after a write always observes it.
const ring = new AsynchronousRing([1, 2]);
const added = ring.add(3); // queued
const sampled = ring.elementAt(2n); // observes the write above
await added;
(await sampled).get().element; // 3Factories that are asynchronous by nature — usePromise, useBlob, useInterval,
useSubscription — return AsynchronousSemantic directly, and useFrom / useIterate upgrade
themselves when handed an AsyncIterable.
The mathematical half of the library treats a cycle as a first-class object rather than a lookup table. Three constructions work together.
A Ring is an n-fold cycle: the element count, the positional period and the number of distinct
phases are the same integer n. The phase of a position is computed from its exact residue, so the
ring is periodic to the bit:
import { SynchronousRing } from "semantic-typescript";
const ring = new SynchronousRing(["a", "b", "c", "d", "e", "f"]);
ring.phaseAt(0n).get().toDegreeValue(); // 0
ring.phaseAt(1n).get().toDegreeValue(); // 60
ring.phaseAt(6n).get().getValue() === ring.phaseAt(0n).get().getValue(); // true, exactly
ring.purity(); // 1
ring.isBalanced(); // true
ring.sequences().map(z => z.magnitude()); // [0, 6, 0, 0, 0, 0]frequency is the ambient rotation rate, not a phase step between positions: the environment
moves every phasor by the same angle, so it can never break the intrinsic pattern. span fixes how
many turns the ring occupies.
purity() is the share of energy carried by the fundamental mode,
max(|c₁|², |c_{n−1}|²) / (n · Σ|z|²). It is 1 exactly when the ring is the n-th roots of unity
up to rotation, and it costs O(n) because only two bins are needed. Both directions are tested
because a ring may traverse the circle either way.
A PhaseField maps a relative phase to a score in [-1, 1]:
import { useField, useRadian } from "semantic-typescript";
const cosine = useField([1]); // cos(2πΔ): +1 aligned, −1 opposed
const mixed = useField([0.4, 0.3, 0.3]); // first three harmonics
cosine.sample(useRadian(0)); // 1
cosine.sample(useRadian(Math.PI)); // -1The score is a truncated cosine series, a₀ + Σ wₘ·cos(2πmΔ), and the constructor enforces
|a₀| + Σ|wₘ| ≤ 1. By the triangle inequality the score can never leave [-1, 1] — the bound is a
consequence of the construction, not a clamp applied afterwards.
The reason the field is a series rather than a table: an n-point table reproduces its own samples
exactly, but its trigonometric interpolant overshoots between them. A five-point table scored
[+1, +0.5, −1, −0.5, 0] interpolates over [−1.0428, 1.0041], which already breaks the promise.
A bounded series cannot.
Each ring element is a reference phase. analyse locates every observation back on the ring and
measures it against each reference:
const ring = new SynchronousRing(["a", "b", "c", "d", "e"]);
const report = ring.analyse(["a", "c", "c", "a", "zz", "e"]);
report.matched; // 5
report.missed; // 1 ('zz' is not on the ring)
report.references.map(r => r.count); // [2, 0, 2, 0, 1]Each reference row reports count, the mean relative phase mean, and the mean resultant length
resultant. One identity is worth remembering: resultant is the same for every reference,
because changing the baseline only rotates all relative phases by a constant. What differs per
reference is the count and the mean.
const ring = new SynchronousRing(["a", "b", "c"]);
ring.scoresAt(0, cosine); // one score per position, in [-1, 1]| Call | Answers | Cost |
|---|---|---|
purity() |
is the ring still the n-th roots of unity? |
O(n) |
resultant(t?) |
how concentrated are the phasors right now? | O(n) |
analyse(data) |
how does this data sit against every element? | O(n · m) |
toSpectrum() |
the ring as a spectrum, wavenumber on the frequency axis | O(n²) |
useAngularSpectrum(ring, instants, plan) |
the (ω, n) plane over time |
O(n² · frames) |
Two more primitives round out the model: combineRings(a, b) joins two rings on their least common
multiple (a pullback, not a product — the pairs that cannot share an instant are simply absent), and
rate() gives each element its own intrinsic frequency so that a ring can drift against the
environment. jetAt, windingAt and errorIntegralAt expose the differential state of that drift.
The ring constructions are one half of the analytic surface; the other half works on sampled
signals. Its most reused primitive is the window, and every shape here has two forms. The symmetric
form divides the frame by count − 1; the periodic (DFT-even) form divides it by count, one
sample short of a full period, so the taper is continuous under the periodic extension the DFT
assumes.
useHannWindow(1, 4); // 0.75 symmetric
useHannPeriodicWindow(1, 4); // 0.5 periodic
coherentGain(useHannPeriodicWindow, 16); // 0.5
equivalentNoiseBandwidth(useHannPeriodicWindow, 16); // 1.5| Shape | Symmetric | Periodic | Notes |
|---|---|---|---|
| Rectangular | useRectangularWindow |
— | no taper; no periodic distinction |
| Hann | useHannWindow |
useHannPeriodicWindow |
the short-time default |
| Hamming | useHammingWindow |
useHammingPeriodicWindow |
— |
| Blackman | useBlackmanWindow |
useBlackmanPeriodicWindow |
— |
| Nuttall | useNuttallWindow |
useNuttallPeriodicWindow |
four-term Blackman–Harris |
| Flat-top | useFlatTopWindow |
useFlatTopPeriodicWindow |
amplitude accuracy over resolution |
| Kaiser | useKaiserWindow(beta) |
useKaiserPeriodicWindow(beta) |
beta sets the main-lobe width |
Use the periodic form for analysis and overlap-add: at a half-frame hop the periodic Hann window
sums to 1.000000 at every length, while the symmetric form ripples by 0.841 dB at N = 16 and
still by 0.013 dB at N = 1024. Keep the symmetric form for filter design, where the frame is not
one period of the taper.
The flat-top windows return negative weights near the ends of the frame, because that is what a flat-top window is. They are returned unchanged rather than clamped, because clamping perturbs the main lobe and destroys the low scalloping loss the window exists for:
useFlatTopWindow(0, 1024); // -0.000421 — negative by definition
Array.from({ length: 1024 }, (_unused, index) => useFlatTopWindow(index, 1024))
.filter(weight => weight < 0).length; // 578decibel is gone as a single function, because an amplitude and a power do not share a factor.
amplitudeDecibel applies 20·log10 to a voltage-like ratio; powerDecibel applies 10·log10 to a
power, an energy, a mean square or a power spectral density:
amplitudeDecibel(0.25); // -12.0412
powerDecibel(0.25); // -6.0206Bin.decibel and Spectrum.decibelOf are amplitude levels; Spectrum.powerDecibelOf is the
matching power level. Feeding a power to the amplitude form reports 20·log10(power) and doubles
the level in decibels.
Two spectrum measurements changed reading:
const twoBins = new Spectrum(new Sample(2, 1), [new Complex(2, 0), new Complex(1, 0)]);
twoBins.flatness(); // 0.8 — geometric over arithmetic mean of power
const shape = new Spectrum(new Sample(4, 1), [
new Complex(1, 0), new Complex(2, 0), new Complex(0, 0), new Complex(0, 0),
]);
shape.bandEnergy(new Frequency(0), new Frequency(2)); // 9
shape.bandEnergy(new Frequency(0), new Frequency(2), "two-sided"); // 5flatness() compares the geometric and arithmetic means of power() over the one-sided half of the
spectrum, not of magnitude over both halves, and the display-only amplitude doubling of
oneSided() is deliberately not applied. bandEnergy() defaults to the one-sided reading, folding
the conjugate half so that DC and Nyquist count once and every interior bin counts twice; a band
that covers the whole axis then satisfies Parseval, Σ|X|² = N·Σx². Pass "two-sided" for a
complex spectrum, whose bins are already independent.
Sampler.shortTimeFourierTransform and Sampler.spectrogram follow the SciPy stft conventions:
the periodic Hann window, a zero-filled boundary at both ends and a zero-filled tail.
const sampler = new Sampler(new Sample(64, 1));
const spectrogram = sampler.spectrogram(time => Math.cos(2 * Math.PI * 4 * time), 16, 8);
spectrogram.size(); // 9 frames
spectrogram.getBoundary(); // "zeros"
spectrogram.getTime(6); // 0.75 — frame centres, not frame starts
sampler.spectrogram(time => Math.cos(2 * Math.PI * 4 * time), 16, 8, useHannWindow,
{ boundary: "none", padded: false }).size(); // 7 — complete frames onlyThe extension centres the first frame on the first sample, and the tail padding keeps the last
samples reachable: 64 samples at frame 16 and hop 8 produce 9 frames instead of 7, and 1000 samples
at frame 256 and hop 128 produce 9 instead of 6. Frame times are centres — (k·hop + (frameSize mod 2)/2)/rate for an extended view, (k·hop + frameSize/2)/rate for an unextended one — and
toFrame() trims the extension before inverting.
structure/error.ts exports a fifteen-class hierarchy for the errors raised around a pipeline — by
your own code, a custom collector or an adapter:
SemanticError
├── IllegalOperationError
├── ValidationError
│ ├── IndexError
│ ├── RangeError (also exported as SemanticRangeError)
│ └── ConfigurationError
├── CapacityError
├── StateError
│ └── IllegalStateError
├── ConcurrencyError
├── CollectionError
├── SerializationError
├── NumericError
│ └── ArithmeticError
└── PhaseError
Each class fixes name, forwards the native cause, carries an optional context bag and
serialises through toJSON(); each has a brand guard named after it.
const error = new CapacityError("LRU capacity exhausted", { context: { capacity: 0 } });
error.name; // "CapacityError"
isCapacityError(error); // true
isValidationError(error); // false — a capacity failure is not a validation failure
isSemanticError(error); // true
error.toJSON(); // { name: "CapacityError", message: "…", context: { capacity: 0 } }SemanticRangeError is the same class as the exported RangeError, so instanceof holds across
both names; importing RangeError shadows the global constructor for the whole module, which is why
the alias exists. Neither class is thrown by the library's own argument checks, which keep raising
native TypeError and RangeError — the hierarchy is the vocabulary for the layer above.
| Helper | Purpose |
|---|---|
useCompare(a, b) |
Total ordering across mixed primitives |
useToNumber / useToBigInt |
Lossless, range-checked numeric conversion |
useRandom |
Seeded or unseeded random helpers |
useTraverse |
Structural walk of nested objects and arrays |
useStringify |
Circular-safe JSON serialisation |
useHash / maskOf / register / unregister |
Structural hashing and the hash strategy registry |
invalidate / validate |
Guard clauses that raise a consistent error shape |
Optional<T> |
The library's null replacement: Optional.of(x) / Optional.empty() |
useTraverse(target, callback) flattens any object into its non-null leaves. It descends nested
objects, walks iterable values by position, skips null and undefined, and never re-enters an
object it has already visited — so an object graph with a cycle terminates instead of overflowing
the stack.
const source = { id: 7, tags: ["a", "b"], owner: { name: "Ada", note: null } };
const leaves: string[] = [];
useTraverse(source, (key, value, path) => {
leaves.push(`${path.join(".")} = ${String(value)}`);
return true;
});
leaves; // ["id = 7", "tags.0 = a", "tags.1 = b", "owner.name = Ada"]The callback runs once per leaf and receives the leaf's own key (its index when the leaf came from
an iterable), the value, and the path that reached it. Returning false abandons the whole
traversal, not just the current branch:
let visited = 0;
useTraverse({ a: 1, b: 2, c: 3 }, () => {
visited += 1;
return false;
});
visited; // 1 — the first leaf already stops the walkA cycle is safe for the same reason:
const cyclic: any = { name: "root", child: { name: "leaf" } };
cyclic.child.parent = cyclic;
const names: string[] = [];
useTraverse(cyclic, (_key, value) => {
if (typeof value === "string") names.push(value);
return true;
});
names; // ["root", "leaf"] — the back edge to `cyclic` is not revisitedEach callback receives its own path snapshot. The walk keeps a single working array internally and, at the moment a leaf is handed over, copies it into a fresh array frozen with Object.freeze. The snapshot never changes afterwards, so it can be stored as it is, two callbacks never share one, and re-running the traversal does not disturb snapshots already handed out. Its type is ReadonlyArray, so writing to it is a compile error, and the frozen array also throws a TypeError at runtime. The safety costs one O(depth) allocation per reported leaf, and a branch that reports nothing allocates nothing extra.
const paths: Array<ReadonlyArray<PropertyKey>> = [];
useTraverse({ a: { b: 1 }, c: 2 }, (_key, _value, path) => {
paths.push(path); // safe to keep as-is
return true;
});
paths[0] === paths[1]; // false — one array per call
paths.map(path => path.join(".")); // ["a.b", "c"]useAttribute is this walk turned into a factory: it reports the same leaves as { key, value }
pairs, so the rules — and the cost — are identical.
useAttribute({ a: 1, b: { c: 2 } }).toUnordered().toArray();
// [{ key: "a", value: 1 }, { key: "c", value: 2 }]Laziness has a price per stage. Each operator adds a generator hop. For tight numeric loops over
in-memory arrays, a plain for loop will beat any pipeline; the pipeline wins when the source is
lazy, infinite, or the pipeline is long enough for the index discipline to pay off.
toOrdered() sorts; toUnordered() does not. If a reversal seems to have "not worked", the
collector was unordered — index rewriting only becomes an order when indices are normalised.
useGenerate without an interrupt never ends. Always supply a stopping predicate or a
downstream limit / takeWhile.
Ring is an insertion-ordered set. Elements are unique and keep the order in which they were
first added. A duplicate add returns false, and a set that would introduce one throws. This is
what lets a phase be a function of the element rather than of whichever position it happens to
occupy.
Spectrum assumes a real signal in its one-sided helpers. oneSided(), getNyquist() and
centroid() assume conjugate symmetry. The spatial spectrum of a phase ring is complex and in
general not symmetric — a ring that traverses the circle backwards puts all of its energy in bin
n − 1, beyond Nyquist. Read the bins directly.
Structure collectors are terminals, not views. Each one drains the pipeline and then builds a full structure, so it costs O(n) time and O(n) space and cannot run on an infinite source. Reach for one at the end of a pipeline, never inside a loop over one.
variance() and friends return an Optional. variance().get() on a sample that is too small
throws; use .orElse(0) or check .isPresent() when the sample size is not under your control.
The same applies to standardDeviation, quantile, interquartileRange, skewness and
kurtosis.
The periodic windows are the analysis windows. useHannWindow and the other symmetric forms are
for filter design; a short-time transform wants useHannPeriodicWindow and its siblings, or the
overlap-add sum will ripple wherever the frames meet.
Chapter 10 introduced the ring as the library's model of a cycle and used it to measure a data set against every phase. This chapter is the complete surface: how a position becomes an angle, what the ambient frequency does and does not change, and the instruments that read the ring against time, against data and against another ring.
A SynchronousRing is a positional list backed by a circular doubly linked ring whose head and
tail are joined. It is also an insertion-ordered set: no element appears twice, and a rejected
write leaves both the links and the samples untouched. What makes it a ring rather than a list is
that the phase of a position is a pure function of its residue modulo the current length, so the
ring is periodic to the bit and its balance cannot drift.
A ring carries n elements, and the element count, the positional period and the number of
distinct phases are the same integer. Position i carries
phase + span · 2π · (((i mod n) + n) mod n) / n
with the residue reduced in bigint before it ever becomes a number. That is the whole reason
phaseAt(i) and phaseAt(i + n) are identical rather than merely close: the second call
reduces to the same integer, evaluates the same product and produces the same double. An index
far beyond the exact integer range of number still lands on the right position, and a negative
index walks the ring backwards.
| Call | Returns | Meaning |
|---|---|---|
new SynchronousRing(source, options?) |
ring | source is any iterable, duplicates dropped |
getPhase() / getFrequency() / getSpan() |
Angle, Frequency, number |
the three construction parameters |
size() |
bigint |
the n that is also the period |
phaseAt(index) |
Optional<Angle> |
intrinsic phase only; ignores time, rate and frequency |
elementAt(index) |
Optional<RingElement> |
element, intrinsic phase, ambient frequency, phasor |
frequencyAt(index) |
Optional<Frequency> |
the ambient rate, empty for an empty ring |
toComplex() / toComplex(index) |
Complex[] / Optional<Complex> |
intrinsic phasors |
RingOptions adds two per-element functions: magnitude(element, index) gives the phasor length
and defaults to one, and rate(element, index) gives the element its own intrinsic frequency and
defaults to zero, a fixed marker.
import { SynchronousRing } from "semantic-typescript";
const ring = new SynchronousRing(["a", "b", "c", "d", "e", "f"]);
ring.size(); // 6n
ring.phaseAt(0n).get().toDegreeValue(); // 0
ring.phaseAt(1n).get().toDegreeValue(); // 60
ring.phaseAt(-1n).get().toDegreeValue(); // 300
ring.phaseAt(6n).get().getValue() === ring.phaseAt(0n).get().getValue(); // true
ring.phaseAt(10n ** 20n).get().toDegreeValue(); // 240phaseAt(-1n) is 300 on a six-element ring, and 10n ** 20n reduces to 4 modulo six, so it
reads 240. Neither case accumulates a floating-point product, which is what keeps the identity
exact under a change of index representation.
frequency is the ambient rotation rate of the environment the ring is embedded in, not a
phase step between positions. The environment advances the phase of every element by the same
angle, so it cannot change the intrinsic pattern, cannot break the balance and cannot change any
phaseAt result. It defaults to 0 Hz.
relativeAt(index, time) subtracts that common angle, leaving the coordinate a PhaseField is
scored against. viewAt and viewsAt return the full state at an instant, including the
element's own rate, and the differential accessors expose the drift:
| Call | Formula | Reads |
|---|---|---|
relativeAt(index, time) |
intrinsic + ownRate · t − ambient · t |
the phase the environment sees |
viewAt(index, time) / viewsAt(time) |
the same, plus element, index, time, rate, phasor | the full state |
jetAt(index, time) |
phase at t, own rate, slip = ownRate − ambient |
the differential state |
windingAt(index, time) |
trunc(slip · t) |
whole turns slipped, truncated toward zero |
errorIntegralAt(index, time, origin?) |
∫ relative phase dt, in turns · seconds |
how long an element has been ahead |
resultant(time?) |
abs(Σ w · e^{iθ}) / Σ w |
phase concentration, unaffected by ambient rotation |
analyse(data, time?) |
counts and means per reference | data measured against every element |
scoreAt(index, time, field) / scoresAt(time, field) |
the field sampled at the relative phase | a bounded score per position |
import { SynchronousRing, useHertz } from "semantic-typescript";
const moving = new SynchronousRing(["a", "b", "c", "d", "e", "f"], {
frequency: useHertz(1),
});
moving.getFrequency().getHertz(); // 1
moving.phaseAt(1n).get().toDegreeValue(); // 60 — intrinsic
moving.relativeAt(1n, 0.25).get().toDegreeValue(); // 330 — 60 − 90
moving.viewAt(1n, 0.25).get().relative.toDegreeValue(); // 330
moving.jetAt(1n, 0.25).get().slip; // -1
moving.purity(); // 1 — rotation cannot break itAt 1 Hz the environment advances a quarter turn in 0.25 s, so the relative phase of position
one is 60 − 90 = 330 degrees while its intrinsic phase stays 60. The jet reports slip of
-1 hertz because the element itself does not move, and purity() is still 1: rotating every
phasor by the same angle is not a deformation.
purity() is the share of the ring's energy carried by the fundamental mode:
max(abs(c₁)², abs(c_{n−1})²) / (n · Σ abs(z)²)
Both fundamental bins are tested because a ring may traverse the circle in either direction. A
ring built with span: 5 over six elements steps by 300 degrees, which is −60 degrees: it
runs backwards, puts all of its energy in c₅, and would be reported as impure by a formula that
only looked at c₁. The cost stays O(n) because only those two bins and the total energy are
needed.
The edge cases are part of the contract. A ring of fewer than two elements has no fundamental
mode and reports purity() === 0 and isBalanced() === false, whatever its content. A ring whose
phasors all have zero length has no energy and also reports zero. isBalanced(epsilon?) is
1 − purity() <= epsilon, with 1E-9 as the default tolerance.
import { SynchronousRing } from "semantic-typescript";
const forward = new SynchronousRing(["a", "b", "c", "d", "e", "f"]);
const backward = new SynchronousRing(["a", "b", "c", "d", "e", "f"], { span: 5 });
const weighted = new SynchronousRing(["a", "b", "c", "d"], {
magnitude: element => element === "a" ? 1 : 0,
});
forward.purity(); // 1
backward.phaseAt(1n).get().toDegreeValue(); // 300 — it runs backwards
backward.purity(); // 1 — the energy sits in c₅
new SynchronousRing(["only"]).purity(); // 0
new SynchronousRing(["only"]).isBalanced(); // false
weighted.purity(); // 0.25resultant(time?) is the mean resultant length: the magnitude of the phasor sum divided by the
sum of the magnitudes. Unlike purity, which asks whether the ring is still the n-th roots of
unity, resultant asks how concentrated the phasors are right now. A balanced ring reports 0;
a ring whose elements all share one angle would report 1, but the set semantics forbid duplicate
elements, so in practice 1 appears when magnitude zeroes all but one element.
zeroSequence() is the mean phasor, that is c₀ / n. sequences() returns all n symmetric
components, lowest order first, by a direct transform. toSpectrum() wraps them in a Spectrum
whose bin j reports j cycles per ring revolution, so the frequency axis is the wavenumber
axis.
import { SynchronousRing } from "semantic-typescript";
const round = (value: number): number => Number(value.toFixed(4));
const ring = new SynchronousRing(["a", "b", "c", "d", "e", "f"]);
round(ring.resultant().get()); // 0
round(ring.zeroSequence().get().magnitude()); // 0
ring.sequences().map(component => round(component.magnitude())); // [0, 6, 0, 0, 0, 0]
ring.toSpectrum().get().size(); // 6
ring.toSpectrum().get().getResolution().getHertz(); // 1The components are complex and in general not conjugate-symmetric: a balanced ring puts all
of its energy in bin one, and a backwards ring puts it in bin n − 1. The one-sided helpers of
Spectrum assume a real signal, so read the bins directly instead of folding them.
analyse(data, time?) matches every observation back to the ring by identity and measures it
against each element as a reference phase. Observations the ring does not hold are counted as
missed rather than guessed at, because the ring is the only source of phases. Each reference
row reports count, the mean relative phase mean, and the mean resultant length resultant.
One identity is worth remembering: resultant is the same for every reference. Changing the
baseline rotates all relative phases by a constant, and a rotation cannot change a resultant
length. What differs per reference is the count and the mean.
import { SynchronousRing } from "semantic-typescript";
const round = (value: number): number => Number(value.toFixed(4));
const ring = new SynchronousRing(["a", "b", "c", "d", "e"]);
const report = ring.analyse(["a", "c", "c", "a", "zz", "e"]);
report.matched; // 5
report.missed; // 1
report.references.map(row => row.count); // [2, 0, 2, 0, 1]
report.references.map(row => round(row.resultant)); // [0.1453, 0.1453, 0.1453, 0.1453, 0.1453]Scoring is the other side of the same coin. A PhaseField is a bounded cosine series (chapter
10.2), and scoreAt/scoresAt sample it at the relative phase, so the same ring can be scored
against alignment, against a harmonic pattern, or against any other field without changing the
ring.
import { SynchronousRing, useField } from "semantic-typescript";
const round = (value: number): number => Number(value.toFixed(4));
const ring = new SynchronousRing(["a", "b", "c", "d", "e", "f"]);
const cosine = useField([1]);
round(ring.scoreAt(0n, 0, cosine).get()); // 1
round(ring.scoreAt(1n, 0, cosine).get()); // 0.5
ring.scoresAt(0, cosine).map(score => round(score)); // [1, 0.5, -0.5, -1, -0.5, 0.5]combineRings(left, right) joins two rings on their least common multiple, not their product.
Instant i of the joint ring reads i mod m of the left ring and i mod n of the right ring,
so the joint ring closes after lcm(m, n) instants. The pairs that can never co-occur — those
whose positions disagree modulo gcd(m, n) — are simply absent, which is why the result is a
pullback rather than a product; aligns(left, right) reports whether a given pair is
representable.
useAngularSpectrum(ring, instants, plan) builds the (ω, n) plane: each instant contributes a
frame of phasors, frame k of mode j is the j-th symmetric component of that frame, and
cells[frame][order] is the coefficient. Taking one column gives the time series of a single
spatial mode, and spectrum(order) is its temporal spectrum.
import { Sample, SynchronousRing, combineRings, useAngularSpectrum } from "semantic-typescript";
const left = new SynchronousRing(["a", "b", "c", "d"]);
const right = new SynchronousRing(["x", "y", "z", "w", "v", "u"]);
const joined = combineRings(left, right);
joined.period; // 12n
joined.divisor; // 2n
joined.ring.size(); // 12n
joined.aligns(0n, 0n); // true
joined.aligns(1n, 0n); // false
joined.ring.get(0n).join("-"); // a-x
const angular = useAngularSpectrum(left, [0, 0.25, 0.5, 0.75], new Sample(4, 1));
angular.order; // 4
angular.frameCount; // 4
angular.cells[0][1].magnitude(); // 4
angular.spectrum(1).get().size(); // 4Exactness is exact. phaseAt(i) and phaseAt(i + n) are the same double, not two values
within a tolerance. A ring built by accumulating 2π / n per element would fail this; this one
reduces the index in bigint first.
frequency is not a phase step. It is the ambient rotation rate, it defaults to zero, and
rotating every phasor by the same angle cannot break the intrinsic pattern. If you want positions
to advance relative to each other, use rate, not frequency.
purity() must look at both fundamental bins. A backwards ring is still the n-th roots of
unity; only c₁ would call it impure.
n < 2 is a defined edge case. purity() returns 0 and isBalanced() returns false; an
empty ring returns empty optionals from the sampling methods.
A span that shares a factor with n collapses the orbit. Six elements with span: 2 visit
only three distinct phases, and the ring is no longer balanced.
resultant is rotation-invariant, not time-invariant. Ambient rotation leaves it unchanged;
per-element rates change it as the ring de-phases.
The ring is a set, and analyse matches by identity. Duplicate elements are dropped at
construction, and an object that is structurally equal to a ring element but not the same
reference is counted as missed.
toSpectrum() is complex. Its bins are not conjugate-symmetric, so the one-sided helpers of
Spectrum do not apply. Read the bins.
windingAt truncates toward zero, and errorIntegralAt integrates phase. Half a turn of slip
reports 0n; the error integral is in turns · seconds, not a rate.
import { SynchronousRing, useHertz } from "semantic-typescript";
const collapsed = new SynchronousRing(["a", "b", "c", "d", "e", "f"], { span: 2 });
collapsed.phaseAt(1n).get().toDegreeValue(); // 120
collapsed.phaseAt(3n).get().toDegreeValue(); // 0
Number(collapsed.purity().toFixed(4)); // 0
const falling = new SynchronousRing(["a", "b", "c", "d"], {
frequency: useHertz(2),
rate: () => useHertz(0.5),
});
falling.jetAt(0n, 2).get().slip; // -1.5
falling.windingAt(0n, 1).get(); // -1n
falling.windingAt(0n, 0.5).get(); // 0n
const objects = new SynchronousRing([{ key: 1 }, { key: 2 }]);
objects.analyse([{ key: 1 }]).missed; // 1 — identity, not structureChapter 10 treated a ring as a cycle of phases. This chapter is the temporal counterpart: a real-valued record that carries, besides its samples, the two pieces of context every spectral formula needs — the sampling plan that turns a bin index into hertz, and the taper that fixes both the coherent gain and the noise bandwidth.
The value object is SynchronousSignalCollectable, with AsynchronousSignalCollectable as its
queued twin. Both expose the same SignalCollectable surface: twenty-three methods, each delegating
to exactly one useSynchronous<Method> factory in synchronous/collectors. The split is
deliberate. The class is a documented facade, the factory owns the accumulation, and the constructor
caches sum(w) / count and count * sum(w^2) / sum(w)^2 once, at this length. A caller who has to
remember "divide by the coherent gain" will eventually forget, and the resulting 6.02 decibels look
like a plausible measurement.
A signal is built from an iterable and a rate, from an existing Frame, or from the constructor
with an explicit Sample. The default taper is the periodic Hann window; every analysis that does
not name a window uses it, and every analysis accepts an override. The metadata accessors are
constant-time reads of state fixed at construction, so a signal can be handed around without the
context drifting.
| Call | Returns | Meaning |
|---|---|---|
SynchronousSignalCollectable.useRate(values, rate, window?) |
signal | duration count / rate |
SynchronousSignalCollectable.useFrame(frame, window?) |
signal | keeps the frame's plan |
new SynchronousSignalCollectable(plan, values, window?) |
signal | plan count must equal the sample count |
getSample() / getValues() / getWindow() |
plan, samples, taper | the frozen context |
getRate() |
number |
samples per second |
getNyquist() / getResolution() |
Frequency |
rate / 2 and rate / count |
getCoherentGain() / getNoiseBandwidth() |
number |
recomputed at this exact length |
size() / at(index) / toFrame() / equals(other) / toString() |
— | value semantics |
import { SynchronousSignalCollectable } from "semantic-typescript";
const signal = SynchronousSignalCollectable.useRate(
Array.from({ length: 64 }, (_unused, index) => Math.cos(2 * Math.PI * 8 * index / 64)),
64,
);
signal.size(); // 64n
signal.getRate(); // 64
signal.getNyquist().getHertz(); // 32
signal.getResolution().getHertz(); // 1
signal.getCoherentGain(); // 0.5
signal.getNoiseBandwidth(); // 1.5getResolution() is the bin spacing, not the resolving power: it is rate / count because the
transform has count bins, and padding the transform changes the spacing without changing what the
window can separate (section 14.8).
The methods fall into six families. Seven are spectral readings, two are time–frequency transforms, four are analytic quantities, three are sequence statistics, five are shape descriptors, and two return another signal.
| Method | Returns | Convention |
|---|---|---|
spectrum(size?, window?) |
Spectrum |
raw and unnormalised, full complex grid |
oneSided(size?, window?) |
Bin[] |
conjugate half folded, DC and Nyquist not doubled |
amplitude(window?) |
number[] |
2·abs(X) / (count · CG), once at DC and Nyquist |
power(window?) |
number[] |
A² / 2 per interior bin, A² at DC and Nyquist |
decibel(window?) |
number[] |
20·log10(amplitude) |
powerDecibel(window?) |
number[] |
10·log10(power) |
powerSpectralDensity(frameSize?, hopSize?, window?) |
SpectralDensity |
Welch average, abs(X)² / (rate · Σw²) |
shortTimeFourierTransform(frameSize, hopSize, window?) |
Spectrum[] |
extended boundaries, centre times |
spectrogram(frameSize, hopSize, window?) |
Spectrogram |
the same columns with a time axis |
analyticSignal() |
Complex[] |
the Hilbert pair x + i·H(x) |
instantaneousPhase() |
Angle[] |
folded into [0, 2π) |
instantaneousFrequency(rate?, gate?) |
number[] |
unwrapped phase difference, gated on the envelope |
envelope() |
number[] |
abs(z[n]), circular at the edges |
zeroCrossingRate(perSecond?) |
number |
crossings per adjacent sample pair |
spectralFlux(frameSize?, hopSize?, window?) |
number[] |
unit-norm magnitude distance between frames |
autocorrelation(maximumLag?, normalization?) |
number[] |
coefficient by default, ρ[0] = 1 |
spectralCentroid(window?) |
Optional<Frequency> |
power-weighted mean frequency |
spectralBandwidth(window?) |
Optional<Frequency> |
power-weighted standard deviation |
spectralRolloff(ratio?, window?) |
Optional<Frequency> |
first bin reaching ratio of the power |
spectralFlatness(window?) |
number |
geometric over arithmetic mean of power |
signalToNoiseRatio(frameSize?, hopSize?, window?, floorQuantile?) |
Optional<number> |
median noise floor, 10·log10 |
detrended(mode?) |
signal | "none", "constant" (default), "linear" |
windowed(window?) |
signal | applies a taper and adopts it |
The optional arguments are not decoration. frameSize defaults to min(256, size), the hop to
half a frame rounded down, instantaneousFrequency gates at 1E-3 of the peak envelope, and
spectralRolloff uses 0.85. Each default is the value a reference implementation picks when the
parameter is named rather than supplied.
Three normalisations are needed because three different questions are being asked.
amplitude() answers "how large is the sinusoid on this bin": 2·abs(X[k]) / (count · CG) for an
interior bin and abs(X[k]) / (count · CG) at DC and Nyquist. The factor of two folds the
conjugate half; the coherent gain removes the taper. Omit either and a unit tone reads 0.5,
which is minus 6.02 decibels.
power() answers "what mean square would a sinusoid on this bin have": A² / 2 for an interior
bin and A² at DC and Nyquist, because those two do not oscillate. This is the peak convention,
and it deliberately does not satisfy Parseval: a window spreads each line over its main lobe, so
the sum of every bin exceeds the mean square by the equivalent noise bandwidth — 1.5 for Hann.
powerSpectralDensity() answers "how much power per hertz": abs(X)² / (rate · Σw²), averaged
over overlapping segments. Only the sum of the squared weights keeps a white-noise floor unbiased;
dividing by count reads 0.375 for unit noise and dividing by count · CG² reads 1.5.
import { SynchronousSignalCollectable } from "semantic-typescript";
const round = (value: number): number => Number(value.toFixed(4));
const tone = SynchronousSignalCollectable.useRate(
Array.from({ length: 256 }, (_unused, index) => Math.cos(2 * Math.PI * 32 * index / 256)),
256,
);
round(tone.amplitude()[32]); // 1
round(tone.power()[32]); // 0.5
round(tone.power().reduce((sum, power) => sum + power, 0)); // 0.75
round(tone.powerSpectralDensity(64n, 32n).totalPower()); // 0.5
tone.powerSpectralDensity(64n, 32n).getSegments(); // 7nThe pair of totals is the check worth remembering: the peak-convention sum is 0.75 while the
density integrates to 0.5, and the ratio is exactly the noise bandwidth. The density is also the
only one of the three that survives a change of frame length.
An amplitude and a power do not share a factor, so they do not share a logarithm. amplitudeDecibel
applies 20·log10 and is correct for a voltage, a displacement or a sound pressure; powerDecibel
applies 10·log10 and is correct for a power, an energy, a mean square or a density. The method
names on the signal follow the same split.
import { amplitudeDecibel, powerDecibel, SynchronousSignalCollectable } from "semantic-typescript";
const round = (value: number): number => Number(value.toFixed(4));
const tone = SynchronousSignalCollectable.useRate(
Array.from({ length: 256 }, (_unused, index) => Math.cos(2 * Math.PI * 32 * index / 256)),
256,
);
amplitudeDecibel(0.25); // -12.0412
powerDecibel(0.25); // -6.0206
round(tone.decibel()[32]); // 0
round(tone.powerDecibel()[32]); // -3.0103Both readings of the same tone are correct: its amplitude is one (zero decibels), and its mean square is one half (minus 3.01 decibels). Feeding a power to the amplitude convention would double the level in decibels, turning the correct minus 6.02 into minus 12.04.
A real signal satisfies X[count − k] = conj(X[k]), so the negative-frequency half carries no
independent information. The fold doubles every interior bin and keeps the two self-conjugate bins
— DC and, for an even length, Nyquist — exactly as the transform produced them. Those two are their
own mirror image; doubling them invents energy and reports exactly 6.02 decibels too high.
import { SynchronousSignalCollectable } from "semantic-typescript";
const round = (value: number): number => Number(value.toFixed(4));
const edge = SynchronousSignalCollectable.useRate(
Array.from({ length: 16 }, (_unused, index) => 2 + Math.cos(Math.PI * index)),
16,
);
const bins = edge.oneSided();
bins.length; // 9
round(bins[0].magnitude()); // 16
round(bins[8].magnitude()); // 8
round(edge.amplitude()[0]); // 2
round(edge.amplitude()[8]); // 1The raw magnitudes are 16 and 8 because the taper's weights sum to eight; dividing by
count · CG = 8 turns them into the calibrated amplitudes 2 and 1. Doubling the edge bins
would read 4 and 2, and the error would never raise an exception.
shortTimeFourierTransform and spectrogram frame the record the way the reference short-time
transform does: half a frame of zeros at each end, then a zero-padded tail so that a whole number
of hops remains. The first frame is centred on the first sample, the last frame reaches the last
sample, and frame m is centred at m · hop / rate seconds. Retaining only complete frames would
discard up to frameSize − 1 samples — for 1000 samples at frame 256 and hop 128, that is 104
samples, about a tenth of the record.
import { SynchronousSignalCollectable } from "semantic-typescript";
const tone = SynchronousSignalCollectable.useRate(
Array.from({ length: 64 }, (_unused, index) => Math.cos(2 * Math.PI * 4 * index / 64)),
64,
);
const spectrogram = tone.spectrogram(16n, 8n);
spectrogram.size(); // 9
spectrogram.getFrameSize(); // 16
spectrogram.getBoundary(); // "zeros"
spectrogram.getTime(0); // 0
spectrogram.getTime(6); // 0.75
tone.shortTimeFourierTransform(16n, 8n).length; // 9
tone.spectralFlux(16n, 8n).length; // 8Every column is a Spectrum over frameSize bins, exactly as spectrum() would produce for the
same frame, and getTime() reports frame centres rather than frame starts.
Every shape has two forms. The symmetric form divides the frame by count − 1; the periodic
(DFT-even) form divides it by count, one sample short of a full period, so the taper is continuous
under the periodic extension the DFT assumes. Analysis and overlap-add need the periodic form;
filter design keeps the symmetric one.
The difference is measurable and never raises an error. At fifty per cent overlap the periodic Hann
window sums to one at every position; the symmetric form ripples, by 0.84 decibels at sixteen
samples, 0.21 at sixty-four and still 0.013 at one thousand and twenty-four. The class exposes
the coherent gain and the equivalent noise bandwidth of whichever taper it holds, so the correction
is never hard-coded: a symmetric Hann reads 0.4375 at eight samples, not 0.5.
import {
coherentGain, equivalentNoiseBandwidth, useFlatTopWindow,
useHannPeriodicWindow, useHannWindow,
} from "semantic-typescript";
const round = (value: number): number => Number(value.toFixed(4));
round(useHannWindow(1, 4)); // 0.75
round(useHannPeriodicWindow(1, 4)); // 0.5
coherentGain(useHannPeriodicWindow, 16); // 0.5
equivalentNoiseBandwidth(useHannPeriodicWindow, 16); // 1.5
useFlatTopWindow(0, 1024); // -0.000421
Array.from({ length: 1024 }, (_unused, i) => useFlatTopWindow(i, 1024)).filter(w => w < 0).length; // 578The flat-top row is not a mistake in the table. A flat-top window is partially negative by definition — 578 of 1024 weights here — and clamping those weights to zero would perturb the main lobe and destroy the low scalloping loss the window exists for. The library returns the series unchanged.
Zero padding interpolates, it never resolves. A transform length above the sample count samples
the same underlying DTFT more densely: the peak location becomes finer and the bin spacing
becomes rate / size, but the ability to separate two components is still governed by the window
length and shape. A tone exactly between two bins reads 82.1093 and 80.8598 on the unpadded
grid, and 128 at its own frequency on the padded grid.
import { SynchronousSignalCollectable, useRectangularWindow } from "semantic-typescript";
const round = (value: number): number => Number(value.toFixed(4));
const tone = SynchronousSignalCollectable.useRate(
Array.from({ length: 256 }, (_unused, index) => Math.cos(2 * Math.PI * 100.5 * index / 256)),
256,
);
const flat = tone.spectrum(256n, useRectangularWindow);
const padded = tone.spectrum(1024n, useRectangularWindow);
flat.getResolution().getHertz(); // 1
padded.getResolution().getHertz(); // 0.25
round(flat.magnitudeAt(100).get()); // 82.1093
round(flat.magnitudeAt(101).get()); // 80.8598
round(padded.magnitudeAt(402).get()); // 128
padded.frequencyAt(402).get().getHertz(); // 100.5Two equal tones 0.4 bins apart stay merged at any padding: with a 256-point record and a Hann
window, padding to 8192 produces a single local maximum, not two.
The Hilbert transform is circular. analyticSignal, envelope and instantaneousPhase treat
the first and last samples as neighbours, so a record whose period does not divide its length is
contaminated at both ends. The middle of the record is trustworthy; the ends are not.
import { SynchronousSignalCollectable } from "semantic-typescript";
const round = (value: number): number => Number(value.toFixed(4));
const edge = SynchronousSignalCollectable.useRate(
Array.from({ length: 512 }, (_unused, index) => Math.cos(2 * Math.PI * 8.3 * index / 512)),
512,
);
const envelope = edge.envelope();
round(envelope.slice(8, -8).reduce((sum, value) => sum + value, 0) / 496); // 1.0025
round(Math.max(...envelope.slice(0, 8))); // 1.2592
round(Math.min(...envelope.slice(-8))); // 0.1314A vanishing envelope does not define a phase. Where the envelope collapses, the difference of
two rounding-noise angles can be half a turn, which reads as half the sampling rate. The gate is
what keeps the number meaningful, and the default is 1E-3 of the peak envelope.
import { SynchronousSignalCollectable } from "semantic-typescript";
const round = (value: number): number => Number(value.toFixed(4));
const beat = SynchronousSignalCollectable.useRate(
Array.from({ length: 4096 }, (_unused, index) =>
Math.cos(2 * Math.PI * 100 * index / 2048) + Math.cos(2 * Math.PI * 102 * index / 2048)),
2048,
);
round(Math.max(...beat.instantaneousFrequency(2048, 0).map(Math.abs))); // 920.218
round(Math.max(...beat.instantaneousFrequency().map(Math.abs))); // 101An unnormalised correlation is not a similarity. The raw sum r[k] grows with the record
length, so two lengths cannot be compared. The default "coefficient" rule divides by r[0] and
keeps every value in [-1, 1]; "unbiased" divides by count − k and pays with a variance that
grows with the lag.
import { SynchronousSignalCollectable } from "semantic-typescript";
const round = (value: number): number => Number(value.toFixed(4));
const periodic = SynchronousSignalCollectable.useRate(
Array.from({ length: 1024 }, (_unused, index) => Math.cos(2 * Math.PI * 8 * index / 1024)),
1024,
);
const rho = periodic.autocorrelation(200n);
round(rho[0]); // 1
round(rho[128]); // 0.875
round(rho[64]); // -0.9375
round(periodic.autocorrelation(200n, "none")[0]); // 512One more reading deserves a warning: spectralFlux normalises each frame before comparing, so a
level change alone produces zero flux. Its first and last values are edge artefacts of the zero
boundary, not onsets.
MIT © Eloy Kim