[0.1.9] Recording toolbar: a Mic button, to turn the microphone on and off mid-recording

- 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.
This commit is contained in:
Musa Mahmood 2026-10-10 08:16:12 +01:00
parent 72f2754226
commit 3f5d1ea124
9 changed files with 136 additions and 29 deletions

View File

@ -188,11 +188,13 @@ register the key, the Event Log says so; choose another, such as `Ctrl-Shift-S
- Escape or a right click cancels.
3. **Recording starts** at once, at up to 30 frames a second, cursor included. A frame is written
only when the screen changes, so a still screen costs almost nothing. **Sound:** what the
computer plays is recorded with it, and the microphone too if `microphone` is set in `[video]`
(off by default).
computer plays is recorded with it. The microphone is off until you turn it on with the
toolbar's **Mic** button, which works mid-recording. The button's state is kept for the next
recording.
- **A red border** is drawn just outside the region.
- **A toolbar** beside it shows the time, the size and any frames dropped, with **Pause** (the time
paused is left out of the video), **Stop**, and **Discard**, which asks first.
- **A toolbar** beside it shows the time, the size and any frames dropped, with **Mic** (on or
off), **Pause** (the time paused is left out of the video), **Stop**, and **Discard**, which asks
first.
- **Neither is in the recording.** Neither takes the focus from the window being recorded.
4. **Ctrl+PrintScreen again**, or Stop, stops it. The video goes into **Screen Captures** beside the stills,
as an H.264 MP4 in an `.mcap` with the window it was recorded from, and opens in the Capture window.
@ -212,7 +214,7 @@ register the key, the Event Log says so; choose another, such as `Ctrl-Shift-S
- `fps` (30) and `cursor` (true).
- `capture`: `auto` (Desktop Duplication, else Windows.Graphics.Capture), `dxgi` or `wgc`.
- `system_audio` (true): what the computer plays, from the default output device.
- `microphone` (false): the default input device.
- `microphone` (false): the default input device. The toolbar's Mic button sets it.
- Sound is AAC at 160 kb/s. A device unplugged mid-recording is picked up again from the new default.
- **PrintScreen while recording** takes the whole screen under the cursor at once, without freezing
the screens, which would show in the recording.

View File

@ -29,7 +29,7 @@ APP_NAME :: "mcap";
APP_FOLDER_NAME :: "mcap";
GIT_BRANCH :: "check";
GIT_HASH :: "-";
VERSION :: "0.1.8";
VERSION :: "0.1.9";
BUILD_DATE :: "-";
DEBUG :: true;
COMPILE_TESTS :: true;

View File

@ -8,7 +8,7 @@ APP_NAME :: "mcap";
APP_FOLDER_NAME :: "mcap";
APP_BUNDLE_IDENTIFIER :: "com.example.mcap";
VERSION :: "0.1.8";
VERSION :: "0.1.9";
// Where everything goes: bin/, or bin-headless/ for builds made with no_crash_dialog, so automated
// builds never write over the ones you run by hand.
build_directory := "bin";

View File

