@bendyline/squisq 2.11.7 → 2.11.8
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/{Doc-BkxT7G80.d.ts → Doc-CynsGqL4.d.ts} +208 -1
- package/dist/{ImageEditDoc-C8ZB54Vv.d.ts → ImageEditDoc-BVZe_5y4.d.ts} +1 -1
- package/dist/{chunk-SKKBRLLM.js → chunk-5FBI3ZW4.js} +7 -42
- package/dist/{chunk-YGGYYBJS.js → chunk-JJO6JFNZ.js} +1 -1
- package/dist/{chunk-SOIV7KDO.js → chunk-JM2QARY2.js} +42 -10
- package/dist/chunk-JSGIWW35.js +70 -0
- package/dist/{chunk-IYZTF4VO.js → chunk-KFZOCWPK.js} +2 -2
- package/dist/chunk-L7QZORLB.js +230 -0
- package/dist/chunk-NEWNNLWN.js +64 -0
- package/dist/{chunk-6ECMNUIP.js → chunk-OU5FYBOB.js} +170 -4
- package/dist/{chunk-3YNFMFNK.js → chunk-RMCUZDW5.js} +17 -8
- package/dist/{chunk-XJXBI3N5.js → chunk-XZVBR5BP.js} +1 -1
- package/dist/{chunk-GAZKTT4R.js → chunk-YNO6JV36.js} +96 -33
- package/dist/{chunk-EF353KGH.js → chunk-ZEONJCP3.js} +1 -1
- package/dist/doc/index.d.ts +5 -5
- package/dist/doc/index.js +22 -20
- package/dist/fence/index.d.ts +1 -1
- package/dist/generate/index.d.ts +1 -1
- package/dist/imageEdit/index.d.ts +47 -4
- package/dist/imageEdit/index.js +5 -1
- package/dist/index.d.ts +5 -5
- package/dist/index.js +48 -39
- package/dist/jsonForm/index.d.ts +1 -1
- package/dist/jsonForm/index.js +4 -2
- package/dist/markdown/index.js +3 -3
- package/dist/{materializePageSection-Dp2aHFbc.d.ts → materializePageSection-CAJoa8fh.d.ts} +1 -1
- package/dist/mediaEdit/index.d.ts +467 -0
- package/dist/mediaEdit/index.js +990 -0
- package/dist/narration/index.d.ts +20 -12
- package/dist/narration/index.js +6 -4
- package/dist/schemas/index.d.ts +4 -4
- package/dist/schemas/index.js +7 -3
- package/dist/{themeLibrary-BbSjnGmd.d.ts → themeLibrary-z7t3x5CU.d.ts} +1 -1
- package/dist/timing/index.d.ts +1 -1
- package/dist/transform/index.d.ts +2 -2
- package/dist/transform/index.js +6 -5
- package/package.json +6 -1
- package/dist/{chunk-CW24CH7I.js → chunk-HUXK7EOT.js} +3 -3
|
@@ -0,0 +1,990 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createBiquadCascade,
|
|
3
|
+
highpassCoeffs,
|
|
4
|
+
lowpassCoeffs
|
|
5
|
+
} from "../chunk-NEWNNLWN.js";
|
|
6
|
+
import {
|
|
7
|
+
createMediaTimeMap
|
|
8
|
+
} from "../chunk-JSGIWW35.js";
|
|
9
|
+
import {
|
|
10
|
+
MEDIA_EDIT_PARAMS,
|
|
11
|
+
MEDIA_FX_ORDER,
|
|
12
|
+
MEDIA_FX_SPECS,
|
|
13
|
+
formatMediaNumber,
|
|
14
|
+
hasRenderableFx,
|
|
15
|
+
mediaEditHtmlAttribute,
|
|
16
|
+
mediaEditValuesFromAttributes,
|
|
17
|
+
mediaEditValuesFromParams,
|
|
18
|
+
mediaRenderKey,
|
|
19
|
+
normalizeMediaCrop,
|
|
20
|
+
normalizeMediaCuts,
|
|
21
|
+
parseMediaCrop,
|
|
22
|
+
parseMediaCuts,
|
|
23
|
+
parseMediaEdits,
|
|
24
|
+
parseMediaFx,
|
|
25
|
+
serializeMediaCrop,
|
|
26
|
+
serializeMediaCuts,
|
|
27
|
+
serializeMediaEdits,
|
|
28
|
+
serializeMediaFx
|
|
29
|
+
} from "../chunk-L7QZORLB.js";
|
|
30
|
+
|
|
31
|
+
// src/mediaEdit/renderStore.ts
|
|
32
|
+
var MEDIA_RENDER_DIR = ".mediaEdits";
|
|
33
|
+
var MEDIA_RENDER_MANIFEST_VERSION = 1;
|
|
34
|
+
var MEDIA_RENDER_GC_AGE_DAYS = 7;
|
|
35
|
+
var RENDER_EXTENSIONS = /* @__PURE__ */ new Set(["webm", "wav", "ogg", "m4a"]);
|
|
36
|
+
function mediaRenderStem(src) {
|
|
37
|
+
const base = src.split(/[?#]/, 1)[0].split("/").pop() ?? "media";
|
|
38
|
+
const stem = base.replace(/\.[^.]+$/, "").replace(/[^A-Za-z0-9_-]+/g, "_");
|
|
39
|
+
return stem.slice(0, 80) || "media";
|
|
40
|
+
}
|
|
41
|
+
function mediaRenderFilePath(src, key, extension) {
|
|
42
|
+
return `${MEDIA_RENDER_DIR}/${mediaRenderStem(src)}.${key}.${extension}`;
|
|
43
|
+
}
|
|
44
|
+
function mediaRenderManifestPath(src, key) {
|
|
45
|
+
return `${MEDIA_RENDER_DIR}/${mediaRenderStem(src)}.${key}.json`;
|
|
46
|
+
}
|
|
47
|
+
function parseMediaRenderPath(path) {
|
|
48
|
+
const segments = path.split("/");
|
|
49
|
+
const dirIndex = segments.length - 2;
|
|
50
|
+
if (dirIndex < 0 || segments[dirIndex] !== MEDIA_RENDER_DIR) return null;
|
|
51
|
+
const match = segments[segments.length - 1].match(/^(.+)\.([0-9a-f]{12})\.([a-z0-9]+)$/);
|
|
52
|
+
if (!match) return null;
|
|
53
|
+
const extension = match[3];
|
|
54
|
+
const isManifest = extension === "json";
|
|
55
|
+
if (!isManifest && !RENDER_EXTENSIONS.has(extension)) return null;
|
|
56
|
+
return { stem: match[1], key: match[2], extension, isManifest };
|
|
57
|
+
}
|
|
58
|
+
function isMediaRenderPath(path) {
|
|
59
|
+
return parseMediaRenderPath(path) != null;
|
|
60
|
+
}
|
|
61
|
+
function buildMediaRenderIndex(entries) {
|
|
62
|
+
const manifests = /* @__PURE__ */ new Map();
|
|
63
|
+
const renders = [];
|
|
64
|
+
for (const entry of entries) {
|
|
65
|
+
const parsed = parseMediaRenderPath(entry.name);
|
|
66
|
+
if (!parsed) continue;
|
|
67
|
+
if (parsed.isManifest) manifests.set(parsed.key, entry.name);
|
|
68
|
+
else renders.push({ key: parsed.key, path: entry.name, size: entry.size });
|
|
69
|
+
}
|
|
70
|
+
const index = /* @__PURE__ */ new Map();
|
|
71
|
+
for (const render of renders) {
|
|
72
|
+
index.set(render.key, { ...render, manifestPath: manifests.get(render.key) ?? null });
|
|
73
|
+
}
|
|
74
|
+
return index;
|
|
75
|
+
}
|
|
76
|
+
function parseMediaRenderManifest(text) {
|
|
77
|
+
let raw;
|
|
78
|
+
try {
|
|
79
|
+
raw = JSON.parse(text);
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
if (!raw || typeof raw !== "object") return null;
|
|
84
|
+
const m = raw;
|
|
85
|
+
const str = (v) => typeof v === "string";
|
|
86
|
+
const numOrNull = (v) => typeof v === "number" && Number.isFinite(v) ? v : null;
|
|
87
|
+
if (m.version !== MEDIA_RENDER_MANIFEST_VERSION) return null;
|
|
88
|
+
if (!str(m.src) || !str(m.key) || !str(m.fx) || !str(m.file) || !str(m.mimeType)) return null;
|
|
89
|
+
if (!str(m.engine) || !str(m.createdAt)) return null;
|
|
90
|
+
const ignoredOps2 = Array.isArray(m.ignoredOps) ? m.ignoredOps.filter(str) : [];
|
|
91
|
+
const manifest = {
|
|
92
|
+
version: MEDIA_RENDER_MANIFEST_VERSION,
|
|
93
|
+
src: m.src,
|
|
94
|
+
key: m.key,
|
|
95
|
+
fx: m.fx,
|
|
96
|
+
ignoredOps: ignoredOps2,
|
|
97
|
+
file: m.file,
|
|
98
|
+
mimeType: m.mimeType,
|
|
99
|
+
sourceSize: numOrNull(m.sourceSize),
|
|
100
|
+
sourceDuration: numOrNull(m.sourceDuration),
|
|
101
|
+
engine: m.engine,
|
|
102
|
+
createdAt: m.createdAt
|
|
103
|
+
};
|
|
104
|
+
if (m.analysis && typeof m.analysis === "object") {
|
|
105
|
+
const a = m.analysis;
|
|
106
|
+
const analysis = {};
|
|
107
|
+
const lufs = numOrNull(a.integratedLufs);
|
|
108
|
+
const gain = numOrNull(a.appliedGainDb);
|
|
109
|
+
const breaths = numOrNull(a.breathCount);
|
|
110
|
+
if (lufs != null) analysis.integratedLufs = lufs;
|
|
111
|
+
if (gain != null) analysis.appliedGainDb = gain;
|
|
112
|
+
if (breaths != null) analysis.breathCount = breaths;
|
|
113
|
+
manifest.analysis = analysis;
|
|
114
|
+
}
|
|
115
|
+
return manifest;
|
|
116
|
+
}
|
|
117
|
+
function serializeMediaRenderManifest(manifest) {
|
|
118
|
+
return `${JSON.stringify(manifest, null, 2)}
|
|
119
|
+
`;
|
|
120
|
+
}
|
|
121
|
+
function mediaRenderStaleness(manifest, current) {
|
|
122
|
+
if (current.sourceSize != null && manifest.sourceSize != null && current.sourceSize !== manifest.sourceSize) {
|
|
123
|
+
return "source-changed";
|
|
124
|
+
}
|
|
125
|
+
const known = new Set(current.knownOps ?? MEDIA_FX_ORDER);
|
|
126
|
+
const nowKnown = manifest.ignoredOps.some(
|
|
127
|
+
(token) => known.has(token.split(":", 1)[0].toLowerCase())
|
|
128
|
+
);
|
|
129
|
+
return nowKnown ? "engine-upgraded" : null;
|
|
130
|
+
}
|
|
131
|
+
function flattenBlocks(blocks, out = []) {
|
|
132
|
+
for (const block of blocks) {
|
|
133
|
+
out.push(block);
|
|
134
|
+
if (block.children && block.children.length > 0) flattenBlocks(block.children, out);
|
|
135
|
+
}
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
function collectDocMediaClips(doc) {
|
|
139
|
+
const clips = [];
|
|
140
|
+
for (const block of flattenBlocks(doc.blocks)) clips.push(...block.media ?? []);
|
|
141
|
+
clips.push(...doc.documentMedia ?? []);
|
|
142
|
+
return clips;
|
|
143
|
+
}
|
|
144
|
+
function mediaClipRenderKey(clip) {
|
|
145
|
+
const fx = clip.edits?.fx;
|
|
146
|
+
return hasRenderableFx(fx) ? mediaRenderKey(clip.src, fx) : null;
|
|
147
|
+
}
|
|
148
|
+
function referencedMediaRenderKeys(doc) {
|
|
149
|
+
const keys = /* @__PURE__ */ new Set();
|
|
150
|
+
for (const clip of collectDocMediaClips(doc)) {
|
|
151
|
+
const key = mediaClipRenderKey(clip);
|
|
152
|
+
if (key) keys.add(key);
|
|
153
|
+
}
|
|
154
|
+
return keys;
|
|
155
|
+
}
|
|
156
|
+
function mediaRenderKeyFor(src, fx) {
|
|
157
|
+
const chain = parseMediaFx(fx) ?? void 0;
|
|
158
|
+
return hasRenderableFx(chain) ? mediaRenderKey(src, chain) : null;
|
|
159
|
+
}
|
|
160
|
+
function selectMediaRendersForGc(index, referencedKeys, manifests, now = /* @__PURE__ */ new Date(), olderThanDays = MEDIA_RENDER_GC_AGE_DAYS) {
|
|
161
|
+
const cutoff = now.getTime() - olderThanDays * 24 * 60 * 60 * 1e3;
|
|
162
|
+
const paths = [];
|
|
163
|
+
for (const [key, entry] of index) {
|
|
164
|
+
if (referencedKeys.has(key)) continue;
|
|
165
|
+
const manifest = manifests.get(key);
|
|
166
|
+
if (manifest) {
|
|
167
|
+
const created = Date.parse(manifest.createdAt);
|
|
168
|
+
if (Number.isFinite(created) && created > cutoff) continue;
|
|
169
|
+
}
|
|
170
|
+
paths.push(entry.path);
|
|
171
|
+
if (entry.manifestPath) paths.push(entry.manifestPath);
|
|
172
|
+
}
|
|
173
|
+
return paths;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// src/mediaEdit/dsp/breath.ts
|
|
177
|
+
var HOP_SEC = 0.01;
|
|
178
|
+
var FRAME_SEC = 0.02;
|
|
179
|
+
var PITCH_WINDOW_SEC = 0.04;
|
|
180
|
+
var PITCH_MIN_HZ = 70;
|
|
181
|
+
var PITCH_MAX_HZ = 400;
|
|
182
|
+
var DECIMATED_RATE = 6e3;
|
|
183
|
+
var SILENT_DB = -120;
|
|
184
|
+
function createBreathAnalyzer(sampleRate) {
|
|
185
|
+
const hop = Math.round(HOP_SEC * sampleRate);
|
|
186
|
+
const frameLen = Math.round(FRAME_SEC * sampleRate);
|
|
187
|
+
const decimation = Math.max(1, Math.round(sampleRate / DECIMATED_RATE));
|
|
188
|
+
const lowRate = sampleRate / decimation;
|
|
189
|
+
const pitchLen = Math.round(PITCH_WINDOW_SEC * lowRate);
|
|
190
|
+
const minLag = Math.max(1, Math.floor(lowRate / PITCH_MAX_HZ));
|
|
191
|
+
const maxLag = Math.min(pitchLen - 1, Math.ceil(lowRate / PITCH_MIN_HZ));
|
|
192
|
+
const levelBand = createBiquadCascade([highpassCoeffs(sampleRate, 60)]);
|
|
193
|
+
const pitchBand = createBiquadCascade([
|
|
194
|
+
highpassCoeffs(sampleRate, 60),
|
|
195
|
+
lowpassCoeffs(sampleRate, 900),
|
|
196
|
+
lowpassCoeffs(sampleRate, 900)
|
|
197
|
+
]);
|
|
198
|
+
const keepFull = frameLen + hop;
|
|
199
|
+
let full = new Float32Array(0);
|
|
200
|
+
let fullStart = 0;
|
|
201
|
+
let low = [];
|
|
202
|
+
let lowStart = 0;
|
|
203
|
+
let decimPhase = 0;
|
|
204
|
+
let nextFrameStart = 0;
|
|
205
|
+
const out = [];
|
|
206
|
+
const window = new Float64Array(pitchLen);
|
|
207
|
+
const voicingAt = (centerLow) => {
|
|
208
|
+
const begin = Math.round(centerLow - pitchLen / 2) - lowStart;
|
|
209
|
+
if (begin < 0 || begin + pitchLen > low.length) return 0;
|
|
210
|
+
let mean = 0;
|
|
211
|
+
for (let i = 0; i < pitchLen; i++) mean += low[begin + i];
|
|
212
|
+
mean /= pitchLen;
|
|
213
|
+
for (let i = 0; i < pitchLen; i++) window[i] = low[begin + i] - mean;
|
|
214
|
+
let energy = 0;
|
|
215
|
+
for (let i = 0; i < pitchLen; i++) energy += window[i] * window[i];
|
|
216
|
+
if (energy <= 1e-12) return 0;
|
|
217
|
+
let best = 0;
|
|
218
|
+
for (let lag = minLag; lag <= maxLag; lag++) {
|
|
219
|
+
let acc = 0;
|
|
220
|
+
let e1 = 0;
|
|
221
|
+
let e2 = 0;
|
|
222
|
+
for (let i = 0; i + lag < pitchLen; i++) {
|
|
223
|
+
const a = window[i];
|
|
224
|
+
const b = window[i + lag];
|
|
225
|
+
acc += a * b;
|
|
226
|
+
e1 += a * a;
|
|
227
|
+
e2 += b * b;
|
|
228
|
+
}
|
|
229
|
+
const norm = Math.sqrt(e1 * e2);
|
|
230
|
+
if (norm > 0 && acc / norm > best) best = acc / norm;
|
|
231
|
+
}
|
|
232
|
+
return best;
|
|
233
|
+
};
|
|
234
|
+
return {
|
|
235
|
+
push(mono) {
|
|
236
|
+
const filtered = mono.slice();
|
|
237
|
+
pitchBand.process(filtered);
|
|
238
|
+
for (let i = 0; i < filtered.length; i++) {
|
|
239
|
+
if (decimPhase === 0) low.push(filtered[i]);
|
|
240
|
+
decimPhase = (decimPhase + 1) % decimation;
|
|
241
|
+
}
|
|
242
|
+
const leveled = mono.slice();
|
|
243
|
+
levelBand.process(leveled);
|
|
244
|
+
const merged = new Float32Array(full.length + leveled.length);
|
|
245
|
+
merged.set(full, 0);
|
|
246
|
+
merged.set(leveled, full.length);
|
|
247
|
+
full = merged;
|
|
248
|
+
const lowEnd = lowStart + low.length;
|
|
249
|
+
while (nextFrameStart + frameLen <= fullStart + full.length) {
|
|
250
|
+
const centerFull = nextFrameStart + frameLen / 2;
|
|
251
|
+
const centerLow = centerFull / decimation;
|
|
252
|
+
if (centerLow + pitchLen / 2 > lowEnd) break;
|
|
253
|
+
const s = nextFrameStart - fullStart;
|
|
254
|
+
let sumSq = 0;
|
|
255
|
+
let crossings = 0;
|
|
256
|
+
for (let i = 0; i < frameLen; i++) {
|
|
257
|
+
const v = full[s + i];
|
|
258
|
+
sumSq += v * v;
|
|
259
|
+
if (i > 0 && v >= 0 !== full[s + i - 1] >= 0) crossings++;
|
|
260
|
+
}
|
|
261
|
+
const rms = Math.sqrt(sumSq / frameLen);
|
|
262
|
+
out.push({
|
|
263
|
+
t: centerFull / sampleRate,
|
|
264
|
+
rmsDb: rms > 0 ? 20 * Math.log10(rms) : SILENT_DB,
|
|
265
|
+
voicing: voicingAt(centerLow),
|
|
266
|
+
zcr: crossings / (frameLen - 1)
|
|
267
|
+
});
|
|
268
|
+
nextFrameStart += hop;
|
|
269
|
+
}
|
|
270
|
+
const dropFull = Math.max(0, nextFrameStart - fullStart - keepFull);
|
|
271
|
+
if (dropFull > 0) {
|
|
272
|
+
full = full.slice(dropFull);
|
|
273
|
+
fullStart += dropFull;
|
|
274
|
+
}
|
|
275
|
+
const neededLow = Math.floor(nextFrameStart / decimation - pitchLen) - lowStart;
|
|
276
|
+
if (neededLow > 0) {
|
|
277
|
+
low = low.slice(neededLow);
|
|
278
|
+
lowStart += neededLow;
|
|
279
|
+
}
|
|
280
|
+
},
|
|
281
|
+
frames: () => out
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
function percentile(values, p) {
|
|
285
|
+
if (values.length === 0) return SILENT_DB;
|
|
286
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
287
|
+
return sorted[Math.min(sorted.length - 1, Math.max(0, Math.floor(p * (sorted.length - 1))))];
|
|
288
|
+
}
|
|
289
|
+
function detectBreaths(frames, options = {}) {
|
|
290
|
+
if (frames.length === 0) return [];
|
|
291
|
+
const sensitivity = Math.min(1, Math.max(0, options.sensitivity ?? 0.5));
|
|
292
|
+
const minDur = options.minDurationSec ?? 0.12;
|
|
293
|
+
const maxDur = options.maxDurationSec ?? 1;
|
|
294
|
+
const voicedThreshold = 0.6;
|
|
295
|
+
const levels = frames.map((f) => f.rmsDb);
|
|
296
|
+
const floor = percentile(levels, 0.1);
|
|
297
|
+
const voicedLevels = frames.filter((f) => f.voicing >= voicedThreshold).map((f) => f.rmsDb);
|
|
298
|
+
const speech = percentile(voicedLevels.length > 0 ? voicedLevels : levels, 0.9);
|
|
299
|
+
if (speech - floor < 12) return [];
|
|
300
|
+
const aboveFloor = 6 - 3 * sensitivity;
|
|
301
|
+
const belowSpeech = 10 - 6 * sensitivity;
|
|
302
|
+
const maxVoicing = 0.35 + 0.15 * sensitivity;
|
|
303
|
+
const maxZcr = 0.2 + 0.05 * sensitivity;
|
|
304
|
+
const isCandidate = (f) => f.voicing < maxVoicing && f.rmsDb > floor + aboveFloor && f.rmsDb < speech - belowSpeech && f.zcr < maxZcr;
|
|
305
|
+
const isVoiced = (f) => f.voicing >= voicedThreshold && f.rmsDb > floor + 10;
|
|
306
|
+
const runs = [];
|
|
307
|
+
let current = null;
|
|
308
|
+
for (let i = 0; i < frames.length; i++) {
|
|
309
|
+
if (isCandidate(frames[i])) {
|
|
310
|
+
if (current && i - current.last <= 2) current.last = i;
|
|
311
|
+
else {
|
|
312
|
+
current = { first: i, last: i };
|
|
313
|
+
runs.push(current);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
const contextFrames = Math.round(0.06 / HOP_SEC);
|
|
318
|
+
const voicedNear = (from, to) => {
|
|
319
|
+
for (let i = Math.max(0, from); i <= Math.min(frames.length - 1, to); i++) {
|
|
320
|
+
if (isVoiced(frames[i])) return true;
|
|
321
|
+
}
|
|
322
|
+
return false;
|
|
323
|
+
};
|
|
324
|
+
const regions = [];
|
|
325
|
+
for (const run of runs) {
|
|
326
|
+
const start = frames[run.first].t - HOP_SEC / 2;
|
|
327
|
+
const end = frames[run.last].t + HOP_SEC / 2;
|
|
328
|
+
const duration = end - start;
|
|
329
|
+
if (duration < minDur || duration > maxDur) continue;
|
|
330
|
+
const voicedBefore = voicedNear(run.first - contextFrames, run.first - 1);
|
|
331
|
+
const voicedAfter = voicedNear(run.last + 1, run.last + contextFrames);
|
|
332
|
+
if (voicedBefore && voicedAfter && duration < 0.25) continue;
|
|
333
|
+
regions.push({ start, end });
|
|
334
|
+
}
|
|
335
|
+
return regions;
|
|
336
|
+
}
|
|
337
|
+
function breathGainAt(regions, reductionDb, t, rampSec = 0.015) {
|
|
338
|
+
const floorGain = Math.pow(10, Math.min(0, reductionDb) / 20);
|
|
339
|
+
for (const r of regions) {
|
|
340
|
+
if (t < r.start || t >= r.end) continue;
|
|
341
|
+
const ramp = Math.min(rampSec, (r.end - r.start) / 2);
|
|
342
|
+
const edge = Math.min(t - r.start, r.end - t);
|
|
343
|
+
const depth = ramp > 0 ? Math.min(1, edge / ramp) : 1;
|
|
344
|
+
return 1 - (1 - floorGain) * depth;
|
|
345
|
+
}
|
|
346
|
+
return 1;
|
|
347
|
+
}
|
|
348
|
+
function applyBreathEnvelope(channels, sampleRate, startSec, regions, reductionDb) {
|
|
349
|
+
const length = channels[0]?.length ?? 0;
|
|
350
|
+
if (length === 0 || regions.length === 0) return;
|
|
351
|
+
const endSec = startSec + length / sampleRate;
|
|
352
|
+
const touching = regions.filter((r) => r.end > startSec && r.start < endSec);
|
|
353
|
+
if (touching.length === 0) return;
|
|
354
|
+
for (let i = 0; i < length; i++) {
|
|
355
|
+
const g = breathGainAt(touching, reductionDb, startSec + i / sampleRate);
|
|
356
|
+
if (g === 1) continue;
|
|
357
|
+
for (const ch of channels) ch[i] *= g;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// src/mediaEdit/dsp/denoiser.ts
|
|
362
|
+
var SampleFifo = class {
|
|
363
|
+
constructor(capacity) {
|
|
364
|
+
this.start = 0;
|
|
365
|
+
this.end = 0;
|
|
366
|
+
this.buffer = new Float32Array(Math.max(16, capacity));
|
|
367
|
+
}
|
|
368
|
+
get length() {
|
|
369
|
+
return this.end - this.start;
|
|
370
|
+
}
|
|
371
|
+
push(samples) {
|
|
372
|
+
if (this.end + samples.length > this.buffer.length) {
|
|
373
|
+
const live = this.buffer.subarray(this.start, this.end);
|
|
374
|
+
const next = live.length + samples.length > this.buffer.length ? new Float32Array((live.length + samples.length) * 2) : this.buffer;
|
|
375
|
+
next.set(live, 0);
|
|
376
|
+
this.buffer = next;
|
|
377
|
+
this.end = live.length;
|
|
378
|
+
this.start = 0;
|
|
379
|
+
}
|
|
380
|
+
this.buffer.set(samples, this.end);
|
|
381
|
+
this.end += samples.length;
|
|
382
|
+
}
|
|
383
|
+
pushZeros(count) {
|
|
384
|
+
this.push(new Float32Array(count));
|
|
385
|
+
}
|
|
386
|
+
shift(count) {
|
|
387
|
+
const out = this.buffer.slice(this.start, this.start + count);
|
|
388
|
+
this.start += count;
|
|
389
|
+
return out;
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
function createDenoiseStage(denoiser, strength) {
|
|
393
|
+
const frameSize = denoiser.frameSize;
|
|
394
|
+
const latency = frameSize + denoiser.latency;
|
|
395
|
+
const wet = Math.min(1, Math.max(0, strength));
|
|
396
|
+
const input = new SampleFifo(frameSize * 4);
|
|
397
|
+
const output = new SampleFifo(frameSize * 4);
|
|
398
|
+
const dry = new SampleFifo(frameSize * 4);
|
|
399
|
+
output.pushZeros(frameSize);
|
|
400
|
+
dry.pushZeros(latency);
|
|
401
|
+
const frame = new Float32Array(frameSize);
|
|
402
|
+
return {
|
|
403
|
+
latency,
|
|
404
|
+
process(block) {
|
|
405
|
+
input.push(block);
|
|
406
|
+
dry.push(block);
|
|
407
|
+
while (input.length >= frameSize) {
|
|
408
|
+
frame.set(input.shift(frameSize));
|
|
409
|
+
denoiser.processFrame(frame);
|
|
410
|
+
output.push(frame);
|
|
411
|
+
}
|
|
412
|
+
const out = output.shift(block.length);
|
|
413
|
+
const delayedDry = dry.shift(block.length);
|
|
414
|
+
if (wet < 1) {
|
|
415
|
+
for (let i = 0; i < out.length; i++) out[i] = wet * out[i] + (1 - wet) * delayedDry[i];
|
|
416
|
+
}
|
|
417
|
+
return out;
|
|
418
|
+
},
|
|
419
|
+
dispose: () => denoiser.dispose()
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// src/mediaEdit/dsp/limiter.ts
|
|
424
|
+
var OVERSAMPLE = 4;
|
|
425
|
+
var TAPS_PER_PHASE = 8;
|
|
426
|
+
function interpolationKernels() {
|
|
427
|
+
const kernels = [];
|
|
428
|
+
const half = TAPS_PER_PHASE / 2;
|
|
429
|
+
for (let phase = 1; phase < OVERSAMPLE; phase++) {
|
|
430
|
+
const frac = phase / OVERSAMPLE;
|
|
431
|
+
const k = new Float32Array(TAPS_PER_PHASE);
|
|
432
|
+
let sum = 0;
|
|
433
|
+
for (let t = 0; t < TAPS_PER_PHASE; t++) {
|
|
434
|
+
const x = t - (half - 1) - frac;
|
|
435
|
+
const sinc = x === 0 ? 1 : Math.sin(Math.PI * x) / (Math.PI * x);
|
|
436
|
+
const w = 0.5 * (1 + Math.cos(Math.PI * x / half));
|
|
437
|
+
k[t] = sinc * w;
|
|
438
|
+
sum += k[t];
|
|
439
|
+
}
|
|
440
|
+
for (let t = 0; t < TAPS_PER_PHASE; t++) k[t] /= sum;
|
|
441
|
+
kernels.push(k);
|
|
442
|
+
}
|
|
443
|
+
return kernels;
|
|
444
|
+
}
|
|
445
|
+
function createLimiter(options) {
|
|
446
|
+
const channelCount = options.channels;
|
|
447
|
+
const ceiling = Math.pow(10, (options.ceilingDb ?? -1) / 20);
|
|
448
|
+
const lookahead = Math.max(
|
|
449
|
+
1,
|
|
450
|
+
Math.round((options.lookaheadMs ?? 5) / 1e3 * options.sampleRate)
|
|
451
|
+
);
|
|
452
|
+
const releaseCoeff = Math.exp(-1 / ((options.releaseMs ?? 80) / 1e3 * options.sampleRate));
|
|
453
|
+
const kernels = interpolationKernels();
|
|
454
|
+
const half = TAPS_PER_PHASE / 2;
|
|
455
|
+
const history = Array.from({ length: channelCount }, () => new Float32Array(TAPS_PER_PHASE));
|
|
456
|
+
const delaySize = lookahead + half;
|
|
457
|
+
const delay = Array.from({ length: channelCount }, () => new Float32Array(delaySize));
|
|
458
|
+
const required = new Float64Array(lookahead);
|
|
459
|
+
const dequeIdx = new Int32Array(lookahead + 1);
|
|
460
|
+
let dqHead = 0;
|
|
461
|
+
let dqTail = 0;
|
|
462
|
+
const minRing = new Float64Array(lookahead).fill(1);
|
|
463
|
+
let minSum = lookahead;
|
|
464
|
+
let n = 0;
|
|
465
|
+
let gain = 1;
|
|
466
|
+
let smallest = 1;
|
|
467
|
+
const truePeak = (sample, c) => {
|
|
468
|
+
const h = history[c];
|
|
469
|
+
h.copyWithin(0, 1);
|
|
470
|
+
h[TAPS_PER_PHASE - 1] = sample;
|
|
471
|
+
let peak = Math.max(Math.abs(h[half - 1]), Math.abs(h[half]));
|
|
472
|
+
for (const k of kernels) {
|
|
473
|
+
let v = 0;
|
|
474
|
+
for (let t = 0; t < TAPS_PER_PHASE; t++) v += k[t] * h[t];
|
|
475
|
+
peak = Math.max(peak, Math.abs(v));
|
|
476
|
+
}
|
|
477
|
+
return peak;
|
|
478
|
+
};
|
|
479
|
+
const step = (input, out, i) => {
|
|
480
|
+
let peak = 0;
|
|
481
|
+
for (let c = 0; c < channelCount; c++) peak = Math.max(peak, truePeak(input[c], c));
|
|
482
|
+
const r = peak > ceiling ? ceiling / peak : 1;
|
|
483
|
+
const slot = n % lookahead;
|
|
484
|
+
const cap = lookahead + 1;
|
|
485
|
+
while (dqTail > dqHead && dequeIdx[dqHead % cap] <= n - lookahead) dqHead++;
|
|
486
|
+
required[slot] = r;
|
|
487
|
+
while (dqTail > dqHead && required[dequeIdx[(dqTail - 1) % cap] % lookahead] >= r) dqTail--;
|
|
488
|
+
dequeIdx[dqTail % cap] = n;
|
|
489
|
+
dqTail++;
|
|
490
|
+
const windowMin = required[dequeIdx[dqHead % cap] % lookahead];
|
|
491
|
+
minSum += windowMin - minRing[slot];
|
|
492
|
+
minRing[slot] = windowMin;
|
|
493
|
+
const smoothed = Math.min(1, minSum / lookahead);
|
|
494
|
+
gain = smoothed < gain ? smoothed : smoothed + (gain - smoothed) * releaseCoeff;
|
|
495
|
+
if (gain < smallest) smallest = gain;
|
|
496
|
+
const writeSlot = n % delaySize;
|
|
497
|
+
const readSlot = (n + 1) % delaySize;
|
|
498
|
+
for (let c = 0; c < channelCount; c++) {
|
|
499
|
+
const d = delay[c];
|
|
500
|
+
d[writeSlot] = input[c];
|
|
501
|
+
out[c][i] = d[readSlot] * gain;
|
|
502
|
+
}
|
|
503
|
+
n++;
|
|
504
|
+
};
|
|
505
|
+
const run = (channels, length) => {
|
|
506
|
+
const out = Array.from({ length: channelCount }, () => new Float32Array(length));
|
|
507
|
+
const frame = new Array(channelCount).fill(0);
|
|
508
|
+
for (let i = 0; i < length; i++) {
|
|
509
|
+
for (let c = 0; c < channelCount; c++) frame[c] = channels[c]?.[i] ?? 0;
|
|
510
|
+
step(frame, out, i);
|
|
511
|
+
}
|
|
512
|
+
return out;
|
|
513
|
+
};
|
|
514
|
+
return {
|
|
515
|
+
latency: lookahead - 1 + half,
|
|
516
|
+
process: (channels) => run(channels, channels[0]?.length ?? 0),
|
|
517
|
+
flush: () => run([], lookahead - 1 + half),
|
|
518
|
+
minGain: () => smallest
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// src/mediaEdit/dsp/loudness.ts
|
|
523
|
+
var ABSOLUTE_GATE = -70;
|
|
524
|
+
var RELATIVE_GATE = -10;
|
|
525
|
+
var STEPS_PER_BLOCK = 4;
|
|
526
|
+
function kWeightingCoeffs(sampleRate) {
|
|
527
|
+
let f0 = 1681.974450955533;
|
|
528
|
+
let q = 0.7071752369554196;
|
|
529
|
+
let k = Math.tan(Math.PI * f0 / sampleRate);
|
|
530
|
+
const vh = Math.pow(10, 3.999843853973347 / 20);
|
|
531
|
+
const vb = Math.pow(vh, 0.4996667741545416);
|
|
532
|
+
let a0 = 1 + k / q + k * k;
|
|
533
|
+
const shelf = {
|
|
534
|
+
b0: (vh + vb * k / q + k * k) / a0,
|
|
535
|
+
b1: 2 * (k * k - vh) / a0,
|
|
536
|
+
b2: (vh - vb * k / q + k * k) / a0,
|
|
537
|
+
a1: 2 * (k * k - 1) / a0,
|
|
538
|
+
a2: (1 - k / q + k * k) / a0
|
|
539
|
+
};
|
|
540
|
+
f0 = 38.13547087602444;
|
|
541
|
+
q = 0.5003270373238773;
|
|
542
|
+
k = Math.tan(Math.PI * f0 / sampleRate);
|
|
543
|
+
a0 = 1 + k / q + k * k;
|
|
544
|
+
const rlb = {
|
|
545
|
+
b0: 1,
|
|
546
|
+
b1: -2,
|
|
547
|
+
b2: 1,
|
|
548
|
+
a1: 2 * (k * k - 1) / a0,
|
|
549
|
+
a2: (1 - k / q + k * k) / a0
|
|
550
|
+
};
|
|
551
|
+
return [shelf, rlb];
|
|
552
|
+
}
|
|
553
|
+
function kWeighting(sampleRate) {
|
|
554
|
+
return createBiquadCascade(kWeightingCoeffs(sampleRate));
|
|
555
|
+
}
|
|
556
|
+
function blockLoudness(meanSquareSum) {
|
|
557
|
+
return meanSquareSum > 0 ? -0.691 + 10 * Math.log10(meanSquareSum) : Number.NEGATIVE_INFINITY;
|
|
558
|
+
}
|
|
559
|
+
function createLoudnessMeter(sampleRate, channelCount) {
|
|
560
|
+
const filters = Array.from({ length: channelCount }, () => kWeighting(sampleRate));
|
|
561
|
+
const stepSamples = Math.round(sampleRate / 10);
|
|
562
|
+
const steps = [];
|
|
563
|
+
let stepSum = 0;
|
|
564
|
+
let stepFill = 0;
|
|
565
|
+
let scratch = new Float32Array(0);
|
|
566
|
+
return {
|
|
567
|
+
push(channels) {
|
|
568
|
+
const length = channels[0]?.length ?? 0;
|
|
569
|
+
if (length === 0) return;
|
|
570
|
+
if (scratch.length < length * channelCount) scratch = new Float32Array(length * channelCount);
|
|
571
|
+
const weighted = [];
|
|
572
|
+
for (let c = 0; c < channelCount; c++) {
|
|
573
|
+
const view = scratch.subarray(c * length, (c + 1) * length);
|
|
574
|
+
view.set(channels[c] ?? channels[0]);
|
|
575
|
+
filters[c].process(view);
|
|
576
|
+
weighted.push(view);
|
|
577
|
+
}
|
|
578
|
+
for (let i = 0; i < length; i++) {
|
|
579
|
+
let sum = 0;
|
|
580
|
+
for (let c = 0; c < channelCount; c++) sum += weighted[c][i] * weighted[c][i];
|
|
581
|
+
stepSum += sum;
|
|
582
|
+
stepFill++;
|
|
583
|
+
if (stepFill === stepSamples) {
|
|
584
|
+
steps.push(stepSum / stepSamples);
|
|
585
|
+
stepSum = 0;
|
|
586
|
+
stepFill = 0;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
},
|
|
590
|
+
stepPowers: () => steps,
|
|
591
|
+
integrated: () => integratedLoudnessFromSteps(steps)
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
function integratedLoudnessFromSteps(steps) {
|
|
595
|
+
const blocks = [];
|
|
596
|
+
for (let i = 0; i + STEPS_PER_BLOCK <= steps.length; i++) {
|
|
597
|
+
let sum = 0;
|
|
598
|
+
for (let k = 0; k < STEPS_PER_BLOCK; k++) sum += steps[i + k];
|
|
599
|
+
blocks.push(sum / STEPS_PER_BLOCK);
|
|
600
|
+
}
|
|
601
|
+
const absolute = blocks.filter((z) => blockLoudness(z) > ABSOLUTE_GATE);
|
|
602
|
+
if (absolute.length === 0) return Number.NEGATIVE_INFINITY;
|
|
603
|
+
const absoluteMean = absolute.reduce((a, b) => a + b, 0) / absolute.length;
|
|
604
|
+
const threshold = blockLoudness(absoluteMean) + RELATIVE_GATE;
|
|
605
|
+
const relative = absolute.filter((z) => blockLoudness(z) > threshold);
|
|
606
|
+
if (relative.length === 0) return Number.NEGATIVE_INFINITY;
|
|
607
|
+
return blockLoudness(relative.reduce((a, b) => a + b, 0) / relative.length);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// src/mediaEdit/dsp/chain.ts
|
|
611
|
+
var MEDIA_FX_SAMPLE_RATE = 48e3;
|
|
612
|
+
var MEDIA_FX_ENGINE = "squisq-media-edit/1";
|
|
613
|
+
var LOUDNESS_GAIN_RANGE = { min: -20, max: 24 };
|
|
614
|
+
var MEDIA_FX_TRUE_PEAK_CEILING_DB = -1;
|
|
615
|
+
function opValues(fx) {
|
|
616
|
+
const values = {};
|
|
617
|
+
for (const op of fx.ops) values[op.id] = op.value;
|
|
618
|
+
return values;
|
|
619
|
+
}
|
|
620
|
+
function ignoredOps(fx, hasDenoiser) {
|
|
621
|
+
const ignored = [...fx.unknown];
|
|
622
|
+
const denoise = fx.ops.find((op) => op.id === "denoise");
|
|
623
|
+
if (denoise && !hasDenoiser) ignored.push(`denoise:${denoise.value}`);
|
|
624
|
+
return ignored;
|
|
625
|
+
}
|
|
626
|
+
function assertRate(source) {
|
|
627
|
+
if (source.sampleRate !== MEDIA_FX_SAMPLE_RATE) {
|
|
628
|
+
throw new Error(
|
|
629
|
+
`Media-edit chain runs at ${MEDIA_FX_SAMPLE_RATE} Hz; resample the source first (got ${source.sampleRate}).`
|
|
630
|
+
);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
async function* frontStages(options, onFrames) {
|
|
634
|
+
const { source, signal } = options;
|
|
635
|
+
const ops = opValues(options.fx);
|
|
636
|
+
const channels = source.channels;
|
|
637
|
+
const highpass = ops.highpass != null ? Array.from(
|
|
638
|
+
{ length: channels },
|
|
639
|
+
() => createBiquadCascade([highpassCoeffs(source.sampleRate, ops.highpass)])
|
|
640
|
+
) : null;
|
|
641
|
+
let denoise = null;
|
|
642
|
+
if (ops.denoise != null && options.denoiser) {
|
|
643
|
+
const factory = options.denoiser;
|
|
644
|
+
denoise = await Promise.all(
|
|
645
|
+
Array.from(
|
|
646
|
+
{ length: channels },
|
|
647
|
+
async () => createDenoiseStage(await factory(), ops.denoise)
|
|
648
|
+
)
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
const latency = denoise?.[0]?.latency ?? 0;
|
|
652
|
+
let inFrames = 0;
|
|
653
|
+
let outFrames = 0;
|
|
654
|
+
let toSkip = latency;
|
|
655
|
+
const run = (block) => {
|
|
656
|
+
const copies = block.map((ch) => ch.slice());
|
|
657
|
+
if (highpass) copies.forEach((ch, c) => highpass[c].process(ch));
|
|
658
|
+
let processed = denoise ? copies.map((ch, c) => denoise[c].process(ch)) : copies;
|
|
659
|
+
const length = processed[0]?.length ?? 0;
|
|
660
|
+
const skip = Math.min(toSkip, length);
|
|
661
|
+
toSkip -= skip;
|
|
662
|
+
const room = inFrames - outFrames;
|
|
663
|
+
const take = Math.min(length - skip, room);
|
|
664
|
+
if (take <= 0) return null;
|
|
665
|
+
processed = processed.map((ch) => ch.subarray(skip, skip + take));
|
|
666
|
+
outFrames += take;
|
|
667
|
+
return processed;
|
|
668
|
+
};
|
|
669
|
+
try {
|
|
670
|
+
for await (const block of source.open()) {
|
|
671
|
+
signal?.throwIfAborted();
|
|
672
|
+
const length = block[0]?.length ?? 0;
|
|
673
|
+
if (length === 0) continue;
|
|
674
|
+
inFrames += length;
|
|
675
|
+
onFrames(length);
|
|
676
|
+
const out = run(block);
|
|
677
|
+
if (out) yield out;
|
|
678
|
+
}
|
|
679
|
+
if (latency > 0) {
|
|
680
|
+
const out = run(Array.from({ length: channels }, () => new Float32Array(latency)));
|
|
681
|
+
if (out) yield out;
|
|
682
|
+
}
|
|
683
|
+
} finally {
|
|
684
|
+
denoise?.forEach((stage) => stage.dispose());
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
function monoMix(block) {
|
|
688
|
+
if (block.length === 1) return block[0];
|
|
689
|
+
const mono = new Float32Array(block[0].length);
|
|
690
|
+
for (const ch of block) for (let i = 0; i < mono.length; i++) mono[i] += ch[i] / block.length;
|
|
691
|
+
return mono;
|
|
692
|
+
}
|
|
693
|
+
async function analyzeMediaFx(options) {
|
|
694
|
+
assertRate(options.source);
|
|
695
|
+
const ops = opValues(options.fx);
|
|
696
|
+
const { source } = options;
|
|
697
|
+
const startSec = options.startSec ?? 0;
|
|
698
|
+
const breath = ops.debreath != null ? createBreathAnalyzer(source.sampleRate) : null;
|
|
699
|
+
const meter = ops.loudness != null ? createLoudnessMeter(source.sampleRate, source.channels) : null;
|
|
700
|
+
let seen = 0;
|
|
701
|
+
const total = source.frames ?? 0;
|
|
702
|
+
for await (const block of frontStages(options, (n) => {
|
|
703
|
+
seen += n;
|
|
704
|
+
if (total > 0) options.onProgress?.(Math.min(1, seen / total));
|
|
705
|
+
})) {
|
|
706
|
+
breath?.push(monoMix(block));
|
|
707
|
+
meter?.push(block);
|
|
708
|
+
}
|
|
709
|
+
const breaths = breath ? detectBreaths(breath.frames()).map((r) => ({
|
|
710
|
+
start: r.start + startSec,
|
|
711
|
+
end: r.end + startSec
|
|
712
|
+
})) : [];
|
|
713
|
+
let integratedLufs = null;
|
|
714
|
+
let loudnessGainDb = 0;
|
|
715
|
+
if (meter && ops.loudness != null) {
|
|
716
|
+
const reduction = ops.debreath ?? 0;
|
|
717
|
+
const steps = meter.stepPowers().map((power, k) => {
|
|
718
|
+
if (breaths.length === 0) return power;
|
|
719
|
+
let sum = 0;
|
|
720
|
+
for (let i = 0; i < 100; i++) {
|
|
721
|
+
const g = breathGainAt(breaths, reduction, startSec + k * 0.1 + (i + 0.5) * 1e-3);
|
|
722
|
+
sum += g * g;
|
|
723
|
+
}
|
|
724
|
+
return power * (sum / 100);
|
|
725
|
+
});
|
|
726
|
+
const measured = integratedLoudnessFromSteps(steps);
|
|
727
|
+
if (Number.isFinite(measured)) {
|
|
728
|
+
integratedLufs = measured;
|
|
729
|
+
loudnessGainDb = Math.min(
|
|
730
|
+
LOUDNESS_GAIN_RANGE.max,
|
|
731
|
+
Math.max(LOUDNESS_GAIN_RANGE.min, ops.loudness - measured)
|
|
732
|
+
);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
return { integratedLufs, loudnessGainDb, breaths };
|
|
736
|
+
}
|
|
737
|
+
async function renderMediaFx(options) {
|
|
738
|
+
assertRate(options.source);
|
|
739
|
+
const ops = opValues(options.fx);
|
|
740
|
+
const needsAnalysis = ops.debreath != null || ops.loudness != null;
|
|
741
|
+
const analysisShare = needsAnalysis && !options.analysis ? 0.5 : 0;
|
|
742
|
+
const analysis = options.analysis ?? (needsAnalysis ? await analyzeMediaFx({
|
|
743
|
+
...options,
|
|
744
|
+
onProgress: (f) => options.onProgress?.(f * analysisShare)
|
|
745
|
+
}) : { integratedLufs: null, loudnessGainDb: 0, breaths: [] });
|
|
746
|
+
const { source } = options;
|
|
747
|
+
const startSec = options.startSec ?? 0;
|
|
748
|
+
const gain = Math.pow(10, analysis.loudnessGainDb / 20);
|
|
749
|
+
const limiter = ops.loudness != null ? createLimiter({
|
|
750
|
+
sampleRate: source.sampleRate,
|
|
751
|
+
channels: source.channels,
|
|
752
|
+
ceilingDb: MEDIA_FX_TRUE_PEAK_CEILING_DB
|
|
753
|
+
}) : null;
|
|
754
|
+
let limiterSkip = limiter?.latency ?? 0;
|
|
755
|
+
const outputMeter = ops.loudness != null ? createLoudnessMeter(source.sampleRate, source.channels) : null;
|
|
756
|
+
let processed = 0;
|
|
757
|
+
let written = 0;
|
|
758
|
+
let seen = 0;
|
|
759
|
+
const total = source.frames ?? 0;
|
|
760
|
+
const emit = async (block) => {
|
|
761
|
+
const skip = Math.min(limiterSkip, block[0]?.length ?? 0);
|
|
762
|
+
limiterSkip -= skip;
|
|
763
|
+
const out = skip > 0 ? block.map((ch) => ch.subarray(skip)) : block;
|
|
764
|
+
if ((out[0]?.length ?? 0) === 0) return;
|
|
765
|
+
written += out[0].length;
|
|
766
|
+
outputMeter?.push(out);
|
|
767
|
+
await options.write(out);
|
|
768
|
+
};
|
|
769
|
+
for await (const block of frontStages(options, (n) => {
|
|
770
|
+
seen += n;
|
|
771
|
+
if (total > 0) {
|
|
772
|
+
options.onProgress?.(analysisShare + (1 - analysisShare) * Math.min(1, seen / total));
|
|
773
|
+
}
|
|
774
|
+
})) {
|
|
775
|
+
const blockStartSec = startSec + processed / source.sampleRate;
|
|
776
|
+
processed += block[0].length;
|
|
777
|
+
if (ops.debreath != null && analysis.breaths.length > 0) {
|
|
778
|
+
applyBreathEnvelope(block, source.sampleRate, blockStartSec, analysis.breaths, ops.debreath);
|
|
779
|
+
}
|
|
780
|
+
if (gain !== 1) for (const ch of block) for (let i = 0; i < ch.length; i++) ch[i] *= gain;
|
|
781
|
+
await emit(limiter ? limiter.process(block) : block);
|
|
782
|
+
}
|
|
783
|
+
if (limiter) await emit(limiter.flush());
|
|
784
|
+
return {
|
|
785
|
+
analysis,
|
|
786
|
+
frames: written,
|
|
787
|
+
ignoredOps: ignoredOps(options.fx, options.denoiser != null),
|
|
788
|
+
limiterMinGain: limiter?.minGain() ?? 1,
|
|
789
|
+
outputLufs: outputMeter ? finiteOrNull(outputMeter.integrated()) : null
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
function finiteOrNull(value) {
|
|
793
|
+
return Number.isFinite(value) ? value : null;
|
|
794
|
+
}
|
|
795
|
+
var MEDIA_FX_LOUDNESS_TOLERANCE = 0.5;
|
|
796
|
+
function correctedLoudnessAnalysis(fx, result) {
|
|
797
|
+
const target = opValues(fx).loudness;
|
|
798
|
+
if (target == null || result.outputLufs == null) return null;
|
|
799
|
+
const miss = target - result.outputLufs;
|
|
800
|
+
if (Math.abs(miss) <= MEDIA_FX_LOUDNESS_TOLERANCE) return null;
|
|
801
|
+
const loudnessGainDb = Math.min(
|
|
802
|
+
LOUDNESS_GAIN_RANGE.max,
|
|
803
|
+
Math.max(LOUDNESS_GAIN_RANGE.min, result.analysis.loudnessGainDb + miss)
|
|
804
|
+
);
|
|
805
|
+
if (loudnessGainDb === result.analysis.loudnessGainDb) return null;
|
|
806
|
+
return { ...result.analysis, loudnessGainDb };
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// src/mediaEdit/dsp/resample.ts
|
|
810
|
+
var TABLE_RESOLUTION = 512;
|
|
811
|
+
function createResampler(inRate, outRate, halfTaps = 16) {
|
|
812
|
+
const ratio = inRate / outRate;
|
|
813
|
+
const cutoff = Math.min(1, outRate / inRate);
|
|
814
|
+
const half = Math.ceil(halfTaps / cutoff);
|
|
815
|
+
const table = new Float32Array(half * TABLE_RESOLUTION + 2);
|
|
816
|
+
for (let i = 0; i < table.length; i++) {
|
|
817
|
+
const x = i / TABLE_RESOLUTION;
|
|
818
|
+
const sinc = x === 0 ? 1 : Math.sin(Math.PI * cutoff * x) / (Math.PI * cutoff * x);
|
|
819
|
+
const w = x >= half ? 0 : 0.42 + 0.5 * Math.cos(Math.PI * x / half) + 0.08 * Math.cos(2 * Math.PI * x / half);
|
|
820
|
+
table[i] = cutoff * sinc * w;
|
|
821
|
+
}
|
|
822
|
+
const kernel = (distance) => {
|
|
823
|
+
const pos = Math.abs(distance) * TABLE_RESOLUTION;
|
|
824
|
+
const i = Math.floor(pos);
|
|
825
|
+
if (i + 1 >= table.length) return 0;
|
|
826
|
+
const frac = pos - i;
|
|
827
|
+
return table[i] + (table[i + 1] - table[i]) * frac;
|
|
828
|
+
};
|
|
829
|
+
let buffer = new Float32Array(0);
|
|
830
|
+
let bufferStart = 0;
|
|
831
|
+
let totalIn = 0;
|
|
832
|
+
let nextOut = 0;
|
|
833
|
+
let flushed = false;
|
|
834
|
+
const produce = (final) => {
|
|
835
|
+
const out = [];
|
|
836
|
+
const limit = final ? Math.ceil(totalIn * outRate / inRate) : Number.POSITIVE_INFINITY;
|
|
837
|
+
for (; ; ) {
|
|
838
|
+
if (nextOut >= limit) break;
|
|
839
|
+
const p = nextOut * ratio;
|
|
840
|
+
const center = Math.floor(p);
|
|
841
|
+
if (!final && center + half >= totalIn) break;
|
|
842
|
+
let acc = 0;
|
|
843
|
+
for (let k = center - half + 1; k <= center + half; k++) {
|
|
844
|
+
const idx = k - bufferStart;
|
|
845
|
+
if (idx < 0 || idx >= buffer.length) continue;
|
|
846
|
+
acc += buffer[idx] * kernel(p - k);
|
|
847
|
+
}
|
|
848
|
+
out.push(acc);
|
|
849
|
+
nextOut++;
|
|
850
|
+
}
|
|
851
|
+
const keepFrom = Math.floor(nextOut * ratio) - half;
|
|
852
|
+
const drop = keepFrom - bufferStart;
|
|
853
|
+
if (drop > 0) {
|
|
854
|
+
const count = Math.min(drop, buffer.length);
|
|
855
|
+
buffer = buffer.slice(count);
|
|
856
|
+
bufferStart += count;
|
|
857
|
+
}
|
|
858
|
+
return Float32Array.from(out);
|
|
859
|
+
};
|
|
860
|
+
if (inRate === outRate) {
|
|
861
|
+
return {
|
|
862
|
+
inRate,
|
|
863
|
+
outRate,
|
|
864
|
+
process: (input) => input.slice(),
|
|
865
|
+
flush: () => new Float32Array(0)
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
return {
|
|
869
|
+
inRate,
|
|
870
|
+
outRate,
|
|
871
|
+
process(input) {
|
|
872
|
+
const merged = new Float32Array(buffer.length + input.length);
|
|
873
|
+
merged.set(buffer, 0);
|
|
874
|
+
merged.set(input, buffer.length);
|
|
875
|
+
buffer = merged;
|
|
876
|
+
totalIn += input.length;
|
|
877
|
+
return produce(false);
|
|
878
|
+
},
|
|
879
|
+
flush() {
|
|
880
|
+
if (flushed) return new Float32Array(0);
|
|
881
|
+
flushed = true;
|
|
882
|
+
return produce(true);
|
|
883
|
+
}
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// src/mediaEdit/dsp/pauses.ts
|
|
888
|
+
var PAUSE_TIGHTEN_DEFAULTS = Object.freeze({
|
|
889
|
+
maxPauseSec: 0.8,
|
|
890
|
+
keepSec: 0.4,
|
|
891
|
+
minCutSec: 0.25
|
|
892
|
+
});
|
|
893
|
+
var HOP_SEC2 = 0.01;
|
|
894
|
+
var SILENT_DB2 = -120;
|
|
895
|
+
function percentile2(values, p) {
|
|
896
|
+
if (values.length === 0) return SILENT_DB2;
|
|
897
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
898
|
+
return sorted[Math.min(sorted.length - 1, Math.floor(p * (sorted.length - 1)))];
|
|
899
|
+
}
|
|
900
|
+
function detectPauseCuts(frames, options = {}) {
|
|
901
|
+
if (frames.length === 0) return [];
|
|
902
|
+
const maxPause = options.maxPauseSec ?? PAUSE_TIGHTEN_DEFAULTS.maxPauseSec;
|
|
903
|
+
const keep = Math.min(options.keepSec ?? PAUSE_TIGHTEN_DEFAULTS.keepSec, maxPause);
|
|
904
|
+
const minCut = options.minCutSec ?? PAUSE_TIGHTEN_DEFAULTS.minCutSec;
|
|
905
|
+
const levels = frames.map((f) => f.rmsDb);
|
|
906
|
+
const floor = percentile2(levels, 0.1);
|
|
907
|
+
const voiced = frames.filter((f) => f.voicing >= 0.6).map((f) => f.rmsDb);
|
|
908
|
+
const speech = percentile2(voiced.length > 0 ? voiced : levels, 0.9);
|
|
909
|
+
if (speech - floor < 12) return [];
|
|
910
|
+
const quietCeiling = Math.min(floor + 10, speech - 20);
|
|
911
|
+
const isQuiet = (f) => f.voicing < 0.5 && f.rmsDb < quietCeiling;
|
|
912
|
+
const cuts = [];
|
|
913
|
+
let runStart = -1;
|
|
914
|
+
const closeRun = (endIndex) => {
|
|
915
|
+
if (runStart < 0) return;
|
|
916
|
+
const start = frames[runStart].t - HOP_SEC2 / 2;
|
|
917
|
+
const end = frames[endIndex].t + HOP_SEC2 / 2;
|
|
918
|
+
runStart = -1;
|
|
919
|
+
if (end - start <= maxPause) return;
|
|
920
|
+
const cutStart = start + keep / 2;
|
|
921
|
+
const cutEnd = end - keep / 2;
|
|
922
|
+
if (cutEnd - cutStart >= minCut) cuts.push({ start: cutStart, end: cutEnd });
|
|
923
|
+
};
|
|
924
|
+
for (let i = 0; i < frames.length; i++) {
|
|
925
|
+
if (isQuiet(frames[i])) {
|
|
926
|
+
if (runStart < 0) runStart = i;
|
|
927
|
+
} else {
|
|
928
|
+
closeRun(i - 1);
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
closeRun(frames.length - 1);
|
|
932
|
+
return cuts;
|
|
933
|
+
}
|
|
934
|
+
export {
|
|
935
|
+
MEDIA_EDIT_PARAMS,
|
|
936
|
+
MEDIA_FX_ENGINE,
|
|
937
|
+
MEDIA_FX_LOUDNESS_TOLERANCE,
|
|
938
|
+
MEDIA_FX_ORDER,
|
|
939
|
+
MEDIA_FX_SAMPLE_RATE,
|
|
940
|
+
MEDIA_FX_SPECS,
|
|
941
|
+
MEDIA_FX_TRUE_PEAK_CEILING_DB,
|
|
942
|
+
MEDIA_RENDER_DIR,
|
|
943
|
+
MEDIA_RENDER_GC_AGE_DAYS,
|
|
944
|
+
MEDIA_RENDER_MANIFEST_VERSION,
|
|
945
|
+
PAUSE_TIGHTEN_DEFAULTS,
|
|
946
|
+
analyzeMediaFx,
|
|
947
|
+
applyBreathEnvelope,
|
|
948
|
+
breathGainAt,
|
|
949
|
+
buildMediaRenderIndex,
|
|
950
|
+
collectDocMediaClips,
|
|
951
|
+
correctedLoudnessAnalysis,
|
|
952
|
+
createBreathAnalyzer,
|
|
953
|
+
createDenoiseStage,
|
|
954
|
+
createLimiter,
|
|
955
|
+
createLoudnessMeter,
|
|
956
|
+
createMediaTimeMap,
|
|
957
|
+
createResampler,
|
|
958
|
+
detectBreaths,
|
|
959
|
+
detectPauseCuts,
|
|
960
|
+
formatMediaNumber,
|
|
961
|
+
hasRenderableFx,
|
|
962
|
+
integratedLoudnessFromSteps,
|
|
963
|
+
isMediaRenderPath,
|
|
964
|
+
mediaClipRenderKey,
|
|
965
|
+
mediaEditHtmlAttribute,
|
|
966
|
+
mediaEditValuesFromAttributes,
|
|
967
|
+
mediaEditValuesFromParams,
|
|
968
|
+
mediaRenderFilePath,
|
|
969
|
+
mediaRenderKey,
|
|
970
|
+
mediaRenderKeyFor,
|
|
971
|
+
mediaRenderManifestPath,
|
|
972
|
+
mediaRenderStaleness,
|
|
973
|
+
mediaRenderStem,
|
|
974
|
+
normalizeMediaCrop,
|
|
975
|
+
normalizeMediaCuts,
|
|
976
|
+
parseMediaCrop,
|
|
977
|
+
parseMediaCuts,
|
|
978
|
+
parseMediaEdits,
|
|
979
|
+
parseMediaFx,
|
|
980
|
+
parseMediaRenderManifest,
|
|
981
|
+
parseMediaRenderPath,
|
|
982
|
+
referencedMediaRenderKeys,
|
|
983
|
+
renderMediaFx,
|
|
984
|
+
selectMediaRendersForGc,
|
|
985
|
+
serializeMediaCrop,
|
|
986
|
+
serializeMediaCuts,
|
|
987
|
+
serializeMediaEdits,
|
|
988
|
+
serializeMediaFx,
|
|
989
|
+
serializeMediaRenderManifest
|
|
990
|
+
};
|