feat: added local separator implementation

This commit is contained in:
Xiaohan-Tian
2026-05-11 22:27:22 -07:00
parent 5b599aa4e7
commit d32dc35b6d
16 changed files with 2506 additions and 38 deletions
+31
View File
@@ -0,0 +1,31 @@
import type { LocalSeparatorModelConfig } from './localSeparatorTypes';
export const LOCAL_SEPARATOR_MODEL_URL =
'https://huggingface.co/notabilia/uvr5-models/resolve/main/UVR-MDX-NET-Inst_HQ_3.onnx';
export const LOCAL_SEPARATOR_MODEL_FILENAME = 'UVR-MDX-NET-Inst_HQ_3.onnx';
export const LOCAL_SEPARATOR_MODEL_CONFIG: LocalSeparatorModelConfig = {
filename: LOCAL_SEPARATOR_MODEL_FILENAME,
displayName: 'Vocal and Instrument (Medium Accuracy)',
status: 'ready',
defaults: {
sampleRate: 44100,
hopLength: 1024,
segmentSize: 256,
overlap: 0.25,
batchSize: 1,
enableDenoise: false,
invertUsingSpec: false,
normalizationThreshold: 0.9,
amplificationThreshold: 0,
matchMixOverlap: 0.02,
},
metadata: {
compensate: 1.021,
mdx_dim_f_set: 3072,
mdx_dim_t_set: 8,
mdx_n_fft_scale_set: 7680,
primary_stem: 'Instrumental',
},
};
+139
View File
@@ -0,0 +1,139 @@
import type { LocalSeparatorModelConfig, StereoChannels } from './localSeparatorTypes';
import { FFT, createWindowCache, getHannPeriodic, index4d, reflectPad } from './localSeparatorShared';
interface SpectrogramPayload {
data: Float32Array;
dims: number[];
frames?: number;
}
export class LocalSeparatorCpuDsp {
public readonly window: Float32Array;
public readonly dimF: number;
public readonly forwardFft: FFT;
private readonly nFft: number;
private readonly hopLength: number;
private readonly trim: number;
private readonly numFreqBins: number;
private readonly inverseFft: FFT;
constructor(config: LocalSeparatorModelConfig) {
this.nFft = config.metadata.mdx_n_fft_scale_set;
this.hopLength = config.defaults.hopLength;
this.dimF = config.metadata.mdx_dim_f_set;
this.trim = Math.floor(this.nFft / 2);
this.numFreqBins = Math.floor(this.nFft / 2) + 1;
const windowCache = createWindowCache();
this.window = getHannPeriodic(this.nFft, windowCache);
this.forwardFft = new FFT(this.nFft);
this.inverseFft = new FFT(this.nFft);
}
public async forwardStereo(leftChunk: Float32Array, rightChunk: Float32Array): Promise<SpectrogramPayload> {
const paddedLeft = reflectPad(leftChunk, this.trim, this.trim);
const paddedRight = reflectPad(rightChunk, this.trim, this.trim);
const frames = Math.floor((paddedLeft.length - this.nFft) / this.hopLength) + 1;
const tensor = new Float32Array(4 * this.dimF * frames);
const dims = [1, 4, this.dimF, frames];
for (let frameIndex = 0; frameIndex < frames; frameIndex += 1) {
const offset = frameIndex * this.hopLength;
const leftSpectrum = this.frameSpectrum(paddedLeft, offset);
const rightSpectrum = this.frameSpectrum(paddedRight, offset);
for (let freq = 0; freq < this.dimF; freq += 1) {
tensor[index4d(dims, 0, 0, freq, frameIndex)] = freq < 3 ? 0 : leftSpectrum.real[freq];
tensor[index4d(dims, 0, 1, freq, frameIndex)] = freq < 3 ? 0 : leftSpectrum.imag[freq];
tensor[index4d(dims, 0, 2, freq, frameIndex)] = freq < 3 ? 0 : rightSpectrum.real[freq];
tensor[index4d(dims, 0, 3, freq, frameIndex)] = freq < 3 ? 0 : rightSpectrum.imag[freq];
}
}
return { data: tensor, dims, frames };
}
public async inverseStereo(spectrogramPayload: SpectrogramPayload): Promise<StereoChannels> {
const spectrogram = spectrogramPayload.data;
const dims = spectrogramPayload.dims;
const [, channels, freqBins, frames] = dims;
if (channels !== 4) {
throw new Error(`Expected 4 channels in MDX spectrogram, got ${channels}`);
}
const outputLength = ((frames - 1) * this.hopLength) + this.nFft;
const left = new Float64Array(outputLength);
const right = new Float64Array(outputLength);
const leftWindowSums = new Float64Array(outputLength);
const rightWindowSums = new Float64Array(outputLength);
for (let frameIndex = 0; frameIndex < frames; frameIndex += 1) {
const leftFrame = this.istftFrame(spectrogram, dims, 0, 1, frameIndex, freqBins);
const rightFrame = this.istftFrame(spectrogram, dims, 2, 3, frameIndex, freqBins);
const frameOffset = frameIndex * this.hopLength;
for (let i = 0; i < this.nFft; i += 1) {
const weightedLeft = leftFrame[i] * this.window[i];
const weightedRight = rightFrame[i] * this.window[i];
left[frameOffset + i] += weightedLeft;
right[frameOffset + i] += weightedRight;
const weight = this.window[i] * this.window[i];
leftWindowSums[frameOffset + i] += weight;
rightWindowSums[frameOffset + i] += weight;
}
}
const normalizedLeft = new Float32Array(outputLength - (this.trim * 2));
const normalizedRight = new Float32Array(outputLength - (this.trim * 2));
for (let i = this.trim; i < outputLength - this.trim; i += 1) {
const outIndex = i - this.trim;
normalizedLeft[outIndex] = leftWindowSums[i] > 1e-8 ? left[i] / leftWindowSums[i] : 0;
normalizedRight[outIndex] = rightWindowSums[i] > 1e-8 ? right[i] / rightWindowSums[i] : 0;
}
return [normalizedLeft, normalizedRight];
}
public dispose(): void {}
private frameSpectrum(signal: Float32Array, offset: number): { real: Float64Array; imag: Float64Array } {
const real = new Float64Array(this.nFft);
const imag = new Float64Array(this.nFft);
for (let i = 0; i < this.nFft; i += 1) {
real[i] = signal[offset + i] * this.window[i];
}
this.forwardFft.transform(real, imag);
return { real, imag };
}
private istftFrame(
spectrogram: Float32Array,
dims: number[],
realChannel: number,
imagChannel: number,
frameIndex: number,
freqBins: number,
): Float32Array {
const real = new Float64Array(this.nFft);
const imag = new Float64Array(this.nFft);
for (let freq = 0; freq < freqBins; freq += 1) {
real[freq] = spectrogram[index4d(dims, 0, realChannel, freq, frameIndex)];
imag[freq] = spectrogram[index4d(dims, 0, imagChannel, freq, frameIndex)];
}
for (let freq = 1; freq < this.numFreqBins - 1; freq += 1) {
const mirrored = this.nFft - freq;
real[mirrored] = real[freq];
imag[mirrored] = -imag[freq];
}
this.inverseFft.inverse(real, imag);
const frame = new Float32Array(this.nFft);
for (let i = 0; i < this.nFft; i += 1) {
frame[i] = real[i];
}
return frame;
}
}
+220
View File
@@ -0,0 +1,220 @@
import type { LocalSeparatorModelConfig } from './localSeparatorTypes';
import { LocalSeparatorCpuDsp } from './localSeparatorCpuDsp';
import { reflectPad } from './localSeparatorShared';
type GPUDeviceLike = any;
type GPUBufferLike = any;
type GPUComputePipelineLike = any;
declare const GPUBufferUsage: any;
declare const GPUMapMode: any;
const FRAMING_SHADER = `
struct Params {
nfft: u32,
hop: u32,
frames: u32,
paddedLength: u32,
}
@group(0) @binding(0) var<storage, read> leftInput: array<f32>;
@group(0) @binding(1) var<storage, read> rightInput: array<f32>;
@group(0) @binding(2) var<storage, read> window: array<f32>;
@group(0) @binding(3) var<storage, read_write> output: array<f32>;
@group(0) @binding(4) var<uniform> params: Params;
@compute @workgroup_size(256)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
let index = gid.x;
let total = params.frames * params.nfft * 2u;
if (index >= total) {
return;
}
let sample = index % params.nfft;
let frame = (index / params.nfft) % params.frames;
let channel = index / (params.nfft * params.frames);
let sourceIndex = frame * params.hop + sample;
let sampleValue = select(leftInput[sourceIndex], rightInput[sourceIndex], channel == 1u);
output[index] = sampleValue * window[sample];
}
`;
function alignTo(value: number, alignment: number): number {
return Math.ceil(value / alignment) * alignment;
}
async function readBuffer(device: GPUDeviceLike, sourceBuffer: GPUBufferLike, size: number): Promise<Float32Array> {
const readBuffer = device.createBuffer({
size: alignTo(size, 4),
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
});
const encoder = device.createCommandEncoder();
encoder.copyBufferToBuffer(sourceBuffer, 0, readBuffer, 0, size);
device.queue.submit([encoder.finish()]);
await readBuffer.mapAsync(GPUMapMode.READ);
const copy = new Float32Array(readBuffer.getMappedRange().slice(0));
readBuffer.unmap();
readBuffer.destroy();
return copy;
}
export class LocalSeparatorGpuDsp {
private readonly device: GPUDeviceLike;
private readonly cpuDsp: LocalSeparatorCpuDsp;
private readonly nFft: number;
private readonly hopLength: number;
private readonly trim: number;
private windowBuffer: GPUBufferLike | null = null;
private paramBuffer: GPUBufferLike | null = null;
private readonly pipeline: GPUComputePipelineLike;
public static async create(config: LocalSeparatorModelConfig): Promise<LocalSeparatorGpuDsp> {
if (!('gpu' in navigator)) {
throw new Error('WebGPU is not available for GPU DSP.');
}
const adapter = await (navigator as { gpu?: { requestAdapter: (options: { powerPreference: string }) => Promise<any> } }).gpu?.requestAdapter({
powerPreference: 'high-performance',
});
if (!adapter) {
throw new Error('No WebGPU adapter was available for GPU DSP.');
}
const device = await adapter.requestDevice();
return new LocalSeparatorGpuDsp(config, device);
}
private constructor(config: LocalSeparatorModelConfig, device: GPUDeviceLike) {
this.device = device;
this.cpuDsp = new LocalSeparatorCpuDsp(config);
this.nFft = config.metadata.mdx_n_fft_scale_set;
this.hopLength = config.defaults.hopLength;
this.trim = Math.floor(this.nFft / 2);
this.pipeline = device.createComputePipeline({
layout: 'auto',
compute: {
module: device.createShaderModule({ code: FRAMING_SHADER }),
entryPoint: 'main',
},
});
}
public async forwardStereo(leftChunk: Float32Array, rightChunk: Float32Array): Promise<{
data: Float32Array;
dims: number[];
frames: number;
}> {
this.ensureStaticBuffers();
const paddedLeft = reflectPad(leftChunk, this.trim, this.trim);
const paddedRight = reflectPad(rightChunk, this.trim, this.trim);
const frames = Math.floor((paddedLeft.length - this.nFft) / this.hopLength) + 1;
const leftBuffer = this.device.createBuffer({
size: paddedLeft.byteLength,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
});
const rightBuffer = this.device.createBuffer({
size: paddedRight.byteLength,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
});
const framedSize = frames * this.nFft * 2 * 4;
const outputBuffer = this.device.createBuffer({
size: framedSize,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC,
});
this.device.queue.writeBuffer(leftBuffer, 0, paddedLeft);
this.device.queue.writeBuffer(rightBuffer, 0, paddedRight);
this.device.queue.writeBuffer(this.paramBuffer!, 0, new Uint32Array([this.nFft, this.hopLength, frames, paddedLeft.length]));
const bindGroup = this.device.createBindGroup({
layout: this.pipeline.getBindGroupLayout(0),
entries: [
{ binding: 0, resource: { buffer: leftBuffer } },
{ binding: 1, resource: { buffer: rightBuffer } },
{ binding: 2, resource: { buffer: this.windowBuffer! } },
{ binding: 3, resource: { buffer: outputBuffer } },
{ binding: 4, resource: { buffer: this.paramBuffer! } },
],
});
const encoder = this.device.createCommandEncoder();
const pass = encoder.beginComputePass();
pass.setPipeline(this.pipeline);
pass.setBindGroup(0, bindGroup);
pass.dispatchWorkgroups(Math.ceil((frames * this.nFft * 2) / 256));
pass.end();
this.device.queue.submit([encoder.finish()]);
const framed = await readBuffer(this.device, outputBuffer, framedSize);
leftBuffer.destroy();
rightBuffer.destroy();
outputBuffer.destroy();
return this.packFramedAudio(framed, frames);
}
public async inverseStereo(payload: { data: Float32Array; dims: number[]; frames?: number }) {
return this.cpuDsp.inverseStereo(payload);
}
public dispose(): void {
this.cpuDsp.dispose();
this.windowBuffer?.destroy();
this.paramBuffer?.destroy();
}
private ensureStaticBuffers(): void {
if (!this.windowBuffer) {
const window = this.cpuDsp.window;
this.windowBuffer = this.device.createBuffer({
size: window.byteLength,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
});
this.device.queue.writeBuffer(this.windowBuffer, 0, window);
}
if (!this.paramBuffer) {
this.paramBuffer = this.device.createBuffer({
size: 16,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
}
}
private packFramedAudio(framed: Float32Array, frames: number): { data: Float32Array; dims: number[]; frames: number } {
const tensor = new Float32Array(4 * this.cpuDsp.dimF * frames);
const dims = [1, 4, this.cpuDsp.dimF, frames];
for (let frameIndex = 0; frameIndex < frames; frameIndex += 1) {
const leftOffset = frameIndex * this.nFft;
const rightOffset = (frames * this.nFft) + leftOffset;
const leftSpectrum = this.fftFrame(framed, leftOffset);
const rightSpectrum = this.fftFrame(framed, rightOffset);
for (let freq = 0; freq < this.cpuDsp.dimF; freq += 1) {
tensor[((freq * frames) + frameIndex)] = freq < 3 ? 0 : leftSpectrum.real[freq];
tensor[(this.cpuDsp.dimF * frames) + ((freq * frames) + frameIndex)] = freq < 3 ? 0 : leftSpectrum.imag[freq];
tensor[(2 * this.cpuDsp.dimF * frames) + ((freq * frames) + frameIndex)] = freq < 3 ? 0 : rightSpectrum.real[freq];
tensor[(3 * this.cpuDsp.dimF * frames) + ((freq * frames) + frameIndex)] = freq < 3 ? 0 : rightSpectrum.imag[freq];
}
}
return { data: tensor, dims, frames };
}
private fftFrame(framed: Float32Array, offset: number): { real: Float64Array; imag: Float64Array } {
const real = new Float64Array(this.nFft);
const imag = new Float64Array(this.nFft);
for (let i = 0; i < this.nFft; i += 1) {
real[i] = framed[offset + i];
}
this.cpuDsp.forwardFft.transform(real, imag);
return { real, imag };
}
}
+126
View File
@@ -0,0 +1,126 @@
import { LOCAL_SEPARATOR_MODEL_FILENAME } from './localSeparatorConfig';
export interface ModelDownloadProgress {
receivedBytes: number;
totalBytes: number | null;
percent: number;
}
export class LocalSeparatorModelCache {
private static readonly MODELS_DIR = 'models';
private static readonly TEMP_SUFFIX = '.download';
public static async exists(filename: string = LOCAL_SEPARATOR_MODEL_FILENAME): Promise<boolean> {
try {
const modelsDir = await this.getModelsDir();
await modelsDir.getFileHandle(filename);
return true;
} catch {
return false;
}
}
public static async getFile(filename: string = LOCAL_SEPARATOR_MODEL_FILENAME): Promise<File> {
const modelsDir = await this.getModelsDir();
const fileHandle = await modelsDir.getFileHandle(filename);
return fileHandle.getFile();
}
public static async getArrayBuffer(filename: string = LOCAL_SEPARATOR_MODEL_FILENAME): Promise<ArrayBuffer> {
const file = await this.getFile(filename);
return file.arrayBuffer();
}
public static async delete(filename: string = LOCAL_SEPARATOR_MODEL_FILENAME): Promise<void> {
try {
const modelsDir = await this.getModelsDir();
await modelsDir.removeEntry(filename);
} catch {
// Ignore missing file cleanup.
}
try {
const modelsDir = await this.getModelsDir();
await modelsDir.removeEntry(`${filename}${this.TEMP_SUFFIX}`);
} catch {
// Ignore missing temp file cleanup.
}
}
public static async download(
sourceUrl: string,
filename: string = LOCAL_SEPARATOR_MODEL_FILENAME,
onProgress?: (progress: ModelDownloadProgress) => void,
): Promise<void> {
const response = await fetch(sourceUrl);
if (!response.ok) {
throw new Error(`Model download failed (${response.status})`);
}
const modelsDir = await this.getModelsDir();
const tempName = `${filename}${this.TEMP_SUFFIX}`;
await this.delete(filename);
const tempHandle = await modelsDir.getFileHandle(tempName, { create: true });
const writable = await tempHandle.createWritable();
try {
const totalBytesHeader = response.headers.get('Content-Length');
const totalBytes = totalBytesHeader ? Number(totalBytesHeader) : null;
if (!response.body) {
const buffer = await response.arrayBuffer();
await writable.write(buffer);
onProgress?.({
receivedBytes: buffer.byteLength,
totalBytes,
percent: 100,
});
} else {
const reader = response.body.getReader();
let receivedBytes = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (!value) continue;
await writable.write(value);
receivedBytes += value.byteLength;
onProgress?.({
receivedBytes,
totalBytes,
percent: totalBytes ? (receivedBytes / totalBytes) * 100 : 0,
});
}
}
} catch (error) {
await writable.abort();
await this.delete(filename);
throw error;
}
await writable.close();
const finalHandle = await modelsDir.getFileHandle(filename, { create: true });
const finalWritable = await finalHandle.createWritable();
try {
const tempFile = await tempHandle.getFile();
await finalWritable.write(await tempFile.arrayBuffer());
await finalWritable.close();
} catch (error) {
await finalWritable.abort();
throw error;
} finally {
try {
await modelsDir.removeEntry(tempName);
} catch {
// Ignore temp cleanup errors.
}
}
}
private static async getModelsDir(): Promise<FileSystemDirectoryHandle> {
const root = await navigator.storage.getDirectory();
return root.getDirectoryHandle(this.MODELS_DIR, { create: true });
}
}
+589
View File
@@ -0,0 +1,589 @@
import * as ort from 'onnxruntime-web/webgpu';
import { LocalSeparatorCpuDsp } from './localSeparatorCpuDsp';
import { LocalSeparatorGpuDsp } from './localSeparatorGpuDsp';
import { LocalSeparatorTimingCollector } from './localSeparatorTiming';
import type {
LocalRuntimeProvider,
LocalSeparatorModelConfig,
LocalSeparatorProgress,
StereoChannels,
} from './localSeparatorTypes';
import {
concatFloat32,
createWindowCache,
getHanning,
negateArray,
normalizeChannels,
scaleChannels,
sliceChannels,
} from './localSeparatorShared';
const SAMPLE_RATE = 44100;
function localSeparatorLog(message: string, payload?: unknown): void {
if (payload === undefined) {
console.log(`[localSeparator] ${message}`);
return;
}
console.log(`[localSeparator] ${message}`, payload);
}
interface BrowserMdxSeparatorOptions {
overlap?: number;
runtimeBatchSize?: number;
timing?: LocalSeparatorTimingCollector;
onProgress?: (progress: LocalSeparatorProgress) => void;
onProviderChange?: (provider: string) => void;
}
interface SpectrogramPayload {
data: Float32Array;
dims: number[];
frames?: number;
}
function packBatchPayloads(payloads: SpectrogramPayload[]): { data: Float32Array; dims: number[]; itemSize: number } {
const frames = payloads[0].dims[3];
const dimF = payloads[0].dims[2];
const batch = payloads.length;
const itemSize = 4 * dimF * frames;
const data = new Float32Array(batch * itemSize);
payloads.forEach((payload, index) => {
data.set(payload.data, index * itemSize);
});
return {
data,
dims: [batch, 4, dimF, frames],
itemSize,
};
}
function unpackBatchOutput(outputData: Float32Array, batchInfo: { dims: number[]; itemSize: number }): SpectrogramPayload[] {
const results: SpectrogramPayload[] = [];
for (let index = 0; index < batchInfo.dims[0]; index += 1) {
const start = index * batchInfo.itemSize;
const end = start + batchInfo.itemSize;
results.push({
data: outputData.slice(start, end),
dims: [1, 4, batchInfo.dims[2], batchInfo.dims[3]],
});
}
return results;
}
class BrowserMdxSeparator {
private readonly session: ort.InferenceSession;
private readonly runtimeProvider: LocalRuntimeProvider;
private readonly defaults: LocalSeparatorModelConfig['defaults'];
private readonly metadata: LocalSeparatorModelConfig['metadata'];
public onProgress: (progress: LocalSeparatorProgress) => void;
private overlap: number;
private runtimeBatchSize: number;
private readonly enableDenoise: boolean;
private readonly compensate: number;
private readonly primaryStem: string;
private readonly secondaryStem: string;
private readonly nFft: number;
private readonly hopLength: number;
private readonly chunkSize: number;
private readonly trim: number;
private readonly windowCache = createWindowCache();
private readonly timing: LocalSeparatorTimingCollector;
private dsp: LocalSeparatorCpuDsp | LocalSeparatorGpuDsp;
private dspMode: 'cpu' | 'gpu-hybrid';
public static async create(
session: ort.InferenceSession,
runtimeProvider: LocalRuntimeProvider,
config: LocalSeparatorModelConfig,
options: BrowserMdxSeparatorOptions = {},
): Promise<BrowserMdxSeparator> {
const timing = options.timing ?? new LocalSeparatorTimingCollector('mdx-separation');
let dsp: LocalSeparatorCpuDsp | LocalSeparatorGpuDsp | null = null;
let dspMode: 'cpu' | 'gpu-hybrid' = 'cpu';
if (runtimeProvider === 'webgpu') {
try {
dsp = await timing.measureAsync('dspInit', () => LocalSeparatorGpuDsp.create(config));
dspMode = 'gpu-hybrid';
localSeparatorLog('GPU DSP initialized successfully.');
} catch (error) {
console.warn('[localSeparator] GPU DSP initialization failed, using CPU DSP.', error);
options.onProviderChange?.('cpu/wasm fallback');
localSeparatorLog('GPU DSP initialization failed. Falling back to CPU DSP.', error);
}
}
if (!dsp) {
dsp = new LocalSeparatorCpuDsp(config);
localSeparatorLog('Using CPU DSP.');
}
return new BrowserMdxSeparator(session, runtimeProvider, config, {
...options,
dsp,
dspMode,
timing,
});
}
private constructor(
session: ort.InferenceSession,
runtimeProvider: LocalRuntimeProvider,
config: LocalSeparatorModelConfig,
options: BrowserMdxSeparatorOptions & {
dsp: LocalSeparatorCpuDsp | LocalSeparatorGpuDsp;
dspMode: 'cpu' | 'gpu-hybrid';
timing: LocalSeparatorTimingCollector;
},
) {
this.session = session;
this.runtimeProvider = runtimeProvider;
this.defaults = config.defaults;
this.metadata = config.metadata;
this.onProgress = options.onProgress ?? (() => {});
this.overlap = options.overlap ?? this.defaults.overlap;
this.runtimeBatchSize = Math.max(1, options.runtimeBatchSize ?? 2);
this.enableDenoise = this.defaults.enableDenoise;
this.compensate = this.metadata.compensate;
this.primaryStem = this.metadata.primary_stem ?? 'Vocals';
this.secondaryStem = this.primaryStem === 'Instrumental' ? 'Vocals' : 'Instrumental';
this.nFft = this.metadata.mdx_n_fft_scale_set;
this.hopLength = this.defaults.hopLength;
this.trim = Math.floor(this.nFft / 2);
this.chunkSize = this.hopLength * (this.defaults.segmentSize - 1);
this.dsp = options.dsp;
this.dspMode = options.dspMode;
this.timing = options.timing;
}
public dispose(): void {
this.dsp.dispose();
}
public getDebugSummary(extra: Record<string, unknown> = {}): Record<string, unknown> {
return this.timing.getSummary({
runtimeProvider: this.runtimeProvider,
dspMode: this.dspMode,
...extra,
});
}
public async separate(channels: StereoChannels): Promise<{
stems: Record<string, StereoChannels>;
primaryStem: string;
secondaryStem: string;
}> {
this.onProgress({ stage: 'main', passLabel: 'Main pass', percent: 0, processedChunks: 0, totalChunks: 0 });
const { channels: normalizedChannels, originalPeak } = this.timing.measureSync(
'normalize',
() => normalizeChannels(channels, this.defaults.normalizationThreshold, this.defaults.amplificationThreshold),
);
const primarySource = await this.demix(normalizedChannels, false);
this.onProgress({
stage: 'main-complete',
passLabel: 'Main pass',
percent: this.defaults.invertUsingSpec ? 50 : 100,
processedChunks: 0,
totalChunks: 0,
});
const primaryScaled = this.timing.measureSync('scalePrimary', () => scaleChannels(primarySource, originalPeak));
let secondaryChannels: StereoChannels;
if (this.defaults.invertUsingSpec) {
const rawMix = await this.demix(normalizedChannels, true);
secondaryChannels = this.timing.measureSync('secondaryFromMix', () => {
const secondaryLeft = new Float32Array(rawMix[0].length);
const secondaryRight = new Float32Array(rawMix[1].length);
for (let i = 0; i < secondaryLeft.length; i += 1) {
secondaryLeft[i] = rawMix[0][i] - (primaryScaled[0][i] * this.compensate);
secondaryRight[i] = rawMix[1][i] - (primaryScaled[1][i] * this.compensate);
}
return [secondaryLeft, secondaryRight];
});
} else {
secondaryChannels = this.timing.measureSync('secondarySubtract', () => {
const secondaryLeft = new Float32Array(primaryScaled[0].length);
const secondaryRight = new Float32Array(primaryScaled[1].length);
for (let i = 0; i < secondaryLeft.length; i += 1) {
secondaryLeft[i] = normalizedChannels[0][i] - (primaryScaled[0][i] * this.compensate);
secondaryRight[i] = normalizedChannels[1][i] - (primaryScaled[1][i] * this.compensate);
}
return [secondaryLeft, secondaryRight];
});
}
const primaryNormalized = this.timing.measureSync(
'normalizePrimaryOutput',
() => normalizeChannels(primaryScaled, this.defaults.normalizationThreshold, this.defaults.amplificationThreshold).channels,
);
const secondaryNormalized = this.timing.measureSync(
'normalizeSecondaryOutput',
() => normalizeChannels(secondaryChannels, this.defaults.normalizationThreshold, this.defaults.amplificationThreshold).channels,
);
return {
stems: {
[this.primaryStem]: primaryNormalized,
[this.secondaryStem]: secondaryNormalized,
},
primaryStem: this.primaryStem,
secondaryStem: this.secondaryStem,
};
}
private async demix(channels: StereoChannels, isMatchMix: boolean): Promise<StereoChannels> {
const overlap = isMatchMix ? this.defaults.matchMixOverlap : this.overlap;
const genSize = this.chunkSize - (2 * this.trim);
const pad = genSize + this.trim - (channels[0].length % genSize);
const mixture: StereoChannels = [
concatFloat32([new Float32Array(this.trim), channels[0], new Float32Array(pad)]),
concatFloat32([new Float32Array(this.trim), channels[1], new Float32Array(pad)]),
];
const step = Math.max(1, Math.trunc((1 - overlap) * this.chunkSize));
const result: StereoChannels = [new Float32Array(mixture[0].length), new Float32Array(mixture[1].length)];
const divider: StereoChannels = [new Float32Array(mixture[0].length), new Float32Array(mixture[1].length)];
const totalChunks = Math.ceil(mixture[0].length / step);
let processedChunks = 0;
const windows: Array<{
start: number;
actualSize: number;
leftChunk: Float32Array;
rightChunk: Float32Array;
window: Float32Array | null;
}> = [];
for (let start = 0; start < mixture[0].length; start += step) {
const end = Math.min(start + this.chunkSize, mixture[0].length);
const actualSize = end - start;
const leftChunk = new Float32Array(this.chunkSize);
const rightChunk = new Float32Array(this.chunkSize);
leftChunk.set(mixture[0].subarray(start, end));
rightChunk.set(mixture[1].subarray(start, end));
windows.push({
start,
actualSize,
leftChunk,
rightChunk,
window: overlap !== 0 ? getHanning(actualSize, this.windowCache) : null,
});
}
for (let batchStart = 0; batchStart < windows.length; batchStart += this.runtimeBatchSize) {
const batch = windows.slice(batchStart, batchStart + this.runtimeBatchSize);
const tarWavesBatch = await this.processBatch(batch, isMatchMix);
batch.forEach((chunk, index) => {
const tarWaves = tarWavesBatch[index];
for (let i = 0; i < chunk.actualSize; i += 1) {
const weight = chunk.window ? chunk.window[i] : 1;
result[0][chunk.start + i] += tarWaves[0][i] * weight;
result[1][chunk.start + i] += tarWaves[1][i] * weight;
divider[0][chunk.start + i] += weight;
divider[1][chunk.start + i] += weight;
}
processedChunks += 1;
const passFraction = totalChunks > 0 ? processedChunks / totalChunks : 1;
const overallPercent = isMatchMix ? 50 + (passFraction * 50) : passFraction * (this.defaults.invertUsingSpec ? 50 : 100);
this.onProgress({
stage: isMatchMix ? 'match-mix' : 'main',
passLabel: isMatchMix ? 'Match-mix pass' : 'Main pass',
percent: overallPercent,
processedChunks,
totalChunks,
});
});
}
const left = new Float32Array(channels[0].length);
const right = new Float32Array(channels[1].length);
const endTrim = result[0].length - this.trim;
for (let i = this.trim; i < endTrim; i += 1) {
const outIndex = i - this.trim;
if (outIndex >= left.length) break;
left[outIndex] = divider[0][i] > 1e-8 ? result[0][i] / divider[0][i] : 0;
right[outIndex] = divider[1][i] > 1e-8 ? result[1][i] / divider[1][i] : 0;
}
return [left, right];
}
private async processBatch(
batch: Array<{ leftChunk: Float32Array; rightChunk: Float32Array }>,
isMatchMix: boolean,
): Promise<StereoChannels[]> {
const spectra = await this.timing.measureAsync(
isMatchMix ? 'matchMixDspForward' : 'dspForward',
() => Promise.all(batch.map(chunk => this.dsp.forwardStereo(chunk.leftChunk, chunk.rightChunk))),
);
if (isMatchMix) {
return this.timing.measureAsync(
'matchMixDspInverse',
() => Promise.all(spectra.map(payload => this.dsp.inverseStereo(payload))),
);
}
let predictedPayloads;
if (this.enableDenoise) {
const positiveOutput = await this.executeModelBatch(spectra);
const negativePayloads = spectra.map(payload => ({
data: negateArray(payload.data),
dims: payload.dims,
}));
const negativeOutput = await this.executeModelBatch(negativePayloads);
predictedPayloads = positiveOutput.map((payload, index) => {
const data = new Float32Array(payload.data.length);
for (let i = 0; i < data.length; i += 1) {
data[i] = (negativeOutput[index].data[i] * -0.5) + (payload.data[i] * 0.5);
}
return { data, dims: payload.dims };
});
} else {
predictedPayloads = await this.executeModelBatch(spectra);
}
return this.timing.measureAsync(
'dspInverse',
() => Promise.all(predictedPayloads.map(payload => this.dsp.inverseStereo(payload))),
);
}
private async executeModelBatch(payloads: SpectrogramPayload[]): Promise<SpectrogramPayload[]> {
const packed = packBatchPayloads(payloads);
const tensor = new ort.Tensor('float32', packed.data, packed.dims);
const feeds = { [this.session.inputNames[0]]: tensor };
try {
const outputs = await this.timing.measureAsync('inference', () => this.session.run(feeds));
const firstOutputName = this.session.outputNames[0];
return unpackBatchOutput(outputs[firstOutputName].data as Float32Array, packed);
} catch (error) {
if (payloads.length > 1 && this.runtimeBatchSize > 1) {
console.warn('[localSeparator] Batched inference failed, falling back to batch size 1.', error);
localSeparatorLog('Batched inference failed. Falling back to batch size 1.', error);
this.runtimeBatchSize = 1;
const singleResults: SpectrogramPayload[] = [];
for (const payload of payloads) {
const singlePacked = packBatchPayloads([payload]);
const singleTensor = new ort.Tensor('float32', singlePacked.data, singlePacked.dims);
const singleFeeds = { [this.session.inputNames[0]]: singleTensor };
const outputs = await this.timing.measureAsync('inferenceFallback', () => this.session.run(singleFeeds));
const firstOutputName = this.session.outputNames[0];
singleResults.push(...unpackBatchOutput(outputs[firstOutputName].data as Float32Array, singlePacked));
}
return singleResults;
}
throw error;
}
}
}
export async function decodeAudioToStereo(arrayBuffer: ArrayBuffer): Promise<StereoChannels> {
const audioContext = new AudioContext({ sampleRate: SAMPLE_RATE });
const decoded = await audioContext.decodeAudioData(arrayBuffer.slice(0));
let buffer = decoded;
if (decoded.sampleRate !== SAMPLE_RATE) {
const offline = new OfflineAudioContext({
numberOfChannels: Math.max(2, decoded.numberOfChannels),
length: Math.ceil(decoded.duration * SAMPLE_RATE),
sampleRate: SAMPLE_RATE,
});
const source = offline.createBufferSource();
source.buffer = decoded;
source.connect(offline.destination);
source.start();
buffer = await offline.startRendering();
}
await audioContext.close();
if (buffer.numberOfChannels === 1) {
const mono = buffer.getChannelData(0);
return [new Float32Array(mono), new Float32Array(mono)];
}
return [new Float32Array(buffer.getChannelData(0)), new Float32Array(buffer.getChannelData(1))];
}
export function channelsToWavBlob(channels: StereoChannels, sampleRate: number = SAMPLE_RATE): Blob {
const length = channels[0].length;
const interleaved = new Int16Array(length * 2);
for (let i = 0; i < length; i += 1) {
interleaved[i * 2] = toInt16(channels[0][i]);
interleaved[(i * 2) + 1] = toInt16(channels[1][i]);
}
const buffer = new ArrayBuffer(44 + (interleaved.length * 2));
const view = new DataView(buffer);
writeAscii(view, 0, 'RIFF');
view.setUint32(4, 36 + (interleaved.length * 2), true);
writeAscii(view, 8, 'WAVE');
writeAscii(view, 12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, 2, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * 4, true);
view.setUint16(32, 4, true);
view.setUint16(34, 16, true);
writeAscii(view, 36, 'data');
view.setUint32(40, interleaved.length * 2, true);
let offset = 44;
for (let i = 0; i < interleaved.length; i += 1) {
view.setInt16(offset, interleaved[i], true);
offset += 2;
}
return new Blob([buffer], { type: 'audio/wav' });
}
function writeAscii(view: DataView, offset: number, text: string): void {
for (let i = 0; i < text.length; i += 1) {
view.setUint8(offset + i, text.charCodeAt(i));
}
}
function toInt16(value: number): number {
const clamped = Math.max(-1, Math.min(1, value));
return clamped < 0 ? Math.round(clamped * 0x8000) : Math.round(clamped * 0x7fff);
}
function concatChannelPairs(chunks: StereoChannels[]): StereoChannels {
return [concatFloat32(chunks.map(chunk => chunk[0])), concatFloat32(chunks.map(chunk => chunk[1]))];
}
export async function runLocalSeparator(options: {
session: ort.InferenceSession;
runtimeProvider: LocalRuntimeProvider;
modelConfig: LocalSeparatorModelConfig;
audioBuffer: ArrayBuffer;
chunkDurationSeconds: number | null;
overlap: number;
onProgress: (progress: LocalSeparatorProgress) => void;
onProviderChange?: (provider: string) => void;
}): Promise<{
stems: Array<{ name: string; blob: Blob }>;
providerLabel: string;
debugSummary: Record<string, unknown>;
}> {
const timing = new LocalSeparatorTimingCollector('local-separation');
const decoded = await timing.measureAsync('decode', () => decodeAudioToStereo(options.audioBuffer));
localSeparatorLog(`Running browser MDX separation on ${options.runtimeProvider === 'webgpu' ? 'GPU/WebGPU' : 'CPU/wasm'}...`);
const separator = await BrowserMdxSeparator.create(
options.session,
options.runtimeProvider,
options.modelConfig,
{
overlap: options.overlap,
runtimeBatchSize: options.modelConfig.defaults.batchSize > 1 ? options.modelConfig.defaults.batchSize : 2,
timing,
onProgress: options.onProgress,
onProviderChange: options.onProviderChange,
},
);
try {
const outputs = await separateWithOptionalChunking(
separator,
decoded,
timing,
options.chunkDurationSeconds,
options.modelConfig,
options.onProgress,
);
const primaryBlob = timing.measureSync('wavEncodePrimary', () => channelsToWavBlob(outputs.stems[outputs.primaryStem]));
const secondaryBlob = timing.measureSync('wavEncodeSecondary', () => channelsToWavBlob(outputs.stems[outputs.secondaryStem]));
return {
stems: [
{ name: outputs.primaryStem, blob: primaryBlob },
{ name: outputs.secondaryStem, blob: secondaryBlob },
],
providerLabel: options.runtimeProvider === 'webgpu' ? 'GPU/WebGPU' : 'CPU/wasm',
debugSummary: separator.getDebugSummary({ model: options.modelConfig.filename }),
};
} finally {
localSeparatorLog('Separation timing summary', separator.getDebugSummary({ model: options.modelConfig.filename }));
separator.dispose();
}
}
async function separateWithOptionalChunking(
separator: BrowserMdxSeparator,
decoded: StereoChannels,
timing: LocalSeparatorTimingCollector,
chunkDurationSeconds: number | null,
modelConfig: LocalSeparatorModelConfig,
onProgress: (progress: LocalSeparatorProgress) => void,
): Promise<{
stems: Record<string, StereoChannels>;
primaryStem: string;
secondaryStem: string;
}> {
if (!chunkDurationSeconds) {
return separator.separate(decoded);
}
const chunkSamples = Math.max(1, Math.floor(chunkDurationSeconds * SAMPLE_RATE));
if (decoded[0].length <= chunkSamples) {
return separator.separate(decoded);
}
const totalChunks = Math.ceil(decoded[0].length / chunkSamples);
const primaryStem = modelConfig.metadata.primary_stem ?? 'Vocals';
const secondaryStem = primaryStem === 'Instrumental' ? 'Vocals' : 'Instrumental';
const primaryStemChunks: StereoChannels[] = [];
const secondaryStemChunks: StereoChannels[] = [];
const baseOnProgress = separator.onProgress;
for (let index = 0; index < totalChunks; index += 1) {
const start = index * chunkSamples;
const end = Math.min(start + chunkSamples, decoded[0].length);
const chunk = sliceChannels(decoded, start, end);
separator.onProgress = progress => {
const chunkFraction = progress.percent / 100;
const overallPercent = ((index + chunkFraction) / totalChunks) * 100;
baseOnProgress({
...progress,
percent: overallPercent,
passLabel: `Audio chunk ${index + 1}/${totalChunks}: ${progress.passLabel}`,
});
};
onProgress({
stage: 'chunk-prep',
passLabel: `Audio chunk ${index + 1}/${totalChunks}: preparing ${Math.round((end - start) / SAMPLE_RATE)}s chunk...`,
percent: (index / totalChunks) * 100,
processedChunks: index,
totalChunks,
});
const result = await timing.measureAsync('chunkedSeparate', () => separator.separate(chunk));
primaryStemChunks.push(result.stems[primaryStem]);
secondaryStemChunks.push(result.stems[secondaryStem]);
}
separator.onProgress = baseOnProgress;
return {
stems: {
[primaryStem]: concatChannelPairs(primaryStemChunks),
[secondaryStem]: concatChannelPairs(secondaryStemChunks),
},
primaryStem,
secondaryStem,
};
}
+95
View File
@@ -0,0 +1,95 @@
import * as ort from 'onnxruntime-web/webgpu';
import ortWasmJsepMjsUrl from 'onnxruntime-web/ort-wasm-simd-threaded.jsep.mjs?url';
import ortWasmJsepUrl from 'onnxruntime-web/ort-wasm-simd-threaded.jsep.wasm?url';
import type { LocalRuntimeState, LocalRuntimeSupport, LocalSeparatorModelConfig } from './localSeparatorTypes';
function localSeparatorLog(message: string, payload?: unknown): void {
if (payload === undefined) {
console.log(`[localSeparator] ${message}`);
return;
}
console.log(`[localSeparator] ${message}`, payload);
}
export function detectLocalRuntimeSupport(): LocalRuntimeSupport {
const support = {
webgpuExposed: typeof navigator !== 'undefined' && 'gpu' in navigator,
};
if (support.webgpuExposed) {
localSeparatorLog('WebGPU API is exposed by this browser.');
} else {
localSeparatorLog('WebGPU API is not exposed by this browser. CPU/wasm will be used.');
}
return support;
}
export class LocalOrtRuntimeManager {
private runtime: LocalRuntimeState | null = null;
private currentModel: string | null = null;
private readonly onProviderChange: (provider: string) => void;
private static wasmPathsConfigured = false;
constructor({ onProviderChange }: { onProviderChange?: (provider: string) => void } = {}) {
this.onProviderChange = onProviderChange ?? (() => {});
}
public reset(): void {
this.runtime = null;
this.currentModel = null;
}
public async ensureRuntime(modelConfig: LocalSeparatorModelConfig, modelData: Uint8Array): Promise<LocalRuntimeState> {
if (this.runtime && this.currentModel === modelConfig.filename) {
return this.runtime;
}
if (!LocalOrtRuntimeManager.wasmPathsConfigured) {
ort.env.wasm.wasmPaths = {
mjs: ortWasmJsepMjsUrl,
wasm: ortWasmJsepUrl,
};
localSeparatorLog('Configured ONNX Runtime wasm paths.', ort.env.wasm.wasmPaths);
LocalOrtRuntimeManager.wasmPathsConfigured = true;
}
const providersToTry: Array<'webgpu' | 'wasm'> = [];
if (typeof navigator !== 'undefined' && 'gpu' in navigator) {
providersToTry.push('webgpu');
localSeparatorLog('navigator.gpu is available, trying WebGPU first.');
} else {
localSeparatorLog('navigator.gpu is not available. Falling back to CPU/wasm.');
}
providersToTry.push('wasm');
let lastError: unknown = null;
for (const provider of providersToTry) {
try {
if (provider === 'webgpu' && ort.env?.webgpu) {
ort.env.webgpu.powerPreference = 'high-performance';
localSeparatorLog('Using WebGPU power preference high-performance.');
}
const session = await ort.InferenceSession.create(modelData, {
executionProviders: [provider],
graphOptimizationLevel: 'all',
});
this.runtime = { provider, session };
this.currentModel = modelConfig.filename;
this.onProviderChange(provider === 'wasm' ? 'cpu/wasm' : provider);
localSeparatorLog(`Using provider: ${provider}`);
return this.runtime;
} catch (error) {
lastError = error;
if (provider === 'webgpu') {
localSeparatorLog('WebGPU session creation failed. Falling back to CPU/wasm.', error);
this.onProviderChange('cpu/wasm fallback');
} else {
localSeparatorLog(`Provider failed: ${provider}`, error);
}
}
}
throw new Error(`Unable to create ONNX Runtime session. ${lastError ? String(lastError) : ''}`.trim());
}
}
+275
View File
@@ -0,0 +1,275 @@
import type { StereoChannels } from './localSeparatorTypes';
export function index4d(dims: number[], i0: number, i1: number, i2: number, i3: number): number {
return (((i0 * dims[1] + i1) * dims[2] + i2) * dims[3]) + i3;
}
export function concatFloat32(parts: Float32Array[]): Float32Array {
const length = parts.reduce((sum, part) => sum + part.length, 0);
const output = new Float32Array(length);
let offset = 0;
for (const part of parts) {
output.set(part, offset);
offset += part.length;
}
return output;
}
export function reflectPad(signal: Float32Array, leftPad: number, rightPad: number): Float32Array {
const result = new Float32Array(leftPad + signal.length + rightPad);
const last = signal.length - 1;
for (let i = 0; i < leftPad; i += 1) {
result[i] = signal[leftPad - i];
}
result.set(signal, leftPad);
for (let i = 0; i < rightPad; i += 1) {
result[leftPad + signal.length + i] = signal[last - 1 - i];
}
return result;
}
export function nextPowerOfTwo(value: number): number {
let result = 1;
while (result < value) {
result <<= 1;
}
return result;
}
export function scaleChannels(channels: StereoChannels, scale: number): StereoChannels {
return channels.map(channel => {
const output = new Float32Array(channel.length);
for (let i = 0; i < channel.length; i += 1) {
output[i] = channel[i] * scale;
}
return output;
}) as StereoChannels;
}
export function negateArray(data: Float32Array): Float32Array {
const output = new Float32Array(data.length);
for (let i = 0; i < data.length; i += 1) {
output[i] = -data[i];
}
return output;
}
export function sliceChannels(channels: StereoChannels, startSample: number, endSample: number): StereoChannels {
return [
channels[0].slice(startSample, endSample),
channels[1].slice(startSample, endSample),
];
}
export function normalizeChannels(
channels: StereoChannels,
maxPeak: number,
minPeak: number | null,
): { channels: StereoChannels; originalPeak: number } {
let peak = 0;
for (const channel of channels) {
for (let i = 0; i < channel.length; i += 1) {
peak = Math.max(peak, Math.abs(channel[i]));
}
}
if (peak === 0) {
return { channels, originalPeak: 0 };
}
let scale = 1;
if (peak > maxPeak) {
scale = maxPeak / peak;
} else if (minPeak !== null && peak < minPeak && minPeak > 0) {
scale = minPeak / peak;
}
const normalized = channels.map(channel => {
const output = new Float32Array(channel.length);
for (let i = 0; i < channel.length; i += 1) {
output[i] = channel[i] * scale;
}
return output;
}) as StereoChannels;
return { channels: normalized, originalPeak: peak };
}
export function createWindowCache(): { periodic: Map<number, Float32Array>; symmetric: Map<number, Float32Array> } {
return {
periodic: new Map(),
symmetric: new Map(),
};
}
export function getHannPeriodic(
length: number,
cache: { periodic: Map<number, Float32Array> },
): Float32Array {
const hit = cache.periodic.get(length);
if (hit) return hit;
const window = new Float32Array(length);
for (let i = 0; i < length; i += 1) {
window[i] = 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / length);
}
cache.periodic.set(length, window);
return window;
}
export function getHanning(
length: number,
cache: { symmetric: Map<number, Float32Array> },
): Float32Array {
const hit = cache.symmetric.get(length);
if (hit) return hit;
const window = new Float32Array(length);
if (length === 1) {
window[0] = 1;
} else {
for (let i = 0; i < length; i += 1) {
window[i] = 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / (length - 1));
}
}
cache.symmetric.set(length, window);
return window;
}
export class FFT {
private readonly size: number;
constructor(size: number) {
this.size = size;
}
public transform(real: Float64Array, imag: Float64Array): void {
if (real.length !== imag.length || real.length !== this.size) {
throw new Error('FFT input shape mismatch.');
}
if ((this.size & (this.size - 1)) === 0) {
this.transformRadix2(real, imag);
} else {
this.transformBluestein(real, imag);
}
}
public inverse(real: Float64Array, imag: Float64Array): void {
for (let i = 0; i < this.size; i += 1) {
imag[i] = -imag[i];
}
this.transform(real, imag);
for (let i = 0; i < this.size; i += 1) {
real[i] /= this.size;
imag[i] = -imag[i] / this.size;
}
}
private transformRadix2(real: Float64Array, imag: Float64Array): void {
const n = this.size;
const levels = Math.trunc(Math.log2(n));
for (let i = 0; i < n; i += 1) {
const j = reverseBits(i, levels);
if (j > i) {
[real[i], real[j]] = [real[j], real[i]];
[imag[i], imag[j]] = [imag[j], imag[i]];
}
}
for (let size = 2; size <= n; size <<= 1) {
const halfsize = size >>> 1;
const tableStep = n / size;
for (let i = 0; i < n; i += size) {
for (let j = i, k = 0; j < i + halfsize; j += 1, k += tableStep) {
const angle = (2 * Math.PI * k) / n;
const cos = Math.cos(angle);
const sin = Math.sin(angle);
const tpre = real[j + halfsize] * cos + imag[j + halfsize] * sin;
const tpim = -real[j + halfsize] * sin + imag[j + halfsize] * cos;
real[j + halfsize] = real[j] - tpre;
imag[j + halfsize] = imag[j] - tpim;
real[j] += tpre;
imag[j] += tpim;
}
}
}
}
private transformBluestein(real: Float64Array, imag: Float64Array): void {
const n = this.size;
const m = nextPowerOfTwo((n * 2) + 1);
const areal = new Float64Array(m);
const aimag = new Float64Array(m);
const breal = new Float64Array(m);
const bimag = new Float64Array(m);
const creal = new Float64Array(m);
const cimag = new Float64Array(m);
for (let i = 0; i < n; i += 1) {
const angle = (Math.PI * ((i * i) % (n * 2))) / n;
const cos = Math.cos(angle);
const sin = Math.sin(angle);
areal[i] = real[i] * cos + imag[i] * sin;
aimag[i] = -real[i] * sin + imag[i] * cos;
breal[i] = cos;
bimag[i] = sin;
if (i !== 0) {
breal[m - i] = cos;
bimag[m - i] = sin;
}
}
convolveComplex(areal, aimag, breal, bimag, creal, cimag);
for (let i = 0; i < n; i += 1) {
const angle = (Math.PI * ((i * i) % (n * 2))) / n;
const cos = Math.cos(angle);
const sin = Math.sin(angle);
real[i] = (creal[i] * cos) + (cimag[i] * sin);
imag[i] = (-creal[i] * sin) + (cimag[i] * cos);
}
}
}
function reverseBits(x: number, bits: number): number {
let y = 0;
for (let i = 0; i < bits; i += 1) {
y = (y << 1) | (x & 1);
x >>>= 1;
}
return y;
}
function convolveComplex(
xreal: Float64Array,
ximag: Float64Array,
yreal: Float64Array,
yimag: Float64Array,
outreal: Float64Array,
outimag: Float64Array,
): void {
const n = xreal.length;
const fft = new FFT(n);
const xr = new Float64Array(xreal);
const xi = new Float64Array(ximag);
const yr = new Float64Array(yreal);
const yi = new Float64Array(yimag);
fft.transform(xr, xi);
fft.transform(yr, yi);
for (let i = 0; i < n; i += 1) {
const tempReal = (xr[i] * yr[i]) - (xi[i] * yi[i]);
const tempImag = (xi[i] * yr[i]) + (xr[i] * yi[i]);
xr[i] = tempReal;
xi[i] = tempImag;
}
fft.inverse(xr, xi);
outreal.set(xr);
outimag.set(xi);
}
+52
View File
@@ -0,0 +1,52 @@
export class LocalSeparatorTimingCollector {
private readonly label: string;
private readonly sections = new Map<string, { totalMs: number; count: number }>();
private readonly startedAt = performance.now();
constructor(label = 'timing') {
this.label = label;
}
private track(name: string, durationMs: number): void {
const section = this.sections.get(name) ?? { totalMs: 0, count: 0 };
section.totalMs += durationMs;
section.count += 1;
this.sections.set(name, section);
}
public async measureAsync<T>(name: string, fn: () => Promise<T>): Promise<T> {
const started = performance.now();
try {
return await fn();
} finally {
this.track(name, performance.now() - started);
}
}
public measureSync<T>(name: string, fn: () => T): T {
const started = performance.now();
try {
return fn();
} finally {
this.track(name, performance.now() - started);
}
}
public getSummary(extra: Record<string, unknown> = {}): Record<string, unknown> {
const sections: Record<string, { totalMs: number; count: number; averageMs: number }> = {};
for (const [name, section] of this.sections.entries()) {
sections[name] = {
totalMs: Number(section.totalMs.toFixed(2)),
count: section.count,
averageMs: Number((section.totalMs / Math.max(section.count, 1)).toFixed(2)),
};
}
return {
label: this.label,
totalMs: Number((performance.now() - this.startedAt).toFixed(2)),
sections,
...extra,
};
}
}
+49
View File
@@ -0,0 +1,49 @@
export type StereoChannels = [Float32Array, Float32Array];
export interface LocalSeparatorModelDefaults {
sampleRate: number;
hopLength: number;
segmentSize: number;
overlap: number;
batchSize: number;
enableDenoise: boolean;
invertUsingSpec: boolean;
normalizationThreshold: number;
amplificationThreshold: number;
matchMixOverlap: number;
}
export interface LocalSeparatorModelMetadata {
compensate: number;
mdx_dim_f_set: number;
mdx_dim_t_set: number;
mdx_n_fft_scale_set: number;
primary_stem: string;
}
export interface LocalSeparatorModelConfig {
filename: string;
displayName: string;
status: 'ready';
defaults: LocalSeparatorModelDefaults;
metadata: LocalSeparatorModelMetadata;
}
export interface LocalSeparatorProgress {
stage: string;
passLabel: string;
percent: number;
processedChunks: number;
totalChunks: number;
}
export interface LocalRuntimeSupport {
webgpuExposed: boolean;
}
export type LocalRuntimeProvider = 'webgpu' | 'wasm';
export interface LocalRuntimeState {
provider: LocalRuntimeProvider;
session: import('onnxruntime-web/webgpu').InferenceSession;
}