@ -16,7 +16,9 @@
// 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.
// 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});
@ -72,7 +74,8 @@ Record_Options :: struct {
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
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 {
@ -114,7 +117,9 @@ Recorder :: struct {
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: the MP4 has sound
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;
@ -209,7 +214,8 @@ record_start :: (r: *Recorder, options: Record_Options) -> ok: bool, error: stri
// 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.
if r.options.system_audio || r.options.microphone {
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");
@ -270,6 +276,27 @@ record_pause :: (r: *Recorder, pause: bool) {
}
}
// 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);
@ -571,18 +598,27 @@ record_audio_proc :: (thread: *Thread) -> s64 {
kinds: [2] Audio_Source_Kind = .[.SYSTEM, .MICROPHONE];
wanted: [2] bool = .[r.options.system_audio, r.options.microphone];
names, errors: [..] string;
names.allocator, errors.allocator = temp, temp;
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; array_add(*names, s.name); }
else { audio_close(s); array_add(*errors, tprint("%: %", what, error)); log_error("[record] No %: %", what, error); }
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); }
}
r.stats.audio = join(..names, "; ");
r.stats.audio_errors = join(..errors, "; ");
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) {
@ -595,6 +631,29 @@ record_audio_proc :: (thread: *Thread) -> s64 {
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

View File

@ -11,6 +11,8 @@
// time, Pause, Stop and Discard (app/screen_record_win32.jai); both are kept out of the recording.
// Time paused is left out of the video. With sound: what the computer plays and the microphone, as
// [video] in the settings file says, as are the frame rate, the cursor and how the screen is taken.
// The toolbar's Mic button turns the microphone on and off while recording, and is kept in [video]
// for the next: every recording has a sound track for it, silent while nothing is on.
//
// A recording that stops by itself (the screen went away, the encoder failed) keeps what it has, and
// so does quitting while recording. One left in the temporary folder by a crash is offered at the next
@ -50,7 +52,21 @@ screen_record_frame :: () {
screen_record_stop();
return;
}
failed_mic, mic_error := record_microphone_failed(*screen_record.recorder);
if failed_mic {
toast_show(.Error, "The microphone could not be turned on: %", mic_error);
screen_record_remember_microphone(false);
record_ui_refresh();
}
if record_ui_take_ask() == {
case .MICROPHONE;
on := !record_microphone(*screen_record.recorder);
if record_set_microphone(*screen_record.recorder, on) {
log("[record] Microphone turned %.", ifx on then "on" else "off");
toast_show(.Success, "Microphone %", ifx on then "on" else "off");
screen_record_remember_microphone(on);
}
record_ui_refresh();
case .PAUSE;
pause := !record_paused(*screen_record.recorder);
record_pause(*screen_record.recorder, pause);
@ -206,6 +222,7 @@ screen_record_start :: (immediate := false) {
options.cursor = app_user_settings.video_cursor;
options.system_audio = app_user_settings.video_system_audio;
options.microphone = app_user_settings.video_microphone;
options.audio_track = true; // so the Mic button can turn the microphone on mid-recording
if app_user_settings.video_capture == {
case "dxgi"; options.capture = .DXGI;
case "wgc"; options.capture = .WGC;
@ -236,6 +253,13 @@ screen_record_start :: (immediate := false) {
record_ui_open(options.region, shot.whole_screen, stats.width, stats.height);
}
// The Mic button's state, into [video] for the next recording.
screen_record_remember_microphone :: (on: bool) {
if app_user_settings.video_microphone == on return;
app_user_settings.video_microphone = on;
app_save_user_settings_file();
}
// Stops, and deletes what was recorded (the toolbar's Discard, which asked first).
screen_record_discard :: () {
if !screen_record.recording return;

View File

@ -1,5 +1,5 @@
// While recording (app/screen_record.jai), on Windows: a thin red border just outside the region, and a
// toolbar beside it with the time, the size, frames dropped, and Pause, Stop and Discard. Both are
// toolbar beside it with the time, the size, frames dropped, and Mic, Pause, Stop and Discard. Both are
// plain GDI windows, as the overlay is, so they work while mcap is hidden in the tray, and both are
// kept out of every capture (WDA_EXCLUDEFROMCAPTURE), the recording's own included. The border is
// outside the region anyway, so it could never be in the video.
@ -9,7 +9,7 @@
// message, as Stop and Discard close the toolbar. Discard asks first, in the toolbar itself: a
// message box would be in the recording.
Record_UI_Ask :: enum u8 { NONE; PAUSE; STOP; DISCARD; }
Record_UI_Ask :: enum u8 { NONE; MICROPHONE; PAUSE; STOP; DISCARD; }
record_ui: struct {
border, toolbar: HWND;
@ -19,7 +19,7 @@ record_ui: struct {
asked: Record_UI_Ask; // a button pressed, for screen_record_frame
confirming: bool; // Discard pressed once: "Discard it? Yes No"
hovered: s32 = -1; // the button under the mouse
buttons: [3] Record_UI_Button;
buttons: [4] Record_UI_Button;
button_count: s32;
started: Apollo_Time;
size_text: string; // "1920 × 1080"; heap
@ -170,10 +170,11 @@ record_toolbar_buttons :: () -> [] Record_UI_Button {
record_ui.buttons[1] = .{label = "No", confirm = true};
record_ui.button_count = 2;
} else {
record_ui.buttons[0] = .{label = ifx record_paused(*screen_record.recorder) then "Resume" else "Pause", ask = .PAUSE};
record_ui.buttons[1] = .{label = "Stop", ask = .STOP};
record_ui.buttons[2] = .{label = "Discard", confirm = true};
record_ui.button_count = 3;
record_ui.buttons[0] = .{label = ifx record_microphone(*screen_record.recorder) then "Mic: on" else "Mic: off", ask = .MICROPHONE};
record_ui.buttons[1] = .{label = ifx record_paused(*screen_record.recorder) then "Resume" else "Pause", ask = .PAUSE};
record_ui.buttons[2] = .{label = "Stop", ask = .STOP};
record_ui.buttons[3] = .{label = "Discard", confirm = true};
record_ui.button_count = 4;
}
return array_view(record_ui.buttons, 0, record_ui.button_count);
}
@ -196,7 +197,7 @@ record_toolbar_size :: () -> width: s32, height: s32 {
padding, gap := record_ui_px(10), record_ui_px(8);
status_width := text_width(dc, tprint("PAUSED 00:00:00 %", record_ui.size_text));
dropped_width := text_width(dc, "999 dropped, screen lost 9 times");
buttons_width := text_width(dc, "Resume") + text_width(dc, "Stop") + text_width(dc, "Yes, discard") + 3 * 2 * padding + 2 * gap;
buttons_width := text_width(dc, "Mic: off") + text_width(dc, "Resume") + text_width(dc, "Stop") + text_width(dc, "Yes, discard") + 4 * 2 * padding + 3 * gap;
height := record_ui_px(34);
width := padding + record_ui_px(12) + gap + status_width + gap + dropped_width + gap + buttons_width + padding;
return width, height;
@ -313,7 +314,8 @@ record_toolbar_paint :: (hwnd: HWND) {
label_width := text_width(dc, it.label);
r := RECT.{right - label_width - 2 * padding, (height - button_height) / 2, right, (height - button_height) / 2 + button_height};
record_ui.buttons[it_index].rect = r;
fill(r, ifx it_index == record_ui.hovered then cast(u32) RECORD_UI_HOVER else cast(u32) RECORD_UI_BUTTON);
lit := it.ask == .MICROPHONE && record_microphone(*screen_record.recorder); // on: in the record red
fill(r, ifx lit then cast(u32) RECORD_UI_RED else ifx it_index == record_ui.hovered then cast(u32) RECORD_UI_HOVER else cast(u32) RECORD_UI_BUTTON);
SetTextColor(dc, ifx it.ask == .DISCARD then cast(u32) RECORD_UI_AMBER else cast(u32) RECORD_UI_TEXT);
wide := utf8_to_wide(it.label,, temp);
count: s32;

View File

@ -671,7 +671,7 @@ app_save_user_settings_file :: () -> bool {
if seen.video_fps app_user_settings.video_fps = on_disk.video_fps;
if seen.video_cursor app_user_settings.video_cursor = on_disk.video_cursor;
if seen.video_system_audio app_user_settings.video_system_audio = on_disk.video_system_audio;
if seen.video_microphone app_user_settings.video_microphone = on_disk.video_microphone;
// video_microphone is the app's: the recording toolbar's Mic button sets it.
if seen.video_capture {
free(app_user_settings.video_capture,, context.default_allocator);
app_user_settings.video_capture = copy_string(on_disk.video_capture,, context.default_allocator);

View File

@ -22,6 +22,7 @@
// frame: for measuring throughput.
// -remux IN OUT a recording's fragmented MP4 made a regular one, as the app does, and nothing else.
// -audio WHICH records sound too: system (what the computer plays), mic, or both.
// -mic-toggle with -audio system: the microphone turned on 3 s in, and off at 7 s, as the toolbar does.
// -sync-test every 2 s a white square flashes on the region and a 1 kHz beep plays; then the
// flashes and beeps are found in the file and their offsets given, and how they drift.
// -check FILE reads FILE back with FFmpeg and says what it holds, and nothing else: for a
@ -45,6 +46,7 @@ main :: () {
snapshot_folder: string;
pause_seconds: float64;
sync_test := false;
mic_toggle := false;
i := 1;
number :: (index: s64) -> s32 #expand { return cast(s32) string_to_int(`args[index]); }
while i < args.count {
@ -74,6 +76,7 @@ main :: () {
if a == "-poll" { options.testing |= .WGC_POLL; i += 1; continue; }
if a == "-lose" { options.testing |= .LOSE_DUPLICATION; i += 1; continue; }
if a == "-sync-test" { sync_test = true; i += 1; continue; }
if a == "-mic-toggle" { mic_toggle = true; options.audio_track = true; i += 1; continue; }
if a == "-audio" && more >= 1 {
which := args[i + 1];
options.system_audio = which == "system" || which == "both";
@ -194,6 +197,16 @@ main :: () {
print(" % s: % captured, % sent (% repeats), % written, % dropped, % lost, % KB\n",
FF(to_float64_seconds(last_print - start), 0), s.captured, s.emitted, s.repeats, s.written, s.dropped, s.lost, s.bytes / 1024);
}
if mic_toggle {
elapsed := to_float64_seconds(current_time_monotonic() - start);
should := elapsed >= 3 && elapsed < 7;
if should != record_microphone(*recorder) && (elapsed < 3.5 || (elapsed >= 7 && elapsed < 7.5)) {
record_set_microphone(*recorder, should);
print(" % s: microphone %\n", FF(elapsed, 1), ifx should then "on" else "off");
}
failed, why := record_microphone_failed(*recorder);
if failed print(" microphone failed: %\n", why);
}
if flash { // a flash and a beep together every 2 s, the flash for 200 ms
elapsed := to_float64_seconds(current_time_monotonic() - start);
if elapsed >= next_flash {
@ -240,7 +253,7 @@ main :: () {
print(" each: copy % (per image), read back % , encode % (per frame)\n",
ms_each(s.copy_ticks, s.captured), ms_each(s.readback_ticks, s.emitted), ms_each(s.encode_ticks, s.written));
print(" file: % KB, % MB a minute\n", file_size / 1024, FF(cast(float64) file_size / (1024 * 1024) / s.seconds * 60, 1));
if options.system_audio || options.microphone {
if options.system_audio || options.microphone || options.audio_track {
print(" sound: %; % AAC frames%\n", ifx s.audio then s.audio else "none opened", s.audio_frames, ifx s.audio_errors then tprint(" (%)", s.audio_errors) else "");
sound := sound_report(options.path);
if sound.found print(" in file: % s of sound, % dB RMS, % onsets\n", FF(sound.seconds, 2), FF(sound.rms_db, 1), sound.onsets.count);

View File

@ -499,7 +499,14 @@ Captured_Frame :: struct {
<li>A packet within 30 ms of where its source left off follows on exactly, so timing jitter doesn't leave clicks.</li>
<li>A source that sends nothing, as loopback does while nothing plays, is silence, and the mix moves on without it 200 ms late.</li>
</ul></li>
<li><strong>Not in the toolbar.</strong> The sources are chosen in <code>[video]</code>, not switched in the toolbar. An MP4 can't gain a sound stream once its header is written, so a source can't be added mid-recording.</li>
<li><strong>The microphone in the toolbar</strong> (added in mcap 0.1.9, on request).
<ul>
<li><strong>Mic: on / off</strong> turns the microphone on and off mid-recording (<code>record_set_microphone</code>), and its state is kept as <code>microphone</code> in <code>[video]</code> for the next recording.</li>
<li>The microphone is opened only while on, so Windows' microphone-in-use indicator shows only then.</li>
<li>An MP4 can't gain a sound stream once its header is written, so the app's recordings always have one (<code>audio_track</code>), silent while nothing is on.</li>
<li>Checked with <code>record-probe -mic-toggle</code>, which turns the microphone on at 3 s and off at 7 s. With system sound, and with nothing else on, the sound track runs the whole recording without a break.</li>
</ul>
What the computer plays is still chosen only in <code>[video]</code>.</li>
<li><strong>A lost device</strong> (a headset unplugged, the default changed) is opened again from the new default once a second.</li>
</ul></div>
</div>