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,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`.

View File

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

View File

@@ -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.

View File

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

View File

@@ -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.

View File

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

View File

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

View File

@@ -0,0 +1 @@
export { AudioAnalyzerPlugin } from "./audio-analyzer.js";

View File

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

View File

@@ -0,0 +1,5 @@
export {
AudioAnalyzerPluginOptionsSchema,
AudioAnalyzerPresetDataSchema,
AudioAnalyzerPresetSchema,
} from "./schemas.js";

View File

@@ -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.

View File

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

View File

@@ -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"]
}

View File

@@ -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"
]
}

View File

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

View File

@@ -0,0 +1,3 @@
import { createTestConfig } from "@suaveplan/testing/config";
export default createTestConfig({ testTimeout: 30_000 });