From e6c37f2e4d71a239d67c3aef73b5acd8d3477b23 Mon Sep 17 00:00:00 2001 From: Giorgio Zamparelli Date: Mon, 26 Jan 2026 12:19:32 +0100 Subject: [PATCH 01/10] fix: cropArea now uses the options provided --- Sources/screencapturekit-cli/ScreenCaptureKitCli.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Sources/screencapturekit-cli/ScreenCaptureKitCli.swift b/Sources/screencapturekit-cli/ScreenCaptureKitCli.swift index 98a20b3..bd70f91 100644 --- a/Sources/screencapturekit-cli/ScreenCaptureKitCli.swift +++ b/Sources/screencapturekit-cli/ScreenCaptureKitCli.swift @@ -329,6 +329,15 @@ struct ScreenRecorder { // Configurer la fréquence d'images config.minimumFrameInterval = CMTime(value: 1, timescale: Int32(truncating: NSNumber(value: showCursor ? 60 : 30))) config.showsCursor = showCursor + + // 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 + } // Configurer la capture du son système si nécessaire if let _ = audioDeviceId { From 8cd798f70721d0c1a86508595376594d164ae946 Mon Sep 17 00:00:00 2001 From: Giorgio Zamparelli Date: Mon, 26 Jan 2026 12:43:48 +0100 Subject: [PATCH 02/10] feat: add outputFilePath option to startRecording Allows specifying a custom output file path instead of using a temporary file. If not specified, falls back to temp file behavior. Co-Authored-By: Claude Opus 4.5 --- src/index.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index e412621..53ccc1c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -117,6 +117,7 @@ export type { MicrophoneDevice }; * @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. */ type RecordingOptions = { fps: number; @@ -130,6 +131,7 @@ type RecordingOptions = { enableHDR?: boolean; recordToFile?: boolean; audioOnly?: boolean; + outputFilePath?: string; }; export type { RecordingOptions }; @@ -215,6 +217,7 @@ export class ScreenCaptureKit { * @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. */ @@ -230,6 +233,7 @@ export class ScreenCaptureKit { enableHDR = false, recordToFile = false, audioOnly = false, + outputFilePath = undefined, }: Partial = {}) { this.processId = getRandomId(); // Stocke les options actuelles pour utilisation ultérieure @@ -245,15 +249,16 @@ export class ScreenCaptureKit { enableHDR, recordToFile, audioOnly, + outputFilePath, }; - + 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 = { From 59731a04a765384f26f44d31083a6fdddc6993f4 Mon Sep 17 00:00:00 2001 From: Giorgio Zamparelli Date: Mon, 26 Jan 2026 14:06:48 +0100 Subject: [PATCH 03/10] chore: translate French comments to English Co-Authored-By: Claude Opus 4.5 --- Package.swift | 2 +- .../ScreenCaptureKitCli.swift | 32 ++-- example/audio-capture.js | 160 +++++++++--------- src/index.ts | 48 +++--- src/utils/packagePaths.ts | 4 +- 5 files changed, 123 insertions(+), 123 deletions(-) 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 bd70f91..170d15e 100644 --- a/Sources/screencapturekit-cli/ScreenCaptureKitCli.swift +++ b/Sources/screencapturekit-cli/ScreenCaptureKitCli.swift @@ -212,7 +212,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") } @@ -296,37 +296,37 @@ 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 @@ -339,14 +339,14 @@ struct ScreenRecorder { config.height = Int(cropRect.height) * displayScaleFactor } - // Configurer la capture du son système si nécessaire + // Configure system audio capture if needed if let _ = audioDeviceId { config.capturesAudio = true 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 @@ -356,10 +356,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() @@ -380,7 +380,7 @@ 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) @@ -584,8 +584,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..206508d 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, 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/src/index.ts b/src/index.ts index 53ccc1c..a176f37 100644 --- a/src/index.ts +++ b/src/index.ts @@ -236,7 +236,7 @@ export class ScreenCaptureKit { outputFilePath = undefined, }: Partial = {}) { this.processId = getRandomId(); - // Stocke les options actuelles pour utilisation ultérieure + // Store current options for later use this.currentOptions = { fps, cropArea, @@ -353,34 +353,34 @@ 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?.microphoneDeviceId @@ -388,10 +388,10 @@ export class ScreenCaptureKit { 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", @@ -402,7 +402,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); @@ -411,14 +411,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, @@ -435,14 +435,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", [ @@ -456,7 +456,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; } } @@ -557,30 +557,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); From d30f213e1c44b6ccb7b3dbf5ab6dc8de86bf2972 Mon Sep 17 00:00:00 2001 From: Giorgio Zamparelli Date: Mon, 26 Jan 2026 14:33:27 +0100 Subject: [PATCH 04/10] feat: replace audioDeviceId with captureSystemAudio boolean - Rename audioDeviceId (string) to captureSystemAudio (boolean) - System audio is now disabled by default - Remove unused device ID validation since it's just a flag - Update TypeScript types and JSDoc comments - Update example files Co-Authored-By: Claude Opus 4.5 --- .../ScreenCaptureKitCli.swift | 36 +++++++++---------- example/audio-capture.js | 2 +- example/audio-only.js | 2 +- example/screen-with-audio.js | 2 +- src/index.ts | 18 +++++----- 5 files changed, 30 insertions(+), 30 deletions(-) diff --git a/Sources/screencapturekit-cli/ScreenCaptureKitCli.swift b/Sources/screencapturekit-cli/ScreenCaptureKitCli.swift index 170d15e..b57b603 100644 --- a/Sources/screencapturekit-cli/ScreenCaptureKitCli.swift +++ b/Sources/screencapturekit-cli/ScreenCaptureKitCli.swift @@ -20,7 +20,7 @@ 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? @@ -63,11 +63,11 @@ 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 @@ -182,11 +182,11 @@ 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 @@ -241,18 +241,18 @@ struct ScreenRecorder { videoInput = AVAssetWriterInput(mediaType: .video, outputSettings: outputSettings) videoInput.expectsMediaDataInRealTime = true - // Configure audio input if an audio device is specified - if audioDeviceId != nil { + // Configure audio input if system audio capture is enabled + if captureSystemAudio { let audioSettings: [String: Any] = [ AVFormatIDKey: kAudioFormatMPEG4AAC, AVSampleRateKey: 48000, AVNumberOfChannelsKey: 2, AVEncoderBitRateKey: 256000 ] - + audioInput = AVAssetWriterInput(mediaType: .audio, outputSettings: audioSettings) audioInput?.expectsMediaDataInRealTime = true - + if let audioInput = audioInput, assetWriter.canAdd(audioInput) { assetWriter.add(audioInput) } @@ -338,10 +338,10 @@ struct ScreenRecorder { config.width = Int(cropRect.width) * displayScaleFactor config.height = Int(cropRect.height) * displayScaleFactor } - - // Configure system audio capture if needed - if let _ = audioDeviceId { - config.capturesAudio = true + + // Configure system audio capture (disabled by default) + config.capturesAudio = captureSystemAudio + if captureSystemAudio { config.excludesCurrentProcessAudio = true print("System audio capture enabled") } @@ -384,7 +384,7 @@ struct ScreenRecorder { 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) } diff --git a/example/audio-capture.js b/example/audio-capture.js index 206508d..1c0cd88 100644 --- a/example/audio-capture.js +++ b/example/audio-capture.js @@ -152,7 +152,7 @@ async function main() { // Screen required even for audio only screenId: selectedScreen.id, // Audio - audioDeviceId: selectedAudioDevice?.id, + captureSystemAudio: !!selectedAudioDevice, microphoneDeviceId: selectedMic?.id, // Option to automatically convert to MP3 audioOnly: true, 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 a176f37..1169ad3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -111,7 +111,7 @@ 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+). @@ -125,7 +125,7 @@ type RecordingOptions = { showCursor: boolean; highlightClicks: boolean; screenId: number; - audioDeviceId?: string; + captureSystemAudio?: boolean; microphoneDeviceId?: string; videoCodec?: string; enableHDR?: boolean; @@ -144,7 +144,7 @@ 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. @@ -158,7 +158,7 @@ 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]]; @@ -212,7 +212,7 @@ 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. @@ -227,7 +227,7 @@ export class ScreenCaptureKit { showCursor = true, highlightClicks = false, screenId = 0, - audioDeviceId = undefined, + captureSystemAudio = false, microphoneDeviceId = undefined, videoCodec = "h264", enableHDR = false, @@ -243,7 +243,7 @@ export class ScreenCaptureKit { showCursor, highlightClicks, screenId, - audioDeviceId, + captureSystemAudio, microphoneDeviceId, videoCodec, enableHDR, @@ -267,7 +267,7 @@ export class ScreenCaptureKit { showCursor, highlightClicks, screenId, - audioDeviceId, + captureSystemAudio, }; if (highlightClicks === true) { @@ -382,7 +382,7 @@ export class ScreenCaptureKit { // If we have multiple audio sources, we need to merge them const hasMultipleAudioTracks = !!( - this.currentOptions?.audioDeviceId && + this.currentOptions?.captureSystemAudio && this.currentOptions?.microphoneDeviceId ); From 3f90bcd9ecaff0d1d5ba2783d7f36277f96b291a Mon Sep 17 00:00:00 2001 From: Giorgio Zamparelli Date: Mon, 26 Jan 2026 14:49:30 +0100 Subject: [PATCH 05/10] fix: improve microphone audio quality and reduce sample dropping - Increase microphone bitrate from 128kbps to 256kbps - Change microphone audio from mono to stereo - Add retry loop (up to 10ms) when AVAssetWriter isn't ready - Reorder checks to silently discard audio before video starts - Remove noisy log spam for dropped samples Co-Authored-By: Claude Opus 4.5 --- .../ScreenCaptureKitCli.swift | 33 +++++++++++-------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/Sources/screencapturekit-cli/ScreenCaptureKitCli.swift b/Sources/screencapturekit-cli/ScreenCaptureKitCli.swift index b57b603..3019359 100644 --- a/Sources/screencapturekit-cli/ScreenCaptureKitCli.swift +++ b/Sources/screencapturekit-cli/ScreenCaptureKitCli.swift @@ -263,8 +263,8 @@ struct ScreenRecorder { let micSettings: [String: Any] = [ AVFormatIDKey: kAudioFormatMPEG4AAC, AVSampleRateKey: 48000, - AVNumberOfChannelsKey: 1, - AVEncoderBitRateKey: 128000 + AVNumberOfChannelsKey: 2, + AVEncoderBitRateKey: 256000 ] microphoneInput = AVAssetWriterInput(mediaType: .audio, outputSettings: micSettings) @@ -513,20 +513,27 @@ 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 briefly for the writer to become ready (up to 10ms) + var retryCount = 0 + while !audioInput.isReadyForMoreMediaData && retryCount < 10 { + usleep(1000) // 1ms + retryCount += 1 + } + + guard audioInput.isReadyForMoreMediaData else { + // Only log occasionally to avoid spam + return + } + // Retime audio sample buffer to match video timeline let presentationTime = sampleBuffer.presentationTimeStamp - firstSampleTime let timing = CMSampleTimingInfo( @@ -534,7 +541,7 @@ struct ScreenRecorder { presentationTimeStamp: presentationTime, decodeTimeStamp: .invalid ) - + if let retimedSampleBuffer = try? CMSampleBuffer(copying: sampleBuffer, withNewTiming: [timing]) { audioInput.append(retimedSampleBuffer) } else { From bcd29f8b7c1dd3bfc825114ae8296c3e62102ba0 Mon Sep 17 00:00:00 2001 From: Giorgio Zamparelli Date: Mon, 26 Jan 2026 14:55:50 +0100 Subject: [PATCH 06/10] fix: log dropped samples summary at end of recording instead of spam - Add counters for dropped audio samples and video frames - Print summary at end of recording instead of logging each drop - Helps diagnose audio quality issues without console spam Co-Authored-By: Claude Opus 4.5 --- .../ScreenCaptureKitCli.swift | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/Sources/screencapturekit-cli/ScreenCaptureKitCli.swift b/Sources/screencapturekit-cli/ScreenCaptureKitCli.swift index 3019359..7a2777c 100644 --- a/Sources/screencapturekit-cli/ScreenCaptureKitCli.swift +++ b/Sources/screencapturekit-cli/ScreenCaptureKitCli.swift @@ -429,8 +429,16 @@ struct ScreenRecorder { videoInput.markAsFinished() audioInput?.markAsFinished() microphoneInput?.markAsFinished() - + await assetWriter.finishWriting() + + // Log dropped samples summary + if streamOutput.droppedVideoFrameCount > 0 { + print("Dropped \(streamOutput.droppedVideoFrameCount) video frame(s) during recording") + } + if streamOutput.droppedAudioSampleCount > 0 { + print("Dropped \(streamOutput.droppedAudioSampleCount) audio sample(s) during recording") + } } private class StreamOutput: NSObject, SCStreamOutput { @@ -441,6 +449,8 @@ struct ScreenRecorder { var sessionStarted = false var firstSampleTime: CMTime = .zero var lastSampleBuffer: CMSampleBuffer? + var droppedAudioSampleCount = 0 + var droppedVideoFrameCount = 0 init(videoInput: AVAssetWriterInput, audioInput: AVAssetWriterInput? = nil, @@ -471,7 +481,7 @@ struct ScreenRecorder { private func handleVideoSampleBuffer(_ sampleBuffer: CMSampleBuffer) { guard videoInput.isReadyForMoreMediaData else { - print("AVAssetWriterInput (video) isn't ready, dropping frame") + droppedVideoFrameCount += 1 return } @@ -530,7 +540,7 @@ struct ScreenRecorder { } guard audioInput.isReadyForMoreMediaData else { - // Only log occasionally to avoid spam + droppedAudioSampleCount += 1 return } From 5358bb4952aaaee34b092a38ed32f3a635c9e445 Mon Sep 17 00:00:00 2001 From: Giorgio Zamparelli Date: Tue, 27 Jan 2026 09:57:52 +0100 Subject: [PATCH 07/10] fix: discard audio samples with negative presentation times Audio from external devices (e.g., Elgato Wave Link) may have timestamps earlier than the first video frame, resulting in negative presentation times. These samples were being placed at time 0 in the output file, causing audio to play before the corresponding video. Co-Authored-By: Claude Opus 4.5 --- Sources/screencapturekit-cli/ScreenCaptureKitCli.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Sources/screencapturekit-cli/ScreenCaptureKitCli.swift b/Sources/screencapturekit-cli/ScreenCaptureKitCli.swift index 7a2777c..196c333 100644 --- a/Sources/screencapturekit-cli/ScreenCaptureKitCli.swift +++ b/Sources/screencapturekit-cli/ScreenCaptureKitCli.swift @@ -546,6 +546,15 @@ struct ScreenRecorder { // 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 + if presentationTime < .zero { + droppedAudioSampleCount += 1 + return + } + let timing = CMSampleTimingInfo( duration: sampleBuffer.duration, presentationTimeStamp: presentationTime, From 48c04ee4e59797c21cac24cfacca0b27b849efb5 Mon Sep 17 00:00:00 2001 From: Giorgio Zamparelli Date: Tue, 27 Jan 2026 10:10:42 +0100 Subject: [PATCH 08/10] feat: add configurable audio codec and bitrate options Add audioBitRate and audioCodec options to improve audio recording quality: - audioBitRate: configurable bitrate (default increased to 320kbps from 256kbps) - audioCodec: support for 'aac' (default), 'alac' (lossless), and 'pcm' (uncompressed) Apple Lossless (alac) provides highest quality for voice recordings without compression artifacts. Co-Authored-By: Claude Opus 4.5 --- .../ScreenCaptureKitCli.swift | 63 ++++++++++++++----- src/index.ts | 16 ++++- 2 files changed, 61 insertions(+), 18 deletions(-) diff --git a/Sources/screencapturekit-cli/ScreenCaptureKitCli.swift b/Sources/screencapturekit-cli/ScreenCaptureKitCli.swift index 196c333..a2d4f69 100644 --- a/Sources/screencapturekit-cli/ScreenCaptureKitCli.swift +++ b/Sources/screencapturekit-cli/ScreenCaptureKitCli.swift @@ -25,6 +25,8 @@ struct Options: Decodable { let videoCodec: String? let enableHDR: Bool? let useDirectRecordingAPI: Bool? + let audioBitRate: Int? + let audioCodec: String? } @main @@ -70,7 +72,9 @@ extension ScreenCaptureKitCLI { 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)") @@ -189,7 +193,9 @@ struct ScreenRecorder { 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 @@ -241,14 +247,42 @@ struct ScreenRecorder { videoInput = AVAssetWriterInput(mediaType: .video, outputSettings: outputSettings) videoInput.expectsMediaDataInRealTime = true + // Helper function to create audio settings based on codec choice + func createAudioSettings(codec: String, bitRate: Int) -> [String: Any] { + switch codec.lowercased() { + case "alac", "lossless", "apple-lossless": + // Apple Lossless for highest quality (no bitrate needed) + return [ + AVFormatIDKey: kAudioFormatAppleLossless, + AVSampleRateKey: 48000, + AVNumberOfChannelsKey: 2, + AVEncoderBitDepthHintKey: 24 + ] + case "pcm", "linear-pcm", "uncompressed": + // Uncompressed PCM for maximum quality (largest file size) + return [ + AVFormatIDKey: kAudioFormatLinearPCM, + AVSampleRateKey: 48000, + AVNumberOfChannelsKey: 2, + AVLinearPCMBitDepthKey: 24, + AVLinearPCMIsFloatKey: false, + AVLinearPCMIsBigEndianKey: false, + AVLinearPCMIsNonInterleaved: false + ] + default: + // AAC with configurable bitrate (default) + return [ + AVFormatIDKey: kAudioFormatMPEG4AAC, + AVSampleRateKey: 48000, + AVNumberOfChannelsKey: 2, + AVEncoderBitRateKey: bitRate + ] + } + } + // Configure audio input if system audio capture is enabled if captureSystemAudio { - let audioSettings: [String: Any] = [ - AVFormatIDKey: kAudioFormatMPEG4AAC, - AVSampleRateKey: 48000, - AVNumberOfChannelsKey: 2, - AVEncoderBitRateKey: 256000 - ] + let audioSettings = createAudioSettings(codec: audioCodec, bitRate: audioBitRate) audioInput = AVAssetWriterInput(mediaType: .audio, outputSettings: audioSettings) audioInput?.expectsMediaDataInRealTime = true @@ -257,19 +291,14 @@ struct ScreenRecorder { assetWriter.add(audioInput) } } - + // Configure microphone input if a microphone device is specified if microphoneDeviceId != nil { - let micSettings: [String: Any] = [ - AVFormatIDKey: kAudioFormatMPEG4AAC, - AVSampleRateKey: 48000, - AVNumberOfChannelsKey: 2, - AVEncoderBitRateKey: 256000 - ] - + let micSettings = createAudioSettings(codec: audioCodec, bitRate: audioBitRate) + microphoneInput = AVAssetWriterInput(mediaType: .audio, outputSettings: micSettings) microphoneInput?.expectsMediaDataInRealTime = true - + if let microphoneInput = microphoneInput, assetWriter.canAdd(microphoneInput) { assetWriter.add(microphoneInput) } diff --git a/src/index.ts b/src/index.ts index 1169ad3..352346a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -118,6 +118,8 @@ export type { MicrophoneDevice }; * @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; @@ -132,6 +134,8 @@ type RecordingOptions = { recordToFile?: boolean; audioOnly?: boolean; outputFilePath?: string; + audioBitRate?: number; + audioCodec?: "aac" | "alac" | "pcm"; }; export type { RecordingOptions }; @@ -150,6 +154,8 @@ export type { RecordingOptions }; * @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 = { @@ -164,6 +170,8 @@ type RecordingOptionsForScreenCaptureKit = { cropRect?: [[x: number, y: number], [width: number, height: number]]; enableHDR?: boolean; useDirectRecordingAPI?: boolean; + audioBitRate?: number; + audioCodec?: string; }; export type { RecordingOptionsForScreenCaptureKit }; @@ -234,6 +242,8 @@ export class ScreenCaptureKit { recordToFile = false, audioOnly = false, outputFilePath = undefined, + audioBitRate = 320000, + audioCodec = "aac", }: Partial = {}) { this.processId = getRandomId(); // Store current options for later use @@ -250,6 +260,8 @@ export class ScreenCaptureKit { recordToFile, audioOnly, outputFilePath, + audioBitRate, + audioCodec, }; return new Promise((resolve, reject) => { @@ -259,7 +271,7 @@ export class ScreenCaptureKit { } this.videoPath = outputFilePath || createTempFile({ extension: "mp4" }); - + console.log(this.videoPath); const recorderOptions: RecordingOptionsForScreenCaptureKit = { destination: fileUrlFromPath(this.videoPath as string), @@ -268,6 +280,8 @@ export class ScreenCaptureKit { highlightClicks, screenId, captureSystemAudio, + audioBitRate, + audioCodec, }; if (highlightClicks === true) { From dc96a625dd30724f219364812456ed69346d7c6d Mon Sep 17 00:00:00 2001 From: Giorgio Zamparelli Date: Thu, 29 Jan 2026 09:03:48 +0100 Subject: [PATCH 09/10] feat: get sample rate from microphone --- .../ScreenCaptureKitCli.swift | 88 +++++++++++++++---- 1 file changed, 70 insertions(+), 18 deletions(-) diff --git a/Sources/screencapturekit-cli/ScreenCaptureKitCli.swift b/Sources/screencapturekit-cli/ScreenCaptureKitCli.swift index a2d4f69..0661d9c 100644 --- a/Sources/screencapturekit-cli/ScreenCaptureKitCli.swift +++ b/Sources/screencapturekit-cli/ScreenCaptureKitCli.swift @@ -247,22 +247,46 @@ struct ScreenRecorder { videoInput = AVAssetWriterInput(mediaType: .video, outputSettings: outputSettings) videoInput.expectsMediaDataInRealTime = true + // 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) -> [String: Any] { + func createAudioSettings(codec: String, bitRate: Int, sampleRate: Double = 48000) -> [String: Any] { switch codec.lowercased() { case "alac", "lossless", "apple-lossless": - // Apple Lossless for highest quality (no bitrate needed) + // Apple Lossless supports any sample rate, lossless quality return [ AVFormatIDKey: kAudioFormatAppleLossless, - AVSampleRateKey: 48000, + AVSampleRateKey: sampleRate, AVNumberOfChannelsKey: 2, AVEncoderBitDepthHintKey: 24 ] case "pcm", "linear-pcm", "uncompressed": - // Uncompressed PCM for maximum quality (largest file size) + // Uncompressed PCM supports any sample rate return [ AVFormatIDKey: kAudioFormatLinearPCM, - AVSampleRateKey: 48000, + AVSampleRateKey: sampleRate, AVNumberOfChannelsKey: 2, AVLinearPCMBitDepthKey: 24, AVLinearPCMIsFloatKey: false, @@ -270,10 +294,14 @@ struct ScreenRecorder { AVLinearPCMIsNonInterleaved: false ] default: - // AAC with configurable bitrate (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: 48000, + AVSampleRateKey: aacSampleRate, AVNumberOfChannelsKey: 2, AVEncoderBitRateKey: bitRate ] @@ -282,7 +310,8 @@ struct ScreenRecorder { // Configure audio input if system audio capture is enabled if captureSystemAudio { - let audioSettings = createAudioSettings(codec: audioCodec, bitRate: audioBitRate) + // System audio uses standard 48kHz + let audioSettings = createAudioSettings(codec: audioCodec, bitRate: audioBitRate, sampleRate: 48000) audioInput = AVAssetWriterInput(mediaType: .audio, outputSettings: audioSettings) audioInput?.expectsMediaDataInRealTime = true @@ -294,7 +323,8 @@ struct ScreenRecorder { // Configure microphone input if a microphone device is specified if microphoneDeviceId != nil { - let micSettings = createAudioSettings(codec: audioCodec, bitRate: audioBitRate) + // 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 @@ -359,6 +389,11 @@ struct ScreenRecorder { config.minimumFrameInterval = CMTime(value: 1, timescale: Int32(truncating: NSNumber(value: showCursor ? 60 : 30))) config.showsCursor = showCursor + // 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 @@ -462,11 +497,23 @@ struct ScreenRecorder { await assetWriter.finishWriting() // Log dropped samples summary - if streamOutput.droppedVideoFrameCount > 0 { - print("Dropped \(streamOutput.droppedVideoFrameCount) video frame(s) during recording") + 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") } - if streamOutput.droppedAudioSampleCount > 0 { - print("Dropped \(streamOutput.droppedAudioSampleCount) audio sample(s) during recording") + + // Log early audio samples (informational, not a problem) + if streamOutput.earlyAudioSampleCount > 0 { + print("ℹ️ Discarded \(streamOutput.earlyAudioSampleCount) early audio sample(s) at recording start (expected)") } } @@ -478,8 +525,9 @@ struct ScreenRecorder { var sessionStarted = false var firstSampleTime: CMTime = .zero var lastSampleBuffer: CMSampleBuffer? - var droppedAudioSampleCount = 0 + 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, @@ -561,15 +609,18 @@ struct ScreenRecorder { return } - // Wait briefly for the writer to become ready (up to 10ms) + // 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 < 10 { - usleep(1000) // 1ms + 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 } @@ -579,8 +630,9 @@ struct ScreenRecorder { // 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 { - droppedAudioSampleCount += 1 + earlyAudioSampleCount += 1 return } From ae489e613d50d6266f10a6de7928b7e5d7f00f99 Mon Sep 17 00:00:00 2001 From: Giorgio Zamparelli Date: Thu, 29 Jan 2026 09:24:17 +0100 Subject: [PATCH 10/10] feat: move faststart bytes to the beginning to avoid Google Chrome making extra requests to find that in the tail --- Sources/screencapturekit-cli/ScreenCaptureKitCli.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Sources/screencapturekit-cli/ScreenCaptureKitCli.swift b/Sources/screencapturekit-cli/ScreenCaptureKitCli.swift index 0661d9c..3edc1e9 100644 --- a/Sources/screencapturekit-cli/ScreenCaptureKitCli.swift +++ b/Sources/screencapturekit-cli/ScreenCaptureKitCli.swift @@ -202,6 +202,9 @@ struct ScreenRecorder { // 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