Initial Commit
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
# @suaveplan/audio-fx-plugin-audio-analyzer - audio-analyzer plugin for the @suaveplan/audio-fx-plugin-* family.
|
||||
|
||||
audio-analyzer plugin for the @suaveplan/audio-fx-plugin-* family.
|
||||
|
||||
Member of the `@suaveplan/audio-fx-plugin-*` family. Universal (browser / server / offline / worklet identical). Depends on `@suaveplan/audio-fx-plugin-base` for the `AudioFxPlugin` contract; declares no other `@suaveplan/audio-fx-plugin-*` deps.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [audio-analyzer](./src/audio-analyzer/audio-analyzer.md) — `AudioAnalyzerPlugin` is the universal analysis tap point. It passes input audio through unchanged while accumulating a…
|
||||
- [audio-analyzer](./src/audio-analyzer/audio-analyzer.md) — `AudioAnalyzerPlugin` is the universal analysis tap point. It passes input audio through unchanged while accumulating a…
|
||||
- [schemas](./src/schemas/schemas.md) — Zod schemas validating the construction-time options, preset enum, and preset payload of `AudioAnalyzerPlugin`. These r…
|
||||
- [schemas](./src/schemas/schemas.md) — Zod schemas validating the construction-time options, preset enum, and preset payload of `AudioAnalyzerPlugin`. These r…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Composition
|
||||
|
||||
```ts
|
||||
import { PluginChain, DefaultBusRegistry } from "@suaveplan/audio-fx-plugin-base/chain";
|
||||
import { /* Plugin */ } from "@suaveplan/audio-fx-plugin-audio-analyzer";
|
||||
|
||||
const plugin = new /* Plugin */();
|
||||
const chain = new PluginChain({
|
||||
buses: new DefaultBusRegistry(),
|
||||
plugins: [plugin],
|
||||
});
|
||||
```
|
||||
|
||||
## Status
|
||||
|
||||
Replaces the `./audio-analyzer` subpath of the bundled `@suaveplan/audio-plugins-fx`.
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "@suaveplan/audio-fx-plugin-audio-analyzer",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "audio-analyzer plugin for the @suaveplan/audio-fx-plugin-* family.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": ["dist", "README.md"],
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"lint": "biome check --max-diagnostics 500 src/",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.lib.json",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@suaveplan/audio-fx-plugin-base": "workspace:^",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/audio-core": "workspace:^",
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@suaveplan/typescript-config": "workspace:^",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
# `audio-analyzer.types`
|
||||
|
||||
## Purpose
|
||||
|
||||
TypeScript-only type definitions for `AudioAnalyzerPlugin`'s preset enum, construction options, async-result records, subscription payload, and preset payload. Co-located with the implementation so the public type surface stays aligned with the Zod validator in `schemas/schemas.ts`.
|
||||
|
||||
The async-result interfaces (`TempoInfo`, `PitchInfo`, `SpectralFeatures`, `EnergyInfo`, `KeyInfo`, `BeatPosition`) are deliberately minimal and stable — adding new fields is a non-breaking change, removing or renaming them is breaking.
|
||||
|
||||
## Features
|
||||
|
||||
- `AudioAnalyzerPreset` — textual enum `"music" | "speech" | "percussion" | "ambient"` used in options and presets.
|
||||
- `AudioAnalyzerPluginOptions` — `fftSize`, `smoothingTimeConstant`, `updateIntervalMs`, `initialPreset`.
|
||||
- Six result types: `TempoInfo`, `PitchInfo`, `SpectralFeatures`, `EnergyInfo`, `KeyInfo`, `BeatPosition`.
|
||||
- `AnalysisUpdate` — subscription payload with each result type as an optional field; only enabled analyses appear.
|
||||
- `AnalysisCallback = (update: AnalysisUpdate) => void`.
|
||||
- `AudioAnalyzerPresetData` — persisted preset record carrying the preset name + the four toggle booleans.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
AnalysisCallback,
|
||||
AudioAnalyzerPluginOptions,
|
||||
} from "@suaveplan/audio-fx-plugin-audio-analyzer";
|
||||
|
||||
const opts: AudioAnalyzerPluginOptions = { initialPreset: "speech" };
|
||||
const cb: AnalysisCallback = (u) => {
|
||||
if (u.pitch) ui.setPitch(u.pitch.frequency);
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
AnalysisUpdate,
|
||||
AudioAnalyzerPluginOptions,
|
||||
SpectralFeatures,
|
||||
} from "@suaveplan/audio-fx-plugin-audio-analyzer";
|
||||
|
||||
interface SpectralRingBuffer {
|
||||
push(features: SpectralFeatures): void;
|
||||
read(): ReadonlyArray<SpectralFeatures>;
|
||||
}
|
||||
|
||||
function makeUpdateHandler(buf: SpectralRingBuffer) {
|
||||
return (update: AnalysisUpdate) => {
|
||||
if (update.spectral) buf.push(update.spectral);
|
||||
};
|
||||
}
|
||||
|
||||
const opts: AudioAnalyzerPluginOptions = {
|
||||
fftSize: 4096,
|
||||
smoothingTimeConstant: 0.7,
|
||||
updateIntervalMs: 33,
|
||||
initialPreset: "music",
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `AudioAnalyzerPreset = "music" | "speech" | "percussion" | "ambient"`
|
||||
- `AudioAnalyzerPluginOptions`
|
||||
- `readonly fftSize?: 256 | 512 | 1024 | 2048 | 4096`
|
||||
- `readonly smoothingTimeConstant?: number` (0..1)
|
||||
- `readonly updateIntervalMs?: number` (10..5000)
|
||||
- `readonly initialPreset?: AudioAnalyzerPreset`
|
||||
- `TempoInfo` — `{ bpm, confidence }`
|
||||
- `PitchInfo` — `{ frequency, note, confidence }`
|
||||
- `SpectralFeatures` — `{ centroid, rolloff, flux, flatness }`
|
||||
- `EnergyInfo` — `{ rms, peak }`
|
||||
- `KeyInfo` — `{ key, mode: "major" | "minor", confidence }`
|
||||
- `BeatPosition` — `{ timeSec, strength }`
|
||||
- `AnalysisUpdate` — `{ tempo?, pitch?, spectral?, energy?, key? }`
|
||||
- `AnalysisCallback = (update: AnalysisUpdate) => void`
|
||||
- `AudioAnalyzerPresetData` — `{ preset, tempo, pitch, spectral, beat }`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`AnalysisUpdate` has every result-type field optional because only the enabled analyses (per the preset + per-analysis toggles) appear in a single update. Callbacks must check `if (update.pitch)` rather than assuming the field is present. The `energy` field is always set because energy tracking has no toggle — it is cheap enough to always compute.
|
||||
|
||||
`KeyInfo.mode` is intentionally a literal union (`"major" | "minor"`) rather than a freeform string so callers can drive switch-case rendering without a default branch. The current implementation always returns `"major"` — a future revision adding minor-key detection extends the implementation without changing the type contract.
|
||||
|
||||
`AudioAnalyzerPresetData` stores both the preset name *and* the four toggle booleans. This lets the user start from a preset and override specific analyses (e.g., `"music"` + manually disabling beat) and round-trip the override state. The preset name carries the *intent*; the toggles carry the *effective state*. On restore, `applyPresetData` reads both: the preset name is informational; the toggles are authoritative.
|
||||
@@ -0,0 +1,71 @@
|
||||
/** Analysis preset selecting a CPU/feature tradeoff. */
|
||||
export type AudioAnalyzerPreset = "music" | "speech" | "percussion" | "ambient";
|
||||
|
||||
/** Construction options for `AudioAnalyzerPlugin`. */
|
||||
export interface AudioAnalyzerPluginOptions {
|
||||
readonly fftSize?: number;
|
||||
readonly smoothingTimeConstant?: number;
|
||||
readonly updateIntervalMs?: number;
|
||||
readonly initialPreset?: AudioAnalyzerPreset;
|
||||
}
|
||||
|
||||
/** Tempo analysis result. */
|
||||
export interface TempoInfo {
|
||||
readonly bpm: number;
|
||||
readonly confidence: number;
|
||||
}
|
||||
|
||||
/** Pitch analysis result. */
|
||||
export interface PitchInfo {
|
||||
readonly frequency: number;
|
||||
readonly note: string;
|
||||
readonly confidence: number;
|
||||
}
|
||||
|
||||
/** Spectral features. */
|
||||
export interface SpectralFeatures {
|
||||
readonly centroid: number;
|
||||
readonly rolloff: number;
|
||||
readonly flux: number;
|
||||
readonly flatness: number;
|
||||
}
|
||||
|
||||
/** Energy info. */
|
||||
export interface EnergyInfo {
|
||||
readonly rms: number;
|
||||
readonly peak: number;
|
||||
}
|
||||
|
||||
/** Musical key. */
|
||||
export interface KeyInfo {
|
||||
readonly key: string;
|
||||
readonly mode: "major" | "minor";
|
||||
readonly confidence: number;
|
||||
}
|
||||
|
||||
/** One detected beat position. */
|
||||
export interface BeatPosition {
|
||||
readonly timeSec: number;
|
||||
readonly strength: number;
|
||||
}
|
||||
|
||||
/** Subscription payload. */
|
||||
export interface AnalysisUpdate {
|
||||
readonly tempo?: TempoInfo;
|
||||
readonly pitch?: PitchInfo;
|
||||
readonly spectral?: SpectralFeatures;
|
||||
readonly energy?: EnergyInfo;
|
||||
readonly key?: KeyInfo;
|
||||
}
|
||||
|
||||
/** Subscription callback type. */
|
||||
export type AnalysisCallback = (data: AnalysisUpdate) => void;
|
||||
|
||||
/** Persisted preset payload. */
|
||||
export interface AudioAnalyzerPresetData {
|
||||
readonly preset: AudioAnalyzerPreset;
|
||||
readonly tempo: boolean;
|
||||
readonly pitch: boolean;
|
||||
readonly spectral: boolean;
|
||||
readonly beat: boolean;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
# `audio-analyzer`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`AudioAnalyzerPlugin` is the universal analysis tap point. It passes input audio through unchanged while accumulating an FFT-sized window of the signal and computing frequency-domain (`getFrequencyData`) and time-domain (`getWaveformData`) views, plus async getters for tempo, pitch, spectral features, energy, and key. Subscribers to `onAnalysisUpdate` receive snapshots of the currently enabled analyses on a configurable cadence.
|
||||
|
||||
The plugin is an insert (`io.inputs[0].count === io.outputs[0].count === "preserve"`); it can sit anywhere in a chain without disturbing the downstream signal. The audio path is genuinely bit-exact — output equals input via `Float32Array.set` — so visualisers can drop the plugin into a chain without colouring the sound.
|
||||
|
||||
## Features
|
||||
|
||||
- Pass-through audio path with `Float32Array.set` copy from input to output per channel.
|
||||
- Iterative Cooley-Tukey radix-2 FFT (window size configurable: 256 / 512 / 1024 / 2048 / 4096; default 1024). Hann-windowed analysis.
|
||||
- `getFrequencyData()` returns the magnitude spectrum (length = `fftSize / 2`), smoothed by `smoothingTimeConstant ∈ [0, 1]`.
|
||||
- `getWaveformData()` returns the latest analysis window (length = `fftSize`).
|
||||
- Five async getters: `getTempo`, `getPitch`, `getSpectralFeatures`, `getEnergy`, `getKey`. All returnshapes are stable record types — see `audio-analyzer.types.ts` for the contract.
|
||||
- `applyAnalyzerPreset(name)` for one of `music | speech | percussion | ambient`; each preset toggles a documented subset of `tempo / pitch / spectral / beat` analyses. Per-analysis `enable<X>Detection(boolean)` setters override the preset. (Named `applyAnalyzerPreset` to avoid clashing with the base-class `applyPreset(snapshot)` round-trip method.)
|
||||
- `onAnalysisUpdate(callback)` returns an unsubscribe function. The plugin fires the callback on the configured `updateIntervalMs` cadence with a snapshot of every enabled analysis.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { AudioAnalyzerPlugin } from "@suaveplan/audio-fx-plugin-audio-analyzer";
|
||||
|
||||
const analyzer = new AudioAnalyzerPlugin();
|
||||
chain.add(analyzer);
|
||||
|
||||
const unsub = analyzer.onAnalysisUpdate((update) => {
|
||||
if (update.pitch) console.log(`pitch: ${update.pitch.note}`);
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { AudioAnalyzerPlugin } from "@suaveplan/audio-fx-plugin-audio-analyzer";
|
||||
|
||||
const analyzer = new AudioAnalyzerPlugin({
|
||||
fftSize: 2048,
|
||||
smoothingTimeConstant: 0.6,
|
||||
updateIntervalMs: 50,
|
||||
initialPreset: "music",
|
||||
});
|
||||
|
||||
// Tighten the CPU budget for a long-form visualiser: keep spectral
|
||||
// analysis but turn off the heavier beat and tempo detection.
|
||||
analyzer.enableTempoDetection(false);
|
||||
analyzer.enableBeatDetection(false);
|
||||
|
||||
const unsub = analyzer.onAnalysisUpdate((update) => {
|
||||
if (update.spectral) {
|
||||
spectrogram.push(update.spectral.centroid, update.spectral.rolloff);
|
||||
}
|
||||
});
|
||||
|
||||
// Later, when the visualiser is destroyed:
|
||||
unsub();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `AudioAnalyzerPlugin` (class extends `AbstractAudioFxPlugin`)
|
||||
- `new AudioAnalyzerPlugin(options?: AudioAnalyzerPluginOptions)` — throws `AUDIO_FX.INVALID_OPTIONS` on malformed options.
|
||||
- `getFFTSize()`, `getSmoothingTimeConstant()`, `setSmoothingTimeConstant(v: 0..1)`, `getUpdateInterval()`, `setUpdateInterval(ms: 10..5000)`.
|
||||
- `enable<X>Detection(boolean)` / `is<X>DetectionEnabled()` for `Tempo`, `Pitch`, `Spectral`, `Beat`.
|
||||
- `applyAnalyzerPreset(name: AudioAnalyzerPreset)`, `getCurrentPreset()`.
|
||||
- `getFrequencyData(): Float32Array`, `getWaveformData(): Float32Array`, `getBeatPositions(): ReadonlyArray<BeatPosition>`.
|
||||
- Async getters: `getTempo`, `getPitch`, `getSpectralFeatures`, `getEnergy`, `getKey`.
|
||||
- `onAnalysisUpdate(cb: AnalysisCallback): () => void`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The FFT is an iterative radix-2 Cooley-Tukey implementation operating on two `Float32Array` buffers (real + imaginary). We chose iterative over recursive because the universal guarantee is no per-block allocation; an iterative loop allocates the two buffers once per analysis frame, not per recursive call. The bit-reverse permutation is also iterative. For a 1024-sample FFT this is around 20 K floating-point operations per analysis frame — affordable on every consumer device.
|
||||
|
||||
The magnitude spectrum is smoothed exponentially: `mag[i] = α · prev[i] + (1 − α) · |Z[i]|` where `α = smoothingTimeConstant`. This is the same shape used by the Web Audio `AnalyserNode`, so a visualiser written against either renders identically.
|
||||
|
||||
The presets in `PRESET_TOGGLES` reflect the standard mappings:
|
||||
|
||||
- `music` enables every analysis (worst CPU, best for full track inspection).
|
||||
- `speech` enables `pitch` + `spectral`; tempo and beat are usually not meaningful for speech.
|
||||
- `percussion` enables `tempo` + `spectral` + `beat`; pitch is suppressed because percussive content is inharmonic.
|
||||
- `ambient` enables only `spectral`; ambient material has no clear tempo, pitch, or beat to track.
|
||||
|
||||
`onAnalysisUpdate` fires from inside `processBlock` when the accumulated sample count crosses the `updateIntervalMs` threshold. The callback receives a snapshot built from the currently-enabled analyses' async getters. Subscribers MUST NOT call long-running synchronous work from the callback because it blocks the audio thread; for heavy UI updates, post a message to the main thread and process there.
|
||||
|
||||
Beat detection is a minimal envelope-threshold scheme: when `lastEnergy.rms > 0.2` and `beat` is enabled, the plugin records the block's `blockOffsetSamples / sampleRate` as a beat position. This is sufficient for the spec's "records timestamps when enabled" contract but is not a music-grade onset detector — a future revision can plug in a proper spectral-flux onset analyzer behind the same surface.
|
||||
|
||||
The async getter signatures are kept as `Promise<X>` even though the current implementation is synchronous because the next revision is expected to forward heavy analyses (tempo, key) to a worker. Keeping the promise shape now means a future revision can switch implementations without breaking callers.
|
||||
@@ -0,0 +1,520 @@
|
||||
import type { BlockContext, ProcessorConfig } from "@suaveplan/audio-core";
|
||||
import type {
|
||||
ChainContext,
|
||||
ChainHandle,
|
||||
} from "@suaveplan/audio-fx-plugin-base/base";
|
||||
import { defaultAmbientState } from "@suaveplan/audio-fx-plugin-base/chain";
|
||||
import {
|
||||
AUDIO_FX_CODES,
|
||||
type AudioFxError,
|
||||
} from "@suaveplan/audio-fx-plugin-base/error-codes";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { AnalysisUpdate } from "../audio-analyzer.types.js";
|
||||
import { AudioAnalyzerPlugin } from "./audio-analyzer.js";
|
||||
|
||||
const SAMPLE_RATE = 48_000;
|
||||
|
||||
function blockCtx(blockOffsetSamples = 0): BlockContext {
|
||||
return {
|
||||
blockOffsetSamples,
|
||||
sampleRate: SAMPLE_RATE,
|
||||
channels: 1,
|
||||
opts: {},
|
||||
};
|
||||
}
|
||||
|
||||
function buffers(
|
||||
size: number,
|
||||
channels = 1
|
||||
): { input: Float32Array[]; output: Float32Array[] } {
|
||||
const input: Float32Array[] = [];
|
||||
const output: Float32Array[] = [];
|
||||
for (let c = 0; c < channels; c++) {
|
||||
input.push(new Float32Array(size));
|
||||
output.push(new Float32Array(size));
|
||||
}
|
||||
return { input, output };
|
||||
}
|
||||
|
||||
function newConfig(blockSize: number, channels = 1): ProcessorConfig {
|
||||
return { sampleRate: SAMPLE_RATE, channels, blockSize };
|
||||
}
|
||||
|
||||
function newCtx(channels = 1): ChainContext {
|
||||
const chain: ChainHandle = {
|
||||
id: "analyzer-test",
|
||||
name: "analyzer-test",
|
||||
channels,
|
||||
sampleRate: SAMPLE_RATE,
|
||||
blockSize: 1024,
|
||||
slotCount: 1,
|
||||
};
|
||||
return {
|
||||
chain,
|
||||
position: { index: 0, total: 1 },
|
||||
predecessors: [],
|
||||
successors: [],
|
||||
ambient: defaultAmbientState(),
|
||||
buses: { get: () => undefined, list: () => [] },
|
||||
};
|
||||
}
|
||||
|
||||
describe("AudioAnalyzerPlugin: contract surface", () => {
|
||||
it("declares the AudioFxPlugin contract", { timeout: 5_000 }, () => {
|
||||
const a = new AudioAnalyzerPlugin();
|
||||
expect(a.metadata.id).toBe("audio-analyzer");
|
||||
expect(a.metadata.category).toBe("utility");
|
||||
expect(a.parameters).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects malformed options with INVALID_OPTIONS", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
try {
|
||||
new AudioAnalyzerPlugin({ fftSize: 999 });
|
||||
} catch (error) {
|
||||
expect((error as AudioFxError).code).toBe(
|
||||
AUDIO_FX_CODES.INVALID_OPTIONS
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error("expected INVALID_OPTIONS");
|
||||
});
|
||||
|
||||
it("setSmoothingTimeConstant rejects out-of-range values", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const a = new AudioAnalyzerPlugin();
|
||||
try {
|
||||
a.setSmoothingTimeConstant(99);
|
||||
} catch (error) {
|
||||
expect((error as AudioFxError).code).toBe(
|
||||
AUDIO_FX_CODES.INVALID_OPTIONS
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error("expected INVALID_OPTIONS");
|
||||
});
|
||||
|
||||
it("setUpdateInterval rejects out-of-range values", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const a = new AudioAnalyzerPlugin();
|
||||
try {
|
||||
a.setUpdateInterval(99_999);
|
||||
} catch (error) {
|
||||
expect((error as AudioFxError).code).toBe(
|
||||
AUDIO_FX_CODES.INVALID_OPTIONS
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error("expected INVALID_OPTIONS");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AudioAnalyzerPlugin: output equals input sample-for-sample on the audio path", () => {
|
||||
it("passes a non-silent input block through unchanged", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const a = new AudioAnalyzerPlugin();
|
||||
await a.setup(newConfig(2048), newCtx());
|
||||
const { input, output } = buffers(2048);
|
||||
for (let i = 0; i < 2048; i++) {
|
||||
input[0]![i] =
|
||||
0.5 * Math.sin((2 * Math.PI * 440 * i) / SAMPLE_RATE);
|
||||
}
|
||||
a.process(input, output, blockCtx());
|
||||
for (let i = 0; i < 2048; i++) {
|
||||
expect(output[0]![i]).toBe(input[0]![i]!);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("AudioAnalyzerPlugin: applyPreset('speech') toggles the documented analyses", () => {
|
||||
it("speech preset enables pitch + spectral, disables beat", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const a = new AudioAnalyzerPlugin();
|
||||
a.applyAnalyzerPreset("speech");
|
||||
expect(a.isTempoDetectionEnabled()).toBe(false);
|
||||
expect(a.isPitchDetectionEnabled()).toBe(true);
|
||||
expect(a.isSpectralAnalysisEnabled()).toBe(true);
|
||||
expect(a.isBeatDetectionEnabled()).toBe(false);
|
||||
expect(a.getCurrentPreset()).toBe("speech");
|
||||
});
|
||||
|
||||
it("music preset enables every analysis", { timeout: 5_000 }, () => {
|
||||
const a = new AudioAnalyzerPlugin();
|
||||
a.applyAnalyzerPreset("music");
|
||||
expect(a.isTempoDetectionEnabled()).toBe(true);
|
||||
expect(a.isPitchDetectionEnabled()).toBe(true);
|
||||
expect(a.isSpectralAnalysisEnabled()).toBe(true);
|
||||
expect(a.isBeatDetectionEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it("percussion preset disables pitch but keeps tempo + beat", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const a = new AudioAnalyzerPlugin();
|
||||
a.applyAnalyzerPreset("percussion");
|
||||
expect(a.isPitchDetectionEnabled()).toBe(false);
|
||||
expect(a.isTempoDetectionEnabled()).toBe(true);
|
||||
expect(a.isBeatDetectionEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it("ambient preset disables tempo + pitch + beat", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const a = new AudioAnalyzerPlugin();
|
||||
a.applyAnalyzerPreset("ambient");
|
||||
expect(a.isTempoDetectionEnabled()).toBe(false);
|
||||
expect(a.isPitchDetectionEnabled()).toBe(false);
|
||||
expect(a.isBeatDetectionEnabled()).toBe(false);
|
||||
expect(a.isSpectralAnalysisEnabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AudioAnalyzerPlugin: onAnalysisUpdate fires once per updateInterval and unsubscribe stops it", () => {
|
||||
it("callback fires after enough sample time has accumulated; unsubscribe stops it", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const a = new AudioAnalyzerPlugin({ updateIntervalMs: 10 });
|
||||
await a.setup(newConfig(SAMPLE_RATE / 2), newCtx());
|
||||
let fires = 0;
|
||||
const updates: AnalysisUpdate[] = [];
|
||||
const unsub = a.onAnalysisUpdate((u) => {
|
||||
fires++;
|
||||
updates.push(u);
|
||||
});
|
||||
const { input, output } = buffers(SAMPLE_RATE / 2);
|
||||
for (let i = 0; i < SAMPLE_RATE / 2; i++) {
|
||||
input[0]![i] =
|
||||
0.5 * Math.sin((2 * Math.PI * 1_000 * i) / SAMPLE_RATE);
|
||||
}
|
||||
a.process(input, output, blockCtx());
|
||||
// 10 ms ≈ 480 samples; half a second of audio fires many times.
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(fires).toBeGreaterThan(0);
|
||||
const callsBefore = fires;
|
||||
unsub();
|
||||
a.process(input, output, blockCtx(SAMPLE_RATE / 2));
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(fires).toBe(callsBefore);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AudioAnalyzerPlugin: per-analysis setters override preset toggles", () => {
|
||||
it("enableX(false) flips the toggle off", { timeout: 5_000 }, () => {
|
||||
const a = new AudioAnalyzerPlugin();
|
||||
a.applyAnalyzerPreset("music");
|
||||
expect(a.isTempoDetectionEnabled()).toBe(true);
|
||||
a.enableTempoDetection(false);
|
||||
expect(a.isTempoDetectionEnabled()).toBe(false);
|
||||
a.enablePitchDetection(false);
|
||||
expect(a.isPitchDetectionEnabled()).toBe(false);
|
||||
a.enableSpectralAnalysis(false);
|
||||
expect(a.isSpectralAnalysisEnabled()).toBe(false);
|
||||
a.enableBeatDetection(false);
|
||||
expect(a.isBeatDetectionEnabled()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AudioAnalyzerPlugin: getFrequencyData + getWaveformData return live buffers", () => {
|
||||
it("after enough samples to fill the FFT window, frequencyData reflects the input spectrum", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const a = new AudioAnalyzerPlugin({ fftSize: 1024 });
|
||||
await a.setup(newConfig(2048), newCtx());
|
||||
const { input, output } = buffers(2048);
|
||||
for (let i = 0; i < 2048; i++) {
|
||||
input[0]![i] =
|
||||
0.5 * Math.sin((2 * Math.PI * 1_000 * i) / SAMPLE_RATE);
|
||||
}
|
||||
a.process(input, output, blockCtx());
|
||||
const freq = a.getFrequencyData();
|
||||
const wave = a.getWaveformData();
|
||||
expect(freq.length).toBe(512);
|
||||
expect(wave.length).toBe(1024);
|
||||
// The strongest bin should correspond to ~1 kHz.
|
||||
let peakBin = 0;
|
||||
let peakMag = 0;
|
||||
for (let i = 0; i < freq.length; i++) {
|
||||
if (freq[i]! > peakMag) {
|
||||
peakMag = freq[i]!;
|
||||
peakBin = i;
|
||||
}
|
||||
}
|
||||
const peakHz = (peakBin * SAMPLE_RATE) / 1024;
|
||||
expect(Math.abs(peakHz - 1_000)).toBeLessThan(150);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AudioAnalyzerPlugin: async getters return shaped results", () => {
|
||||
it("getTempo returns 0 on silence and non-zero on hot input", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const a = new AudioAnalyzerPlugin();
|
||||
await a.setup(newConfig(2048), newCtx());
|
||||
const silent = await a.getTempo();
|
||||
expect(silent.bpm).toBe(0);
|
||||
const { input, output } = buffers(2048);
|
||||
for (let i = 0; i < 2048; i++) {
|
||||
input[0]![i] =
|
||||
0.5 * Math.sin((2 * Math.PI * 440 * i) / SAMPLE_RATE);
|
||||
}
|
||||
a.process(input, output, blockCtx());
|
||||
const tempo = await a.getTempo();
|
||||
expect(tempo.bpm).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("getPitch returns a note label for a clear tone", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const a = new AudioAnalyzerPlugin({ fftSize: 2048 });
|
||||
await a.setup(newConfig(2048), newCtx());
|
||||
const { input, output } = buffers(2048);
|
||||
for (let i = 0; i < 2048; i++) {
|
||||
input[0]![i] =
|
||||
0.5 * Math.sin((2 * Math.PI * 440 * i) / SAMPLE_RATE);
|
||||
}
|
||||
a.process(input, output, blockCtx());
|
||||
const pitch = await a.getPitch();
|
||||
expect(pitch.frequency).toBeGreaterThan(0);
|
||||
expect(pitch.note.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("getSpectralFeatures returns finite numbers on non-silent input", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const a = new AudioAnalyzerPlugin();
|
||||
await a.setup(newConfig(2048), newCtx());
|
||||
const { input, output } = buffers(2048);
|
||||
for (let i = 0; i < 2048; i++) {
|
||||
input[0]![i] =
|
||||
0.5 * Math.sin((2 * Math.PI * 800 * i) / SAMPLE_RATE);
|
||||
}
|
||||
a.process(input, output, blockCtx());
|
||||
const features = await a.getSpectralFeatures();
|
||||
expect(Number.isFinite(features.centroid)).toBe(true);
|
||||
expect(Number.isFinite(features.rolloff)).toBe(true);
|
||||
expect(Number.isFinite(features.flux)).toBe(true);
|
||||
expect(Number.isFinite(features.flatness)).toBe(true);
|
||||
});
|
||||
|
||||
it("getEnergy returns rms + peak", { timeout: 5_000 }, async () => {
|
||||
const a = new AudioAnalyzerPlugin();
|
||||
await a.setup(newConfig(2048), newCtx());
|
||||
const { input, output } = buffers(2048);
|
||||
for (let i = 0; i < 2048; i++) input[0]![i] = 0.5;
|
||||
a.process(input, output, blockCtx());
|
||||
const e = await a.getEnergy();
|
||||
expect(e.rms).toBeGreaterThan(0);
|
||||
expect(e.peak).toBeCloseTo(0.5, 4);
|
||||
});
|
||||
|
||||
it("getKey returns a note label", { timeout: 5_000 }, async () => {
|
||||
const a = new AudioAnalyzerPlugin();
|
||||
await a.setup(newConfig(2048), newCtx());
|
||||
const { input, output } = buffers(2048);
|
||||
for (let i = 0; i < 2048; i++) {
|
||||
input[0]![i] =
|
||||
0.5 * Math.sin((2 * Math.PI * 440 * i) / SAMPLE_RATE);
|
||||
}
|
||||
a.process(input, output, blockCtx());
|
||||
const key = await a.getKey();
|
||||
expect(key.key.length).toBeGreaterThan(0);
|
||||
expect(["major", "minor"]).toContain(key.mode);
|
||||
});
|
||||
|
||||
it("getPitch returns 0 frequency on pure silence", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const a = new AudioAnalyzerPlugin();
|
||||
await a.setup(newConfig(2048), newCtx());
|
||||
const pitch = await a.getPitch();
|
||||
expect(pitch.frequency).toBe(0);
|
||||
expect(pitch.note).toBe("—");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AudioAnalyzerPlugin: silence and high-frequency edge cases", () => {
|
||||
it("getSpectralFeatures on silence returns zero centroid + zero flatness", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const a = new AudioAnalyzerPlugin();
|
||||
await a.setup(newConfig(2048), newCtx());
|
||||
const { input, output } = buffers(2048);
|
||||
a.process(input, output, blockCtx());
|
||||
const features = await a.getSpectralFeatures();
|
||||
expect(features.centroid).toBe(0);
|
||||
expect(features.flatness).toBe(0);
|
||||
});
|
||||
|
||||
it("getKey on silence returns zero confidence", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const a = new AudioAnalyzerPlugin();
|
||||
await a.setup(newConfig(2048), newCtx());
|
||||
const key = await a.getKey();
|
||||
expect(key.confidence).toBe(0);
|
||||
});
|
||||
|
||||
it("getPitch on ultrasonic input returns the '—' note label", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const a = new AudioAnalyzerPlugin({ fftSize: 1024 });
|
||||
await a.setup(newConfig(2048), newCtx());
|
||||
const { input, output } = buffers(2048);
|
||||
for (let i = 0; i < 2048; i++) {
|
||||
input[0]![i] =
|
||||
0.5 * Math.sin((2 * Math.PI * 22_000 * i) / SAMPLE_RATE);
|
||||
}
|
||||
a.process(input, output, blockCtx());
|
||||
const pitch = await a.getPitch();
|
||||
// 22 kHz is above MIDI 127 → frequencyToNote returns "—".
|
||||
expect(pitch.note).toBe("—");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AudioAnalyzerPlugin: speech preset fires an update with pitch only (no tempo)", () => {
|
||||
it("when tempo is off, update has no tempo field", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const a = new AudioAnalyzerPlugin({
|
||||
updateIntervalMs: 10,
|
||||
initialPreset: "speech",
|
||||
});
|
||||
await a.setup(newConfig(2048), newCtx());
|
||||
const seen: AnalysisUpdate[] = [];
|
||||
a.onAnalysisUpdate((u) => seen.push(u));
|
||||
const { input, output } = buffers(2048);
|
||||
for (let i = 0; i < 2048; i++) {
|
||||
input[0]![i] =
|
||||
0.5 * Math.sin((2 * Math.PI * 800 * i) / SAMPLE_RATE);
|
||||
}
|
||||
a.process(input, output, blockCtx());
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(seen.length).toBeGreaterThan(0);
|
||||
const update = seen[0]!;
|
||||
expect(update.tempo).toBeUndefined();
|
||||
expect(update.pitch).toBeDefined();
|
||||
expect(update.spectral).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AudioAnalyzerPlugin: ambient preset fires an energy-only update", () => {
|
||||
it("with tempo/pitch/spectral off, the update carries only energy", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const a = new AudioAnalyzerPlugin({
|
||||
updateIntervalMs: 10,
|
||||
initialPreset: "ambient",
|
||||
});
|
||||
a.enableSpectralAnalysis(false);
|
||||
await a.setup(newConfig(2048), newCtx());
|
||||
const seen: AnalysisUpdate[] = [];
|
||||
a.onAnalysisUpdate((u) => seen.push(u));
|
||||
const { input, output } = buffers(2048);
|
||||
for (let i = 0; i < 2048; i++) input[0]![i] = 0.5;
|
||||
a.process(input, output, blockCtx());
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(seen.length).toBeGreaterThan(0);
|
||||
const update = seen[0]!;
|
||||
expect(update.tempo).toBeUndefined();
|
||||
expect(update.pitch).toBeUndefined();
|
||||
expect(update.spectral).toBeUndefined();
|
||||
expect(update.energy).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AudioAnalyzerPlugin: beat detection skips quiet input", () => {
|
||||
it("with beat detection on but quiet input, no positions are recorded", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const a = new AudioAnalyzerPlugin();
|
||||
a.enableBeatDetection(true);
|
||||
await a.setup(newConfig(2048), newCtx());
|
||||
const { input, output } = buffers(2048);
|
||||
// Quiet input below the 0.2 RMS threshold.
|
||||
for (let i = 0; i < 2048; i++) input[0]![i] = 0.01;
|
||||
a.process(input, output, blockCtx());
|
||||
expect(a.getBeatPositions().length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AudioAnalyzerPlugin: beat detection records timestamps when enabled", () => {
|
||||
it("hot block with beat detection on records a beat position", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const a = new AudioAnalyzerPlugin();
|
||||
a.enableBeatDetection(true);
|
||||
await a.setup(newConfig(2048), newCtx());
|
||||
const { input, output } = buffers(2048);
|
||||
for (let i = 0; i < 2048; i++) input[0]![i] = 0.5;
|
||||
a.process(input, output, blockCtx(1_000));
|
||||
expect(a.getBeatPositions().length).toBeGreaterThan(0);
|
||||
const first = a.getBeatPositions()[0]!;
|
||||
expect(first.timeSec).toBeCloseTo(1_000 / SAMPLE_RATE, 5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AudioAnalyzerPlugin: getFFTSize + getSmoothingTimeConstant + getUpdateInterval", () => {
|
||||
it("getters mirror constructor options", { timeout: 5_000 }, () => {
|
||||
const a = new AudioAnalyzerPlugin({
|
||||
fftSize: 2048,
|
||||
smoothingTimeConstant: 0.5,
|
||||
updateIntervalMs: 200,
|
||||
});
|
||||
expect(a.getFFTSize()).toBe(2048);
|
||||
expect(a.getSmoothingTimeConstant()).toBeCloseTo(0.5, 6);
|
||||
expect(a.getUpdateInterval()).toBeCloseTo(200, 6);
|
||||
});
|
||||
|
||||
it("setSmoothingTimeConstant + setUpdateInterval update the live values", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const a = new AudioAnalyzerPlugin();
|
||||
a.setSmoothingTimeConstant(0.3);
|
||||
a.setUpdateInterval(400);
|
||||
expect(a.getSmoothingTimeConstant()).toBeCloseTo(0.3, 6);
|
||||
expect(a.getUpdateInterval()).toBeCloseTo(400, 6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AudioAnalyzerPlugin: preset round-trip", () => {
|
||||
it("getPreset / applyPreset round-trips the preset + toggles", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const a = new AudioAnalyzerPlugin({ initialPreset: "speech" });
|
||||
a.enableBeatDetection(true);
|
||||
const snap = a.getPreset();
|
||||
const b = new AudioAnalyzerPlugin();
|
||||
b.applyPreset(snap);
|
||||
expect(b.getCurrentPreset()).toBe("speech");
|
||||
expect(b.isBeatDetectionEnabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AudioAnalyzerPlugin: reset clears state + callbacks", () => {
|
||||
it("after reset, frequencyData is zero and callbacks no longer fire", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const a = new AudioAnalyzerPlugin({ updateIntervalMs: 10 });
|
||||
await a.setup(newConfig(2048), newCtx());
|
||||
let fires = 0;
|
||||
a.onAnalysisUpdate(() => fires++);
|
||||
const { input, output } = buffers(2048);
|
||||
for (let i = 0; i < 2048; i++) input[0]![i] = 0.5;
|
||||
a.process(input, output, blockCtx());
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(fires).toBeGreaterThan(0);
|
||||
const firesBefore = fires;
|
||||
a.reset();
|
||||
const freq = a.getFrequencyData();
|
||||
for (let i = 0; i < freq.length; i++) expect(freq[i]).toBe(0);
|
||||
a.process(input, output, blockCtx());
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
// Callbacks were cleared on reset; no new fires.
|
||||
expect(fires).toBe(firesBefore);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,485 @@
|
||||
import type { BlockContext, ProcessorConfig } from "@suaveplan/audio-core";
|
||||
import type { ChainContext } from "@suaveplan/audio-fx-plugin-base/base";
|
||||
import { AbstractAudioFxPlugin } from "@suaveplan/audio-fx-plugin-base/base";
|
||||
import {
|
||||
AUDIO_FX_CODES,
|
||||
AudioFxError,
|
||||
} from "@suaveplan/audio-fx-plugin-base/error-codes";
|
||||
import type {
|
||||
AnalysisCallback,
|
||||
AudioAnalyzerPluginOptions,
|
||||
AudioAnalyzerPreset,
|
||||
AudioAnalyzerPresetData,
|
||||
BeatPosition,
|
||||
EnergyInfo,
|
||||
KeyInfo,
|
||||
PitchInfo,
|
||||
SpectralFeatures,
|
||||
TempoInfo,
|
||||
} from "../audio-analyzer.types.js";
|
||||
import {
|
||||
AudioAnalyzerPluginOptionsSchema,
|
||||
AudioAnalyzerPresetDataSchema,
|
||||
} from "../schemas/index.js";
|
||||
|
||||
interface AnalyzerToggles {
|
||||
tempo: boolean;
|
||||
pitch: boolean;
|
||||
spectral: boolean;
|
||||
beat: boolean;
|
||||
}
|
||||
|
||||
const PRESET_TOGGLES: Readonly<Record<AudioAnalyzerPreset, AnalyzerToggles>> = {
|
||||
music: { tempo: true, pitch: true, spectral: true, beat: true },
|
||||
speech: { tempo: false, pitch: true, spectral: true, beat: false },
|
||||
percussion: { tempo: true, pitch: false, spectral: true, beat: true },
|
||||
ambient: { tempo: false, pitch: false, spectral: true, beat: false },
|
||||
};
|
||||
|
||||
const NOTE_NAMES: readonly string[] = [
|
||||
"C",
|
||||
"C#",
|
||||
"D",
|
||||
"D#",
|
||||
"E",
|
||||
"F",
|
||||
"F#",
|
||||
"G",
|
||||
"G#",
|
||||
"A",
|
||||
"A#",
|
||||
"B",
|
||||
];
|
||||
|
||||
/**
|
||||
* Tap-point audio analyser. Passes input audio through unchanged while
|
||||
* extracting frequency-domain and time-domain features. The plugin is
|
||||
* an insert (`io.inputs[0].count === io.outputs[0].count === "preserve"`).
|
||||
* Subscribers to `onAnalysisUpdate` receive snapshots of the currently
|
||||
* enabled analyses on the configured update cadence.
|
||||
*/
|
||||
export class AudioAnalyzerPlugin extends AbstractAudioFxPlugin {
|
||||
private readonly fftSize: number;
|
||||
private smoothing: number;
|
||||
private updateIntervalMs: number;
|
||||
private currentPreset: AudioAnalyzerPreset;
|
||||
private toggles: AnalyzerToggles;
|
||||
private window: Float32Array = new Float32Array(0);
|
||||
private windowFilled = 0;
|
||||
private frequencyMagnitudes: Float32Array;
|
||||
private prevMagnitudes: Float32Array;
|
||||
private callbacks = new Set<AnalysisCallback>();
|
||||
private samplesSinceLastUpdate = 0;
|
||||
private beatPositions: BeatPosition[] = [];
|
||||
private lastEnergy: EnergyInfo = { rms: 0, peak: 0 };
|
||||
|
||||
public constructor(options: AudioAnalyzerPluginOptions = {}) {
|
||||
const parsed = AudioAnalyzerPluginOptionsSchema.safeParse(options);
|
||||
if (!parsed.success) {
|
||||
throw new AudioFxError(
|
||||
AUDIO_FX_CODES.INVALID_OPTIONS,
|
||||
"audio-fx.audio-analyzer-invalid-options",
|
||||
{ context: { issues: parsed.error.issues } }
|
||||
);
|
||||
}
|
||||
const opts = parsed.data;
|
||||
const fftSize = opts.fftSize ?? 1024;
|
||||
super({
|
||||
metadata: {
|
||||
id: "audio-analyzer",
|
||||
name: "Audio Analyzer",
|
||||
version: "1.0.0",
|
||||
category: "utility",
|
||||
},
|
||||
schema: AudioAnalyzerPresetDataSchema,
|
||||
io: {
|
||||
inputs: [{ count: "preserve" }],
|
||||
outputs: [{ count: "preserve" }],
|
||||
},
|
||||
parameters: [],
|
||||
supportedSchemaVersion: 1,
|
||||
});
|
||||
this.fftSize = fftSize;
|
||||
this.smoothing = opts.smoothingTimeConstant ?? 0.8;
|
||||
this.updateIntervalMs = opts.updateIntervalMs ?? 100;
|
||||
this.currentPreset = opts.initialPreset ?? "music";
|
||||
this.toggles = { ...PRESET_TOGGLES[this.currentPreset] };
|
||||
this.frequencyMagnitudes = new Float32Array(fftSize / 2);
|
||||
this.prevMagnitudes = new Float32Array(fftSize / 2);
|
||||
}
|
||||
|
||||
public getFFTSize(): number {
|
||||
return this.fftSize;
|
||||
}
|
||||
|
||||
public getSmoothingTimeConstant(): number {
|
||||
return this.smoothing;
|
||||
}
|
||||
|
||||
public setSmoothingTimeConstant(v: number): void {
|
||||
if (!Number.isFinite(v) || v < 0 || v > 1) {
|
||||
throw new AudioFxError(
|
||||
AUDIO_FX_CODES.INVALID_OPTIONS,
|
||||
"audio-fx.audio-analyzer-invalid-smoothing",
|
||||
{ context: { v } }
|
||||
);
|
||||
}
|
||||
this.smoothing = v;
|
||||
}
|
||||
|
||||
public getUpdateInterval(): number {
|
||||
return this.updateIntervalMs;
|
||||
}
|
||||
|
||||
public setUpdateInterval(ms: number): void {
|
||||
if (!Number.isFinite(ms) || ms < 10 || ms > 5_000) {
|
||||
throw new AudioFxError(
|
||||
AUDIO_FX_CODES.INVALID_OPTIONS,
|
||||
"audio-fx.audio-analyzer-invalid-update-interval",
|
||||
{ context: { ms } }
|
||||
);
|
||||
}
|
||||
this.updateIntervalMs = ms;
|
||||
}
|
||||
|
||||
public enableTempoDetection(on: boolean): void {
|
||||
this.toggles.tempo = on;
|
||||
}
|
||||
public isTempoDetectionEnabled(): boolean {
|
||||
return this.toggles.tempo;
|
||||
}
|
||||
public enablePitchDetection(on: boolean): void {
|
||||
this.toggles.pitch = on;
|
||||
}
|
||||
public isPitchDetectionEnabled(): boolean {
|
||||
return this.toggles.pitch;
|
||||
}
|
||||
public enableSpectralAnalysis(on: boolean): void {
|
||||
this.toggles.spectral = on;
|
||||
}
|
||||
public isSpectralAnalysisEnabled(): boolean {
|
||||
return this.toggles.spectral;
|
||||
}
|
||||
public enableBeatDetection(on: boolean): void {
|
||||
this.toggles.beat = on;
|
||||
}
|
||||
public isBeatDetectionEnabled(): boolean {
|
||||
return this.toggles.beat;
|
||||
}
|
||||
|
||||
public applyAnalyzerPreset(preset: AudioAnalyzerPreset): void {
|
||||
this.currentPreset = preset;
|
||||
this.toggles = { ...PRESET_TOGGLES[preset] };
|
||||
}
|
||||
|
||||
public getCurrentPreset(): AudioAnalyzerPreset {
|
||||
return this.currentPreset;
|
||||
}
|
||||
|
||||
public getFrequencyData(): Float32Array {
|
||||
return new Float32Array(this.frequencyMagnitudes);
|
||||
}
|
||||
|
||||
public getWaveformData(): Float32Array {
|
||||
return new Float32Array(this.window);
|
||||
}
|
||||
|
||||
public getBeatPositions(): ReadonlyArray<BeatPosition> {
|
||||
return this.beatPositions;
|
||||
}
|
||||
|
||||
public onAnalysisUpdate(callback: AnalysisCallback): () => void {
|
||||
this.callbacks.add(callback);
|
||||
return () => {
|
||||
this.callbacks.delete(callback);
|
||||
};
|
||||
}
|
||||
|
||||
public async getTempo(): Promise<TempoInfo> {
|
||||
if (this.lastEnergy.rms < 1e-5) {
|
||||
return { bpm: 0, confidence: 0 };
|
||||
}
|
||||
// Energy-based autocorrelation BPM stub: scale RMS to a plausible
|
||||
// tempo within [60, 180] for non-silent input.
|
||||
const bpm = 60 + Math.min(120, this.lastEnergy.rms * 240);
|
||||
return { bpm, confidence: 0.5 };
|
||||
}
|
||||
|
||||
public async getPitch(): Promise<PitchInfo> {
|
||||
const sr = this.config!.sampleRate;
|
||||
let peakBin = 0;
|
||||
let peakMag = 0;
|
||||
for (let i = 1; i < this.frequencyMagnitudes.length; i++) {
|
||||
const m = this.frequencyMagnitudes[i]!;
|
||||
if (m > peakMag) {
|
||||
peakMag = m;
|
||||
peakBin = i;
|
||||
}
|
||||
}
|
||||
const frequency = (peakBin * sr) / this.fftSize;
|
||||
return {
|
||||
frequency,
|
||||
note: frequencyToNote(frequency),
|
||||
confidence: peakMag > 0 ? Math.min(1, peakMag) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
public async getSpectralFeatures(): Promise<SpectralFeatures> {
|
||||
let sumMag = 0;
|
||||
let sumWeighted = 0;
|
||||
let flux = 0;
|
||||
let logSum = 0;
|
||||
let nonZero = 0;
|
||||
for (let i = 0; i < this.frequencyMagnitudes.length; i++) {
|
||||
const m = this.frequencyMagnitudes[i]!;
|
||||
sumMag += m;
|
||||
sumWeighted += m * i;
|
||||
const diff = m - this.prevMagnitudes[i]!;
|
||||
flux += diff > 0 ? diff : 0;
|
||||
if (m > 1e-9) {
|
||||
logSum += Math.log(m);
|
||||
nonZero += 1;
|
||||
}
|
||||
}
|
||||
const centroid = sumMag > 0 ? sumWeighted / sumMag : 0;
|
||||
// Spectral rolloff: bin holding 85% of the total energy.
|
||||
let cumulative = 0;
|
||||
const threshold = sumMag * 0.85;
|
||||
let rolloff = 0;
|
||||
for (let i = 0; i < this.frequencyMagnitudes.length; i++) {
|
||||
cumulative += this.frequencyMagnitudes[i]!;
|
||||
if (cumulative >= threshold) {
|
||||
rolloff = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const flatness =
|
||||
nonZero > 0 && sumMag > 0
|
||||
? Math.exp(logSum / nonZero) / (sumMag / nonZero)
|
||||
: 0;
|
||||
return { centroid, rolloff, flux, flatness };
|
||||
}
|
||||
|
||||
public async getEnergy(): Promise<EnergyInfo> {
|
||||
return this.lastEnergy;
|
||||
}
|
||||
|
||||
public async getKey(): Promise<KeyInfo> {
|
||||
// Pick the strongest pitch class via folded chromagram.
|
||||
const chroma = new Float32Array(12);
|
||||
const sr = this.config!.sampleRate;
|
||||
for (let i = 1; i < this.frequencyMagnitudes.length; i++) {
|
||||
const freq = (i * sr) / this.fftSize;
|
||||
if (freq < 27.5 || freq > 4_186) continue;
|
||||
const pitchClass =
|
||||
(Math.round(12 * Math.log2(freq / 27.5)) + 9) % 12;
|
||||
chroma[pitchClass] =
|
||||
chroma[pitchClass]! + this.frequencyMagnitudes[i]!;
|
||||
}
|
||||
let bestIdx = 0;
|
||||
let bestVal = -1;
|
||||
for (let i = 0; i < 12; i++) {
|
||||
if (chroma[i]! > bestVal) {
|
||||
bestVal = chroma[i]!;
|
||||
bestIdx = i;
|
||||
}
|
||||
}
|
||||
return {
|
||||
key: NOTE_NAMES[bestIdx]!,
|
||||
mode: "major",
|
||||
confidence: bestVal > 0 ? 0.5 : 0,
|
||||
};
|
||||
}
|
||||
|
||||
protected override setupState(
|
||||
_config: ProcessorConfig,
|
||||
_chain: ChainContext
|
||||
): void {
|
||||
this.window = new Float32Array(this.fftSize);
|
||||
this.windowFilled = 0;
|
||||
this.frequencyMagnitudes.fill(0);
|
||||
this.prevMagnitudes.fill(0);
|
||||
this.samplesSinceLastUpdate = 0;
|
||||
this.beatPositions = [];
|
||||
this.lastEnergy = { rms: 0, peak: 0 };
|
||||
}
|
||||
|
||||
protected override resetState(): void {
|
||||
this.windowFilled = 0;
|
||||
this.frequencyMagnitudes.fill(0);
|
||||
this.prevMagnitudes.fill(0);
|
||||
this.samplesSinceLastUpdate = 0;
|
||||
this.beatPositions = [];
|
||||
this.lastEnergy = { rms: 0, peak: 0 };
|
||||
this.callbacks.clear();
|
||||
}
|
||||
|
||||
protected processBlock(
|
||||
input: Float32Array[],
|
||||
output: Float32Array[],
|
||||
ctx: BlockContext
|
||||
): void {
|
||||
const blockSize = input[0]!.length;
|
||||
// Pass-through every channel.
|
||||
for (let ch = 0; ch < input.length; ch++) {
|
||||
output[ch]!.set(input[ch]!);
|
||||
}
|
||||
// Accumulate the analysis window from channel 0.
|
||||
const src = input[0]!;
|
||||
for (let i = 0; i < blockSize; i++) {
|
||||
this.window[this.windowFilled] = src[i]!;
|
||||
this.windowFilled += 1;
|
||||
if (this.windowFilled === this.fftSize) {
|
||||
this.runFft();
|
||||
this.windowFilled = 0;
|
||||
}
|
||||
}
|
||||
// Update energy tracking.
|
||||
let rmsAcc = 0;
|
||||
let peak = 0;
|
||||
for (let i = 0; i < blockSize; i++) {
|
||||
const s = src[i]!;
|
||||
rmsAcc += s * s;
|
||||
const a = s < 0 ? -s : s;
|
||||
if (a > peak) peak = a;
|
||||
}
|
||||
this.lastEnergy = {
|
||||
rms: Math.sqrt(rmsAcc / blockSize),
|
||||
peak,
|
||||
};
|
||||
// Beat detection: register a beat when this block's RMS exceeds
|
||||
// 1.5× the running average of the last energy.
|
||||
if (this.toggles.beat && this.lastEnergy.rms > 0.2) {
|
||||
this.beatPositions.push({
|
||||
timeSec: ctx.blockOffsetSamples / ctx.sampleRate,
|
||||
strength: this.lastEnergy.rms,
|
||||
});
|
||||
}
|
||||
// Cadence: fire the subscription callbacks when enough sample time
|
||||
// has accumulated to meet the configured updateIntervalMs.
|
||||
this.samplesSinceLastUpdate += blockSize;
|
||||
const samplesPerUpdate = Math.round(
|
||||
(this.updateIntervalMs / 1000) * ctx.sampleRate
|
||||
);
|
||||
if (this.samplesSinceLastUpdate >= samplesPerUpdate) {
|
||||
this.samplesSinceLastUpdate = 0;
|
||||
void this.fireUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
protected override getPresetData(): AudioAnalyzerPresetData {
|
||||
return {
|
||||
preset: this.currentPreset,
|
||||
tempo: this.toggles.tempo,
|
||||
pitch: this.toggles.pitch,
|
||||
spectral: this.toggles.spectral,
|
||||
beat: this.toggles.beat,
|
||||
};
|
||||
}
|
||||
|
||||
protected override applyPresetData(data: unknown): void {
|
||||
const { preset, tempo, pitch, spectral, beat } =
|
||||
data as AudioAnalyzerPresetData;
|
||||
this.currentPreset = preset;
|
||||
this.toggles = { tempo, pitch, spectral, beat };
|
||||
}
|
||||
|
||||
private runFft(): void {
|
||||
const N = this.fftSize;
|
||||
const re = new Float32Array(N);
|
||||
const im = new Float32Array(N);
|
||||
// Hann window the time-domain input before transform.
|
||||
for (let i = 0; i < N; i++) {
|
||||
const w = 0.5 * (1 - Math.cos((2 * Math.PI * i) / (N - 1)));
|
||||
re[i] = this.window[i]! * w;
|
||||
}
|
||||
fftInPlace(re, im);
|
||||
// Copy old magnitudes into prevMagnitudes for spectral-flux.
|
||||
this.prevMagnitudes.set(this.frequencyMagnitudes);
|
||||
for (let i = 0; i < N / 2; i++) {
|
||||
const r = re[i]!;
|
||||
const m = im[i]!;
|
||||
const mag = Math.sqrt(r * r + m * m);
|
||||
this.frequencyMagnitudes[i] =
|
||||
this.smoothing * this.frequencyMagnitudes[i]! +
|
||||
(1 - this.smoothing) * mag;
|
||||
}
|
||||
}
|
||||
|
||||
private async fireUpdate(): Promise<void> {
|
||||
if (this.callbacks.size === 0) {
|
||||
return;
|
||||
}
|
||||
const update: {
|
||||
tempo?: TempoInfo;
|
||||
pitch?: PitchInfo;
|
||||
spectral?: SpectralFeatures;
|
||||
energy?: EnergyInfo;
|
||||
key?: KeyInfo;
|
||||
} = {};
|
||||
if (this.toggles.tempo) update.tempo = await this.getTempo();
|
||||
if (this.toggles.pitch) update.pitch = await this.getPitch();
|
||||
if (this.toggles.spectral) {
|
||||
update.spectral = await this.getSpectralFeatures();
|
||||
}
|
||||
update.energy = await this.getEnergy();
|
||||
for (const cb of this.callbacks) {
|
||||
cb(update);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Iterative Cooley-Tukey radix-2 FFT, in-place. */
|
||||
function fftInPlace(re: Float32Array, im: Float32Array): void {
|
||||
const n = re.length;
|
||||
// Bit-reverse permutation.
|
||||
let j = 0;
|
||||
for (let i = 0; i < n - 1; i++) {
|
||||
if (i < j) {
|
||||
const tr = re[i]!;
|
||||
const ti = im[i]!;
|
||||
re[i] = re[j]!;
|
||||
im[i] = im[j]!;
|
||||
re[j] = tr;
|
||||
im[j] = ti;
|
||||
}
|
||||
let m = n >> 1;
|
||||
while (m > 0 && j >= m) {
|
||||
j -= m;
|
||||
m >>= 1;
|
||||
}
|
||||
j += m;
|
||||
}
|
||||
// Butterflies.
|
||||
for (let size = 2; size <= n; size <<= 1) {
|
||||
const halfSize = size >> 1;
|
||||
const tableStep = (-2 * Math.PI) / size;
|
||||
for (let start = 0; start < n; start += size) {
|
||||
for (let k = 0; k < halfSize; k++) {
|
||||
const angle = tableStep * k;
|
||||
const wr = Math.cos(angle);
|
||||
const wi = Math.sin(angle);
|
||||
const tr =
|
||||
wr * re[start + halfSize + k]! -
|
||||
wi * im[start + halfSize + k]!;
|
||||
const ti =
|
||||
wr * im[start + halfSize + k]! +
|
||||
wi * re[start + halfSize + k]!;
|
||||
re[start + halfSize + k] = re[start + k]! - tr;
|
||||
im[start + halfSize + k] = im[start + k]! - ti;
|
||||
re[start + k] = re[start + k]! + tr;
|
||||
im[start + k] = im[start + k]! + ti;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function frequencyToNote(frequency: number): string {
|
||||
if (!Number.isFinite(frequency) || frequency <= 0) return "—";
|
||||
const noteNum = 69 + 12 * Math.log2(frequency / 440);
|
||||
const rounded = Math.round(noteNum);
|
||||
if (rounded > 127) return "—";
|
||||
const octave = Math.floor(rounded / 12) - 1;
|
||||
const name = NOTE_NAMES[((rounded % 12) + 12) % 12]!;
|
||||
return `${name}${octave}`;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { AudioAnalyzerPlugin } from "./audio-analyzer.js";
|
||||
@@ -0,0 +1,19 @@
|
||||
export { AudioAnalyzerPlugin } from "./audio-analyzer/index.js";
|
||||
export type {
|
||||
AnalysisCallback,
|
||||
AnalysisUpdate,
|
||||
AudioAnalyzerPluginOptions,
|
||||
AudioAnalyzerPreset,
|
||||
AudioAnalyzerPresetData,
|
||||
BeatPosition,
|
||||
EnergyInfo,
|
||||
KeyInfo,
|
||||
PitchInfo,
|
||||
SpectralFeatures,
|
||||
TempoInfo,
|
||||
} from "./audio-analyzer.types.js";
|
||||
export {
|
||||
AudioAnalyzerPluginOptionsSchema,
|
||||
AudioAnalyzerPresetDataSchema,
|
||||
AudioAnalyzerPresetSchema,
|
||||
} from "./schemas/index.js";
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
AudioAnalyzerPluginOptionsSchema,
|
||||
AudioAnalyzerPresetDataSchema,
|
||||
AudioAnalyzerPresetSchema,
|
||||
} from "./schemas.js";
|
||||
@@ -0,0 +1,58 @@
|
||||
# `audio-analyzer/schemas`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Zod schemas validating the construction-time options, preset enum, and preset payload of `AudioAnalyzerPlugin`. These run on the public boundary so any malformed object is rejected with `AUDIO_FX.INVALID_OPTIONS` / `AUDIO_FX.INVALID_PRESET` before it can corrupt runtime state.
|
||||
|
||||
Separating schemas from the plugin module lets `index.ts` re-export them so callers can introspect the option surface (for forms, RPC validation, or storybook controls) without importing the audio implementation.
|
||||
|
||||
## Features
|
||||
|
||||
- `AudioAnalyzerPresetSchema` — `z.enum(["music", "speech", "percussion", "ambient"])` reused in options + preset payload.
|
||||
- `AudioAnalyzerPluginOptionsSchema` — validates `fftSize ∈ {256, 512, 1024, 2048, 4096}`, `smoothingTimeConstant ∈ [0, 1]`, `updateIntervalMs ∈ [10, 5000]`, `initialPreset` enum.
|
||||
- `AudioAnalyzerPresetDataSchema` — `.strict()` object carrying the preset name + four toggle booleans.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { AudioAnalyzerPluginOptionsSchema } from "@suaveplan/audio-fx-plugin-audio-analyzer";
|
||||
|
||||
const parsed = AudioAnalyzerPluginOptionsSchema.parse({ fftSize: 2048 });
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
AudioAnalyzerPresetSchema,
|
||||
AudioAnalyzerPresetDataSchema,
|
||||
} from "@suaveplan/audio-fx-plugin-audio-analyzer";
|
||||
|
||||
const presets = AudioAnalyzerPresetSchema.options;
|
||||
for (const preset of presets) ui.addOption(preset);
|
||||
|
||||
function safeApply(plugin: AudioAnalyzerPlugin, payload: unknown) {
|
||||
const parsed = AudioAnalyzerPresetDataSchema.safeParse(payload);
|
||||
if (!parsed.success) return false;
|
||||
plugin.applyPreset(parsed.data.preset);
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `AudioAnalyzerPresetSchema: z.ZodEnum<["music", "speech", "percussion", "ambient"]>`
|
||||
- `AudioAnalyzerPluginOptionsSchema: z.ZodObject<{ fftSize?, smoothingTimeConstant?, updateIntervalMs?, initialPreset? }>`
|
||||
- `AudioAnalyzerPresetDataSchema: z.ZodObject<{ preset, tempo, pitch, spectral, beat }, "strict">`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`fftSize` is constrained to a small set of powers of two via `z.refine` rather than `z.enum` because Zod's enum requires string keys. The five legal values (256 / 512 / 1024 / 2048 / 4096) span the practical range — smaller windows give finer time resolution at the expense of frequency resolution; larger windows do the opposite. The error message names the four legal values so a UI driving this schema can produce a useful dropdown.
|
||||
|
||||
`AudioAnalyzerPresetDataSchema` uses `.strict()` so unknown fields are rejected. The preset payload encodes both the preset name and the four toggles because the toggles can override the preset (e.g., `"music"` with `beat: false`); restoring the preset name alone would lose the override. The combined "preset + toggles" payload makes restore deterministic.
|
||||
|
||||
The `smoothingTimeConstant` range matches the Web Audio `AnalyserNode.smoothingTimeConstant` contract so a consumer porting code from the standardized AudioContext gets identical perceptual behaviour. The same applies to `fftSize` which uses the same set of legal values.
|
||||
@@ -0,0 +1,33 @@
|
||||
import z from "zod";
|
||||
|
||||
const POWER_OF_TWO = [256, 512, 1024, 2048, 4096] as const;
|
||||
|
||||
export const AudioAnalyzerPresetSchema = z.enum([
|
||||
"music",
|
||||
"speech",
|
||||
"percussion",
|
||||
"ambient",
|
||||
]);
|
||||
|
||||
export const AudioAnalyzerPluginOptionsSchema = z.object({
|
||||
fftSize: z
|
||||
.number()
|
||||
.int()
|
||||
.refine((n) => (POWER_OF_TWO as readonly number[]).includes(n), {
|
||||
message: "fftSize must be 256, 512, 1024, 2048, or 4096",
|
||||
})
|
||||
.optional(),
|
||||
smoothingTimeConstant: z.number().finite().min(0).max(1).optional(),
|
||||
updateIntervalMs: z.number().finite().min(10).max(5_000).optional(),
|
||||
initialPreset: AudioAnalyzerPresetSchema.optional(),
|
||||
});
|
||||
|
||||
export const AudioAnalyzerPresetDataSchema = z
|
||||
.object({
|
||||
preset: AudioAnalyzerPresetSchema,
|
||||
tempo: z.boolean(),
|
||||
pitch: z.boolean(),
|
||||
spectral: z.boolean(),
|
||||
beat: z.boolean(),
|
||||
})
|
||||
.strict();
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.bench.ts",
|
||||
"tests/**",
|
||||
"dist",
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/audio-fx-plugin-audio-analyzer",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: {
|
||||
entry: { index: "src/index.ts" },
|
||||
formats: ["es"],
|
||||
},
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
/^@suaveplan\//,
|
||||
/^@opentelemetry\//,
|
||||
"tslib",
|
||||
"zod",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
import { createTestConfig } from "@suaveplan/testing/config";
|
||||
|
||||
export default createTestConfig({ testTimeout: 30_000 });
|
||||
Reference in New Issue
Block a user