system audio
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user