semantic-typescript 2.0.0 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +587 -574
- package/dist/asynchronous/collectable.js +10 -4
- package/dist/optional.js +0 -1
- package/dist/readonly-map.d.ts +1 -0
- package/dist/readonly-map.js +26 -0
- package/dist/synchronous/collectable.js +2 -1
- package/package.json +1 -1
- package/readme.cn.md +1271 -1271
- package/readme.de.md +1397 -1397
- package/readme.es.md +1381 -1381
- package/readme.fr.md +1390 -1390
- package/readme.jp.md +1300 -1300
- package/readme.kr.md +1297 -1297
- package/readme.md +1442 -1442
- package/readme.ru.md +1378 -1378
- package/readme.tw.md +1271 -1271
package/readme.md
CHANGED
|
@@ -1,1442 +1,1442 @@
|
|
|
1
|
-
# **Semantic-TypeScript**
|
|
2
|
-
|
|
3
|
-
**Flow, Indexed.** Your data, under precise control.
|
|
4
|
-
|
|
5
|
-
English · [简体中文](./readme.cn.md) · [繁體中文](./readme.tw.md) · [日本語](./readme.jp.md) · [한국어](./readme.kr.md) · [Deutsch](./readme.de.md) · [Français](./readme.fr.md) · [Español](./readme.es.md) · [Русский](./readme.ru.md)
|
|
6
|
-
|
|
7
|
-
A type-safe stream processing library that unifies **JavaScript generators**, **Java Stream** and
|
|
8
|
-
**index-based data addressing**. Every element carries a `bigint` index, every pipeline is lazy, and
|
|
9
|
-
every terminal operation is an explicit opt-in.
|
|
10
|
-
|
|
11
|
-
```bash
|
|
12
|
-
npm install semantic-typescript
|
|
13
|
-
```
|
|
14
|
-
|
|
15
|
-
- Zero runtime dependencies, pure ESM, ships its own types.
|
|
16
|
-
- 684 regression tests: `npm test`.
|
|
17
|
-
- Full change history: [CHANGELOG.md](./CHANGELOG.md).
|
|
18
|
-
|
|
19
|
-
---
|
|
20
|
-
|
|
21
|
-
## Table of Contents
|
|
22
|
-
|
|
23
|
-
1. [Design Philosophy](#1-design-philosophy)
|
|
24
|
-
2. [Architecture](#2-architecture)
|
|
25
|
-
3. [Quick Start](#3-quick-start)
|
|
26
|
-
4. [Factory Functions](#4-factory-functions)
|
|
27
|
-
5. [Transformation Operators](#5-transformation-operators)
|
|
28
|
-
6. [Collectables and Terminal Operations](#6-collectables-and-terminal-operations)
|
|
29
|
-
7. [Statistics and Windows](#7-statistics-and-windows)
|
|
30
|
-
8. [The Index Model](#8-the-index-model)
|
|
31
|
-
9. [The Asynchronous Model](#9-the-asynchronous-model)
|
|
32
|
-
10. [Periodic Analysis: Rings, Fields and Scores](#10-periodic-analysis-rings-fields-and-scores)
|
|
33
|
-
11. [Utility Helpers](#11-utility-helpers)
|
|
34
|
-
12. [Performance and Pitfalls](#12-performance-and-pitfalls)
|
|
35
|
-
13. [Phase Rings: An Exact Cycle of Positions](#13-phase-rings)
|
|
36
|
-
14. [Signal Analysis: Windows, Spectra and Time](#14-signal-analysis)
|
|
37
|
-
15. [License](#15-license)
|
|
38
|
-
|
|
39
|
-
---
|
|
40
|
-
|
|
41
|
-
<a id="1-design-philosophy"></a>
|
|
42
|
-
## 1. Design Philosophy
|
|
43
|
-
|
|
44
|
-
Three ideas, one pipeline:
|
|
45
|
-
|
|
46
|
-
**Index is data.** Every element is addressed by a `bigint`. Reversal, rotation, shuffling and
|
|
47
|
-
partitioning are index arithmetic, not buffer operations. Nothing is materialised until a
|
|
48
|
-
collectable asks for it.
|
|
49
|
-
|
|
50
|
-
**Laziness is the default.** A factory wraps a generator; an operator wraps another generator. No
|
|
51
|
-
element is requested until a terminal operation runs, so an infinite source is perfectly legal as
|
|
52
|
-
long as something downstream stops it.
|
|
53
|
-
|
|
54
|
-
**Terminals are explicit.** `map` returns a `Semantic`, not an array. Values appear only when you
|
|
55
|
-
name a collectable — `toUnordered()`, `toOrdered()`, `toNumericStatistics()`, `toWindow()` — and each
|
|
56
|
-
one states what it costs and what order it produces.
|
|
57
|
-
|
|
58
|
-
The same discipline applies to the mathematical half of the library. A `Complex`, an `Angle` or a
|
|
59
|
-
`Frequency` is an immutable value whose invariants are checked at construction; an `Optional`
|
|
60
|
-
replaces `null`; and a phase ring states exactly how many elements it holds, what period it has and
|
|
61
|
-
how balanced it is.
|
|
62
|
-
|
|
63
|
-
---
|
|
64
|
-
|
|
65
|
-
<a id="2-architecture"></a>
|
|
66
|
-
## 2. Architecture
|
|
67
|
-
|
|
68
|
-
```
|
|
69
|
-
factory ──▶ Semantic ──▶ Semantic ──▶ Collectable ──▶ result
|
|
70
|
-
(source) (map) (filter) (terminal)
|
|
71
|
-
▲
|
|
72
|
-
└── every operator returns a new Semantic wrapping a generator
|
|
73
|
-
```
|
|
74
|
-
|
|
75
|
-
| Layer | Module | Responsibility |
|
|
76
|
-
| :--- | :--- | :--- |
|
|
77
|
-
| Sources | `synchronous/collectors`, `asynchronous/collectors` | Wrap an iterable, a generator, a promise, a blob, an event target |
|
|
78
|
-
| Pipelines | `synchronous/semantic`, `asynchronous/semantic` | Lazy operators and index rewriting |
|
|
79
|
-
| Collectables | `synchronous/semantic`, `asynchronous/semantic` | Ordered, unordered, statistics, window and custom terminals |
|
|
80
|
-
| Collectors | `synchronous/collector`, `asynchronous/collector` | The fold primitives every terminal is built from |
|
|
81
|
-
| Mathematics | `mathematics/*` | Angles, complexes, frequencies, spectra, windows, estimators, epicycles, phase rings |
|
|
82
|
-
| Structures | `structure/*` | Lists, sets, maps, trees, forests, rings, fields |
|
|
83
|
-
| Errors | `structure/error` | The typed `SemanticError` hierarchy and its brand guards |
|
|
84
|
-
|
|
85
|
-
Synchronous and asynchronous families share one vocabulary: the same operator names, the same index
|
|
86
|
-
semantics, the same collectables. The asynchronous variants simply return promises, and they queue
|
|
87
|
-
writes so that a read issued after a write always observes it.
|
|
88
|
-
|
|
89
|
-
### 2.1 What this release moved and broke
|
|
90
|
-
|
|
91
|
-
Three modules changed location or shape, and a set of defaults changed meaning. Every old behaviour
|
|
92
|
-
is still reachable through an explicit option; nothing was removed without a replacement.
|
|
93
|
-
|
|
94
|
-
| Change | Before | Now |
|
|
95
|
-
| :--- | :--- | :--- |
|
|
96
|
-
| Forests | `structure/tree/rootedForest`, `structure/tree/unionFindForest` | `structure/forest/rootedForest`, `structure/forest/unionFindForest`; `tree/index.ts` no longer re-exports them |
|
|
97
|
-
| Errors | ad-hoc native errors | a fifteen-class hierarchy in `structure/error.ts`, with one brand guard per class and the `SemanticRangeError` alias |
|
|
98
|
-
| Estimator options | fixed definitions | `mathematics/quantile.ts` exports `QuantileMethod`, `QuantileOptions`, `SkewnessEstimator`, `KurtosisEstimator` and `VarianceOptions` |
|
|
99
|
-
| `quantile` | Hyndman–Fan type 1 | type 7 — R `quantile`, NumPy `linear`, Excel `PERCENTILE.INC` |
|
|
100
|
-
| `skewness` / `kurtosis` | `g1` / `g2` | the bias-corrected `G1` / `G2` |
|
|
101
|
-
| `variance` / `standardDeviation` | `ddof = 0` | `ddof = 1`, wrapped in an `Optional` |
|
|
102
|
-
| `decibel` | one `20·log10` function | removed; `amplitudeDecibel` (20·log10) and `powerDecibel` (10·log10) |
|
|
103
|
-
| `Spectrum.flatness()` | magnitudes over both halves | powers over the one-sided half |
|
|
104
|
-
| `Spectrum.bandEnergy()` | raw two-sided sum | one-sided by default, folding the conjugate half |
|
|
105
|
-
| `shortTimeFourierTransform` / `spectrogram` | symmetric Hann, complete frames | periodic Hann, `boundary: "zeros"`, `padded: true` |
|
|
106
|
-
|
|
107
|
-
Chapters 6, 7 and 10 document the replacements; the measured consequences are in
|
|
108
|
-
[CHANGELOG.md](./CHANGELOG.md).
|
|
109
|
-
|
|
110
|
-
---
|
|
111
|
-
|
|
112
|
-
<a id="3-quick-start"></a>
|
|
113
|
-
## 3. Quick Start
|
|
114
|
-
|
|
115
|
-
```typescript
|
|
116
|
-
import {
|
|
117
|
-
useOf, useFrom, useRange, useText, useStringify, usePromise
|
|
118
|
-
} from "semantic-typescript";
|
|
119
|
-
|
|
120
|
-
// Numeric aggregation
|
|
121
|
-
const sum: number = useOf(10, 20, 30, 40)
|
|
122
|
-
.map(n => n * 2)
|
|
123
|
-
.toNumericStatistics()
|
|
124
|
-
.summate(); // 200
|
|
125
|
-
|
|
126
|
-
// BigInt aggregation
|
|
127
|
-
const bigSum: bigint = useOf(10n, 20n, 30n, 40n)
|
|
128
|
-
.map(n => n * 2n)
|
|
129
|
-
.toBigintStatistics()
|
|
130
|
-
.summate(); // 200n
|
|
131
|
-
|
|
132
|
-
// Reordering is index arithmetic — collect with toOrdered() for it to take effect
|
|
133
|
-
const reversed: number[] = useFrom([1, 2, 3, 4, 5]).reverse().toOrdered().toArray(); // [5,4,3,2,1]
|
|
134
|
-
const rotated: number[] = useFrom([1, 2, 3, 4, 5]).translate(2).toOrdered().toArray(); // [4,5,1,2,3]
|
|
135
|
-
|
|
136
|
-
// Lazy evaluation over a large range
|
|
137
|
-
const multiples: bigint[] = useRange(0n, 1_000_000n)
|
|
138
|
-
.filter(n => n % 17n === 0n)
|
|
139
|
-
.limit(10n)
|
|
140
|
-
.toUnordered()
|
|
141
|
-
.toArray(); // [0n,17n,…,153n]
|
|
142
|
-
|
|
143
|
-
// Unicode-safe string processing (code points, not UTF-16 units)
|
|
144
|
-
useText("semantic 👍").toUnordered().forEach(character => console.log(character));
|
|
145
|
-
|
|
146
|
-
// Circular-safe serialisation
|
|
147
|
-
const cyclic: any = { a: 1 }; cyclic.self = cyclic;
|
|
148
|
-
useStringify(cyclic); // '{"a":1}'
|
|
149
|
-
|
|
150
|
-
// Terminals hand the elements to real structures, not only to arrays
|
|
151
|
-
useOf(3, 1, 4, 1, 5).toCollectable().toOrderedSet().toArray(); // [1,3,4,5]
|
|
152
|
-
useFrom([3, 1, 4, 1, 5]).toCollectable().toBinarySearchTree().size(); // 5n
|
|
153
|
-
|
|
154
|
-
// Async sources
|
|
155
|
-
const total = await usePromise(Promise.resolve(7)).toNumericStatistics().summate(); // 7
|
|
156
|
-
```
|
|
157
|
-
|
|
158
|
-
---
|
|
159
|
-
|
|
160
|
-
<a id="4-factory-functions"></a>
|
|
161
|
-
## 4. Factory Functions
|
|
162
|
-
|
|
163
|
-
Construction is always O(1) — a factory only wraps a generator. The table states the cost of
|
|
164
|
-
*consuming* `n` elements, plus the extra space the source itself needs.
|
|
165
|
-
|
|
166
|
-
| Factory | Signature (result) | Time (consume n) | Space (source) | Notes |
|
|
167
|
-
| :--- | :--- | :--- | :--- | :--- |
|
|
168
|
-
| `useOf` | `useOf<E>(...elements) → SynchronousSemantic<E>` | O(n) | O(n) | Indices are 0..n−1; the spread argument is already materialised |
|
|
169
|
-
| `useFrom` | `useFrom<E>(Iterable<E>) → SynchronousSemantic<E>`<br>`useFrom<E>(AsyncIterable<E>) → AsynchronousSemantic<E>` | O(n) | O(1) | Wraps any iterable; iterated once on collect |
|
|
170
|
-
| `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 |
|
|
171
|
-
| `useFill` | `useFill<E>(element \| supplier, count) → SynchronousSemantic<E>` | O(n) | O(1) | A supplier is called once per element |
|
|
172
|
-
| `useEmpty` | `useEmpty<E>() → SynchronousSemantic<E>` | O(1) | O(1) | Identity element for `concat` |
|
|
173
|
-
| `useText` | `useText(text, delimiter?)` | O(n) | O(n) for the text | Iterates **code points**, so emoji and CJK stay single elements |
|
|
174
|
-
| `useAttribute` | `useAttribute<T>(object)` | O(v + e) | O(d + v) | Recursively walks nested objects with cycle protection |
|
|
175
|
-
| `useGenerate` | `useGenerate<E>(supplier, interrupt)` | O(n) × supplier cost | O(1) | **Must** have a stopping `interrupt` or a downstream `limit` |
|
|
176
|
-
| `useIterate` | `useIterate<E>(Iterable<E>)` / `(AsyncIterable<E>)` | O(n) per collection | O(1) | Replays the iterator on each collection |
|
|
177
|
-
| `useInterval` | `useInterval(period, delay?) → AsynchronousSemantic<number>` | n × period (real time) | O(1) | Emits elapsed time |
|
|
178
|
-
| `usePromise` | `usePromise<T>(promise) → AsynchronousSemantic<T>` | O(1) | O(1) | A single element at index `0n` |
|
|
179
|
-
| `useBlob` | `useBlob(blob, chunk?) → AsynchronousSemantic<Uint8Array>` | O(blob size) | O(chunk) per copy | Default chunk is 64 KiB |
|
|
180
|
-
| `useSubscription` | `useSubscription(target, subscriber, event) → AsynchronousSemantic<Event>` | one element per event | O(1) | `limit()` / `takeWhile()` define the unsubscribe boundary |
|
|
181
|
-
|
|
182
|
-
```typescript
|
|
183
|
-
useOf(1, 2, 3).toUnordered().toArray(); // [1,2,3]
|
|
184
|
-
useFrom(new Set([1, 2, 2])).toUnordered().toArray(); // [1,2]
|
|
185
|
-
useRange(0, 5).toUnordered().toArray(); // [0,1,2,3,4]
|
|
186
|
-
useRange(10n, 0n, -5n).toUnordered().toArray(); // [10n,5n]
|
|
187
|
-
useFill("x", 3n).toUnordered().toArray(); // ["x","x","x"]
|
|
188
|
-
useText("A😊B").toUnordered().toArray(); // ["A","😊","B"]
|
|
189
|
-
useGenerate(() => 7, (_e, index) => index >= 3n).toUnordered().toArray(); // [7,7,7]
|
|
190
|
-
```
|
|
191
|
-
|
|
192
|
-
---
|
|
193
|
-
|
|
194
|
-
<a id="5-transformation-operators"></a>
|
|
195
|
-
## 5. Transformation Operators
|
|
196
|
-
|
|
197
|
-
Every operator returns a new `Semantic`; none touches an element until a collectable runs. Time is
|
|
198
|
-
the extra cost **per element**; space is what the operator keeps while the pipeline runs.
|
|
199
|
-
|
|
200
|
-
| Operator | Purpose | Index effect | Time | Space |
|
|
201
|
-
| :--- | :--- | :--- | :--- | :--- |
|
|
202
|
-
| `map(mapper)` | 1:1 transform | forwarded | O(1) | O(1) |
|
|
203
|
-
| `filter(predicate)` | keep matching elements | forwarded | O(1) | O(1) |
|
|
204
|
-
| `flat(mapper)` / `flatMap(mapper)` | 1:N flatten | re-indexed from 0 | O(1) per emit | O(1) |
|
|
205
|
-
| `distinct(keyExtractor?)` | drop repeated keys, keep the first | forwarded | O(1) average | O(n) |
|
|
206
|
-
| `peek(consumer)` | side effect, element passes through | forwarded | O(1) | O(1) |
|
|
207
|
-
| `limit(count)` | take the first *n* | re-indexed from 0 | O(1) | O(1) |
|
|
208
|
-
| `skip(count)` | drop the first *n* | forwarded | O(1) | O(1) |
|
|
209
|
-
| `sub(start, end)` | positional slice `[start, end)` | forwarded | O(1) | O(1) |
|
|
210
|
-
| `takeWhile(predicate)` | take while the predicate holds | forwarded | O(1) | O(1) |
|
|
211
|
-
| `dropWhile(predicate)` | drop while the predicate holds | re-indexed from 0 | O(1) | O(1) |
|
|
212
|
-
| `concat(other)` | append a semantic or iterable | continues the index | O(1) | O(1) |
|
|
213
|
-
| `redirect(redirector)` | compute a new index however you like | **yours** | O(1) | O(1) |
|
|
214
|
-
| `reverse()` | `redirect((_e, i) => -i - 1n)` | negated and shifted | O(1) | O(1) |
|
|
215
|
-
| `translate(offset \| mapper)` | shift every index | `i + offset` | O(1) | O(1) |
|
|
216
|
-
| `shuffle(mapper?)` | randomise the index | random | O(1) | O(1) |
|
|
217
|
-
| `pipe(conversion)` | hand the generator to a custom function | up to you | yours | yours |
|
|
218
|
-
| `semantic()` | lift a collectable back into a semantic | unchanged | O(1) | O(1) |
|
|
219
|
-
|
|
220
|
-
**`reverse`, `translate` and `shuffle` only rewrite indices.** Nothing is buffered or rotated until
|
|
221
|
-
`toOrdered()` normalises the indices modulo the element count:
|
|
222
|
-
|
|
223
|
-
```typescript
|
|
224
|
-
useFrom([1, 2, 3, 4, 5]).reverse().toOrdered().toArray(); // [5,4,3,2,1]
|
|
225
|
-
useFrom([1, 2, 3, 4, 5]).translate(2).toOrdered().toArray(); // [4,5,1,2,3] (rotation)
|
|
226
|
-
useFrom([1, 2, 3, 4, 5]).translate(7).toOrdered().toArray(); // [4,5,1,2,3] (7 ≡ 2 mod 5)
|
|
227
|
-
useFrom([1, 2, 3]).shuffle((_e, i) => -i - 1n).toOrdered().toArray(); // [3,2,1]
|
|
228
|
-
```
|
|
229
|
-
|
|
230
|
-
---
|
|
231
|
-
|
|
232
|
-
<a id="6-collectables-and-terminal-operations"></a>
|
|
233
|
-
## 6. Collectables and Terminal Operations
|
|
234
|
-
|
|
235
|
-
The collectable owns both the **order** and the **index bookkeeping**. `n` is the element count.
|
|
236
|
-
|
|
237
|
-
### 6.1 Collectables
|
|
238
|
-
|
|
239
|
-
| Collectable | Structure | Time | Order | Use when |
|
|
240
|
-
| :--- | :--- | :--- | :--- | :--- |
|
|
241
|
-
| `toUnordered()` | `Map<bigint, E>` keyed by index | O(n) | insertion | maximum throughput; raw indices preserved untouched |
|
|
242
|
-
| `toOrdered()` | array sorted by element index | O(n log n) | by normalised index | reversal/rotation must take effect |
|
|
243
|
-
| `toOrdered(comparator)` | sorted by your comparator | O(n log n) | by your comparator | sorted views |
|
|
244
|
-
| `toNumericStatistics()` | value-sorted buffer | O(n log n) | by value | statistics over `number` |
|
|
245
|
-
| `toBigintStatistics()` | value-sorted buffer | O(n log n) | by value | statistics over `bigint` |
|
|
246
|
-
| `toWindow()` | value-sorted buffer | O(n log n) | by value | `slide` / `tumble` |
|
|
247
|
-
| `toCollectable()` | the collectable itself, wired to the generator | O(1) | — | the cheapest way to reach a terminal |
|
|
248
|
-
| `toCollectable(mapper)` | whatever the mapper returns | — | — | custom collectables |
|
|
249
|
-
|
|
250
|
-
### 6.2 Terminals
|
|
251
|
-
|
|
252
|
-
Every terminal lives on the **collectable**, not on the `Semantic`. That is what "terminals are
|
|
253
|
-
explicit" means in practice: a pipeline produces nothing until a collectable is named, and
|
|
254
|
-
`toCollectable()` with no mapper is the cheapest one to name.
|
|
255
|
-
|
|
256
|
-
| Terminal | Returns | Time | Notes |
|
|
257
|
-
| :--- | :--- | :--- | :--- |
|
|
258
|
-
| `toArray()` / `toSet()` | `Array<E>` / `Set<E>` | O(n) | Drains the buffer; `toSet` deduplicates by identity |
|
|
259
|
-
| `toMap(key, value?)` | `Map<K, V>` | O(n) average | Last write wins |
|
|
260
|
-
| `count()` / `isEmpty()` | `bigint` / `boolean` | O(1) | The buffer already knows |
|
|
261
|
-
| `forEach(action)` | `void` | O(n) | `action(element, index)` |
|
|
262
|
-
| `join(…)` | `string` | O(n) | `join()`, `join(delimiter)`, `join(prefix, delimiter, suffix)` |
|
|
263
|
-
| `reduce(…)` | `Optional<E>` / `E` / `R` | O(n) | Empty stream → empty `Optional` |
|
|
264
|
-
| `group(classifier)` / `groupBy(key, value)` | `Map<K, E[]>` | O(n) average | — |
|
|
265
|
-
| `partition(count)` | `Array<Array<E>>` | O(n) | Round-robin by index |
|
|
266
|
-
| `partitionBy(classifier)` | `Array<Array<E>>` | O(n) | The bucket number comes from the classifier |
|
|
267
|
-
| `anyMatch` / `allMatch` / `nonMatch` | `boolean` | O(n) worst, short-circuits | — |
|
|
268
|
-
| `findAny` / `findFirst` / `findLast` | `Optional<E>` | O(1)–O(n) | Positional in materialised order |
|
|
269
|
-
| `findAt(k)` / `findNegativeAt(k)` | `Optional<E>` | O(k) / O(n−k) | Counts from the front / the end |
|
|
270
|
-
| `findMaximum(cmp?)` / `findMinimum(cmp?)` | `Optional<E>` | O(n) | Defaults to a structural comparison |
|
|
271
|
-
| `frequency()` | `Map<E, bigint>` | O(n) average | Statistics collectables only |
|
|
272
|
-
| `write(stream, accumulator?)` | `WritableStream<S>` | O(n) | IO-like terminal |
|
|
273
|
-
| `collect(collector)` | `R` | O(n) | The generic fold behind every terminal above |
|
|
274
|
-
|
|
275
|
-
### 6.3 Structure collectors
|
|
276
|
-
|
|
277
|
-
`toArray`, `toSet` and `toMap` end a pipeline in a JavaScript container. Twelve more terminals end it
|
|
278
|
-
in one of the library's own structures, so the result carries the invariants that structure
|
|
279
|
-
guarantees rather than the ones an array happens to have. They are available on every collectable,
|
|
280
|
-
including the bare one from `toCollectable()`.
|
|
281
|
-
|
|
282
|
-
| Terminal | Returns | Order | Cost |
|
|
283
|
-
| :--- | :--- | :--- | :--- |
|
|
284
|
-
| `toArray()` | `Array<E>` | traversal | O(n) |
|
|
285
|
-
| `toSet()` | `Set<E>` | insertion, SameValueZero | O(n) average |
|
|
286
|
-
| `toMap(key, value?)` | `Map<K, V>` | insertion; the last write wins | O(n) average |
|
|
287
|
-
| `toOrderedSet(comparator?)` | `SynchronousOrderedSet<E>` | comparator-ascending | O(n²) worst case |
|
|
288
|
-
| `toUnorderedSet()` | `SynchronousUnorderedSet<E>` | unspecified (hash buckets) | O(n) expected |
|
|
289
|
-
| `toOrderedMap(key, value?)` | `SynchronousOrderedMap<K, V>` | insertion; the last write wins | O(n) expected |
|
|
290
|
-
| `toUnorderedMap(key, value?)` | `SynchronousUnorderedMap<K, V>` | unspecified | O(n) expected |
|
|
291
|
-
| `toList()` | `SynchronousList<E>` | traversal, duplicates kept | O(n) |
|
|
292
|
-
| `toRing(span?)` | `SynchronousRing<E>` | traversal fixes ring positions | O(n) |
|
|
293
|
-
| `toLRUSet(capacity)` | `SynchronousUnorderedLRUSet<E>` | recency; the last `capacity` survive | O(n²) worst case |
|
|
294
|
-
| `toLRUMap(capacity, key, value?)` | `SynchronousUnorderedLRUMap<K, V>` | recency | O(n) expected |
|
|
295
|
-
| `toSkipSet(comparator?)` | `SynchronousSkipSet<E>` | comparator-ascending, navigable | O(n²) worst case |
|
|
296
|
-
| `toSkipMap(key, value?)` | `SynchronousSkipMap<K, V>` | key comparator, navigable | O(n) expected |
|
|
297
|
-
| `toBinarySearchTree(comparator?)` | `SynchronousBinarySearchTree<E>` | comparator-ascending, height-balanced | O(n log n) |
|
|
298
|
-
| `toRootedForest()` | `SynchronousRootedForest<E>` | encounter order becomes structure | O(n) |
|
|
299
|
-
|
|
300
|
-
The result type follows the collectable's own element type, so no terminal needs a type argument: a
|
|
301
|
-
`SynchronousCollectable<E>` hands you `Array<E>`, `SynchronousList<E>` or `SynchronousOrderedSet<E>`
|
|
302
|
-
directly. Only the business arguments remain — the comparator of `toOrderedSet` / `toSkipSet` /
|
|
303
|
-
`toBinarySearchTree`, the capacity of the LRU pair, the span of `toRing`, and the key and value
|
|
304
|
-
extractors of the map family.
|
|
305
|
-
|
|
306
|
-
A ring is a set, so repeated elements keep their first position; every other structure above keeps
|
|
307
|
-
duplicates unless its own contract says otherwise (`toOrderedSet`, `toUnorderedSet`, `toSkipSet` are
|
|
308
|
-
sets; `toBinarySearchTree` and `toList` are not).
|
|
309
|
-
|
|
310
|
-
```typescript
|
|
311
|
-
const numbers = useOf(3, 1, 4, 1, 5).toCollectable();
|
|
312
|
-
|
|
313
|
-
numbers.toOrderedSet().toArray(); // [1,3,4,5]
|
|
314
|
-
numbers.toUnorderedSet().size(); // 4n — the duplicate 1 collapses
|
|
315
|
-
numbers.toList().toArray(); // [3,1,4,1,5]
|
|
316
|
-
numbers.toSkipSet().first().get(); // 1 — navigable by comparator
|
|
317
|
-
numbers.toBinarySearchTree().size(); // 5n — duplicates retained
|
|
318
|
-
numbers.toRootedForest().getRoots().length; // 1
|
|
319
|
-
useOf(1, 2, 3).toCollectable().toRing().phaseAt(3n).get().toDegreeValue(); // 0
|
|
320
|
-
useOf(3, 1, 3).toCollectable().toOrderedMap(n => n, n => `v${n}`).get(3).get(); // "v3"
|
|
321
|
-
```
|
|
322
|
-
|
|
323
|
-
The asynchronous collectables expose the same terminals and resolve each in a promise, so the call
|
|
324
|
-
shape only gains an `await`.
|
|
325
|
-
|
|
326
|
-
---
|
|
327
|
-
|
|
328
|
-
<a id="7-statistics-and-windows"></a>
|
|
329
|
-
## 7. Statistics and Windows
|
|
330
|
-
|
|
331
|
-
### 7.1 The statistics surface
|
|
332
|
-
|
|
333
|
-
```typescript
|
|
334
|
-
useOf(3, 1, 4, 1, 5).toNumericStatistics().summate(); // 14
|
|
335
|
-
useOf(3, 1, 4, 1, 5).toNumericStatistics().average(); // 2.8
|
|
336
|
-
useOf(3, 1, 4, 1, 5).toNumericStatistics().median(); // 3
|
|
337
|
-
useOf(3, 1, 4, 1, 5).toNumericStatistics().mode(); // 1
|
|
338
|
-
useOf(3, 1, 4, 1, 5).toNumericStatistics().variance().get(); // 3.2 (n − 1)
|
|
339
|
-
useOf(3, 1, 4, 1, 5).toNumericStatistics().standardDeviation().get(); // 1.7889 (n − 1)
|
|
340
|
-
```
|
|
341
|
-
|
|
342
|
-
Both statistics families expose the same surface — `summate`, `average`, `median`, `mode`,
|
|
343
|
-
`variance`, `standardDeviation`, `frequency`, `findMaximum`, `findMinimum` — with `bigint` arithmetic
|
|
344
|
-
for `toBigintStatistics()`. The dispersion measures and the quantile family return `Optional`,
|
|
345
|
-
because a sample can be too small for them to be defined; unwrap them with `.get()`, `.orElse(…)` or
|
|
346
|
-
`.isPresent()`.
|
|
347
|
-
|
|
348
|
-
### 7.2 Estimators and defaults
|
|
349
|
-
|
|
350
|
-
The defaults are the definitions R, NumPy, pandas and Excel use, so a value computed here can be
|
|
351
|
-
compared with those tools without a correction factor.
|
|
352
|
-
|
|
353
|
-
| Statistic | Default | Previous | Restore the old value with |
|
|
354
|
-
| :--- | :--- | :--- | :--- |
|
|
355
|
-
| `quantile(probability)` | Hyndman–Fan type 7 | type 1 | `{ method: "type-1" }` |
|
|
356
|
-
| `interquartileRange()` | Hyndman–Fan type 7 | type 1 | `{ method: "type-1" }` |
|
|
357
|
-
| `skewness()` | bias-corrected `G1` | `g1` | `{ estimator: "g1" }` |
|
|
358
|
-
| `kurtosis()` | bias-corrected excess `G2` | `g2` | `{ estimator: "g2" }` |
|
|
359
|
-
| `variance()` | `ddof = 1` | `ddof = 0` | `{ ddof: 0 }` |
|
|
360
|
-
| `standardDeviation()` | `ddof = 1` | `ddof = 0` | `{ ddof: 0 }` |
|
|
361
|
-
|
|
362
|
-
All nine Hyndman–Fan types are available as `"type-1"` … `"type-9"`, and `{ excess: false }` adds
|
|
363
|
-
three back to a kurtosis, reporting the non-excess fourth standardised moment. The option types —
|
|
364
|
-
`QuantileMethod`, `QuantileOptions`, `SkewnessEstimator`, `KurtosisEstimator`, `VarianceOptions` —
|
|
365
|
-
are exported from `mathematics/quantile.ts`.
|
|
366
|
-
|
|
367
|
-
```typescript
|
|
368
|
-
const sample = useOf(1, 2, 3, 4).toNumericStatistics();
|
|
369
|
-
|
|
370
|
-
sample.variance().get(); // 1.6667 (5/3, ddof = 1)
|
|
371
|
-
sample.variance({ ddof: 0 }).get(); // 1.25
|
|
372
|
-
sample.standardDeviation().get(); // 1.2910
|
|
373
|
-
sample.skewness().get(); // 0
|
|
374
|
-
sample.kurtosis().get(); // -1.2 (G2)
|
|
375
|
-
sample.kurtosis({ estimator: "g2" }).get(); // -1.36
|
|
376
|
-
sample.kurtosis({ excess: false }).get(); // 1.8
|
|
377
|
-
|
|
378
|
-
const quartiles = useOf(1, 2, 3, 4, 5, 6, 7, 8).toNumericStatistics();
|
|
379
|
-
quartiles.quantile(0.75).get(); // 6.25 (type 7)
|
|
380
|
-
quartiles.quantile(0.75, { method: "type-1" }).get(); // 6 (nearest rank)
|
|
381
|
-
quartiles.interquartileRange().get(); // 3.5
|
|
382
|
-
```
|
|
383
|
-
|
|
384
|
-
`G1` and `G2` are undefined below three and four observations, and the sample variance and standard
|
|
385
|
-
deviation are undefined below two under `ddof = 1`. That is reported as an empty `Optional` rather
|
|
386
|
-
than as a misleading zero:
|
|
387
|
-
|
|
388
|
-
```typescript
|
|
389
|
-
useOf(1, 2, 3).toNumericStatistics().kurtosis().isEmpty(); // true (G2 needs four observations)
|
|
390
|
-
useOf(1, 2).toNumericStatistics().skewness().isEmpty(); // true (G1 needs three)
|
|
391
|
-
useOf(1).toNumericStatistics().variance().isEmpty(); // true (ddof = 1 needs two)
|
|
392
|
-
useOf(1).toNumericStatistics().variance({ ddof: 0 }).get(); // 0
|
|
393
|
-
```
|
|
394
|
-
|
|
395
|
-
The bigint views accept the same options and round every result back to a whole `bigint`.
|
|
396
|
-
|
|
397
|
-
### 7.3 Windows
|
|
398
|
-
|
|
399
|
-
Windows re-expose the buffered elements as a pipeline of pipelines, so a window can be consumed
|
|
400
|
-
without materialising it into an array first. Both forms include a final partial window.
|
|
401
|
-
|
|
402
|
-
```typescript
|
|
403
|
-
useFrom([1, 2, 3, 4]).toWindow().slide(2n, 1n).toUnordered().toArray()
|
|
404
|
-
.map(window => window.toUnordered().toArray()); // [[1,2],[2,3],[3,4],[4]]
|
|
405
|
-
|
|
406
|
-
useFrom([1, 2, 3, 4]).toWindow().tumble(2n).toUnordered().toArray()
|
|
407
|
-
.map(window => window.toUnordered().toArray()); // [[1,2],[3,4]]
|
|
408
|
-
```
|
|
409
|
-
|
|
410
|
-
---
|
|
411
|
-
|
|
412
|
-
<a id="8-the-index-model"></a>
|
|
413
|
-
## 8. The Index Model
|
|
414
|
-
|
|
415
|
-
An index is a `bigint` attached to every element. Operators either **forward** it, **re-index** it
|
|
416
|
-
from zero, or let you **compute** it.
|
|
417
|
-
|
|
418
|
-
Ordered collection normalises each index modulo the element count, then sorts by the result. That is
|
|
419
|
-
why `translate(7)` on five elements equals `translate(2)`: `7 ≡ 2 (mod 5)`.
|
|
420
|
-
|
|
421
|
-
Unordered collection keeps whatever index the pipeline produced and returns elements in arrival
|
|
422
|
-
order. Negative indices are legal and simply sort before non-negative ones once normalised.
|
|
423
|
-
|
|
424
|
-
---
|
|
425
|
-
|
|
426
|
-
<a id="9-the-asynchronous-model"></a>
|
|
427
|
-
## 9. The Asynchronous Model
|
|
428
|
-
|
|
429
|
-
`AsynchronousSemantic` mirrors the synchronous surface, returning promises. Writes are queued, so a
|
|
430
|
-
read issued after a write always observes it.
|
|
431
|
-
|
|
432
|
-
```typescript
|
|
433
|
-
const ring = new AsynchronousRing([1, 2]);
|
|
434
|
-
const added = ring.add(3); // queued
|
|
435
|
-
const sampled = ring.elementAt(2n); // observes the write above
|
|
436
|
-
await added;
|
|
437
|
-
(await sampled).get().element; // 3
|
|
438
|
-
```
|
|
439
|
-
|
|
440
|
-
Factories that are asynchronous by nature — `usePromise`, `useBlob`, `useInterval`,
|
|
441
|
-
`useSubscription` — return `AsynchronousSemantic` directly, and `useFrom` / `useIterate` upgrade
|
|
442
|
-
themselves when handed an `AsyncIterable`.
|
|
443
|
-
|
|
444
|
-
---
|
|
445
|
-
|
|
446
|
-
<a id="10-periodic-analysis-rings-fields-and-scores"></a>
|
|
447
|
-
## 10. Periodic Analysis: Rings, Fields and Scores
|
|
448
|
-
|
|
449
|
-
The mathematical half of the library treats a cycle as a first-class object rather than a lookup
|
|
450
|
-
table. Three constructions work together.
|
|
451
|
-
|
|
452
|
-
### 10.1 A ring is one integer
|
|
453
|
-
|
|
454
|
-
A `Ring` is an **n-fold cycle**: the element count, the positional period and the number of distinct
|
|
455
|
-
phases are the same integer `n`. The phase of a position is computed from its exact residue, so the
|
|
456
|
-
ring is periodic to the bit:
|
|
457
|
-
|
|
458
|
-
```typescript
|
|
459
|
-
import { SynchronousRing } from "semantic-typescript";
|
|
460
|
-
|
|
461
|
-
const ring = new SynchronousRing(["a", "b", "c", "d", "e", "f"]);
|
|
462
|
-
ring.phaseAt(0n).get().toDegreeValue(); // 0
|
|
463
|
-
ring.phaseAt(1n).get().toDegreeValue(); // 60
|
|
464
|
-
ring.phaseAt(6n).get().getValue() === ring.phaseAt(0n).get().getValue(); // true, exactly
|
|
465
|
-
ring.purity(); // 1
|
|
466
|
-
ring.isBalanced(); // true
|
|
467
|
-
ring.sequences().map(z => z.magnitude()); // [0, 6, 0, 0, 0, 0]
|
|
468
|
-
```
|
|
469
|
-
|
|
470
|
-
`frequency` is the **ambient** rotation rate, not a phase step between positions: the environment
|
|
471
|
-
moves every phasor by the same angle, so it can never break the intrinsic pattern. `span` fixes how
|
|
472
|
-
many turns the ring occupies.
|
|
473
|
-
|
|
474
|
-
`purity()` is the share of energy carried by the fundamental mode,
|
|
475
|
-
`max(|c₁|², |c_{n−1}|²) / (n · Σ|z|²)`. It is `1` exactly when the ring is the `n`-th roots of unity
|
|
476
|
-
up to rotation, and it costs `O(n)` because only two bins are needed. Both directions are tested
|
|
477
|
-
because a ring may traverse the circle either way.
|
|
478
|
-
|
|
479
|
-
### 10.2 A field is a bounded score
|
|
480
|
-
|
|
481
|
-
A `PhaseField` maps a relative phase to a score in `[-1, 1]`:
|
|
482
|
-
|
|
483
|
-
```typescript
|
|
484
|
-
import { useField, useRadian } from "semantic-typescript";
|
|
485
|
-
|
|
486
|
-
const cosine = useField([1]); // cos(2πΔ): +1 aligned, −1 opposed
|
|
487
|
-
const mixed = useField([0.4, 0.3, 0.3]); // first three harmonics
|
|
488
|
-
|
|
489
|
-
cosine.sample(useRadian(0)); // 1
|
|
490
|
-
cosine.sample(useRadian(Math.PI)); // -1
|
|
491
|
-
```
|
|
492
|
-
|
|
493
|
-
The score is a truncated cosine series, `a₀ + Σ wₘ·cos(2πmΔ)`, and the constructor enforces
|
|
494
|
-
`|a₀| + Σ|wₘ| ≤ 1`. By the triangle inequality the score can never leave `[-1, 1]` — the bound is a
|
|
495
|
-
**consequence of the construction**, not a clamp applied afterwards.
|
|
496
|
-
|
|
497
|
-
The reason the field is a series rather than a table: an `n`-point table reproduces its own samples
|
|
498
|
-
exactly, but its trigonometric interpolant overshoots between them. A five-point table scored
|
|
499
|
-
`[+1, +0.5, −1, −0.5, 0]` interpolates over `[−1.0428, 1.0041]`, which already breaks the promise.
|
|
500
|
-
A bounded series cannot.
|
|
501
|
-
|
|
502
|
-
### 10.3 Analysis measures data against every element
|
|
503
|
-
|
|
504
|
-
Each ring element is a reference phase. `analyse` locates every observation back on the ring and
|
|
505
|
-
measures it against each reference:
|
|
506
|
-
|
|
507
|
-
```typescript
|
|
508
|
-
const ring = new SynchronousRing(["a", "b", "c", "d", "e"]);
|
|
509
|
-
const report = ring.analyse(["a", "c", "c", "a", "zz", "e"]);
|
|
510
|
-
|
|
511
|
-
report.matched; // 5
|
|
512
|
-
report.missed; // 1 ('zz' is not on the ring)
|
|
513
|
-
report.references.map(r => r.count); // [2, 0, 2, 0, 1]
|
|
514
|
-
```
|
|
515
|
-
|
|
516
|
-
Each reference row reports `count`, the mean relative phase `mean`, and the mean resultant length
|
|
517
|
-
`resultant`. One identity is worth remembering: **`resultant` is the same for every reference**,
|
|
518
|
-
because changing the baseline only rotates all relative phases by a constant. What differs per
|
|
519
|
-
reference is the count and the mean.
|
|
520
|
-
|
|
521
|
-
```typescript
|
|
522
|
-
const ring = new SynchronousRing(["a", "b", "c"]);
|
|
523
|
-
ring.scoresAt(0, cosine); // one score per position, in [-1, 1]
|
|
524
|
-
```
|
|
525
|
-
|
|
526
|
-
### 10.4 Related instruments
|
|
527
|
-
|
|
528
|
-
| Call | Answers | Cost |
|
|
529
|
-
| :--- | :--- | :--- |
|
|
530
|
-
| `purity()` | is the ring still the `n`-th roots of unity? | O(n) |
|
|
531
|
-
| `resultant(t?)` | how concentrated are the phasors right now? | O(n) |
|
|
532
|
-
| `analyse(data)` | how does this data sit against every element? | O(n · m) |
|
|
533
|
-
| `toSpectrum()` | the ring as a spectrum, wavenumber on the frequency axis | O(n²) |
|
|
534
|
-
| `useAngularSpectrum(ring, instants, plan)` | the `(ω, n)` plane over time | O(n² · frames) |
|
|
535
|
-
|
|
536
|
-
Two more primitives round out the model: `combineRings(a, b)` joins two rings on their least common
|
|
537
|
-
multiple (a pullback, not a product — the pairs that cannot share an instant are simply absent), and
|
|
538
|
-
`rate()` gives each element its own intrinsic frequency so that a ring can drift against the
|
|
539
|
-
environment. `jetAt`, `windingAt` and `errorIntegralAt` expose the differential state of that drift.
|
|
540
|
-
|
|
541
|
-
### 10.5 Spectral windows
|
|
542
|
-
|
|
543
|
-
The ring constructions are one half of the analytic surface; the other half works on sampled
|
|
544
|
-
signals. Its most reused primitive is the window, and every shape here has two forms. The symmetric
|
|
545
|
-
form divides the frame by `count − 1`; the periodic (DFT-even) form divides it by `count`, one
|
|
546
|
-
sample short of a full period, so the taper is continuous under the periodic extension the DFT
|
|
547
|
-
assumes.
|
|
548
|
-
|
|
549
|
-
```typescript
|
|
550
|
-
useHannWindow(1, 4); // 0.75 symmetric
|
|
551
|
-
useHannPeriodicWindow(1, 4); // 0.5 periodic
|
|
552
|
-
coherentGain(useHannPeriodicWindow, 16); // 0.5
|
|
553
|
-
equivalentNoiseBandwidth(useHannPeriodicWindow, 16); // 1.5
|
|
554
|
-
```
|
|
555
|
-
|
|
556
|
-
| Shape | Symmetric | Periodic | Notes |
|
|
557
|
-
| :--- | :--- | :--- | :--- |
|
|
558
|
-
| Rectangular | `useRectangularWindow` | — | no taper; no periodic distinction |
|
|
559
|
-
| Hann | `useHannWindow` | `useHannPeriodicWindow` | the short-time default |
|
|
560
|
-
| Hamming | `useHammingWindow` | `useHammingPeriodicWindow` | — |
|
|
561
|
-
| Blackman | `useBlackmanWindow` | `useBlackmanPeriodicWindow` | — |
|
|
562
|
-
| Nuttall | `useNuttallWindow` | `useNuttallPeriodicWindow` | four-term Blackman–Harris |
|
|
563
|
-
| Flat-top | `useFlatTopWindow` | `useFlatTopPeriodicWindow` | amplitude accuracy over resolution |
|
|
564
|
-
| Kaiser | `useKaiserWindow(beta)` | `useKaiserPeriodicWindow(beta)` | `beta` sets the main-lobe width |
|
|
565
|
-
|
|
566
|
-
Use the periodic form for analysis and overlap-add: at a half-frame hop the periodic Hann window
|
|
567
|
-
sums to `1.000000` at every length, while the symmetric form ripples by `0.841 dB` at `N = 16` and
|
|
568
|
-
still by `0.013 dB` at `N = 1024`. Keep the symmetric form for filter design, where the frame is not
|
|
569
|
-
one period of the taper.
|
|
570
|
-
|
|
571
|
-
The flat-top windows return **negative weights** near the ends of the frame, because that is what a
|
|
572
|
-
flat-top window is. They are returned unchanged rather than clamped, because clamping perturbs the
|
|
573
|
-
main lobe and destroys the low scalloping loss the window exists for:
|
|
574
|
-
|
|
575
|
-
```typescript
|
|
576
|
-
useFlatTopWindow(0, 1024); // -0.000421 — negative by definition
|
|
577
|
-
Array.from({ length: 1024 }, (_unused, index) => useFlatTopWindow(index, 1024))
|
|
578
|
-
.filter(weight => weight < 0).length; // 578
|
|
579
|
-
```
|
|
580
|
-
|
|
581
|
-
### 10.6 Levels, flatness and band energy
|
|
582
|
-
|
|
583
|
-
`decibel` is gone as a single function, because an amplitude and a power do not share a factor.
|
|
584
|
-
`amplitudeDecibel` applies `20·log10` to a voltage-like ratio; `powerDecibel` applies `10·log10` to a
|
|
585
|
-
power, an energy, a mean square or a power spectral density:
|
|
586
|
-
|
|
587
|
-
```typescript
|
|
588
|
-
amplitudeDecibel(0.25); // -12.0412
|
|
589
|
-
powerDecibel(0.25); // -6.0206
|
|
590
|
-
```
|
|
591
|
-
|
|
592
|
-
`Bin.decibel` and `Spectrum.decibelOf` are amplitude levels; `Spectrum.powerDecibelOf` is the
|
|
593
|
-
matching power level. Feeding a power to the amplitude form reports `20·log10(power)` and doubles
|
|
594
|
-
the level in decibels.
|
|
595
|
-
|
|
596
|
-
Two spectrum measurements changed reading:
|
|
597
|
-
|
|
598
|
-
```typescript
|
|
599
|
-
const twoBins = new Spectrum(new Sample(2, 1), [new Complex(2, 0), new Complex(1, 0)]);
|
|
600
|
-
twoBins.flatness(); // 0.8 — geometric over arithmetic mean of power
|
|
601
|
-
|
|
602
|
-
const shape = new Spectrum(new Sample(4, 1), [
|
|
603
|
-
new Complex(1, 0), new Complex(2, 0), new Complex(0, 0), new Complex(0, 0),
|
|
604
|
-
]);
|
|
605
|
-
shape.bandEnergy(new Frequency(0), new Frequency(2)); // 9
|
|
606
|
-
shape.bandEnergy(new Frequency(0), new Frequency(2), "two-sided"); // 5
|
|
607
|
-
```
|
|
608
|
-
|
|
609
|
-
`flatness()` compares the geometric and arithmetic means of `power()` over the one-sided half of the
|
|
610
|
-
spectrum, not of magnitude over both halves, and the display-only amplitude doubling of
|
|
611
|
-
`oneSided()` is deliberately not applied. `bandEnergy()` defaults to the one-sided reading, folding
|
|
612
|
-
the conjugate half so that DC and Nyquist count once and every interior bin counts twice; a band
|
|
613
|
-
that covers the whole axis then satisfies Parseval, `Σ|X|² = N·Σx²`. Pass `"two-sided"` for a
|
|
614
|
-
complex spectrum, whose bins are already independent.
|
|
615
|
-
|
|
616
|
-
### 10.7 Short-time analysis
|
|
617
|
-
|
|
618
|
-
`Sampler.shortTimeFourierTransform` and `Sampler.spectrogram` follow the SciPy `stft` conventions:
|
|
619
|
-
the periodic Hann window, a zero-filled boundary at both ends and a zero-filled tail.
|
|
620
|
-
|
|
621
|
-
```typescript
|
|
622
|
-
const sampler = new Sampler(new Sample(64, 1));
|
|
623
|
-
const spectrogram = sampler.spectrogram(time => Math.cos(2 * Math.PI * 4 * time), 16, 8);
|
|
624
|
-
|
|
625
|
-
spectrogram.size(); // 9 frames
|
|
626
|
-
spectrogram.getBoundary(); // "zeros"
|
|
627
|
-
spectrogram.getTime(6); // 0.75 — frame centres, not frame starts
|
|
628
|
-
sampler.spectrogram(time => Math.cos(2 * Math.PI * 4 * time), 16, 8, useHannWindow,
|
|
629
|
-
{ boundary: "none", padded: false }).size(); // 7 — complete frames only
|
|
630
|
-
```
|
|
631
|
-
|
|
632
|
-
The extension centres the first frame on the first sample, and the tail padding keeps the last
|
|
633
|
-
samples reachable: 64 samples at frame 16 and hop 8 produce 9 frames instead of 7, and 1000 samples
|
|
634
|
-
at frame 256 and hop 128 produce 9 instead of 6. Frame times are centres — `(k·hop + (frameSize mod
|
|
635
|
-
2)/2)/rate` for an extended view, `(k·hop + frameSize/2)/rate` for an unextended one — and
|
|
636
|
-
`toFrame()` trims the extension before inverting.
|
|
637
|
-
|
|
638
|
-
---
|
|
639
|
-
|
|
640
|
-
<a id="11-utility-helpers"></a>
|
|
641
|
-
## 11. Utility Helpers
|
|
642
|
-
|
|
643
|
-
### 11.1 The error hierarchy
|
|
644
|
-
|
|
645
|
-
`structure/error.ts` exports a fifteen-class hierarchy for the errors raised *around* a pipeline — by
|
|
646
|
-
your own code, a custom collector or an adapter:
|
|
647
|
-
|
|
648
|
-
```text
|
|
649
|
-
SemanticError
|
|
650
|
-
├── IllegalOperationError
|
|
651
|
-
├── ValidationError
|
|
652
|
-
│ ├── IndexError
|
|
653
|
-
│ ├── RangeError (also exported as SemanticRangeError)
|
|
654
|
-
│ └── ConfigurationError
|
|
655
|
-
├── CapacityError
|
|
656
|
-
├── StateError
|
|
657
|
-
│ └── IllegalStateError
|
|
658
|
-
├── ConcurrencyError
|
|
659
|
-
├── CollectionError
|
|
660
|
-
├── SerializationError
|
|
661
|
-
├── NumericError
|
|
662
|
-
│ └── ArithmeticError
|
|
663
|
-
└── PhaseError
|
|
664
|
-
```
|
|
665
|
-
|
|
666
|
-
Each class fixes `name`, forwards the native `cause`, carries an optional `context` bag and
|
|
667
|
-
serialises through `toJSON()`; each has a brand guard named after it.
|
|
668
|
-
|
|
669
|
-
```typescript
|
|
670
|
-
const error = new CapacityError("LRU capacity exhausted", { context: { capacity: 0 } });
|
|
671
|
-
|
|
672
|
-
error.name; // "CapacityError"
|
|
673
|
-
isCapacityError(error); // true
|
|
674
|
-
isValidationError(error); // false — a capacity failure is not a validation failure
|
|
675
|
-
isSemanticError(error); // true
|
|
676
|
-
error.toJSON(); // { name: "CapacityError", message: "…", context: { capacity: 0 } }
|
|
677
|
-
```
|
|
678
|
-
|
|
679
|
-
`SemanticRangeError` is the same class as the exported `RangeError`, so `instanceof` holds across
|
|
680
|
-
both names; importing `RangeError` shadows the global constructor for the whole module, which is why
|
|
681
|
-
the alias exists. Neither class is thrown by the library's own argument checks, which keep raising
|
|
682
|
-
native `TypeError` and `RangeError` — the hierarchy is the vocabulary for the layer above.
|
|
683
|
-
|
|
684
|
-
### 11.2 Utility table
|
|
685
|
-
|
|
686
|
-
| Helper | Purpose |
|
|
687
|
-
| :--- | :--- |
|
|
688
|
-
| `useCompare(a, b)` | Total ordering across mixed primitives |
|
|
689
|
-
| `useToNumber` / `useToBigInt` | Lossless, range-checked numeric conversion |
|
|
690
|
-
| `useRandom` | Seeded or unseeded random helpers |
|
|
691
|
-
| `useTraverse` | Structural walk of nested objects and arrays |
|
|
692
|
-
| `useStringify` | Circular-safe JSON serialisation |
|
|
693
|
-
| `useHash` / `maskOf` / `register` / `unregister` | Structural hashing and the hash strategy registry |
|
|
694
|
-
| `invalidate` / `validate` | Guard clauses that raise a consistent error shape |
|
|
695
|
-
| `Optional<T>` | The library's `null` replacement: `Optional.of(x)` / `Optional.empty()` |
|
|
696
|
-
|
|
697
|
-
### 11.3 `useTraverse`
|
|
698
|
-
|
|
699
|
-
`useTraverse(target, callback)` flattens any object into its **non-null leaves**. It descends nested
|
|
700
|
-
objects, walks iterable values by position, skips `null` and `undefined`, and never re-enters an
|
|
701
|
-
object it has already visited — so an object graph with a cycle terminates instead of overflowing
|
|
702
|
-
the stack.
|
|
703
|
-
|
|
704
|
-
```typescript
|
|
705
|
-
const source = { id: 7, tags: ["a", "b"], owner: { name: "Ada", note: null } };
|
|
706
|
-
const leaves: string[] = [];
|
|
707
|
-
|
|
708
|
-
useTraverse(source, (key, value, path) => {
|
|
709
|
-
leaves.push(`${path.join(".")} = ${String(value)}`);
|
|
710
|
-
return true;
|
|
711
|
-
});
|
|
712
|
-
|
|
713
|
-
leaves; // ["id = 7", "tags.0 = a", "tags.1 = b", "owner.name = Ada"]
|
|
714
|
-
```
|
|
715
|
-
|
|
716
|
-
The callback runs once per leaf and receives the leaf's own key (its index when the leaf came from
|
|
717
|
-
an iterable), the value, and the path that reached it. Returning `false` abandons the **whole**
|
|
718
|
-
traversal, not just the current branch:
|
|
719
|
-
|
|
720
|
-
```typescript
|
|
721
|
-
let visited = 0;
|
|
722
|
-
useTraverse({ a: 1, b: 2, c: 3 }, () => {
|
|
723
|
-
visited += 1;
|
|
724
|
-
return false;
|
|
725
|
-
});
|
|
726
|
-
visited; // 1 — the first leaf already stops the walk
|
|
727
|
-
```
|
|
728
|
-
|
|
729
|
-
A cycle is safe for the same reason:
|
|
730
|
-
|
|
731
|
-
```typescript
|
|
732
|
-
const cyclic: any = { name: "root", child: { name: "leaf" } };
|
|
733
|
-
cyclic.child.parent = cyclic;
|
|
734
|
-
|
|
735
|
-
const names: string[] = [];
|
|
736
|
-
useTraverse(cyclic, (_key, value) => {
|
|
737
|
-
if (typeof value === "string") names.push(value);
|
|
738
|
-
return true;
|
|
739
|
-
});
|
|
740
|
-
names; // ["root", "leaf"] — the back edge to `cyclic` is not revisited
|
|
741
|
-
```
|
|
742
|
-
|
|
743
|
-
**Each 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.
|
|
744
|
-
|
|
745
|
-
```typescript
|
|
746
|
-
const paths: Array<ReadonlyArray<PropertyKey>> = [];
|
|
747
|
-
useTraverse({ a: { b: 1 }, c: 2 }, (_key, _value, path) => {
|
|
748
|
-
paths.push(path); // safe to keep as-is
|
|
749
|
-
return true;
|
|
750
|
-
});
|
|
751
|
-
paths[0] === paths[1]; // false — one array per call
|
|
752
|
-
paths.map(path => path.join(".")); // ["a.b", "c"]
|
|
753
|
-
```
|
|
754
|
-
|
|
755
|
-
`useAttribute` is this walk turned into a factory: it reports the same leaves as `{ key, value }`
|
|
756
|
-
pairs, so the rules — and the cost — are identical.
|
|
757
|
-
|
|
758
|
-
```typescript
|
|
759
|
-
useAttribute({ a: 1, b: { c: 2 } }).toUnordered().toArray();
|
|
760
|
-
// [{ key: "a", value: 1 }, { key: "c", value: 2 }]
|
|
761
|
-
```
|
|
762
|
-
|
|
763
|
-
---
|
|
764
|
-
|
|
765
|
-
<a id="12-performance-and-pitfalls"></a>
|
|
766
|
-
## 12. Performance and Pitfalls
|
|
767
|
-
|
|
768
|
-
**Laziness has a price per stage.** Each operator adds a generator hop. For tight numeric loops over
|
|
769
|
-
in-memory arrays, a plain `for` loop will beat any pipeline; the pipeline wins when the source is
|
|
770
|
-
lazy, infinite, or the pipeline is long enough for the index discipline to pay off.
|
|
771
|
-
|
|
772
|
-
**`toOrdered()` sorts; `toUnordered()` does not.** If a reversal seems to have "not worked", the
|
|
773
|
-
collector was unordered — index rewriting only becomes an order when indices are normalised.
|
|
774
|
-
|
|
775
|
-
**`useGenerate` without an `interrupt` never ends.** Always supply a stopping predicate or a
|
|
776
|
-
downstream `limit` / `takeWhile`.
|
|
777
|
-
|
|
778
|
-
**`Ring` is an insertion-ordered set.** Elements are unique and keep the order in which they were
|
|
779
|
-
first added. A duplicate `add` returns `false`, and a `set` that would introduce one throws. This is
|
|
780
|
-
what lets a phase be a function of the *element* rather than of whichever position it happens to
|
|
781
|
-
occupy.
|
|
782
|
-
|
|
783
|
-
**`Spectrum` assumes a real signal in its one-sided helpers.** `oneSided()`, `getNyquist()` and
|
|
784
|
-
`centroid()` assume conjugate symmetry. The spatial spectrum of a phase ring is complex and in
|
|
785
|
-
general not symmetric — a ring that traverses the circle backwards puts all of its energy in bin
|
|
786
|
-
`n − 1`, beyond Nyquist. Read the bins directly.
|
|
787
|
-
|
|
788
|
-
**Structure collectors are terminals, not views.** Each one drains the pipeline and then builds a
|
|
789
|
-
full structure, so it costs O(n) time and O(n) space and cannot run on an infinite source. Reach for
|
|
790
|
-
one at the end of a pipeline, never inside a loop over one.
|
|
791
|
-
|
|
792
|
-
**`variance()` and friends return an `Optional`.** `variance().get()` on a sample that is too small
|
|
793
|
-
throws; use `.orElse(0)` or check `.isPresent()` when the sample size is not under your control.
|
|
794
|
-
The same applies to `standardDeviation`, `quantile`, `interquartileRange`, `skewness` and
|
|
795
|
-
`kurtosis`.
|
|
796
|
-
|
|
797
|
-
**The periodic windows are the analysis windows.** `useHannWindow` and the other symmetric forms are
|
|
798
|
-
for filter design; a short-time transform wants `useHannPeriodicWindow` and its siblings, or the
|
|
799
|
-
overlap-add sum will ripple wherever the frames meet.
|
|
800
|
-
|
|
801
|
-
---
|
|
802
|
-
|
|
803
|
-
<a id="13-phase-rings"></a>
|
|
804
|
-
## 13. Phase Rings: An Exact Cycle of Positions
|
|
805
|
-
|
|
806
|
-
Chapter 10 introduced the ring as the library's model of a cycle and used it to measure a data
|
|
807
|
-
set against every phase. This chapter is the complete surface: how a position becomes an angle,
|
|
808
|
-
what the ambient frequency does and does not change, and the instruments that read the ring
|
|
809
|
-
against time, against data and against another ring.
|
|
810
|
-
|
|
811
|
-
A `SynchronousRing` is a positional list backed by a circular doubly linked ring whose head and
|
|
812
|
-
tail are joined. It is also an insertion-ordered *set*: no element appears twice, and a rejected
|
|
813
|
-
write leaves both the links and the samples untouched. What makes it a ring rather than a list is
|
|
814
|
-
that the phase of a position is a pure function of its residue modulo the current length, so the
|
|
815
|
-
ring is periodic to the bit and its balance cannot drift.
|
|
816
|
-
|
|
817
|
-
### 13.1 A ring is one integer, computed exactly
|
|
818
|
-
|
|
819
|
-
A ring carries `n` elements, and the element count, the positional period and the number of
|
|
820
|
-
distinct phases are the same integer. Position `i` carries
|
|
821
|
-
|
|
822
|
-
`phase + span · 2π · (((i mod n) + n) mod n) / n`
|
|
823
|
-
|
|
824
|
-
with the residue reduced in `bigint` before it ever becomes a `number`. That is the whole reason
|
|
825
|
-
`phaseAt(i)` and `phaseAt(i + n)` are *identical* rather than merely close: the second call
|
|
826
|
-
reduces to the same integer, evaluates the same product and produces the same double. An index
|
|
827
|
-
far beyond the exact integer range of `number` still lands on the right position, and a negative
|
|
828
|
-
index walks the ring backwards.
|
|
829
|
-
|
|
830
|
-
| Call | Returns | Meaning |
|
|
831
|
-
| :--- | :--- | :--- |
|
|
832
|
-
| `new SynchronousRing(source, options?)` | ring | `source` is any iterable, duplicates dropped |
|
|
833
|
-
| `getPhase()` / `getFrequency()` / `getSpan()` | `Angle`, `Frequency`, `number` | the three construction parameters |
|
|
834
|
-
| `size()` | `bigint` | the `n` that is also the period |
|
|
835
|
-
| `phaseAt(index)` | `Optional<Angle>` | intrinsic phase only; ignores time, rate and frequency |
|
|
836
|
-
| `elementAt(index)` | `Optional<RingElement>` | element, intrinsic phase, ambient frequency, phasor |
|
|
837
|
-
| `frequencyAt(index)` | `Optional<Frequency>` | the ambient rate, empty for an empty ring |
|
|
838
|
-
| `toComplex()` / `toComplex(index)` | `Complex[]` / `Optional<Complex>` | intrinsic phasors |
|
|
839
|
-
|
|
840
|
-
`RingOptions` adds two per-element functions: `magnitude(element, index)` gives the phasor length
|
|
841
|
-
and defaults to one, and `rate(element, index)` gives the element its own intrinsic frequency and
|
|
842
|
-
defaults to zero, a fixed marker.
|
|
843
|
-
|
|
844
|
-
```typescript
|
|
845
|
-
import { SynchronousRing } from "semantic-typescript";
|
|
846
|
-
|
|
847
|
-
const ring = new SynchronousRing(["a", "b", "c", "d", "e", "f"]);
|
|
848
|
-
|
|
849
|
-
ring.size(); // 6n
|
|
850
|
-
ring.phaseAt(0n).get().toDegreeValue(); // 0
|
|
851
|
-
ring.phaseAt(1n).get().toDegreeValue(); // 60
|
|
852
|
-
ring.phaseAt(-1n).get().toDegreeValue(); // 300
|
|
853
|
-
ring.phaseAt(6n).get().getValue() === ring.phaseAt(0n).get().getValue(); // true
|
|
854
|
-
ring.phaseAt(10n ** 20n).get().toDegreeValue(); // 240
|
|
855
|
-
```
|
|
856
|
-
|
|
857
|
-
`phaseAt(-1n)` is `300` on a six-element ring, and `10n ** 20n` reduces to `4` modulo six, so it
|
|
858
|
-
reads `240`. Neither case accumulates a floating-point product, which is what keeps the identity
|
|
859
|
-
exact under a change of index representation.
|
|
860
|
-
|
|
861
|
-
### 13.2 The ambient frequency and the state at an instant
|
|
862
|
-
|
|
863
|
-
`frequency` is the **ambient rotation rate** of the environment the ring is embedded in, not a
|
|
864
|
-
phase step between positions. The environment advances the phase of every element by the same
|
|
865
|
-
angle, so it cannot change the intrinsic pattern, cannot break the balance and cannot change any
|
|
866
|
-
`phaseAt` result. It defaults to `0 Hz`.
|
|
867
|
-
|
|
868
|
-
`relativeAt(index, time)` subtracts that common angle, leaving the coordinate a `PhaseField` is
|
|
869
|
-
scored against. `viewAt` and `viewsAt` return the full state at an instant, including the
|
|
870
|
-
element's own `rate`, and the differential accessors expose the drift:
|
|
871
|
-
|
|
872
|
-
| Call | Formula | Reads |
|
|
873
|
-
| :--- | :--- | :--- |
|
|
874
|
-
| `relativeAt(index, time)` | `intrinsic + ownRate · t − ambient · t` | the phase the environment sees |
|
|
875
|
-
| `viewAt(index, time)` / `viewsAt(time)` | the same, plus element, index, time, rate, phasor | the full state |
|
|
876
|
-
| `jetAt(index, time)` | phase at `t`, own rate, `slip = ownRate − ambient` | the differential state |
|
|
877
|
-
| `windingAt(index, time)` | `trunc(slip · t)` | whole turns slipped, truncated toward zero |
|
|
878
|
-
| `errorIntegralAt(index, time, origin?)` | `∫ relative phase dt`, in turns · seconds | how long an element has been ahead |
|
|
879
|
-
| `resultant(time?)` | `abs(Σ w · e^{iθ}) / Σ w` | phase concentration, unaffected by ambient rotation |
|
|
880
|
-
| `analyse(data, time?)` | counts and means per reference | data measured against every element |
|
|
881
|
-
| `scoreAt(index, time, field)` / `scoresAt(time, field)` | the field sampled at the relative phase | a bounded score per position |
|
|
882
|
-
|
|
883
|
-
```typescript
|
|
884
|
-
import { SynchronousRing, useHertz } from "semantic-typescript";
|
|
885
|
-
|
|
886
|
-
const moving = new SynchronousRing(["a", "b", "c", "d", "e", "f"], {
|
|
887
|
-
frequency: useHertz(1),
|
|
888
|
-
});
|
|
889
|
-
|
|
890
|
-
moving.getFrequency().getHertz(); // 1
|
|
891
|
-
moving.phaseAt(1n).get().toDegreeValue(); // 60 — intrinsic
|
|
892
|
-
moving.relativeAt(1n, 0.25).get().toDegreeValue(); // 330 — 60 − 90
|
|
893
|
-
moving.viewAt(1n, 0.25).get().relative.toDegreeValue(); // 330
|
|
894
|
-
moving.jetAt(1n, 0.25).get().slip; // -1
|
|
895
|
-
moving.purity(); // 1 — rotation cannot break it
|
|
896
|
-
```
|
|
897
|
-
|
|
898
|
-
At `1 Hz` the environment advances a quarter turn in `0.25 s`, so the relative phase of position
|
|
899
|
-
one is `60 − 90 = 330` degrees while its intrinsic phase stays `60`. The jet reports `slip` of
|
|
900
|
-
`-1` hertz because the element itself does not move, and `purity()` is still `1`: rotating every
|
|
901
|
-
phasor by the same angle is not a deformation.
|
|
902
|
-
|
|
903
|
-
### 13.3 Purity and balance
|
|
904
|
-
|
|
905
|
-
`purity()` is the share of the ring's energy carried by the fundamental mode:
|
|
906
|
-
|
|
907
|
-
`max(abs(c₁)², abs(c_{n−1})²) / (n · Σ abs(z)²)`
|
|
908
|
-
|
|
909
|
-
Both fundamental bins are tested because a ring may traverse the circle in either direction. A
|
|
910
|
-
ring built with `span: 5` over six elements steps by `300` degrees, which is `−60` degrees: it
|
|
911
|
-
runs backwards, puts all of its energy in `c₅`, and would be reported as impure by a formula that
|
|
912
|
-
only looked at `c₁`. The cost stays `O(n)` because only those two bins and the total energy are
|
|
913
|
-
needed.
|
|
914
|
-
|
|
915
|
-
The edge cases are part of the contract. A ring of fewer than two elements has no fundamental
|
|
916
|
-
mode and reports `purity() === 0` and `isBalanced() === false`, whatever its content. A ring whose
|
|
917
|
-
phasors all have zero length has no energy and also reports zero. `isBalanced(epsilon?)` is
|
|
918
|
-
`1 − purity() <= epsilon`, with `1E-9` as the default tolerance.
|
|
919
|
-
|
|
920
|
-
```typescript
|
|
921
|
-
import { SynchronousRing } from "semantic-typescript";
|
|
922
|
-
|
|
923
|
-
const forward = new SynchronousRing(["a", "b", "c", "d", "e", "f"]);
|
|
924
|
-
const backward = new SynchronousRing(["a", "b", "c", "d", "e", "f"], { span: 5 });
|
|
925
|
-
const weighted = new SynchronousRing(["a", "b", "c", "d"], {
|
|
926
|
-
magnitude: element => element === "a" ? 1 : 0,
|
|
927
|
-
});
|
|
928
|
-
|
|
929
|
-
forward.purity(); // 1
|
|
930
|
-
backward.phaseAt(1n).get().toDegreeValue(); // 300 — it runs backwards
|
|
931
|
-
backward.purity(); // 1 — the energy sits in c₅
|
|
932
|
-
new SynchronousRing(["only"]).purity(); // 0
|
|
933
|
-
new SynchronousRing(["only"]).isBalanced(); // false
|
|
934
|
-
weighted.purity(); // 0.25
|
|
935
|
-
```
|
|
936
|
-
|
|
937
|
-
### 13.4 Resultant and the symmetric components
|
|
938
|
-
|
|
939
|
-
`resultant(time?)` is the mean resultant length: the magnitude of the phasor sum divided by the
|
|
940
|
-
sum of the magnitudes. Unlike `purity`, which asks whether the ring is still the `n`-th roots of
|
|
941
|
-
unity, `resultant` asks how concentrated the phasors are right now. A balanced ring reports `0`;
|
|
942
|
-
a ring whose elements all share one angle would report `1`, but the set semantics forbid duplicate
|
|
943
|
-
elements, so in practice `1` appears when `magnitude` zeroes all but one element.
|
|
944
|
-
|
|
945
|
-
`zeroSequence()` is the mean phasor, that is `c₀ / n`. `sequences()` returns all `n` symmetric
|
|
946
|
-
components, lowest order first, by a direct transform. `toSpectrum()` wraps them in a `Spectrum`
|
|
947
|
-
whose bin `j` reports `j` cycles per ring revolution, so the frequency axis is the wavenumber
|
|
948
|
-
axis.
|
|
949
|
-
|
|
950
|
-
```typescript
|
|
951
|
-
import { SynchronousRing } from "semantic-typescript";
|
|
952
|
-
|
|
953
|
-
const round = (value: number): number => Number(value.toFixed(4));
|
|
954
|
-
const ring = new SynchronousRing(["a", "b", "c", "d", "e", "f"]);
|
|
955
|
-
|
|
956
|
-
round(ring.resultant().get()); // 0
|
|
957
|
-
round(ring.zeroSequence().get().magnitude()); // 0
|
|
958
|
-
ring.sequences().map(component => round(component.magnitude())); // [0, 6, 0, 0, 0, 0]
|
|
959
|
-
ring.toSpectrum().get().size(); // 6
|
|
960
|
-
ring.toSpectrum().get().getResolution().getHertz(); // 1
|
|
961
|
-
```
|
|
962
|
-
|
|
963
|
-
The components are complex and in general **not conjugate-symmetric**: a balanced ring puts all
|
|
964
|
-
of its energy in bin one, and a backwards ring puts it in bin `n − 1`. The one-sided helpers of
|
|
965
|
-
`Spectrum` assume a real signal, so read the bins directly instead of folding them.
|
|
966
|
-
|
|
967
|
-
### 13.5 Analysing data against every element
|
|
968
|
-
|
|
969
|
-
`analyse(data, time?)` matches every observation back to the ring by identity and measures it
|
|
970
|
-
against each element as a reference phase. Observations the ring does not hold are counted as
|
|
971
|
-
`missed` rather than guessed at, because the ring is the only source of phases. Each reference
|
|
972
|
-
row reports `count`, the mean relative phase `mean`, and the mean resultant length `resultant`.
|
|
973
|
-
|
|
974
|
-
One identity is worth remembering: **`resultant` is the same for every reference**. Changing the
|
|
975
|
-
baseline rotates all relative phases by a constant, and a rotation cannot change a resultant
|
|
976
|
-
length. What differs per reference is the count and the mean.
|
|
977
|
-
|
|
978
|
-
```typescript
|
|
979
|
-
import { SynchronousRing } from "semantic-typescript";
|
|
980
|
-
|
|
981
|
-
const round = (value: number): number => Number(value.toFixed(4));
|
|
982
|
-
const ring = new SynchronousRing(["a", "b", "c", "d", "e"]);
|
|
983
|
-
const report = ring.analyse(["a", "c", "c", "a", "zz", "e"]);
|
|
984
|
-
|
|
985
|
-
report.matched; // 5
|
|
986
|
-
report.missed; // 1
|
|
987
|
-
report.references.map(row => row.count); // [2, 0, 2, 0, 1]
|
|
988
|
-
report.references.map(row => round(row.resultant)); // [0.1453, 0.1453, 0.1453, 0.1453, 0.1453]
|
|
989
|
-
```
|
|
990
|
-
|
|
991
|
-
Scoring is the other side of the same coin. A `PhaseField` is a bounded cosine series (chapter
|
|
992
|
-
10.2), and `scoreAt`/`scoresAt` sample it at the relative phase, so the same ring can be scored
|
|
993
|
-
against alignment, against a harmonic pattern, or against any other field without changing the
|
|
994
|
-
ring.
|
|
995
|
-
|
|
996
|
-
```typescript
|
|
997
|
-
import { SynchronousRing, useField } from "semantic-typescript";
|
|
998
|
-
|
|
999
|
-
const round = (value: number): number => Number(value.toFixed(4));
|
|
1000
|
-
const ring = new SynchronousRing(["a", "b", "c", "d", "e", "f"]);
|
|
1001
|
-
const cosine = useField([1]);
|
|
1002
|
-
|
|
1003
|
-
round(ring.scoreAt(0n, 0, cosine).get()); // 1
|
|
1004
|
-
round(ring.scoreAt(1n, 0, cosine).get()); // 0.5
|
|
1005
|
-
ring.scoresAt(0, cosine).map(score => round(score)); // [1, 0.5, -0.5, -1, -0.5, 0.5]
|
|
1006
|
-
```
|
|
1007
|
-
|
|
1008
|
-
### 13.6 Combining rings and the angular spectrum
|
|
1009
|
-
|
|
1010
|
-
`combineRings(left, right)` joins two rings on their least common multiple, not their product.
|
|
1011
|
-
Instant `i` of the joint ring reads `i mod m` of the left ring and `i mod n` of the right ring,
|
|
1012
|
-
so the joint ring closes after `lcm(m, n)` instants. The pairs that can never co-occur — those
|
|
1013
|
-
whose positions disagree modulo `gcd(m, n)` — are simply absent, which is why the result is a
|
|
1014
|
-
pullback rather than a product; `aligns(left, right)` reports whether a given pair is
|
|
1015
|
-
representable.
|
|
1016
|
-
|
|
1017
|
-
`useAngularSpectrum(ring, instants, plan)` builds the `(ω, n)` plane: each instant contributes a
|
|
1018
|
-
frame of phasors, frame `k` of mode `j` is the `j`-th symmetric component of that frame, and
|
|
1019
|
-
`cells[frame][order]` is the coefficient. Taking one column gives the time series of a single
|
|
1020
|
-
spatial mode, and `spectrum(order)` is its temporal spectrum.
|
|
1021
|
-
|
|
1022
|
-
```typescript
|
|
1023
|
-
import { Sample, SynchronousRing, combineRings, useAngularSpectrum } from "semantic-typescript";
|
|
1024
|
-
|
|
1025
|
-
const left = new SynchronousRing(["a", "b", "c", "d"]);
|
|
1026
|
-
const right = new SynchronousRing(["x", "y", "z", "w", "v", "u"]);
|
|
1027
|
-
const joined = combineRings(left, right);
|
|
1028
|
-
|
|
1029
|
-
joined.period; // 12n
|
|
1030
|
-
joined.divisor; // 2n
|
|
1031
|
-
joined.ring.size(); // 12n
|
|
1032
|
-
joined.aligns(0n, 0n); // true
|
|
1033
|
-
joined.aligns(1n, 0n); // false
|
|
1034
|
-
joined.ring.get(0n).join("-"); // a-x
|
|
1035
|
-
|
|
1036
|
-
const angular = useAngularSpectrum(left, [0, 0.25, 0.5, 0.75], new Sample(4, 1));
|
|
1037
|
-
angular.order; // 4
|
|
1038
|
-
angular.frameCount; // 4
|
|
1039
|
-
angular.cells[0][1].magnitude(); // 4
|
|
1040
|
-
angular.spectrum(1).get().size(); // 4
|
|
1041
|
-
```
|
|
1042
|
-
|
|
1043
|
-
### 13.7 Pitfalls
|
|
1044
|
-
|
|
1045
|
-
**Exactness is exact.** `phaseAt(i)` and `phaseAt(i + n)` are the same double, not two values
|
|
1046
|
-
within a tolerance. A ring built by accumulating `2π / n` per element would fail this; this one
|
|
1047
|
-
reduces the index in `bigint` first.
|
|
1048
|
-
|
|
1049
|
-
**`frequency` is not a phase step.** It is the ambient rotation rate, it defaults to zero, and
|
|
1050
|
-
rotating every phasor by the same angle cannot break the intrinsic pattern. If you want positions
|
|
1051
|
-
to advance relative to each other, use `rate`, not `frequency`.
|
|
1052
|
-
|
|
1053
|
-
**`purity()` must look at both fundamental bins.** A backwards ring is still the `n`-th roots of
|
|
1054
|
-
unity; only `c₁` would call it impure.
|
|
1055
|
-
|
|
1056
|
-
**`n < 2` is a defined edge case.** `purity()` returns `0` and `isBalanced()` returns `false`; an
|
|
1057
|
-
empty ring returns empty optionals from the sampling methods.
|
|
1058
|
-
|
|
1059
|
-
**A span that shares a factor with `n` collapses the orbit.** Six elements with `span: 2` visit
|
|
1060
|
-
only three distinct phases, and the ring is no longer balanced.
|
|
1061
|
-
|
|
1062
|
-
**`resultant` is rotation-invariant, not time-invariant.** Ambient rotation leaves it unchanged;
|
|
1063
|
-
per-element `rate`s change it as the ring de-phases.
|
|
1064
|
-
|
|
1065
|
-
**The ring is a set, and `analyse` matches by identity.** Duplicate elements are dropped at
|
|
1066
|
-
construction, and an object that is structurally equal to a ring element but not the same
|
|
1067
|
-
reference is counted as missed.
|
|
1068
|
-
|
|
1069
|
-
**`toSpectrum()` is complex.** Its bins are not conjugate-symmetric, so the one-sided helpers of
|
|
1070
|
-
`Spectrum` do not apply. Read the bins.
|
|
1071
|
-
|
|
1072
|
-
**`windingAt` truncates toward zero, and `errorIntegralAt` integrates phase.** Half a turn of slip
|
|
1073
|
-
reports `0n`; the error integral is in turns · seconds, not a rate.
|
|
1074
|
-
|
|
1075
|
-
```typescript
|
|
1076
|
-
import { SynchronousRing, useHertz } from "semantic-typescript";
|
|
1077
|
-
|
|
1078
|
-
const collapsed = new SynchronousRing(["a", "b", "c", "d", "e", "f"], { span: 2 });
|
|
1079
|
-
collapsed.phaseAt(1n).get().toDegreeValue(); // 120
|
|
1080
|
-
collapsed.phaseAt(3n).get().toDegreeValue(); // 0
|
|
1081
|
-
Number(collapsed.purity().toFixed(4)); // 0
|
|
1082
|
-
|
|
1083
|
-
const falling = new SynchronousRing(["a", "b", "c", "d"], {
|
|
1084
|
-
frequency: useHertz(2),
|
|
1085
|
-
rate: () => useHertz(0.5),
|
|
1086
|
-
});
|
|
1087
|
-
falling.jetAt(0n, 2).get().slip; // -1.5
|
|
1088
|
-
falling.windingAt(0n, 1).get(); // -1n
|
|
1089
|
-
falling.windingAt(0n, 0.5).get(); // 0n
|
|
1090
|
-
|
|
1091
|
-
const objects = new SynchronousRing([{ key: 1 }, { key: 2 }]);
|
|
1092
|
-
objects.analyse([{ key: 1 }]).missed; // 1 — identity, not structure
|
|
1093
|
-
```
|
|
1094
|
-
|
|
1095
|
-
---
|
|
1096
|
-
|
|
1097
|
-
<a id="14-signal-analysis"></a>
|
|
1098
|
-
## 14. Signal Analysis: Windows, Spectra and Time
|
|
1099
|
-
|
|
1100
|
-
Chapter 10 treated a ring as a cycle of phases. This chapter is the temporal counterpart: a
|
|
1101
|
-
real-valued record that carries, besides its samples, the two pieces of context every spectral
|
|
1102
|
-
formula needs — the sampling plan that turns a bin index into hertz, and the taper that fixes both
|
|
1103
|
-
the coherent gain and the noise bandwidth.
|
|
1104
|
-
|
|
1105
|
-
The value object is `SynchronousSignalCollectable`, with `AsynchronousSignalCollectable` as its
|
|
1106
|
-
queued twin. Both expose the same `SignalCollectable` surface: twenty-three methods, each delegating
|
|
1107
|
-
to exactly one `useSynchronous<Method>` factory in `synchronous/collectors`. The split is
|
|
1108
|
-
deliberate. The class is a documented facade, the factory owns the accumulation, and the constructor
|
|
1109
|
-
caches `sum(w) / count` and `count * sum(w^2) / sum(w)^2` once, at this length. A caller who has to
|
|
1110
|
-
remember "divide by the coherent gain" will eventually forget, and the resulting 6.02 decibels look
|
|
1111
|
-
like a plausible measurement.
|
|
1112
|
-
|
|
1113
|
-
### 14.1 A signal carries its own calibration
|
|
1114
|
-
|
|
1115
|
-
A signal is built from an iterable and a rate, from an existing `Frame`, or from the constructor
|
|
1116
|
-
with an explicit `Sample`. The default taper is the periodic Hann window; every analysis that does
|
|
1117
|
-
not name a window uses it, and every analysis accepts an override. The metadata accessors are
|
|
1118
|
-
constant-time reads of state fixed at construction, so a signal can be handed around without the
|
|
1119
|
-
context drifting.
|
|
1120
|
-
|
|
1121
|
-
| Call | Returns | Meaning |
|
|
1122
|
-
| :--- | :--- | :--- |
|
|
1123
|
-
| `SynchronousSignalCollectable.useRate(values, rate, window?)` | signal | duration `count / rate` |
|
|
1124
|
-
| `SynchronousSignalCollectable.useFrame(frame, window?)` | signal | keeps the frame's plan |
|
|
1125
|
-
| `new SynchronousSignalCollectable(plan, values, window?)` | signal | plan count must equal the sample count |
|
|
1126
|
-
| `getSample()` / `getValues()` / `getWindow()` | plan, samples, taper | the frozen context |
|
|
1127
|
-
| `getRate()` | `number` | samples per second |
|
|
1128
|
-
| `getNyquist()` / `getResolution()` | `Frequency` | `rate / 2` and `rate / count` |
|
|
1129
|
-
| `getCoherentGain()` / `getNoiseBandwidth()` | `number` | recomputed at this exact length |
|
|
1130
|
-
| `size()` / `at(index)` / `toFrame()` / `equals(other)` / `toString()` | — | value semantics |
|
|
1131
|
-
|
|
1132
|
-
```typescript
|
|
1133
|
-
import { SynchronousSignalCollectable } from "semantic-typescript";
|
|
1134
|
-
|
|
1135
|
-
const signal = SynchronousSignalCollectable.useRate(
|
|
1136
|
-
Array.from({ length: 64 }, (_unused, index) => Math.cos(2 * Math.PI * 8 * index / 64)),
|
|
1137
|
-
64,
|
|
1138
|
-
);
|
|
1139
|
-
|
|
1140
|
-
signal.size(); // 64n
|
|
1141
|
-
signal.getRate(); // 64
|
|
1142
|
-
signal.getNyquist().getHertz(); // 32
|
|
1143
|
-
signal.getResolution().getHertz(); // 1
|
|
1144
|
-
signal.getCoherentGain(); // 0.5
|
|
1145
|
-
signal.getNoiseBandwidth(); // 1.5
|
|
1146
|
-
```
|
|
1147
|
-
|
|
1148
|
-
`getResolution()` is the bin spacing, not the resolving power: it is `rate / count` because the
|
|
1149
|
-
transform has `count` bins, and padding the transform changes the spacing without changing what the
|
|
1150
|
-
window can separate (section 14.8).
|
|
1151
|
-
|
|
1152
|
-
### 14.2 The twenty-three methods
|
|
1153
|
-
|
|
1154
|
-
The methods fall into six families. Seven are spectral readings, two are time–frequency transforms,
|
|
1155
|
-
four are analytic quantities, three are sequence statistics, five are shape descriptors, and two
|
|
1156
|
-
return another signal.
|
|
1157
|
-
|
|
1158
|
-
| Method | Returns | Convention |
|
|
1159
|
-
| :--- | :--- | :--- |
|
|
1160
|
-
| `spectrum(size?, window?)` | `Spectrum` | raw and unnormalised, full complex grid |
|
|
1161
|
-
| `oneSided(size?, window?)` | `Bin[]` | conjugate half folded, DC and Nyquist not doubled |
|
|
1162
|
-
| `amplitude(window?)` | `number[]` | `2·abs(X) / (count · CG)`, once at DC and Nyquist |
|
|
1163
|
-
| `power(window?)` | `number[]` | `A² / 2` per interior bin, `A²` at DC and Nyquist |
|
|
1164
|
-
| `decibel(window?)` | `number[]` | `20·log10(amplitude)` |
|
|
1165
|
-
| `powerDecibel(window?)` | `number[]` | `10·log10(power)` |
|
|
1166
|
-
| `powerSpectralDensity(frameSize?, hopSize?, window?)` | `SpectralDensity` | Welch average, `abs(X)² / (rate · Σw²)` |
|
|
1167
|
-
| `shortTimeFourierTransform(frameSize, hopSize, window?)` | `Spectrum[]` | extended boundaries, centre times |
|
|
1168
|
-
| `spectrogram(frameSize, hopSize, window?)` | `Spectrogram` | the same columns with a time axis |
|
|
1169
|
-
| `analyticSignal()` | `Complex[]` | the Hilbert pair `x + i·H(x)` |
|
|
1170
|
-
| `instantaneousPhase()` | `Angle[]` | folded into `[0, 2π)` |
|
|
1171
|
-
| `instantaneousFrequency(rate?, gate?)` | `number[]` | unwrapped phase difference, gated on the envelope |
|
|
1172
|
-
| `envelope()` | `number[]` | `abs(z[n])`, circular at the edges |
|
|
1173
|
-
| `zeroCrossingRate(perSecond?)` | `number` | crossings per adjacent sample pair |
|
|
1174
|
-
| `spectralFlux(frameSize?, hopSize?, window?)` | `number[]` | unit-norm magnitude distance between frames |
|
|
1175
|
-
| `autocorrelation(maximumLag?, normalization?)` | `number[]` | coefficient by default, `ρ[0] = 1` |
|
|
1176
|
-
| `spectralCentroid(window?)` | `Optional<Frequency>` | power-weighted mean frequency |
|
|
1177
|
-
| `spectralBandwidth(window?)` | `Optional<Frequency>` | power-weighted standard deviation |
|
|
1178
|
-
| `spectralRolloff(ratio?, window?)` | `Optional<Frequency>` | first bin reaching `ratio` of the power |
|
|
1179
|
-
| `spectralFlatness(window?)` | `number` | geometric over arithmetic mean of power |
|
|
1180
|
-
| `signalToNoiseRatio(frameSize?, hopSize?, window?, floorQuantile?)` | `Optional<number>` | median noise floor, `10·log10` |
|
|
1181
|
-
| `detrended(mode?)` | signal | `"none"`, `"constant"` (default), `"linear"` |
|
|
1182
|
-
| `windowed(window?)` | signal | applies a taper and adopts it |
|
|
1183
|
-
|
|
1184
|
-
The optional arguments are not decoration. `frameSize` defaults to `min(256, size)`, the hop to
|
|
1185
|
-
half a frame rounded down, `instantaneousFrequency` gates at `1E-3` of the peak envelope, and
|
|
1186
|
-
`spectralRolloff` uses `0.85`. Each default is the value a reference implementation picks when the
|
|
1187
|
-
parameter is named rather than supplied.
|
|
1188
|
-
|
|
1189
|
-
### 14.3 Amplitude, power and density
|
|
1190
|
-
|
|
1191
|
-
Three normalisations are needed because three different questions are being asked.
|
|
1192
|
-
|
|
1193
|
-
`amplitude()` answers "how large is the sinusoid on this bin": `2·abs(X[k]) / (count · CG)` for an
|
|
1194
|
-
interior bin and `abs(X[k]) / (count · CG)` at DC and Nyquist. The factor of two folds the
|
|
1195
|
-
conjugate half; the coherent gain removes the taper. Omit either and a unit tone reads `0.5`,
|
|
1196
|
-
which is minus 6.02 decibels.
|
|
1197
|
-
|
|
1198
|
-
`power()` answers "what mean square would a sinusoid on this bin have": `A² / 2` for an interior
|
|
1199
|
-
bin and `A²` at DC and Nyquist, because those two do not oscillate. This is the peak convention,
|
|
1200
|
-
and it deliberately does not satisfy Parseval: a window spreads each line over its main lobe, so
|
|
1201
|
-
the sum of every bin exceeds the mean square by the equivalent noise bandwidth — `1.5` for Hann.
|
|
1202
|
-
|
|
1203
|
-
`powerSpectralDensity()` answers "how much power per hertz": `abs(X)² / (rate · Σw²)`, averaged
|
|
1204
|
-
over overlapping segments. Only the sum of the squared weights keeps a white-noise floor unbiased;
|
|
1205
|
-
dividing by `count` reads `0.375` for unit noise and dividing by `count · CG²` reads `1.5`.
|
|
1206
|
-
|
|
1207
|
-
```typescript
|
|
1208
|
-
import { SynchronousSignalCollectable } from "semantic-typescript";
|
|
1209
|
-
|
|
1210
|
-
const round = (value: number): number => Number(value.toFixed(4));
|
|
1211
|
-
const tone = SynchronousSignalCollectable.useRate(
|
|
1212
|
-
Array.from({ length: 256 }, (_unused, index) => Math.cos(2 * Math.PI * 32 * index / 256)),
|
|
1213
|
-
256,
|
|
1214
|
-
);
|
|
1215
|
-
|
|
1216
|
-
round(tone.amplitude()[32]); // 1
|
|
1217
|
-
round(tone.power()[32]); // 0.5
|
|
1218
|
-
round(tone.power().reduce((sum, power) => sum + power, 0)); // 0.75
|
|
1219
|
-
round(tone.powerSpectralDensity(64n, 32n).totalPower()); // 0.5
|
|
1220
|
-
tone.powerSpectralDensity(64n, 32n).getSegments(); // 7n
|
|
1221
|
-
```
|
|
1222
|
-
|
|
1223
|
-
The pair of totals is the check worth remembering: the peak-convention sum is `0.75` while the
|
|
1224
|
-
density integrates to `0.5`, and the ratio is exactly the noise bandwidth. The density is also the
|
|
1225
|
-
only one of the three that survives a change of frame length.
|
|
1226
|
-
|
|
1227
|
-
### 14.4 Levels: the two decibel conventions
|
|
1228
|
-
|
|
1229
|
-
An amplitude and a power do not share a factor, so they do not share a logarithm. `amplitudeDecibel`
|
|
1230
|
-
applies `20·log10` and is correct for a voltage, a displacement or a sound pressure; `powerDecibel`
|
|
1231
|
-
applies `10·log10` and is correct for a power, an energy, a mean square or a density. The method
|
|
1232
|
-
names on the signal follow the same split.
|
|
1233
|
-
|
|
1234
|
-
```typescript
|
|
1235
|
-
import { amplitudeDecibel, powerDecibel, SynchronousSignalCollectable } from "semantic-typescript";
|
|
1236
|
-
|
|
1237
|
-
const round = (value: number): number => Number(value.toFixed(4));
|
|
1238
|
-
const tone = SynchronousSignalCollectable.useRate(
|
|
1239
|
-
Array.from({ length: 256 }, (_unused, index) => Math.cos(2 * Math.PI * 32 * index / 256)),
|
|
1240
|
-
256,
|
|
1241
|
-
);
|
|
1242
|
-
|
|
1243
|
-
amplitudeDecibel(0.25); // -12.0412
|
|
1244
|
-
powerDecibel(0.25); // -6.0206
|
|
1245
|
-
round(tone.decibel()[32]); // 0
|
|
1246
|
-
round(tone.powerDecibel()[32]); // -3.0103
|
|
1247
|
-
```
|
|
1248
|
-
|
|
1249
|
-
Both readings of the same tone are correct: its amplitude is one (zero decibels), and its mean
|
|
1250
|
-
square is one half (minus 3.01 decibels). Feeding a power to the amplitude convention would double
|
|
1251
|
-
the level in decibels, turning the correct minus 6.02 into minus 12.04.
|
|
1252
|
-
|
|
1253
|
-
### 14.5 The one-sided fold
|
|
1254
|
-
|
|
1255
|
-
A real signal satisfies `X[count − k] = conj(X[k])`, so the negative-frequency half carries no
|
|
1256
|
-
independent information. The fold doubles every interior bin and keeps the two self-conjugate bins
|
|
1257
|
-
— DC and, for an even length, Nyquist — exactly as the transform produced them. Those two are their
|
|
1258
|
-
own mirror image; doubling them invents energy and reports exactly 6.02 decibels too high.
|
|
1259
|
-
|
|
1260
|
-
```typescript
|
|
1261
|
-
import { SynchronousSignalCollectable } from "semantic-typescript";
|
|
1262
|
-
|
|
1263
|
-
const round = (value: number): number => Number(value.toFixed(4));
|
|
1264
|
-
const edge = SynchronousSignalCollectable.useRate(
|
|
1265
|
-
Array.from({ length: 16 }, (_unused, index) => 2 + Math.cos(Math.PI * index)),
|
|
1266
|
-
16,
|
|
1267
|
-
);
|
|
1268
|
-
const bins = edge.oneSided();
|
|
1269
|
-
|
|
1270
|
-
bins.length; // 9
|
|
1271
|
-
round(bins[0].magnitude()); // 16
|
|
1272
|
-
round(bins[8].magnitude()); // 8
|
|
1273
|
-
round(edge.amplitude()[0]); // 2
|
|
1274
|
-
round(edge.amplitude()[8]); // 1
|
|
1275
|
-
```
|
|
1276
|
-
|
|
1277
|
-
The raw magnitudes are `16` and `8` because the taper's weights sum to eight; dividing by
|
|
1278
|
-
`count · CG = 8` turns them into the calibrated amplitudes `2` and `1`. Doubling the edge bins
|
|
1279
|
-
would read `4` and `2`, and the error would never raise an exception.
|
|
1280
|
-
|
|
1281
|
-
### 14.6 Time–frequency views
|
|
1282
|
-
|
|
1283
|
-
`shortTimeFourierTransform` and `spectrogram` frame the record the way the reference short-time
|
|
1284
|
-
transform does: half a frame of zeros at each end, then a zero-padded tail so that a whole number
|
|
1285
|
-
of hops remains. The first frame is centred on the first sample, the last frame reaches the last
|
|
1286
|
-
sample, and frame `m` is centred at `m · hop / rate` seconds. Retaining only complete frames would
|
|
1287
|
-
discard up to `frameSize − 1` samples — for 1000 samples at frame 256 and hop 128, that is 104
|
|
1288
|
-
samples, about a tenth of the record.
|
|
1289
|
-
|
|
1290
|
-
```typescript
|
|
1291
|
-
import { SynchronousSignalCollectable } from "semantic-typescript";
|
|
1292
|
-
|
|
1293
|
-
const tone = SynchronousSignalCollectable.useRate(
|
|
1294
|
-
Array.from({ length: 64 }, (_unused, index) => Math.cos(2 * Math.PI * 4 * index / 64)),
|
|
1295
|
-
64,
|
|
1296
|
-
);
|
|
1297
|
-
const spectrogram = tone.spectrogram(16n, 8n);
|
|
1298
|
-
|
|
1299
|
-
spectrogram.size(); // 9
|
|
1300
|
-
spectrogram.getFrameSize(); // 16
|
|
1301
|
-
spectrogram.getBoundary(); // "zeros"
|
|
1302
|
-
spectrogram.getTime(0); // 0
|
|
1303
|
-
spectrogram.getTime(6); // 0.75
|
|
1304
|
-
tone.shortTimeFourierTransform(16n, 8n).length; // 9
|
|
1305
|
-
tone.spectralFlux(16n, 8n).length; // 8
|
|
1306
|
-
```
|
|
1307
|
-
|
|
1308
|
-
Every column is a `Spectrum` over `frameSize` bins, exactly as `spectrum()` would produce for the
|
|
1309
|
-
same frame, and `getTime()` reports frame centres rather than frame starts.
|
|
1310
|
-
|
|
1311
|
-
### 14.7 Windows, coherent gain and COLA
|
|
1312
|
-
|
|
1313
|
-
Every shape has two forms. The symmetric form divides the frame by `count − 1`; the periodic
|
|
1314
|
-
(DFT-even) form divides it by `count`, one sample short of a full period, so the taper is continuous
|
|
1315
|
-
under the periodic extension the DFT assumes. Analysis and overlap-add need the periodic form;
|
|
1316
|
-
filter design keeps the symmetric one.
|
|
1317
|
-
|
|
1318
|
-
The difference is measurable and never raises an error. At fifty per cent overlap the periodic Hann
|
|
1319
|
-
window sums to one at every position; the symmetric form ripples, by `0.84` decibels at sixteen
|
|
1320
|
-
samples, `0.21` at sixty-four and still `0.013` at one thousand and twenty-four. The class exposes
|
|
1321
|
-
the coherent gain and the equivalent noise bandwidth of whichever taper it holds, so the correction
|
|
1322
|
-
is never hard-coded: a symmetric Hann reads `0.4375` at eight samples, not `0.5`.
|
|
1323
|
-
|
|
1324
|
-
```typescript
|
|
1325
|
-
import {
|
|
1326
|
-
coherentGain, equivalentNoiseBandwidth, useFlatTopWindow,
|
|
1327
|
-
useHannPeriodicWindow, useHannWindow,
|
|
1328
|
-
} from "semantic-typescript";
|
|
1329
|
-
|
|
1330
|
-
const round = (value: number): number => Number(value.toFixed(4));
|
|
1331
|
-
|
|
1332
|
-
round(useHannWindow(1, 4)); // 0.75
|
|
1333
|
-
round(useHannPeriodicWindow(1, 4)); // 0.5
|
|
1334
|
-
coherentGain(useHannPeriodicWindow, 16); // 0.5
|
|
1335
|
-
equivalentNoiseBandwidth(useHannPeriodicWindow, 16); // 1.5
|
|
1336
|
-
useFlatTopWindow(0, 1024); // -0.000421
|
|
1337
|
-
Array.from({ length: 1024 }, (_unused, i) => useFlatTopWindow(i, 1024)).filter(w => w < 0).length; // 578
|
|
1338
|
-
```
|
|
1339
|
-
|
|
1340
|
-
The flat-top row is not a mistake in the table. A flat-top window is partially negative by
|
|
1341
|
-
definition — 578 of 1024 weights here — and clamping those weights to zero would perturb the main
|
|
1342
|
-
lobe and destroy the low scalloping loss the window exists for. The library returns the series
|
|
1343
|
-
unchanged.
|
|
1344
|
-
|
|
1345
|
-
### 14.8 Pitfalls
|
|
1346
|
-
|
|
1347
|
-
**Zero padding interpolates, it never resolves.** A transform length above the sample count samples
|
|
1348
|
-
the same underlying DTFT more densely: the peak *location* becomes finer and the bin spacing
|
|
1349
|
-
becomes `rate / size`, but the ability to separate two components is still governed by the window
|
|
1350
|
-
length and shape. A tone exactly between two bins reads `82.1093` and `80.8598` on the unpadded
|
|
1351
|
-
grid, and `128` at its own frequency on the padded grid.
|
|
1352
|
-
|
|
1353
|
-
```typescript
|
|
1354
|
-
import { SynchronousSignalCollectable, useRectangularWindow } from "semantic-typescript";
|
|
1355
|
-
|
|
1356
|
-
const round = (value: number): number => Number(value.toFixed(4));
|
|
1357
|
-
const tone = SynchronousSignalCollectable.useRate(
|
|
1358
|
-
Array.from({ length: 256 }, (_unused, index) => Math.cos(2 * Math.PI * 100.5 * index / 256)),
|
|
1359
|
-
256,
|
|
1360
|
-
);
|
|
1361
|
-
const flat = tone.spectrum(256n, useRectangularWindow);
|
|
1362
|
-
const padded = tone.spectrum(1024n, useRectangularWindow);
|
|
1363
|
-
|
|
1364
|
-
flat.getResolution().getHertz(); // 1
|
|
1365
|
-
padded.getResolution().getHertz(); // 0.25
|
|
1366
|
-
round(flat.magnitudeAt(100).get()); // 82.1093
|
|
1367
|
-
round(flat.magnitudeAt(101).get()); // 80.8598
|
|
1368
|
-
round(padded.magnitudeAt(402).get()); // 128
|
|
1369
|
-
padded.frequencyAt(402).get().getHertz(); // 100.5
|
|
1370
|
-
```
|
|
1371
|
-
|
|
1372
|
-
Two equal tones `0.4` bins apart stay merged at any padding: with a 256-point record and a Hann
|
|
1373
|
-
window, padding to 8192 produces a single local maximum, not two.
|
|
1374
|
-
|
|
1375
|
-
**The Hilbert transform is circular.** `analyticSignal`, `envelope` and `instantaneousPhase` treat
|
|
1376
|
-
the first and last samples as neighbours, so a record whose period does not divide its length is
|
|
1377
|
-
contaminated at both ends. The middle of the record is trustworthy; the ends are not.
|
|
1378
|
-
|
|
1379
|
-
```typescript
|
|
1380
|
-
import { SynchronousSignalCollectable } from "semantic-typescript";
|
|
1381
|
-
|
|
1382
|
-
const round = (value: number): number => Number(value.toFixed(4));
|
|
1383
|
-
const edge = SynchronousSignalCollectable.useRate(
|
|
1384
|
-
Array.from({ length: 512 }, (_unused, index) => Math.cos(2 * Math.PI * 8.3 * index / 512)),
|
|
1385
|
-
512,
|
|
1386
|
-
);
|
|
1387
|
-
const envelope = edge.envelope();
|
|
1388
|
-
|
|
1389
|
-
round(envelope.slice(8, -8).reduce((sum, value) => sum + value, 0) / 496); // 1.0025
|
|
1390
|
-
round(Math.max(...envelope.slice(0, 8))); // 1.2592
|
|
1391
|
-
round(Math.min(...envelope.slice(-8))); // 0.1314
|
|
1392
|
-
```
|
|
1393
|
-
|
|
1394
|
-
**A vanishing envelope does not define a phase.** Where the envelope collapses, the difference of
|
|
1395
|
-
two rounding-noise angles can be half a turn, which reads as half the sampling rate. The gate is
|
|
1396
|
-
what keeps the number meaningful, and the default is `1E-3` of the peak envelope.
|
|
1397
|
-
|
|
1398
|
-
```typescript
|
|
1399
|
-
import { SynchronousSignalCollectable } from "semantic-typescript";
|
|
1400
|
-
|
|
1401
|
-
const round = (value: number): number => Number(value.toFixed(4));
|
|
1402
|
-
const beat = SynchronousSignalCollectable.useRate(
|
|
1403
|
-
Array.from({ length: 4096 }, (_unused, index) =>
|
|
1404
|
-
Math.cos(2 * Math.PI * 100 * index / 2048) + Math.cos(2 * Math.PI * 102 * index / 2048)),
|
|
1405
|
-
2048,
|
|
1406
|
-
);
|
|
1407
|
-
|
|
1408
|
-
round(Math.max(...beat.instantaneousFrequency(2048, 0).map(Math.abs))); // 920.218
|
|
1409
|
-
round(Math.max(...beat.instantaneousFrequency().map(Math.abs))); // 101
|
|
1410
|
-
```
|
|
1411
|
-
|
|
1412
|
-
**An unnormalised correlation is not a similarity.** The raw sum `r[k]` grows with the record
|
|
1413
|
-
length, so two lengths cannot be compared. The default `"coefficient"` rule divides by `r[0]` and
|
|
1414
|
-
keeps every value in `[-1, 1]`; `"unbiased"` divides by `count − k` and pays with a variance that
|
|
1415
|
-
grows with the lag.
|
|
1416
|
-
|
|
1417
|
-
```typescript
|
|
1418
|
-
import { SynchronousSignalCollectable } from "semantic-typescript";
|
|
1419
|
-
|
|
1420
|
-
const round = (value: number): number => Number(value.toFixed(4));
|
|
1421
|
-
const periodic = SynchronousSignalCollectable.useRate(
|
|
1422
|
-
Array.from({ length: 1024 }, (_unused, index) => Math.cos(2 * Math.PI * 8 * index / 1024)),
|
|
1423
|
-
1024,
|
|
1424
|
-
);
|
|
1425
|
-
const rho = periodic.autocorrelation(200n);
|
|
1426
|
-
|
|
1427
|
-
round(rho[0]); // 1
|
|
1428
|
-
round(rho[128]); // 0.875
|
|
1429
|
-
round(rho[64]); // -0.9375
|
|
1430
|
-
round(periodic.autocorrelation(200n, "none")[0]); // 512
|
|
1431
|
-
```
|
|
1432
|
-
|
|
1433
|
-
One more reading deserves a warning: `spectralFlux` normalises each frame before comparing, so a
|
|
1434
|
-
level change alone produces zero flux. Its first and last values are edge artefacts of the zero
|
|
1435
|
-
boundary, not onsets.
|
|
1436
|
-
|
|
1437
|
-
---
|
|
1438
|
-
|
|
1439
|
-
<a id="15-license"></a>
|
|
1440
|
-
## 15. License
|
|
1441
|
-
|
|
1442
|
-
MIT © Eloy Kim
|
|
1
|
+
# **Semantic-TypeScript**
|
|
2
|
+
|
|
3
|
+
**Flow, Indexed.** Your data, under precise control.
|
|
4
|
+
|
|
5
|
+
English · [简体中文](./readme.cn.md) · [繁體中文](./readme.tw.md) · [日本語](./readme.jp.md) · [한국어](./readme.kr.md) · [Deutsch](./readme.de.md) · [Français](./readme.fr.md) · [Español](./readme.es.md) · [Русский](./readme.ru.md)
|
|
6
|
+
|
|
7
|
+
A type-safe stream processing library that unifies **JavaScript generators**, **Java Stream** and
|
|
8
|
+
**index-based data addressing**. Every element carries a `bigint` index, every pipeline is lazy, and
|
|
9
|
+
every terminal operation is an explicit opt-in.
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install semantic-typescript
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
- Zero runtime dependencies, pure ESM, ships its own types.
|
|
16
|
+
- 684 regression tests: `npm test`.
|
|
17
|
+
- Full change history: [CHANGELOG.md](./CHANGELOG.md).
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Table of Contents
|
|
22
|
+
|
|
23
|
+
1. [Design Philosophy](#1-design-philosophy)
|
|
24
|
+
2. [Architecture](#2-architecture)
|
|
25
|
+
3. [Quick Start](#3-quick-start)
|
|
26
|
+
4. [Factory Functions](#4-factory-functions)
|
|
27
|
+
5. [Transformation Operators](#5-transformation-operators)
|
|
28
|
+
6. [Collectables and Terminal Operations](#6-collectables-and-terminal-operations)
|
|
29
|
+
7. [Statistics and Windows](#7-statistics-and-windows)
|
|
30
|
+
8. [The Index Model](#8-the-index-model)
|
|
31
|
+
9. [The Asynchronous Model](#9-the-asynchronous-model)
|
|
32
|
+
10. [Periodic Analysis: Rings, Fields and Scores](#10-periodic-analysis-rings-fields-and-scores)
|
|
33
|
+
11. [Utility Helpers](#11-utility-helpers)
|
|
34
|
+
12. [Performance and Pitfalls](#12-performance-and-pitfalls)
|
|
35
|
+
13. [Phase Rings: An Exact Cycle of Positions](#13-phase-rings)
|
|
36
|
+
14. [Signal Analysis: Windows, Spectra and Time](#14-signal-analysis)
|
|
37
|
+
15. [License](#15-license)
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
<a id="1-design-philosophy"></a>
|
|
42
|
+
## 1. Design Philosophy
|
|
43
|
+
|
|
44
|
+
Three ideas, one pipeline:
|
|
45
|
+
|
|
46
|
+
**Index is data.** Every element is addressed by a `bigint`. Reversal, rotation, shuffling and
|
|
47
|
+
partitioning are index arithmetic, not buffer operations. Nothing is materialised until a
|
|
48
|
+
collectable asks for it.
|
|
49
|
+
|
|
50
|
+
**Laziness is the default.** A factory wraps a generator; an operator wraps another generator. No
|
|
51
|
+
element is requested until a terminal operation runs, so an infinite source is perfectly legal as
|
|
52
|
+
long as something downstream stops it.
|
|
53
|
+
|
|
54
|
+
**Terminals are explicit.** `map` returns a `Semantic`, not an array. Values appear only when you
|
|
55
|
+
name a collectable — `toUnordered()`, `toOrdered()`, `toNumericStatistics()`, `toWindow()` — and each
|
|
56
|
+
one states what it costs and what order it produces.
|
|
57
|
+
|
|
58
|
+
The same discipline applies to the mathematical half of the library. A `Complex`, an `Angle` or a
|
|
59
|
+
`Frequency` is an immutable value whose invariants are checked at construction; an `Optional`
|
|
60
|
+
replaces `null`; and a phase ring states exactly how many elements it holds, what period it has and
|
|
61
|
+
how balanced it is.
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
<a id="2-architecture"></a>
|
|
66
|
+
## 2. Architecture
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
factory ──▶ Semantic ──▶ Semantic ──▶ Collectable ──▶ result
|
|
70
|
+
(source) (map) (filter) (terminal)
|
|
71
|
+
▲
|
|
72
|
+
└── every operator returns a new Semantic wrapping a generator
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
| Layer | Module | Responsibility |
|
|
76
|
+
| :--- | :--- | :--- |
|
|
77
|
+
| Sources | `synchronous/collectors`, `asynchronous/collectors` | Wrap an iterable, a generator, a promise, a blob, an event target |
|
|
78
|
+
| Pipelines | `synchronous/semantic`, `asynchronous/semantic` | Lazy operators and index rewriting |
|
|
79
|
+
| Collectables | `synchronous/semantic`, `asynchronous/semantic` | Ordered, unordered, statistics, window and custom terminals |
|
|
80
|
+
| Collectors | `synchronous/collector`, `asynchronous/collector` | The fold primitives every terminal is built from |
|
|
81
|
+
| Mathematics | `mathematics/*` | Angles, complexes, frequencies, spectra, windows, estimators, epicycles, phase rings |
|
|
82
|
+
| Structures | `structure/*` | Lists, sets, maps, trees, forests, rings, fields |
|
|
83
|
+
| Errors | `structure/error` | The typed `SemanticError` hierarchy and its brand guards |
|
|
84
|
+
|
|
85
|
+
Synchronous and asynchronous families share one vocabulary: the same operator names, the same index
|
|
86
|
+
semantics, the same collectables. The asynchronous variants simply return promises, and they queue
|
|
87
|
+
writes so that a read issued after a write always observes it.
|
|
88
|
+
|
|
89
|
+
### 2.1 What this release moved and broke
|
|
90
|
+
|
|
91
|
+
Three modules changed location or shape, and a set of defaults changed meaning. Every old behaviour
|
|
92
|
+
is still reachable through an explicit option; nothing was removed without a replacement.
|
|
93
|
+
|
|
94
|
+
| Change | Before | Now |
|
|
95
|
+
| :--- | :--- | :--- |
|
|
96
|
+
| Forests | `structure/tree/rootedForest`, `structure/tree/unionFindForest` | `structure/forest/rootedForest`, `structure/forest/unionFindForest`; `tree/index.ts` no longer re-exports them |
|
|
97
|
+
| Errors | ad-hoc native errors | a fifteen-class hierarchy in `structure/error.ts`, with one brand guard per class and the `SemanticRangeError` alias |
|
|
98
|
+
| Estimator options | fixed definitions | `mathematics/quantile.ts` exports `QuantileMethod`, `QuantileOptions`, `SkewnessEstimator`, `KurtosisEstimator` and `VarianceOptions` |
|
|
99
|
+
| `quantile` | Hyndman–Fan type 1 | type 7 — R `quantile`, NumPy `linear`, Excel `PERCENTILE.INC` |
|
|
100
|
+
| `skewness` / `kurtosis` | `g1` / `g2` | the bias-corrected `G1` / `G2` |
|
|
101
|
+
| `variance` / `standardDeviation` | `ddof = 0` | `ddof = 1`, wrapped in an `Optional` |
|
|
102
|
+
| `decibel` | one `20·log10` function | removed; `amplitudeDecibel` (20·log10) and `powerDecibel` (10·log10) |
|
|
103
|
+
| `Spectrum.flatness()` | magnitudes over both halves | powers over the one-sided half |
|
|
104
|
+
| `Spectrum.bandEnergy()` | raw two-sided sum | one-sided by default, folding the conjugate half |
|
|
105
|
+
| `shortTimeFourierTransform` / `spectrogram` | symmetric Hann, complete frames | periodic Hann, `boundary: "zeros"`, `padded: true` |
|
|
106
|
+
|
|
107
|
+
Chapters 6, 7 and 10 document the replacements; the measured consequences are in
|
|
108
|
+
[CHANGELOG.md](./CHANGELOG.md).
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
<a id="3-quick-start"></a>
|
|
113
|
+
## 3. Quick Start
|
|
114
|
+
|
|
115
|
+
```typescript
|
|
116
|
+
import {
|
|
117
|
+
useOf, useFrom, useRange, useText, useStringify, usePromise
|
|
118
|
+
} from "semantic-typescript";
|
|
119
|
+
|
|
120
|
+
// Numeric aggregation
|
|
121
|
+
const sum: number = useOf(10, 20, 30, 40)
|
|
122
|
+
.map(n => n * 2)
|
|
123
|
+
.toNumericStatistics()
|
|
124
|
+
.summate(); // 200
|
|
125
|
+
|
|
126
|
+
// BigInt aggregation
|
|
127
|
+
const bigSum: bigint = useOf(10n, 20n, 30n, 40n)
|
|
128
|
+
.map(n => n * 2n)
|
|
129
|
+
.toBigintStatistics()
|
|
130
|
+
.summate(); // 200n
|
|
131
|
+
|
|
132
|
+
// Reordering is index arithmetic — collect with toOrdered() for it to take effect
|
|
133
|
+
const reversed: number[] = useFrom([1, 2, 3, 4, 5]).reverse().toOrdered().toArray(); // [5,4,3,2,1]
|
|
134
|
+
const rotated: number[] = useFrom([1, 2, 3, 4, 5]).translate(2).toOrdered().toArray(); // [4,5,1,2,3]
|
|
135
|
+
|
|
136
|
+
// Lazy evaluation over a large range
|
|
137
|
+
const multiples: bigint[] = useRange(0n, 1_000_000n)
|
|
138
|
+
.filter(n => n % 17n === 0n)
|
|
139
|
+
.limit(10n)
|
|
140
|
+
.toUnordered()
|
|
141
|
+
.toArray(); // [0n,17n,…,153n]
|
|
142
|
+
|
|
143
|
+
// Unicode-safe string processing (code points, not UTF-16 units)
|
|
144
|
+
useText("semantic 👍").toUnordered().forEach(character => console.log(character));
|
|
145
|
+
|
|
146
|
+
// Circular-safe serialisation
|
|
147
|
+
const cyclic: any = { a: 1 }; cyclic.self = cyclic;
|
|
148
|
+
useStringify(cyclic); // '{"a":1}'
|
|
149
|
+
|
|
150
|
+
// Terminals hand the elements to real structures, not only to arrays
|
|
151
|
+
useOf(3, 1, 4, 1, 5).toCollectable().toOrderedSet().toArray(); // [1,3,4,5]
|
|
152
|
+
useFrom([3, 1, 4, 1, 5]).toCollectable().toBinarySearchTree().size(); // 5n
|
|
153
|
+
|
|
154
|
+
// Async sources
|
|
155
|
+
const total = await usePromise(Promise.resolve(7)).toNumericStatistics().summate(); // 7
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
<a id="4-factory-functions"></a>
|
|
161
|
+
## 4. Factory Functions
|
|
162
|
+
|
|
163
|
+
Construction is always O(1) — a factory only wraps a generator. The table states the cost of
|
|
164
|
+
*consuming* `n` elements, plus the extra space the source itself needs.
|
|
165
|
+
|
|
166
|
+
| Factory | Signature (result) | Time (consume n) | Space (source) | Notes |
|
|
167
|
+
| :--- | :--- | :--- | :--- | :--- |
|
|
168
|
+
| `useOf` | `useOf<E>(...elements) → SynchronousSemantic<E>` | O(n) | O(n) | Indices are 0..n−1; the spread argument is already materialised |
|
|
169
|
+
| `useFrom` | `useFrom<E>(Iterable<E>) → SynchronousSemantic<E>`<br>`useFrom<E>(AsyncIterable<E>) → AsynchronousSemantic<E>` | O(n) | O(1) | Wraps any iterable; iterated once on collect |
|
|
170
|
+
| `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 |
|
|
171
|
+
| `useFill` | `useFill<E>(element \| supplier, count) → SynchronousSemantic<E>` | O(n) | O(1) | A supplier is called once per element |
|
|
172
|
+
| `useEmpty` | `useEmpty<E>() → SynchronousSemantic<E>` | O(1) | O(1) | Identity element for `concat` |
|
|
173
|
+
| `useText` | `useText(text, delimiter?)` | O(n) | O(n) for the text | Iterates **code points**, so emoji and CJK stay single elements |
|
|
174
|
+
| `useAttribute` | `useAttribute<T>(object)` | O(v + e) | O(d + v) | Recursively walks nested objects with cycle protection |
|
|
175
|
+
| `useGenerate` | `useGenerate<E>(supplier, interrupt)` | O(n) × supplier cost | O(1) | **Must** have a stopping `interrupt` or a downstream `limit` |
|
|
176
|
+
| `useIterate` | `useIterate<E>(Iterable<E>)` / `(AsyncIterable<E>)` | O(n) per collection | O(1) | Replays the iterator on each collection |
|
|
177
|
+
| `useInterval` | `useInterval(period, delay?) → AsynchronousSemantic<number>` | n × period (real time) | O(1) | Emits elapsed time |
|
|
178
|
+
| `usePromise` | `usePromise<T>(promise) → AsynchronousSemantic<T>` | O(1) | O(1) | A single element at index `0n` |
|
|
179
|
+
| `useBlob` | `useBlob(blob, chunk?) → AsynchronousSemantic<Uint8Array>` | O(blob size) | O(chunk) per copy | Default chunk is 64 KiB |
|
|
180
|
+
| `useSubscription` | `useSubscription(target, subscriber, event) → AsynchronousSemantic<Event>` | one element per event | O(1) | `limit()` / `takeWhile()` define the unsubscribe boundary |
|
|
181
|
+
|
|
182
|
+
```typescript
|
|
183
|
+
useOf(1, 2, 3).toUnordered().toArray(); // [1,2,3]
|
|
184
|
+
useFrom(new Set([1, 2, 2])).toUnordered().toArray(); // [1,2]
|
|
185
|
+
useRange(0, 5).toUnordered().toArray(); // [0,1,2,3,4]
|
|
186
|
+
useRange(10n, 0n, -5n).toUnordered().toArray(); // [10n,5n]
|
|
187
|
+
useFill("x", 3n).toUnordered().toArray(); // ["x","x","x"]
|
|
188
|
+
useText("A😊B").toUnordered().toArray(); // ["A","😊","B"]
|
|
189
|
+
useGenerate(() => 7, (_e, index) => index >= 3n).toUnordered().toArray(); // [7,7,7]
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
---
|
|
193
|
+
|
|
194
|
+
<a id="5-transformation-operators"></a>
|
|
195
|
+
## 5. Transformation Operators
|
|
196
|
+
|
|
197
|
+
Every operator returns a new `Semantic`; none touches an element until a collectable runs. Time is
|
|
198
|
+
the extra cost **per element**; space is what the operator keeps while the pipeline runs.
|
|
199
|
+
|
|
200
|
+
| Operator | Purpose | Index effect | Time | Space |
|
|
201
|
+
| :--- | :--- | :--- | :--- | :--- |
|
|
202
|
+
| `map(mapper)` | 1:1 transform | forwarded | O(1) | O(1) |
|
|
203
|
+
| `filter(predicate)` | keep matching elements | forwarded | O(1) | O(1) |
|
|
204
|
+
| `flat(mapper)` / `flatMap(mapper)` | 1:N flatten | re-indexed from 0 | O(1) per emit | O(1) |
|
|
205
|
+
| `distinct(keyExtractor?)` | drop repeated keys, keep the first | forwarded | O(1) average | O(n) |
|
|
206
|
+
| `peek(consumer)` | side effect, element passes through | forwarded | O(1) | O(1) |
|
|
207
|
+
| `limit(count)` | take the first *n* | re-indexed from 0 | O(1) | O(1) |
|
|
208
|
+
| `skip(count)` | drop the first *n* | forwarded | O(1) | O(1) |
|
|
209
|
+
| `sub(start, end)` | positional slice `[start, end)` | forwarded | O(1) | O(1) |
|
|
210
|
+
| `takeWhile(predicate)` | take while the predicate holds | forwarded | O(1) | O(1) |
|
|
211
|
+
| `dropWhile(predicate)` | drop while the predicate holds | re-indexed from 0 | O(1) | O(1) |
|
|
212
|
+
| `concat(other)` | append a semantic or iterable | continues the index | O(1) | O(1) |
|
|
213
|
+
| `redirect(redirector)` | compute a new index however you like | **yours** | O(1) | O(1) |
|
|
214
|
+
| `reverse()` | `redirect((_e, i) => -i - 1n)` | negated and shifted | O(1) | O(1) |
|
|
215
|
+
| `translate(offset \| mapper)` | shift every index | `i + offset` | O(1) | O(1) |
|
|
216
|
+
| `shuffle(mapper?)` | randomise the index | random | O(1) | O(1) |
|
|
217
|
+
| `pipe(conversion)` | hand the generator to a custom function | up to you | yours | yours |
|
|
218
|
+
| `semantic()` | lift a collectable back into a semantic | unchanged | O(1) | O(1) |
|
|
219
|
+
|
|
220
|
+
**`reverse`, `translate` and `shuffle` only rewrite indices.** Nothing is buffered or rotated until
|
|
221
|
+
`toOrdered()` normalises the indices modulo the element count:
|
|
222
|
+
|
|
223
|
+
```typescript
|
|
224
|
+
useFrom([1, 2, 3, 4, 5]).reverse().toOrdered().toArray(); // [5,4,3,2,1]
|
|
225
|
+
useFrom([1, 2, 3, 4, 5]).translate(2).toOrdered().toArray(); // [4,5,1,2,3] (rotation)
|
|
226
|
+
useFrom([1, 2, 3, 4, 5]).translate(7).toOrdered().toArray(); // [4,5,1,2,3] (7 ≡ 2 mod 5)
|
|
227
|
+
useFrom([1, 2, 3]).shuffle((_e, i) => -i - 1n).toOrdered().toArray(); // [3,2,1]
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
---
|
|
231
|
+
|
|
232
|
+
<a id="6-collectables-and-terminal-operations"></a>
|
|
233
|
+
## 6. Collectables and Terminal Operations
|
|
234
|
+
|
|
235
|
+
The collectable owns both the **order** and the **index bookkeeping**. `n` is the element count.
|
|
236
|
+
|
|
237
|
+
### 6.1 Collectables
|
|
238
|
+
|
|
239
|
+
| Collectable | Structure | Time | Order | Use when |
|
|
240
|
+
| :--- | :--- | :--- | :--- | :--- |
|
|
241
|
+
| `toUnordered()` | `Map<bigint, E>` keyed by index | O(n) | insertion | maximum throughput; raw indices preserved untouched |
|
|
242
|
+
| `toOrdered()` | array sorted by element index | O(n log n) | by normalised index | reversal/rotation must take effect |
|
|
243
|
+
| `toOrdered(comparator)` | sorted by your comparator | O(n log n) | by your comparator | sorted views |
|
|
244
|
+
| `toNumericStatistics()` | value-sorted buffer | O(n log n) | by value | statistics over `number` |
|
|
245
|
+
| `toBigintStatistics()` | value-sorted buffer | O(n log n) | by value | statistics over `bigint` |
|
|
246
|
+
| `toWindow()` | value-sorted buffer | O(n log n) | by value | `slide` / `tumble` |
|
|
247
|
+
| `toCollectable()` | the collectable itself, wired to the generator | O(1) | — | the cheapest way to reach a terminal |
|
|
248
|
+
| `toCollectable(mapper)` | whatever the mapper returns | — | — | custom collectables |
|
|
249
|
+
|
|
250
|
+
### 6.2 Terminals
|
|
251
|
+
|
|
252
|
+
Every terminal lives on the **collectable**, not on the `Semantic`. That is what "terminals are
|
|
253
|
+
explicit" means in practice: a pipeline produces nothing until a collectable is named, and
|
|
254
|
+
`toCollectable()` with no mapper is the cheapest one to name.
|
|
255
|
+
|
|
256
|
+
| Terminal | Returns | Time | Notes |
|
|
257
|
+
| :--- | :--- | :--- | :--- |
|
|
258
|
+
| `toArray()` / `toSet()` | `Array<E>` / `Set<E>` | O(n) | Drains the buffer; `toSet` deduplicates by identity |
|
|
259
|
+
| `toMap(key, value?)` | `Map<K, V>` | O(n) average | Last write wins |
|
|
260
|
+
| `count()` / `isEmpty()` | `bigint` / `boolean` | O(1) | The buffer already knows |
|
|
261
|
+
| `forEach(action)` | `void` | O(n) | `action(element, index)` |
|
|
262
|
+
| `join(…)` | `string` | O(n) | `join()`, `join(delimiter)`, `join(prefix, delimiter, suffix)` |
|
|
263
|
+
| `reduce(…)` | `Optional<E>` / `E` / `R` | O(n) | Empty stream → empty `Optional` |
|
|
264
|
+
| `group(classifier)` / `groupBy(key, value)` | `Map<K, E[]>` | O(n) average | — |
|
|
265
|
+
| `partition(count)` | `Array<Array<E>>` | O(n) | Round-robin by index |
|
|
266
|
+
| `partitionBy(classifier)` | `Array<Array<E>>` | O(n) | The bucket number comes from the classifier |
|
|
267
|
+
| `anyMatch` / `allMatch` / `nonMatch` | `boolean` | O(n) worst, short-circuits | — |
|
|
268
|
+
| `findAny` / `findFirst` / `findLast` | `Optional<E>` | O(1)–O(n) | Positional in materialised order |
|
|
269
|
+
| `findAt(k)` / `findNegativeAt(k)` | `Optional<E>` | O(k) / O(n−k) | Counts from the front / the end |
|
|
270
|
+
| `findMaximum(cmp?)` / `findMinimum(cmp?)` | `Optional<E>` | O(n) | Defaults to a structural comparison |
|
|
271
|
+
| `frequency()` | `Map<E, bigint>` | O(n) average | Statistics collectables only |
|
|
272
|
+
| `write(stream, accumulator?)` | `WritableStream<S>` | O(n) | IO-like terminal |
|
|
273
|
+
| `collect(collector)` | `R` | O(n) | The generic fold behind every terminal above |
|
|
274
|
+
|
|
275
|
+
### 6.3 Structure collectors
|
|
276
|
+
|
|
277
|
+
`toArray`, `toSet` and `toMap` end a pipeline in a JavaScript container. Twelve more terminals end it
|
|
278
|
+
in one of the library's own structures, so the result carries the invariants that structure
|
|
279
|
+
guarantees rather than the ones an array happens to have. They are available on every collectable,
|
|
280
|
+
including the bare one from `toCollectable()`.
|
|
281
|
+
|
|
282
|
+
| Terminal | Returns | Order | Cost |
|
|
283
|
+
| :--- | :--- | :--- | :--- |
|
|
284
|
+
| `toArray()` | `Array<E>` | traversal | O(n) |
|
|
285
|
+
| `toSet()` | `Set<E>` | insertion, SameValueZero | O(n) average |
|
|
286
|
+
| `toMap(key, value?)` | `Map<K, V>` | insertion; the last write wins | O(n) average |
|
|
287
|
+
| `toOrderedSet(comparator?)` | `SynchronousOrderedSet<E>` | comparator-ascending | O(n²) worst case |
|
|
288
|
+
| `toUnorderedSet()` | `SynchronousUnorderedSet<E>` | unspecified (hash buckets) | O(n) expected |
|
|
289
|
+
| `toOrderedMap(key, value?)` | `SynchronousOrderedMap<K, V>` | insertion; the last write wins | O(n) expected |
|
|
290
|
+
| `toUnorderedMap(key, value?)` | `SynchronousUnorderedMap<K, V>` | unspecified | O(n) expected |
|
|
291
|
+
| `toList()` | `SynchronousList<E>` | traversal, duplicates kept | O(n) |
|
|
292
|
+
| `toRing(span?)` | `SynchronousRing<E>` | traversal fixes ring positions | O(n) |
|
|
293
|
+
| `toLRUSet(capacity)` | `SynchronousUnorderedLRUSet<E>` | recency; the last `capacity` survive | O(n²) worst case |
|
|
294
|
+
| `toLRUMap(capacity, key, value?)` | `SynchronousUnorderedLRUMap<K, V>` | recency | O(n) expected |
|
|
295
|
+
| `toSkipSet(comparator?)` | `SynchronousSkipSet<E>` | comparator-ascending, navigable | O(n²) worst case |
|
|
296
|
+
| `toSkipMap(key, value?)` | `SynchronousSkipMap<K, V>` | key comparator, navigable | O(n) expected |
|
|
297
|
+
| `toBinarySearchTree(comparator?)` | `SynchronousBinarySearchTree<E>` | comparator-ascending, height-balanced | O(n log n) |
|
|
298
|
+
| `toRootedForest()` | `SynchronousRootedForest<E>` | encounter order becomes structure | O(n) |
|
|
299
|
+
|
|
300
|
+
The result type follows the collectable's own element type, so no terminal needs a type argument: a
|
|
301
|
+
`SynchronousCollectable<E>` hands you `Array<E>`, `SynchronousList<E>` or `SynchronousOrderedSet<E>`
|
|
302
|
+
directly. Only the business arguments remain — the comparator of `toOrderedSet` / `toSkipSet` /
|
|
303
|
+
`toBinarySearchTree`, the capacity of the LRU pair, the span of `toRing`, and the key and value
|
|
304
|
+
extractors of the map family.
|
|
305
|
+
|
|
306
|
+
A ring is a set, so repeated elements keep their first position; every other structure above keeps
|
|
307
|
+
duplicates unless its own contract says otherwise (`toOrderedSet`, `toUnorderedSet`, `toSkipSet` are
|
|
308
|
+
sets; `toBinarySearchTree` and `toList` are not).
|
|
309
|
+
|
|
310
|
+
```typescript
|
|
311
|
+
const numbers = useOf(3, 1, 4, 1, 5).toCollectable();
|
|
312
|
+
|
|
313
|
+
numbers.toOrderedSet().toArray(); // [1,3,4,5]
|
|
314
|
+
numbers.toUnorderedSet().size(); // 4n — the duplicate 1 collapses
|
|
315
|
+
numbers.toList().toArray(); // [3,1,4,1,5]
|
|
316
|
+
numbers.toSkipSet().first().get(); // 1 — navigable by comparator
|
|
317
|
+
numbers.toBinarySearchTree().size(); // 5n — duplicates retained
|
|
318
|
+
numbers.toRootedForest().getRoots().length; // 1
|
|
319
|
+
useOf(1, 2, 3).toCollectable().toRing().phaseAt(3n).get().toDegreeValue(); // 0
|
|
320
|
+
useOf(3, 1, 3).toCollectable().toOrderedMap(n => n, n => `v${n}`).get(3).get(); // "v3"
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
The asynchronous collectables expose the same terminals and resolve each in a promise, so the call
|
|
324
|
+
shape only gains an `await`.
|
|
325
|
+
|
|
326
|
+
---
|
|
327
|
+
|
|
328
|
+
<a id="7-statistics-and-windows"></a>
|
|
329
|
+
## 7. Statistics and Windows
|
|
330
|
+
|
|
331
|
+
### 7.1 The statistics surface
|
|
332
|
+
|
|
333
|
+
```typescript
|
|
334
|
+
useOf(3, 1, 4, 1, 5).toNumericStatistics().summate(); // 14
|
|
335
|
+
useOf(3, 1, 4, 1, 5).toNumericStatistics().average(); // 2.8
|
|
336
|
+
useOf(3, 1, 4, 1, 5).toNumericStatistics().median(); // 3
|
|
337
|
+
useOf(3, 1, 4, 1, 5).toNumericStatistics().mode(); // 1
|
|
338
|
+
useOf(3, 1, 4, 1, 5).toNumericStatistics().variance().get(); // 3.2 (n − 1)
|
|
339
|
+
useOf(3, 1, 4, 1, 5).toNumericStatistics().standardDeviation().get(); // 1.7889 (n − 1)
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
Both statistics families expose the same surface — `summate`, `average`, `median`, `mode`,
|
|
343
|
+
`variance`, `standardDeviation`, `frequency`, `findMaximum`, `findMinimum` — with `bigint` arithmetic
|
|
344
|
+
for `toBigintStatistics()`. The dispersion measures and the quantile family return `Optional`,
|
|
345
|
+
because a sample can be too small for them to be defined; unwrap them with `.get()`, `.orElse(…)` or
|
|
346
|
+
`.isPresent()`.
|
|
347
|
+
|
|
348
|
+
### 7.2 Estimators and defaults
|
|
349
|
+
|
|
350
|
+
The defaults are the definitions R, NumPy, pandas and Excel use, so a value computed here can be
|
|
351
|
+
compared with those tools without a correction factor.
|
|
352
|
+
|
|
353
|
+
| Statistic | Default | Previous | Restore the old value with |
|
|
354
|
+
| :--- | :--- | :--- | :--- |
|
|
355
|
+
| `quantile(probability)` | Hyndman–Fan type 7 | type 1 | `{ method: "type-1" }` |
|
|
356
|
+
| `interquartileRange()` | Hyndman–Fan type 7 | type 1 | `{ method: "type-1" }` |
|
|
357
|
+
| `skewness()` | bias-corrected `G1` | `g1` | `{ estimator: "g1" }` |
|
|
358
|
+
| `kurtosis()` | bias-corrected excess `G2` | `g2` | `{ estimator: "g2" }` |
|
|
359
|
+
| `variance()` | `ddof = 1` | `ddof = 0` | `{ ddof: 0 }` |
|
|
360
|
+
| `standardDeviation()` | `ddof = 1` | `ddof = 0` | `{ ddof: 0 }` |
|
|
361
|
+
|
|
362
|
+
All nine Hyndman–Fan types are available as `"type-1"` … `"type-9"`, and `{ excess: false }` adds
|
|
363
|
+
three back to a kurtosis, reporting the non-excess fourth standardised moment. The option types —
|
|
364
|
+
`QuantileMethod`, `QuantileOptions`, `SkewnessEstimator`, `KurtosisEstimator`, `VarianceOptions` —
|
|
365
|
+
are exported from `mathematics/quantile.ts`.
|
|
366
|
+
|
|
367
|
+
```typescript
|
|
368
|
+
const sample = useOf(1, 2, 3, 4).toNumericStatistics();
|
|
369
|
+
|
|
370
|
+
sample.variance().get(); // 1.6667 (5/3, ddof = 1)
|
|
371
|
+
sample.variance({ ddof: 0 }).get(); // 1.25
|
|
372
|
+
sample.standardDeviation().get(); // 1.2910
|
|
373
|
+
sample.skewness().get(); // 0
|
|
374
|
+
sample.kurtosis().get(); // -1.2 (G2)
|
|
375
|
+
sample.kurtosis({ estimator: "g2" }).get(); // -1.36
|
|
376
|
+
sample.kurtosis({ excess: false }).get(); // 1.8
|
|
377
|
+
|
|
378
|
+
const quartiles = useOf(1, 2, 3, 4, 5, 6, 7, 8).toNumericStatistics();
|
|
379
|
+
quartiles.quantile(0.75).get(); // 6.25 (type 7)
|
|
380
|
+
quartiles.quantile(0.75, { method: "type-1" }).get(); // 6 (nearest rank)
|
|
381
|
+
quartiles.interquartileRange().get(); // 3.5
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
`G1` and `G2` are undefined below three and four observations, and the sample variance and standard
|
|
385
|
+
deviation are undefined below two under `ddof = 1`. That is reported as an empty `Optional` rather
|
|
386
|
+
than as a misleading zero:
|
|
387
|
+
|
|
388
|
+
```typescript
|
|
389
|
+
useOf(1, 2, 3).toNumericStatistics().kurtosis().isEmpty(); // true (G2 needs four observations)
|
|
390
|
+
useOf(1, 2).toNumericStatistics().skewness().isEmpty(); // true (G1 needs three)
|
|
391
|
+
useOf(1).toNumericStatistics().variance().isEmpty(); // true (ddof = 1 needs two)
|
|
392
|
+
useOf(1).toNumericStatistics().variance({ ddof: 0 }).get(); // 0
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
The bigint views accept the same options and round every result back to a whole `bigint`.
|
|
396
|
+
|
|
397
|
+
### 7.3 Windows
|
|
398
|
+
|
|
399
|
+
Windows re-expose the buffered elements as a pipeline of pipelines, so a window can be consumed
|
|
400
|
+
without materialising it into an array first. Both forms include a final partial window.
|
|
401
|
+
|
|
402
|
+
```typescript
|
|
403
|
+
useFrom([1, 2, 3, 4]).toWindow().slide(2n, 1n).toUnordered().toArray()
|
|
404
|
+
.map(window => window.toUnordered().toArray()); // [[1,2],[2,3],[3,4],[4]]
|
|
405
|
+
|
|
406
|
+
useFrom([1, 2, 3, 4]).toWindow().tumble(2n).toUnordered().toArray()
|
|
407
|
+
.map(window => window.toUnordered().toArray()); // [[1,2],[3,4]]
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
---
|
|
411
|
+
|
|
412
|
+
<a id="8-the-index-model"></a>
|
|
413
|
+
## 8. The Index Model
|
|
414
|
+
|
|
415
|
+
An index is a `bigint` attached to every element. Operators either **forward** it, **re-index** it
|
|
416
|
+
from zero, or let you **compute** it.
|
|
417
|
+
|
|
418
|
+
Ordered collection normalises each index modulo the element count, then sorts by the result. That is
|
|
419
|
+
why `translate(7)` on five elements equals `translate(2)`: `7 ≡ 2 (mod 5)`.
|
|
420
|
+
|
|
421
|
+
Unordered collection keeps whatever index the pipeline produced and returns elements in arrival
|
|
422
|
+
order. Negative indices are legal and simply sort before non-negative ones once normalised.
|
|
423
|
+
|
|
424
|
+
---
|
|
425
|
+
|
|
426
|
+
<a id="9-the-asynchronous-model"></a>
|
|
427
|
+
## 9. The Asynchronous Model
|
|
428
|
+
|
|
429
|
+
`AsynchronousSemantic` mirrors the synchronous surface, returning promises. Writes are queued, so a
|
|
430
|
+
read issued after a write always observes it.
|
|
431
|
+
|
|
432
|
+
```typescript
|
|
433
|
+
const ring = new AsynchronousRing([1, 2]);
|
|
434
|
+
const added = ring.add(3); // queued
|
|
435
|
+
const sampled = ring.elementAt(2n); // observes the write above
|
|
436
|
+
await added;
|
|
437
|
+
(await sampled).get().element; // 3
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
Factories that are asynchronous by nature — `usePromise`, `useBlob`, `useInterval`,
|
|
441
|
+
`useSubscription` — return `AsynchronousSemantic` directly, and `useFrom` / `useIterate` upgrade
|
|
442
|
+
themselves when handed an `AsyncIterable`.
|
|
443
|
+
|
|
444
|
+
---
|
|
445
|
+
|
|
446
|
+
<a id="10-periodic-analysis-rings-fields-and-scores"></a>
|
|
447
|
+
## 10. Periodic Analysis: Rings, Fields and Scores
|
|
448
|
+
|
|
449
|
+
The mathematical half of the library treats a cycle as a first-class object rather than a lookup
|
|
450
|
+
table. Three constructions work together.
|
|
451
|
+
|
|
452
|
+
### 10.1 A ring is one integer
|
|
453
|
+
|
|
454
|
+
A `Ring` is an **n-fold cycle**: the element count, the positional period and the number of distinct
|
|
455
|
+
phases are the same integer `n`. The phase of a position is computed from its exact residue, so the
|
|
456
|
+
ring is periodic to the bit:
|
|
457
|
+
|
|
458
|
+
```typescript
|
|
459
|
+
import { SynchronousRing } from "semantic-typescript";
|
|
460
|
+
|
|
461
|
+
const ring = new SynchronousRing(["a", "b", "c", "d", "e", "f"]);
|
|
462
|
+
ring.phaseAt(0n).get().toDegreeValue(); // 0
|
|
463
|
+
ring.phaseAt(1n).get().toDegreeValue(); // 60
|
|
464
|
+
ring.phaseAt(6n).get().getValue() === ring.phaseAt(0n).get().getValue(); // true, exactly
|
|
465
|
+
ring.purity(); // 1
|
|
466
|
+
ring.isBalanced(); // true
|
|
467
|
+
ring.sequences().map(z => z.magnitude()); // [0, 6, 0, 0, 0, 0]
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
`frequency` is the **ambient** rotation rate, not a phase step between positions: the environment
|
|
471
|
+
moves every phasor by the same angle, so it can never break the intrinsic pattern. `span` fixes how
|
|
472
|
+
many turns the ring occupies.
|
|
473
|
+
|
|
474
|
+
`purity()` is the share of energy carried by the fundamental mode,
|
|
475
|
+
`max(|c₁|², |c_{n−1}|²) / (n · Σ|z|²)`. It is `1` exactly when the ring is the `n`-th roots of unity
|
|
476
|
+
up to rotation, and it costs `O(n)` because only two bins are needed. Both directions are tested
|
|
477
|
+
because a ring may traverse the circle either way.
|
|
478
|
+
|
|
479
|
+
### 10.2 A field is a bounded score
|
|
480
|
+
|
|
481
|
+
A `PhaseField` maps a relative phase to a score in `[-1, 1]`:
|
|
482
|
+
|
|
483
|
+
```typescript
|
|
484
|
+
import { useField, useRadian } from "semantic-typescript";
|
|
485
|
+
|
|
486
|
+
const cosine = useField([1]); // cos(2πΔ): +1 aligned, −1 opposed
|
|
487
|
+
const mixed = useField([0.4, 0.3, 0.3]); // first three harmonics
|
|
488
|
+
|
|
489
|
+
cosine.sample(useRadian(0)); // 1
|
|
490
|
+
cosine.sample(useRadian(Math.PI)); // -1
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
The score is a truncated cosine series, `a₀ + Σ wₘ·cos(2πmΔ)`, and the constructor enforces
|
|
494
|
+
`|a₀| + Σ|wₘ| ≤ 1`. By the triangle inequality the score can never leave `[-1, 1]` — the bound is a
|
|
495
|
+
**consequence of the construction**, not a clamp applied afterwards.
|
|
496
|
+
|
|
497
|
+
The reason the field is a series rather than a table: an `n`-point table reproduces its own samples
|
|
498
|
+
exactly, but its trigonometric interpolant overshoots between them. A five-point table scored
|
|
499
|
+
`[+1, +0.5, −1, −0.5, 0]` interpolates over `[−1.0428, 1.0041]`, which already breaks the promise.
|
|
500
|
+
A bounded series cannot.
|
|
501
|
+
|
|
502
|
+
### 10.3 Analysis measures data against every element
|
|
503
|
+
|
|
504
|
+
Each ring element is a reference phase. `analyse` locates every observation back on the ring and
|
|
505
|
+
measures it against each reference:
|
|
506
|
+
|
|
507
|
+
```typescript
|
|
508
|
+
const ring = new SynchronousRing(["a", "b", "c", "d", "e"]);
|
|
509
|
+
const report = ring.analyse(["a", "c", "c", "a", "zz", "e"]);
|
|
510
|
+
|
|
511
|
+
report.matched; // 5
|
|
512
|
+
report.missed; // 1 ('zz' is not on the ring)
|
|
513
|
+
report.references.map(r => r.count); // [2, 0, 2, 0, 1]
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
Each reference row reports `count`, the mean relative phase `mean`, and the mean resultant length
|
|
517
|
+
`resultant`. One identity is worth remembering: **`resultant` is the same for every reference**,
|
|
518
|
+
because changing the baseline only rotates all relative phases by a constant. What differs per
|
|
519
|
+
reference is the count and the mean.
|
|
520
|
+
|
|
521
|
+
```typescript
|
|
522
|
+
const ring = new SynchronousRing(["a", "b", "c"]);
|
|
523
|
+
ring.scoresAt(0, cosine); // one score per position, in [-1, 1]
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
### 10.4 Related instruments
|
|
527
|
+
|
|
528
|
+
| Call | Answers | Cost |
|
|
529
|
+
| :--- | :--- | :--- |
|
|
530
|
+
| `purity()` | is the ring still the `n`-th roots of unity? | O(n) |
|
|
531
|
+
| `resultant(t?)` | how concentrated are the phasors right now? | O(n) |
|
|
532
|
+
| `analyse(data)` | how does this data sit against every element? | O(n · m) |
|
|
533
|
+
| `toSpectrum()` | the ring as a spectrum, wavenumber on the frequency axis | O(n²) |
|
|
534
|
+
| `useAngularSpectrum(ring, instants, plan)` | the `(ω, n)` plane over time | O(n² · frames) |
|
|
535
|
+
|
|
536
|
+
Two more primitives round out the model: `combineRings(a, b)` joins two rings on their least common
|
|
537
|
+
multiple (a pullback, not a product — the pairs that cannot share an instant are simply absent), and
|
|
538
|
+
`rate()` gives each element its own intrinsic frequency so that a ring can drift against the
|
|
539
|
+
environment. `jetAt`, `windingAt` and `errorIntegralAt` expose the differential state of that drift.
|
|
540
|
+
|
|
541
|
+
### 10.5 Spectral windows
|
|
542
|
+
|
|
543
|
+
The ring constructions are one half of the analytic surface; the other half works on sampled
|
|
544
|
+
signals. Its most reused primitive is the window, and every shape here has two forms. The symmetric
|
|
545
|
+
form divides the frame by `count − 1`; the periodic (DFT-even) form divides it by `count`, one
|
|
546
|
+
sample short of a full period, so the taper is continuous under the periodic extension the DFT
|
|
547
|
+
assumes.
|
|
548
|
+
|
|
549
|
+
```typescript
|
|
550
|
+
useHannWindow(1, 4); // 0.75 symmetric
|
|
551
|
+
useHannPeriodicWindow(1, 4); // 0.5 periodic
|
|
552
|
+
coherentGain(useHannPeriodicWindow, 16); // 0.5
|
|
553
|
+
equivalentNoiseBandwidth(useHannPeriodicWindow, 16); // 1.5
|
|
554
|
+
```
|
|
555
|
+
|
|
556
|
+
| Shape | Symmetric | Periodic | Notes |
|
|
557
|
+
| :--- | :--- | :--- | :--- |
|
|
558
|
+
| Rectangular | `useRectangularWindow` | — | no taper; no periodic distinction |
|
|
559
|
+
| Hann | `useHannWindow` | `useHannPeriodicWindow` | the short-time default |
|
|
560
|
+
| Hamming | `useHammingWindow` | `useHammingPeriodicWindow` | — |
|
|
561
|
+
| Blackman | `useBlackmanWindow` | `useBlackmanPeriodicWindow` | — |
|
|
562
|
+
| Nuttall | `useNuttallWindow` | `useNuttallPeriodicWindow` | four-term Blackman–Harris |
|
|
563
|
+
| Flat-top | `useFlatTopWindow` | `useFlatTopPeriodicWindow` | amplitude accuracy over resolution |
|
|
564
|
+
| Kaiser | `useKaiserWindow(beta)` | `useKaiserPeriodicWindow(beta)` | `beta` sets the main-lobe width |
|
|
565
|
+
|
|
566
|
+
Use the periodic form for analysis and overlap-add: at a half-frame hop the periodic Hann window
|
|
567
|
+
sums to `1.000000` at every length, while the symmetric form ripples by `0.841 dB` at `N = 16` and
|
|
568
|
+
still by `0.013 dB` at `N = 1024`. Keep the symmetric form for filter design, where the frame is not
|
|
569
|
+
one period of the taper.
|
|
570
|
+
|
|
571
|
+
The flat-top windows return **negative weights** near the ends of the frame, because that is what a
|
|
572
|
+
flat-top window is. They are returned unchanged rather than clamped, because clamping perturbs the
|
|
573
|
+
main lobe and destroys the low scalloping loss the window exists for:
|
|
574
|
+
|
|
575
|
+
```typescript
|
|
576
|
+
useFlatTopWindow(0, 1024); // -0.000421 — negative by definition
|
|
577
|
+
Array.from({ length: 1024 }, (_unused, index) => useFlatTopWindow(index, 1024))
|
|
578
|
+
.filter(weight => weight < 0).length; // 578
|
|
579
|
+
```
|
|
580
|
+
|
|
581
|
+
### 10.6 Levels, flatness and band energy
|
|
582
|
+
|
|
583
|
+
`decibel` is gone as a single function, because an amplitude and a power do not share a factor.
|
|
584
|
+
`amplitudeDecibel` applies `20·log10` to a voltage-like ratio; `powerDecibel` applies `10·log10` to a
|
|
585
|
+
power, an energy, a mean square or a power spectral density:
|
|
586
|
+
|
|
587
|
+
```typescript
|
|
588
|
+
amplitudeDecibel(0.25); // -12.0412
|
|
589
|
+
powerDecibel(0.25); // -6.0206
|
|
590
|
+
```
|
|
591
|
+
|
|
592
|
+
`Bin.decibel` and `Spectrum.decibelOf` are amplitude levels; `Spectrum.powerDecibelOf` is the
|
|
593
|
+
matching power level. Feeding a power to the amplitude form reports `20·log10(power)` and doubles
|
|
594
|
+
the level in decibels.
|
|
595
|
+
|
|
596
|
+
Two spectrum measurements changed reading:
|
|
597
|
+
|
|
598
|
+
```typescript
|
|
599
|
+
const twoBins = new Spectrum(new Sample(2, 1), [new Complex(2, 0), new Complex(1, 0)]);
|
|
600
|
+
twoBins.flatness(); // 0.8 — geometric over arithmetic mean of power
|
|
601
|
+
|
|
602
|
+
const shape = new Spectrum(new Sample(4, 1), [
|
|
603
|
+
new Complex(1, 0), new Complex(2, 0), new Complex(0, 0), new Complex(0, 0),
|
|
604
|
+
]);
|
|
605
|
+
shape.bandEnergy(new Frequency(0), new Frequency(2)); // 9
|
|
606
|
+
shape.bandEnergy(new Frequency(0), new Frequency(2), "two-sided"); // 5
|
|
607
|
+
```
|
|
608
|
+
|
|
609
|
+
`flatness()` compares the geometric and arithmetic means of `power()` over the one-sided half of the
|
|
610
|
+
spectrum, not of magnitude over both halves, and the display-only amplitude doubling of
|
|
611
|
+
`oneSided()` is deliberately not applied. `bandEnergy()` defaults to the one-sided reading, folding
|
|
612
|
+
the conjugate half so that DC and Nyquist count once and every interior bin counts twice; a band
|
|
613
|
+
that covers the whole axis then satisfies Parseval, `Σ|X|² = N·Σx²`. Pass `"two-sided"` for a
|
|
614
|
+
complex spectrum, whose bins are already independent.
|
|
615
|
+
|
|
616
|
+
### 10.7 Short-time analysis
|
|
617
|
+
|
|
618
|
+
`Sampler.shortTimeFourierTransform` and `Sampler.spectrogram` follow the SciPy `stft` conventions:
|
|
619
|
+
the periodic Hann window, a zero-filled boundary at both ends and a zero-filled tail.
|
|
620
|
+
|
|
621
|
+
```typescript
|
|
622
|
+
const sampler = new Sampler(new Sample(64, 1));
|
|
623
|
+
const spectrogram = sampler.spectrogram(time => Math.cos(2 * Math.PI * 4 * time), 16, 8);
|
|
624
|
+
|
|
625
|
+
spectrogram.size(); // 9 frames
|
|
626
|
+
spectrogram.getBoundary(); // "zeros"
|
|
627
|
+
spectrogram.getTime(6); // 0.75 — frame centres, not frame starts
|
|
628
|
+
sampler.spectrogram(time => Math.cos(2 * Math.PI * 4 * time), 16, 8, useHannWindow,
|
|
629
|
+
{ boundary: "none", padded: false }).size(); // 7 — complete frames only
|
|
630
|
+
```
|
|
631
|
+
|
|
632
|
+
The extension centres the first frame on the first sample, and the tail padding keeps the last
|
|
633
|
+
samples reachable: 64 samples at frame 16 and hop 8 produce 9 frames instead of 7, and 1000 samples
|
|
634
|
+
at frame 256 and hop 128 produce 9 instead of 6. Frame times are centres — `(k·hop + (frameSize mod
|
|
635
|
+
2)/2)/rate` for an extended view, `(k·hop + frameSize/2)/rate` for an unextended one — and
|
|
636
|
+
`toFrame()` trims the extension before inverting.
|
|
637
|
+
|
|
638
|
+
---
|
|
639
|
+
|
|
640
|
+
<a id="11-utility-helpers"></a>
|
|
641
|
+
## 11. Utility Helpers
|
|
642
|
+
|
|
643
|
+
### 11.1 The error hierarchy
|
|
644
|
+
|
|
645
|
+
`structure/error.ts` exports a fifteen-class hierarchy for the errors raised *around* a pipeline — by
|
|
646
|
+
your own code, a custom collector or an adapter:
|
|
647
|
+
|
|
648
|
+
```text
|
|
649
|
+
SemanticError
|
|
650
|
+
├── IllegalOperationError
|
|
651
|
+
├── ValidationError
|
|
652
|
+
│ ├── IndexError
|
|
653
|
+
│ ├── RangeError (also exported as SemanticRangeError)
|
|
654
|
+
│ └── ConfigurationError
|
|
655
|
+
├── CapacityError
|
|
656
|
+
├── StateError
|
|
657
|
+
│ └── IllegalStateError
|
|
658
|
+
├── ConcurrencyError
|
|
659
|
+
├── CollectionError
|
|
660
|
+
├── SerializationError
|
|
661
|
+
├── NumericError
|
|
662
|
+
│ └── ArithmeticError
|
|
663
|
+
└── PhaseError
|
|
664
|
+
```
|
|
665
|
+
|
|
666
|
+
Each class fixes `name`, forwards the native `cause`, carries an optional `context` bag and
|
|
667
|
+
serialises through `toJSON()`; each has a brand guard named after it.
|
|
668
|
+
|
|
669
|
+
```typescript
|
|
670
|
+
const error = new CapacityError("LRU capacity exhausted", { context: { capacity: 0 } });
|
|
671
|
+
|
|
672
|
+
error.name; // "CapacityError"
|
|
673
|
+
isCapacityError(error); // true
|
|
674
|
+
isValidationError(error); // false — a capacity failure is not a validation failure
|
|
675
|
+
isSemanticError(error); // true
|
|
676
|
+
error.toJSON(); // { name: "CapacityError", message: "…", context: { capacity: 0 } }
|
|
677
|
+
```
|
|
678
|
+
|
|
679
|
+
`SemanticRangeError` is the same class as the exported `RangeError`, so `instanceof` holds across
|
|
680
|
+
both names; importing `RangeError` shadows the global constructor for the whole module, which is why
|
|
681
|
+
the alias exists. Neither class is thrown by the library's own argument checks, which keep raising
|
|
682
|
+
native `TypeError` and `RangeError` — the hierarchy is the vocabulary for the layer above.
|
|
683
|
+
|
|
684
|
+
### 11.2 Utility table
|
|
685
|
+
|
|
686
|
+
| Helper | Purpose |
|
|
687
|
+
| :--- | :--- |
|
|
688
|
+
| `useCompare(a, b)` | Total ordering across mixed primitives |
|
|
689
|
+
| `useToNumber` / `useToBigInt` | Lossless, range-checked numeric conversion |
|
|
690
|
+
| `useRandom` | Seeded or unseeded random helpers |
|
|
691
|
+
| `useTraverse` | Structural walk of nested objects and arrays |
|
|
692
|
+
| `useStringify` | Circular-safe JSON serialisation |
|
|
693
|
+
| `useHash` / `maskOf` / `register` / `unregister` | Structural hashing and the hash strategy registry |
|
|
694
|
+
| `invalidate` / `validate` | Guard clauses that raise a consistent error shape |
|
|
695
|
+
| `Optional<T>` | The library's `null` replacement: `Optional.of(x)` / `Optional.empty()` |
|
|
696
|
+
|
|
697
|
+
### 11.3 `useTraverse`
|
|
698
|
+
|
|
699
|
+
`useTraverse(target, callback)` flattens any object into its **non-null leaves**. It descends nested
|
|
700
|
+
objects, walks iterable values by position, skips `null` and `undefined`, and never re-enters an
|
|
701
|
+
object it has already visited — so an object graph with a cycle terminates instead of overflowing
|
|
702
|
+
the stack.
|
|
703
|
+
|
|
704
|
+
```typescript
|
|
705
|
+
const source = { id: 7, tags: ["a", "b"], owner: { name: "Ada", note: null } };
|
|
706
|
+
const leaves: string[] = [];
|
|
707
|
+
|
|
708
|
+
useTraverse(source, (key, value, path) => {
|
|
709
|
+
leaves.push(`${path.join(".")} = ${String(value)}`);
|
|
710
|
+
return true;
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
leaves; // ["id = 7", "tags.0 = a", "tags.1 = b", "owner.name = Ada"]
|
|
714
|
+
```
|
|
715
|
+
|
|
716
|
+
The callback runs once per leaf and receives the leaf's own key (its index when the leaf came from
|
|
717
|
+
an iterable), the value, and the path that reached it. Returning `false` abandons the **whole**
|
|
718
|
+
traversal, not just the current branch:
|
|
719
|
+
|
|
720
|
+
```typescript
|
|
721
|
+
let visited = 0;
|
|
722
|
+
useTraverse({ a: 1, b: 2, c: 3 }, () => {
|
|
723
|
+
visited += 1;
|
|
724
|
+
return false;
|
|
725
|
+
});
|
|
726
|
+
visited; // 1 — the first leaf already stops the walk
|
|
727
|
+
```
|
|
728
|
+
|
|
729
|
+
A cycle is safe for the same reason:
|
|
730
|
+
|
|
731
|
+
```typescript
|
|
732
|
+
const cyclic: any = { name: "root", child: { name: "leaf" } };
|
|
733
|
+
cyclic.child.parent = cyclic;
|
|
734
|
+
|
|
735
|
+
const names: string[] = [];
|
|
736
|
+
useTraverse(cyclic, (_key, value) => {
|
|
737
|
+
if (typeof value === "string") names.push(value);
|
|
738
|
+
return true;
|
|
739
|
+
});
|
|
740
|
+
names; // ["root", "leaf"] — the back edge to `cyclic` is not revisited
|
|
741
|
+
```
|
|
742
|
+
|
|
743
|
+
**Each 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.
|
|
744
|
+
|
|
745
|
+
```typescript
|
|
746
|
+
const paths: Array<ReadonlyArray<PropertyKey>> = [];
|
|
747
|
+
useTraverse({ a: { b: 1 }, c: 2 }, (_key, _value, path) => {
|
|
748
|
+
paths.push(path); // safe to keep as-is
|
|
749
|
+
return true;
|
|
750
|
+
});
|
|
751
|
+
paths[0] === paths[1]; // false — one array per call
|
|
752
|
+
paths.map(path => path.join(".")); // ["a.b", "c"]
|
|
753
|
+
```
|
|
754
|
+
|
|
755
|
+
`useAttribute` is this walk turned into a factory: it reports the same leaves as `{ key, value }`
|
|
756
|
+
pairs, so the rules — and the cost — are identical.
|
|
757
|
+
|
|
758
|
+
```typescript
|
|
759
|
+
useAttribute({ a: 1, b: { c: 2 } }).toUnordered().toArray();
|
|
760
|
+
// [{ key: "a", value: 1 }, { key: "c", value: 2 }]
|
|
761
|
+
```
|
|
762
|
+
|
|
763
|
+
---
|
|
764
|
+
|
|
765
|
+
<a id="12-performance-and-pitfalls"></a>
|
|
766
|
+
## 12. Performance and Pitfalls
|
|
767
|
+
|
|
768
|
+
**Laziness has a price per stage.** Each operator adds a generator hop. For tight numeric loops over
|
|
769
|
+
in-memory arrays, a plain `for` loop will beat any pipeline; the pipeline wins when the source is
|
|
770
|
+
lazy, infinite, or the pipeline is long enough for the index discipline to pay off.
|
|
771
|
+
|
|
772
|
+
**`toOrdered()` sorts; `toUnordered()` does not.** If a reversal seems to have "not worked", the
|
|
773
|
+
collector was unordered — index rewriting only becomes an order when indices are normalised.
|
|
774
|
+
|
|
775
|
+
**`useGenerate` without an `interrupt` never ends.** Always supply a stopping predicate or a
|
|
776
|
+
downstream `limit` / `takeWhile`.
|
|
777
|
+
|
|
778
|
+
**`Ring` is an insertion-ordered set.** Elements are unique and keep the order in which they were
|
|
779
|
+
first added. A duplicate `add` returns `false`, and a `set` that would introduce one throws. This is
|
|
780
|
+
what lets a phase be a function of the *element* rather than of whichever position it happens to
|
|
781
|
+
occupy.
|
|
782
|
+
|
|
783
|
+
**`Spectrum` assumes a real signal in its one-sided helpers.** `oneSided()`, `getNyquist()` and
|
|
784
|
+
`centroid()` assume conjugate symmetry. The spatial spectrum of a phase ring is complex and in
|
|
785
|
+
general not symmetric — a ring that traverses the circle backwards puts all of its energy in bin
|
|
786
|
+
`n − 1`, beyond Nyquist. Read the bins directly.
|
|
787
|
+
|
|
788
|
+
**Structure collectors are terminals, not views.** Each one drains the pipeline and then builds a
|
|
789
|
+
full structure, so it costs O(n) time and O(n) space and cannot run on an infinite source. Reach for
|
|
790
|
+
one at the end of a pipeline, never inside a loop over one.
|
|
791
|
+
|
|
792
|
+
**`variance()` and friends return an `Optional`.** `variance().get()` on a sample that is too small
|
|
793
|
+
throws; use `.orElse(0)` or check `.isPresent()` when the sample size is not under your control.
|
|
794
|
+
The same applies to `standardDeviation`, `quantile`, `interquartileRange`, `skewness` and
|
|
795
|
+
`kurtosis`.
|
|
796
|
+
|
|
797
|
+
**The periodic windows are the analysis windows.** `useHannWindow` and the other symmetric forms are
|
|
798
|
+
for filter design; a short-time transform wants `useHannPeriodicWindow` and its siblings, or the
|
|
799
|
+
overlap-add sum will ripple wherever the frames meet.
|
|
800
|
+
|
|
801
|
+
---
|
|
802
|
+
|
|
803
|
+
<a id="13-phase-rings"></a>
|
|
804
|
+
## 13. Phase Rings: An Exact Cycle of Positions
|
|
805
|
+
|
|
806
|
+
Chapter 10 introduced the ring as the library's model of a cycle and used it to measure a data
|
|
807
|
+
set against every phase. This chapter is the complete surface: how a position becomes an angle,
|
|
808
|
+
what the ambient frequency does and does not change, and the instruments that read the ring
|
|
809
|
+
against time, against data and against another ring.
|
|
810
|
+
|
|
811
|
+
A `SynchronousRing` is a positional list backed by a circular doubly linked ring whose head and
|
|
812
|
+
tail are joined. It is also an insertion-ordered *set*: no element appears twice, and a rejected
|
|
813
|
+
write leaves both the links and the samples untouched. What makes it a ring rather than a list is
|
|
814
|
+
that the phase of a position is a pure function of its residue modulo the current length, so the
|
|
815
|
+
ring is periodic to the bit and its balance cannot drift.
|
|
816
|
+
|
|
817
|
+
### 13.1 A ring is one integer, computed exactly
|
|
818
|
+
|
|
819
|
+
A ring carries `n` elements, and the element count, the positional period and the number of
|
|
820
|
+
distinct phases are the same integer. Position `i` carries
|
|
821
|
+
|
|
822
|
+
`phase + span · 2π · (((i mod n) + n) mod n) / n`
|
|
823
|
+
|
|
824
|
+
with the residue reduced in `bigint` before it ever becomes a `number`. That is the whole reason
|
|
825
|
+
`phaseAt(i)` and `phaseAt(i + n)` are *identical* rather than merely close: the second call
|
|
826
|
+
reduces to the same integer, evaluates the same product and produces the same double. An index
|
|
827
|
+
far beyond the exact integer range of `number` still lands on the right position, and a negative
|
|
828
|
+
index walks the ring backwards.
|
|
829
|
+
|
|
830
|
+
| Call | Returns | Meaning |
|
|
831
|
+
| :--- | :--- | :--- |
|
|
832
|
+
| `new SynchronousRing(source, options?)` | ring | `source` is any iterable, duplicates dropped |
|
|
833
|
+
| `getPhase()` / `getFrequency()` / `getSpan()` | `Angle`, `Frequency`, `number` | the three construction parameters |
|
|
834
|
+
| `size()` | `bigint` | the `n` that is also the period |
|
|
835
|
+
| `phaseAt(index)` | `Optional<Angle>` | intrinsic phase only; ignores time, rate and frequency |
|
|
836
|
+
| `elementAt(index)` | `Optional<RingElement>` | element, intrinsic phase, ambient frequency, phasor |
|
|
837
|
+
| `frequencyAt(index)` | `Optional<Frequency>` | the ambient rate, empty for an empty ring |
|
|
838
|
+
| `toComplex()` / `toComplex(index)` | `Complex[]` / `Optional<Complex>` | intrinsic phasors |
|
|
839
|
+
|
|
840
|
+
`RingOptions` adds two per-element functions: `magnitude(element, index)` gives the phasor length
|
|
841
|
+
and defaults to one, and `rate(element, index)` gives the element its own intrinsic frequency and
|
|
842
|
+
defaults to zero, a fixed marker.
|
|
843
|
+
|
|
844
|
+
```typescript
|
|
845
|
+
import { SynchronousRing } from "semantic-typescript";
|
|
846
|
+
|
|
847
|
+
const ring = new SynchronousRing(["a", "b", "c", "d", "e", "f"]);
|
|
848
|
+
|
|
849
|
+
ring.size(); // 6n
|
|
850
|
+
ring.phaseAt(0n).get().toDegreeValue(); // 0
|
|
851
|
+
ring.phaseAt(1n).get().toDegreeValue(); // 60
|
|
852
|
+
ring.phaseAt(-1n).get().toDegreeValue(); // 300
|
|
853
|
+
ring.phaseAt(6n).get().getValue() === ring.phaseAt(0n).get().getValue(); // true
|
|
854
|
+
ring.phaseAt(10n ** 20n).get().toDegreeValue(); // 240
|
|
855
|
+
```
|
|
856
|
+
|
|
857
|
+
`phaseAt(-1n)` is `300` on a six-element ring, and `10n ** 20n` reduces to `4` modulo six, so it
|
|
858
|
+
reads `240`. Neither case accumulates a floating-point product, which is what keeps the identity
|
|
859
|
+
exact under a change of index representation.
|
|
860
|
+
|
|
861
|
+
### 13.2 The ambient frequency and the state at an instant
|
|
862
|
+
|
|
863
|
+
`frequency` is the **ambient rotation rate** of the environment the ring is embedded in, not a
|
|
864
|
+
phase step between positions. The environment advances the phase of every element by the same
|
|
865
|
+
angle, so it cannot change the intrinsic pattern, cannot break the balance and cannot change any
|
|
866
|
+
`phaseAt` result. It defaults to `0 Hz`.
|
|
867
|
+
|
|
868
|
+
`relativeAt(index, time)` subtracts that common angle, leaving the coordinate a `PhaseField` is
|
|
869
|
+
scored against. `viewAt` and `viewsAt` return the full state at an instant, including the
|
|
870
|
+
element's own `rate`, and the differential accessors expose the drift:
|
|
871
|
+
|
|
872
|
+
| Call | Formula | Reads |
|
|
873
|
+
| :--- | :--- | :--- |
|
|
874
|
+
| `relativeAt(index, time)` | `intrinsic + ownRate · t − ambient · t` | the phase the environment sees |
|
|
875
|
+
| `viewAt(index, time)` / `viewsAt(time)` | the same, plus element, index, time, rate, phasor | the full state |
|
|
876
|
+
| `jetAt(index, time)` | phase at `t`, own rate, `slip = ownRate − ambient` | the differential state |
|
|
877
|
+
| `windingAt(index, time)` | `trunc(slip · t)` | whole turns slipped, truncated toward zero |
|
|
878
|
+
| `errorIntegralAt(index, time, origin?)` | `∫ relative phase dt`, in turns · seconds | how long an element has been ahead |
|
|
879
|
+
| `resultant(time?)` | `abs(Σ w · e^{iθ}) / Σ w` | phase concentration, unaffected by ambient rotation |
|
|
880
|
+
| `analyse(data, time?)` | counts and means per reference | data measured against every element |
|
|
881
|
+
| `scoreAt(index, time, field)` / `scoresAt(time, field)` | the field sampled at the relative phase | a bounded score per position |
|
|
882
|
+
|
|
883
|
+
```typescript
|
|
884
|
+
import { SynchronousRing, useHertz } from "semantic-typescript";
|
|
885
|
+
|
|
886
|
+
const moving = new SynchronousRing(["a", "b", "c", "d", "e", "f"], {
|
|
887
|
+
frequency: useHertz(1),
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
moving.getFrequency().getHertz(); // 1
|
|
891
|
+
moving.phaseAt(1n).get().toDegreeValue(); // 60 — intrinsic
|
|
892
|
+
moving.relativeAt(1n, 0.25).get().toDegreeValue(); // 330 — 60 − 90
|
|
893
|
+
moving.viewAt(1n, 0.25).get().relative.toDegreeValue(); // 330
|
|
894
|
+
moving.jetAt(1n, 0.25).get().slip; // -1
|
|
895
|
+
moving.purity(); // 1 — rotation cannot break it
|
|
896
|
+
```
|
|
897
|
+
|
|
898
|
+
At `1 Hz` the environment advances a quarter turn in `0.25 s`, so the relative phase of position
|
|
899
|
+
one is `60 − 90 = 330` degrees while its intrinsic phase stays `60`. The jet reports `slip` of
|
|
900
|
+
`-1` hertz because the element itself does not move, and `purity()` is still `1`: rotating every
|
|
901
|
+
phasor by the same angle is not a deformation.
|
|
902
|
+
|
|
903
|
+
### 13.3 Purity and balance
|
|
904
|
+
|
|
905
|
+
`purity()` is the share of the ring's energy carried by the fundamental mode:
|
|
906
|
+
|
|
907
|
+
`max(abs(c₁)², abs(c_{n−1})²) / (n · Σ abs(z)²)`
|
|
908
|
+
|
|
909
|
+
Both fundamental bins are tested because a ring may traverse the circle in either direction. A
|
|
910
|
+
ring built with `span: 5` over six elements steps by `300` degrees, which is `−60` degrees: it
|
|
911
|
+
runs backwards, puts all of its energy in `c₅`, and would be reported as impure by a formula that
|
|
912
|
+
only looked at `c₁`. The cost stays `O(n)` because only those two bins and the total energy are
|
|
913
|
+
needed.
|
|
914
|
+
|
|
915
|
+
The edge cases are part of the contract. A ring of fewer than two elements has no fundamental
|
|
916
|
+
mode and reports `purity() === 0` and `isBalanced() === false`, whatever its content. A ring whose
|
|
917
|
+
phasors all have zero length has no energy and also reports zero. `isBalanced(epsilon?)` is
|
|
918
|
+
`1 − purity() <= epsilon`, with `1E-9` as the default tolerance.
|
|
919
|
+
|
|
920
|
+
```typescript
|
|
921
|
+
import { SynchronousRing } from "semantic-typescript";
|
|
922
|
+
|
|
923
|
+
const forward = new SynchronousRing(["a", "b", "c", "d", "e", "f"]);
|
|
924
|
+
const backward = new SynchronousRing(["a", "b", "c", "d", "e", "f"], { span: 5 });
|
|
925
|
+
const weighted = new SynchronousRing(["a", "b", "c", "d"], {
|
|
926
|
+
magnitude: element => element === "a" ? 1 : 0,
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
forward.purity(); // 1
|
|
930
|
+
backward.phaseAt(1n).get().toDegreeValue(); // 300 — it runs backwards
|
|
931
|
+
backward.purity(); // 1 — the energy sits in c₅
|
|
932
|
+
new SynchronousRing(["only"]).purity(); // 0
|
|
933
|
+
new SynchronousRing(["only"]).isBalanced(); // false
|
|
934
|
+
weighted.purity(); // 0.25
|
|
935
|
+
```
|
|
936
|
+
|
|
937
|
+
### 13.4 Resultant and the symmetric components
|
|
938
|
+
|
|
939
|
+
`resultant(time?)` is the mean resultant length: the magnitude of the phasor sum divided by the
|
|
940
|
+
sum of the magnitudes. Unlike `purity`, which asks whether the ring is still the `n`-th roots of
|
|
941
|
+
unity, `resultant` asks how concentrated the phasors are right now. A balanced ring reports `0`;
|
|
942
|
+
a ring whose elements all share one angle would report `1`, but the set semantics forbid duplicate
|
|
943
|
+
elements, so in practice `1` appears when `magnitude` zeroes all but one element.
|
|
944
|
+
|
|
945
|
+
`zeroSequence()` is the mean phasor, that is `c₀ / n`. `sequences()` returns all `n` symmetric
|
|
946
|
+
components, lowest order first, by a direct transform. `toSpectrum()` wraps them in a `Spectrum`
|
|
947
|
+
whose bin `j` reports `j` cycles per ring revolution, so the frequency axis is the wavenumber
|
|
948
|
+
axis.
|
|
949
|
+
|
|
950
|
+
```typescript
|
|
951
|
+
import { SynchronousRing } from "semantic-typescript";
|
|
952
|
+
|
|
953
|
+
const round = (value: number): number => Number(value.toFixed(4));
|
|
954
|
+
const ring = new SynchronousRing(["a", "b", "c", "d", "e", "f"]);
|
|
955
|
+
|
|
956
|
+
round(ring.resultant().get()); // 0
|
|
957
|
+
round(ring.zeroSequence().get().magnitude()); // 0
|
|
958
|
+
ring.sequences().map(component => round(component.magnitude())); // [0, 6, 0, 0, 0, 0]
|
|
959
|
+
ring.toSpectrum().get().size(); // 6
|
|
960
|
+
ring.toSpectrum().get().getResolution().getHertz(); // 1
|
|
961
|
+
```
|
|
962
|
+
|
|
963
|
+
The components are complex and in general **not conjugate-symmetric**: a balanced ring puts all
|
|
964
|
+
of its energy in bin one, and a backwards ring puts it in bin `n − 1`. The one-sided helpers of
|
|
965
|
+
`Spectrum` assume a real signal, so read the bins directly instead of folding them.
|
|
966
|
+
|
|
967
|
+
### 13.5 Analysing data against every element
|
|
968
|
+
|
|
969
|
+
`analyse(data, time?)` matches every observation back to the ring by identity and measures it
|
|
970
|
+
against each element as a reference phase. Observations the ring does not hold are counted as
|
|
971
|
+
`missed` rather than guessed at, because the ring is the only source of phases. Each reference
|
|
972
|
+
row reports `count`, the mean relative phase `mean`, and the mean resultant length `resultant`.
|
|
973
|
+
|
|
974
|
+
One identity is worth remembering: **`resultant` is the same for every reference**. Changing the
|
|
975
|
+
baseline rotates all relative phases by a constant, and a rotation cannot change a resultant
|
|
976
|
+
length. What differs per reference is the count and the mean.
|
|
977
|
+
|
|
978
|
+
```typescript
|
|
979
|
+
import { SynchronousRing } from "semantic-typescript";
|
|
980
|
+
|
|
981
|
+
const round = (value: number): number => Number(value.toFixed(4));
|
|
982
|
+
const ring = new SynchronousRing(["a", "b", "c", "d", "e"]);
|
|
983
|
+
const report = ring.analyse(["a", "c", "c", "a", "zz", "e"]);
|
|
984
|
+
|
|
985
|
+
report.matched; // 5
|
|
986
|
+
report.missed; // 1
|
|
987
|
+
report.references.map(row => row.count); // [2, 0, 2, 0, 1]
|
|
988
|
+
report.references.map(row => round(row.resultant)); // [0.1453, 0.1453, 0.1453, 0.1453, 0.1453]
|
|
989
|
+
```
|
|
990
|
+
|
|
991
|
+
Scoring is the other side of the same coin. A `PhaseField` is a bounded cosine series (chapter
|
|
992
|
+
10.2), and `scoreAt`/`scoresAt` sample it at the relative phase, so the same ring can be scored
|
|
993
|
+
against alignment, against a harmonic pattern, or against any other field without changing the
|
|
994
|
+
ring.
|
|
995
|
+
|
|
996
|
+
```typescript
|
|
997
|
+
import { SynchronousRing, useField } from "semantic-typescript";
|
|
998
|
+
|
|
999
|
+
const round = (value: number): number => Number(value.toFixed(4));
|
|
1000
|
+
const ring = new SynchronousRing(["a", "b", "c", "d", "e", "f"]);
|
|
1001
|
+
const cosine = useField([1]);
|
|
1002
|
+
|
|
1003
|
+
round(ring.scoreAt(0n, 0, cosine).get()); // 1
|
|
1004
|
+
round(ring.scoreAt(1n, 0, cosine).get()); // 0.5
|
|
1005
|
+
ring.scoresAt(0, cosine).map(score => round(score)); // [1, 0.5, -0.5, -1, -0.5, 0.5]
|
|
1006
|
+
```
|
|
1007
|
+
|
|
1008
|
+
### 13.6 Combining rings and the angular spectrum
|
|
1009
|
+
|
|
1010
|
+
`combineRings(left, right)` joins two rings on their least common multiple, not their product.
|
|
1011
|
+
Instant `i` of the joint ring reads `i mod m` of the left ring and `i mod n` of the right ring,
|
|
1012
|
+
so the joint ring closes after `lcm(m, n)` instants. The pairs that can never co-occur — those
|
|
1013
|
+
whose positions disagree modulo `gcd(m, n)` — are simply absent, which is why the result is a
|
|
1014
|
+
pullback rather than a product; `aligns(left, right)` reports whether a given pair is
|
|
1015
|
+
representable.
|
|
1016
|
+
|
|
1017
|
+
`useAngularSpectrum(ring, instants, plan)` builds the `(ω, n)` plane: each instant contributes a
|
|
1018
|
+
frame of phasors, frame `k` of mode `j` is the `j`-th symmetric component of that frame, and
|
|
1019
|
+
`cells[frame][order]` is the coefficient. Taking one column gives the time series of a single
|
|
1020
|
+
spatial mode, and `spectrum(order)` is its temporal spectrum.
|
|
1021
|
+
|
|
1022
|
+
```typescript
|
|
1023
|
+
import { Sample, SynchronousRing, combineRings, useAngularSpectrum } from "semantic-typescript";
|
|
1024
|
+
|
|
1025
|
+
const left = new SynchronousRing(["a", "b", "c", "d"]);
|
|
1026
|
+
const right = new SynchronousRing(["x", "y", "z", "w", "v", "u"]);
|
|
1027
|
+
const joined = combineRings(left, right);
|
|
1028
|
+
|
|
1029
|
+
joined.period; // 12n
|
|
1030
|
+
joined.divisor; // 2n
|
|
1031
|
+
joined.ring.size(); // 12n
|
|
1032
|
+
joined.aligns(0n, 0n); // true
|
|
1033
|
+
joined.aligns(1n, 0n); // false
|
|
1034
|
+
joined.ring.get(0n).join("-"); // a-x
|
|
1035
|
+
|
|
1036
|
+
const angular = useAngularSpectrum(left, [0, 0.25, 0.5, 0.75], new Sample(4, 1));
|
|
1037
|
+
angular.order; // 4
|
|
1038
|
+
angular.frameCount; // 4
|
|
1039
|
+
angular.cells[0][1].magnitude(); // 4
|
|
1040
|
+
angular.spectrum(1).get().size(); // 4
|
|
1041
|
+
```
|
|
1042
|
+
|
|
1043
|
+
### 13.7 Pitfalls
|
|
1044
|
+
|
|
1045
|
+
**Exactness is exact.** `phaseAt(i)` and `phaseAt(i + n)` are the same double, not two values
|
|
1046
|
+
within a tolerance. A ring built by accumulating `2π / n` per element would fail this; this one
|
|
1047
|
+
reduces the index in `bigint` first.
|
|
1048
|
+
|
|
1049
|
+
**`frequency` is not a phase step.** It is the ambient rotation rate, it defaults to zero, and
|
|
1050
|
+
rotating every phasor by the same angle cannot break the intrinsic pattern. If you want positions
|
|
1051
|
+
to advance relative to each other, use `rate`, not `frequency`.
|
|
1052
|
+
|
|
1053
|
+
**`purity()` must look at both fundamental bins.** A backwards ring is still the `n`-th roots of
|
|
1054
|
+
unity; only `c₁` would call it impure.
|
|
1055
|
+
|
|
1056
|
+
**`n < 2` is a defined edge case.** `purity()` returns `0` and `isBalanced()` returns `false`; an
|
|
1057
|
+
empty ring returns empty optionals from the sampling methods.
|
|
1058
|
+
|
|
1059
|
+
**A span that shares a factor with `n` collapses the orbit.** Six elements with `span: 2` visit
|
|
1060
|
+
only three distinct phases, and the ring is no longer balanced.
|
|
1061
|
+
|
|
1062
|
+
**`resultant` is rotation-invariant, not time-invariant.** Ambient rotation leaves it unchanged;
|
|
1063
|
+
per-element `rate`s change it as the ring de-phases.
|
|
1064
|
+
|
|
1065
|
+
**The ring is a set, and `analyse` matches by identity.** Duplicate elements are dropped at
|
|
1066
|
+
construction, and an object that is structurally equal to a ring element but not the same
|
|
1067
|
+
reference is counted as missed.
|
|
1068
|
+
|
|
1069
|
+
**`toSpectrum()` is complex.** Its bins are not conjugate-symmetric, so the one-sided helpers of
|
|
1070
|
+
`Spectrum` do not apply. Read the bins.
|
|
1071
|
+
|
|
1072
|
+
**`windingAt` truncates toward zero, and `errorIntegralAt` integrates phase.** Half a turn of slip
|
|
1073
|
+
reports `0n`; the error integral is in turns · seconds, not a rate.
|
|
1074
|
+
|
|
1075
|
+
```typescript
|
|
1076
|
+
import { SynchronousRing, useHertz } from "semantic-typescript";
|
|
1077
|
+
|
|
1078
|
+
const collapsed = new SynchronousRing(["a", "b", "c", "d", "e", "f"], { span: 2 });
|
|
1079
|
+
collapsed.phaseAt(1n).get().toDegreeValue(); // 120
|
|
1080
|
+
collapsed.phaseAt(3n).get().toDegreeValue(); // 0
|
|
1081
|
+
Number(collapsed.purity().toFixed(4)); // 0
|
|
1082
|
+
|
|
1083
|
+
const falling = new SynchronousRing(["a", "b", "c", "d"], {
|
|
1084
|
+
frequency: useHertz(2),
|
|
1085
|
+
rate: () => useHertz(0.5),
|
|
1086
|
+
});
|
|
1087
|
+
falling.jetAt(0n, 2).get().slip; // -1.5
|
|
1088
|
+
falling.windingAt(0n, 1).get(); // -1n
|
|
1089
|
+
falling.windingAt(0n, 0.5).get(); // 0n
|
|
1090
|
+
|
|
1091
|
+
const objects = new SynchronousRing([{ key: 1 }, { key: 2 }]);
|
|
1092
|
+
objects.analyse([{ key: 1 }]).missed; // 1 — identity, not structure
|
|
1093
|
+
```
|
|
1094
|
+
|
|
1095
|
+
---
|
|
1096
|
+
|
|
1097
|
+
<a id="14-signal-analysis"></a>
|
|
1098
|
+
## 14. Signal Analysis: Windows, Spectra and Time
|
|
1099
|
+
|
|
1100
|
+
Chapter 10 treated a ring as a cycle of phases. This chapter is the temporal counterpart: a
|
|
1101
|
+
real-valued record that carries, besides its samples, the two pieces of context every spectral
|
|
1102
|
+
formula needs — the sampling plan that turns a bin index into hertz, and the taper that fixes both
|
|
1103
|
+
the coherent gain and the noise bandwidth.
|
|
1104
|
+
|
|
1105
|
+
The value object is `SynchronousSignalCollectable`, with `AsynchronousSignalCollectable` as its
|
|
1106
|
+
queued twin. Both expose the same `SignalCollectable` surface: twenty-three methods, each delegating
|
|
1107
|
+
to exactly one `useSynchronous<Method>` factory in `synchronous/collectors`. The split is
|
|
1108
|
+
deliberate. The class is a documented facade, the factory owns the accumulation, and the constructor
|
|
1109
|
+
caches `sum(w) / count` and `count * sum(w^2) / sum(w)^2` once, at this length. A caller who has to
|
|
1110
|
+
remember "divide by the coherent gain" will eventually forget, and the resulting 6.02 decibels look
|
|
1111
|
+
like a plausible measurement.
|
|
1112
|
+
|
|
1113
|
+
### 14.1 A signal carries its own calibration
|
|
1114
|
+
|
|
1115
|
+
A signal is built from an iterable and a rate, from an existing `Frame`, or from the constructor
|
|
1116
|
+
with an explicit `Sample`. The default taper is the periodic Hann window; every analysis that does
|
|
1117
|
+
not name a window uses it, and every analysis accepts an override. The metadata accessors are
|
|
1118
|
+
constant-time reads of state fixed at construction, so a signal can be handed around without the
|
|
1119
|
+
context drifting.
|
|
1120
|
+
|
|
1121
|
+
| Call | Returns | Meaning |
|
|
1122
|
+
| :--- | :--- | :--- |
|
|
1123
|
+
| `SynchronousSignalCollectable.useRate(values, rate, window?)` | signal | duration `count / rate` |
|
|
1124
|
+
| `SynchronousSignalCollectable.useFrame(frame, window?)` | signal | keeps the frame's plan |
|
|
1125
|
+
| `new SynchronousSignalCollectable(plan, values, window?)` | signal | plan count must equal the sample count |
|
|
1126
|
+
| `getSample()` / `getValues()` / `getWindow()` | plan, samples, taper | the frozen context |
|
|
1127
|
+
| `getRate()` | `number` | samples per second |
|
|
1128
|
+
| `getNyquist()` / `getResolution()` | `Frequency` | `rate / 2` and `rate / count` |
|
|
1129
|
+
| `getCoherentGain()` / `getNoiseBandwidth()` | `number` | recomputed at this exact length |
|
|
1130
|
+
| `size()` / `at(index)` / `toFrame()` / `equals(other)` / `toString()` | — | value semantics |
|
|
1131
|
+
|
|
1132
|
+
```typescript
|
|
1133
|
+
import { SynchronousSignalCollectable } from "semantic-typescript";
|
|
1134
|
+
|
|
1135
|
+
const signal = SynchronousSignalCollectable.useRate(
|
|
1136
|
+
Array.from({ length: 64 }, (_unused, index) => Math.cos(2 * Math.PI * 8 * index / 64)),
|
|
1137
|
+
64,
|
|
1138
|
+
);
|
|
1139
|
+
|
|
1140
|
+
signal.size(); // 64n
|
|
1141
|
+
signal.getRate(); // 64
|
|
1142
|
+
signal.getNyquist().getHertz(); // 32
|
|
1143
|
+
signal.getResolution().getHertz(); // 1
|
|
1144
|
+
signal.getCoherentGain(); // 0.5
|
|
1145
|
+
signal.getNoiseBandwidth(); // 1.5
|
|
1146
|
+
```
|
|
1147
|
+
|
|
1148
|
+
`getResolution()` is the bin spacing, not the resolving power: it is `rate / count` because the
|
|
1149
|
+
transform has `count` bins, and padding the transform changes the spacing without changing what the
|
|
1150
|
+
window can separate (section 14.8).
|
|
1151
|
+
|
|
1152
|
+
### 14.2 The twenty-three methods
|
|
1153
|
+
|
|
1154
|
+
The methods fall into six families. Seven are spectral readings, two are time–frequency transforms,
|
|
1155
|
+
four are analytic quantities, three are sequence statistics, five are shape descriptors, and two
|
|
1156
|
+
return another signal.
|
|
1157
|
+
|
|
1158
|
+
| Method | Returns | Convention |
|
|
1159
|
+
| :--- | :--- | :--- |
|
|
1160
|
+
| `spectrum(size?, window?)` | `Spectrum` | raw and unnormalised, full complex grid |
|
|
1161
|
+
| `oneSided(size?, window?)` | `Bin[]` | conjugate half folded, DC and Nyquist not doubled |
|
|
1162
|
+
| `amplitude(window?)` | `number[]` | `2·abs(X) / (count · CG)`, once at DC and Nyquist |
|
|
1163
|
+
| `power(window?)` | `number[]` | `A² / 2` per interior bin, `A²` at DC and Nyquist |
|
|
1164
|
+
| `decibel(window?)` | `number[]` | `20·log10(amplitude)` |
|
|
1165
|
+
| `powerDecibel(window?)` | `number[]` | `10·log10(power)` |
|
|
1166
|
+
| `powerSpectralDensity(frameSize?, hopSize?, window?)` | `SpectralDensity` | Welch average, `abs(X)² / (rate · Σw²)` |
|
|
1167
|
+
| `shortTimeFourierTransform(frameSize, hopSize, window?)` | `Spectrum[]` | extended boundaries, centre times |
|
|
1168
|
+
| `spectrogram(frameSize, hopSize, window?)` | `Spectrogram` | the same columns with a time axis |
|
|
1169
|
+
| `analyticSignal()` | `Complex[]` | the Hilbert pair `x + i·H(x)` |
|
|
1170
|
+
| `instantaneousPhase()` | `Angle[]` | folded into `[0, 2π)` |
|
|
1171
|
+
| `instantaneousFrequency(rate?, gate?)` | `number[]` | unwrapped phase difference, gated on the envelope |
|
|
1172
|
+
| `envelope()` | `number[]` | `abs(z[n])`, circular at the edges |
|
|
1173
|
+
| `zeroCrossingRate(perSecond?)` | `number` | crossings per adjacent sample pair |
|
|
1174
|
+
| `spectralFlux(frameSize?, hopSize?, window?)` | `number[]` | unit-norm magnitude distance between frames |
|
|
1175
|
+
| `autocorrelation(maximumLag?, normalization?)` | `number[]` | coefficient by default, `ρ[0] = 1` |
|
|
1176
|
+
| `spectralCentroid(window?)` | `Optional<Frequency>` | power-weighted mean frequency |
|
|
1177
|
+
| `spectralBandwidth(window?)` | `Optional<Frequency>` | power-weighted standard deviation |
|
|
1178
|
+
| `spectralRolloff(ratio?, window?)` | `Optional<Frequency>` | first bin reaching `ratio` of the power |
|
|
1179
|
+
| `spectralFlatness(window?)` | `number` | geometric over arithmetic mean of power |
|
|
1180
|
+
| `signalToNoiseRatio(frameSize?, hopSize?, window?, floorQuantile?)` | `Optional<number>` | median noise floor, `10·log10` |
|
|
1181
|
+
| `detrended(mode?)` | signal | `"none"`, `"constant"` (default), `"linear"` |
|
|
1182
|
+
| `windowed(window?)` | signal | applies a taper and adopts it |
|
|
1183
|
+
|
|
1184
|
+
The optional arguments are not decoration. `frameSize` defaults to `min(256, size)`, the hop to
|
|
1185
|
+
half a frame rounded down, `instantaneousFrequency` gates at `1E-3` of the peak envelope, and
|
|
1186
|
+
`spectralRolloff` uses `0.85`. Each default is the value a reference implementation picks when the
|
|
1187
|
+
parameter is named rather than supplied.
|
|
1188
|
+
|
|
1189
|
+
### 14.3 Amplitude, power and density
|
|
1190
|
+
|
|
1191
|
+
Three normalisations are needed because three different questions are being asked.
|
|
1192
|
+
|
|
1193
|
+
`amplitude()` answers "how large is the sinusoid on this bin": `2·abs(X[k]) / (count · CG)` for an
|
|
1194
|
+
interior bin and `abs(X[k]) / (count · CG)` at DC and Nyquist. The factor of two folds the
|
|
1195
|
+
conjugate half; the coherent gain removes the taper. Omit either and a unit tone reads `0.5`,
|
|
1196
|
+
which is minus 6.02 decibels.
|
|
1197
|
+
|
|
1198
|
+
`power()` answers "what mean square would a sinusoid on this bin have": `A² / 2` for an interior
|
|
1199
|
+
bin and `A²` at DC and Nyquist, because those two do not oscillate. This is the peak convention,
|
|
1200
|
+
and it deliberately does not satisfy Parseval: a window spreads each line over its main lobe, so
|
|
1201
|
+
the sum of every bin exceeds the mean square by the equivalent noise bandwidth — `1.5` for Hann.
|
|
1202
|
+
|
|
1203
|
+
`powerSpectralDensity()` answers "how much power per hertz": `abs(X)² / (rate · Σw²)`, averaged
|
|
1204
|
+
over overlapping segments. Only the sum of the squared weights keeps a white-noise floor unbiased;
|
|
1205
|
+
dividing by `count` reads `0.375` for unit noise and dividing by `count · CG²` reads `1.5`.
|
|
1206
|
+
|
|
1207
|
+
```typescript
|
|
1208
|
+
import { SynchronousSignalCollectable } from "semantic-typescript";
|
|
1209
|
+
|
|
1210
|
+
const round = (value: number): number => Number(value.toFixed(4));
|
|
1211
|
+
const tone = SynchronousSignalCollectable.useRate(
|
|
1212
|
+
Array.from({ length: 256 }, (_unused, index) => Math.cos(2 * Math.PI * 32 * index / 256)),
|
|
1213
|
+
256,
|
|
1214
|
+
);
|
|
1215
|
+
|
|
1216
|
+
round(tone.amplitude()[32]); // 1
|
|
1217
|
+
round(tone.power()[32]); // 0.5
|
|
1218
|
+
round(tone.power().reduce((sum, power) => sum + power, 0)); // 0.75
|
|
1219
|
+
round(tone.powerSpectralDensity(64n, 32n).totalPower()); // 0.5
|
|
1220
|
+
tone.powerSpectralDensity(64n, 32n).getSegments(); // 7n
|
|
1221
|
+
```
|
|
1222
|
+
|
|
1223
|
+
The pair of totals is the check worth remembering: the peak-convention sum is `0.75` while the
|
|
1224
|
+
density integrates to `0.5`, and the ratio is exactly the noise bandwidth. The density is also the
|
|
1225
|
+
only one of the three that survives a change of frame length.
|
|
1226
|
+
|
|
1227
|
+
### 14.4 Levels: the two decibel conventions
|
|
1228
|
+
|
|
1229
|
+
An amplitude and a power do not share a factor, so they do not share a logarithm. `amplitudeDecibel`
|
|
1230
|
+
applies `20·log10` and is correct for a voltage, a displacement or a sound pressure; `powerDecibel`
|
|
1231
|
+
applies `10·log10` and is correct for a power, an energy, a mean square or a density. The method
|
|
1232
|
+
names on the signal follow the same split.
|
|
1233
|
+
|
|
1234
|
+
```typescript
|
|
1235
|
+
import { amplitudeDecibel, powerDecibel, SynchronousSignalCollectable } from "semantic-typescript";
|
|
1236
|
+
|
|
1237
|
+
const round = (value: number): number => Number(value.toFixed(4));
|
|
1238
|
+
const tone = SynchronousSignalCollectable.useRate(
|
|
1239
|
+
Array.from({ length: 256 }, (_unused, index) => Math.cos(2 * Math.PI * 32 * index / 256)),
|
|
1240
|
+
256,
|
|
1241
|
+
);
|
|
1242
|
+
|
|
1243
|
+
amplitudeDecibel(0.25); // -12.0412
|
|
1244
|
+
powerDecibel(0.25); // -6.0206
|
|
1245
|
+
round(tone.decibel()[32]); // 0
|
|
1246
|
+
round(tone.powerDecibel()[32]); // -3.0103
|
|
1247
|
+
```
|
|
1248
|
+
|
|
1249
|
+
Both readings of the same tone are correct: its amplitude is one (zero decibels), and its mean
|
|
1250
|
+
square is one half (minus 3.01 decibels). Feeding a power to the amplitude convention would double
|
|
1251
|
+
the level in decibels, turning the correct minus 6.02 into minus 12.04.
|
|
1252
|
+
|
|
1253
|
+
### 14.5 The one-sided fold
|
|
1254
|
+
|
|
1255
|
+
A real signal satisfies `X[count − k] = conj(X[k])`, so the negative-frequency half carries no
|
|
1256
|
+
independent information. The fold doubles every interior bin and keeps the two self-conjugate bins
|
|
1257
|
+
— DC and, for an even length, Nyquist — exactly as the transform produced them. Those two are their
|
|
1258
|
+
own mirror image; doubling them invents energy and reports exactly 6.02 decibels too high.
|
|
1259
|
+
|
|
1260
|
+
```typescript
|
|
1261
|
+
import { SynchronousSignalCollectable } from "semantic-typescript";
|
|
1262
|
+
|
|
1263
|
+
const round = (value: number): number => Number(value.toFixed(4));
|
|
1264
|
+
const edge = SynchronousSignalCollectable.useRate(
|
|
1265
|
+
Array.from({ length: 16 }, (_unused, index) => 2 + Math.cos(Math.PI * index)),
|
|
1266
|
+
16,
|
|
1267
|
+
);
|
|
1268
|
+
const bins = edge.oneSided();
|
|
1269
|
+
|
|
1270
|
+
bins.length; // 9
|
|
1271
|
+
round(bins[0].magnitude()); // 16
|
|
1272
|
+
round(bins[8].magnitude()); // 8
|
|
1273
|
+
round(edge.amplitude()[0]); // 2
|
|
1274
|
+
round(edge.amplitude()[8]); // 1
|
|
1275
|
+
```
|
|
1276
|
+
|
|
1277
|
+
The raw magnitudes are `16` and `8` because the taper's weights sum to eight; dividing by
|
|
1278
|
+
`count · CG = 8` turns them into the calibrated amplitudes `2` and `1`. Doubling the edge bins
|
|
1279
|
+
would read `4` and `2`, and the error would never raise an exception.
|
|
1280
|
+
|
|
1281
|
+
### 14.6 Time–frequency views
|
|
1282
|
+
|
|
1283
|
+
`shortTimeFourierTransform` and `spectrogram` frame the record the way the reference short-time
|
|
1284
|
+
transform does: half a frame of zeros at each end, then a zero-padded tail so that a whole number
|
|
1285
|
+
of hops remains. The first frame is centred on the first sample, the last frame reaches the last
|
|
1286
|
+
sample, and frame `m` is centred at `m · hop / rate` seconds. Retaining only complete frames would
|
|
1287
|
+
discard up to `frameSize − 1` samples — for 1000 samples at frame 256 and hop 128, that is 104
|
|
1288
|
+
samples, about a tenth of the record.
|
|
1289
|
+
|
|
1290
|
+
```typescript
|
|
1291
|
+
import { SynchronousSignalCollectable } from "semantic-typescript";
|
|
1292
|
+
|
|
1293
|
+
const tone = SynchronousSignalCollectable.useRate(
|
|
1294
|
+
Array.from({ length: 64 }, (_unused, index) => Math.cos(2 * Math.PI * 4 * index / 64)),
|
|
1295
|
+
64,
|
|
1296
|
+
);
|
|
1297
|
+
const spectrogram = tone.spectrogram(16n, 8n);
|
|
1298
|
+
|
|
1299
|
+
spectrogram.size(); // 9
|
|
1300
|
+
spectrogram.getFrameSize(); // 16
|
|
1301
|
+
spectrogram.getBoundary(); // "zeros"
|
|
1302
|
+
spectrogram.getTime(0); // 0
|
|
1303
|
+
spectrogram.getTime(6); // 0.75
|
|
1304
|
+
tone.shortTimeFourierTransform(16n, 8n).length; // 9
|
|
1305
|
+
tone.spectralFlux(16n, 8n).length; // 8
|
|
1306
|
+
```
|
|
1307
|
+
|
|
1308
|
+
Every column is a `Spectrum` over `frameSize` bins, exactly as `spectrum()` would produce for the
|
|
1309
|
+
same frame, and `getTime()` reports frame centres rather than frame starts.
|
|
1310
|
+
|
|
1311
|
+
### 14.7 Windows, coherent gain and COLA
|
|
1312
|
+
|
|
1313
|
+
Every shape has two forms. The symmetric form divides the frame by `count − 1`; the periodic
|
|
1314
|
+
(DFT-even) form divides it by `count`, one sample short of a full period, so the taper is continuous
|
|
1315
|
+
under the periodic extension the DFT assumes. Analysis and overlap-add need the periodic form;
|
|
1316
|
+
filter design keeps the symmetric one.
|
|
1317
|
+
|
|
1318
|
+
The difference is measurable and never raises an error. At fifty per cent overlap the periodic Hann
|
|
1319
|
+
window sums to one at every position; the symmetric form ripples, by `0.84` decibels at sixteen
|
|
1320
|
+
samples, `0.21` at sixty-four and still `0.013` at one thousand and twenty-four. The class exposes
|
|
1321
|
+
the coherent gain and the equivalent noise bandwidth of whichever taper it holds, so the correction
|
|
1322
|
+
is never hard-coded: a symmetric Hann reads `0.4375` at eight samples, not `0.5`.
|
|
1323
|
+
|
|
1324
|
+
```typescript
|
|
1325
|
+
import {
|
|
1326
|
+
coherentGain, equivalentNoiseBandwidth, useFlatTopWindow,
|
|
1327
|
+
useHannPeriodicWindow, useHannWindow,
|
|
1328
|
+
} from "semantic-typescript";
|
|
1329
|
+
|
|
1330
|
+
const round = (value: number): number => Number(value.toFixed(4));
|
|
1331
|
+
|
|
1332
|
+
round(useHannWindow(1, 4)); // 0.75
|
|
1333
|
+
round(useHannPeriodicWindow(1, 4)); // 0.5
|
|
1334
|
+
coherentGain(useHannPeriodicWindow, 16); // 0.5
|
|
1335
|
+
equivalentNoiseBandwidth(useHannPeriodicWindow, 16); // 1.5
|
|
1336
|
+
useFlatTopWindow(0, 1024); // -0.000421
|
|
1337
|
+
Array.from({ length: 1024 }, (_unused, i) => useFlatTopWindow(i, 1024)).filter(w => w < 0).length; // 578
|
|
1338
|
+
```
|
|
1339
|
+
|
|
1340
|
+
The flat-top row is not a mistake in the table. A flat-top window is partially negative by
|
|
1341
|
+
definition — 578 of 1024 weights here — and clamping those weights to zero would perturb the main
|
|
1342
|
+
lobe and destroy the low scalloping loss the window exists for. The library returns the series
|
|
1343
|
+
unchanged.
|
|
1344
|
+
|
|
1345
|
+
### 14.8 Pitfalls
|
|
1346
|
+
|
|
1347
|
+
**Zero padding interpolates, it never resolves.** A transform length above the sample count samples
|
|
1348
|
+
the same underlying DTFT more densely: the peak *location* becomes finer and the bin spacing
|
|
1349
|
+
becomes `rate / size`, but the ability to separate two components is still governed by the window
|
|
1350
|
+
length and shape. A tone exactly between two bins reads `82.1093` and `80.8598` on the unpadded
|
|
1351
|
+
grid, and `128` at its own frequency on the padded grid.
|
|
1352
|
+
|
|
1353
|
+
```typescript
|
|
1354
|
+
import { SynchronousSignalCollectable, useRectangularWindow } from "semantic-typescript";
|
|
1355
|
+
|
|
1356
|
+
const round = (value: number): number => Number(value.toFixed(4));
|
|
1357
|
+
const tone = SynchronousSignalCollectable.useRate(
|
|
1358
|
+
Array.from({ length: 256 }, (_unused, index) => Math.cos(2 * Math.PI * 100.5 * index / 256)),
|
|
1359
|
+
256,
|
|
1360
|
+
);
|
|
1361
|
+
const flat = tone.spectrum(256n, useRectangularWindow);
|
|
1362
|
+
const padded = tone.spectrum(1024n, useRectangularWindow);
|
|
1363
|
+
|
|
1364
|
+
flat.getResolution().getHertz(); // 1
|
|
1365
|
+
padded.getResolution().getHertz(); // 0.25
|
|
1366
|
+
round(flat.magnitudeAt(100).get()); // 82.1093
|
|
1367
|
+
round(flat.magnitudeAt(101).get()); // 80.8598
|
|
1368
|
+
round(padded.magnitudeAt(402).get()); // 128
|
|
1369
|
+
padded.frequencyAt(402).get().getHertz(); // 100.5
|
|
1370
|
+
```
|
|
1371
|
+
|
|
1372
|
+
Two equal tones `0.4` bins apart stay merged at any padding: with a 256-point record and a Hann
|
|
1373
|
+
window, padding to 8192 produces a single local maximum, not two.
|
|
1374
|
+
|
|
1375
|
+
**The Hilbert transform is circular.** `analyticSignal`, `envelope` and `instantaneousPhase` treat
|
|
1376
|
+
the first and last samples as neighbours, so a record whose period does not divide its length is
|
|
1377
|
+
contaminated at both ends. The middle of the record is trustworthy; the ends are not.
|
|
1378
|
+
|
|
1379
|
+
```typescript
|
|
1380
|
+
import { SynchronousSignalCollectable } from "semantic-typescript";
|
|
1381
|
+
|
|
1382
|
+
const round = (value: number): number => Number(value.toFixed(4));
|
|
1383
|
+
const edge = SynchronousSignalCollectable.useRate(
|
|
1384
|
+
Array.from({ length: 512 }, (_unused, index) => Math.cos(2 * Math.PI * 8.3 * index / 512)),
|
|
1385
|
+
512,
|
|
1386
|
+
);
|
|
1387
|
+
const envelope = edge.envelope();
|
|
1388
|
+
|
|
1389
|
+
round(envelope.slice(8, -8).reduce((sum, value) => sum + value, 0) / 496); // 1.0025
|
|
1390
|
+
round(Math.max(...envelope.slice(0, 8))); // 1.2592
|
|
1391
|
+
round(Math.min(...envelope.slice(-8))); // 0.1314
|
|
1392
|
+
```
|
|
1393
|
+
|
|
1394
|
+
**A vanishing envelope does not define a phase.** Where the envelope collapses, the difference of
|
|
1395
|
+
two rounding-noise angles can be half a turn, which reads as half the sampling rate. The gate is
|
|
1396
|
+
what keeps the number meaningful, and the default is `1E-3` of the peak envelope.
|
|
1397
|
+
|
|
1398
|
+
```typescript
|
|
1399
|
+
import { SynchronousSignalCollectable } from "semantic-typescript";
|
|
1400
|
+
|
|
1401
|
+
const round = (value: number): number => Number(value.toFixed(4));
|
|
1402
|
+
const beat = SynchronousSignalCollectable.useRate(
|
|
1403
|
+
Array.from({ length: 4096 }, (_unused, index) =>
|
|
1404
|
+
Math.cos(2 * Math.PI * 100 * index / 2048) + Math.cos(2 * Math.PI * 102 * index / 2048)),
|
|
1405
|
+
2048,
|
|
1406
|
+
);
|
|
1407
|
+
|
|
1408
|
+
round(Math.max(...beat.instantaneousFrequency(2048, 0).map(Math.abs))); // 920.218
|
|
1409
|
+
round(Math.max(...beat.instantaneousFrequency().map(Math.abs))); // 101
|
|
1410
|
+
```
|
|
1411
|
+
|
|
1412
|
+
**An unnormalised correlation is not a similarity.** The raw sum `r[k]` grows with the record
|
|
1413
|
+
length, so two lengths cannot be compared. The default `"coefficient"` rule divides by `r[0]` and
|
|
1414
|
+
keeps every value in `[-1, 1]`; `"unbiased"` divides by `count − k` and pays with a variance that
|
|
1415
|
+
grows with the lag.
|
|
1416
|
+
|
|
1417
|
+
```typescript
|
|
1418
|
+
import { SynchronousSignalCollectable } from "semantic-typescript";
|
|
1419
|
+
|
|
1420
|
+
const round = (value: number): number => Number(value.toFixed(4));
|
|
1421
|
+
const periodic = SynchronousSignalCollectable.useRate(
|
|
1422
|
+
Array.from({ length: 1024 }, (_unused, index) => Math.cos(2 * Math.PI * 8 * index / 1024)),
|
|
1423
|
+
1024,
|
|
1424
|
+
);
|
|
1425
|
+
const rho = periodic.autocorrelation(200n);
|
|
1426
|
+
|
|
1427
|
+
round(rho[0]); // 1
|
|
1428
|
+
round(rho[128]); // 0.875
|
|
1429
|
+
round(rho[64]); // -0.9375
|
|
1430
|
+
round(periodic.autocorrelation(200n, "none")[0]); // 512
|
|
1431
|
+
```
|
|
1432
|
+
|
|
1433
|
+
One more reading deserves a warning: `spectralFlux` normalises each frame before comparing, so a
|
|
1434
|
+
level change alone produces zero flux. Its first and last values are edge artefacts of the zero
|
|
1435
|
+
boundary, not onsets.
|
|
1436
|
+
|
|
1437
|
+
---
|
|
1438
|
+
|
|
1439
|
+
<a id="15-license"></a>
|
|
1440
|
+
## 15. License
|
|
1441
|
+
|
|
1442
|
+
MIT © Eloy Kim
|