fablecut 0.0.0-stage → 1.12.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/CLAUDE.md +1198 -0
- package/LICENSE +21 -0
- package/README.md +571 -2
- package/analyze.js +289 -0
- package/app.js +13339 -0
- package/audio-fx.js +241 -0
- package/cli.js +31 -0
- package/color.js +1043 -0
- package/denoise.js +120 -0
- package/ducking.js +127 -0
- package/edit-ops.js +962 -0
- package/encode-profiles.js +302 -0
- package/encoding-profiles.json +68 -0
- package/export-jobs.js +163 -0
- package/favicon.svg +14 -0
- package/fx-worklet.js +162 -0
- package/icons/favicon-32.png +0 -0
- package/import-url.js +293 -0
- package/index.html +420 -0
- package/library/README.md +15 -0
- package/library/elements/.gitkeep +0 -0
- package/library/elements/bokeh-drift.svg +19 -0
- package/library/elements/light-leak-cool.svg +16 -0
- package/library/elements/light-leak-warm.svg +22 -0
- package/library/elements/vhs-scanlines.svg +18 -0
- package/library/elements/vignette-frame.svg +14 -0
- package/library/fonts/.gitkeep +0 -0
- package/library/fonts/Abril Fatface.woff2 +0 -0
- package/library/fonts/Anton.woff2 +0 -0
- package/library/fonts/Archivo Black.woff2 +0 -0
- package/library/fonts/Bangers.woff2 +0 -0
- package/library/fonts/Barlow.woff2 +0 -0
- package/library/fonts/Bebas Neue.woff2 +0 -0
- package/library/fonts/Bungee.woff2 +0 -0
- package/library/fonts/Caveat.woff2 +0 -0
- package/library/fonts/DM Sans.woff2 +0 -0
- package/library/fonts/Inter.woff2 +0 -0
- package/library/fonts/LICENSES.md +31 -0
- package/library/fonts/Lato.woff2 +0 -0
- package/library/fonts/Montserrat.woff2 +0 -0
- package/library/fonts/Oswald.woff2 +0 -0
- package/library/fonts/Pacifico.woff2 +0 -0
- package/library/fonts/Playfair Display.woff2 +0 -0
- package/library/fonts/Poppins.woff2 +0 -0
- package/library/fonts/Righteous.woff2 +0 -0
- package/library/fonts/Roboto.woff2 +0 -0
- package/library/fonts/Rubik.woff2 +0 -0
- package/library/fonts/Teko.woff2 +0 -0
- package/library/sfx/README.md +18 -0
- package/library/svg/ai-star.svg +17 -0
- package/library/svg/arrow-bounce.svg +18 -0
- package/library/svg/arrow-scribble.svg +13 -0
- package/library/svg/burst-lines.svg +30 -0
- package/library/svg/checkmark-success.svg +19 -0
- package/library/svg/confetti-burst.svg +23 -0
- package/library/svg/countdown-ring.svg +92 -0
- package/library/svg/equalizer.svg +18 -0
- package/library/svg/glow-orbs.svg +29 -0
- package/library/svg/heart-pop.svg +28 -0
- package/library/svg/loading-dots.svg +16 -0
- package/library/svg/location-pin.svg +25 -0
- package/library/svg/lower-third.svg +17 -0
- package/library/svg/new-badge.svg +18 -0
- package/library/svg/progress-bar.svg +16 -0
- package/library/svg/radial-pulse.svg +19 -0
- package/library/svg/rating-stars.svg +25 -0
- package/library/svg/rec-indicator.svg +41 -0
- package/library/svg/sparkles.svg +17 -0
- package/library/svg/spawning-hearts.svg +39 -0
- package/library/svg/speech-bubble.svg +23 -0
- package/library/svg/subscribe-bell.svg +29 -0
- package/library/svg/subscribe-pill.svg +22 -0
- package/library/svg/swipe-up.svg +46 -0
- package/library/svg/underline-swoosh.svg +15 -0
- package/loudness.js +135 -0
- package/mcp-server.js +1421 -0
- package/meter-worklet.js +229 -0
- package/package.json +45 -4
- package/paths.js +106 -0
- package/ruler-worker.js +120 -0
- package/server.js +775 -0
- package/style.css +3563 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="540" height="960" viewBox="0 0 540 960">
|
|
2
|
+
<!-- FableCut animated SVG: looping hearts from the lower right. -->
|
|
3
|
+
<style>
|
|
4
|
+
.heart { animation-timing-function: cubic-bezier(.25,.46,.45,.94); }
|
|
5
|
+
@keyframes floatA {
|
|
6
|
+
0% { transform: translate(432px,930px) scale(0.3) rotate(0deg); opacity: 0; }
|
|
7
|
+
12% { transform: translate(420px,810px) scale(1) rotate(-8deg); opacity: 0.95; }
|
|
8
|
+
50% { transform: translate(372px,510px) scale(1.1) rotate(6deg); }
|
|
9
|
+
80% { transform: translate(408px,210px) scale(0.95) rotate(-5deg); opacity: 0.85; }
|
|
10
|
+
100% { transform: translate(360px,-90px) scale(0.8) rotate(10deg); opacity: 0; }
|
|
11
|
+
}
|
|
12
|
+
@keyframes floatB {
|
|
13
|
+
0% { transform: translate(444px,930px) scale(0.25) rotate(0deg); opacity: 0; }
|
|
14
|
+
15% { transform: translate(432px,780px) scale(0.9) rotate(7deg); opacity: 0.95; }
|
|
15
|
+
45% { transform: translate(330px,480px) scale(1.05) rotate(-9deg); }
|
|
16
|
+
80% { transform: translate(372px,180px) scale(0.9) rotate(5deg); opacity: 0.8; }
|
|
17
|
+
100% { transform: translate(300px,-90px) scale(0.75) rotate(-6deg); opacity: 0; }
|
|
18
|
+
}
|
|
19
|
+
@keyframes floatC {
|
|
20
|
+
0% { transform: translate(420px,930px) scale(0.3) rotate(0deg); opacity: 0; }
|
|
21
|
+
15% { transform: translate(396px,792px) scale(0.95) rotate(-5deg); opacity: 0.95; }
|
|
22
|
+
50% { transform: translate(288px,450px) scale(1.15) rotate(8deg); }
|
|
23
|
+
85% { transform: translate(252px,150px) scale(0.9) rotate(-7deg); opacity: 0.8; }
|
|
24
|
+
100% { transform: translate(228px,-90px) scale(0.7) rotate(4deg); opacity: 0; }
|
|
25
|
+
}
|
|
26
|
+
.h1 { fill: #FE2C55; animation: floatA 4.2s infinite both; }
|
|
27
|
+
.h2 { fill: #25F4EE; animation: floatB 4.6s infinite both; }
|
|
28
|
+
.h3 { fill: #FF007F; animation: floatC 4.4s infinite both; }
|
|
29
|
+
.h4 { fill: #FFAA00; animation: floatA 4.8s infinite both; }
|
|
30
|
+
.h5 { fill: #FE2C55; animation: floatC 4.3s infinite both; }
|
|
31
|
+
.h6 { fill: #FFFFFF; animation: floatB 4.5s infinite both; }
|
|
32
|
+
</style>
|
|
33
|
+
<path class="heart h1" style="--d:0s" transform="translate(270 430) scale(3.2)" d="M0,-18 C-15,-48 -48,-27 -48,3 C-48,30 0,60 0,66 C0,60 48,30 48,3 C48,-27 15,-48 0,-18 Z"/>
|
|
34
|
+
<path class="heart h2" style="--d:0.8s" transform="translate(390 560) scale(2.2) rotate(-18)" d="M0,-18 C-15,-48 -48,-27 -48,3 C-48,30 0,60 0,66 C0,60 48,30 48,3 C48,-27 15,-48 0,-18 Z"/>
|
|
35
|
+
<path class="heart h3" style="--d:1.6s" transform="translate(155 540) scale(2.1) rotate(16)" d="M0,-18 C-15,-48 -48,-27 -48,3 C-48,30 0,60 0,66 C0,60 48,30 48,3 C48,-27 15,-48 0,-18 Z"/>
|
|
36
|
+
<path class="heart h4" style="--d:2.4s" transform="translate(360 300) scale(1.8) rotate(12)" d="M0,-18 C-15,-48 -48,-27 -48,3 C-48,30 0,60 0,66 C0,60 48,30 48,3 C48,-27 15,-48 0,-18 Z"/>
|
|
37
|
+
<path class="heart h5" style="--d:3.1s" transform="translate(180 310) scale(1.6) rotate(-10)" d="M0,-18 C-15,-48 -48,-27 -48,3 C-48,30 0,60 0,66 C0,60 48,30 48,3 C48,-27 15,-48 0,-18 Z"/>
|
|
38
|
+
<path class="heart h6" style="--d:3.7s" transform="translate(270 620) scale(1.7)" d="M0,-18 C-15,-48 -48,-27 -48,3 C-48,30 0,60 0,66 C0,60 48,30 48,3 C48,-27 15,-48 0,-18 Z"/>
|
|
39
|
+
</svg>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="500" height="360" viewBox="0 0 500 360">
|
|
2
|
+
<!-- FableCut animated SVG · bubble pops in (~0.5s), then typing dots loop (chat / comment) -->
|
|
3
|
+
<style>
|
|
4
|
+
.bub { transform-box: fill-box; transform-origin: 250px 280px;
|
|
5
|
+
animation: pop 0.5s cubic-bezier(.34,1.56,.64,1) both; }
|
|
6
|
+
@keyframes pop { 0% { transform: scale(0); opacity: 0; } 100% { transform: scale(1); opacity: 1; } }
|
|
7
|
+
.td { transform-box: fill-box; transform-origin: center;
|
|
8
|
+
animation: type 1.2s ease-in-out infinite; }
|
|
9
|
+
@keyframes type {
|
|
10
|
+
0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
|
|
11
|
+
30% { transform: translateY(-12px); opacity: 1; }
|
|
12
|
+
}
|
|
13
|
+
</style>
|
|
14
|
+
<g class="bub" style="--d:0s">
|
|
15
|
+
<rect x="60" y="50" width="380" height="200" rx="40" fill="#ffffff"/>
|
|
16
|
+
<path d="M180 245 L150 320 L250 245 Z" fill="#ffffff"/>
|
|
17
|
+
</g>
|
|
18
|
+
<g fill="#7b6cff">
|
|
19
|
+
<circle class="td" style="--d:0.5s" cx="185" cy="150" r="20"/>
|
|
20
|
+
<circle class="td" style="--d:0.65s" cx="250" cy="150" r="20"/>
|
|
21
|
+
<circle class="td" style="--d:0.8s" cx="315" cy="150" r="20"/>
|
|
22
|
+
</g>
|
|
23
|
+
</svg>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="500" height="500" viewBox="0 0 500 500">
|
|
2
|
+
<!-- FableCut animated SVG · one-shot pop then repeated ring-swing (notification / subscribe) -->
|
|
3
|
+
<style>
|
|
4
|
+
.badge { transform-box: fill-box; transform-origin: center;
|
|
5
|
+
animation: pop 0.5s cubic-bezier(.34,1.56,.64,1) both; }
|
|
6
|
+
@keyframes pop { 0% { transform: scale(0); } 100% { transform: scale(1); } }
|
|
7
|
+
.bell { transform-box: fill-box; transform-origin: 250px 150px;
|
|
8
|
+
animation: swing 2.2s ease-in-out infinite; }
|
|
9
|
+
@keyframes swing {
|
|
10
|
+
0%, 60%, 100% { transform: rotate(0deg); }
|
|
11
|
+
68% { transform: rotate(16deg); }
|
|
12
|
+
76% { transform: rotate(-13deg); }
|
|
13
|
+
84% { transform: rotate(8deg); }
|
|
14
|
+
92% { transform: rotate(-4deg); }
|
|
15
|
+
}
|
|
16
|
+
.clap { transform-box: fill-box; transform-origin: 250px 150px;
|
|
17
|
+
animation: swing 2.2s ease-in-out infinite; }
|
|
18
|
+
.dot { transform-box: fill-box; transform-origin: center;
|
|
19
|
+
animation: ping 2.2s ease-out infinite; }
|
|
20
|
+
@keyframes ping { 0%,60% { transform: scale(0); opacity: 0; } 70% { transform: scale(1); opacity: 1; } 100% { transform: scale(1.3); opacity: 0; } }
|
|
21
|
+
</style>
|
|
22
|
+
<circle class="badge" style="--d:0s" cx="250" cy="250" r="180" fill="#ff5d73"/>
|
|
23
|
+
<g class="bell" style="--d:0.5s" fill="#fff">
|
|
24
|
+
<path d="M250 150 c-58 0 -84 44 -84 104 c0 56 -20 74 -30 90 l228 0 c-10 -16 -30 -34 -30 -90 c0 -60 -26 -104 -84 -104z"/>
|
|
25
|
+
<circle cx="250" cy="146" r="16"/>
|
|
26
|
+
</g>
|
|
27
|
+
<path class="clap" style="--d:0.5s" d="M228 356 a22 22 0 0 0 44 0z" fill="#fff"/>
|
|
28
|
+
<circle class="dot" style="--d:0.5s" cx="372" cy="128" r="26" fill="#ffd166"/>
|
|
29
|
+
</svg>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="560" height="160" viewBox="0 0 560 160">
|
|
2
|
+
<!-- FableCut animated SVG · one-shot (~1.4s) · subscribe pill + ringing bell -->
|
|
3
|
+
<style>
|
|
4
|
+
.pill { transform-box: fill-box; transform-origin: center; animation: pop 0.5s cubic-bezier(.34,1.56,.64,1) both; }
|
|
5
|
+
.txt { animation: fadein 0.35s ease both; }
|
|
6
|
+
.bell { transform-box: fill-box; transform-origin: 50% 15%; animation: ring 0.7s ease-in-out both; }
|
|
7
|
+
@keyframes pop { from { transform: scale(0); } to { transform: scale(1); } }
|
|
8
|
+
@keyframes fadein { from { opacity: 0; } to { opacity: 1; } }
|
|
9
|
+
@keyframes ring { 0%,100% { transform: rotate(0); } 20% { transform: rotate(24deg); }
|
|
10
|
+
45% { transform: rotate(-18deg); } 70% { transform: rotate(10deg); } }
|
|
11
|
+
</style>
|
|
12
|
+
<g class="pill" style="--d:0s">
|
|
13
|
+
<rect x="20" y="30" width="430" height="100" rx="50" fill="#e11d48"/>
|
|
14
|
+
<text class="txt" style="--d:0.35s" x="235" y="97" text-anchor="middle"
|
|
15
|
+
font-family="Arial, sans-serif" font-size="46" font-weight="800" fill="#ffffff"
|
|
16
|
+
letter-spacing="2">SUBSCRIBE</text>
|
|
17
|
+
</g>
|
|
18
|
+
<g class="bell" style="--d:0.6s">
|
|
19
|
+
<path d="M505 45 c-18 0 -30 14 -30 34 v18 l-10 14 h80 l-10 -14 v-18 c0 -20 -12 -34 -30 -34z" fill="#ffd166"/>
|
|
20
|
+
<circle cx="505" cy="118" r="8" fill="#ffd166"/>
|
|
21
|
+
</g>
|
|
22
|
+
</svg>
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="200" viewBox="0 0 120 200">
|
|
2
|
+
<!-- FableCut animated SVG · loop · "swipe up" hand gesture
|
|
3
|
+
Bouncing upward arrow/hand for social-media CTAs. -->
|
|
4
|
+
<style>
|
|
5
|
+
.hand {
|
|
6
|
+
transform-box: fill-box;
|
|
7
|
+
transform-origin: center bottom;
|
|
8
|
+
animation: bounce 1s cubic-bezier(.36,0,.66,1) infinite;
|
|
9
|
+
}
|
|
10
|
+
@keyframes bounce {
|
|
11
|
+
0%, 100% { transform: translateY(0); }
|
|
12
|
+
40% { transform: translateY(-30px); }
|
|
13
|
+
60% { transform: translateY(-30px); }
|
|
14
|
+
}
|
|
15
|
+
.arrow {
|
|
16
|
+
stroke: #ffffff;
|
|
17
|
+
stroke-width: 4;
|
|
18
|
+
stroke-linecap: round;
|
|
19
|
+
stroke-linejoin: round;
|
|
20
|
+
fill: none;
|
|
21
|
+
opacity: 0.9;
|
|
22
|
+
}
|
|
23
|
+
.fade-arrow {
|
|
24
|
+
animation: fadeUp 1s ease-in-out infinite;
|
|
25
|
+
animation-delay: var(--d, 0s);
|
|
26
|
+
}
|
|
27
|
+
@keyframes fadeUp {
|
|
28
|
+
0%, 100% { opacity: 0.2; transform: translateY(10px); }
|
|
29
|
+
40% { opacity: 0.9; transform: translateY(-10px); }
|
|
30
|
+
60% { opacity: 0.9; transform: translateY(-10px); }
|
|
31
|
+
}
|
|
32
|
+
</style>
|
|
33
|
+
<!-- Trailing chevrons (fade up) -->
|
|
34
|
+
<g class="fade-arrow" style="--d:0s">
|
|
35
|
+
<polyline class="arrow" points="30,140 60,110 90,140"/>
|
|
36
|
+
</g>
|
|
37
|
+
<g class="fade-arrow" style="--d:0.15s">
|
|
38
|
+
<polyline class="arrow" points="30,170 60,140 90,170" opacity="0.5"/>
|
|
39
|
+
</g>
|
|
40
|
+
<!-- Hand / main chevron -->
|
|
41
|
+
<g class="hand">
|
|
42
|
+
<polyline class="arrow" points="20,100 60,55 100,100" stroke-width="5"/>
|
|
43
|
+
<!-- Palm base -->
|
|
44
|
+
<rect x="45" y="95" width="30" height="12" rx="6" fill="#ffffff" opacity="0.85"/>
|
|
45
|
+
</g>
|
|
46
|
+
</svg>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="600" height="120" viewBox="0 0 600 120">
|
|
2
|
+
<!-- FableCut animated SVG · one-shot (~0.8s) · hand-drawn underline swoosh.
|
|
3
|
+
Place under a title text clip; tint via the clip's hue slider. -->
|
|
4
|
+
<style>
|
|
5
|
+
.stroke {
|
|
6
|
+
stroke-dasharray: 640; stroke-dashoffset: 640;
|
|
7
|
+
animation: draw 0.8s cubic-bezier(.3,.8,.3,1) both;
|
|
8
|
+
}
|
|
9
|
+
@keyframes draw { to { stroke-dashoffset: 0; } }
|
|
10
|
+
</style>
|
|
11
|
+
<path class="stroke" style="--d:0s" d="M20 70 C 180 40, 420 40, 580 62" fill="none"
|
|
12
|
+
stroke="#ffd166" stroke-width="14" stroke-linecap="round"/>
|
|
13
|
+
<path class="stroke" style="--d:0.18s" d="M60 96 C 220 74, 380 74, 540 90" fill="none"
|
|
14
|
+
stroke="#ffd166" stroke-width="9" stroke-linecap="round" opacity="0.7"/>
|
|
15
|
+
</svg>
|
package/loudness.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/* FableCut loudness measurement — ITU-R BS.1770-4 integrated loudness (LUFS,
|
|
2
|
+
EBU R128 gating) and sample peak. Zero dependencies. Loaded by the editor as
|
|
3
|
+
a plain script (global `FableCutLoudness`) and required by the MCP server,
|
|
4
|
+
so Normalize in the UI and `fablecut_normalize_audio` measure identically.
|
|
5
|
+
|
|
6
|
+
A clip is measured AFTER its channel routing (audioChannel / channelMode)
|
|
7
|
+
and BEFORE gain, volume and pan: equal-power pan keeps BS.1770 loudness, so
|
|
8
|
+
the reading holds wherever the clip is panned. */
|
|
9
|
+
(function (root, factory) {
|
|
10
|
+
const api = factory();
|
|
11
|
+
if (typeof module === "object" && module.exports) module.exports = api;
|
|
12
|
+
else root.FableCutLoudness = api;
|
|
13
|
+
})(typeof self !== "undefined" ? self : this, function () {
|
|
14
|
+
"use strict";
|
|
15
|
+
|
|
16
|
+
/* K-weighting: stage 1 high shelf + stage 2 RLB high-pass, with the
|
|
17
|
+
BS.1770 analog prototypes re-derived for any sample rate (same values the
|
|
18
|
+
meter worklet uses). */
|
|
19
|
+
function shelfCoeffs(fs) {
|
|
20
|
+
const f0 = 1681.974450955533, G = 3.999843853973347, Q = 0.7071752369554196;
|
|
21
|
+
const K = Math.tan(Math.PI * f0 / fs);
|
|
22
|
+
const Vh = Math.pow(10, G / 20), Vb = Math.pow(Vh, 0.5);
|
|
23
|
+
const a0 = 1 + K / Q + K * K;
|
|
24
|
+
return {
|
|
25
|
+
b0: (Vh + Vb * K / Q + K * K) / a0, b1: 2 * (K * K - Vh) / a0,
|
|
26
|
+
b2: (Vh - Vb * K / Q + K * K) / a0, a1: 2 * (K * K - 1) / a0, a2: (1 - K / Q + K * K) / a0,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
function hpfCoeffs(fs) {
|
|
30
|
+
const f0 = 38.13547087613982, Q = 0.5003270373238773;
|
|
31
|
+
const K = Math.tan(Math.PI * f0 / fs);
|
|
32
|
+
const a0 = 1 + K / Q + K * K;
|
|
33
|
+
return { b0: 1 / a0, b1: -2 / a0, b2: 1 / a0, a1: 2 * (K * K - 1) / a0, a2: (1 - K / Q + K * K) / a0 };
|
|
34
|
+
}
|
|
35
|
+
function biquad(c) { return { ...c, z1: 0, z2: 0 }; }
|
|
36
|
+
|
|
37
|
+
const ABS_GATE = -70; // LUFS
|
|
38
|
+
const REL_GATE = -10; // LU below the abs-gated mean
|
|
39
|
+
const loud = (ms) => -0.691 + 10 * Math.log10(ms);
|
|
40
|
+
|
|
41
|
+
/** Streaming meter: push() channel arrays of equal length as audio arrives,
|
|
42
|
+
* then result(). Every channel is weighted 1 (mono / stereo programme). */
|
|
43
|
+
class LoudnessMeter {
|
|
44
|
+
constructor(channels, sampleRate) {
|
|
45
|
+
this.fs = sampleRate;
|
|
46
|
+
this.step = Math.max(1, Math.round(sampleRate * 0.1)); // 100 ms hop, 400 ms blocks (75% overlap)
|
|
47
|
+
this.filters = [];
|
|
48
|
+
for (let i = 0; i < channels; i++)
|
|
49
|
+
this.filters.push([biquad(shelfCoeffs(sampleRate)), biquad(hpfCoeffs(sampleRate))]);
|
|
50
|
+
this.sub = []; // K-weighted energy per 100 ms step, summed over channels
|
|
51
|
+
this.acc = 0; // energy of the step being filled
|
|
52
|
+
this.fill = 0; // samples in the step being filled
|
|
53
|
+
this.tail = 0; // energy of everything (fallback for < 400 ms)
|
|
54
|
+
this.n = 0;
|
|
55
|
+
this.peak = 0;
|
|
56
|
+
}
|
|
57
|
+
push(chs) {
|
|
58
|
+
const len = chs.length ? chs[0].length : 0;
|
|
59
|
+
for (let i = 0; i < len; i++) {
|
|
60
|
+
let e = 0;
|
|
61
|
+
for (let c = 0; c < chs.length && c < this.filters.length; c++) {
|
|
62
|
+
const x = chs[c][i];
|
|
63
|
+
const a = x < 0 ? -x : x;
|
|
64
|
+
if (a > this.peak) this.peak = a;
|
|
65
|
+
const [s, h] = this.filters[c];
|
|
66
|
+
let y = s.b0 * x + s.z1;
|
|
67
|
+
s.z1 = s.b1 * x - s.a1 * y + s.z2; s.z2 = s.b2 * x - s.a2 * y;
|
|
68
|
+
const x2 = y;
|
|
69
|
+
y = h.b0 * x2 + h.z1;
|
|
70
|
+
h.z1 = h.b1 * x2 - h.a1 * y + h.z2; h.z2 = h.b2 * x2 - h.a2 * y;
|
|
71
|
+
e += y * y;
|
|
72
|
+
}
|
|
73
|
+
this.acc += e;
|
|
74
|
+
this.tail += e;
|
|
75
|
+
if (++this.fill === this.step) { this.sub.push(this.acc); this.acc = 0; this.fill = 0; }
|
|
76
|
+
}
|
|
77
|
+
this.n += len;
|
|
78
|
+
}
|
|
79
|
+
/** {lufs, peak, peakDb}: lufs is -Infinity for silence, peak is linear. */
|
|
80
|
+
result() {
|
|
81
|
+
const blocks = [];
|
|
82
|
+
for (let j = 0; j + 4 <= this.sub.length; j++)
|
|
83
|
+
blocks.push((this.sub[j] + this.sub[j + 1] + this.sub[j + 2] + this.sub[j + 3]) / (4 * this.step));
|
|
84
|
+
// Shorter than one 400 ms block: gate the whole clip as a single block.
|
|
85
|
+
if (!blocks.length && this.n > 0) blocks.push(this.tail / this.n);
|
|
86
|
+
const absGated = blocks.filter((z) => z > 0 && loud(z) > ABS_GATE);
|
|
87
|
+
let lufs = -Infinity;
|
|
88
|
+
if (absGated.length) {
|
|
89
|
+
const rel = loud(absGated.reduce((a, b) => a + b, 0) / absGated.length) + REL_GATE;
|
|
90
|
+
const gated = absGated.filter((z) => loud(z) > rel);
|
|
91
|
+
if (gated.length) lufs = loud(gated.reduce((a, b) => a + b, 0) / gated.length);
|
|
92
|
+
}
|
|
93
|
+
const peakDb = this.peak > 0 ? 20 * Math.log10(this.peak) : -Infinity;
|
|
94
|
+
return { lufs, peak: this.peak, peakDb };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function measure(chs, sampleRate) {
|
|
99
|
+
const m = new LoudnessMeter(chs.length, sampleRate);
|
|
100
|
+
m.push(chs);
|
|
101
|
+
return m.result();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const CHANNEL_MODES = ["stereo", "mono", "left", "right", "swap"];
|
|
105
|
+
|
|
106
|
+
/** What a clip sends into its gain stage, as channel arrays. Mirrors the
|
|
107
|
+
* editor's audio graph: an isolated stem (audioChannel) or a mono mode is
|
|
108
|
+
* one channel (pan places it with equal power); stereo/swap keep them all. */
|
|
109
|
+
function routeChannels(chs, { audioChannel, channelMode } = {}) {
|
|
110
|
+
if (!chs.length) return [];
|
|
111
|
+
if (Number.isInteger(audioChannel) && audioChannel >= 0)
|
|
112
|
+
return audioChannel < chs.length ? [chs[audioChannel]] : [];
|
|
113
|
+
const mode = CHANNEL_MODES.includes(channelMode) ? channelMode : "stereo";
|
|
114
|
+
if (chs.length === 1) return [chs[0]];
|
|
115
|
+
if (mode === "left") return [chs[0]];
|
|
116
|
+
if (mode === "right") return [chs[1]];
|
|
117
|
+
if (mode === "swap") return [chs[1], chs[0], ...chs.slice(2)];
|
|
118
|
+
if (mode === "mono") {
|
|
119
|
+
const n = chs[0].length, out = new Float32Array(n), k = 1 / chs.length;
|
|
120
|
+
for (const x of chs) for (let i = 0; i < n; i++) out[i] += x[i] * k;
|
|
121
|
+
return [out];
|
|
122
|
+
}
|
|
123
|
+
return chs;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Gain (dB) that brings a measurement to `target` ({mode:"lufs"|"peak", value}).
|
|
127
|
+
* null when the clip is silent — nothing to normalize. */
|
|
128
|
+
function normalizeGainDb(m, target) {
|
|
129
|
+
const level = target.mode === "peak" ? m.peakDb : m.lufs;
|
|
130
|
+
if (!Number.isFinite(level)) return null;
|
|
131
|
+
return target.value - level;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return { LoudnessMeter, measure, routeChannels, normalizeGainDb, CHANNEL_MODES, shelfCoeffs, hpfCoeffs };
|
|
135
|
+
});
|