fix(audio): reuse shared singleton AudioContext in SoundManager (#528)
Maintain a single lazy-initialized AudioContext instance across playSound calls with automatic suspended-context resume and clean close/recreation support. TAG=agy CONV=4e31689c-063a-4965-968b-59c0f5795f97
This commit is contained in:
parent
462fdc7516
commit
58cbc88e7c
|
|
@ -1,3 +1,45 @@
|
|||
export interface ToneSpec {
|
||||
readonly type: OscillatorType;
|
||||
readonly from: number;
|
||||
readonly to: number;
|
||||
readonly gain: number;
|
||||
readonly durationS: number;
|
||||
readonly delayS?: number;
|
||||
}
|
||||
|
||||
type AudioContextCtor = new () => AudioContext;
|
||||
|
||||
let sharedCtx: AudioContext | null = null;
|
||||
let sharedCtxCtor: AudioContextCtor | null = null;
|
||||
let sharedMasterGain: GainNode | null = null;
|
||||
let sharedSfxGain: GainNode | null = null;
|
||||
let sharedMusicGain: GainNode | null = null;
|
||||
let warnedAudioFailure = false;
|
||||
|
||||
function getAudioContextCtor(): AudioContextCtor | undefined {
|
||||
const g = globalThis as unknown as {
|
||||
AudioContext?: AudioContextCtor;
|
||||
webkitAudioContext?: AudioContextCtor;
|
||||
window?: {
|
||||
AudioContext?: AudioContextCtor;
|
||||
webkitAudioContext?: AudioContextCtor;
|
||||
};
|
||||
};
|
||||
return (
|
||||
g.AudioContext ??
|
||||
g.webkitAudioContext ??
|
||||
g.window?.AudioContext ??
|
||||
g.window?.webkitAudioContext
|
||||
);
|
||||
}
|
||||
|
||||
function warnOnce(err: unknown): void {
|
||||
if (!warnedAudioFailure) {
|
||||
warnedAudioFailure = true;
|
||||
console.warn('[AudioManager] Audio playback failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
export class AudioManager {
|
||||
private ctx: AudioContext | null = null;
|
||||
private masterGain: GainNode | null = null;
|
||||
|
|
@ -11,84 +53,284 @@ export class AudioManager {
|
|||
// We defer AudioContext creation until needed due to autoplay policies
|
||||
}
|
||||
|
||||
private ensureContext() {
|
||||
if (!this.ctx && typeof AudioContext !== 'undefined') {
|
||||
this.ctx = new AudioContext();
|
||||
this.masterGain = this.ctx.createGain();
|
||||
this.sfxGain = this.ctx.createGain();
|
||||
this.musicGain = this.ctx.createGain();
|
||||
|
||||
this.masterGain.connect(this.ctx.destination);
|
||||
this.sfxGain.connect(this.masterGain);
|
||||
this.musicGain.connect(this.masterGain);
|
||||
private ensureContext(): AudioContext | null {
|
||||
const Ctor = getAudioContextCtor();
|
||||
if (!Ctor) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
if (!sharedCtx || sharedCtx.state === 'closed' || sharedCtxCtor !== Ctor) {
|
||||
sharedCtx = new Ctor();
|
||||
sharedCtxCtor = Ctor;
|
||||
sharedMasterGain = typeof sharedCtx.createGain === 'function' ? sharedCtx.createGain() : null;
|
||||
sharedSfxGain = typeof sharedCtx.createGain === 'function' ? sharedCtx.createGain() : null;
|
||||
sharedMusicGain = typeof sharedCtx.createGain === 'function' ? sharedCtx.createGain() : null;
|
||||
|
||||
if (sharedMasterGain && sharedCtx.destination) {
|
||||
sharedMasterGain.connect?.(sharedCtx.destination);
|
||||
}
|
||||
if (sharedSfxGain && sharedMasterGain) {
|
||||
sharedSfxGain.connect?.(sharedMasterGain);
|
||||
}
|
||||
if (sharedMusicGain && sharedMasterGain) {
|
||||
sharedMusicGain.connect?.(sharedMasterGain);
|
||||
}
|
||||
}
|
||||
if (sharedCtx.state === 'suspended' && typeof sharedCtx.resume === 'function') {
|
||||
void Promise.resolve(sharedCtx.resume()).catch(() => {});
|
||||
}
|
||||
this.ctx = sharedCtx;
|
||||
this.masterGain = sharedMasterGain;
|
||||
this.sfxGain = sharedSfxGain;
|
||||
this.musicGain = sharedMusicGain;
|
||||
return this.ctx;
|
||||
} catch (err) {
|
||||
warnOnce(err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Return the active shared AudioContext if created, or null. */
|
||||
getContext(): AudioContext | null {
|
||||
return this.ctx ?? sharedCtx;
|
||||
}
|
||||
|
||||
/** Set the global master volume [0.0..1.0] */
|
||||
setMasterVolume(value: number) {
|
||||
setMasterVolume(value: number): void {
|
||||
const clamped = Math.max(0, Math.min(1, Number.isFinite(value) ? value : 0));
|
||||
this.ensureContext();
|
||||
if (this.masterGain) this.masterGain.gain.value = value;
|
||||
if (this.masterGain?.gain) this.masterGain.gain.value = clamped;
|
||||
}
|
||||
|
||||
/** Set the SFX volume [0.0..1.0] */
|
||||
setSfxVolume(value: number) {
|
||||
setSfxVolume(value: number): void {
|
||||
const clamped = Math.max(0, Math.min(1, Number.isFinite(value) ? value : 0));
|
||||
this.ensureContext();
|
||||
if (this.sfxGain) this.sfxGain.gain.value = value;
|
||||
if (this.sfxGain?.gain) this.sfxGain.gain.value = clamped;
|
||||
}
|
||||
|
||||
/** Set the music volume [0.0..1.0] */
|
||||
setMusicVolume(value: number) {
|
||||
setMusicVolume(value: number): void {
|
||||
const clamped = Math.max(0, Math.min(1, Number.isFinite(value) ? value : 0));
|
||||
this.ensureContext();
|
||||
if (this.musicGain) this.musicGain.gain.value = value;
|
||||
if (this.musicGain?.gain) this.musicGain.gain.value = clamped;
|
||||
}
|
||||
|
||||
/** Decode a full WAFF/RIFF MPQ audio member and cache it by name */
|
||||
async loadBuffer(name: string, data: Uint8Array) {
|
||||
this.ensureContext();
|
||||
if (!this.ctx) return;
|
||||
async loadBuffer(name: string, data: Uint8Array): Promise<void> {
|
||||
const ctx = this.ensureContext();
|
||||
if (!ctx) return;
|
||||
|
||||
// Ensure WAFF/RIFF is correctly passed. WebAudio takes an ArrayBuffer
|
||||
const buffer = await this.ctx.decodeAudioData(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer);
|
||||
const buffer = await ctx.decodeAudioData(
|
||||
data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer,
|
||||
);
|
||||
this.buffers.set(name, buffer);
|
||||
}
|
||||
|
||||
/** Play a synthesized tone using the shared AudioContext and currentTime scheduling */
|
||||
playTone(spec: ToneSpec): void {
|
||||
const ctx = this.ensureContext();
|
||||
if (!ctx) return;
|
||||
try {
|
||||
const now = typeof ctx.currentTime === 'number' ? ctx.currentTime : 0;
|
||||
const t0 = now + (spec.delayS ?? 0);
|
||||
const t1 = t0 + spec.durationS;
|
||||
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.type = spec.type;
|
||||
osc.frequency?.setValueAtTime?.(spec.from, t0);
|
||||
osc.frequency?.exponentialRampToValueAtTime?.(Math.max(0.01, spec.to), t1);
|
||||
gain.gain?.setValueAtTime?.(spec.gain, t0);
|
||||
gain.gain?.exponentialRampToValueAtTime?.(0.01, t1);
|
||||
|
||||
osc.connect?.(gain);
|
||||
const targetNode = this.sfxGain ?? ctx.destination;
|
||||
if (targetNode) {
|
||||
gain.connect?.(targetNode);
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
try {
|
||||
osc.disconnect?.();
|
||||
} catch {
|
||||
// ignore disconnect errors on already-disconnected nodes
|
||||
}
|
||||
try {
|
||||
gain.disconnect?.();
|
||||
} catch {
|
||||
// ignore disconnect errors on already-disconnected nodes
|
||||
}
|
||||
};
|
||||
if (typeof osc.addEventListener === 'function') {
|
||||
osc.addEventListener('ended', cleanup, { once: true });
|
||||
} else {
|
||||
osc.onended = cleanup;
|
||||
}
|
||||
|
||||
osc.start?.(t0);
|
||||
osc.stop?.(t1);
|
||||
} catch (err) {
|
||||
warnOnce(err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Play a sound effect by name or synthesized ToneSpec using the shared AudioContext */
|
||||
playSound(soundOrSpec?: string | ToneSpec): void {
|
||||
if (typeof soundOrSpec === 'object' && soundOrSpec !== null) {
|
||||
this.playTone(soundOrSpec);
|
||||
return;
|
||||
}
|
||||
if (typeof soundOrSpec === 'string' && this.buffers.has(soundOrSpec)) {
|
||||
this.playSfx(soundOrSpec);
|
||||
return;
|
||||
}
|
||||
this.playTone({
|
||||
type: 'triangle',
|
||||
from: 160,
|
||||
to: 80,
|
||||
gain: 0.25,
|
||||
durationS: 0.12,
|
||||
delayS: 0,
|
||||
});
|
||||
}
|
||||
|
||||
/** Play an item drop sound effect by name using the shared AudioContext */
|
||||
playItemDropSound(soundName?: string): void {
|
||||
this.playSound(soundName);
|
||||
}
|
||||
|
||||
/** Play a sound effect one-shot */
|
||||
playSfx(name: string) {
|
||||
this.ensureContext();
|
||||
if (!this.ctx || !this.sfxGain) return;
|
||||
if (this.ctx.state === 'suspended') this.ctx.resume().catch(() => {});
|
||||
playSfx(name: string): void {
|
||||
if (name === 'inventory_full' && !this.buffers.has(name)) {
|
||||
this.playTone({
|
||||
type: 'sawtooth',
|
||||
from: 175,
|
||||
to: 90,
|
||||
gain: 0.3,
|
||||
durationS: 0.3,
|
||||
delayS: 0,
|
||||
});
|
||||
this.playTone({
|
||||
type: 'triangle',
|
||||
from: 110,
|
||||
to: 45,
|
||||
gain: 0.2,
|
||||
durationS: 0.1,
|
||||
delayS: 0.35,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const ctx = this.ensureContext();
|
||||
if (!ctx || !this.sfxGain) return;
|
||||
|
||||
const buffer = this.buffers.get(name);
|
||||
if (!buffer) return;
|
||||
|
||||
const source = this.ctx.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
source.connect(this.sfxGain);
|
||||
source.start();
|
||||
try {
|
||||
const source = ctx.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
source.connect(this.sfxGain);
|
||||
const cleanup = () => {
|
||||
try {
|
||||
source.disconnect?.();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
if (typeof source.addEventListener === 'function') {
|
||||
source.addEventListener('ended', cleanup, { once: true });
|
||||
} else {
|
||||
source.onended = cleanup;
|
||||
}
|
||||
source.start();
|
||||
} catch (err) {
|
||||
warnOnce(err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Play music (loops, switches without crossfade) */
|
||||
playMusic(name: string) {
|
||||
this.ensureContext();
|
||||
if (!this.ctx || !this.musicGain) return;
|
||||
if (this.ctx.state === 'suspended') this.ctx.resume().catch(() => {});
|
||||
playMusic(name: string): void {
|
||||
const ctx = this.ensureContext();
|
||||
if (!ctx || !this.musicGain) return;
|
||||
|
||||
const buffer = this.buffers.get(name);
|
||||
if (!buffer) return;
|
||||
|
||||
// Stop current music
|
||||
try {
|
||||
// Stop current music
|
||||
if (this.currentMusicSource) {
|
||||
this.currentMusicSource.stop();
|
||||
this.currentMusicSource.disconnect();
|
||||
this.currentMusicSource = null;
|
||||
}
|
||||
|
||||
const source = ctx.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
source.loop = true;
|
||||
source.connect(this.musicGain);
|
||||
source.start();
|
||||
|
||||
this.currentMusicSource = source;
|
||||
} catch (err) {
|
||||
warnOnce(err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Close the shared AudioContext and release audio resources */
|
||||
dispose(): void {
|
||||
if (this.currentMusicSource) {
|
||||
this.currentMusicSource.stop();
|
||||
this.currentMusicSource.disconnect();
|
||||
try {
|
||||
this.currentMusicSource.stop();
|
||||
this.currentMusicSource.disconnect();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
this.currentMusicSource = null;
|
||||
}
|
||||
const ctxToClose = this.ctx ?? sharedCtx;
|
||||
this.ctx = null;
|
||||
this.masterGain = null;
|
||||
this.sfxGain = null;
|
||||
this.musicGain = null;
|
||||
sharedCtx = null;
|
||||
sharedCtxCtor = null;
|
||||
sharedMasterGain = null;
|
||||
sharedSfxGain = null;
|
||||
sharedMusicGain = null;
|
||||
warnedAudioFailure = false;
|
||||
if (ctxToClose && ctxToClose.state !== 'closed' && typeof ctxToClose.close === 'function') {
|
||||
void Promise.resolve(ctxToClose.close()).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
const source = this.ctx.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
source.loop = true;
|
||||
source.connect(this.musicGain);
|
||||
source.start();
|
||||
static getShared(): AudioManager {
|
||||
return sharedAudioManager;
|
||||
}
|
||||
|
||||
this.currentMusicSource = source;
|
||||
static playTone(spec: ToneSpec): void {
|
||||
sharedAudioManager.playTone(spec);
|
||||
}
|
||||
|
||||
static playSound(soundOrSpec?: string | ToneSpec): void {
|
||||
sharedAudioManager.playSound(soundOrSpec);
|
||||
}
|
||||
|
||||
static playSfx(name: string): void {
|
||||
sharedAudioManager.playSfx(name);
|
||||
}
|
||||
|
||||
static dispose(): void {
|
||||
sharedAudioManager.dispose();
|
||||
}
|
||||
|
||||
static resetSharedContextForTesting(): void {
|
||||
sharedAudioManager.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
export class SoundManager extends AudioManager {}
|
||||
|
||||
export const sharedAudioManager = new AudioManager();
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,212 @@
|
|||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AudioManager, SoundManager, sharedAudioManager } from '../src/audio/manager.ts'
|
||||
import { playStashOpenSound } from '../src/scene/frontend/mouse-controller.ts'
|
||||
import { playInventoryFullFeedback } from '../src/scene/frontend/overhead-labels.ts'
|
||||
|
||||
interface MockOscillatorNode {
|
||||
type: string
|
||||
frequency: {
|
||||
value: number
|
||||
setValueAtTime: ReturnType<typeof vi.fn>
|
||||
exponentialRampToValueAtTime: ReturnType<typeof vi.fn>
|
||||
}
|
||||
connect: ReturnType<typeof vi.fn>
|
||||
disconnect: ReturnType<typeof vi.fn>
|
||||
start: ReturnType<typeof vi.fn>
|
||||
stop: ReturnType<typeof vi.fn>
|
||||
addEventListener: ReturnType<typeof vi.fn>
|
||||
onended: (() => void) | null
|
||||
triggerEnded: () => void
|
||||
}
|
||||
|
||||
interface MockGainNode {
|
||||
gain: {
|
||||
value: number
|
||||
setValueAtTime: ReturnType<typeof vi.fn>
|
||||
linearRampToValueAtTime: ReturnType<typeof vi.fn>
|
||||
exponentialRampToValueAtTime: ReturnType<typeof vi.fn>
|
||||
}
|
||||
connect: ReturnType<typeof vi.fn>
|
||||
disconnect: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
describe('P0 #528: Shared Singleton AudioContext & Web Audio Lifecycle', () => {
|
||||
let ctorCount = 0
|
||||
let resumeCalls = 0
|
||||
let closeCalls = 0
|
||||
let createdOscillators: MockOscillatorNode[] = []
|
||||
let createdGains: MockGainNode[] = []
|
||||
let mockState: 'running' | 'suspended' | 'closed' = 'running'
|
||||
|
||||
beforeEach(() => {
|
||||
ctorCount = 0
|
||||
resumeCalls = 0
|
||||
closeCalls = 0
|
||||
createdOscillators = []
|
||||
createdGains = []
|
||||
mockState = 'running'
|
||||
AudioManager.resetSharedContextForTesting()
|
||||
|
||||
class MockAudioContext {
|
||||
currentTime = 10.0
|
||||
sampleRate = 22050
|
||||
destination = {}
|
||||
get state() {
|
||||
return mockState
|
||||
}
|
||||
constructor() {
|
||||
ctorCount += 1
|
||||
}
|
||||
resume = vi.fn(async () => {
|
||||
resumeCalls += 1
|
||||
mockState = 'running'
|
||||
})
|
||||
close = vi.fn(async () => {
|
||||
closeCalls += 1
|
||||
mockState = 'closed'
|
||||
})
|
||||
createOscillator = vi.fn((): MockOscillatorNode => {
|
||||
const listeners: Array<() => void> = []
|
||||
const osc: MockOscillatorNode = {
|
||||
type: 'sine',
|
||||
frequency: {
|
||||
value: 440,
|
||||
setValueAtTime: vi.fn(),
|
||||
exponentialRampToValueAtTime: vi.fn(),
|
||||
},
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
addEventListener: vi.fn((event: string, cb: () => void) => {
|
||||
if (event === 'ended') listeners.push(cb)
|
||||
}),
|
||||
onended: null,
|
||||
triggerEnded: () => {
|
||||
for (const cb of listeners) cb()
|
||||
if (osc.onended) osc.onended()
|
||||
},
|
||||
}
|
||||
createdOscillators.push(osc)
|
||||
return osc
|
||||
})
|
||||
createGain = vi.fn((): MockGainNode => {
|
||||
const gain: MockGainNode = {
|
||||
gain: {
|
||||
value: 1,
|
||||
setValueAtTime: vi.fn(),
|
||||
linearRampToValueAtTime: vi.fn(),
|
||||
exponentialRampToValueAtTime: vi.fn(),
|
||||
},
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
}
|
||||
createdGains.push(gain)
|
||||
return gain
|
||||
})
|
||||
createBuffer = vi.fn((_channels: number, length: number, sampleRate: number) => ({
|
||||
length,
|
||||
sampleRate,
|
||||
getChannelData: () => new Float32Array(length),
|
||||
}))
|
||||
createBufferSource = vi.fn(() => {
|
||||
const listeners: Array<() => void> = []
|
||||
return {
|
||||
buffer: null,
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
start: vi.fn(),
|
||||
addEventListener: vi.fn((event: string, cb: () => void) => {
|
||||
if (event === 'ended') listeners.push(cb)
|
||||
}),
|
||||
onended: null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
vi.stubGlobal('AudioContext', MockAudioContext)
|
||||
vi.stubGlobal('window', { AudioContext: MockAudioContext })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
AudioManager.resetSharedContextForTesting()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('constructs at most 1 AudioContext across 50 sound and feedback triggers', () => {
|
||||
const managerA = new AudioManager()
|
||||
const managerB = new SoundManager()
|
||||
|
||||
for (let i = 0; i < 20; i++) {
|
||||
playInventoryFullFeedback()
|
||||
playStashOpenSound(managerA)
|
||||
managerA.playItemDropSound('item_ring_1')
|
||||
managerB.playSound('item_gold_1')
|
||||
SoundManager.playTone({ type: 'sine', from: 220, to: 180, durationS: 0.1, gain: 0.1 })
|
||||
}
|
||||
|
||||
expect(ctorCount).toBe(1)
|
||||
})
|
||||
|
||||
it('automatically calls resume() when the shared AudioContext is suspended', () => {
|
||||
mockState = 'suspended'
|
||||
playInventoryFullFeedback()
|
||||
expect(ctorCount).toBe(1)
|
||||
expect(resumeCalls).toBeGreaterThanOrEqual(1)
|
||||
expect(mockState).toBe('running')
|
||||
})
|
||||
|
||||
it('schedules delayed tones on ctx.currentTime without setTimeout and disconnects nodes on ended', () => {
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout')
|
||||
playInventoryFullFeedback()
|
||||
|
||||
// playInventoryFullFeedback plays two tones: delayS = 0 and delayS = 0.35
|
||||
expect(setTimeoutSpy).not.toHaveBeenCalled()
|
||||
expect(createdOscillators.length).toBe(2)
|
||||
|
||||
const firstOsc = createdOscillators[0]!
|
||||
const secondOsc = createdOscillators[1]!
|
||||
expect(firstOsc.start).toHaveBeenCalledWith(10.0)
|
||||
expect(secondOsc.start).toHaveBeenCalledWith(10.35)
|
||||
|
||||
// Trigger ended on both oscillators and verify disconnect() cleanup
|
||||
const toneGains = createdGains.slice(-2)
|
||||
firstOsc.triggerEnded()
|
||||
secondOsc.triggerEnded()
|
||||
expect(firstOsc.disconnect).toHaveBeenCalled()
|
||||
expect(secondOsc.disconnect).toHaveBeenCalled()
|
||||
expect(toneGains[0]!.disconnect).toHaveBeenCalled()
|
||||
expect(toneGains[1]!.disconnect).toHaveBeenCalled()
|
||||
|
||||
setTimeoutSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('closes the shared AudioContext on dispose() and recreates cleanly if used again', () => {
|
||||
sharedAudioManager.playItemDropSound('item_potion_1')
|
||||
expect(ctorCount).toBe(1)
|
||||
|
||||
sharedAudioManager.dispose()
|
||||
expect(closeCalls).toBe(1)
|
||||
|
||||
// Subsequent sound after dispose creates a fresh context
|
||||
sharedAudioManager.playItemDropSound('item_potion_1')
|
||||
expect(ctorCount).toBe(2)
|
||||
})
|
||||
|
||||
it('has zero raw new AudioContext instantiations in overhead-labels.ts and mouse-controller.ts', () => {
|
||||
const overheadSrc = readFileSync(
|
||||
resolve(process.cwd(), 'src/scene/frontend/overhead-labels.ts'),
|
||||
'utf8',
|
||||
)
|
||||
const mouseSrc = readFileSync(
|
||||
resolve(process.cwd(), 'src/scene/frontend/mouse-controller.ts'),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
expect(overheadSrc).not.toMatch(/new\s+AudioCtx|webkitAudioContext/)
|
||||
expect(overheadSrc).not.toContain('I cannot carry anymore')
|
||||
expect(mouseSrc).not.toMatch(/new\s+AudioCtx|webkitAudioContext/)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue