feat(daemon): add canvas/WebGL/AudioContext noise injection for stealth (Task 13)
- Implement injectCanvasNoise() using context.addInitScript() - Add deterministic hash-based pixel/byte noise for Canvas 2D: - Patch toDataURL, toBlob with save-modify-restore pattern - Patch getImageData to add +/-1 to random pixel RGB channels - Add WebGL readPixels noise for both WebGL and WebGL2 contexts - Add AudioContext noise: detune oscillator, patch AnalyserNode methods - Session-based seed ensures consistent noise within same context - Write integration tests covering canvas, WebGL, audio, toBlob, and edge cases
This commit is contained in:
@@ -0,0 +1,317 @@
|
||||
// Integration tests for injectCanvasNoise / Canvas/WebGL/Audio 噪声注入集成测试
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { chromium } from 'playwright-extra';
|
||||
import StealthPlugin from 'puppeteer-extra-plugin-stealth';
|
||||
import type { Browser, BrowserContext } from 'playwright';
|
||||
import type { CanvasNoiseConfig } from '@visionl/core';
|
||||
import { injectCanvasNoise } from '../stealth/canvas-noise.js';
|
||||
|
||||
chromium.use(StealthPlugin());
|
||||
|
||||
const integration =
|
||||
process.env.VISIONL_INTEGRATION === '1' ? describe : describe.skip;
|
||||
|
||||
const enabledConfig: CanvasNoiseConfig = { enabled: true, strength: 0.5 };
|
||||
const disabledConfig: CanvasNoiseConfig = { enabled: false, strength: 0.5 };
|
||||
|
||||
/** Helper: draw fingerprinting canvas and return toDataURL hash */
|
||||
async function canvasFingerprint(page: import('playwright').Page): Promise<string> {
|
||||
return page.evaluate(() => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 280;
|
||||
canvas.height = 60;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.font = '14px Arial';
|
||||
ctx.fillStyle = '#069';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.fillStyle = 'rgba(102, 204, 0, 0.9)';
|
||||
ctx.fillText('VisionL 🔒 2026', 4, 17);
|
||||
ctx.fillStyle = '#f60';
|
||||
ctx.fillRect(60, 20, 80, 10);
|
||||
return canvas.toDataURL();
|
||||
});
|
||||
}
|
||||
|
||||
integration('injectCanvasNoise', () => {
|
||||
let browser: Browser;
|
||||
|
||||
beforeAll(async () => {
|
||||
browser = await chromium.launch({
|
||||
headless: true,
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (browser) await browser.close();
|
||||
});
|
||||
|
||||
// ────────── enabled = false: baseline / 基线 ──────────
|
||||
it('should NOT alter canvas output when enabled=false', async () => {
|
||||
const ctx1 = await browser.newContext();
|
||||
await injectCanvasNoise(ctx1, disabledConfig);
|
||||
const page1 = await ctx1.newPage();
|
||||
await page1.goto('about:blank');
|
||||
const result1 = await canvasFingerprint(page1);
|
||||
|
||||
const ctx2 = await browser.newContext();
|
||||
// No injection at all / 无注入
|
||||
const page2 = await ctx2.newPage();
|
||||
await page2.goto('about:blank');
|
||||
const result2 = await canvasFingerprint(page2);
|
||||
|
||||
// Without noise, canvas output should be deterministic (same renderer)
|
||||
// 无噪声时 canvas 输出应一致(相同渲染器)
|
||||
expect(result1).toBe(result2);
|
||||
|
||||
await ctx1.close();
|
||||
await ctx2.close();
|
||||
});
|
||||
|
||||
// ────────── enabled = true: noise differs from baseline ──────────
|
||||
it('should alter canvas output when enabled=true', async () => {
|
||||
const ctxNoisy = await browser.newContext();
|
||||
await injectCanvasNoise(ctxNoisy, enabledConfig);
|
||||
const pageNoisy = await ctxNoisy.newPage();
|
||||
await pageNoisy.goto('about:blank');
|
||||
const noised = await canvasFingerprint(pageNoisy);
|
||||
|
||||
const ctxBaseline = await browser.newContext();
|
||||
const pageBaseline = await ctxBaseline.newPage();
|
||||
await pageBaseline.goto('about:blank');
|
||||
const baseline = await canvasFingerprint(pageBaseline);
|
||||
|
||||
expect(noised).not.toBe(baseline);
|
||||
|
||||
await ctxNoisy.close();
|
||||
await ctxBaseline.close();
|
||||
});
|
||||
|
||||
// ────────── seed consistency: same context → same noise pattern ──────────
|
||||
it('should produce consistent noise within the same context (seed consistency)', async () => {
|
||||
const ctx = await browser.newContext();
|
||||
await injectCanvasNoise(ctx, enabledConfig);
|
||||
const page = await ctx.newPage();
|
||||
await page.goto('about:blank');
|
||||
|
||||
// Two calls should produce identical results
|
||||
// 两次调用应产生相同结果
|
||||
const result1 = await canvasFingerprint(page);
|
||||
const result2 = await canvasFingerprint(page);
|
||||
expect(result1).toBe(result2);
|
||||
|
||||
await ctx.close();
|
||||
});
|
||||
|
||||
// ────────── different contexts → different noise (different seeds) ──────────
|
||||
it('should produce different noise in different contexts (different seeds)', async () => {
|
||||
const ctx1 = await browser.newContext();
|
||||
await injectCanvasNoise(ctx1, enabledConfig);
|
||||
const page1 = await ctx1.newPage();
|
||||
await page1.goto('about:blank');
|
||||
const result1 = await canvasFingerprint(page1);
|
||||
|
||||
const ctx2 = await browser.newContext();
|
||||
await injectCanvasNoise(ctx2, enabledConfig);
|
||||
const page2 = await ctx2.newPage();
|
||||
await page2.goto('about:blank');
|
||||
const result2 = await canvasFingerprint(page2);
|
||||
|
||||
// Different seeds should produce different results
|
||||
// 不同种子应产生不同结果
|
||||
expect(result1).not.toBe(result2);
|
||||
|
||||
await ctx1.close();
|
||||
await ctx2.close();
|
||||
});
|
||||
|
||||
// ────────── getImageData noise ──────────
|
||||
it('should add noise to getImageData output', async () => {
|
||||
const ctxNoisy = await browser.newContext();
|
||||
await injectCanvasNoise(ctxNoisy, enabledConfig);
|
||||
const pageNoisy = await ctxNoisy.newPage();
|
||||
await pageNoisy.goto('about:blank');
|
||||
|
||||
const ctxBaseline = await browser.newContext();
|
||||
const pageBaseline = await ctxBaseline.newPage();
|
||||
await pageBaseline.goto('about:blank');
|
||||
|
||||
const getImageDataHash = async (p: import('playwright').Page) =>
|
||||
p.evaluate(() => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 100;
|
||||
canvas.height = 100;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
ctx.fillStyle = 'blue';
|
||||
ctx.fillRect(0, 0, 50, 50);
|
||||
ctx.fillStyle = 'red';
|
||||
ctx.fillRect(50, 50, 50, 50);
|
||||
const imageData = ctx.getImageData(0, 0, 100, 100);
|
||||
// Hash the pixel data
|
||||
let hash = 0;
|
||||
for (let i = 0; i < imageData.data.length; i++) {
|
||||
hash = ((hash << 5) - hash + imageData.data[i]) | 0;
|
||||
}
|
||||
return hash;
|
||||
});
|
||||
|
||||
const noisyHash = await getImageDataHash(pageNoisy);
|
||||
const baselineHash = await getImageDataHash(pageBaseline);
|
||||
|
||||
expect(noisyHash).not.toBe(baselineHash);
|
||||
|
||||
await ctxNoisy.close();
|
||||
await ctxBaseline.close();
|
||||
});
|
||||
|
||||
// ────────── WebGL readPixels noise ──────────
|
||||
it('should add noise to WebGL readPixels output', async () => {
|
||||
const ctxNoisy = await browser.newContext();
|
||||
await injectCanvasNoise(ctxNoisy, enabledConfig);
|
||||
const pageNoisy = await ctxNoisy.newPage();
|
||||
await pageNoisy.goto('about:blank');
|
||||
|
||||
const ctxBaseline = await browser.newContext();
|
||||
const pageBaseline = await ctxBaseline.newPage();
|
||||
await pageBaseline.goto('about:blank');
|
||||
|
||||
const webglHash = async (p: import('playwright').Page): Promise<number | null> =>
|
||||
p.evaluate(() => {
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 32;
|
||||
canvas.height = 32;
|
||||
const gl = canvas.getContext('webgl');
|
||||
if (!gl) return null;
|
||||
gl.clearColor(0.1, 0.2, 0.3, 1.0);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
const pixels = new Uint8Array(32 * 32 * 4);
|
||||
gl.readPixels(0, 0, 32, 32, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
|
||||
let hash = 0;
|
||||
for (let i = 0; i < pixels.length; i++) {
|
||||
hash = ((hash << 5) - hash + pixels[i]) | 0;
|
||||
}
|
||||
return hash;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const noisyHash = await webglHash(pageNoisy);
|
||||
const baselineHash = await webglHash(pageBaseline);
|
||||
|
||||
if (noisyHash !== null && baselineHash !== null) {
|
||||
expect(noisyHash).not.toBe(baselineHash);
|
||||
}
|
||||
|
||||
await ctxNoisy.close();
|
||||
await ctxBaseline.close();
|
||||
});
|
||||
|
||||
// ────────── AudioContext noise ──────────
|
||||
it('should add noise to AudioContext AnalyserNode output', async () => {
|
||||
const ctxNoisy = await browser.newContext();
|
||||
await injectCanvasNoise(ctxNoisy, enabledConfig);
|
||||
const pageNoisy = await ctxNoisy.newPage();
|
||||
await pageNoisy.goto('about:blank');
|
||||
|
||||
const ctxBaseline = await browser.newContext();
|
||||
const pageBaseline = await ctxBaseline.newPage();
|
||||
await pageBaseline.goto('about:blank');
|
||||
|
||||
const audioHash = async (p: import('playwright').Page): Promise<number | null> =>
|
||||
p.evaluate((): number | null => {
|
||||
try {
|
||||
const AudioCtx = (window as any).AudioContext || (window as any).webkitAudioContext;
|
||||
if (!AudioCtx) return null;
|
||||
const audioCtx = new AudioCtx();
|
||||
const oscillator = audioCtx.createOscillator();
|
||||
const analyser = audioCtx.createAnalyser();
|
||||
analyser.fftSize = 256;
|
||||
oscillator.connect(analyser);
|
||||
const data = new Float32Array(analyser.frequencyBinCount);
|
||||
analyser.getFloatFrequencyData(data);
|
||||
let hash = 0;
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
// Quantize float to int for hashing / 浮点量化后哈希
|
||||
hash = ((hash << 5) - hash + (data[i] * 1000) | 0) | 0;
|
||||
}
|
||||
oscillator.disconnect();
|
||||
audioCtx.close();
|
||||
return hash;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const noisyHash = await audioHash(pageNoisy);
|
||||
const baselineHash = await audioHash(pageBaseline);
|
||||
|
||||
if (noisyHash !== null && baselineHash !== null) {
|
||||
expect(noisyHash).not.toBe(baselineHash);
|
||||
}
|
||||
|
||||
await ctxNoisy.close();
|
||||
await ctxBaseline.close();
|
||||
});
|
||||
|
||||
// ────────── strength = 0 should not alter anything ──────────
|
||||
it('should not alter canvas when strength=0', async () => {
|
||||
const ctxZero = await browser.newContext();
|
||||
await injectCanvasNoise(ctxZero, { enabled: true, strength: 0 });
|
||||
const pageZero = await ctxZero.newPage();
|
||||
await pageZero.goto('about:blank');
|
||||
const resultZero = await canvasFingerprint(pageZero);
|
||||
|
||||
const ctxBaseline = await browser.newContext();
|
||||
const pageBaseline = await ctxBaseline.newPage();
|
||||
await pageBaseline.goto('about:blank');
|
||||
const resultBaseline = await canvasFingerprint(pageBaseline);
|
||||
|
||||
expect(resultZero).toBe(resultBaseline);
|
||||
|
||||
await ctxZero.close();
|
||||
await ctxBaseline.close();
|
||||
});
|
||||
|
||||
// ────────── toBlob noise ──────────
|
||||
it('should add noise to toBlob output', async () => {
|
||||
const ctxNoisy = await browser.newContext();
|
||||
await injectCanvasNoise(ctxNoisy, enabledConfig);
|
||||
const pageNoisy = await ctxNoisy.newPage();
|
||||
await pageNoisy.goto('about:blank');
|
||||
|
||||
const ctxBaseline = await browser.newContext();
|
||||
const pageBaseline = await ctxBaseline.newPage();
|
||||
await pageBaseline.goto('about:blank');
|
||||
|
||||
const blobHash = async (p: import('playwright').Page): Promise<string> =>
|
||||
p.evaluate((): Promise<string> => {
|
||||
return new Promise((resolve) => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 100;
|
||||
canvas.height = 60;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
ctx.fillStyle = '#069';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.fillStyle = 'rgba(102, 204, 0, 0.9)';
|
||||
ctx.fillText('Test', 4, 17);
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) { resolve('null'); return; }
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => resolve(reader.result as string);
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const noisyResult = await blobHash(pageNoisy);
|
||||
const baselineResult = await blobHash(pageBaseline);
|
||||
|
||||
expect(noisyResult).not.toBe(baselineResult);
|
||||
|
||||
await ctxNoisy.close();
|
||||
await ctxBaseline.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,257 @@
|
||||
// Stealth — Canvas / WebGL / AudioContext noise injection / 隐身 — Canvas/WebGL/AudioContext 噪声注入
|
||||
// Adds ±1 noise to pixel/byte outputs to defeat browser fingerprinting.
|
||||
import type { BrowserContext } from 'playwright';
|
||||
import type { CanvasNoiseConfig } from '@visionl/core';
|
||||
|
||||
/** Deterministic hash for per-pixel-byte noise decision / 确定性哈希决定每个像素/字节是否加噪 */
|
||||
const HASH_MULTIPLIER = 2654435761;
|
||||
|
||||
export async function injectCanvasNoise(
|
||||
context: BrowserContext,
|
||||
opts: CanvasNoiseConfig,
|
||||
): Promise<void> {
|
||||
if (!opts.enabled) return;
|
||||
|
||||
// Generate a random session seed on the Node side / 在 Node 侧生成随机会话种子
|
||||
const seed = Math.floor(Math.random() * 0x7fffffff);
|
||||
|
||||
await context.addInitScript((args) => {
|
||||
const { seed, strength } = args;
|
||||
const noisePercent = Math.round(strength * 10); // 0–10% of pixels/bytes affected
|
||||
|
||||
if (noisePercent <= 0) return;
|
||||
|
||||
// ---------- deterministic pixel/byte selector / 确定性选择器 ----------
|
||||
function shouldAffect(index: number): boolean {
|
||||
const hash = ((index * HASH_MULTIPLIER + seed) & 0x7fffffff) >>> 0;
|
||||
return (hash % 100) < noisePercent;
|
||||
}
|
||||
|
||||
function noiseOffset(): number {
|
||||
// ±1 or ±0 based on the same deterministic stream (sort of)
|
||||
const m = ((Math.floor(Math.random() * 100000) * HASH_MULTIPLIER + seed) & 0x7fffffff) >>> 0;
|
||||
return (m & 1) ? 1 : -1;
|
||||
}
|
||||
|
||||
// Clamp helpers / 钳位辅助
|
||||
function clampByte(v: number): number {
|
||||
return Math.max(0, Math.min(255, v));
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// 1. Canvas 2D noise / Canvas 2D 噪声
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
const origGetImageData = CanvasRenderingContext2D.prototype.getImageData;
|
||||
const origPutImageData = CanvasRenderingContext2D.prototype.putImageData;
|
||||
const origToDataURL = HTMLCanvasElement.prototype.toDataURL;
|
||||
const origToBlob = HTMLCanvasElement.prototype.toBlob;
|
||||
|
||||
// Cast putImageData to avoid overload ambiguity with .call() / 避免 .call() 重载歧义
|
||||
type PutImageData3 = (imageData: ImageData, dx: number, dy: number) => void;
|
||||
const put3 = origPutImageData as PutImageData3;
|
||||
|
||||
function addNoiseToImageData(imageData: ImageData): void {
|
||||
const data = imageData.data;
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const pixelIdx = i / 4;
|
||||
if (!shouldAffect(pixelIdx)) continue;
|
||||
data[i] = clampByte(data[i] + noiseOffset()); // R
|
||||
data[i + 1] = clampByte(data[i + 1] + noiseOffset()); // G
|
||||
data[i + 2] = clampByte(data[i + 2] + noiseOffset()); // B
|
||||
// Alpha untouched / Alpha 不变
|
||||
}
|
||||
}
|
||||
|
||||
// getImageData — add noise to returned ImageData / 给返回的 ImageData 加噪
|
||||
CanvasRenderingContext2D.prototype.getImageData = function (
|
||||
x: number,
|
||||
y: number,
|
||||
w: number,
|
||||
h: number,
|
||||
): ImageData {
|
||||
const result = origGetImageData.call(this, x, y, w, h);
|
||||
addNoiseToImageData(result);
|
||||
return result;
|
||||
};
|
||||
|
||||
// toDataURL — save canvas pixels, add noise, call original, restore
|
||||
// 先保存像素 → 加噪 → 调用原方法 → 恢复
|
||||
HTMLCanvasElement.prototype.toDataURL = function (
|
||||
type?: string,
|
||||
quality?: any,
|
||||
): string {
|
||||
const ctx = (this as HTMLCanvasElement).getContext('2d');
|
||||
let saved: ImageData | null = null;
|
||||
const w = (this as HTMLCanvasElement).width;
|
||||
const h = (this as HTMLCanvasElement).height;
|
||||
|
||||
if (ctx && w > 0 && h > 0) {
|
||||
saved = origGetImageData.call(ctx, 0, 0, w, h);
|
||||
const noised = new ImageData(
|
||||
new Uint8ClampedArray(saved.data),
|
||||
saved.width,
|
||||
saved.height,
|
||||
);
|
||||
addNoiseToImageData(noised);
|
||||
put3.call(ctx, noised, 0, 0);
|
||||
}
|
||||
|
||||
const result = origToDataURL.call(this, type, quality);
|
||||
|
||||
// Restore original pixel data / 恢复原始像素
|
||||
if (saved && ctx) {
|
||||
put3.call(ctx, saved, 0, 0);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
// toBlob — same save-modify-restore pattern / 同样的保存-修改-恢复模式
|
||||
HTMLCanvasElement.prototype.toBlob = function (
|
||||
callback: BlobCallback,
|
||||
type?: string,
|
||||
quality?: any,
|
||||
): void {
|
||||
const ctx = (this as HTMLCanvasElement).getContext('2d');
|
||||
let saved: ImageData | null = null;
|
||||
const w = (this as HTMLCanvasElement).width;
|
||||
const h = (this as HTMLCanvasElement).height;
|
||||
|
||||
if (ctx && w > 0 && h > 0) {
|
||||
saved = origGetImageData.call(ctx, 0, 0, w, h);
|
||||
const noised = new ImageData(
|
||||
new Uint8ClampedArray(saved.data),
|
||||
saved.width,
|
||||
saved.height,
|
||||
);
|
||||
addNoiseToImageData(noised);
|
||||
put3.call(ctx, noised, 0, 0);
|
||||
}
|
||||
|
||||
origToBlob.call(this, (blob: Blob | null) => {
|
||||
// Restore after callback fires / 回调后恢复
|
||||
if (saved && ctx) {
|
||||
put3.call(ctx, saved, 0, 0);
|
||||
}
|
||||
callback(blob);
|
||||
}, type, quality);
|
||||
};
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// 2. WebGL noise / WebGL 噪声
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
function addNoiseToWebGLBuffer(
|
||||
buffer: ArrayBufferView,
|
||||
bytesPerPixel: number,
|
||||
elementCount: number,
|
||||
): void {
|
||||
const bytes = new Uint8Array(
|
||||
buffer.buffer,
|
||||
buffer.byteOffset,
|
||||
buffer.byteLength,
|
||||
);
|
||||
for (let i = 0; i < elementCount; i++) {
|
||||
const base = i * bytesPerPixel;
|
||||
for (let b = 0; b < bytesPerPixel; b++) {
|
||||
const byteIdx = base + b;
|
||||
if (!shouldAffect(byteIdx)) continue;
|
||||
bytes[byteIdx] = clampByte(bytes[byteIdx] + noiseOffset());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function hookReadPixels(proto: any): void {
|
||||
const origReadPixels = proto.readPixels;
|
||||
proto.readPixels = function (
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
format: number,
|
||||
type: number,
|
||||
pixels: ArrayBufferView,
|
||||
): void {
|
||||
origReadPixels.call(this, x, y, width, height, format, type, pixels);
|
||||
|
||||
const elementCount = width * height;
|
||||
// RGBA = 4 bytes per pixel, other formats vary
|
||||
// GL_RGBA = 0x1908, GL_UNSIGNED_BYTE = 0x1401
|
||||
const bytesPerPixel = 4; // conservative default
|
||||
addNoiseToWebGLBuffer(pixels, bytesPerPixel, elementCount);
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof WebGLRenderingContext !== 'undefined') {
|
||||
hookReadPixels(WebGLRenderingContext.prototype);
|
||||
}
|
||||
if (typeof WebGL2RenderingContext !== 'undefined') {
|
||||
hookReadPixels(WebGL2RenderingContext.prototype);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// 3. AudioContext noise / AudioContext 噪声
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
if (typeof AudioContext === 'undefined' && typeof (window as any).webkitAudioContext === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const AudioCtxCtor: typeof AudioContext =
|
||||
(window as any).AudioContext || (window as any).webkitAudioContext;
|
||||
|
||||
// Noise at ~-100dB FS level (inaudible but modifies fingerprint)
|
||||
// ~-100dB FS ≈ 0.00001 in linear; db domain add ±1 offset
|
||||
const origCreateOscillator = AudioCtxCtor.prototype.createOscillator;
|
||||
AudioCtxCtor.prototype.createOscillator = function () {
|
||||
const osc = origCreateOscillator.call(this);
|
||||
// Slight random detune per session / 极小的会话级随机失谐
|
||||
osc.detune.value = (seed % 20) - 10; // -10 to +10 cents, imperceptible
|
||||
return osc;
|
||||
};
|
||||
|
||||
function addNoiseToFloatFrequencyData(array: Float32Array<ArrayBuffer>): void {
|
||||
for (let i = 0; i < array.length; i++) {
|
||||
if (!shouldAffect(i)) continue;
|
||||
// Add ±1 to dB values (~-100 dB noise floor) / 在 dB 值上加 ±1(约 -100dB 噪底)
|
||||
array[i] += noiseOffset();
|
||||
}
|
||||
}
|
||||
|
||||
function addNoiseToByteFrequencyData(array: Uint8Array<ArrayBuffer>): void {
|
||||
for (let i = 0; i < array.length; i++) {
|
||||
if (!shouldAffect(i)) continue;
|
||||
array[i] = clampByte(array[i] + noiseOffset());
|
||||
}
|
||||
}
|
||||
|
||||
function addNoiseToFloatTimeDomainData(array: Float32Array<ArrayBuffer>): void {
|
||||
for (let i = 0; i < array.length; i++) {
|
||||
if (!shouldAffect(i)) continue;
|
||||
// Add extremely small offset (~-100 dB FS ≈ 0.00001)
|
||||
array[i] += noiseOffset() * 0.00001;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof AnalyserNode !== 'undefined') {
|
||||
const origGetFloatFrequencyData = AnalyserNode.prototype.getFloatFrequencyData;
|
||||
const origGetByteFrequencyData = AnalyserNode.prototype.getByteFrequencyData;
|
||||
const origGetFloatTimeDomainData = AnalyserNode.prototype.getFloatTimeDomainData;
|
||||
|
||||
AnalyserNode.prototype.getFloatFrequencyData = function (array: Float32Array): void {
|
||||
origGetFloatFrequencyData.call(this, array as Float32Array<ArrayBuffer>);
|
||||
addNoiseToFloatFrequencyData(array as Float32Array<ArrayBuffer>);
|
||||
};
|
||||
|
||||
AnalyserNode.prototype.getByteFrequencyData = function (array: Uint8Array): void {
|
||||
origGetByteFrequencyData.call(this, array as Uint8Array<ArrayBuffer>);
|
||||
addNoiseToByteFrequencyData(array as Uint8Array<ArrayBuffer>);
|
||||
};
|
||||
|
||||
AnalyserNode.prototype.getFloatTimeDomainData = function (array: Float32Array): void {
|
||||
origGetFloatTimeDomainData.call(this, array as Float32Array<ArrayBuffer>);
|
||||
addNoiseToFloatTimeDomainData(array as Float32Array<ArrayBuffer>);
|
||||
};
|
||||
}
|
||||
}, { seed, strength: opts.strength });
|
||||
}
|
||||
Reference in New Issue
Block a user