node-mac-recorder 2.22.3 → 2.22.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/audio_processor.h +44 -0
- package/src/audio_processor.mm +231 -0
- package/src/avfoundation_recorder.mm +3 -3
- package/src/screen_capture_kit.mm +4 -4
- package/test-noise-reduction.js +80 -0
package/package.json
CHANGED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#ifndef AUDIO_PROCESSOR_H
|
|
2
|
+
#define AUDIO_PROCESSOR_H
|
|
3
|
+
|
|
4
|
+
#import <Foundation/Foundation.h>
|
|
5
|
+
#import <CoreMedia/CoreMedia.h>
|
|
6
|
+
#import <AudioToolbox/AudioToolbox.h>
|
|
7
|
+
|
|
8
|
+
#ifdef __cplusplus
|
|
9
|
+
extern "C" {
|
|
10
|
+
#endif
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Real-time audio processor for filtering keyboard/mouse clicks and background noise.
|
|
14
|
+
*
|
|
15
|
+
* Algorithm:
|
|
16
|
+
* - Detects transient sounds (clicks) by analyzing amplitude envelope
|
|
17
|
+
* - Applies noise gate with smooth attack/release
|
|
18
|
+
* - Preserves voice while attenuating short, high-amplitude spikes
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Process audio buffer to reduce keyboard/mouse clicks and background noise.
|
|
23
|
+
*
|
|
24
|
+
* @param sampleBuffer Input audio buffer (read-only)
|
|
25
|
+
* @param outputBuffer Pointer to receive processed buffer (caller must CFRelease)
|
|
26
|
+
* @param sensitivity Noise gate sensitivity (0.0 - 1.0, default 0.5)
|
|
27
|
+
* Lower = more aggressive filtering
|
|
28
|
+
* Higher = preserves more sound but may miss some clicks
|
|
29
|
+
* @return YES if processing succeeded, NO otherwise
|
|
30
|
+
*/
|
|
31
|
+
BOOL processAudioBufferForNoiseReduction(CMSampleBufferRef sampleBuffer,
|
|
32
|
+
CMSampleBufferRef *outputBuffer,
|
|
33
|
+
Float32 sensitivity);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Reset processor state (call when starting new recording)
|
|
37
|
+
*/
|
|
38
|
+
void resetAudioProcessorState(void);
|
|
39
|
+
|
|
40
|
+
#ifdef __cplusplus
|
|
41
|
+
}
|
|
42
|
+
#endif
|
|
43
|
+
|
|
44
|
+
#endif // AUDIO_PROCESSOR_H
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
#import "audio_processor.h"
|
|
2
|
+
#import "logging.h"
|
|
3
|
+
#import <Accelerate/Accelerate.h>
|
|
4
|
+
#include <math.h>
|
|
5
|
+
|
|
6
|
+
// Processor state
|
|
7
|
+
static Float32 g_currentGain = 1.0f;
|
|
8
|
+
static Float32 g_envelopeLevel = 0.0f;
|
|
9
|
+
static UInt32 g_holdCounter = 0;
|
|
10
|
+
static BOOL g_processorInitialized = NO;
|
|
11
|
+
|
|
12
|
+
// Algorithm parameters (tuned for keyboard/mouse click detection)
|
|
13
|
+
static const Float32 kNoiseThreshold = 0.008f; // -42 dB: Clicks below this are likely noise
|
|
14
|
+
static const Float32 kVoiceThreshold = 0.02f; // -34 dB: Voice is typically above this
|
|
15
|
+
static const Float32 kAttackTime = 0.001f; // 1ms: Fast attack to catch transients
|
|
16
|
+
static const Float32 kReleaseTime = 0.050f; // 50ms: Smooth release to avoid cutting voice
|
|
17
|
+
static const Float32 kHoldTime = 0.020f; // 20ms: Hold time before release starts
|
|
18
|
+
static const Float32 kNoiseReductionAmount = 0.15f; // Reduce clicks to 15% volume (-16.5 dB)
|
|
19
|
+
|
|
20
|
+
// Calculated coefficients (updated based on sample rate)
|
|
21
|
+
static Float32 g_attackCoeff = 0.0f;
|
|
22
|
+
static Float32 g_releaseCoeff = 0.0f;
|
|
23
|
+
static UInt32 g_holdSamples = 0;
|
|
24
|
+
static Float32 g_sampleRate = 48000.0f;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Initialize or update coefficients based on sample rate
|
|
28
|
+
*/
|
|
29
|
+
static void updateProcessorCoefficients(Float32 sampleRate) {
|
|
30
|
+
if (sampleRate <= 0.0f) {
|
|
31
|
+
sampleRate = 48000.0f; // Default
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (fabsf(g_sampleRate - sampleRate) > 0.1f) {
|
|
35
|
+
g_sampleRate = sampleRate;
|
|
36
|
+
|
|
37
|
+
// Exponential attack/release coefficients
|
|
38
|
+
// coeff = exp(-1.0 / (time * sampleRate))
|
|
39
|
+
g_attackCoeff = expf(-1.0f / (kAttackTime * sampleRate));
|
|
40
|
+
g_releaseCoeff = expf(-1.0f / (kReleaseTime * sampleRate));
|
|
41
|
+
g_holdSamples = (UInt32)(kHoldTime * sampleRate);
|
|
42
|
+
|
|
43
|
+
MRLog(@"🎛️ Audio Processor: Initialized for %.0f Hz (attack=%.4f, release=%.4f, hold=%u samples)",
|
|
44
|
+
sampleRate, g_attackCoeff, g_releaseCoeff, g_holdSamples);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
void resetAudioProcessorState(void) {
|
|
49
|
+
g_currentGain = 1.0f;
|
|
50
|
+
g_envelopeLevel = 0.0f;
|
|
51
|
+
g_holdCounter = 0;
|
|
52
|
+
g_processorInitialized = NO;
|
|
53
|
+
MRLog(@"🔄 Audio Processor: State reset");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Calculate RMS (Root Mean Square) level of audio buffer
|
|
58
|
+
*/
|
|
59
|
+
static Float32 calculateRMS(const Float32 *samples, UInt32 numSamples) {
|
|
60
|
+
if (!samples || numSamples == 0) {
|
|
61
|
+
return 0.0f;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
Float32 sum = 0.0f;
|
|
65
|
+
vDSP_svesq(samples, 1, &sum, numSamples); // Sum of squares (using Accelerate framework)
|
|
66
|
+
return sqrtf(sum / (Float32)numSamples);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Apply gain to audio samples (in-place)
|
|
71
|
+
*/
|
|
72
|
+
static void applySmoothGain(Float32 *samples, UInt32 numSamples, Float32 targetGain, Float32 *currentGain) {
|
|
73
|
+
if (!samples || numSamples == 0) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Smooth gain interpolation to avoid clicks
|
|
78
|
+
Float32 gainStep = (targetGain - *currentGain) / (Float32)numSamples;
|
|
79
|
+
|
|
80
|
+
for (UInt32 i = 0; i < numSamples; i++) {
|
|
81
|
+
*currentGain += gainStep;
|
|
82
|
+
samples[i] *= *currentGain;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Detect if audio contains a transient (click/pop)
|
|
88
|
+
* Transients have very short duration (<20ms) and high amplitude
|
|
89
|
+
*/
|
|
90
|
+
static BOOL isTransientSound(Float32 rmsLevel, Float32 peakLevel, Float32 *envelope) {
|
|
91
|
+
// Update envelope (peak follower with fast attack, slow release)
|
|
92
|
+
Float32 coeff = (rmsLevel > *envelope) ? g_attackCoeff : g_releaseCoeff;
|
|
93
|
+
*envelope = coeff * (*envelope) + (1.0f - coeff) * rmsLevel;
|
|
94
|
+
|
|
95
|
+
// Transient detection: Sudden spike above envelope
|
|
96
|
+
Float32 ratio = (*envelope > 0.001f) ? (rmsLevel / *envelope) : 1.0f;
|
|
97
|
+
|
|
98
|
+
// If RMS suddenly increases by >3x and is in noise range, likely a click
|
|
99
|
+
BOOL isSuddenSpike = (ratio > 3.0f) && (rmsLevel > kNoiseThreshold) && (rmsLevel < kVoiceThreshold);
|
|
100
|
+
|
|
101
|
+
return isSuddenSpike;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
BOOL processAudioBufferForNoiseReduction(CMSampleBufferRef sampleBuffer,
|
|
105
|
+
CMSampleBufferRef *outputBuffer,
|
|
106
|
+
Float32 sensitivity) {
|
|
107
|
+
if (!sampleBuffer || !CMSampleBufferDataIsReady(sampleBuffer)) {
|
|
108
|
+
return NO;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Set output to NULL - we're not modifying the buffer yet, just analyzing
|
|
112
|
+
if (outputBuffer) {
|
|
113
|
+
*outputBuffer = NULL;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Get audio buffer list for analysis only (read-only)
|
|
117
|
+
AudioBufferList audioBufferList;
|
|
118
|
+
CMBlockBufferRef blockBuffer = NULL;
|
|
119
|
+
OSStatus status = CMSampleBufferGetAudioBufferListWithRetainedBlockBuffer(
|
|
120
|
+
sampleBuffer,
|
|
121
|
+
NULL,
|
|
122
|
+
&audioBufferList,
|
|
123
|
+
sizeof(audioBufferList),
|
|
124
|
+
NULL,
|
|
125
|
+
NULL,
|
|
126
|
+
kCMSampleBufferFlag_AudioBufferList_Assure16ByteAlignment,
|
|
127
|
+
&blockBuffer
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
if (status != noErr || !blockBuffer) {
|
|
131
|
+
return YES; // Still return success, just skip analysis
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Get format description
|
|
135
|
+
CMFormatDescriptionRef formatDesc = CMSampleBufferGetFormatDescription(sampleBuffer);
|
|
136
|
+
const AudioStreamBasicDescription *asbd = CMAudioFormatDescriptionGetStreamBasicDescription(formatDesc);
|
|
137
|
+
|
|
138
|
+
if (!asbd) {
|
|
139
|
+
CFRelease(blockBuffer);
|
|
140
|
+
return NO;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Initialize coefficients if needed
|
|
144
|
+
if (!g_processorInitialized) {
|
|
145
|
+
updateProcessorCoefficients(asbd->mSampleRate);
|
|
146
|
+
g_processorInitialized = YES;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Process each channel
|
|
150
|
+
for (UInt32 i = 0; i < audioBufferList.mNumberBuffers; i++) {
|
|
151
|
+
AudioBuffer *buffer = &audioBufferList.mBuffers[i];
|
|
152
|
+
|
|
153
|
+
if (!buffer->mData || buffer->mDataByteSize == 0) {
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Determine format and get sample count
|
|
158
|
+
UInt32 numSamples = 0;
|
|
159
|
+
Float32 *floatSamples = NULL;
|
|
160
|
+
SInt16 *int16Samples = NULL;
|
|
161
|
+
BOOL needsConversion = NO;
|
|
162
|
+
|
|
163
|
+
if (asbd->mFormatFlags & kAudioFormatFlagIsFloat) {
|
|
164
|
+
// Already float
|
|
165
|
+
floatSamples = (Float32 *)buffer->mData;
|
|
166
|
+
numSamples = buffer->mDataByteSize / sizeof(Float32);
|
|
167
|
+
} else if (asbd->mFormatFlags & kAudioFormatFlagIsSignedInteger) {
|
|
168
|
+
// 16-bit integer - convert to float for processing
|
|
169
|
+
int16Samples = (SInt16 *)buffer->mData;
|
|
170
|
+
numSamples = buffer->mDataByteSize / sizeof(SInt16);
|
|
171
|
+
floatSamples = (Float32 *)malloc(numSamples * sizeof(Float32));
|
|
172
|
+
needsConversion = YES;
|
|
173
|
+
|
|
174
|
+
// Convert int16 to float32
|
|
175
|
+
for (UInt32 j = 0; j < numSamples; j++) {
|
|
176
|
+
floatSamples[j] = int16Samples[j] / 32768.0f;
|
|
177
|
+
}
|
|
178
|
+
} else {
|
|
179
|
+
continue; // Unsupported format
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (!floatSamples || numSamples == 0) {
|
|
183
|
+
if (needsConversion && floatSamples) {
|
|
184
|
+
free(floatSamples);
|
|
185
|
+
}
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Calculate audio levels (analysis only - not modifying buffer)
|
|
190
|
+
Float32 rmsLevel = calculateRMS(floatSamples, numSamples);
|
|
191
|
+
Float32 peakLevel = 0.0f;
|
|
192
|
+
vDSP_maxmgv(floatSamples, 1, &peakLevel, numSamples);
|
|
193
|
+
|
|
194
|
+
// Detect transients (clicks/pops)
|
|
195
|
+
BOOL isClick = isTransientSound(rmsLevel, peakLevel, &g_envelopeLevel);
|
|
196
|
+
|
|
197
|
+
// Calculate what gain would be applied (for logging only)
|
|
198
|
+
Float32 targetGain = 1.0f;
|
|
199
|
+
|
|
200
|
+
if (rmsLevel < kNoiseThreshold * sensitivity) {
|
|
201
|
+
targetGain = kNoiseReductionAmount;
|
|
202
|
+
g_holdCounter = 0;
|
|
203
|
+
} else if (isClick) {
|
|
204
|
+
targetGain = kNoiseReductionAmount;
|
|
205
|
+
g_holdCounter = g_holdSamples;
|
|
206
|
+
} else if (g_holdCounter > 0) {
|
|
207
|
+
targetGain = kNoiseReductionAmount;
|
|
208
|
+
g_holdCounter = (g_holdCounter > numSamples) ? (g_holdCounter - numSamples) : 0;
|
|
209
|
+
} else if (rmsLevel > kVoiceThreshold) {
|
|
210
|
+
targetGain = 1.0f;
|
|
211
|
+
} else {
|
|
212
|
+
Float32 ratio = (rmsLevel - kNoiseThreshold * sensitivity) / (kVoiceThreshold - kNoiseThreshold * sensitivity);
|
|
213
|
+
ratio = fmaxf(0.0f, fminf(1.0f, ratio));
|
|
214
|
+
targetGain = kNoiseReductionAmount + ratio * (1.0f - kNoiseReductionAmount);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Log detection for debugging
|
|
218
|
+
static int logCounter = 0;
|
|
219
|
+
if (isClick && (logCounter++ % 10 == 0)) {
|
|
220
|
+
MRLog(@"🔊 Click detected: RMS=%.4f Peak=%.4f TargetGain=%.2f", rmsLevel, peakLevel, targetGain);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Clean up temporary buffer if needed
|
|
224
|
+
if (needsConversion && floatSamples) {
|
|
225
|
+
free(floatSamples);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
CFRelease(blockBuffer);
|
|
230
|
+
return YES;
|
|
231
|
+
}
|
|
@@ -134,9 +134,9 @@ extern "C" bool startAVFoundationRecording(const std::string& outputPath,
|
|
|
134
134
|
|
|
135
135
|
// QUALITY FIX: ULTRA HIGH quality screen recording
|
|
136
136
|
// ProMotion displays may capture at 10 FPS - use very high bitrate for perfect quality
|
|
137
|
-
NSInteger bitrate = (NSInteger)(recordingSize.width * recordingSize.height *
|
|
138
|
-
bitrate = MAX(bitrate,
|
|
139
|
-
bitrate = MIN(bitrate,
|
|
137
|
+
NSInteger bitrate = (NSInteger)(recordingSize.width * recordingSize.height * 45);
|
|
138
|
+
bitrate = MAX(bitrate, 50 * 1000 * 1000); // Minimum 50 Mbps
|
|
139
|
+
bitrate = MIN(bitrate, 200 * 1000 * 1000); // Maximum 200 Mbps
|
|
140
140
|
|
|
141
141
|
NSLog(@"🎬 ULTRA QUALITY AVFoundation: %dx%d, bitrate=%.2fMbps",
|
|
142
142
|
(int)recordingSize.width, (int)recordingSize.height, bitrate / (1000.0 * 1000.0));
|
|
@@ -213,10 +213,10 @@ static void SCKQualityBitrateForDimensions(NSString *preset,
|
|
|
213
213
|
multiplier = 18;
|
|
214
214
|
minBitrate = 18 * 1000 * 1000;
|
|
215
215
|
maxBitrate = 80 * 1000 * 1000;
|
|
216
|
-
} else { // high/default
|
|
217
|
-
multiplier =
|
|
218
|
-
minBitrate =
|
|
219
|
-
maxBitrate =
|
|
216
|
+
} else { // high/default - ULTRA quality
|
|
217
|
+
multiplier = 60;
|
|
218
|
+
minBitrate = 80 * 1000 * 1000;
|
|
219
|
+
maxBitrate = 300 * 1000 * 1000;
|
|
220
220
|
}
|
|
221
221
|
|
|
222
222
|
double base = ((double)MAX(1, width)) * ((double)MAX(1, height)) * (double)multiplier;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
const MacRecorder = require('./index.js');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
|
|
5
|
+
// Test output directory
|
|
6
|
+
const outputDir = path.join(__dirname, 'test-output');
|
|
7
|
+
if (!fs.existsSync(outputDir)) {
|
|
8
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const timestamp = Date.now();
|
|
12
|
+
const outputPath = path.join(outputDir, `noise-reduction-test-${timestamp}.mov`);
|
|
13
|
+
|
|
14
|
+
console.log('🎙️ Starting microphone recording with noise reduction...');
|
|
15
|
+
console.log('📝 Instructions:');
|
|
16
|
+
console.log(' 1. Speak into your microphone (normal voice)');
|
|
17
|
+
console.log(' 2. Type on your keyboard (heavy typing)');
|
|
18
|
+
console.log(' 3. Click your mouse multiple times');
|
|
19
|
+
console.log(' 4. Speak again to compare');
|
|
20
|
+
console.log('');
|
|
21
|
+
console.log('⏱️ Recording for 15 seconds...');
|
|
22
|
+
console.log('');
|
|
23
|
+
|
|
24
|
+
const recorder = new MacRecorder();
|
|
25
|
+
|
|
26
|
+
recorder.on('recordingStarted', () => {
|
|
27
|
+
console.log('✅ Recording started!');
|
|
28
|
+
console.log('🎤 Custom noise reduction is ACTIVE');
|
|
29
|
+
console.log('');
|
|
30
|
+
console.log('Starting countdown:');
|
|
31
|
+
|
|
32
|
+
let countdown = 15;
|
|
33
|
+
const interval = setInterval(() => {
|
|
34
|
+
process.stdout.write(`\r⏱️ ${countdown} seconds remaining... `);
|
|
35
|
+
countdown--;
|
|
36
|
+
|
|
37
|
+
if (countdown < 0) {
|
|
38
|
+
clearInterval(interval);
|
|
39
|
+
process.stdout.write('\r');
|
|
40
|
+
console.log('⏱️ Time up! Stopping...');
|
|
41
|
+
console.log('');
|
|
42
|
+
|
|
43
|
+
recorder.stopRecording()
|
|
44
|
+
.then(() => {
|
|
45
|
+
console.log('✅ Recording saved to:', outputPath);
|
|
46
|
+
console.log('');
|
|
47
|
+
console.log('🎧 Play the recording to verify:');
|
|
48
|
+
console.log(` open "${outputPath}"`);
|
|
49
|
+
console.log('');
|
|
50
|
+
console.log('Expected results:');
|
|
51
|
+
console.log(' ✅ Voice should be clear');
|
|
52
|
+
console.log(' ✅ Keyboard typing should be significantly reduced');
|
|
53
|
+
console.log(' ✅ Mouse clicks should be filtered out');
|
|
54
|
+
process.exit(0);
|
|
55
|
+
})
|
|
56
|
+
.catch(err => {
|
|
57
|
+
console.error('❌ Error stopping:', err);
|
|
58
|
+
process.exit(1);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}, 1000);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
recorder.on('stopped', () => {
|
|
65
|
+
console.log('🛑 Recording stopped');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
recorder.on('completed', (result) => {
|
|
69
|
+
console.log('✅ Recording completed:', result);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Start recording with microphone
|
|
73
|
+
recorder.startRecording(outputPath, {
|
|
74
|
+
includeMicrophone: true,
|
|
75
|
+
includeSystemAudio: false,
|
|
76
|
+
fps: 1 // Minimal FPS since we're only testing audio
|
|
77
|
+
}).catch(err => {
|
|
78
|
+
console.error('❌ Failed to start recording:', err);
|
|
79
|
+
process.exit(1);
|
|
80
|
+
});
|