diff --git a/Package.swift b/Package.swift index cde4467..8176566 100644 --- a/Package.swift +++ b/Package.swift @@ -5,7 +5,7 @@ import PackageDescription let package = Package( name: "screencapturekit-cli", - platforms: [.macOS(.v13)], // HDR nécessite macOS 13+, certaines fonctionnalités microphone nécessitent macOS 15+ + platforms: [.macOS(.v13)], // HDR requires macOS 13+, some microphone features require macOS 15+ dependencies: [ .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.2.2"), ], diff --git a/Sources/screencapturekit-cli/ScreenCaptureKitCli.swift b/Sources/screencapturekit-cli/ScreenCaptureKitCli.swift index 98a20b3..3edc1e9 100644 --- a/Sources/screencapturekit-cli/ScreenCaptureKitCli.swift +++ b/Sources/screencapturekit-cli/ScreenCaptureKitCli.swift @@ -20,11 +20,13 @@ struct Options: Decodable { let showCursor: Bool let highlightClicks: Bool let screenId: CGDirectDisplayID - let audioDeviceId: String? + let captureSystemAudio: Bool? let microphoneDeviceId: String? let videoCodec: String? let enableHDR: Bool? let useDirectRecordingAPI: Bool? + let audioBitRate: Int? + let audioCodec: String? } @main @@ -63,14 +65,16 @@ extension ScreenCaptureKitCLI { } let screenRecorder = try await ScreenRecorder( - url: options.destination, - displayID: options.screenId, - showCursor: options.showCursor, + url: options.destination, + displayID: options.screenId, + showCursor: options.showCursor, cropRect: options.cropRect, - audioDeviceId: options.audioDeviceId, + captureSystemAudio: options.captureSystemAudio ?? false, microphoneDeviceId: options.microphoneDeviceId, enableHDR: options.enableHDR ?? false, - useDirectRecordingAPI: options.useDirectRecordingAPI ?? false + useDirectRecordingAPI: options.useDirectRecordingAPI ?? false, + audioBitRate: options.audioBitRate ?? 320000, + audioCodec: options.audioCodec ?? "aac" ) print("Starting screen recording of display \(options.screenId)") @@ -182,20 +186,25 @@ struct ScreenRecorder { private var useDirectRecording: Bool init( - url: URL, - displayID: CGDirectDisplayID, - showCursor: Bool = true, + url: URL, + displayID: CGDirectDisplayID, + showCursor: Bool = true, cropRect: CGRect? = nil, - audioDeviceId: String? = nil, + captureSystemAudio: Bool = false, microphoneDeviceId: String? = nil, enableHDR: Bool = false, - useDirectRecordingAPI: Bool = false + useDirectRecordingAPI: Bool = false, + audioBitRate: Int = 320000, + audioCodec: String = "aac" ) async throws { self.useDirectRecording = useDirectRecordingAPI // Create AVAssetWriter for a QuickTime movie file assetWriter = try AVAssetWriter(url: url, fileType: .mov) + // Enable fast start (moov atom at beginning) for streaming/progressive download + assetWriter.shouldOptimizeForNetworkUse = true + // MARK: AVAssetWriter setup // Get size and pixel scale factor for display @@ -212,7 +221,7 @@ struct ScreenRecorder { // AVAssetWriterInput supports maximum resolution of 4096x2304 for H.264 let videoSize = downsizedVideoSize(source: cropRect?.size ?? displaySize, scaleFactor: displayScaleFactor) - // Utiliser le preset 4K maximal + // Use the maximum 4K preset guard let assistant = AVOutputSettingsAssistant(preset: .preset3840x2160) else { throw RecordingError("Can't create AVOutputSettingsAssistant with .preset3840x2160") } @@ -241,35 +250,88 @@ struct ScreenRecorder { videoInput = AVAssetWriterInput(mediaType: .video, outputSettings: outputSettings) videoInput.expectsMediaDataInRealTime = true - // Configure audio input if an audio device is specified - if audioDeviceId != nil { - let audioSettings: [String: Any] = [ - AVFormatIDKey: kAudioFormatMPEG4AAC, - AVSampleRateKey: 48000, - AVNumberOfChannelsKey: 2, - AVEncoderBitRateKey: 256000 - ] - + // Helper function to get sample rate from a microphone device + func getMicrophoneSampleRate(deviceId: String?) -> Double { + guard let deviceId = deviceId else { return 48000 } + + let discoverySession = AVCaptureDevice.DiscoverySession( + deviceTypes: [.builtInMicrophone, .externalUnknown], + mediaType: .audio, + position: .unspecified + ) + + if let device = discoverySession.devices.first(where: { $0.uniqueID == deviceId }) { + // Get the device's active format sample rate + let sampleRate = device.activeFormat.formatDescription.audioStreamBasicDescription?.mSampleRate ?? 48000 + print("Detected microphone sample rate: \(Int(sampleRate)) Hz for device: \(device.localizedName)") + return sampleRate + } + + print("Microphone device not found, using default 48000 Hz") + return 48000 + } + + // Detect microphone sample rate before creating audio settings + let microphoneSampleRate = getMicrophoneSampleRate(deviceId: microphoneDeviceId) + + // Helper function to create audio settings based on codec choice + func createAudioSettings(codec: String, bitRate: Int, sampleRate: Double = 48000) -> [String: Any] { + switch codec.lowercased() { + case "alac", "lossless", "apple-lossless": + // Apple Lossless supports any sample rate, lossless quality + return [ + AVFormatIDKey: kAudioFormatAppleLossless, + AVSampleRateKey: sampleRate, + AVNumberOfChannelsKey: 2, + AVEncoderBitDepthHintKey: 24 + ] + case "pcm", "linear-pcm", "uncompressed": + // Uncompressed PCM supports any sample rate + return [ + AVFormatIDKey: kAudioFormatLinearPCM, + AVSampleRateKey: sampleRate, + AVNumberOfChannelsKey: 2, + AVLinearPCMBitDepthKey: 24, + AVLinearPCMIsFloatKey: false, + AVLinearPCMIsBigEndianKey: false, + AVLinearPCMIsNonInterleaved: false + ] + default: + // AAC max sample rate is 48kHz - Core Audio will resample automatically + let aacSampleRate = min(sampleRate, 48000) + if sampleRate > 48000 { + print("Note: Resampling from \(Int(sampleRate)) Hz to \(Int(aacSampleRate)) Hz for AAC (browser compatible)") + } + return [ + AVFormatIDKey: kAudioFormatMPEG4AAC, + AVSampleRateKey: aacSampleRate, + AVNumberOfChannelsKey: 2, + AVEncoderBitRateKey: bitRate + ] + } + } + + // Configure audio input if system audio capture is enabled + if captureSystemAudio { + // System audio uses standard 48kHz + let audioSettings = createAudioSettings(codec: audioCodec, bitRate: audioBitRate, sampleRate: 48000) + audioInput = AVAssetWriterInput(mediaType: .audio, outputSettings: audioSettings) audioInput?.expectsMediaDataInRealTime = true - + if let audioInput = audioInput, assetWriter.canAdd(audioInput) { assetWriter.add(audioInput) } } - + // Configure microphone input if a microphone device is specified if microphoneDeviceId != nil { - let micSettings: [String: Any] = [ - AVFormatIDKey: kAudioFormatMPEG4AAC, - AVSampleRateKey: 48000, - AVNumberOfChannelsKey: 1, - AVEncoderBitRateKey: 128000 - ] - + // Use the detected microphone sample rate to avoid resampling artifacts + let micSettings = createAudioSettings(codec: audioCodec, bitRate: audioBitRate, sampleRate: microphoneSampleRate) + microphoneInput = AVAssetWriterInput(mediaType: .audio, outputSettings: micSettings) microphoneInput?.expectsMediaDataInRealTime = true - + if let microphoneInput = microphoneInput, assetWriter.canAdd(microphoneInput) { assetWriter.add(microphoneInput) } @@ -296,48 +358,62 @@ struct ScreenRecorder { // MARK: SCStream setup - // Obtenir le contenu partageable + // Get shareable content let sharableContent = try await SCShareableContent.current print("Displays: \(sharableContent.displays.count), Windows: \(sharableContent.windows.count), Apps: \(sharableContent.applications.count)") - // Trouver l'écran demandé + // Find the requested screen guard let display = sharableContent.displays.first(where: { $0.displayID == displayID }) else { throw RecordingError("No display with ID \(displayID) found") } let filter = SCContentFilter(display: display, excludingWindows: []) - // Configurer le stream + // Configure the stream var config: SCStreamConfiguration if enableHDR, #available(macOS 13.0, *) { - // Pour macOS 15+, utiliser le preset HDR + // For macOS 15+, use the HDR preset if #available(macOS 15.0, *) { let preset = SCStreamConfiguration.Preset.captureHDRStreamCanonicalDisplay config = SCStreamConfiguration(preset: preset) } else { - // Fallback pour macOS 13-14 + // Fallback for macOS 13-14 config = SCStreamConfiguration() - // Pour macOS 13-14, nous n'avons pas de méthode simple pour activer HDR - // sans utiliser d'API dépréciée + // For macOS 13-14, we don't have a simple method to enable HDR + // without using deprecated APIs print("HDR enabled but limited support on this macOS version") } } else { config = SCStreamConfiguration() } - // Configurer la fréquence d'images + // Configure frame rate config.minimumFrameInterval = CMTime(value: 1, timescale: Int32(truncating: NSNumber(value: showCursor ? 60 : 30))) config.showsCursor = showCursor - - // Configurer la capture du son système si nécessaire - if let _ = audioDeviceId { - config.capturesAudio = true + + // Configure queue depth for sample buffers + // Higher values prevent dropped samples under system load + // Apple default is typically 3-5, we use 8 for audio-sensitive recording + config.queueDepth = 8 + + // Configure crop area if specified + if let cropRect = cropRect { + // Set the source rectangle to capture only the specified region + config.sourceRect = cropRect + // Set output dimensions to match the crop area (scaled by display factor) + config.width = Int(cropRect.width) * displayScaleFactor + config.height = Int(cropRect.height) * displayScaleFactor + } + + // Configure system audio capture (disabled by default) + config.capturesAudio = captureSystemAudio + if captureSystemAudio { config.excludesCurrentProcessAudio = true print("System audio capture enabled") } - // Configurer la capture de microphone si nécessaire + // Configure microphone capture if needed if let microphoneDeviceId = microphoneDeviceId { if #available(macOS 15.0, *) { config.captureMicrophone = true @@ -347,10 +423,10 @@ struct ScreenRecorder { } } - // Créer le stream + // Create the stream stream = SCStream(filter: filter, configuration: config, delegate: nil) - // Utiliser l'API d'enregistrement direct si spécifié + // Use the direct recording API if specified if useDirectRecordingAPI { if #available(macOS 15.0, *) { let recordingConfig = SCRecordingOutputConfiguration() @@ -371,11 +447,11 @@ struct ScreenRecorder { } } - // Configuration de sortie de stream pour l'enregistrement manuel + // Configure stream output for manual recording if !useDirectRecordingAPI || !self.useDirectRecording { try stream.addStreamOutput(streamOutput, type: .screen, sampleHandlerQueue: videoSampleBufferQueue) - if audioDeviceId != nil { + if captureSystemAudio { try stream.addStreamOutput(streamOutput, type: .audio, sampleHandlerQueue: audioSampleBufferQueue) } @@ -420,8 +496,28 @@ struct ScreenRecorder { videoInput.markAsFinished() audioInput?.markAsFinished() microphoneInput?.markAsFinished() - + await assetWriter.finishWriting() + + // Log dropped samples summary + if streamOutput.droppedVideoFrameCount > 0 || streamOutput.droppedAudioSampleCount > 0 { + print("⚠️ RECORDING QUALITY WARNING:") + if streamOutput.droppedVideoFrameCount > 0 { + print(" - Dropped \(streamOutput.droppedVideoFrameCount) video frame(s)") + } + if streamOutput.droppedAudioSampleCount > 0 { + print(" - Dropped \(streamOutput.droppedAudioSampleCount) audio sample(s) due to writer backpressure") + print(" Audio drops can cause stuttering/glitches in the recording.") + print(" Consider closing other applications to reduce system load.") + } + } else { + print("✓ Recording completed with no dropped samples") + } + + // Log early audio samples (informational, not a problem) + if streamOutput.earlyAudioSampleCount > 0 { + print("ℹ️ Discarded \(streamOutput.earlyAudioSampleCount) early audio sample(s) at recording start (expected)") + } } private class StreamOutput: NSObject, SCStreamOutput { @@ -432,6 +528,9 @@ struct ScreenRecorder { var sessionStarted = false var firstSampleTime: CMTime = .zero var lastSampleBuffer: CMSampleBuffer? + var droppedAudioSampleCount = 0 // Dropped due to writer not ready (problematic) + var droppedVideoFrameCount = 0 + var earlyAudioSampleCount = 0 // Dropped due to arriving before first video (expected) init(videoInput: AVAssetWriterInput, audioInput: AVAssetWriterInput? = nil, @@ -462,7 +561,7 @@ struct ScreenRecorder { private func handleVideoSampleBuffer(_ sampleBuffer: CMSampleBuffer) { guard videoInput.isReadyForMoreMediaData else { - print("AVAssetWriterInput (video) isn't ready, dropping frame") + droppedVideoFrameCount += 1 return } @@ -504,28 +603,48 @@ struct ScreenRecorder { private func handleAudioSampleBuffer(_ sampleBuffer: CMSampleBuffer, isFromMicrophone: Bool) { let input = isFromMicrophone ? microphoneInput : audioInput - - guard let audioInput = input, audioInput.isReadyForMoreMediaData else { - if input != nil { - print("AVAssetWriterInput (audio) isn't ready, dropping sample") - } - return - } - + + guard let audioInput = input else { return } + // Offset audio sample relative to video start time + // If first video sample hasn't arrived yet, discard audio (expected during startup) if firstSampleTime == .zero { - // If first video sample hasn't arrived yet, cache this audio sample for later return } - + + // Wait for the writer to become ready (up to 100ms) + // 10ms was too short and caused dropped samples under system load + var retryCount = 0 + while !audioInput.isReadyForMoreMediaData && retryCount < 100 { + usleep(1000) // 1ms per retry, 100 retries = 100ms max + retryCount += 1 + } + + guard audioInput.isReadyForMoreMediaData else { + droppedAudioSampleCount += 1 + let sourceLabel = isFromMicrophone ? "microphone" : "system audio" + print("WARNING: Dropped \(sourceLabel) sample after \(retryCount)ms wait (writer not ready)") + return + } + // Retime audio sample buffer to match video timeline let presentationTime = sampleBuffer.presentationTimeStamp - firstSampleTime + + // Discard audio samples with negative presentation times + // This happens when the audio source (e.g., external microphone/audio interface) + // has timestamps from before the first video frame was captured + // This is expected at recording start and not a quality issue + if presentationTime < .zero { + earlyAudioSampleCount += 1 + return + } + let timing = CMSampleTimingInfo( duration: sampleBuffer.duration, presentationTimeStamp: presentationTime, decodeTimeStamp: .invalid ) - + if let retimedSampleBuffer = try? CMSampleBuffer(copying: sampleBuffer, withNewTiming: [timing]) { audioInput.append(retimedSampleBuffer) } else { @@ -575,8 +694,8 @@ class RecordingDelegate: NSObject, SCRecordingOutputDelegate { extension AVCaptureDevice { var manufacturer: String { - // La méthode properties n'existe pas - // Utilisons une valeur par défaut + // The properties method doesn't exist + // Use a default value return "Unknown" } } diff --git a/example/audio-capture.js b/example/audio-capture.js index 797602b..1c0cd88 100644 --- a/example/audio-capture.js +++ b/example/audio-capture.js @@ -1,4 +1,4 @@ -// Exemple de capture audio (système et microphone) +// Example of audio capture (system and microphone) import createScreenRecorder from 'screencapturekit'; import { screens, audioDevices, microphoneDevices } from 'screencapturekit'; import { exec } from 'child_process'; @@ -13,55 +13,55 @@ import os from 'os'; const execAsync = promisify(exec); const __dirname = dirname(fileURLToPath(import.meta.url)); -// Durée d'enregistrement en millisecondes -const RECORDING_DURATION = 15000; // 15 secondes +// Recording duration in milliseconds +const RECORDING_DURATION = 15000; // 15 seconds -// Créer une interface readline pour l'interaction utilisateur +// Create a readline interface for user interaction const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); -// Fonction pour poser une question et obtenir une réponse +// Function to ask a question and get an answer function question(query) { return new Promise(resolve => rl.question(query, resolve)); } -// Fonction pour choisir un périphérique dans une liste +// Function to choose a device from a list async function chooseDevice(devices, type) { if (!devices || devices.length === 0) { return null; } - - console.log(`\nPériphériques ${type} disponibles:`); + + console.log(`\nAvailable ${type} devices:`); devices.forEach((device, index) => { - console.log(` [${index}] ${device.name} (${device.manufacturer || 'Fabricant inconnu'}) (ID=${device.id})`); + console.log(` [${index}] ${device.name} (${device.manufacturer || 'Unknown manufacturer'}) (ID=${device.id})`); }); - + const defaultChoice = 0; - const input = await question(`Choisissez un périphérique ${type} [0-${devices.length - 1}] (défaut: ${defaultChoice}): `); + const input = await question(`Choose a ${type} device [0-${devices.length - 1}] (default: ${defaultChoice}): `); const choice = input === '' ? defaultChoice : parseInt(input, 10); - + if (isNaN(choice) || choice < 0 || choice >= devices.length) { - console.log(`Choix invalide, utilisation du périphérique ${defaultChoice}`); + console.log(`Invalid choice, using device ${defaultChoice}`); return devices[defaultChoice]; } - + return devices[choice]; } async function openYouTubeInBrowser(url) { - console.log(`Ouverture de ${url} dans le navigateur par défaut...`); + console.log(`Opening ${url} in the default browser...`); try { await execAsync(`open "${url}"`); return true; } catch (error) { - console.error(`Erreur lors de l'ouverture du navigateur: ${error.message}`); + console.error(`Error opening browser: ${error.message}`); return false; } } -// Fonction pour générer un nom de fichier audio unique +// Function to generate a unique audio file name function generateAudioFileName() { const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); return path.join(os.tmpdir(), `audio-capture-${timestamp}.m4a`); @@ -69,94 +69,94 @@ function generateAudioFileName() { async function main() { try { - console.log('=== CONFIGURATION DE CAPTURE AUDIO ==='); - - // Obtenir les écrans disponibles (nécessaire même pour l'audio uniquement) + console.log('=== AUDIO CAPTURE CONFIGURATION ==='); + + // Get available screens (required even for audio only) const availableScreens = await screens(); if (!availableScreens || availableScreens.length === 0) { - throw new Error('Aucun écran disponible pour l\'enregistrement, nécessaire même pour l\'audio'); + throw new Error('No screen available for recording, required even for audio'); } - - // Utiliser le premier écran disponible + + // Use the first available screen const selectedScreen = availableScreens[0]; - console.log(`\nÉcran utilisé pour la capture (nécessaire pour l'API): ${selectedScreen.width}x${selectedScreen.height}`); + console.log(`\nScreen used for capture (required for API): ${selectedScreen.width}x${selectedScreen.height}`); - // Obtenir les périphériques audio système + // Get system audio devices const systemAudioDevices = await audioDevices(); let selectedAudioDevice = null; let captureSystemAudio = false; - + if (!systemAudioDevices || systemAudioDevices.length === 0) { - console.warn('⚠️ Aucun périphérique audio système disponible'); + console.warn('⚠️ No system audio device available'); } else { - // Demander si l'utilisateur veut capturer l'audio système - const captureAudio = await question('\nVoulez-vous capturer l\'audio système? (O/n): '); + // Ask if user wants to capture system audio + const captureAudio = await question('\nDo you want to capture system audio? (Y/n): '); captureSystemAudio = captureAudio.toLowerCase() !== 'n'; - + if (captureSystemAudio) { - // Choisir un périphérique audio + // Choose an audio device selectedAudioDevice = await chooseDevice(systemAudioDevices, 'audio'); if (selectedAudioDevice) { - console.log(`\n✅ Périphérique audio sélectionné: ${selectedAudioDevice.name} (ID=${selectedAudioDevice.id})`); + console.log(`\n✅ Selected audio device: ${selectedAudioDevice.name} (ID=${selectedAudioDevice.id})`); } } } - // Obtenir les microphones + // Get microphones let micDevices = []; let selectedMic = null; let captureMicrophone = false; - + try { micDevices = await microphoneDevices(); - + if (!micDevices || micDevices.length === 0) { - console.warn('⚠️ Aucun microphone disponible'); + console.warn('⚠️ No microphone available'); } else { - // Demander si l'utilisateur veut capturer le microphone - const captureMic = await question('\nVoulez-vous capturer le microphone? (O/n): '); + // Ask if user wants to capture microphone + const captureMic = await question('\nDo you want to capture microphone? (Y/n): '); captureMicrophone = captureMic.toLowerCase() !== 'n'; - + if (captureMicrophone) { - // Choisir un microphone + // Choose a microphone selectedMic = await chooseDevice(micDevices, 'microphone'); if (selectedMic) { - console.log(`\n✅ Microphone sélectionné: ${selectedMic.name} (ID=${selectedMic.id})`); + console.log(`\n✅ Selected microphone: ${selectedMic.name} (ID=${selectedMic.id})`); } } } } catch (error) { - console.warn(`⚠️ Capture microphone non disponible: ${error.message}`); + console.warn(`⚠️ Microphone capture not available: ${error.message}`); } - // Vérifier qu'au moins une source audio est sélectionnée + // Check that at least one audio source is selected if (!captureSystemAudio && !captureMicrophone) { - console.error('❌ Erreur: Aucune source audio sélectionnée. Au moins une source est nécessaire.'); + console.error('❌ Error: No audio source selected. At least one source is required.'); rl.close(); return; } - - // Demander la durée d'enregistrement - const durationInput = await question(`\nDurée d'enregistrement en secondes (défaut: ${RECORDING_DURATION/1000}): `); + + // Ask for recording duration + const durationInput = await question(`\nRecording duration in seconds (default: ${RECORDING_DURATION/1000}): `); const duration = durationInput === '' ? RECORDING_DURATION : parseInt(durationInput, 10) * 1000; - + if (isNaN(duration) || duration <= 0) { - console.log(`Durée invalide, utilisation de la valeur par défaut: ${RECORDING_DURATION/1000} secondes`); + console.log(`Invalid duration, using default value: ${RECORDING_DURATION/1000} seconds`); } - // Créer un enregistreur + // Create a recorder const recorder = createScreenRecorder(); - - // Préparer les options + + // Prepare options const options = { - // Écran requis même pour l'audio uniquement + // Screen required even for audio only screenId: selectedScreen.id, // Audio - audioDeviceId: selectedAudioDevice?.id, + captureSystemAudio: !!selectedAudioDevice, microphoneDeviceId: selectedMic?.id, - // Option pour convertir automatiquement en MP3 + // Option to automatically convert to MP3 audioOnly: true, - // Paramètres minimaux car on ne garde que l'audio + // Minimal settings since we only keep audio fps: 1, showCursor: false, highlightClicks: false, @@ -167,44 +167,44 @@ async function main() { height: 1 } }; - - console.log('\nOptions d\'enregistrement:'); + + console.log('\nRecording options:'); console.log(JSON.stringify(options, null, 2)); - - // Demander confirmation pour démarrer - const startConfirm = await question('\nDémarrer l\'enregistrement audio? (O/n): '); - + + // Ask for confirmation to start + const startConfirm = await question('\nStart audio recording? (Y/n): '); + if (startConfirm.toLowerCase() === 'n') { - console.log('Enregistrement annulé.'); + console.log('Recording cancelled.'); rl.close(); return; } - - // Démarrer l'enregistrement - console.log('\nDémarrage de l\'enregistrement audio...'); + + // Start recording + console.log('\nStarting audio recording...'); await recorder.startRecording(options); - // Ouvrir YouTube dans le navigateur + // Open YouTube in browser const youtubeURL = 'https://www.youtube.com/watch?v=xvFZjo5PgG0'; await openYouTubeInBrowser(youtubeURL); - - console.log(`\nEnregistrement en cours pendant ${duration/1000} secondes...`); - + + console.log(`\nRecording in progress for ${duration/1000} seconds...`); + if (captureMicrophone) { - console.log('Parlez dans votre microphone pour tester la capture audio!'); + console.log('Speak into your microphone to test audio capture!'); } - - // Attendre la durée spécifiée + + // Wait for the specified duration await new Promise(resolve => setTimeout(resolve, duration)); - - // Arrêter l'enregistrement - console.log('Arrêt de l\'enregistrement...'); + + // Stop recording + console.log('Stopping recording...'); const audioPath = await recorder.stopRecording(); - - console.log(`\n✅ Audio enregistré à: ${audioPath}`); - console.log(' L\'enregistrement contient:'); - console.log(` - Audio système: ${captureSystemAudio ? '✅' : '❌'}`); - console.log(` - Audio microphone: ${captureMicrophone ? '✅' : '❌'}`); + + console.log(`\n✅ Audio recorded at: ${audioPath}`); + console.log(' Recording contains:'); + console.log(` - System audio: ${captureSystemAudio ? '✅' : '❌'}`); + console.log(` - Microphone audio: ${captureMicrophone ? '✅' : '❌'}`); rl.close(); } catch (error) { diff --git a/example/audio-only.js b/example/audio-only.js index f9bb6cd..fc50d64 100644 --- a/example/audio-only.js +++ b/example/audio-only.js @@ -86,7 +86,7 @@ async function main() { // Basic options for audio-only recording const options = { screenId: availableScreens[0].id, - audioDeviceId: selectedAudioDevice?.id, + captureSystemAudio: !!selectedAudioDevice, microphoneDeviceId: selectedMicDevice?.id, audioOnly: true, fps: 1, diff --git a/example/screen-with-audio.js b/example/screen-with-audio.js index f887b9e..13c8bac 100644 --- a/example/screen-with-audio.js +++ b/example/screen-with-audio.js @@ -53,7 +53,7 @@ async function main() { // Capture options const options = { screenId: screen.id, - audioDeviceId: selectedAudio.id, + captureSystemAudio: true, fps: 30, showCursor: true, highlightClicks: true diff --git a/src/index.ts b/src/index.ts index e412621..352346a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -111,12 +111,15 @@ export type { MicrophoneDevice }; * @property {boolean} showCursor - Show the cursor in the recording. * @property {boolean} highlightClicks - Highlight mouse clicks. * @property {number} screenId - Identifier of the screen to capture. - * @property {string} [audioDeviceId] - Identifier of the system audio device. + * @property {boolean} [captureSystemAudio] - Capture system audio (default: false). * @property {string} [microphoneDeviceId] - Identifier of the microphone device. * @property {string} videoCodec - Video codec to use. * @property {boolean} [enableHDR] - Enable HDR recording (on macOS 13.0+). * @property {boolean} [recordToFile] - Use the direct recording API (on macOS 14.0+). * @property {boolean} [audioOnly] - Record audio only, will convert to mp3 after recording. + * @property {string} [outputFilePath] - Custom output file path. If not specified, a temp file is created. + * @property {number} [audioBitRate] - Audio bitrate in bps (default: 320000 for high quality). + * @property {string} [audioCodec] - Audio codec: "aac" (default), "alac" (lossless), or "pcm" (uncompressed). */ type RecordingOptions = { fps: number; @@ -124,12 +127,15 @@ type RecordingOptions = { showCursor: boolean; highlightClicks: boolean; screenId: number; - audioDeviceId?: string; + captureSystemAudio?: boolean; microphoneDeviceId?: string; videoCodec?: string; enableHDR?: boolean; recordToFile?: boolean; audioOnly?: boolean; + outputFilePath?: string; + audioBitRate?: number; + audioCodec?: "aac" | "alac" | "pcm"; }; export type { RecordingOptions }; @@ -142,12 +148,14 @@ export type { RecordingOptions }; * @property {boolean} showCursor - Show the cursor in the recording. * @property {boolean} highlightClicks - Highlight mouse clicks. * @property {number} screenId - Identifier of the screen to capture. - * @property {string} [audioDeviceId] - Identifier of the system audio device. + * @property {boolean} [captureSystemAudio] - Capture system audio (default: false). * @property {string} [microphoneDeviceId] - Identifier of the microphone device. * @property {string} [videoCodec] - Video codec to use. * @property {Array} [cropRect] - Coordinates of the cropping area. * @property {boolean} [enableHDR] - Enable HDR recording. * @property {boolean} [useDirectRecordingAPI] - Use the direct recording API. + * @property {number} [audioBitRate] - Audio bitrate in bps. + * @property {string} [audioCodec] - Audio codec to use. * @private */ type RecordingOptionsForScreenCaptureKit = { @@ -156,12 +164,14 @@ type RecordingOptionsForScreenCaptureKit = { showCursor: boolean; highlightClicks: boolean; screenId: number; - audioDeviceId?: string; + captureSystemAudio?: boolean; microphoneDeviceId?: string; videoCodec?: string; cropRect?: [[x: number, y: number], [width: number, height: number]]; enableHDR?: boolean; useDirectRecordingAPI?: boolean; + audioBitRate?: number; + audioCodec?: string; }; export type { RecordingOptionsForScreenCaptureKit }; @@ -210,11 +220,12 @@ export class ScreenCaptureKit { * @param {boolean} [options.showCursor=true] - Show the cursor. * @param {boolean} [options.highlightClicks=false] - Highlight mouse clicks. * @param {number} [options.screenId=0] - Screen ID to capture. - * @param {string} [options.audioDeviceId] - System audio device ID. + * @param {boolean} [options.captureSystemAudio=false] - Capture system audio. * @param {string} [options.microphoneDeviceId] - Microphone device ID. * @param {string} [options.videoCodec="h264"] - Video codec to use. * @param {boolean} [options.enableHDR=false] - Enable HDR recording. * @param {boolean} [options.recordToFile=false] - Use the direct recording API. + * @param {string} [options.outputFilePath] - Custom output file path. If not specified, a temp file is created. * @returns {Promise} A promise that resolves when recording starts. * @throws {Error} If recording is already in progress or if the options are invalid. */ @@ -224,37 +235,43 @@ export class ScreenCaptureKit { showCursor = true, highlightClicks = false, screenId = 0, - audioDeviceId = undefined, + captureSystemAudio = false, microphoneDeviceId = undefined, videoCodec = "h264", enableHDR = false, recordToFile = false, audioOnly = false, + outputFilePath = undefined, + audioBitRate = 320000, + audioCodec = "aac", }: Partial = {}) { this.processId = getRandomId(); - // Stocke les options actuelles pour utilisation ultérieure + // Store current options for later use this.currentOptions = { fps, cropArea, showCursor, highlightClicks, screenId, - audioDeviceId, + captureSystemAudio, microphoneDeviceId, videoCodec, enableHDR, recordToFile, audioOnly, + outputFilePath, + audioBitRate, + audioCodec, }; - + return new Promise((resolve, reject) => { if (this.recorder !== undefined) { reject(new Error("Call `.stopRecording()` first")); return; } - this.videoPath = createTempFile({ extension: "mp4" }); - + this.videoPath = outputFilePath || createTempFile({ extension: "mp4" }); + console.log(this.videoPath); const recorderOptions: RecordingOptionsForScreenCaptureKit = { destination: fileUrlFromPath(this.videoPath as string), @@ -262,7 +279,9 @@ export class ScreenCaptureKit { showCursor, highlightClicks, screenId, - audioDeviceId, + captureSystemAudio, + audioBitRate, + audioCodec, }; if (highlightClicks === true) { @@ -348,45 +367,45 @@ export class ScreenCaptureKit { */ async stopRecording() { this.throwIfNotStarted(); - console.log("Arrêt de l'enregistrement"); + console.log("Stopping recording"); this.recorder?.kill(); await this.recorder; - console.log("Enregistrement arrêté"); + console.log("Recording stopped"); this.recorder = undefined; if (!this.videoPath) { return null; } - // Ajoutons un délai pour s'assurer que le fichier est complètement écrit + // Add a delay to ensure the file is completely written await new Promise(resolve => setTimeout(resolve, 1000)); let currentFile = this.videoPath; - // Vérifier si le fichier existe et a une taille + // Check if the file exists and has content try { const stats = fs.statSync(currentFile); if (stats.size === 0) { - console.error("Le fichier d'enregistrement est vide"); + console.error("Recording file is empty"); return null; } } catch (error) { - console.error("Erreur lors de la vérification du fichier d'enregistrement:", error); + console.error("Error checking recording file:", error); return null; } - // Si nous avons plusieurs sources audio, nous devons les fusionner + // If we have multiple audio sources, we need to merge them const hasMultipleAudioTracks = !!( - this.currentOptions?.audioDeviceId && + this.currentOptions?.captureSystemAudio && this.currentOptions?.microphoneDeviceId ); if (hasMultipleAudioTracks) { try { - console.log("Fusion des pistes audio avec ffmpeg"); + console.log("Merging audio tracks with ffmpeg"); this.processedVideoPath = createTempFile({ extension: "mp4" }); - // Vérifier la structure du fichier avec ffprobe + // Check file structure with ffprobe const { stdout: probeOutput } = await execa("ffprobe", [ "-v", "error", "-show_entries", "stream=index,codec_type", @@ -397,7 +416,7 @@ export class ScreenCaptureKit { const probeResult = JSON.parse(probeOutput); const streams = probeResult.streams || []; - // Identifier les indices des flux audio et vidéo + // Identify audio and video stream indices const audioStreams = streams .filter((stream: {codec_type: string; index: number}) => stream.codec_type === "audio") .map((stream: {index: number}) => stream.index); @@ -406,14 +425,14 @@ export class ScreenCaptureKit { .find((stream: {codec_type: string; index: number}) => stream.codec_type === "video")?.index; if (audioStreams.length < 2 || videoStream === undefined) { - console.log("Pas assez de pistes audio pour fusionner ou pas de piste vidéo"); + console.log("Not enough audio tracks to merge or no video track"); } else { const systemAudioIndex = audioStreams[0]; const microphoneIndex = audioStreams[1]; const filterComplex = `[0:${systemAudioIndex}]volume=1[a1];[0:${microphoneIndex}]volume=3[a2];[a1][a2]amerge=inputs=2[aout]`; - // Traitement vidéo + // Process video await execa("ffmpeg", [ "-i", currentFile, "-filter_complex", filterComplex, @@ -430,14 +449,14 @@ export class ScreenCaptureKit { currentFile = this.processedVideoPath; } } catch (error) { - console.error("Erreur lors de la fusion des pistes audio:", error); + console.error("Error merging audio tracks:", error); } } - // Si audioOnly est activé, convertir en MP3 + // If audioOnly is enabled, convert to MP3 if (this.currentOptions?.audioOnly) { try { - console.log("Conversion en MP3"); + console.log("Converting to MP3"); const audioPath = createTempFile({ extension: "mp3" }); await execa("ffmpeg", [ @@ -451,7 +470,7 @@ export class ScreenCaptureKit { return audioPath; } catch (error) { - console.error("Erreur lors de la conversion en MP3:", error); + console.error("Error converting to MP3:", error); return currentFile; } } @@ -552,30 +571,30 @@ export const supportsHDRCapture = supportsHDR; */ export const videoCodecs = getCodecs(); -// Fonction de remplacement pour temporaryFile sans créer le fichier +// Replacement function for temporaryFile without creating the file function createTempFile(options: { extension?: string } = {}): string { const tempDir = os.tmpdir(); const randomId = uuidv4(); const extension = options.extension ? `.${options.extension}` : ''; const tempFilePath = path.join(tempDir, `${randomId}${extension}`); - // Ne pas créer le fichier, juste retourner le chemin + // Don't create the file, just return the path return tempFilePath; } -// Fonction personnalisée pour remplacer fileUrl +// Custom function to replace fileUrl function fileUrlFromPath(filePath: string): string { - // Encodage des caractères spéciaux + // Encode special characters let pathName = filePath.replace(/\\/g, '/'); - // Assurez-vous que le chemin commence par un slash si ce n'est pas déjà le cas + // Make sure the path starts with a slash if it doesn't already if (pathName[0] !== '/') { pathName = '/' + pathName; } - // Encodage des caractères spéciaux dans l'URL + // Encode special characters in the URL pathName = encodeURI(pathName) - // Encodage supplémentaire pour les caractères qui ne sont pas gérés par encodeURI + // Additional encoding for characters not handled by encodeURI .replace(/#/g, '%23') .replace(/\?/g, '%3F'); diff --git a/src/utils/packagePaths.ts b/src/utils/packagePaths.ts index f3ccf1c..139f58a 100644 --- a/src/utils/packagePaths.ts +++ b/src/utils/packagePaths.ts @@ -14,7 +14,7 @@ export const getPackageRoot = () => { return path.join(__dirname, "..", "..", "dist"); } try { - // Résolution via le point d'entrée du package + // Resolution via package entry point const app = require("electron").app; const packageMainPath = require.resolve("screencapturekit"); if (typeof process.resourcesPath === "string" && app?.isPackaged) { @@ -24,7 +24,7 @@ export const getPackageRoot = () => { const finalPath = path.dirname(packageMainPath); return finalPath; } catch (e) { - // Fallback pour le développement ES modules + // Fallback for ES modules development const __filename = fileURLToPath(import.meta.url); const finalPath = path.join(path.dirname(__filename)); console.log("finalPath : ESM", finalPath);