@motionscript/audio 0.1.0-alpha.3 → 0.1.0-alpha.5
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 +30 -0
- package/LICENSE +201 -201
- package/NOTICE +48 -0
- package/README.md +92 -0
- package/dist/browser/chunks/chunk-DAFIVUTT.js +2 -0
- package/dist/browser/chunks/chunk-DAFIVUTT.js.map +7 -0
- package/dist/browser/index.js +1 -1
- package/dist/browser/index.js.map +2 -2
- package/dist/browser/kit.js +1 -1
- package/dist/browser/manifest.json +3 -2
- package/dist/index.js.map +1 -1
- package/dist/kit.js.map +1 -1
- package/dist/nodes.js.map +1 -1
- package/dist/waveform/envelope.d.ts +42 -9
- package/dist/waveform/envelope.d.ts.map +1 -1
- package/dist/waveform/envelope.js +34 -4
- package/dist/waveform/envelope.js.map +1 -1
- package/dist/waveform/full-waveform.d.ts +1 -2
- package/dist/waveform/full-waveform.d.ts.map +1 -1
- package/dist/waveform/full-waveform.js +1 -2
- package/dist/waveform/full-waveform.js.map +1 -1
- package/dist/waveform/index.js.map +1 -1
- package/dist/waveform/live-waveform.d.ts +2 -2
- package/dist/waveform/live-waveform.js +2 -2
- package/dist/waveform/live-waveform.js.map +1 -1
- package/dist/waveform/shared.d.ts +10 -8
- package/dist/waveform/shared.d.ts.map +1 -1
- package/dist/waveform/shared.js +14 -15
- package/dist/waveform/shared.js.map +1 -1
- package/package.json +7 -6
- package/registry.json +28 -28
- package/src/index.ts +2 -2
- package/src/kit.ts +22 -22
- package/src/nodes.ts +19 -19
- package/src/waveform/envelope.ts +176 -143
- package/src/waveform/full-waveform.ts +217 -218
- package/src/waveform/index.ts +30 -30
- package/src/waveform/live-waveform.ts +221 -221
- package/src/waveform/shared.ts +197 -194
- package/dist/browser/chunks/chunk-B3JXGNP6.js +0 -2
- package/dist/browser/chunks/chunk-B3JXGNP6.js.map +0 -7
package/src/waveform/envelope.ts
CHANGED
|
@@ -1,143 +1,176 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The shape of a recording, as the waveform nodes draw it.
|
|
3
|
-
*
|
|
4
|
-
* ##
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* The
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
1
|
+
/**
|
|
2
|
+
* The shape of a recording, as the waveform nodes draw it.
|
|
3
|
+
*
|
|
4
|
+
* ## How an envelope reaches a node: the host supplies it
|
|
5
|
+
*
|
|
6
|
+
* Nothing in the engine, and nothing in this kit, decodes audio. A waveform node
|
|
7
|
+
* does not read a `sound` node's `src`, declares no audio asset and is not tied
|
|
8
|
+
* to a clip: it draws the {@link AudioEnvelope} it is handed in its `envelope`
|
|
9
|
+
* prop. Decoding a file into an envelope is the host's job, done once per file
|
|
10
|
+
* before the document is built. Where the recording sits on the audio lane is
|
|
11
|
+
* the author's timing too, which is why `FullWaveform.follow` is opt-in.
|
|
12
|
+
*
|
|
13
|
+
* ### The JSON form
|
|
14
|
+
*
|
|
15
|
+
* A document holds the envelope as plain JSON, because a prop value in a
|
|
16
|
+
* document is JSON. The typed arrays in {@link AudioEnvelope} are one spelling of
|
|
17
|
+
* it; `ArrayLike<number>` also accepts the plain arrays a document stores:
|
|
18
|
+
*
|
|
19
|
+
* ```json
|
|
20
|
+
* {
|
|
21
|
+
* "magnitude": [0, 0.25, 0.5, 1, 0.5, 0],
|
|
22
|
+
* "duration": 6,
|
|
23
|
+
* "speaker": [0, 0, 0, 1, 1, -1],
|
|
24
|
+
* "speakers": 2
|
|
25
|
+
* }
|
|
26
|
+
* ```
|
|
27
|
+
*
|
|
28
|
+
* `magnitude` is the peak loudness, `0`…`1`, of buckets that divide `duration`
|
|
29
|
+
* seconds evenly. `speaker` is `null` when nobody was identified; otherwise it is parallel to `magnitude`, one voice
|
|
30
|
+
* index per bucket, `-1` for "nobody". `speakers` is the voice count, `0` when
|
|
31
|
+
* `speaker` is `null`. A `Float32Array` or `Int16Array` is not JSON-serialisable
|
|
32
|
+
* (`JSON.stringify` writes an object of indices), so a host that holds the typed
|
|
33
|
+
* form converts with `Array.from` before storing it in a document.
|
|
34
|
+
*
|
|
35
|
+
* ## Why the data is baked rather than listened to
|
|
36
|
+
*
|
|
37
|
+
* A scene has to render identically in the preview and in a headless export,
|
|
38
|
+
* and an export has no audio device, no `AudioContext` and no playback to
|
|
39
|
+
* analyse. So a "live" visualiser cannot read a Web Audio analyser: what it
|
|
40
|
+
* reads is a *pre-computed envelope*, indexed by the scene clock. The picture is
|
|
41
|
+
* then a pure function of time, which is what makes scrubbing backwards, jumping
|
|
42
|
+
* to frame 900, and rendering out of order all produce the same frame.
|
|
43
|
+
*
|
|
44
|
+
* The decode happens in the host, once per file, and arrives here through the
|
|
45
|
+
* build — the same arrangement a chart's rows and a protein's atoms already use,
|
|
46
|
+
* and for the same reason: the build is synchronous and cannot await a fetch.
|
|
47
|
+
*
|
|
48
|
+
* ## Why magnitude rather than min/max
|
|
49
|
+
*
|
|
50
|
+
* An editor's waveform is drawn from per-bucket minima *and* maxima, because an
|
|
51
|
+
* editor is a measuring instrument. These nodes are neither — they are a bar
|
|
52
|
+
* chart of loudness that happens to be shaped like a recording — and audio is
|
|
53
|
+
* near enough symmetric that a mirrored magnitude is indistinguishable at the
|
|
54
|
+
* size a node draws. Half the numbers, and one array to reason about.
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A decoded recording, reduced to a uniform grid of loudness — plus, when the
|
|
59
|
+
* host knows it, who is talking in each bucket.
|
|
60
|
+
*
|
|
61
|
+
* The speaker track is **not** something a decoder can produce; it comes from a
|
|
62
|
+
* diarized transcript and is folded in by the host before the build. It lives
|
|
63
|
+
* here rather than in a parallel structure because every reader of it is also a
|
|
64
|
+
* reader of the magnitudes at the same index, and two arrays that must stay the
|
|
65
|
+
* same length are better as one object than as two arguments.
|
|
66
|
+
*/
|
|
67
|
+
export interface AudioEnvelope {
|
|
68
|
+
/**
|
|
69
|
+
* Peak magnitude per bucket, 0…1. Buckets span {@link duration} evenly. A
|
|
70
|
+
* `Float32Array`, or the plain `number[]` a document stores.
|
|
71
|
+
*/
|
|
72
|
+
magnitude: ArrayLike<number>
|
|
73
|
+
/** The decoded length in seconds. */
|
|
74
|
+
duration: number
|
|
75
|
+
/**
|
|
76
|
+
* Which voice is speaking in each bucket, as an index into the recording's
|
|
77
|
+
* own numbering, or `-1` for "nobody identified". Parallel to
|
|
78
|
+
* {@link magnitude}, or `null` when the track was never diarized.
|
|
79
|
+
*/
|
|
80
|
+
speaker: ArrayLike<number> | null
|
|
81
|
+
/** How many distinct voices the diarizer found. Zero when it did not run. */
|
|
82
|
+
speakers: number
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The envelope of a track that hasn't loaded — or isn't there. */
|
|
86
|
+
export const EMPTY_ENVELOPE: AudioEnvelope = {
|
|
87
|
+
magnitude: new Float32Array(0),
|
|
88
|
+
duration: 0,
|
|
89
|
+
speaker: null,
|
|
90
|
+
speakers: 0,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** One drawn bar: how loud, and whose. */
|
|
94
|
+
export interface EnvelopeBar {
|
|
95
|
+
/** 0…1. */
|
|
96
|
+
magnitude: number
|
|
97
|
+
/** The voice index, or -1. */
|
|
98
|
+
speaker: number
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Resamples `[from, to)` seconds of an envelope down to `bars` columns.
|
|
103
|
+
*
|
|
104
|
+
* Peak-preserving rather than averaging, which is the whole difference between
|
|
105
|
+
* a waveform and a blur: averaging a hundred buckets into one column flattens
|
|
106
|
+
* every transient, and transients are what makes a drawn recording read as
|
|
107
|
+
* speech rather than as noise. The speaker of a column is the speaker of its
|
|
108
|
+
* *loudest* bucket, on the same reasoning — a column straddling a turn belongs
|
|
109
|
+
* to whoever is actually audible in it.
|
|
110
|
+
*
|
|
111
|
+
* A window past either end of the recording yields silent, unattributed
|
|
112
|
+
* columns rather than clamping onto the nearest real one. That matters for the
|
|
113
|
+
* live visualiser, which is handed a window centred on the playhead and would
|
|
114
|
+
* otherwise smear the first bucket across the whole run-up to a track's start.
|
|
115
|
+
*/
|
|
116
|
+
export function sampleEnvelope(
|
|
117
|
+
envelope: AudioEnvelope,
|
|
118
|
+
from: number,
|
|
119
|
+
to: number,
|
|
120
|
+
bars: number
|
|
121
|
+
): EnvelopeBar[] {
|
|
122
|
+
const count = Math.max(0, Math.floor(bars))
|
|
123
|
+
const out: EnvelopeBar[] = new Array(count)
|
|
124
|
+
for (let i = 0; i < count; i++) out[i] = { magnitude: 0, speaker: -1 }
|
|
125
|
+
|
|
126
|
+
const buckets = envelope.magnitude.length
|
|
127
|
+
if (count === 0 || buckets === 0 || envelope.duration <= 0) return out
|
|
128
|
+
|
|
129
|
+
const perSecond = buckets / envelope.duration
|
|
130
|
+
const span = (to - from) / count
|
|
131
|
+
|
|
132
|
+
for (let i = 0; i < count; i++) {
|
|
133
|
+
const start = (from + span * i) * perSecond
|
|
134
|
+
const end = (from + span * (i + 1)) * perSecond
|
|
135
|
+
|
|
136
|
+
// At least one bucket per column, so a window zoomed in past the grid draws
|
|
137
|
+
// the sample it is standing on rather than an empty band.
|
|
138
|
+
const first = Math.floor(start)
|
|
139
|
+
const last = Math.max(first, Math.ceil(end) - 1)
|
|
140
|
+
|
|
141
|
+
let loudest = 0
|
|
142
|
+
let speaker = -1
|
|
143
|
+
for (let b = first; b <= last; b++) {
|
|
144
|
+
if (b < 0 || b >= buckets) continue
|
|
145
|
+
const value = envelope.magnitude[b]
|
|
146
|
+
if (value < loudest) continue
|
|
147
|
+
loudest = value
|
|
148
|
+
speaker = envelope.speaker ? envelope.speaker[b] : -1
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
out[i] = { magnitude: loudest, speaker }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return out
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The same window, with everything that isn't `voice` silenced.
|
|
159
|
+
*
|
|
160
|
+
* The point of the speaker filter, and the reason it silences rather than
|
|
161
|
+
* *drops*: a podcast visualiser filtered to one host should go quiet while the
|
|
162
|
+
* other one talks, not compress their turn out of existence. Keeping the bars
|
|
163
|
+
* and zeroing them is what makes the picture read as "this person is not
|
|
164
|
+
* speaking right now" instead of as a jump cut.
|
|
165
|
+
*
|
|
166
|
+
* `voice` of `null` returns the bars untouched, which is the unfiltered node.
|
|
167
|
+
*/
|
|
168
|
+
export function forSpeaker(
|
|
169
|
+
bars: EnvelopeBar[],
|
|
170
|
+
voice: number | null
|
|
171
|
+
): EnvelopeBar[] {
|
|
172
|
+
if (voice === null) return bars
|
|
173
|
+
return bars.map((bar) =>
|
|
174
|
+
bar.speaker === voice ? bar : { magnitude: 0, speaker: bar.speaker }
|
|
175
|
+
)
|
|
176
|
+
}
|