Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -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);
});
});

View File

@@ -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;
}

View File

@@ -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);
});
});

View File

@@ -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 }
);
});
});

View File

@@ -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);
}
});
});

View File

@@ -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;
}