mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 21:45:31 +09:00
refactor(app): name mute and unread state accessors by intent (#1859)
This commit is contained in:
1 parent
52781cfa2d
commit
a2099fb0e2
33 files changed
+173
-490
No files matched your search
@@ -1,192 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
vi.mock('@app/features/app/state/RuntimeConfig', () => ({
|
||||
default: {staticCdnEndpoint: 'https://cdn.test'},
|
||||
}));
|
||||
|
||||
vi.mock('@app/features/voice/state/VoiceSettings', () => ({
|
||||
default: {
|
||||
getDeepFilterNoiseSuppression: () => true,
|
||||
getDeepFilterNoiseSuppressionLevel: () => 80,
|
||||
},
|
||||
}));
|
||||
|
||||
const deepFilterHarness = vi.hoisted(() => ({
|
||||
instances: [] as Array<{
|
||||
audioContext: AudioContext | null;
|
||||
processedTrack: MediaStreamTrack | null;
|
||||
sampleRateAtInit: number | null;
|
||||
}>,
|
||||
}));
|
||||
|
||||
vi.mock('deepfilternet3-noise-filter', () => ({
|
||||
DeepFilterNoiseFilterProcessor: class {
|
||||
audioContext: AudioContext | null = null;
|
||||
processedTrack: MediaStreamTrack | null = null;
|
||||
sampleRateAtInit: number | null = null;
|
||||
|
||||
constructor() {
|
||||
deepFilterHarness.instances.push(this);
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
this.sampleRateAtInit = this.audioContext?.sampleRate ?? null;
|
||||
this.processedTrack = {} as MediaStreamTrack;
|
||||
}
|
||||
|
||||
async destroy(): Promise<void> {}
|
||||
},
|
||||
}));
|
||||
|
||||
const MODEL_SAMPLE_RATE = 48000;
|
||||
|
||||
interface ContextHarness {
|
||||
requestedRates: Array<number | undefined>;
|
||||
closedRates: Array<number>;
|
||||
}
|
||||
|
||||
function installAudioGraph(config: {hardwareSampleRate: number; canBridgeSampleRates: boolean}): ContextHarness {
|
||||
const harness: ContextHarness = {requestedRates: [], closedRates: []};
|
||||
|
||||
class FakeAudioContext {
|
||||
state: AudioContextState = 'running';
|
||||
sampleRate: number;
|
||||
|
||||
constructor(options?: AudioContextOptions) {
|
||||
harness.requestedRates.push(options?.sampleRate);
|
||||
this.sampleRate = options?.sampleRate ?? config.hardwareSampleRate;
|
||||
}
|
||||
|
||||
createMediaStreamSource(): {disconnect: () => void} {
|
||||
if (!config.canBridgeSampleRates && this.sampleRate !== config.hardwareSampleRate) {
|
||||
throw new DOMException(
|
||||
'Connecting AudioNodes from AudioContexts with different sample-rate is currently not supported.',
|
||||
'NotSupportedError',
|
||||
);
|
||||
}
|
||||
return {disconnect: (): void => undefined};
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
harness.closedRates.push(this.sampleRate);
|
||||
this.state = 'closed';
|
||||
}
|
||||
}
|
||||
|
||||
vi.stubGlobal('window', {AudioContext: FakeAudioContext});
|
||||
vi.stubGlobal('MediaStream', class {});
|
||||
return harness;
|
||||
}
|
||||
|
||||
async function loadModule() {
|
||||
vi.resetModules();
|
||||
return await import('@app/features/voice/utils/DeepFilterNoiseProcessor');
|
||||
}
|
||||
|
||||
function createSourceContext(sampleRate: number): AudioContext {
|
||||
return {sampleRate} as unknown as AudioContext;
|
||||
}
|
||||
|
||||
const feedTrack = {} as MediaStreamTrack;
|
||||
|
||||
describe('resolveDeepFilterAudioContext', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('runs the worklet at the model rate when the capture graph already runs there', async () => {
|
||||
const harness = installAudioGraph({hardwareSampleRate: MODEL_SAMPLE_RATE, canBridgeSampleRates: false});
|
||||
const {resolveDeepFilterAudioContext} = await loadModule();
|
||||
|
||||
const context = resolveDeepFilterAudioContext(createSourceContext(MODEL_SAMPLE_RATE), feedTrack);
|
||||
|
||||
expect(context?.sampleRate).toBe(MODEL_SAMPLE_RATE);
|
||||
expect(harness.requestedRates).toEqual([MODEL_SAMPLE_RATE]);
|
||||
expect(harness.closedRates).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps the model rate on browsers that resample across graphs', async () => {
|
||||
const harness = installAudioGraph({hardwareSampleRate: 44100, canBridgeSampleRates: true});
|
||||
const {resolveDeepFilterAudioContext} = await loadModule();
|
||||
|
||||
const context = resolveDeepFilterAudioContext(createSourceContext(44100), feedTrack);
|
||||
|
||||
expect(context?.sampleRate).toBe(MODEL_SAMPLE_RATE);
|
||||
expect(harness.requestedRates).toEqual([MODEL_SAMPLE_RATE]);
|
||||
expect(harness.closedRates).toEqual([]);
|
||||
});
|
||||
|
||||
it('falls back to the capture rate when the browser refuses to bridge sample rates', async () => {
|
||||
const harness = installAudioGraph({hardwareSampleRate: 44100, canBridgeSampleRates: false});
|
||||
const {resolveDeepFilterAudioContext} = await loadModule();
|
||||
|
||||
const context = resolveDeepFilterAudioContext(createSourceContext(44100), feedTrack);
|
||||
|
||||
expect(context?.sampleRate).toBe(44100);
|
||||
expect(harness.requestedRates).toEqual([MODEL_SAMPLE_RATE, 44100]);
|
||||
expect(harness.closedRates).toEqual([MODEL_SAMPLE_RATE]);
|
||||
});
|
||||
|
||||
it('falls back to the capture rate for a bluetooth headset running well below the model rate', async () => {
|
||||
installAudioGraph({hardwareSampleRate: 16000, canBridgeSampleRates: false});
|
||||
const {resolveDeepFilterAudioContext} = await loadModule();
|
||||
|
||||
const context = resolveDeepFilterAudioContext(createSourceContext(16000), feedTrack);
|
||||
|
||||
expect(context?.sampleRate).toBe(16000);
|
||||
});
|
||||
});
|
||||
|
||||
function createCaptureContext(sampleRate: number): AudioContext {
|
||||
const track = {stop: (): void => undefined} as unknown as MediaStreamTrack;
|
||||
const node = {
|
||||
connect: (next: unknown): unknown => next,
|
||||
disconnect: (): void => undefined,
|
||||
stream: {getAudioTracks: (): Array<MediaStreamTrack> => [track]},
|
||||
type: '',
|
||||
frequency: {value: 0},
|
||||
Q: {value: 0},
|
||||
threshold: {value: 0},
|
||||
knee: {value: 0},
|
||||
ratio: {value: 0},
|
||||
attack: {value: 0},
|
||||
release: {value: 0},
|
||||
};
|
||||
return {
|
||||
sampleRate,
|
||||
createMediaStreamDestination: () => node,
|
||||
createMediaStreamSource: () => node,
|
||||
createBiquadFilter: () => node,
|
||||
createDynamicsCompressor: () => node,
|
||||
} as unknown as AudioContext;
|
||||
}
|
||||
|
||||
describe('buildDeepFilterAudioChain', () => {
|
||||
beforeEach(() => {
|
||||
deepFilterHarness.instances.length = 0;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('gives the processor its audio context before the processor builds its own graph', async () => {
|
||||
installAudioGraph({hardwareSampleRate: MODEL_SAMPLE_RATE, canBridgeSampleRates: false});
|
||||
const {buildDeepFilterAudioChain} = await loadModule();
|
||||
|
||||
await buildDeepFilterAudioChain({audioContext: createCaptureContext(MODEL_SAMPLE_RATE)});
|
||||
|
||||
expect(deepFilterHarness.instances[0]?.sampleRateAtInit).toBe(MODEL_SAMPLE_RATE);
|
||||
});
|
||||
|
||||
it('gives the processor a capture-rate context when the browser refuses to bridge sample rates', async () => {
|
||||
installAudioGraph({hardwareSampleRate: 44100, canBridgeSampleRates: false});
|
||||
const {buildDeepFilterAudioChain} = await loadModule();
|
||||
|
||||
await buildDeepFilterAudioChain({audioContext: createCaptureContext(44100)});
|
||||
|
||||
expect(deepFilterHarness.instances[0]?.sampleRateAtInit).toBe(44100);
|
||||
});
|
||||
});
|
||||
@@ -1,68 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const master = {connect: vi.fn(), disconnect: vi.fn()};
|
||||
const createdGains: Array<{connect: ReturnType<typeof vi.fn>; disconnect: ReturnType<typeof vi.fn>}> = [];
|
||||
let captureActive = true;
|
||||
|
||||
vi.mock('@app/features/notification/utils/SoundUtils', () => ({
|
||||
getSoundCaptureAudioContext: () => ({
|
||||
createMediaElementSource: vi.fn(() => ({connect: vi.fn(), disconnect: vi.fn()})),
|
||||
createGain: vi.fn(() => {
|
||||
const gain = {connect: vi.fn(), disconnect: vi.fn(), gain: {value: 1}};
|
||||
createdGains.push(gain);
|
||||
return gain;
|
||||
}),
|
||||
}),
|
||||
getSoundCaptureMasterGainNode: () => master,
|
||||
isSoundCaptureActive: () => captureActive,
|
||||
onSoundCaptureActivated: () => () => undefined,
|
||||
}));
|
||||
|
||||
const {routeMediaElementForSoundCapture} = await import('@app/features/voice/utils/InAppMediaSoundCapture');
|
||||
|
||||
function createElement(): HTMLMediaElement {
|
||||
const listeners = new Map<string, () => void>();
|
||||
return {
|
||||
volume: 1,
|
||||
muted: false,
|
||||
addEventListener: (type: string, listener: () => void) => listeners.set(type, listener),
|
||||
removeEventListener: (type: string) => listeners.delete(type),
|
||||
listenerCount: () => listeners.size,
|
||||
} as unknown as HTMLMediaElement & {listenerCount: () => number};
|
||||
}
|
||||
|
||||
describe('in-app media sound capture', () => {
|
||||
beforeEach(() => {
|
||||
createdGains.length = 0;
|
||||
captureActive = true;
|
||||
});
|
||||
|
||||
it('disconnects the element from the master graph when the caller disposes', () => {
|
||||
const element = createElement();
|
||||
const dispose = routeMediaElementForSoundCapture(element);
|
||||
const gain = createdGains.at(-1);
|
||||
expect(gain?.connect).toHaveBeenCalledWith(master);
|
||||
dispose();
|
||||
expect(gain?.disconnect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('removes the volume listener when the caller disposes', () => {
|
||||
const element = createElement() as HTMLMediaElement & {listenerCount: () => number};
|
||||
const dispose = routeMediaElementForSoundCapture(element);
|
||||
expect(element.listenerCount()).toBe(1);
|
||||
dispose();
|
||||
expect(element.listenerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('reuses the existing source node when the same element is routed again', () => {
|
||||
const element = createElement();
|
||||
routeMediaElementForSoundCapture(element)();
|
||||
const gainsAfterFirst = createdGains.length;
|
||||
const dispose = routeMediaElementForSoundCapture(element);
|
||||
expect(createdGains.length).toBe(gainsAfterFirst);
|
||||
expect(createdGains.at(-1)?.connect).toHaveBeenCalledTimes(2);
|
||||
dispose();
|
||||
});
|
||||
});
|
||||
@@ -1,67 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
FOCUSED_VOICE_DEEP_FILTER_NOISE_REDUCTION_LEVEL,
|
||||
resolveVoiceProcessing,
|
||||
type VoiceProcessingSettingsLike,
|
||||
} from '@app/features/voice/utils/VoiceProcessingProfile';
|
||||
import {describe, expect, it, vi} from 'vitest';
|
||||
|
||||
vi.mock('@app/features/voice/engine/VoiceDevicePermissionState', () => ({
|
||||
default: {getState: () => ({inputDevices: []})},
|
||||
}));
|
||||
|
||||
vi.mock('@app/features/voice/utils/VoiceDeviceManager', () => ({
|
||||
resolveEffectiveDeviceId: () => null,
|
||||
}));
|
||||
|
||||
function createSettings(overrides: Partial<VoiceProcessingSettingsLike> = {}): VoiceProcessingSettingsLike {
|
||||
return {
|
||||
voiceProcessingMode: 'voice',
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true,
|
||||
deepFilterNoiseSuppression: true,
|
||||
deepFilterNoiseSuppressionLevel: 80,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('resolveVoiceProcessing', () => {
|
||||
it('keeps automatic gain control on for focused voice at factory defaults', () => {
|
||||
const resolved = resolveVoiceProcessing(createSettings());
|
||||
expect(resolved.mode).toBe('voice');
|
||||
expect(resolved.autoGainControl).toBe(true);
|
||||
expect(resolved.deepFilter).toBe(true);
|
||||
expect(resolved.deepFilterNoiseReductionLevel).toBe(FOCUSED_VOICE_DEEP_FILTER_NOISE_REDUCTION_LEVEL);
|
||||
});
|
||||
|
||||
it('honours an explicit opt out of automatic gain control in focused voice', () => {
|
||||
const resolved = resolveVoiceProcessing(createSettings({autoGainControl: false}));
|
||||
expect(resolved.autoGainControl).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps automatic gain control on in custom mode while deep filter is enabled', () => {
|
||||
const resolved = resolveVoiceProcessing(
|
||||
createSettings({voiceProcessingMode: 'custom', deepFilterNoiseSuppression: true}),
|
||||
);
|
||||
expect(resolved.autoGainControl).toBe(true);
|
||||
expect(resolved.deepFilter).toBe(true);
|
||||
});
|
||||
|
||||
it('still suppresses browser noise suppression when deep filter is enabled in custom mode', () => {
|
||||
const resolved = resolveVoiceProcessing(
|
||||
createSettings({voiceProcessingMode: 'custom', noiseSuppression: true, deepFilterNoiseSuppression: true}),
|
||||
);
|
||||
expect(resolved.browserNoiseSuppression).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves studio mode as the untouched bypass', () => {
|
||||
const resolved = resolveVoiceProcessing(createSettings({voiceProcessingMode: 'studio'}));
|
||||
expect(resolved.autoGainControl).toBe(false);
|
||||
expect(resolved.echoCancellation).toBe(false);
|
||||
expect(resolved.browserNoiseSuppression).toBe(false);
|
||||
expect(resolved.deepFilter).toBe(false);
|
||||
expect(resolved.contentHint).toBe('music');
|
||||
});
|
||||
});
|
||||
@@ -419,7 +419,7 @@ function handleIncomingTtsMessage(message: Message): void {
|
||||
}
|
||||
if (
|
||||
UserGuildSettings.isGuildOrChannelMuted(channel.guildId ?? null, channel.id) ||
|
||||
UserGuildSettings.isCategoryMuted(channel.guildId ?? null, channel.id)
|
||||
UserGuildSettings.isParentCategoryMuted(channel.guildId ?? null, channel.id)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user