@editframe/elements 0.15.0-beta.8 → 0.16.0-beta.0
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/dist/EF_FRAMEGEN.d.ts +14 -10
- package/dist/EF_FRAMEGEN.js +17 -28
- package/dist/elements/EFCaptions.js +0 -7
- package/dist/elements/EFImage.js +0 -4
- package/dist/elements/EFMedia.d.ts +13 -7
- package/dist/elements/EFMedia.js +217 -111
- package/dist/elements/EFSourceMixin.js +2 -1
- package/dist/elements/EFTemporal.browsertest.d.ts +4 -3
- package/dist/elements/EFTemporal.d.ts +14 -11
- package/dist/elements/EFTemporal.js +63 -87
- package/dist/elements/EFTimegroup.d.ts +2 -4
- package/dist/elements/EFTimegroup.js +15 -103
- package/dist/elements/EFVideo.js +3 -1
- package/dist/elements/EFWaveform.d.ts +3 -2
- package/dist/elements/EFWaveform.js +39 -26
- package/dist/elements/durationConverter.d.ts +8 -8
- package/dist/elements/durationConverter.js +2 -2
- package/dist/elements/updateAnimations.d.ts +9 -0
- package/dist/elements/updateAnimations.js +62 -0
- package/dist/getRenderInfo.d.ts +51 -0
- package/dist/getRenderInfo.js +72 -0
- package/dist/gui/EFFilmstrip.js +7 -16
- package/dist/gui/EFFitScale.d.ts +27 -0
- package/dist/gui/EFFitScale.js +138 -0
- package/dist/gui/EFWorkbench.d.ts +2 -5
- package/dist/gui/EFWorkbench.js +13 -56
- package/dist/gui/TWMixin.css.js +1 -1
- package/dist/gui/TWMixin.js +14 -2
- package/dist/index.d.ts +2 -0
- package/dist/index.js +6 -1
- package/dist/style.css +6 -3
- package/package.json +9 -4
- package/src/elements/EFCaptions.browsertest.ts +2 -2
- package/src/elements/EFCaptions.ts +0 -7
- package/src/elements/EFImage.browsertest.ts +2 -2
- package/src/elements/EFImage.ts +0 -4
- package/src/elements/EFMedia.browsertest.ts +14 -14
- package/src/elements/EFMedia.ts +291 -136
- package/src/elements/EFSourceMixin.ts +4 -4
- package/src/elements/EFTemporal.browsertest.ts +64 -31
- package/src/elements/EFTemporal.ts +99 -119
- package/src/elements/EFTimegroup.ts +15 -133
- package/src/elements/EFVideo.ts +3 -1
- package/src/elements/EFWaveform.ts +54 -39
- package/src/elements/durationConverter.ts +9 -4
- package/src/elements/updateAnimations.ts +88 -0
- package/src/gui/ContextMixin.ts +0 -3
- package/src/gui/EFFilmstrip.ts +7 -16
- package/src/gui/EFFitScale.ts +152 -0
- package/src/gui/EFWorkbench.ts +18 -65
- package/src/gui/TWMixin.ts +19 -2
- package/types.json +1 -1
package/src/elements/EFMedia.ts
CHANGED
|
@@ -10,11 +10,11 @@ import type { TrackFragmentIndex, TrackSegment } from "@editframe/assets";
|
|
|
10
10
|
import { VideoAsset } from "@editframe/assets/EncodedAsset.js";
|
|
11
11
|
import { MP4File } from "@editframe/assets/MP4File.js";
|
|
12
12
|
import { EF_INTERACTIVE } from "../EF_INTERACTIVE.js";
|
|
13
|
-
import { EF_RENDERING } from "../EF_RENDERING.js";
|
|
14
13
|
import { EFSourceMixin } from "./EFSourceMixin.js";
|
|
15
|
-
import { EFTemporal
|
|
14
|
+
import { EFTemporal } from "./EFTemporal.js";
|
|
16
15
|
import { FetchMixin } from "./FetchMixin.js";
|
|
17
16
|
import { EFTargetable } from "./TargetController.ts";
|
|
17
|
+
import { updateAnimations } from "./updateAnimations.ts";
|
|
18
18
|
|
|
19
19
|
const log = debug("ef:elements:EFMedia");
|
|
20
20
|
|
|
@@ -102,9 +102,6 @@ export class EFMedia extends EFTargetable(
|
|
|
102
102
|
|
|
103
103
|
fragmentIndexPath() {
|
|
104
104
|
if (this.assetId) {
|
|
105
|
-
if (EF_RENDERING()) {
|
|
106
|
-
return `editframe://api/v1/isobmff_files/${this.assetId}/index`;
|
|
107
|
-
}
|
|
108
105
|
return `${this.apiHost}/api/v1/isobmff_files/${this.assetId}/index`;
|
|
109
106
|
}
|
|
110
107
|
return `/@ef-track-fragment-index/${this.src ?? ""}`;
|
|
@@ -112,9 +109,6 @@ export class EFMedia extends EFTargetable(
|
|
|
112
109
|
|
|
113
110
|
fragmentTrackPath(trackId: string) {
|
|
114
111
|
if (this.assetId) {
|
|
115
|
-
if (EF_RENDERING()) {
|
|
116
|
-
return `editframe://api/v1/isobmff_tracks/${this.assetId}/${trackId}`;
|
|
117
|
-
}
|
|
118
112
|
return `${this.apiHost}/api/v1/isobmff_tracks/${this.assetId}/${trackId}`;
|
|
119
113
|
}
|
|
120
114
|
// trackId is only specified as a query in the @ef-track url shape
|
|
@@ -126,12 +120,21 @@ export class EFMedia extends EFTargetable(
|
|
|
126
120
|
public trackFragmentIndexLoader = new Task(this, {
|
|
127
121
|
args: () => [this.fragmentIndexPath(), this.fetch] as const,
|
|
128
122
|
task: async ([fragmentIndexPath, fetch], { signal }) => {
|
|
129
|
-
|
|
130
|
-
|
|
123
|
+
try {
|
|
124
|
+
const response = await fetch(fragmentIndexPath, { signal });
|
|
125
|
+
|
|
126
|
+
return (await response.json()) as Record<number, TrackFragmentIndex>;
|
|
127
|
+
} catch (error) {
|
|
128
|
+
log("Failed to load track fragment index", error);
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
131
|
},
|
|
132
132
|
onComplete: () => {
|
|
133
|
+
this.requestUpdate("intrinsicDurationMs");
|
|
133
134
|
this.requestUpdate("ownCurrentTimeMs");
|
|
135
|
+
console.log("Requesting update for durationMs", this, this.rootTimegroup);
|
|
134
136
|
this.rootTimegroup?.requestUpdate("ownCurrentTimeMs");
|
|
137
|
+
this.rootTimegroup?.requestUpdate("durationMs");
|
|
135
138
|
},
|
|
136
139
|
});
|
|
137
140
|
|
|
@@ -341,75 +344,7 @@ export class EFMedia extends EFTargetable(
|
|
|
341
344
|
changedProperties.has("currentTime") ||
|
|
342
345
|
changedProperties.has("ownCurrentTimeMs")
|
|
343
346
|
) {
|
|
344
|
-
|
|
345
|
-
if (
|
|
346
|
-
this.startTimeMs > timelineTimeMs ||
|
|
347
|
-
this.endTimeMs < timelineTimeMs
|
|
348
|
-
) {
|
|
349
|
-
this.style.display = "none";
|
|
350
|
-
return;
|
|
351
|
-
}
|
|
352
|
-
this.style.display = "";
|
|
353
|
-
const animations = this.getAnimations({ subtree: true });
|
|
354
|
-
|
|
355
|
-
this.style.setProperty("--ef-duration", `${this.durationMs}ms`);
|
|
356
|
-
this.style.setProperty(
|
|
357
|
-
"--ef-transition-duration",
|
|
358
|
-
`${this.parentTimegroup?.overlapMs ?? 0}ms`,
|
|
359
|
-
);
|
|
360
|
-
this.style.setProperty(
|
|
361
|
-
"--ef-transition-out-start",
|
|
362
|
-
`${this.durationMs - (this.parentTimegroup?.overlapMs ?? 0)}ms`,
|
|
363
|
-
);
|
|
364
|
-
|
|
365
|
-
for (const animation of animations) {
|
|
366
|
-
if (animation.playState === "running") {
|
|
367
|
-
animation.pause();
|
|
368
|
-
}
|
|
369
|
-
const effect = animation.effect;
|
|
370
|
-
if (!(effect && effect instanceof KeyframeEffect)) {
|
|
371
|
-
return;
|
|
372
|
-
}
|
|
373
|
-
const target = effect.target;
|
|
374
|
-
// TODO: better generalize work avoidance for temporal elements
|
|
375
|
-
if (!target) {
|
|
376
|
-
return;
|
|
377
|
-
}
|
|
378
|
-
if (target.closest("ef-video, ef-audio") !== this) {
|
|
379
|
-
return;
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
// Important to avoid going to the end of the animation
|
|
383
|
-
// or it will reset awkwardly.
|
|
384
|
-
if (isEFTemporal(target)) {
|
|
385
|
-
const timing = effect.getTiming();
|
|
386
|
-
const duration = Number(timing.duration) ?? 0;
|
|
387
|
-
const delay = Number(timing.delay);
|
|
388
|
-
const newTime = Math.floor(
|
|
389
|
-
Math.min(target.ownCurrentTimeMs, duration - 1 + delay),
|
|
390
|
-
);
|
|
391
|
-
if (Number.isNaN(newTime)) {
|
|
392
|
-
return;
|
|
393
|
-
}
|
|
394
|
-
animation.currentTime = newTime;
|
|
395
|
-
} else if (target) {
|
|
396
|
-
const nearestTimegroup = target.closest("ef-timegroup");
|
|
397
|
-
if (!nearestTimegroup) {
|
|
398
|
-
return;
|
|
399
|
-
}
|
|
400
|
-
const timing = effect.getTiming();
|
|
401
|
-
const duration = Number(timing.duration) ?? 0;
|
|
402
|
-
const delay = Number(timing.delay);
|
|
403
|
-
const newTime = Math.floor(
|
|
404
|
-
Math.min(nearestTimegroup.ownCurrentTimeMs, duration - 1 + delay),
|
|
405
|
-
);
|
|
406
|
-
|
|
407
|
-
if (Number.isNaN(newTime)) {
|
|
408
|
-
return;
|
|
409
|
-
}
|
|
410
|
-
animation.currentTime = newTime;
|
|
411
|
-
}
|
|
412
|
-
}
|
|
347
|
+
updateAnimations(this);
|
|
413
348
|
}
|
|
414
349
|
}
|
|
415
350
|
|
|
@@ -417,7 +352,7 @@ export class EFMedia extends EFTargetable(
|
|
|
417
352
|
return true;
|
|
418
353
|
}
|
|
419
354
|
|
|
420
|
-
get
|
|
355
|
+
get intrinsicDurationMs() {
|
|
421
356
|
if (!this.trackFragmentIndexLoader.value) {
|
|
422
357
|
return 0;
|
|
423
358
|
}
|
|
@@ -430,39 +365,7 @@ export class EFMedia extends EFTargetable(
|
|
|
430
365
|
if (durations.length === 0) {
|
|
431
366
|
return 0;
|
|
432
367
|
}
|
|
433
|
-
|
|
434
|
-
this.sourceInMs &&
|
|
435
|
-
this.sourceOutMs &&
|
|
436
|
-
this.sourceOutMs > this.sourceInMs
|
|
437
|
-
) {
|
|
438
|
-
return Math.max(this.sourceOutMs - this.sourceInMs);
|
|
439
|
-
}
|
|
440
|
-
if (this.sourceInMs) {
|
|
441
|
-
return (
|
|
442
|
-
Math.max(...durations) -
|
|
443
|
-
this.trimStartMs -
|
|
444
|
-
this.trimEndMs -
|
|
445
|
-
this.sourceInMs
|
|
446
|
-
);
|
|
447
|
-
}
|
|
448
|
-
if (this.sourceOutMs) {
|
|
449
|
-
return (
|
|
450
|
-
Math.max(...durations) -
|
|
451
|
-
this.trimStartMs -
|
|
452
|
-
this.trimEndMs -
|
|
453
|
-
this.sourceOutMs
|
|
454
|
-
);
|
|
455
|
-
}
|
|
456
|
-
if (this.sourceInMs && this.sourceOutMs) {
|
|
457
|
-
return (
|
|
458
|
-
Math.max(...durations) -
|
|
459
|
-
this.trimStartMs -
|
|
460
|
-
this.trimEndMs -
|
|
461
|
-
this.sourceOutMs -
|
|
462
|
-
this.sourceInMs
|
|
463
|
-
);
|
|
464
|
-
}
|
|
465
|
-
return Math.max(...durations) - this.trimStartMs - this.trimEndMs;
|
|
368
|
+
return Math.max(...durations);
|
|
466
369
|
}
|
|
467
370
|
|
|
468
371
|
#audioContext = new OfflineAudioContext(2, 48000 / 30, 48000);
|
|
@@ -500,13 +403,15 @@ export class EFMedia extends EFTargetable(
|
|
|
500
403
|
async fetchAudioSpanningTime(fromMs: number, toMs: number) {
|
|
501
404
|
// Adjust range for track's own time
|
|
502
405
|
if (this.sourceInMs) {
|
|
503
|
-
fromMs -=
|
|
406
|
+
fromMs -=
|
|
407
|
+
this.startTimeMs - (this.trimStartMs ?? 0) - (this.sourceInMs ?? 0);
|
|
504
408
|
}
|
|
505
409
|
if (this.sourceOutMs) {
|
|
506
|
-
toMs -=
|
|
410
|
+
toMs -=
|
|
411
|
+
this.startTimeMs - (this.trimStartMs ?? 0) - (this.sourceOutMs ?? 0);
|
|
507
412
|
}
|
|
508
|
-
fromMs -= this.startTimeMs - this.trimStartMs;
|
|
509
|
-
toMs -= this.startTimeMs - this.trimStartMs;
|
|
413
|
+
fromMs -= this.startTimeMs - (this.trimStartMs ?? 0);
|
|
414
|
+
toMs -= this.startTimeMs - (this.trimStartMs ?? 0);
|
|
510
415
|
|
|
511
416
|
await this.trackFragmentIndexLoader.taskComplete;
|
|
512
417
|
const audioTrackId = this.defaultAudioTrackId;
|
|
@@ -576,22 +481,51 @@ export class EFMedia extends EFTargetable(
|
|
|
576
481
|
blob: audioBlob,
|
|
577
482
|
startMs:
|
|
578
483
|
(firstFragment.dts / audioTrackIndex.timescale) * 1000 -
|
|
579
|
-
this.trimStartMs,
|
|
484
|
+
(this.trimStartMs ?? 0),
|
|
580
485
|
endMs:
|
|
581
486
|
(lastFragment.dts / audioTrackIndex.timescale) * 1000 +
|
|
582
487
|
(lastFragment.duration / audioTrackIndex.timescale) * 1000 -
|
|
583
|
-
this.trimEndMs,
|
|
488
|
+
(this.trimEndMs ?? 0),
|
|
584
489
|
};
|
|
585
490
|
}
|
|
586
491
|
|
|
587
|
-
|
|
588
|
-
|
|
492
|
+
set fftSize(value: number) {
|
|
493
|
+
const oldValue = this.fftSize;
|
|
494
|
+
this.setAttribute("fft-size", String(value));
|
|
495
|
+
this.requestUpdate("fft-size", oldValue);
|
|
496
|
+
}
|
|
589
497
|
|
|
590
|
-
|
|
591
|
-
|
|
498
|
+
set fftDecay(value: number) {
|
|
499
|
+
const oldValue = this.fftDecay;
|
|
500
|
+
this.setAttribute("fft-decay", String(value));
|
|
501
|
+
this.requestUpdate("fft-decay", oldValue);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
get fftSize() {
|
|
505
|
+
return Number.parseInt(this.getAttribute("fft-size") ?? "128", 10);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
get fftDecay() {
|
|
509
|
+
return Number.parseInt(this.getAttribute("fft-decay") ?? "8", 10);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
set interpolateFrequencies(value: boolean) {
|
|
513
|
+
const oldValue = this.interpolateFrequencies;
|
|
514
|
+
this.setAttribute("interpolate-frequencies", String(value));
|
|
515
|
+
this.requestUpdate("interpolate-frequencies", oldValue);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
get interpolateFrequencies() {
|
|
519
|
+
return this.getAttribute("interpolate-frequencies") !== "false";
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
get shouldInterpolateFrequencies() {
|
|
523
|
+
if (this.hasAttribute("interpolate-frequencies")) {
|
|
524
|
+
return this.getAttribute("interpolate-frequencies") !== "false";
|
|
525
|
+
}
|
|
526
|
+
return false;
|
|
527
|
+
}
|
|
592
528
|
|
|
593
|
-
private static readonly MIN_DB = -90;
|
|
594
|
-
private static readonly MAX_DB = -20;
|
|
595
529
|
private static readonly DECAY_WEIGHT = 0.7;
|
|
596
530
|
|
|
597
531
|
// Update FREQ_WEIGHTS to use the instance fftSize instead of a static value
|
|
@@ -616,6 +550,132 @@ export class EFMedia extends EFTargetable(
|
|
|
616
550
|
return weights;
|
|
617
551
|
}
|
|
618
552
|
|
|
553
|
+
#byteTimeDomainCache = new LRUCache<string, Uint8Array>(100);
|
|
554
|
+
|
|
555
|
+
byteTimeDomainTask = new Task(this, {
|
|
556
|
+
autoRun: EF_INTERACTIVE,
|
|
557
|
+
args: () =>
|
|
558
|
+
[
|
|
559
|
+
this.audioBufferTask.status,
|
|
560
|
+
this.currentSourceTimeMs,
|
|
561
|
+
this.fftSize,
|
|
562
|
+
this.fftDecay,
|
|
563
|
+
this.fftGain,
|
|
564
|
+
this.shouldInterpolateFrequencies,
|
|
565
|
+
] as const,
|
|
566
|
+
task: async () => {
|
|
567
|
+
await this.audioBufferTask.taskComplete;
|
|
568
|
+
if (!this.audioBufferTask.value) return null;
|
|
569
|
+
if (this.currentSourceTimeMs <= 0) return null;
|
|
570
|
+
|
|
571
|
+
const currentTimeMs = this.currentSourceTimeMs;
|
|
572
|
+
const startOffsetMs = this.audioBufferTask.value.startOffsetMs;
|
|
573
|
+
const audioBuffer = this.audioBufferTask.value.buffer;
|
|
574
|
+
|
|
575
|
+
const smoothedKey = `${this.shouldInterpolateFrequencies}:${this.fftSize}:${this.fftDecay}:${this.fftGain}:${startOffsetMs}:${currentTimeMs}`;
|
|
576
|
+
const cachedData = this.#byteTimeDomainCache.get(smoothedKey);
|
|
577
|
+
if (cachedData) return cachedData;
|
|
578
|
+
|
|
579
|
+
// Process multiple frames with decay, similar to the reference code
|
|
580
|
+
const framesData = await Promise.all(
|
|
581
|
+
Array.from({ length: this.fftDecay }, async (_, frameIndex) => {
|
|
582
|
+
const frameOffset = frameIndex * (1000 / 30);
|
|
583
|
+
const startTime = Math.max(
|
|
584
|
+
0,
|
|
585
|
+
(currentTimeMs - frameOffset - startOffsetMs) / 1000,
|
|
586
|
+
);
|
|
587
|
+
|
|
588
|
+
const cacheKey = `${this.shouldInterpolateFrequencies}:${this.fftSize}:${this.fftGain}:${startOffsetMs}:${startTime}`;
|
|
589
|
+
const cachedFrame = this.#byteTimeDomainCache.get(cacheKey);
|
|
590
|
+
if (cachedFrame) return cachedFrame;
|
|
591
|
+
|
|
592
|
+
const audioContext = new OfflineAudioContext(
|
|
593
|
+
2,
|
|
594
|
+
48000 * (1 / 30),
|
|
595
|
+
48000,
|
|
596
|
+
);
|
|
597
|
+
|
|
598
|
+
const source = audioContext.createBufferSource();
|
|
599
|
+
source.buffer = audioBuffer;
|
|
600
|
+
|
|
601
|
+
// Create analyzer for PCM data
|
|
602
|
+
const analyser = audioContext.createAnalyser();
|
|
603
|
+
analyser.fftSize = this.fftSize; // Ensure enough samples
|
|
604
|
+
analyser.minDecibels = -90;
|
|
605
|
+
analyser.maxDecibels = -20;
|
|
606
|
+
|
|
607
|
+
const gainNode = audioContext.createGain();
|
|
608
|
+
gainNode.gain.value = this.fftGain; // Amplify the signal
|
|
609
|
+
|
|
610
|
+
source.connect(gainNode);
|
|
611
|
+
gainNode.connect(analyser);
|
|
612
|
+
analyser.connect(audioContext.destination);
|
|
613
|
+
|
|
614
|
+
source.start(0, startTime, 1 / 30);
|
|
615
|
+
|
|
616
|
+
const dataLength = analyser.fftSize / 2;
|
|
617
|
+
try {
|
|
618
|
+
await audioContext.startRendering();
|
|
619
|
+
const frameData = new Uint8Array(dataLength);
|
|
620
|
+
analyser.getByteTimeDomainData(frameData);
|
|
621
|
+
|
|
622
|
+
// const points = frameData;
|
|
623
|
+
// Calculate RMS and midpoint values
|
|
624
|
+
const points = new Uint8Array(dataLength);
|
|
625
|
+
for (let i = 0; i < dataLength; i++) {
|
|
626
|
+
const pointSamples = frameData.slice(
|
|
627
|
+
i * (frameData.length / dataLength),
|
|
628
|
+
(i + 1) * (frameData.length / dataLength),
|
|
629
|
+
);
|
|
630
|
+
|
|
631
|
+
// Calculate RMS while preserving sign
|
|
632
|
+
const rms = Math.sqrt(
|
|
633
|
+
pointSamples.reduce((sum, sample) => {
|
|
634
|
+
const normalized = (sample - 128) / 128;
|
|
635
|
+
return sum + normalized * normalized;
|
|
636
|
+
}, 0) / pointSamples.length,
|
|
637
|
+
);
|
|
638
|
+
|
|
639
|
+
// Get average sign of the samples to determine direction
|
|
640
|
+
const avgSign = Math.sign(
|
|
641
|
+
pointSamples.reduce((sum, sample) => sum + (sample - 128), 0),
|
|
642
|
+
);
|
|
643
|
+
|
|
644
|
+
// Convert RMS back to byte range, preserving direction
|
|
645
|
+
points[i] = Math.min(255, Math.round(128 + avgSign * rms * 128));
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
this.#byteTimeDomainCache.set(cacheKey, points);
|
|
649
|
+
return points;
|
|
650
|
+
} finally {
|
|
651
|
+
source.disconnect();
|
|
652
|
+
analyser.disconnect();
|
|
653
|
+
}
|
|
654
|
+
}),
|
|
655
|
+
);
|
|
656
|
+
|
|
657
|
+
// Combine frames with decay weighting
|
|
658
|
+
const frameLength = framesData[0]?.length ?? 0;
|
|
659
|
+
const smoothedData = new Uint8Array(frameLength);
|
|
660
|
+
|
|
661
|
+
for (let i = 0; i < frameLength; i++) {
|
|
662
|
+
let weightedSum = 0;
|
|
663
|
+
let weightSum = 0;
|
|
664
|
+
|
|
665
|
+
framesData.forEach((frame, frameIndex) => {
|
|
666
|
+
const decayWeight = EFMedia.DECAY_WEIGHT ** frameIndex;
|
|
667
|
+
weightedSum += (frame[i] ?? 0) * decayWeight;
|
|
668
|
+
weightSum += decayWeight;
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
smoothedData[i] = Math.min(255, Math.round(weightedSum / weightSum));
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
this.#byteTimeDomainCache.set(smoothedKey, smoothedData);
|
|
675
|
+
return smoothedData;
|
|
676
|
+
},
|
|
677
|
+
});
|
|
678
|
+
|
|
619
679
|
#frequencyDataCache = new LRUCache<string, Uint8Array>(100);
|
|
620
680
|
|
|
621
681
|
frequencyDataTask = new Task(this, {
|
|
@@ -624,8 +684,10 @@ export class EFMedia extends EFTargetable(
|
|
|
624
684
|
[
|
|
625
685
|
this.audioBufferTask.status,
|
|
626
686
|
this.currentSourceTimeMs,
|
|
627
|
-
this.fftSize,
|
|
628
|
-
this.fftDecay,
|
|
687
|
+
this.fftSize,
|
|
688
|
+
this.fftDecay,
|
|
689
|
+
this.fftGain,
|
|
690
|
+
this.shouldInterpolateFrequencies,
|
|
629
691
|
] as const,
|
|
630
692
|
task: async () => {
|
|
631
693
|
await this.audioBufferTask.taskComplete;
|
|
@@ -635,7 +697,7 @@ export class EFMedia extends EFTargetable(
|
|
|
635
697
|
const currentTimeMs = this.currentSourceTimeMs;
|
|
636
698
|
const startOffsetMs = this.audioBufferTask.value.startOffsetMs;
|
|
637
699
|
const audioBuffer = this.audioBufferTask.value.buffer;
|
|
638
|
-
const smoothedKey = `${this.fftSize}:${this.fftDecay}:${startOffsetMs}:${currentTimeMs}`;
|
|
700
|
+
const smoothedKey = `${this.shouldInterpolateFrequencies}:${this.fftSize}:${this.fftDecay}:${this.fftGain}:${startOffsetMs}:${currentTimeMs}`;
|
|
639
701
|
|
|
640
702
|
const cachedSmoothedData = this.#frequencyDataCache.get(smoothedKey);
|
|
641
703
|
if (cachedSmoothedData) {
|
|
@@ -651,7 +713,7 @@ export class EFMedia extends EFTargetable(
|
|
|
651
713
|
);
|
|
652
714
|
|
|
653
715
|
// Cache key for this specific frame
|
|
654
|
-
const cacheKey = `${this.fftSize}:${startOffsetMs}:${startTime}`;
|
|
716
|
+
const cacheKey = `${this.shouldInterpolateFrequencies}:${this.fftSize}:${this.fftGain}:${startOffsetMs}:${startTime}`;
|
|
655
717
|
|
|
656
718
|
// Check cache for this specific frame
|
|
657
719
|
const cachedFrame = this.#frequencyDataCache.get(cacheKey);
|
|
@@ -666,13 +728,23 @@ export class EFMedia extends EFTargetable(
|
|
|
666
728
|
);
|
|
667
729
|
const analyser = audioContext.createAnalyser();
|
|
668
730
|
analyser.fftSize = this.fftSize;
|
|
669
|
-
analyser.minDecibels =
|
|
670
|
-
analyser.maxDecibels =
|
|
731
|
+
analyser.minDecibels = -90;
|
|
732
|
+
analyser.maxDecibels = -10;
|
|
733
|
+
|
|
734
|
+
const gainNode = audioContext.createGain();
|
|
735
|
+
gainNode.gain.value = this.fftGain;
|
|
736
|
+
|
|
737
|
+
const filter = audioContext.createBiquadFilter();
|
|
738
|
+
filter.type = "bandpass";
|
|
739
|
+
filter.frequency.value = 15000;
|
|
740
|
+
filter.Q.value = 0.05;
|
|
671
741
|
|
|
672
742
|
const audioBufferSource = audioContext.createBufferSource();
|
|
673
743
|
audioBufferSource.buffer = audioBuffer;
|
|
674
744
|
|
|
675
|
-
audioBufferSource.connect(
|
|
745
|
+
audioBufferSource.connect(filter);
|
|
746
|
+
filter.connect(gainNode);
|
|
747
|
+
gainNode.connect(analyser);
|
|
676
748
|
analyser.connect(audioContext.destination);
|
|
677
749
|
|
|
678
750
|
audioBufferSource.start(0, startTime, 1 / 30);
|
|
@@ -702,7 +774,7 @@ export class EFMedia extends EFTargetable(
|
|
|
702
774
|
|
|
703
775
|
framesData.forEach((frame, frameIndex) => {
|
|
704
776
|
const decayWeight = EFMedia.DECAY_WEIGHT ** frameIndex;
|
|
705
|
-
// biome-ignore lint/style/noNonNullAssertion:
|
|
777
|
+
// biome-ignore lint/style/noNonNullAssertion: Manual bounds check
|
|
706
778
|
weightedSum += frame[i]! * decayWeight;
|
|
707
779
|
weightSum += decayWeight;
|
|
708
780
|
});
|
|
@@ -712,7 +784,7 @@ export class EFMedia extends EFTargetable(
|
|
|
712
784
|
|
|
713
785
|
// Apply frequency weights using instance FREQ_WEIGHTS
|
|
714
786
|
smoothedData.forEach((value, i) => {
|
|
715
|
-
// biome-ignore lint/style/noNonNullAssertion:
|
|
787
|
+
// biome-ignore lint/style/noNonNullAssertion: Manual bounds check
|
|
716
788
|
const freqWeight = this.FREQ_WEIGHTS[i]!;
|
|
717
789
|
smoothedData[i] = Math.min(255, Math.round(value * freqWeight));
|
|
718
790
|
});
|
|
@@ -723,8 +795,91 @@ export class EFMedia extends EFTargetable(
|
|
|
723
795
|
0,
|
|
724
796
|
Math.floor(smoothedData.length / 2),
|
|
725
797
|
);
|
|
726
|
-
this
|
|
727
|
-
|
|
798
|
+
const processedData = this.shouldInterpolateFrequencies
|
|
799
|
+
? processFFTData(slicedData)
|
|
800
|
+
: slicedData;
|
|
801
|
+
this.#frequencyDataCache.set(smoothedKey, processedData);
|
|
802
|
+
return processedData;
|
|
728
803
|
},
|
|
729
804
|
});
|
|
805
|
+
|
|
806
|
+
set fftGain(value: number) {
|
|
807
|
+
const oldValue = this.fftGain;
|
|
808
|
+
this.setAttribute("fft-gain", String(value));
|
|
809
|
+
this.requestUpdate("fft-gain", oldValue);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
get fftGain() {
|
|
813
|
+
return Number.parseFloat(this.getAttribute("fft-gain") ?? "3.0");
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function processFFTData(fftData: Uint8Array, zeroThresholdPercent = 0.1) {
|
|
818
|
+
// Step 1: Determine the threshold for zeros
|
|
819
|
+
const totalBins = fftData.length;
|
|
820
|
+
const zeroThresholdCount = Math.floor(totalBins * zeroThresholdPercent);
|
|
821
|
+
|
|
822
|
+
// Step 2: Interrogate the FFT output to find the cutoff point
|
|
823
|
+
let zeroCount = 0;
|
|
824
|
+
let cutoffIndex = totalBins; // Default to the end of the array
|
|
825
|
+
|
|
826
|
+
for (let i = totalBins - 1; i >= 0; i--) {
|
|
827
|
+
// biome-ignore lint/style/noNonNullAssertion: Manual bounds check
|
|
828
|
+
if (fftData[i]! < 10) {
|
|
829
|
+
zeroCount++;
|
|
830
|
+
} else {
|
|
831
|
+
// If we encounter a non-zero value, we can stop
|
|
832
|
+
if (zeroCount >= zeroThresholdCount) {
|
|
833
|
+
cutoffIndex = i + 1; // Include this index
|
|
834
|
+
break;
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
if (cutoffIndex < zeroThresholdCount) {
|
|
840
|
+
return fftData;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// Step 3: Resample the "good" portion of the data
|
|
844
|
+
const goodData = fftData.slice(0, cutoffIndex);
|
|
845
|
+
const resampledData = interpolateData(goodData, fftData.length);
|
|
846
|
+
|
|
847
|
+
// Step 4: Attenuate the top 10% of interpolated samples
|
|
848
|
+
const attenuationStartIndex = Math.floor(totalBins * 0.9);
|
|
849
|
+
for (let i = attenuationStartIndex; i < totalBins; i++) {
|
|
850
|
+
// Calculate attenuation factor that goes from 1 to 0 over the top 10%
|
|
851
|
+
const attenuationProgress =
|
|
852
|
+
(i - attenuationStartIndex) / (totalBins - attenuationStartIndex) + 0.2;
|
|
853
|
+
const attenuationFactor = Math.max(0, 1 - attenuationProgress);
|
|
854
|
+
// biome-ignore lint/style/noNonNullAssertion: Manual bounds check
|
|
855
|
+
resampledData[i] = Math.floor(resampledData[i]! * attenuationFactor);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
return resampledData;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
function interpolateData(data: Uint8Array, targetSize: number) {
|
|
862
|
+
const resampled = new Uint8Array(targetSize);
|
|
863
|
+
const dataLength = data.length;
|
|
864
|
+
|
|
865
|
+
for (let i = 0; i < targetSize; i++) {
|
|
866
|
+
// Calculate the corresponding index in the original data
|
|
867
|
+
const ratio = (i / (targetSize - 1)) * (dataLength - 1);
|
|
868
|
+
const index = Math.floor(ratio);
|
|
869
|
+
const fraction = ratio - index;
|
|
870
|
+
|
|
871
|
+
// Handle edge cases
|
|
872
|
+
if (index >= dataLength - 1) {
|
|
873
|
+
// biome-ignore lint/style/noNonNullAssertion: Manual bounds check
|
|
874
|
+
resampled[i] = data[dataLength - 1]!; // Last value
|
|
875
|
+
} else {
|
|
876
|
+
// Linear interpolation
|
|
877
|
+
resampled[i] = Math.round(
|
|
878
|
+
// biome-ignore lint/style/noNonNullAssertion: Manual bounds check
|
|
879
|
+
data[index]! * (1 - fraction) + data[index + 1]! * fraction,
|
|
880
|
+
);
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
return resampled;
|
|
730
885
|
}
|
|
@@ -18,12 +18,12 @@ export function EFSourceMixin<T extends Constructor<LitElement>>(
|
|
|
18
18
|
) {
|
|
19
19
|
class EFSourceElement extends superClass {
|
|
20
20
|
get apiHost() {
|
|
21
|
-
|
|
21
|
+
const apiHost =
|
|
22
22
|
this.closest("ef-configuration")?.apiHost ??
|
|
23
23
|
this.closest("ef-workbench")?.apiHost ??
|
|
24
|
-
this.closest("ef-preview")?.apiHost
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
this.closest("ef-preview")?.apiHost;
|
|
25
|
+
|
|
26
|
+
return apiHost || "https://editframe.dev";
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
@property({ type: String })
|