- The toolbar's Mic: on / off (red while on) turns the microphone on and off while recording; its state is saved as microphone in [video] for the next recording. A microphone that cannot be opened turns it off again, with a toast saying why. - modules/Screen_Record: record_set_microphone, record_microphone, record_microphone_failed. The audio thread opens the microphone only while it is on (so Windows shows it in use only then) and closes it when turned off. Record_Options.audio_track gives the MP4 a sound track whatever is on at the start, silent while nothing is, as one cannot be added once the header is written; the app always asks for it. The sources heard and the failures are kept as they happen, not only at the start. - record-probe -mic-toggle: the microphone on at 3 s and off at 7 s. With system sound and with nothing else on, the track runs the whole recording without a break.
788 lines
32 KiB
Plaintext
788 lines
32 KiB
Plaintext
// Screen_Record: a region of one screen recorded to H.264 in an MP4, for the mcap app's screen
|
|
// recording and tools/record_probe.jai (video-capture-plan.html). Windows only.
|
|
//
|
|
// Two threads. The capture thread takes the screen with DXGI Desktop Duplication (dxgi_source.jai),
|
|
// or with Windows.Graphics.Capture (wgc_source.jai) where duplication cannot run, each on a D3D11
|
|
// device of its own; copies the region on the GPU; reads it back through a ring of staging textures,
|
|
// so Map never waits on the GPU (capture_gpu.jai); and, for duplication, draws the cursor in
|
|
// (cursor.jai). The encoder thread hands the frames to FFmpeg (encoder.jai). Between them is a pool of
|
|
// RECORD_BUFFERS frames; when the encoder falls RECORD_QUEUE frames behind, the oldest waiting is
|
|
// dropped and counted.
|
|
//
|
|
// A frame is taken at most fps times a second, and only when the screen or the cursor changed, with
|
|
// one a second while nothing does, so players can seek. Each carries the time it was taken, less the
|
|
// time spent paused (record_pause), so a pause leaves no gap in the video.
|
|
//
|
|
// Sound (system_audio, microphone) has a thread of its own (audio_source.jai's WASAPI): each packet is
|
|
// put on the same timeline as the frames, by when it was heard, less the same pauses, into one mix,
|
|
// and an AAC frame of it is encoded once every source has had its say up to there. A source that sends
|
|
// nothing (the computer playing nothing) is silence. The microphone can be turned on and off while
|
|
// recording (record_set_microphone); with audio_track, the MP4 has a sound track even when no source
|
|
// is on at the start, so it can be.
|
|
//
|
|
// recorder: Recorder;
|
|
// ok, error, fall_back := record_start(*recorder, .{path = "out.mp4", screen = 0});
|
|
// ...
|
|
// ok, error = record_stop(*recorder);
|
|
//
|
|
// With capture .AUTO (the default), Desktop Duplication is tried first and Windows.Graphics.Capture
|
|
// takes over when it cannot run: on a laptop with two GPUs, on a rotated screen, or with too many
|
|
// duplications of the screen running. fall_back is only returned for .DXGI, which does not fall back.
|
|
|
|
RECORD_MIN_WIDTH :: 240; // a smaller region is refused
|
|
RECORD_MIN_HEIGHT :: 180;
|
|
RECORD_QUEUE :: 8; // frames waiting for the encoder at most
|
|
RECORD_BUFFERS :: RECORD_QUEUE + 2; // and the one being filled, and the one being encoded
|
|
|
|
// Whether a region this size may be recorded: at least RECORD_MIN_WIDTH x RECORD_MIN_HEIGHT. It is
|
|
// rounded down to even sizes after, which keeps it at least that, as both are even.
|
|
record_size_allowed :: (width: s64, height: s64) -> bool {
|
|
return width >= RECORD_MIN_WIDTH && height >= RECORD_MIN_HEIGHT;
|
|
}
|
|
|
|
Record_Screen :: struct {
|
|
adapter_index, output_index: u32;
|
|
adapter: string; // "NVIDIA GeForce RTX 5080 Laptop GPU"
|
|
name: string; // "\\.\DISPLAY1"
|
|
desktop: RECT; // on the virtual desktop
|
|
rotation: DXGI_MODE_ROTATION;
|
|
monitor: HMONITOR;
|
|
}
|
|
|
|
Record_Capture :: enum u8 {
|
|
AUTO; // Desktop Duplication, else Windows.Graphics.Capture
|
|
DXGI; // Desktop Duplication only
|
|
WGC; // Windows.Graphics.Capture only
|
|
}
|
|
|
|
// For record-probe: paths a machine may not take by itself.
|
|
Record_Testing :: enum_flags u8 {
|
|
DXGI_UNSUPPORTED; // Desktop Duplication fails as on a laptop with two GPUs (DXGI_ERROR_UNSUPPORTED)
|
|
WGC_POLL; // Windows.Graphics.Capture polled, as when it will not take the FrameArrived handler
|
|
KEEP_BORDER; // Windows.Graphics.Capture's yellow border left on
|
|
LOSE_DUPLICATION; // Desktop Duplication lost every 3 s, as a mode change or the secure desktop loses it
|
|
}
|
|
|
|
Record_Options :: struct {
|
|
path: string; // the .mp4 written
|
|
screen: s32 = -1; // into record_list_screens(); -1: the one holding region
|
|
region: RECT; // on the virtual desktop; empty: the whole screen
|
|
fps: s32 = 30;
|
|
cursor := true;
|
|
encoder: string; // "" tries RECORD_ENCODERS in turn
|
|
capture: Record_Capture;
|
|
legacy_duplicate: bool; // the plain DuplicateOutput, not DuplicateOutput1: for record-probe
|
|
testing: Record_Testing;
|
|
system_audio: bool; // what the computer plays (the default output device)
|
|
microphone: bool; // the default input device, at the start: record_set_microphone turns it on and off
|
|
audio_track: bool; // a sound track whatever is on at the start, so the microphone can be turned on later
|
|
}
|
|
|
|
Record_Stats :: struct {
|
|
source: string; // "DXGI DuplicateOutput1 on <adapter>", "Windows.Graphics.Capture on <adapter>, border off"
|
|
fell_back_from: string; // why Desktop Duplication could not run, when it fell back
|
|
encoder: string;
|
|
width, height: s32;
|
|
captured: s64; // new images of the screen
|
|
pointer_moves: s64; // cursor changes with no new image (Desktop Duplication)
|
|
emitted: s64; // frames sent to be encoded
|
|
repeats: s64; // of them, the once-a-second repeat of an unchanged screen
|
|
written: s64; // encoded
|
|
dropped: s64; // the encoder was too far behind
|
|
lost: s64; // times the duplication was lost and made again
|
|
paused_seconds: float64; // left out of the video
|
|
audio: string; // the sound recorded: "Speakers (Realtek(R) Audio); Microphone (USB Audio)", or ""
|
|
audio_frames: s64; // AAC frames encoded
|
|
audio_errors: string; // sources that could not be opened, or were lost
|
|
copy_ticks, readback_ticks, encode_ticks: s64; // totals, in QueryPerformanceCounter ticks
|
|
bytes: s64; // encoded
|
|
seconds: float64; // from start to stop
|
|
}
|
|
|
|
Recorder :: struct {
|
|
options: Record_Options;
|
|
screen: Record_Screen;
|
|
local: RECT; // the region, in the screen's pixels
|
|
dxgi: Dxgi_Source; // the capture thread's: one of these is open
|
|
wgc: Wgc_Source;
|
|
using_wgc: bool;
|
|
encoder: Encoder;
|
|
width, height: s32;
|
|
frequency: s64; // QueryPerformanceCounter's
|
|
start_ticks: s64;
|
|
|
|
capture_thread, encoder_thread: Thread;
|
|
capture_started, encoder_started, audio_started: bool;
|
|
audio_thread: Thread;
|
|
audio_opened: Semaphore; // the audio thread has opened its sources
|
|
audio_go: Semaphore; // the encoder is open: the audio thread may write
|
|
audio_sources: [2] Audio_Source;
|
|
audio_open: bool; // any source opened, or audio_track: the MP4 has sound
|
|
microphone_on: bool; // the microphone is wanted: record_set_microphone; cleared when it cannot be opened
|
|
microphone_failed: bool; // it could not be opened after being turned on: record_microphone_failed says once
|
|
zero_ticks: s64; // the recording's time 0, for frames and sound alike
|
|
paused_ticks: s64; // time paused so far, for the capture and audio threads; set by record_pause
|
|
pause_began: s64;
|
|
running: bool;
|
|
stopping: bool; // set by record_stop; the capture thread ends, then the encoder
|
|
paused: bool; // set by record_pause; the capture thread takes nothing while it is
|
|
capture_ended: bool;
|
|
|
|
opened: Semaphore; // the capture thread has opened its source, or failed to
|
|
open_ok, open_fall_back: bool;
|
|
open_error: string;
|
|
|
|
mutex: Mutex;
|
|
ready: Semaphore; // signalled for each frame queued, and once when capture ends
|
|
frames: [RECORD_BUFFERS] Record_Frame;
|
|
free: [RECORD_BUFFERS] s32;
|
|
free_count: s32;
|
|
queue: [RECORD_BUFFERS] s32; // oldest first
|
|
queue_count: s32;
|
|
last_frame: [] u8; // the last frame queued, BGRA, for record_last_frame
|
|
stats: Record_Stats;
|
|
error: string; // why it stopped by itself; heap
|
|
}
|
|
|
|
Record_Frame :: struct {
|
|
pixels: [] u8; // BGRA, rows packed
|
|
ticks: s64;
|
|
}
|
|
|
|
// Opens the screen, the encoder and the file, and starts recording.
|
|
record_start :: (r: *Recorder, options: Record_Options) -> ok: bool, error: string, fall_back: bool {
|
|
r.* = .{};
|
|
r.options = options;
|
|
r.options.path = copy_string(options.path);
|
|
r.options.encoder = copy_string(options.encoder);
|
|
if r.options.fps <= 0 r.options.fps = 30;
|
|
|
|
screens := record_list_screens();
|
|
if !screens.count return false, "no screen to record", false;
|
|
screen_index := options.screen;
|
|
region := options.region;
|
|
has_region := region.right > region.left && region.bottom > region.top;
|
|
if screen_index < 0 {
|
|
if !has_region screen_index = 0;
|
|
else for screens if rect_inside(region, it.desktop) { screen_index = xx it_index; break; }
|
|
if screen_index < 0 return false, "the region is not on one screen", false;
|
|
}
|
|
if screen_index >= screens.count return false, tprint("there is no screen %", screen_index), false;
|
|
r.screen = screens[screen_index];
|
|
r.screen.adapter = copy_string(r.screen.adapter);
|
|
r.screen.name = copy_string(r.screen.name);
|
|
if !has_region region = r.screen.desktop;
|
|
if !rect_inside(region, r.screen.desktop) return false, "the region is not on that screen", false;
|
|
|
|
// The minimum first, then even both ways, in the screen's pixels: H.264's 4:2:0 colour needs it.
|
|
// RECORD_MIN_WIDTH and _HEIGHT are even, so what passes stays at least that size.
|
|
#assert RECORD_MIN_WIDTH % 2 == 0 && RECORD_MIN_HEIGHT % 2 == 0;
|
|
if !record_size_allowed(region.right - region.left, region.bottom - region.top)
|
|
return false, tprint("% x % is smaller than % x %", region.right - region.left, region.bottom - region.top, RECORD_MIN_WIDTH, RECORD_MIN_HEIGHT), false;
|
|
d := r.screen.desktop;
|
|
r.local = .{region.left - d.left, region.top - d.top, region.right - d.left, region.bottom - d.top};
|
|
r.local.right = r.local.left + ((r.local.right - r.local.left) & cast(s32) ~1);
|
|
r.local.bottom = r.local.top + ((r.local.bottom - r.local.top) & cast(s32) ~1);
|
|
r.width = r.local.right - r.local.left;
|
|
r.height = r.local.bottom - r.local.top;
|
|
r.stats.width, r.stats.height = r.width, r.height;
|
|
|
|
init(*r.mutex, "record");
|
|
init(*r.ready);
|
|
init(*r.opened);
|
|
for * r.frames it.pixels = NewArray(cast(s64) r.width * r.height * 4, u8, initialized = false);
|
|
for 0..RECORD_BUFFERS - 1 r.free[it] = cast(s32) it;
|
|
r.free_count = RECORD_BUFFERS;
|
|
r.last_frame = NewArray(cast(s64) r.width * r.height * 4, u8);
|
|
frequency: LARGE_INTEGER;
|
|
QueryPerformanceFrequency(*frequency);
|
|
r.frequency = frequency.QuadPart;
|
|
r.start_ticks = ticks_now();
|
|
r.zero_ticks = r.start_ticks;
|
|
|
|
// The source is opened on the capture thread, which uses it: WinRT's objects belong to the
|
|
// apartment they are made in.
|
|
thread_init(*r.capture_thread, record_capture_proc, name = "record capture");
|
|
r.capture_started = true;
|
|
thread_start(*r.capture_thread, r);
|
|
wait_for(*r.opened);
|
|
if !r.open_ok {
|
|
error, fall_back := copy_string(r.open_error,, temp), r.open_fall_back;
|
|
record_free(r);
|
|
return false, error, fall_back;
|
|
}
|
|
|
|
// The sound's sources are opened on their own thread (COM, for WASAPI), before the encoder, which
|
|
// has a sound stream only when one opened.
|
|
r.microphone_on = r.options.microphone;
|
|
if r.options.system_audio || r.options.microphone || r.options.audio_track {
|
|
init(*r.audio_opened);
|
|
init(*r.audio_go);
|
|
thread_init(*r.audio_thread, record_audio_proc, name = "record audio");
|
|
r.audio_started = true;
|
|
thread_start(*r.audio_thread, r);
|
|
wait_for(*r.audio_opened);
|
|
}
|
|
|
|
ok, error, tried := encoder_open(*r.encoder, r.options.path, r.width, r.height, r.options.fps, r.options.encoder, audio = r.audio_open);
|
|
if tried log("[record] Encoders that did not open: %", tried);
|
|
if !ok {
|
|
error = copy_string(error,, temp);
|
|
record_free(r);
|
|
return false, error, false;
|
|
}
|
|
r.stats.encoder = copy_string(r.encoder.name);
|
|
if r.audio_started signal(*r.audio_go);
|
|
|
|
thread_init(*r.encoder_thread, record_encoder_proc, name = "record encoder");
|
|
r.encoder_started = true;
|
|
r.running = true;
|
|
thread_start(*r.encoder_thread, r);
|
|
return true, "", false;
|
|
}
|
|
|
|
// Stops, finishes the file and frees everything. ok is false when recording stopped by itself on an
|
|
// error, or the file could not be finished; what was recorded up to then is in the file all the same.
|
|
record_stop :: (r: *Recorder) -> ok: bool, error: string {
|
|
if !r.running return false, "not recording";
|
|
atomic_swap(*r.stopping, true);
|
|
thread_is_done(*r.capture_thread, -1);
|
|
thread_is_done(*r.encoder_thread, -1);
|
|
if r.audio_started thread_is_done(*r.audio_thread, -1);
|
|
r.running = false;
|
|
|
|
closed, close_error := encoder_close(*r.encoder);
|
|
r.stats.seconds = cast(float64) (ticks_now() - r.start_ticks) / r.frequency;
|
|
r.stats.bytes = r.encoder.bytes;
|
|
error := ifx r.error then copy_string(r.error,, temp) else "";
|
|
record_free(r);
|
|
|
|
if error return false, error;
|
|
if !closed return false, close_error;
|
|
return true, "";
|
|
}
|
|
|
|
// Pauses or resumes. While paused nothing is taken, and the time is left out of the video, sound and
|
|
// all: the time paused is kept here, once, for the capture and audio threads both.
|
|
record_pause :: (r: *Recorder, pause: bool) {
|
|
if !r.running || atomic_read(*r.paused) == pause return;
|
|
now := ticks_now();
|
|
if pause {
|
|
atomic_write(*r.pause_began, now);
|
|
atomic_swap(*r.paused, true);
|
|
} else {
|
|
atomic_add(*r.paused_ticks, now - atomic_read(*r.pause_began)); // before paused clears, so no thread sees it unpaused with the old total
|
|
atomic_swap(*r.paused, false);
|
|
}
|
|
}
|
|
|
|
// Turns the microphone on or off while recording. It is opened only while on, so Windows shows it in
|
|
// use only then. False when the recording has no sound track to put it in.
|
|
record_set_microphone :: (r: *Recorder, on: bool) -> bool {
|
|
if !r.running || !r.audio_open return false;
|
|
atomic_swap(*r.microphone_on, on);
|
|
return true;
|
|
}
|
|
|
|
record_microphone :: (r: *Recorder) -> bool {
|
|
return r.running && atomic_read(*r.microphone_on);
|
|
}
|
|
|
|
// Whether the microphone could not be opened when turned on (it is off again then), and why; once.
|
|
record_microphone_failed :: (r: *Recorder) -> failed: bool, error: string {
|
|
if !r.running || !atomic_read(*r.microphone_failed) return false, "";
|
|
atomic_swap(*r.microphone_failed, false);
|
|
lock(*r.mutex);
|
|
defer unlock(*r.mutex);
|
|
return true, copy_string(r.stats.audio_errors,, temp);
|
|
}
|
|
|
|
// Ticks on the recording's timeline: time 0 at its start, less the time paused.
|
|
record_timeline :: (r: *Recorder, ticks: s64) -> s64 {
|
|
return ticks - r.zero_ticks - atomic_read(*r.paused_ticks);
|
|
}
|
|
|
|
record_paused :: (r: *Recorder) -> bool {
|
|
return r.running && atomic_read(*r.paused);
|
|
}
|
|
|
|
// As they are now; strings are the recorder's.
|
|
record_stats :: (r: *Recorder) -> Record_Stats {
|
|
if !r.running return r.stats;
|
|
lock(*r.mutex);
|
|
defer unlock(*r.mutex);
|
|
return r.stats;
|
|
}
|
|
|
|
// The last frame queued, BGRA (temp), cursor and all.
|
|
record_last_frame :: (r: *Recorder) -> pixels: [] u8, width: s32, height: s32 {
|
|
if r.running lock(*r.mutex);
|
|
pixels := array_copy(r.last_frame,, temp);
|
|
if r.running unlock(*r.mutex);
|
|
return pixels, r.width, r.height;
|
|
}
|
|
|
|
// Whether recording stopped by itself (the screen went away, the encoder failed); why, then.
|
|
record_failed :: (r: *Recorder) -> bool, string {
|
|
lock(*r.mutex);
|
|
defer unlock(*r.mutex);
|
|
return r.error.count > 0, r.error;
|
|
}
|
|
|
|
#scope_file
|
|
|
|
Staged :: struct { pending: bool; ticks: s64; pointer: POINT; pointer_visible: bool; }
|
|
|
|
// Stops the capture thread if it runs, and frees what record_start made. The stats' strings are kept.
|
|
record_free :: (r: *Recorder) {
|
|
atomic_swap(*r.stopping, true);
|
|
if r.capture_started { thread_is_done(*r.capture_thread, -1); thread_deinit(*r.capture_thread); r.capture_started = false; }
|
|
if r.encoder_started { thread_is_done(*r.encoder_thread, -1); thread_deinit(*r.encoder_thread); r.encoder_started = false; }
|
|
if r.audio_started {
|
|
signal(*r.audio_go); // in case it waits for an encoder that did not open
|
|
thread_is_done(*r.audio_thread, -1);
|
|
thread_deinit(*r.audio_thread);
|
|
destroy(*r.audio_opened);
|
|
destroy(*r.audio_go);
|
|
r.audio_started = false;
|
|
}
|
|
if r.encoder.format encoder_close(*r.encoder);
|
|
for * r.frames array_free(it.pixels);
|
|
array_free(r.last_frame);
|
|
r.last_frame = .[];
|
|
destroy(*r.opened);
|
|
destroy(*r.ready);
|
|
destroy(*r.mutex);
|
|
free(r.open_error);
|
|
free(r.error);
|
|
r.open_error, r.error = "", "";
|
|
}
|
|
|
|
// Desktop Duplication, unless asked for Windows.Graphics.Capture, which takes over when duplication
|
|
// cannot run here and the options allow.
|
|
record_open_source :: (r: *Recorder) -> bool {
|
|
if r.options.capture != .WGC {
|
|
ok, error, fall_back := dxgi_open(*r.dxgi, r.screen, r.local, r.options.legacy_duplicate);
|
|
if ok && r.options.testing & .DXGI_UNSUPPORTED {
|
|
ok, error, fall_back = false, "Desktop Duplication (testing): 0x887A0004 (DXGI_ERROR_UNSUPPORTED)", true;
|
|
}
|
|
if ok {
|
|
r.stats.source = sprint("DXGI % on %", r.dxgi.method, r.screen.adapter);
|
|
return true;
|
|
}
|
|
dxgi_close(*r.dxgi);
|
|
if r.options.capture == .DXGI || !fall_back {
|
|
r.open_error, r.open_fall_back = copy_string(error), fall_back;
|
|
return false;
|
|
}
|
|
log("[record] Desktop Duplication cannot run here (%); Windows.Graphics.Capture instead.", error);
|
|
r.stats.fell_back_from = copy_string(error);
|
|
}
|
|
ok, error := wgc_open(*r.wgc, r.screen, r.local, r.options.cursor, r.options.testing);
|
|
if !ok {
|
|
wgc_close(*r.wgc);
|
|
r.open_error = copy_string(error);
|
|
return false;
|
|
}
|
|
r.using_wgc = true;
|
|
r.stats.source = sprint("Windows.Graphics.Capture on %, %%", r.screen.adapter, r.wgc.border, ifx r.wgc.polling then ", polled" else "");
|
|
return true;
|
|
}
|
|
|
|
record_capture_proc :: (thread: *Thread) -> s64 {
|
|
r := cast(*Recorder) thread.data;
|
|
// Aware of each monitor's DPI, whatever the process is: Desktop Duplication refuses a thread that is
|
|
// not (DXGI_ERROR_UNSUPPORTED), and the mcap app's ImGui makes only its main thread aware.
|
|
gpu_thread_dpi_aware();
|
|
wgc_thread_start();
|
|
defer wgc_thread_end();
|
|
r.open_ok = record_open_source(r);
|
|
signal(*r.opened);
|
|
if !r.open_ok return 0;
|
|
gpu: *Capture_Gpu = ifx r.using_wgc then *r.wgc.gpu else *r.dxgi.gpu;
|
|
defer if r.using_wgc wgc_close(*r.wgc); else dxgi_close(*r.dxgi);
|
|
|
|
// A change is sent as soon as it comes once its turn is due: turns are a frame's time apart on a
|
|
// schedule that moves on a frame at a time, so the rate never passes fps, and a tenth of a frame
|
|
// early counts as on time, so a 60 Hz screen's frames, which jitter, all make a 60 fps cap.
|
|
period := r.frequency / r.options.fps;
|
|
early := period / 10;
|
|
turn := ticks_now();
|
|
last_emit: s64 = -r.frequency;
|
|
have_image, changed := false, false;
|
|
staged: [RECORD_STAGING] Staged;
|
|
emits: s64;
|
|
was_paused := false;
|
|
last_lost := ticks_now(); // for Record_Testing.LOSE_DUPLICATION
|
|
|
|
while !atomic_read(*r.stopping) {
|
|
now := ticks_now();
|
|
due := ifx changed then turn - early else last_emit + r.frequency; // the change, or the repeat
|
|
wait := cast(u32) clamp((due - now) * 1000 / r.frequency, 0, 100);
|
|
next: Capture_Next;
|
|
error: string;
|
|
if r.using_wgc next, error = wgc_next(*r.wgc, wait);
|
|
else next, error = dxgi_next(*r.dxgi, wait);
|
|
if r.options.testing & .LOSE_DUPLICATION && !r.using_wgc && ticks_now() - last_lost >= 3 * r.frequency {
|
|
next, last_lost = Capture_Next.LOST, ticks_now();
|
|
}
|
|
if next == {
|
|
case .FAILED;
|
|
record_fail(r, error);
|
|
break;
|
|
case .LOST;
|
|
// A mode change, the secure desktop, a game going fullscreen: duplicated again, and on.
|
|
lock(*r.mutex); r.stats.lost += 1; unlock(*r.mutex);
|
|
while !atomic_read(*r.stopping) {
|
|
hr := dxgi_duplicate(*r.dxgi);
|
|
if SUCCEEDED(hr) break;
|
|
if !hresult_is(hr, DXGI_ERROR_ACCESS_LOST) && !hresult_is(hr, 0x80070005) && !hresult_is(hr, DXGI_ERROR_NOT_CURRENTLY_AVAILABLE) {
|
|
record_fail(r, hresult_text("the screen could not be duplicated again", hr));
|
|
break;
|
|
}
|
|
sleep_milliseconds(250);
|
|
}
|
|
if atomic_read(*r.stopping) || r.error break;
|
|
continue;
|
|
case .IMAGE; #through;
|
|
case .IMAGE_AND_POINTER;
|
|
have_image, changed = true, true;
|
|
lock(*r.mutex);
|
|
r.stats.captured += 1;
|
|
r.stats.copy_ticks = gpu.copy_ticks;
|
|
unlock(*r.mutex);
|
|
case .POINTER;
|
|
if r.options.cursor && have_image {
|
|
changed = true;
|
|
lock(*r.mutex); r.stats.pointer_moves += 1; unlock(*r.mutex);
|
|
}
|
|
}
|
|
|
|
// Paused: the source is still drained, so it never stalls, but nothing is sent. On resuming, the
|
|
// screen as it is now is sent at once.
|
|
now = ticks_now();
|
|
if atomic_read(*r.paused) {
|
|
was_paused = true;
|
|
lock(*r.mutex); r.stats.paused_seconds = cast(float64) (atomic_read(*r.paused_ticks) + now - atomic_read(*r.pause_began)) / r.frequency; unlock(*r.mutex);
|
|
continue;
|
|
}
|
|
if was_paused {
|
|
was_paused = false;
|
|
changed = true;
|
|
turn = now;
|
|
lock(*r.mutex); r.stats.paused_seconds = cast(float64) atomic_read(*r.paused_ticks) / r.frequency; unlock(*r.mutex);
|
|
}
|
|
if !have_image continue;
|
|
repeat := !changed && now - last_emit >= r.frequency;
|
|
if !(changed && now >= turn - early) && !repeat continue;
|
|
turn = max(turn + period, now - period); // after a quiet spell, no burst to catch up
|
|
|
|
// Copied to staging now; read back two frames on, when the GPU is done with it.
|
|
slot := emits % RECORD_STAGING;
|
|
if staged[slot].pending record_read_back(r, gpu, slot, staged[slot]);
|
|
ID3D11DeviceContext_CopyResource(gpu.device_context, gpu.staging[slot], gpu.latest);
|
|
staged[slot] = .{pending = true, ticks = record_timeline(r, now)};
|
|
if !r.using_wgc staged[slot].pointer, staged[slot].pointer_visible = r.dxgi.pointer_position, r.dxgi.pointer_visible;
|
|
emits += 1;
|
|
last_emit = now;
|
|
changed = false;
|
|
lock(*r.mutex);
|
|
r.stats.emitted += 1;
|
|
if repeat r.stats.repeats += 1;
|
|
unlock(*r.mutex);
|
|
}
|
|
|
|
// What is still staged, oldest first.
|
|
for 0..RECORD_STAGING - 1 {
|
|
slot := (emits + it) % RECORD_STAGING;
|
|
if staged[slot].pending record_read_back(r, gpu, slot, staged[slot]);
|
|
}
|
|
atomic_swap(*r.capture_ended, true);
|
|
signal(*r.ready);
|
|
return 0;
|
|
}
|
|
|
|
// Copies a staging texture into a free frame, draws the cursor (Desktop Duplication leaves it out)
|
|
// and queues it.
|
|
record_read_back :: (r: *Recorder, gpu: *Capture_Gpu, slot: s64, staged: Staged) {
|
|
t := ticks_now();
|
|
lock(*r.mutex);
|
|
if !r.free_count { // every frame is waiting: drop the oldest
|
|
r.free[r.free_count] = r.queue[0];
|
|
r.free_count += 1;
|
|
for 1..r.queue_count - 1 r.queue[it - 1] = r.queue[it];
|
|
r.queue_count -= 1;
|
|
r.stats.dropped += 1;
|
|
}
|
|
r.free_count -= 1;
|
|
index := r.free[r.free_count];
|
|
unlock(*r.mutex);
|
|
frame := *r.frames[index];
|
|
|
|
mapped: D3D11_MAPPED_SUBRESOURCE;
|
|
hr := ID3D11DeviceContext_Map(gpu.device_context, gpu.staging[slot], 0, .READ, 0, *mapped);
|
|
if FAILED(hr) {
|
|
lock(*r.mutex); r.free[r.free_count] = index; r.free_count += 1; unlock(*r.mutex);
|
|
record_fail(r, hresult_text("reading the frame back", hr));
|
|
return;
|
|
}
|
|
row := cast(s64) r.width * 4;
|
|
for y: 0..r.height - 1 memcpy(frame.pixels.data + y * row, cast(*u8) mapped.pData + cast(s64) y * mapped.RowPitch, row);
|
|
ID3D11DeviceContext_Unmap(gpu.device_context, gpu.staging[slot], 0);
|
|
if r.options.cursor && staged.pointer_visible {
|
|
cursor_draw(frame.pixels.data, r.width, r.height, *r.dxgi.shape,
|
|
staged.pointer.x - cast(s32) gpu.box.left, staged.pointer.y - cast(s32) gpu.box.top);
|
|
}
|
|
frame.ticks = staged.ticks;
|
|
|
|
lock(*r.mutex);
|
|
r.queue[r.queue_count] = index;
|
|
r.queue_count += 1;
|
|
memcpy(r.last_frame.data, frame.pixels.data, frame.pixels.count);
|
|
r.stats.readback_ticks += ticks_now() - t;
|
|
unlock(*r.mutex);
|
|
signal(*r.ready);
|
|
}
|
|
|
|
record_encoder_proc :: (thread: *Thread) -> s64 {
|
|
r := cast(*Recorder) thread.data;
|
|
while true {
|
|
wait_for(*r.ready, 100);
|
|
lock(*r.mutex);
|
|
index: s32 = -1;
|
|
if r.queue_count {
|
|
index = r.queue[0];
|
|
for 1..r.queue_count - 1 r.queue[it - 1] = r.queue[it];
|
|
r.queue_count -= 1;
|
|
}
|
|
unlock(*r.mutex);
|
|
if index < 0 {
|
|
if atomic_read(*r.capture_ended) break;
|
|
continue;
|
|
}
|
|
|
|
frame := *r.frames[index];
|
|
pts := max(frame.ticks, 0) * 90000 / r.frequency; // frame.ticks is on the timeline already
|
|
t := ticks_now();
|
|
ok, error := encoder_write(*r.encoder, frame.pixels.data, pts);
|
|
|
|
lock(*r.mutex);
|
|
r.free[r.free_count] = index;
|
|
r.free_count += 1;
|
|
r.stats.encode_ticks += ticks_now() - t;
|
|
if ok r.stats.written += 1;
|
|
r.stats.bytes = r.encoder.bytes;
|
|
unlock(*r.mutex);
|
|
if !ok {
|
|
record_fail(r, error);
|
|
// Frames still come until the capture thread sees it; they are let go.
|
|
while !atomic_read(*r.capture_ended) {
|
|
wait_for(*r.ready, 100);
|
|
lock(*r.mutex);
|
|
for 0..r.queue_count - 1 { r.free[r.free_count] = r.queue[it]; r.free_count += 1; }
|
|
r.queue_count = 0;
|
|
unlock(*r.mutex);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
// The audio thread: opens the sources asked for, waits for the encoder, then every 10 ms takes their
|
|
// packets into the mix and encodes what every source has covered.
|
|
record_audio_proc :: (thread: *Thread) -> s64 {
|
|
r := cast(*Recorder) thread.data;
|
|
audio_thread_start();
|
|
defer audio_thread_end();
|
|
|
|
kinds: [2] Audio_Source_Kind = .[.SYSTEM, .MICROPHONE];
|
|
wanted: [2] bool = .[r.options.system_audio, r.options.microphone];
|
|
names, errors: [..] string; // every source heard, and every failure, for the stats
|
|
names.allocator, errors.allocator = context.default_allocator, context.default_allocator;
|
|
defer { for names free(it); array_free(names); for errors free(it); array_free(errors); }
|
|
note :: (list: *[..] string, text: string, field: *string) #expand {
|
|
for list.* if it == text return;
|
|
array_add(list, copy_string(text));
|
|
lock(*`r.mutex);
|
|
free(field.*);
|
|
field.* = join(..list.*, "; ");
|
|
unlock(*`r.mutex);
|
|
}
|
|
for 0..1 {
|
|
if !wanted[it] continue;
|
|
s := *r.audio_sources[it];
|
|
ok, error := audio_open(s, kinds[it]);
|
|
what := ifx kinds[it] == .SYSTEM then "system sound" else "microphone";
|
|
if ok { r.audio_open = true; note(*names, s.name, *r.stats.audio); }
|
|
else { audio_close(s); note(*errors, tprint("%: %", what, error), *r.stats.audio_errors); log_error("[record] No %: %", what, error); }
|
|
}
|
|
if !r.audio_sources[1].open atomic_swap(*r.microphone_on, false);
|
|
if r.options.audio_track r.audio_open = true;
|
|
signal(*r.audio_opened);
|
|
wait_for(*r.audio_go);
|
|
if !r.audio_open || !r.encoder.audio || atomic_read(*r.stopping) {
|
|
for * r.audio_sources audio_free(it);
|
|
return 0;
|
|
}
|
|
|
|
mix: Audio_Mix;
|
|
mix.frame_size = r.encoder.audio_frame_size;
|
|
defer array_free(mix.samples);
|
|
while true {
|
|
stopping := atomic_read(*r.stopping);
|
|
// The microphone, turned on or off from the toolbar: opened only while on.
|
|
mic := *r.audio_sources[1];
|
|
mic_wanted := atomic_read(*r.microphone_on) && !stopping;
|
|
if mic_wanted && !mic.open && !wanted[1] {
|
|
ok, error := audio_open(mic, .MICROPHONE);
|
|
if ok {
|
|
log("[record] Microphone on: %.", mic.name);
|
|
note(*names, mic.name, *r.stats.audio);
|
|
mix.started[1] = false;
|
|
} else {
|
|
audio_close(mic);
|
|
log_error("[record] The microphone could not be opened: %", error);
|
|
note(*errors, tprint("microphone: %", error), *r.stats.audio_errors);
|
|
atomic_swap(*r.microphone_on, false);
|
|
atomic_swap(*r.microphone_failed, true);
|
|
}
|
|
} else if !mic_wanted && mic.open {
|
|
log("[record] Microphone off.");
|
|
audio_close(mic);
|
|
mic.lost_at = 0;
|
|
}
|
|
wanted[1] = mic.open || (wanted[1] && mic_wanted);
|
|
|
|
for * source, index: r.audio_sources {
|
|
if !wanted[index] continue;
|
|
if !source.open { // lost: opened again a second on, from the default device then
|
|
if source.lost_at && ticks_now() - source.lost_at >= r.frequency && !stopping {
|
|
if audio_open(source, kinds[index]) log("[record] % is back: %.", ifx kinds[index] == .SYSTEM then "System sound" else "The microphone", source.name);
|
|
else { audio_close(source); source.lost_at = ticks_now(); }
|
|
}
|
|
continue;
|
|
}
|
|
packets, lost := audio_take(source, r.frequency);
|
|
for packets if !atomic_read(*r.paused) audio_mix_add(*mix, r, index, it);
|
|
if lost {
|
|
log_error("[record] % was lost (unplugged, or the default device changed); trying again.", source.name);
|
|
audio_close(source);
|
|
source.lost_at = ticks_now();
|
|
}
|
|
}
|
|
ok, error := audio_mix_encode(*mix, r, final = stopping);
|
|
if !ok { record_fail(r, error); break; }
|
|
if stopping break;
|
|
reset_temporary_storage();
|
|
sleep_milliseconds(10);
|
|
}
|
|
for * r.audio_sources audio_free(it);
|
|
return 0;
|
|
}
|
|
|
|
// The sound so far, from its base: one AAC frame's worth is encoded at a time.
|
|
Audio_Mix :: struct {
|
|
samples: [..] float; // interleaved stereo, from base on
|
|
base: s64; // the frame (in samples, on the timeline) samples[0] is
|
|
next: [2] s64; // each source's next frame, so packets that follow on are not moved by jitter
|
|
started: [2] bool;
|
|
frame_size: s32;
|
|
}
|
|
|
|
AUDIO_SLACK :: AUDIO_RATE / 5; // 200 ms: how late a source's packets may come and still be in the mix
|
|
AUDIO_FOLLOW_ON :: AUDIO_RATE / 33; // 30 ms: a packet that close to where its source left off follows on
|
|
|
|
// A packet into the mix at its place on the timeline, added to what the other source put there.
|
|
audio_mix_add :: (mix: *Audio_Mix, r: *Recorder, source: s64, packet: Audio_Packet) {
|
|
at := record_timeline(r, packet.ticks) * AUDIO_RATE / r.frequency;
|
|
if mix.started[source] && abs(at - mix.next[source]) <= AUDIO_FOLLOW_ON at = mix.next[source];
|
|
mix.started[source] = true;
|
|
mix.next[source] = at + packet.frames;
|
|
end := (at + packet.frames - mix.base) * AUDIO_CHANNELS;
|
|
if end > mix.samples.count {
|
|
old := mix.samples.count;
|
|
array_resize(*mix.samples, end, initialize = false);
|
|
for old..end - 1 mix.samples[it] = 0;
|
|
}
|
|
if packet.silent || !packet.samples.count return;
|
|
for frame: max(at, mix.base)..at + packet.frames - 1 {
|
|
into := (frame - mix.base) * AUDIO_CHANNELS;
|
|
from := (frame - at) * AUDIO_CHANNELS;
|
|
mix.samples[into] += packet.samples[from];
|
|
mix.samples[into + 1] += packet.samples[from + 1];
|
|
}
|
|
}
|
|
|
|
// Encodes the frames every open source has covered: up to where each has sent, or, for one that has
|
|
// sent nothing lately (silence), to AUDIO_SLACK ago. final: everything sent.
|
|
audio_mix_encode :: (mix: *Audio_Mix, r: *Recorder, final: bool) -> ok: bool, error: string {
|
|
// Paused, the timeline stands still where the pause began.
|
|
now := ifx atomic_read(*r.paused) then atomic_read(*r.pause_began) else ticks_now();
|
|
now_frame := record_timeline(r, now) * AUDIO_RATE / r.frequency;
|
|
ready := now_frame - AUDIO_SLACK;
|
|
for source, index: r.audio_sources {
|
|
if !source.open continue;
|
|
covered := max(mix.next[index], now_frame - AUDIO_SLACK);
|
|
ready = min(ready, covered);
|
|
}
|
|
if final { ready = mix.base; for mix.next ready = max(ready, it); }
|
|
|
|
size := mix.frame_size;
|
|
frame := NewArray(size * AUDIO_CHANNELS, float, initialized = false,, temp);
|
|
done: s64;
|
|
while mix.base + size <= ready {
|
|
for i: 0..frame.count - 1 {
|
|
at := done * AUDIO_CHANNELS + i;
|
|
value := ifx at < mix.samples.count then mix.samples[at] else 0.0;
|
|
frame[i] = clamp(value, -1.0, 1.0);
|
|
}
|
|
ok, error := encoder_write_audio(*r.encoder, frame.data, mix.base);
|
|
if !ok return false, error;
|
|
mix.base += size;
|
|
done += size;
|
|
lock(*r.mutex); r.stats.audio_frames += 1; unlock(*r.mutex);
|
|
}
|
|
// What was encoded leaves the front.
|
|
gone := min(done * AUDIO_CHANNELS, mix.samples.count);
|
|
if gone {
|
|
memcpy(mix.samples.data, mix.samples.data + gone, (mix.samples.count - gone) * size_of(float));
|
|
mix.samples.count -= gone;
|
|
}
|
|
return true, "";
|
|
}
|
|
|
|
// Ends the recording from one of its threads; the first reason is kept.
|
|
record_fail :: (r: *Recorder, error: string) {
|
|
log_error("[record] %", error);
|
|
lock(*r.mutex);
|
|
if !r.error r.error = copy_string(error);
|
|
unlock(*r.mutex);
|
|
atomic_swap(*r.stopping, true);
|
|
}
|
|
|
|
rect_inside :: (inner: RECT, outer: RECT) -> bool {
|
|
return inner.left >= outer.left && inner.top >= outer.top && inner.right <= outer.right && inner.bottom <= outer.bottom;
|
|
}
|
|
|
|
#scope_export
|
|
|
|
#load "capture_gpu.jai";
|
|
#load "dxgi_source.jai";
|
|
#load "wgc_source.jai";
|
|
#load "encoder.jai";
|
|
#load "remux.jai";
|
|
#load "audio_source.jai";
|
|
#load "cursor.jai";
|
|
|
|
#import "Basic";
|
|
#import "String";
|
|
#import "Atomics";
|
|
#import "Math";
|
|
#import "Thread";
|
|
#import "Windows";
|
|
#import "Windows_Utf8";
|
|
#import "dxgi";
|
|
#import "d3d11";
|
|
#import "ffmpeg";
|