From 39e15575baa413b6bba0e3d10ed5fbc5609b4c92 Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Sat, 28 Mar 2026 03:34:54 -0400 Subject: [PATCH] make gain slider work with native audio --- src/renderer/audio/AudioCapture.ts | 25 ++++++++++++++++---- src/renderer/audio/inputGain.ts | 21 +++++++++++++++++ test/renderer-helpers.test.ts | 37 ++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 5 deletions(-) create mode 100644 src/renderer/audio/inputGain.ts diff --git a/src/renderer/audio/AudioCapture.ts b/src/renderer/audio/AudioCapture.ts index e6d2046..91c8747 100644 --- a/src/renderer/audio/AudioCapture.ts +++ b/src/renderer/audio/AudioCapture.ts @@ -5,6 +5,7 @@ */ import { audioRouter } from './AudioRouter' +import { applyInputGainToStereoSamples, inputGainDbToLinear } from './inputGain' import type { CaptureBackendKind, CaptureBackendPolicy, @@ -120,11 +121,11 @@ class ElectronCaptureRuntime { private sequence = 0 private sampleRate = 48000 private channelCount = 2 + private inputGainLinear = 1 setInputGain(db: number): void { - if (this.gainNode) { - this.gainNode.gain.value = Math.pow(10, db / 20) - } + this.inputGainLinear = inputGainDbToLinear(db) + this.syncGainNode() } subscribe(listener: (chunk: CaptureChunk) => void): () => void { @@ -202,8 +203,8 @@ class ElectronCaptureRuntime { if (!this.gainNode) { this.gainNode = this.audioContext.createGain() - this.gainNode.gain.value = 1.0 } + this.syncGainNode() if (!this.workletNode) { this.workletNode = new AudioWorkletNode(this.audioContext, 'capture-processor', { @@ -266,6 +267,12 @@ class ElectronCaptureRuntime { this.currentConfigKey = configKey } + private syncGainNode(): void { + if (this.gainNode) { + this.gainNode.gain.value = this.inputGainLinear + } + } + private async requestSystemStream(): Promise { const sources = await window.electronAPI.getDesktopSources() if (!sources.length) { @@ -575,6 +582,8 @@ class AudioCapture { private backendPolicy: CaptureBackendPolicy = DEFAULT_BACKEND_POLICY private activeBackendReason: string | null = null private sessionId: number | null = null + private inputGainDb = 0 + private inputGainLinear = 1 private statusListeners = new Set() constructor() { @@ -819,6 +828,10 @@ class AudioCapture { return } + if (originKind.startsWith('native-') && this.inputGainLinear !== 1) { + applyInputGainToStereoSamples(chunk.left, chunk.right, this.inputGainLinear) + } + audioRouter.ingestChunk(chunk.left, chunk.right, { sessionId: this.sessionId, channelCount: chunk.channelCount, @@ -828,7 +841,9 @@ class AudioCapture { } setInputGain(db: number): void { - this.electronRuntime.setInputGain(db) + this.inputGainDb = db + this.inputGainLinear = inputGainDbToLinear(this.inputGainDb) + this.electronRuntime.setInputGain(this.inputGainDb) } private emitStatus(): void { diff --git a/src/renderer/audio/inputGain.ts b/src/renderer/audio/inputGain.ts new file mode 100644 index 0000000..40b597d --- /dev/null +++ b/src/renderer/audio/inputGain.ts @@ -0,0 +1,21 @@ +export function inputGainDbToLinear(db: number): number { + return Math.pow(10, db / 20) +} + +export function applyInputGainToStereoSamples( + left: Float32Array, + right: Float32Array, + linearGain: number, +): void { + if (linearGain === 1) { + return + } + + for (let index = 0; index < left.length; index += 1) { + left[index] *= linearGain + } + + for (let index = 0; index < right.length; index += 1) { + right[index] *= linearGain + } +} diff --git a/test/renderer-helpers.test.ts b/test/renderer-helpers.test.ts index 52b791a..060312b 100644 --- a/test/renderer-helpers.test.ts +++ b/test/renderer-helpers.test.ts @@ -6,6 +6,10 @@ import { parseColorToRgb, resolveColorToRgb, } from '../src/renderer/utils/color' +import { + applyInputGainToStereoSamples, + inputGainDbToLinear, +} from '../src/renderer/audio/inputGain' import { moveDockedScopeOrder } from '../src/renderer/stores/settingsStore' import { SCOPE_KINDS, type ScopeKind } from '../src/types/scope' import type { ScopePopoutStateMap } from '../src/types/popout' @@ -116,6 +120,39 @@ test('color helpers fall back predictably for invalid values', () => { assert.deepEqual(resolveColorToRgb('rgb(4, 5, 6)'), { r: 4, g: 5, b: 6 }) }) +test('inputGainDbToLinear converts dB offsets to expected linear gain values', () => { + assert.equal(inputGainDbToLinear(0), 1) + assertAlmostEqual(inputGainDbToLinear(6), 1.9952623149688795, 1e-12, '+6 dB gain') + assertAlmostEqual(inputGainDbToLinear(-6), 0.5011872336272722, 1e-12, '-6 dB gain') +}) + +test('applyInputGainToStereoSamples scales both channels in place', () => { + const { left, right } = createFilledStereoChunk(0.25, -0.5, 3) + const linearGain = inputGainDbToLinear(6) + + applyInputGainToStereoSamples(left, right, linearGain) + + for (const sample of left) { + assertAlmostEqual(sample, 0.25 * linearGain, 1e-6, 'left channel scaled') + } + + for (const sample of right) { + assertAlmostEqual(sample, -0.5 * linearGain, 1e-6, 'right channel scaled') + } +}) + +test('applyInputGainToStereoSamples is a no-op for unity gain', () => { + const left = new Float32Array([0.1, -0.2, 0.3]) + const right = new Float32Array([-0.4, 0.5, -0.6]) + const initialLeft = Array.from(left) + const initialRight = Array.from(right) + + applyInputGainToStereoSamples(left, right, 1) + + assert.deepEqual(Array.from(left), initialLeft) + assert.deepEqual(Array.from(right), initialRight) +}) + test('VisualizerFrameLoop renders one invalidated frame while idle', () => { const raf = installFakeAnimationFrame()