tryharness 0.7.26 → 0.7.29

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/lib/listen.mjs ADDED
@@ -0,0 +1,674 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { execFile, spawn } from "node:child_process";
5
+
6
+ // What an agent on this computer can know of a recording without ears (listen): its tempo and beat grid, where bars
7
+ // likely start, how loud it is by the broadcast measure (EBU R128), where it gets louder, quieter or silent, and the
8
+ // words said in it. Measured here from the samples; nothing leaves the computer.
9
+
10
+ // Loudness is defined on the full band, with K-weighting filters published for 48 kHz.
11
+ const RATE = 48_000;
12
+ // Rhythm and speech are read from a 16 kHz mono copy: every third sample, after a low-pass.
13
+ const DOWN = 3;
14
+ const LOW_RATE = RATE / DOWN;
15
+ const LONGEST = 600;
16
+ // Loudness is gathered in 100 ms steps: a momentary block is 4 of them, a short-term window 30.
17
+ const STEP = RATE / 10;
18
+ const STEPS_PER_SECOND = 10;
19
+ const CLIP = 32767 / 32768;
20
+ // A 100 ms step under this many LUFS is silence.
21
+ const SILENT = -60;
22
+ // A new section starts where the 3 s loudness moves this many LU from the section's level and stays moved.
23
+ const LEVEL_SHIFT = 4;
24
+
25
+ // The onset curve: 64 ms frames every 10 ms, so 100 points a second.
26
+ const FRAME = 1024;
27
+ const HOP = 160;
28
+ const FPS = LOW_RATE / HOP;
29
+ // Spectral flux peaks when a sound is three quarters into a frame, where the window rises fastest: a frame's onset is
30
+ // that far after its start (48 ms on clicks over silence and over a steady pad alike).
31
+ const ONSET_LAG = (3 * FRAME) / 4;
32
+ // Under 150 Hz: the kick drum, which most often marks where a bar starts.
33
+ const BASS_BINS = Math.round(150 / (LOW_RATE / FRAME));
34
+ // How strictly beats keep to one period. At librosa's 100 the tracker held the off-beat for bars at a time on four of
35
+ // the content factory's six tracks; at 400, five of the six stay on one grid and the tempos agree with their pages.
36
+ const TIGHTNESS = 400;
37
+ // A grid is steady when beats stray from it by less than this on average.
38
+ const STEADY_STRAY = 0.03;
39
+
40
+ const round = (value) => Math.round(value * 100) / 100;
41
+ const round1 = (value) => Math.round(value * 10) / 10;
42
+ const average = (values) =>
43
+ values.reduce((sum, value) => sum + value, 0) / values.length;
44
+ const lufs = (energy) => -0.691 + 10 * Math.log10(energy);
45
+ const lastLine = (text) => String(text).trim().split("\n").at(-1) || "";
46
+
47
+ // ── Decoding ─────────────────────────────────────────────────────────────────
48
+
49
+ const runs = (tool) =>
50
+ new Promise((resolve) =>
51
+ execFile(tool, ["-version"], (error) => resolve(!error))
52
+ );
53
+
54
+ /**
55
+ * The recording from `from` for at most `seconds`, as a WAV byte stream of 16-bit samples at 48 kHz in its own
56
+ * channels. From ffmpeg; on a Mac without it, from afconvert through a scratch file, which cannot seek, so its first
57
+ * `skip` frames are for the reader to pass over.
58
+ */
59
+ async function decode(file, from, seconds) {
60
+ if (await runs("ffmpeg"))
61
+ return { bytes: fromFfmpeg(file, from, seconds), skip: 0 };
62
+ if (process.platform === "darwin")
63
+ return { bytes: fromAfconvert(file), skip: Math.round(from * RATE) };
64
+ throw new Error("listening needs ffmpeg on this computer");
65
+ }
66
+
67
+ async function* fromFfmpeg(file, from, seconds) {
68
+ const child = spawn("ffmpeg", [
69
+ ...["-v", "error", "-ss", String(from), "-t", String(seconds)],
70
+ ...["-i", file, "-vn", "-sn", "-ar", String(RATE)],
71
+ ...["-c:a", "pcm_s16le", "-f", "wav", "pipe:1"],
72
+ ]);
73
+ let said = "";
74
+ child.stderr.on("data", (chunk) => (said += chunk));
75
+ const exited = new Promise((resolve) => child.on("close", resolve));
76
+ try {
77
+ yield* child.stdout;
78
+ if ((await exited) !== 0)
79
+ throw new Error(`ffmpeg found no sound to read: ${lastLine(said)}`);
80
+ } finally {
81
+ child.kill();
82
+ }
83
+ }
84
+
85
+ async function* fromAfconvert(file) {
86
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "harness-listen-"));
87
+ const wav = path.join(dir, "sound.wav");
88
+ try {
89
+ await new Promise((resolve, reject) =>
90
+ execFile(
91
+ "afconvert",
92
+ ["-f", "WAVE", "-d", `LEI16@${RATE}`, file, wav],
93
+ (error) =>
94
+ error
95
+ ? reject(new Error(`${path.basename(file)} needs ffmpeg to read`))
96
+ : resolve()
97
+ )
98
+ );
99
+ yield* fs.createReadStream(wav);
100
+ } finally {
101
+ fs.rmSync(dir, { recursive: true, force: true });
102
+ }
103
+ }
104
+
105
+ /** Where a WAV stream's samples start and how many channels it has, once enough of it has arrived; null until then. */
106
+ function wavStart(bytes) {
107
+ if (bytes.length < 12) return null;
108
+ const riff = bytes.toString("ascii", 0, 4);
109
+ const wave = bytes.toString("ascii", 8, 12);
110
+ if (riff !== "RIFF" || wave !== "WAVE")
111
+ throw new Error("the decoder did not give a WAV stream");
112
+ let channels = 0;
113
+ for (let at = 12; at + 8 <= bytes.length;) {
114
+ const tag = bytes.toString("ascii", at, at + 4);
115
+ const size = bytes.readUInt32LE(at + 4);
116
+ if (tag === "data") return channels ? { channels, at: at + 8 } : null;
117
+ if (tag === "fmt " && at + 12 <= bytes.length)
118
+ channels = bytes.readUInt16LE(at + 10);
119
+ at += 8 + size + (size % 2);
120
+ }
121
+ return null;
122
+ }
123
+
124
+ /** A WAV byte stream as chunks of interleaved samples from -1 to 1: { channels, samples }. */
125
+ async function* sampleChunks(bytes) {
126
+ let channels = 0;
127
+ let pending = Buffer.alloc(0);
128
+ for await (const chunk of bytes) {
129
+ pending = pending.length ? Buffer.concat([pending, chunk]) : chunk;
130
+ if (!channels) {
131
+ const start = wavStart(pending);
132
+ if (!start) continue;
133
+ channels = start.channels;
134
+ pending = pending.subarray(start.at);
135
+ }
136
+ const whole = pending.length - (pending.length % (2 * channels));
137
+ const samples = new Float32Array(whole / 2);
138
+ for (let i = 0; i < samples.length; i++)
139
+ samples[i] = pending.readInt16LE(2 * i) / 32768;
140
+ pending = pending.subarray(whole);
141
+ yield { channels, samples };
142
+ }
143
+ }
144
+
145
+ // ── Measuring while decoding ─────────────────────────────────────────────────
146
+
147
+ /** A second-order filter over one sample at a time, keeping its own state. */
148
+ function biquad([b0, b1, b2], [, a1, a2]) {
149
+ let x1 = 0;
150
+ let x2 = 0;
151
+ let y1 = 0;
152
+ let y2 = 0;
153
+ return (x) => {
154
+ const y = b0 * x + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2;
155
+ x2 = x1;
156
+ x1 = x;
157
+ y2 = y1;
158
+ y1 = y;
159
+ return y;
160
+ };
161
+ }
162
+
163
+ // ITU-R BS.1770-4 K-weighting at 48 kHz: a high shelf for the head, then a high-pass.
164
+ const SHELF = [
165
+ [1.53512485958697, -2.69169618940638, 1.19839281085285],
166
+ [1, -1.69065929318241, 0.73248077421585],
167
+ ];
168
+ const HIGH_PASS = [
169
+ [1, -2, 1],
170
+ [1, -1.99004745483398, 0.99007225036621],
171
+ ];
172
+
173
+ /** A low-pass biquad at `cutoff` Hz with quality `q` (RBJ's cookbook). */
174
+ function lowPass(cutoff, q) {
175
+ const w = (2 * Math.PI * cutoff) / RATE;
176
+ const alpha = Math.sin(w) / (2 * q);
177
+ const a0 = 1 + alpha;
178
+ const b = (1 - Math.cos(w)) / 2 / a0;
179
+ return [
180
+ [b, 2 * b, b],
181
+ [1, (-2 * Math.cos(w)) / a0, (1 - alpha) / a0],
182
+ ];
183
+ }
184
+ // A fourth-order Butterworth at 7 kHz, so nothing above 8 kHz folds back into the 16 kHz copy.
185
+ const ANTI_ALIAS = [lowPass(7000, 0.5412), lowPass(7000, 1.3066)];
186
+
187
+ /**
188
+ * Reads the stream once: K-weighted energy per 100 ms step (summed over channels), the sample peak, how many samples
189
+ * clipped, and the 16 kHz mono copy.
190
+ */
191
+ async function measure({ bytes, skip }, limit) {
192
+ const steps = [];
193
+ const pieces = [];
194
+ let filters = null;
195
+ const smooth = ANTI_ALIAS.map((stage) => biquad(...stage));
196
+ let frame = 0;
197
+ let energy = 0;
198
+ let filled = 0;
199
+ let peak = 0;
200
+ let clipped = 0;
201
+ const done = () => frame >= skip + limit;
202
+ for await (const { channels, samples } of sampleChunks(bytes)) {
203
+ filters ??= Array.from({ length: channels }, () => [
204
+ biquad(...SHELF),
205
+ biquad(...HIGH_PASS),
206
+ ]);
207
+ const piece = new Float32Array(Math.ceil(samples.length / channels / DOWN));
208
+ let kept = 0;
209
+ for (let at = 0; at < samples.length && !done(); at += channels, frame++) {
210
+ if (frame < skip) continue;
211
+ let sum = 0;
212
+ for (let c = 0; c < channels; c++) {
213
+ const sample = samples[at + c];
214
+ const [shelf, highPass] = filters[c];
215
+ const weighted = highPass(shelf(sample));
216
+ energy += weighted * weighted;
217
+ peak = Math.max(peak, Math.abs(sample));
218
+ if (Math.abs(sample) >= CLIP) clipped++;
219
+ sum += sample;
220
+ }
221
+ const low = smooth[1](smooth[0](sum / channels));
222
+ if ((frame - skip) % DOWN === 0) piece[kept++] = low;
223
+ if (++filled === STEP) {
224
+ steps.push(energy / STEP);
225
+ energy = 0;
226
+ filled = 0;
227
+ }
228
+ }
229
+ pieces.push(piece.subarray(0, kept));
230
+ if (done()) break;
231
+ }
232
+ return { steps, peak, clipped, mono: joined(pieces) };
233
+ }
234
+
235
+ function joined(pieces) {
236
+ const out = new Float32Array(
237
+ pieces.reduce((n, piece) => n + piece.length, 0)
238
+ );
239
+ let at = 0;
240
+ for (const piece of pieces) {
241
+ out.set(piece, at);
242
+ at += piece.length;
243
+ }
244
+ return out;
245
+ }
246
+
247
+ // ── Loudness ─────────────────────────────────────────────────────────────────
248
+
249
+ /** Mean energy over every run of `size` steps, one per step. */
250
+ function windows(steps, size) {
251
+ const out = [];
252
+ for (let i = 0; i + size <= steps.length; i++)
253
+ out.push(average(steps.slice(i, i + size)));
254
+ return out;
255
+ }
256
+
257
+ /** Integrated loudness (BS.1770-4): 400 ms blocks every 100 ms, gated at -70 LUFS and at 10 LU under their mean. */
258
+ function integratedLoudness(steps) {
259
+ const blocks = windows(steps, 4).filter((block) => lufs(block) > -70);
260
+ if (!blocks.length) return null;
261
+ const gate = lufs(average(blocks)) - 10;
262
+ return round1(lufs(average(blocks.filter((block) => lufs(block) > gate))));
263
+ }
264
+
265
+ /** Loudness range (EBU Tech 3342): 3 s loudness from its 10th to 95th percentile, gated at -70 LUFS and 20 LU under. */
266
+ function loudnessRange(steps) {
267
+ const shortTerm = windows(steps, 30).filter((block) => lufs(block) > -70);
268
+ if (!shortTerm.length) return null;
269
+ const gate = lufs(average(shortTerm)) - 20;
270
+ const levels = shortTerm
271
+ .map(lufs)
272
+ .filter((level) => level > gate)
273
+ .sort((a, b) => a - b);
274
+ const at = (share) => levels[Math.round(share * (levels.length - 1))];
275
+ return round1(at(0.95) - at(0.1));
276
+ }
277
+
278
+ /** Stretches of half a second or more in which every 100 ms step is under SILENT. */
279
+ function silences(steps, from) {
280
+ const found = [];
281
+ let start = null;
282
+ for (let i = 0; i <= steps.length; i++) {
283
+ const quiet = i < steps.length && lufs(steps[i]) < SILENT;
284
+ if (quiet && start === null) start = i;
285
+ if (!quiet && start !== null) {
286
+ if (i - start >= STEPS_PER_SECOND / 2)
287
+ found.push({
288
+ start: round(from + start / STEPS_PER_SECOND),
289
+ end: round(from + i / STEPS_PER_SECOND),
290
+ });
291
+ start = null;
292
+ }
293
+ }
294
+ return found;
295
+ }
296
+
297
+ /**
298
+ * Where it gets louder or quieter: sections of the 3 s loudness, each starting where the level moves LEVEL_SHIFT LU
299
+ * from the section's own for two seconds running, with each section's mean level.
300
+ */
301
+ function levelSections(steps, from) {
302
+ const seconds = Math.floor(steps.length / STEPS_PER_SECOND);
303
+ if (seconds < 2) return [];
304
+ const perSecond = Array.from({ length: seconds }, (_, s) => {
305
+ const around = steps.slice(Math.max(0, (s - 1) * 10), (s + 2) * 10);
306
+ return Math.max(SILENT, lufs(average(around)));
307
+ });
308
+ const starts = [0];
309
+ let level = perSecond[0];
310
+ for (let s = 1; s + 1 < seconds; s++) {
311
+ const moved = (t) => Math.abs(perSecond[t] - level) >= LEVEL_SHIFT;
312
+ if (!moved(s) || !moved(s + 1)) continue;
313
+ starts.push(s);
314
+ level = perSecond[s + 1];
315
+ }
316
+ return starts.map((start, i) => {
317
+ const end = starts[i + 1] ?? seconds;
318
+ return {
319
+ start: round(from + start),
320
+ end: round(from + end),
321
+ lufs: round1(average(perSecond.slice(start, end))),
322
+ };
323
+ });
324
+ }
325
+
326
+ // ── Rhythm ───────────────────────────────────────────────────────────────────
327
+
328
+ const WINDOW = Float64Array.from(
329
+ { length: FRAME },
330
+ (_, i) => 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / FRAME)
331
+ );
332
+ const COS = Float64Array.from({ length: FRAME / 2 }, (_, k) =>
333
+ Math.cos((-2 * Math.PI * k) / FRAME)
334
+ );
335
+ const SIN = Float64Array.from({ length: FRAME / 2 }, (_, k) =>
336
+ Math.sin((-2 * Math.PI * k) / FRAME)
337
+ );
338
+
339
+ /** In-place radix-2 FFT of FRAME points. */
340
+ function fft(re, im) {
341
+ for (let i = 1, j = 0; i < FRAME; i++) {
342
+ let bit = FRAME >> 1;
343
+ for (; j & bit; bit >>= 1) j ^= bit;
344
+ j ^= bit;
345
+ if (i < j) {
346
+ [re[i], re[j]] = [re[j], re[i]];
347
+ [im[i], im[j]] = [im[j], im[i]];
348
+ }
349
+ }
350
+ for (let size = 2; size <= FRAME; size *= 2) {
351
+ const half = size / 2;
352
+ const stride = FRAME / size;
353
+ for (let start = 0; start < FRAME; start += size)
354
+ for (let k = 0; k < half; k++) {
355
+ const wr = COS[k * stride];
356
+ const wi = SIN[k * stride];
357
+ const a = start + k;
358
+ const b = a + half;
359
+ const tr = wr * re[b] - wi * im[b];
360
+ const ti = wr * im[b] + wi * re[b];
361
+ re[b] = re[a] - tr;
362
+ im[b] = im[a] - ti;
363
+ re[a] += tr;
364
+ im[a] += ti;
365
+ }
366
+ }
367
+ }
368
+
369
+ /**
370
+ * How sharply sound starts at each 10 ms: the rise of every frequency's log level from one frame to the next (spectral
371
+ * flux), over all frequencies and under 150 Hz. Yields to the daemon's other work every few thousand frames.
372
+ */
373
+ async function onsetCurves(mono) {
374
+ const frames = Math.max(0, Math.floor((mono.length - FRAME) / HOP) + 1);
375
+ const all = new Float32Array(frames);
376
+ const bass = new Float32Array(frames);
377
+ const re = new Float64Array(FRAME);
378
+ const im = new Float64Array(FRAME);
379
+ const previous = new Float64Array(FRAME / 2);
380
+ for (let t = 0; t < frames; t++) {
381
+ for (let i = 0; i < FRAME; i++) {
382
+ re[i] = mono[t * HOP + i] * WINDOW[i];
383
+ im[i] = 0;
384
+ }
385
+ fft(re, im);
386
+ for (let k = 1; k < FRAME / 2; k++) {
387
+ const level = Math.log1p(10 * Math.hypot(re[k], im[k]));
388
+ const rise = Math.max(0, level - previous[k]);
389
+ all[t] += rise;
390
+ if (k < BASS_BINS) bass[t] += rise;
391
+ previous[k] = level;
392
+ }
393
+ if (t % 2000 === 1999)
394
+ await new Promise((resolve) => setImmediate(resolve));
395
+ }
396
+ return { all, bass };
397
+ }
398
+
399
+ /** The onset curve less its local mean (over a second), kept above zero and scaled to unit size. */
400
+ function normalised(curve) {
401
+ const sums = new Float64Array(curve.length + 1);
402
+ curve.forEach((value, i) => (sums[i + 1] = sums[i] + value));
403
+ const reach = FPS / 2;
404
+ const out = curve.map((value, i) => {
405
+ const a = Math.max(0, i - reach);
406
+ const b = Math.min(curve.length, i + reach + 1);
407
+ return Math.max(0, value - (sums[b] - sums[a]) / (b - a));
408
+ });
409
+ const size = Math.sqrt(average(out.map((value) => value * value))) || 1;
410
+ return out.map((value) => value / size);
411
+ }
412
+
413
+ /**
414
+ * The beat period in onset frames: the lag at which the curve best matches itself, between 40 and 240 BPM, leaning
415
+ * toward 120 as listeners do (one octave either side halves the weight twice).
416
+ */
417
+ function beatPeriod(curve) {
418
+ const shortest = Math.floor((FPS * 60) / 240);
419
+ const longest = Math.ceil((FPS * 60) / 40);
420
+ const scores = new Map();
421
+ for (let lag = shortest - 1; lag <= longest + 1; lag++) {
422
+ let sum = 0;
423
+ for (let t = lag; t < curve.length; t++) sum += curve[t] * curve[t - lag];
424
+ const bpm = (FPS * 60) / lag;
425
+ const lean = Math.exp(-0.5 * Math.log2(bpm / 120) ** 2);
426
+ scores.set(lag, (sum / (curve.length - lag)) * lean);
427
+ }
428
+ let best = shortest;
429
+ for (let lag = shortest; lag <= longest; lag++)
430
+ if (scores.get(lag) > scores.get(best)) best = lag;
431
+ // A parabola through the peak and its neighbours places it between whole frames.
432
+ const [a, b, c] = [best - 1, best, best + 1].map((lag) => scores.get(lag));
433
+ const bend = a - 2 * b + c;
434
+ const shift = bend === 0 ? 0 : (0.5 * (a - c)) / bend;
435
+ return best + shift;
436
+ }
437
+
438
+ /** Beat frames by dynamic programming (Ellis 2007): onsets on a beat score, gaps away from the period cost. */
439
+ function trackBeats(curve, period) {
440
+ const count = curve.length;
441
+ const score = new Float64Array(count);
442
+ const previous = new Int32Array(count).fill(-1);
443
+ const nearest = Math.max(1, Math.round(period / 2));
444
+ const farthest = Math.round(period * 2);
445
+ for (let t = 0; t < count; t++) {
446
+ let best = 0;
447
+ for (let p = Math.max(0, t - farthest); p <= t - nearest; p++) {
448
+ const drift = Math.log((t - p) / period);
449
+ const value = score[p] - TIGHTNESS * drift * drift;
450
+ if (value > best) {
451
+ best = value;
452
+ previous[t] = p;
453
+ }
454
+ }
455
+ score[t] = curve[t] + best;
456
+ }
457
+ // The chain ends on the best-scoring frame within the last period.
458
+ let end = Math.max(0, count - Math.round(period));
459
+ for (let t = end; t < count; t++) if (score[t] > score[end]) end = t;
460
+ const beats = [];
461
+ for (let t = end; t >= 0; t = previous[t]) beats.push(t);
462
+ return trimmed(curve, beats.reverse());
463
+ }
464
+
465
+ /**
466
+ * The chain runs on through an intro or outro with no onsets at the period's own pace; beats at either end weaker than
467
+ * half the beats' typical onset are dropped, as librosa does.
468
+ */
469
+ function trimmed(curve, beats) {
470
+ const strengths = beats.map((frame) => peakNear(curve, frame));
471
+ const typical = Math.sqrt(average(strengths.map((s) => s * s)));
472
+ const strong = (i) => strengths[i] >= typical / 2;
473
+ let first = 0;
474
+ let last = beats.length - 1;
475
+ while (first < last && !strong(first)) first++;
476
+ while (last > first && !strong(last)) last--;
477
+ return beats.slice(first, last + 1);
478
+ }
479
+
480
+ /** The strongest onset within 20 ms of a frame; 0 off either end. */
481
+ function peakNear(curve, frame) {
482
+ let most = 0;
483
+ const last = Math.min(curve.length - 1, frame + 2);
484
+ for (let t = Math.max(0, frame - 2); t <= last; t++)
485
+ most = Math.max(most, curve[t]);
486
+ return most;
487
+ }
488
+ const frameAt = (time) => Math.round((time * LOW_RATE - ONSET_LAG) / HOP);
489
+
490
+ /**
491
+ * How much more onset the grid's beats have than any moment does, on average: about 1 for a grid the music ignores.
492
+ * A strict tracker keeps an even spacing through anything, so an even spacing alone does not show a beat.
493
+ */
494
+ function beatStrength(curve, grid) {
495
+ const anywhere = average(curve.map((_, frame) => peakNear(curve, frame)));
496
+ const end = (curve.length * HOP) / LOW_RATE;
497
+ const onBeats = [];
498
+ for (let time = grid.first; time < end; time += grid.length)
499
+ onBeats.push(peakNear(curve, frameAt(time)));
500
+ return average(onBeats) / anywhere;
501
+ }
502
+
503
+ const beatNumber = (time, grid) =>
504
+ Math.round((time - grid.first) / grid.length);
505
+ const missOf = (time, grid) =>
506
+ time - grid.first - beatNumber(time, grid) * grid.length;
507
+
508
+ /** The straight line through beat times by their beat numbers on `grid`: { first, length }. */
509
+ function line(times, grid) {
510
+ const numbers = times.map((time) => beatNumber(time, grid));
511
+ const meanNumber = average(numbers);
512
+ const meanTime = average(times);
513
+ let across = 0;
514
+ let spread = 0;
515
+ numbers.forEach((n, i) => {
516
+ across += (n - meanNumber) * (times[i] - meanTime);
517
+ spread += (n - meanNumber) ** 2;
518
+ });
519
+ const length = across / spread;
520
+ return { first: meanTime - length * meanNumber, length };
521
+ }
522
+
523
+ /**
524
+ * The steady grid through beat times: { first, length, stray, onGrid }. The tracker can hold the off-beat for a few
525
+ * bars (Candy, 18 to 28 s), so the line is fitted again on the beats near it, closer each time; `stray` is how far
526
+ * those beats are from it and `onGrid` the share of beats that are.
527
+ */
528
+ function fitGrid(times, roughLength) {
529
+ let grid = { first: times[0], length: roughLength };
530
+ let kept = times;
531
+ for (const reach of [0.5, 0.25, 0.1]) {
532
+ const near = times.filter(
533
+ (time) => Math.abs(missOf(time, grid)) < reach * grid.length
534
+ );
535
+ if (near.length < 8) break;
536
+ kept = near;
537
+ grid = line(kept, grid);
538
+ }
539
+ const misses = kept.map((time) => missOf(time, grid));
540
+ const stray = Math.sqrt(average(misses.map((miss) => miss * miss)));
541
+ return { ...grid, stray, onGrid: kept.length / times.length };
542
+ }
543
+
544
+ /**
545
+ * Which beat of four starts each bar: the one with the most new bass on it (the kick), with `sure` when it stands
546
+ * clearly above the next. Music with a kick on every beat leaves it unsure.
547
+ */
548
+ function barStart(bass, grid, times) {
549
+ const strength = [0, 0, 0, 0];
550
+ const counts = [0, 0, 0, 0];
551
+ for (const time of times) {
552
+ const n = Math.round((time - grid.first) / grid.length);
553
+ const beat = ((n % 4) + 4) % 4;
554
+ strength[beat] += peakNear(bass, frameAt(time));
555
+ counts[beat]++;
556
+ }
557
+ const mean = strength.map((total, beat) => total / (counts[beat] || 1));
558
+ const order = [0, 1, 2, 3].sort((a, b) => mean[b] - mean[a]);
559
+ return { beat: order[0], sure: mean[order[0]] >= 1.25 * mean[order[1]] };
560
+ }
561
+
562
+ /** The tempo and beat grid, or { steady: false } with the beats found when the sound has no steady beat. */
563
+ async function rhythm(mono, from, quiet) {
564
+ const { all, bass } = await onsetCurves(mono);
565
+ if (all.length < FPS * 4) return { steady: false, beats: [] };
566
+ const curve = normalised(all);
567
+ const period = beatPeriod(curve);
568
+ const timeOf = (frame) => (frame * HOP + ONSET_LAG) / LOW_RATE;
569
+ // A beat right at the end of a silence is the sound starting, so it stays.
570
+ const inSilence = (time) =>
571
+ quiet.some(
572
+ (gap) => time >= gap.start - from && time < gap.end - from - 0.05
573
+ );
574
+ const times = trackBeats(curve, period)
575
+ .map(timeOf)
576
+ .filter((time) => !inSilence(time));
577
+ if (times.length < 8)
578
+ return { steady: false, beats: times.map((t) => round(from + t)) };
579
+ const grid = fitGrid(times, period / FPS);
580
+ const strength = beatStrength(curve, grid);
581
+ const steady =
582
+ grid.stray < STEADY_STRAY && grid.onGrid >= 0.75 && strength >= 1.5;
583
+ if (!steady)
584
+ return { steady: false, beats: times.map((time) => round(from + time)) };
585
+ const bars = barStart(bass, grid, times);
586
+ // The grid's first beat at or after the first beat heard, and the first bar start at or after that.
587
+ const firstNumber = Math.round((times[0] - grid.first) / grid.length);
588
+ const firstBeat = grid.first + firstNumber * grid.length;
589
+ const toBar = (((bars.beat - firstNumber) % 4) + 4) % 4;
590
+ return {
591
+ steady: true,
592
+ bpm: round(60 / grid.length),
593
+ beatSeconds: Math.round(grid.length * 10000) / 10000,
594
+ firstBeat: Math.round((from + firstBeat) * 1000) / 1000,
595
+ strayMs: Math.round(grid.stray * 1000),
596
+ beatStrength: round1(strength),
597
+ bars: {
598
+ beatsPerBar: 4,
599
+ firstDownbeat:
600
+ Math.round((from + firstBeat + toBar * grid.length) * 1000) / 1000,
601
+ sure: bars.sure,
602
+ },
603
+ };
604
+ }
605
+
606
+ // ── Words ────────────────────────────────────────────────────────────────────
607
+
608
+ /** Where someone speaks and what they say, by the relay's speech model; its agent-name fixing is left out. */
609
+ async function words(speech, mono, from) {
610
+ // An agent's question is handoff work: the person's own dictation goes first.
611
+ const spans = await speech.voiced(mono, { lane: "handoff" });
612
+ const voice = spans.map(([start, end]) => ({
613
+ start: round(from + start / LOW_RATE),
614
+ end: round(from + end / LOW_RATE),
615
+ }));
616
+ if (!voice.length) return { voice, words: "" };
617
+ const heard = await speech.transcribe(
618
+ { sampleRate: LOW_RATE, samples: mono },
619
+ { lane: "handoff" }
620
+ );
621
+ return { voice, words: heard.raw };
622
+ }
623
+
624
+ function summarise(listened, loudness, beat) {
625
+ const heard = `${round1(listened.to - listened.from)} s heard`;
626
+ const range = loudness.range === null ? "" : `range ${loudness.range} LU, `;
627
+ const level =
628
+ loudness.integrated === null
629
+ ? "silent"
630
+ : `${loudness.integrated} LUFS (${range}peak ${loudness.peak} dBFS)`;
631
+ if (!beat.steady) return `${heard}; ${level}; no steady beat`;
632
+ const bars = `bars ${beat.bars.sure ? "" : "perhaps "}from ${beat.bars.firstDownbeat} s`;
633
+ return `${heard}; ${level}; a steady ${beat.bpm} BPM from ${beat.firstBeat} s, ${bars}`;
634
+ }
635
+
636
+ /**
637
+ * A recording measured for an agent that cannot hear it: { file, listened: { from, to }, summary, loudness: {
638
+ * integrated, range, peak, clipped }, sections: [{ start, end, lufs }], silences: [{ start, end }], rhythm, voice?,
639
+ * words? }, times in seconds from the start of the file. `rhythm` is { steady: true, bpm, beatSeconds, firstBeat,
640
+ * strayMs, beatStrength, bars: { beatsPerBar, firstDownbeat, sure } } or { steady: false, beats }. At most LONGEST
641
+ * seconds from `from`. Words come only with `speech` (the relay's speech module, when its models are downloaded).
642
+ * Throws when nothing can decode the file.
643
+ */
644
+ export async function listen(file, { from = 0, to, speech = null }) {
645
+ const start = Math.max(0, Number(from) || 0);
646
+ const end = Number(to) > start ? Number(to) : start + LONGEST;
647
+ const seconds = Math.min(LONGEST, end - start);
648
+ const measured = await measure(
649
+ await decode(file, start, seconds),
650
+ Math.round(seconds * RATE)
651
+ );
652
+ const heardSeconds = measured.mono.length / LOW_RATE;
653
+ if (heardSeconds < 0.5)
654
+ throw new Error(`${path.basename(file)} has no sound there`);
655
+ const listened = { from: round(start), to: round(start + heardSeconds) };
656
+ const loudness = {
657
+ integrated: integratedLoudness(measured.steps),
658
+ range: loudnessRange(measured.steps),
659
+ peak: measured.peak > 0 ? round1(20 * Math.log10(measured.peak)) : null,
660
+ clipped: measured.clipped,
661
+ };
662
+ const quiet = silences(measured.steps, start);
663
+ const beat = await rhythm(measured.mono, start, quiet);
664
+ return {
665
+ file,
666
+ listened,
667
+ summary: summarise(listened, loudness, beat),
668
+ loudness,
669
+ sections: levelSections(measured.steps, start),
670
+ silences: quiet,
671
+ rhythm: beat,
672
+ ...(speech ? await words(speech, measured.mono, start) : {}),
673
+ };
674
+ }