Initial Commit
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
describe("exports", () => {
|
||||
it("every feature module has a subpath export entry", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(__dirname, "..", "package.json"), "utf8")
|
||||
) as { exports: Record<string, unknown> };
|
||||
const required = [
|
||||
".",
|
||||
"./meter/loudness-meter",
|
||||
"./algorithms/lufs",
|
||||
"./algorithms/true-peak",
|
||||
"./algorithms/loudness-range",
|
||||
"./filters/k-weighting",
|
||||
"./error-codes",
|
||||
];
|
||||
for (const sub of required) expect(sub in pkg.exports).toBe(true);
|
||||
});
|
||||
|
||||
it("root barrel re-exports the documented surface", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const mod = await import("../src/index.js");
|
||||
const exports = Object.keys(mod);
|
||||
const required = [
|
||||
"LoudnessMeter",
|
||||
"LoudnessMeterOptionsSchema",
|
||||
"AudioAnalysisLoudnessError",
|
||||
"AUDIO_ANALYSIS_LOUDNESS_CODES",
|
||||
"AUDIO_ANALYSIS_LOUDNESS_ERROR_METADATA",
|
||||
"registerAudioAnalysisLoudnessErrorCodes",
|
||||
"buildKWeighting",
|
||||
"createKWeighting",
|
||||
"truePeakDbtp",
|
||||
"samplePeakLinear",
|
||||
"loudnessRange",
|
||||
"momentaryLufs",
|
||||
"shortTermLufs",
|
||||
"IntegratedLoudness",
|
||||
"energyToLufs",
|
||||
"channelWeight",
|
||||
"blockMeanSquare",
|
||||
"combineChannelEnergies",
|
||||
"percentile",
|
||||
];
|
||||
for (const sym of required) expect(exports).toContain(sym);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { AudioDocument } from "@suaveplan/audio-core";
|
||||
|
||||
const PAGE_SIZE = 1024;
|
||||
|
||||
/**
|
||||
* Build an `AudioDocument` from pre-computed channel data. Splits each
|
||||
* channel into pages of `PAGE_SIZE` samples.
|
||||
*/
|
||||
export function buildDoc(
|
||||
channels: Float32Array[],
|
||||
sampleRate: number
|
||||
): AudioDocument {
|
||||
const sourceLength = channels[0]?.length ?? 0;
|
||||
const pages: { channelData: Float32Array[]; startSample: number }[] = [];
|
||||
for (let start = 0; start < sourceLength; start += PAGE_SIZE) {
|
||||
const len = Math.min(PAGE_SIZE, sourceLength - start);
|
||||
const pageData: Float32Array[] = channels.map((ch) =>
|
||||
ch.subarray(start, start + len)
|
||||
);
|
||||
pages.push({ channelData: pageData, startSample: start });
|
||||
}
|
||||
return new AudioDocument({
|
||||
sampleRate,
|
||||
channels: channels.length,
|
||||
sourceLength,
|
||||
pages,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a sine-wave channel buffer at the given amplitude, frequency, and
|
||||
* duration. Amplitude is linear (1.0 = 0 dBFS).
|
||||
*/
|
||||
export function sine(
|
||||
amplitude: number,
|
||||
frequencyHz: number,
|
||||
sampleRate: number,
|
||||
durationSeconds: number
|
||||
): Float32Array {
|
||||
const n = Math.round(durationSeconds * sampleRate);
|
||||
const out = new Float32Array(n);
|
||||
const w = (2 * Math.PI * frequencyHz) / sampleRate;
|
||||
for (let i = 0; i < n; i++) out[i] = amplitude * Math.sin(w * i);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Constant-DC channel. */
|
||||
export function constant(value: number, n: number): Float32Array {
|
||||
const out = new Float32Array(n);
|
||||
out.fill(value);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Concatenate channel segments. */
|
||||
export function concat(...segments: Float32Array[]): Float32Array {
|
||||
let total = 0;
|
||||
for (const s of segments) total += s.length;
|
||||
const out = new Float32Array(total);
|
||||
let p = 0;
|
||||
for (const s of segments) {
|
||||
out.set(s, p);
|
||||
p += s.length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { LoudnessMeter } from "../src/index.js";
|
||||
import { buildDoc, sine } from "./helpers/build-doc.js";
|
||||
|
||||
describe("LoudnessMeter integration", () => {
|
||||
it("full round-trip on a 48 kHz stereo 5 s fixture", {
|
||||
timeout: 60000,
|
||||
}, async () => {
|
||||
const sr = 48000;
|
||||
const left = sine(0.5, 1000, sr, 5);
|
||||
const right = sine(0.5, 1000, sr, 5);
|
||||
const doc = buildDoc([left, right], sr);
|
||||
const result = await new LoudnessMeter().analyze(doc);
|
||||
expect(result.op).toBe("loudness-meter");
|
||||
expect(result.sampleRate).toBe(sr);
|
||||
expect(result.dcOffset.length).toBe(2);
|
||||
expect(result.truePeak).toBeGreaterThanOrEqual(result.peak - 1e-3);
|
||||
});
|
||||
|
||||
it("44.1 kHz mono is handled with rate-adapted K-weighting", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const sr = 44100;
|
||||
const doc = buildDoc([sine(0.25, 1000, sr, 4)], sr);
|
||||
const r = await new LoudnessMeter().analyze(doc);
|
||||
expect(Number.isFinite(r.lufs)).toBe(true);
|
||||
expect(Number.isFinite(r.truePeak)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, it } from "@suaveplan/testing/runner";
|
||||
import fc from "fast-check";
|
||||
import { LoudnessMeter, samplePeakLinear, truePeakDbtp } from "../src/index.js";
|
||||
import { buildDoc, sine } from "./helpers/build-doc.js";
|
||||
|
||||
describe("LoudnessMeter properties", () => {
|
||||
it("LUFS is monotonic with gain on a sine", {
|
||||
timeout: 60000,
|
||||
}, async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
fc.double({ min: 0.05, max: 0.5, noNaN: true }),
|
||||
fc.double({ min: 0.51, max: 0.95, noNaN: true }),
|
||||
async (a, b) => {
|
||||
const sr = 48000;
|
||||
const dur = 3.5;
|
||||
const meter = new LoudnessMeter();
|
||||
const lo = await meter.analyze(
|
||||
buildDoc([sine(a, 1000, sr, dur)], sr)
|
||||
);
|
||||
const hi = await meter.analyze(
|
||||
buildDoc([sine(b, 1000, sr, dur)], sr)
|
||||
);
|
||||
return hi.lufs >= lo.lufs - 0.5;
|
||||
}
|
||||
),
|
||||
{ numRuns: 6 }
|
||||
);
|
||||
});
|
||||
|
||||
it("true peak ≥ sample peak for arbitrary blocks", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(fc.double({ min: -1, max: 1, noNaN: true }), {
|
||||
minLength: 32,
|
||||
maxLength: 4096,
|
||||
}),
|
||||
(arr) => {
|
||||
const ch = Float32Array.from(arr);
|
||||
const sp = samplePeakLinear([ch]);
|
||||
// Below Float32 quantisation, sample peak cannot be
|
||||
// round-trip compared in dB: skip such degenerate inputs.
|
||||
if (sp < 1e-6) return true;
|
||||
const spDb = 20 * Math.log10(sp);
|
||||
const tpDb = truePeakDbtp([ch]);
|
||||
return tpDb >= spDb - 0.1;
|
||||
}
|
||||
),
|
||||
{ numRuns: 25 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
const PKG_DIR = join(__dirname, "..");
|
||||
|
||||
describe("purity", () => {
|
||||
it("importing the package adds no Suaveplan-specific globals", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const banned = ["__suaveplan_audio_loudness__", "LoudnessMeter"];
|
||||
await import("../src/index.js");
|
||||
for (const k of banned)
|
||||
expect(Object.hasOwn(globalThis, k)).toBe(false);
|
||||
});
|
||||
|
||||
it("dependency set is limited to the declared workspace and zod", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(PKG_DIR, "package.json"), "utf8")
|
||||
);
|
||||
const allowed = new Set([
|
||||
"@suaveplan/audio-core",
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/opentelemetry",
|
||||
"@suaveplan/types",
|
||||
"@opentelemetry/api",
|
||||
"tslib",
|
||||
"zod",
|
||||
]);
|
||||
for (const dep of Object.keys(pkg.dependencies ?? {}))
|
||||
expect(allowed.has(dep)).toBe(true);
|
||||
});
|
||||
|
||||
it("source references no browser or Node globals", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Static-scan the source tree (dist is build output; not checked here).
|
||||
const srcGlobs = [
|
||||
"src/meter/loudness-meter.ts",
|
||||
"src/algorithms/lufs.ts",
|
||||
"src/algorithms/true-peak.ts",
|
||||
"src/algorithms/loudness-range.ts",
|
||||
"src/filters/k-weighting.ts",
|
||||
"src/error-codes/error-codes.ts",
|
||||
"src/index.ts",
|
||||
];
|
||||
// Only match identifier-use occurrences (e.g. `window.fetch`,
|
||||
// `document.body`, `process.env`, `Buffer.from`), not the
|
||||
// English-prose words "window" or "document" inside doc-comments
|
||||
// and identifiers like `sourceLength`.
|
||||
const banned = [
|
||||
/\bwindow\.\w/,
|
||||
/\bwindow\[/,
|
||||
/\bdocument\.\w/,
|
||||
/\bdocument\[/,
|
||||
/\bnavigator\.\w/,
|
||||
/\bAudioContext\b/,
|
||||
/\bAudioBuffer\b/,
|
||||
/\bAnalyserNode\b/,
|
||||
/\brequire\(["']node:fs["']\)/,
|
||||
/from\s+["']node:fs["']/,
|
||||
/from\s+["']node:path["']/,
|
||||
/\bprocess\.env/,
|
||||
/\bprocess\.argv/,
|
||||
/\bBuffer\.\w+/,
|
||||
];
|
||||
for (const rel of srcGlobs) {
|
||||
const src = readFileSync(join(PKG_DIR, rel), "utf8");
|
||||
for (const re of banned) expect(re.test(src)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { webcrypto } from "node:crypto";
|
||||
|
||||
type WritableGlobal = { crypto?: Crypto };
|
||||
const g = globalThis as WritableGlobal;
|
||||
if (g.crypto === undefined) {
|
||||
g.crypto = webcrypto as unknown as Crypto;
|
||||
}
|
||||
Reference in New Issue
Block a user