system audio

This commit is contained in:
Siddharth
2026-03-07 16:44:10 -08:00
parent 64bc261c20
commit 371f79a35f
5 changed files with 115 additions and 23 deletions
+10
View File
@@ -29,6 +29,8 @@ OpenScreen is 100% free for personal and commercial use. Use it, modify it, dist
## Core Features
- Record your whole screen or specific apps
- Microphone recording with device selection.
- System audio capture (record what's playing on your screen)
- Add manual zooms (customizable depth levels)
- Customize the duration and position of zooms however you please
- Crop video recordings to hide parts
@@ -70,6 +72,14 @@ You may need to grant screen recording permissions depending on your desktop env
./Openscreen-Linux-*.AppImage --no-sandbox
```
### Limitations
System audio capture relies on Electron's [desktopCapturer](https://www.electronjs.org/docs/latest/api/desktop-capturer) and has some platform-specific quirks:
- **macOS**: Requires macOS 13+. On macOS 14.2+ you'll be prompted to grant audio capture permission. macOS 12 and below does not support system audio (mic still works).
- **Windows**: Works out of the box.
- **Linux**: Needs PipeWire (default on Ubuntu 22.04+, Fedora 34+). Older PulseAudio-only setups may not support system audio (mic should still works).
## Built with
- Electron
- React
+5 -1
View File
@@ -35,7 +35,11 @@
}
],
"icon": "icons/icons/mac/icon.icns",
"artifactName": "${productName}-Mac-${arch}-${version}-Installer.${ext}"
"artifactName": "${productName}-Mac-${arch}-${version}-Installer.${ext}",
"extendInfo": {
"NSAudioCaptureUsageDescription": "OpenScreen needs audio capture permission to record system audio.",
"NSCameraUseContinuityCameraDeviceType": true
}
},
"linux": {
"target": [
+7
View File
@@ -8,6 +8,13 @@ import { registerIpcHandlers } from './ipc/handlers'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
// Use Screen & System Audio Recording permissions instead of CoreAudio Tap API on macOS.
// CoreAudio Tap requires NSAudioCaptureUsageDescription in the parent app's Info.plist,
// which doesn't work when running from a terminal/IDE during development, makes my life easier
if (process.platform === 'darwin') {
app.commandLine.appendSwitch('disable-features', 'MacCatapLoopbackAudioForScreenShare')
}
export const RECORDINGS_DIR = path.join(app.getPath('userData'), 'recordings')
+17 -2
View File
@@ -7,14 +7,14 @@ import { AudioLevelMeter } from "../ui/audio-level-meter";
import { Button } from "../ui/button";
import { BsRecordCircle } from "react-icons/bs";
import { FaRegStopCircle } from "react-icons/fa";
import { MdMonitor, MdMic, MdMicOff } from "react-icons/md";
import { MdMonitor, MdMic, MdMicOff, MdVolumeUp, MdVolumeOff } from "react-icons/md";
import { RxDragHandleDots2 } from "react-icons/rx";
import { FaFolderMinus } from "react-icons/fa6";
import { FiMinus, FiX } from "react-icons/fi";
import { ContentClamp } from "../ui/content-clamp";
export function LaunchWindow() {
const { recording, toggleRecording, microphoneEnabled, setMicrophoneEnabled, microphoneDeviceId, setMicrophoneDeviceId } = useScreenRecorder();
const { recording, toggleRecording, microphoneEnabled, setMicrophoneEnabled, microphoneDeviceId, setMicrophoneDeviceId, systemAudioEnabled, setSystemAudioEnabled } = useScreenRecorder();
const [recordingStart, setRecordingStart] = useState<number | null>(null);
const [elapsed, setElapsed] = useState(0);
@@ -165,6 +165,21 @@ export function LaunchWindow() {
<div className="w-px h-6 bg-white/30" />
<Button
variant="link"
size="sm"
onClick={() => !recording && setSystemAudioEnabled(!systemAudioEnabled)}
disabled={recording}
className={`gap-1 text-white bg-transparent hover:bg-transparent px-1 text-xs ${styles.electronNoDrag}`}
title={systemAudioEnabled ? "Disable system audio" : "Enable system audio"}
>
{systemAudioEnabled ? (
<MdVolumeUp size={16} className="text-green-400" />
) : (
<MdVolumeOff size={16} className="text-white/50" />
)}
</Button>
<Button
variant="link"
size="sm"
+76 -20
View File
@@ -32,6 +32,12 @@ const CHROME_MEDIA_SOURCE = "desktop";
const RECORDING_FILE_PREFIX = "recording-";
const VIDEO_FILE_EXTENSION = ".webm";
const AUDIO_BITRATE_VOICE = 128_000;
const AUDIO_BITRATE_SYSTEM = 192_000;
// Boost mic slightly when mixing with system audio so voice isn't drowned out
const MIC_GAIN_BOOST = 1.4;
type UseScreenRecorderReturn = {
recording: boolean;
toggleRecording: () => void;
@@ -39,16 +45,20 @@ type UseScreenRecorderReturn = {
setMicrophoneEnabled: (enabled: boolean) => void;
microphoneDeviceId: string | undefined;
setMicrophoneDeviceId: (deviceId: string | undefined) => void;
systemAudioEnabled: boolean;
setSystemAudioEnabled: (enabled: boolean) => void;
};
export function useScreenRecorder(): UseScreenRecorderReturn {
const [recording, setRecording] = useState(false);
const [microphoneEnabled, setMicrophoneEnabled] = useState(false);
const [microphoneDeviceId, setMicrophoneDeviceId] = useState<string | undefined>(undefined);
const [systemAudioEnabled, setSystemAudioEnabled] = useState(false);
const mediaRecorder = useRef<MediaRecorder | null>(null);
const stream = useRef<MediaStream | null>(null);
const screenStream = useRef<MediaStream | null>(null);
const microphoneStream = useRef<MediaStream | null>(null);
const mixingContext = useRef<AudioContext | null>(null);
const chunks = useRef<Blob[]>([]);
const startTime = useRef<number>(0);
@@ -92,6 +102,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
microphoneStream.current.getTracks().forEach(track => track.stop());
microphoneStream.current = null;
}
if (mixingContext.current) {
mixingContext.current.close().catch(() => {});
mixingContext.current = null;
}
mediaRecorder.current.stop();
setRecording(false);
@@ -126,6 +140,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
microphoneStream.current.getTracks().forEach(track => track.stop());
microphoneStream.current = null;
}
if (mixingContext.current) {
mixingContext.current.close().catch(() => {});
mixingContext.current = null;
}
};
}, []);
@@ -137,19 +155,39 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
return;
}
const screenMediaStream = await (navigator.mediaDevices as any).getUserMedia({
audio: false,
video: {
mandatory: {
chromeMediaSource: CHROME_MEDIA_SOURCE,
chromeMediaSourceId: selectedSource.id,
maxWidth: TARGET_WIDTH,
maxHeight: TARGET_HEIGHT,
maxFrameRate: TARGET_FRAME_RATE,
minFrameRate: MIN_FRAME_RATE,
},
let screenMediaStream: MediaStream;
const videoConstraints = {
mandatory: {
chromeMediaSource: CHROME_MEDIA_SOURCE,
chromeMediaSourceId: selectedSource.id,
maxWidth: TARGET_WIDTH,
maxHeight: TARGET_HEIGHT,
maxFrameRate: TARGET_FRAME_RATE,
minFrameRate: MIN_FRAME_RATE,
},
});
};
if (systemAudioEnabled) {
try {
screenMediaStream = await (navigator.mediaDevices as any).getUserMedia({
audio: { mandatory: { chromeMediaSource: CHROME_MEDIA_SOURCE, chromeMediaSourceId: selectedSource.id } },
video: videoConstraints,
});
} catch (audioErr) {
console.warn('System audio capture failed, falling back to video-only:', audioErr);
toast.error('System audio not available. Recording without system audio.');
screenMediaStream = await (navigator.mediaDevices as any).getUserMedia({
audio: false,
video: videoConstraints,
});
}
} else {
screenMediaStream = await (navigator.mediaDevices as any).getUserMedia({
audio: false,
video: videoConstraints,
});
}
screenStream.current = screenMediaStream;
// If microphone is enabled, request mic stream
@@ -185,11 +223,25 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
}
stream.current.addTrack(videoTrack);
if (microphoneStream.current) {
const micAudioTrack = microphoneStream.current.getAudioTracks()[0];
if (micAudioTrack) {
stream.current.addTrack(micAudioTrack);
}
const systemAudioTrack = screenMediaStream.getAudioTracks()[0];
const micAudioTrack = microphoneStream.current?.getAudioTracks()[0];
if (systemAudioTrack && micAudioTrack) {
// Mix system audio + mic using Web Audio API
const ctx = new AudioContext();
mixingContext.current = ctx;
const systemSource = ctx.createMediaStreamSource(new MediaStream([systemAudioTrack]));
const micSource = ctx.createMediaStreamSource(new MediaStream([micAudioTrack]));
const micGain = ctx.createGain();
micGain.gain.value = MIC_GAIN_BOOST;
const destination = ctx.createMediaStreamDestination();
systemSource.connect(destination);
micSource.connect(micGain).connect(destination);
stream.current.addTrack(destination.stream.getAudioTracks()[0]);
} else if (systemAudioTrack) {
stream.current.addTrack(systemAudioTrack);
} else if (micAudioTrack) {
stream.current.addTrack(micAudioTrack);
}
try {
await videoTrack.applyConstraints({
@@ -216,13 +268,13 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
)} Mbps`
);
const hasMicAudio = microphoneEnabled && microphoneStream.current !== null;
const hasAudio = stream.current.getAudioTracks().length > 0;
chunks.current = [];
const recorder = new MediaRecorder(stream.current, {
mimeType,
videoBitsPerSecond,
...(hasMicAudio ? { audioBitsPerSecond: 128_000 } : {}),
...(hasAudio ? { audioBitsPerSecond: systemAudioTrack ? AUDIO_BITRATE_SYSTEM : AUDIO_BITRATE_VOICE } : {}),
});
mediaRecorder.current = recorder;
recorder.ondataavailable = e => {
@@ -283,6 +335,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
microphoneStream.current.getTracks().forEach(track => track.stop());
microphoneStream.current = null;
}
if (mixingContext.current) {
mixingContext.current.close().catch(() => {});
mixingContext.current = null;
}
}
};
@@ -290,5 +346,5 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
recording ? stopRecording.current() : startRecording();
};
return { recording, toggleRecording, microphoneEnabled, setMicrophoneEnabled, microphoneDeviceId, setMicrophoneDeviceId };
return { recording, toggleRecording, microphoneEnabled, setMicrophoneEnabled, microphoneDeviceId, setMicrophoneDeviceId, systemAudioEnabled, setSystemAudioEnabled };
}