node-mac-recorder 2.22.2 → 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.
@@ -49,7 +49,8 @@
49
49
  "Bash(timeout 10 ffprobe:*)",
50
50
  "Bash(ffmpeg:*)",
51
51
  "Bash(timeout 30 node:*)",
52
- "Bash(MAC_RECORDER_DEBUG=1 node test-camera-audio-sync.js:*)"
52
+ "Bash(MAC_RECORDER_DEBUG=1 node test-camera-audio-sync.js:*)",
53
+ "WebSearch"
53
54
  ],
54
55
  "deny": [],
55
56
  "ask": []
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-mac-recorder",
3
- "version": "2.22.2",
3
+ "version": "2.22.4",
4
4
  "description": "Native macOS screen recording package for Node.js applications",
5
5
  "main": "index.js",
6
6
  "keywords": [
@@ -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
+ }
@@ -45,6 +45,22 @@ static NSString *g_lastStandaloneAudioOutputPath = nil;
45
45
  return [AVCaptureDevice defaultDeviceWithMediaType:AVMediaTypeAudio];
46
46
  }
47
47
 
48
+ - (BOOL)configureVoiceProcessingForDevice:(AVCaptureDevice *)device {
49
+ // NOTE: Voice Isolation (microphoneMode) is iOS-only and not available on macOS
50
+ // For macOS noise reduction, users should:
51
+ // 1. Enable "Voice Isolation" in macOS Control Center > Microphone menu (macOS 13+)
52
+ // 2. Or use third-party apps like Krisp, RTX Voice, or SoundSource
53
+ //
54
+ // AVCaptureDevice on macOS does not expose noise reduction or voice processing properties
55
+ // Alternative approaches would require:
56
+ // - Core Audio AUVoiceIO unit (complex, requires audio graph restructuring)
57
+ // - AVAudioEngine with voice processing (causes crashes on macOS with aggregate devices)
58
+ // - Custom DSP filtering (significant development effort, may not be effective)
59
+
60
+ MRLog(@"ℹ️ macOS microphone noise reduction: Use System Settings or Control Center to enable Voice Isolation");
61
+ return NO;
62
+ }
63
+
48
64
  - (BOOL)setupWriterWithSampleBuffer:(CMSampleBufferRef)sampleBuffer error:(NSError **)error {
49
65
  if (self.writer) {
50
66
  return YES;
@@ -167,7 +183,10 @@ static NSString *g_lastStandaloneAudioOutputPath = nil;
167
183
  }
168
184
  return NO;
169
185
  }
170
-
186
+
187
+ // Configure voice processing to filter keyboard/mouse clicks and background noise
188
+ [self configureVoiceProcessingForDevice:device];
189
+
171
190
  self.outputPath = outputPath;
172
191
  self.session = [[AVCaptureSession alloc] init];
173
192
 
@@ -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 * 30);
138
- bitrate = MAX(bitrate, 30 * 1000 * 1000); // Minimum 30 Mbps
139
- bitrate = MIN(bitrate, 120 * 1000 * 1000); // Maximum 120 Mbps
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 = 45;
218
- minBitrate = 50 * 1000 * 1000;
219
- maxBitrate = 200 * 1000 * 1000;
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;
@@ -1516,6 +1516,11 @@ static void SCKPerformRecordingSetup(NSDictionary *config, SCShareableContent *c
1516
1516
  micIdToUse = nil;
1517
1517
  }
1518
1518
  }
1519
+
1520
+ // NOTE: Voice processing (microphoneMode) is iOS-only and not available on macOS
1521
+ // Voice isolation is configured in audio_recorder.mm for AVFoundation-based recordings
1522
+ // ScreenCaptureKit microphone capture on macOS 15+ doesn't support microphoneMode
1523
+
1519
1524
  if (micIdToUse && micIdToUse.length > 0) {
1520
1525
  streamConfig.microphoneCaptureDeviceID = micIdToUse;
1521
1526
  }
@@ -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
+ });