Commit Graph
12 Commits
Author SHA1 Message Date
gramps 751d9b199c fix(audio): the track starts with the session, not with the first write
Third measurement, and it names the start-up defect the creator heard as "started off
wrong, then corrected" (ty-20261112-1222-748-0000-2.mp4):

  wall clock (audio start -> audio stop)  12.614 s
  video: 754 frames                       12.588 s   correct
  audio: 585 packets                      12.477 s   137 ms short
  pipe connected                          160 ms after the session opened
  first 150 ms of the audio track         digital silence (the mic delay line's head)

The rate is fixed — 12.5 s of show, no accumulating drift — but starting the clock at
connect made the audio stream begin 160 ms after the video stream, so every word sat 160 ms
late and the track came out that much shorter. The silence at the head is the 175 ms mic
delay doing its job, not a fault.

Now the clock runs from session open. Time that passes before ffmpeg connects stays owed,
and it is paid the moment the pipe connects — out of the capture ring, so it is the audio
that was missed rather than padding. Debt larger than one 200 ms chunk continues to be paid
over the following ticks.

674 pass, 0 warnings.
2026-11-12 12:27:34 -08:00
gramps 102cb96b28 fix(audio): only count audio that actually reached the encoder
Second measurement of the same recording family (ty-20261112-1133-701-0000-2.mp4):

  wall clock (audio start -> audio stop)  6.477 s
  video: 386 frames                       6.455 s   correct
  audio: 294 packets                      6.252 s   226 ms SHORT

Same magnitude as the original bug, opposite sign — the clock fixed the tick-rate error and
exposed the next one underneath. Audio started 49 ms before the pump, ffmpeg connected ~80 ms
after, and every chunk written in that window was dropped (dropped writes 27). The loop still
counted those frames as produced, so the track lost exactly that much time.

Now: the clock does not start until the pipe is connected, and AudioClock.Advance counts a
frame only when its write reached the pipe. A dropped write stays owed and is paid off on the
next tick, so a start-up gap costs content (silence, which there was anyway) instead of
length.

Two measurements, two different signs, one lesson: the audio track's LENGTH has to be the
clock's, and anything that removes samples without removing time — a fast tick, a dropped
write — shows up as drift no static offset can fix.

674 tests (one notification-timing flake under full-suite load; passes alone), 0 warnings.
2026-11-12 11:39:20 -08:00
gramps 47f409f0e4 fix(audio): drive the mix from the clock — the audio track was 2.1% longer than the show
Measured on the creator's recording (ty-20261112-1118-423-0000-2.mp4), which drifts
out of sync over ten seconds:

  wall clock (pump start -> audio stop)   9.875 s
  video: 594 frames, uniform 1/60         9.888 s   correct
  audio: 485,376 samples @ 48 kHz        10.112 s   +2.1%, 224 ms long

So it was not VFR and not wall-clock PTS: the video is CFR and matches the clock (the
only PTS anomaly in the file is one 38 ms gap from two lost frames). The audio track is
what was too long, and a static offset cannot fix a rate error — which is why the opening
looked aligned (the 175 ms mic delay cancels the camera's constant pipeline latency) and
the end did not.

Cause: LiveLoopAsync wrote a fixed 480 frames per iteration and trusted that each
iteration was 10 ms. Under a raised timer resolution they land near 9.8 ms — 102 ticks
per second where 100 are expected, i.e. 102% of an audio second per wall second. FFmpeg
stamps audio by sample count, so every second of show pushed the sound ~2% later against
a correct picture. Video never had the bug because the pump paces off a stopwatch.

Fix: Services/Audio/AudioClock asks the clock how many frames are owed
(expected - produced, clamped to one 200 ms chunk) and the loop produces exactly that,
padding on underrun. A stalled loop pays its debt over the following ticks instead of
dumping one huge write. FillAndMix takes a frame count, and the master limiter/meter take
spans, so a partial chunk is processed without touching stale buffer tail.

674 tests (one log-rotation flake under full-suite load while the app itself is running
and writing startup.log; passes alone), 0 warnings. Ear check pending.
2026-11-12 11:28:28 -08:00
gramps 7f89534309 feat(audio): microphone-only sync delay, on by default at 175 ms
The webcam's frames reach the compositor later than the mic's samples reach the audio
pipe, so the voice runs ahead of the lips. The existing lip-sync offset could not fix
that: it delays the WHOLE mix, which drags desktop and alert audio — neither of which
has a camera to follow — out of sync in the process.

So the delay line learned channels (mono for a source, stereo for the mix, offset always
in milliseconds so the frame count is what stays constant), and FillAndMix runs a second
line over micChunk before the mix. Slider-side: presets off / 150 / 200 in the LIP-SYNC
bay under the whole-mix control, persisted as Audio.MicSyncOffsetMs.

Default 175 ms is the creator's own number for a C920 pipeline (they asked for
+150..+200). It is a setting, not a constant, because the right figure belongs to the
camera and the machine — a faster camera wants less, no camera wants none.

Both delay lines are reconfigured every tick, so the 2026-09-12 trap still applies and is
documented at both sites: a Configure that reallocates discards the buffered voice every
10 ms.

673 pass, 0 warnings. Ear check pending: this is an audio change.
2026-11-12 11:15:50 -08:00
gramps b0f8263414 feat(quality): learn the starting rate from real sessions, on the creator's permission
Each finished broadcast is filed locally — tier, probed encoder, rated, settled, steps
spent — and Models/RateHistory turns that into at most a suggestion. The app never
applies one: it asks before the next stream, "Use it" sets the rate, "Keep mine"
remembers that exact number so the same question does not return until the evidence
moves. Nothing leaves the machine.

Two rules keep it from nagging. The high-water mark is the highest rate that
*finished* a session with zero steps, not the highest seen, so one easy session (a
menu screen, a quiet game) cannot set a target the machine cannot hold. And it is
asymmetric: raising needs 10% above the current rate across five clean sessions of the
same tier and encoder, lowering needs three step-downs in five — fixing damage should
not wait for a percentage, and risking it should.

The encoder key is real, not decorative: StreamHealth.VideoEncoder is now filled from
the probe (FfmpegEncoder), so history recorded against NVENC is not applied to a
machine that later runs QSV. At startup, before anything has streamed, the last
session on that tier is the honest guess.

672 pass, 0 warnings.
2026-11-12 08:24:12 -08:00
gramps 700c7a8c23 fix(encoder): ask for CBR — the capped-VBR triad was never a constant rate
The stream was configured as "@8 Mbps" and averaged 5.1. The cause is the argument
set, not the machine and not YouTube: `-b:v K -maxrate K -bufsize 2K` is capped
VBR. Measured on the bundled BtbN build with a static 640x480 frame at -b:v 2000k:

  default rate control   9,858 bytes / 8 s  =     9 kbps
  -rc cbr            2,001,989 bytes / 8 s  = 1,951 kbps
  -rc vbr                9,858 bytes / 8 s  =     9 kbps

On high-motion testsrc2 the same target produced 2006 kbps (CBR) against 1741
(VBR): VBR undershoots by however easy the picture is, and a composited scene
with a still background is very easy.

Spelling is per encoder, verified rather than assumed:
- h264_nvenc, h264_amf: `-rc cbr`.
- h264_qsv: nothing. It has no `-rc`, and libavcodec/qsvenc.c (select_rc_mode,
  FFmpeg d263fce2b2) selects MFX_RATECONTROL_CBR when bitrate == maxrate — which
  the triad already sets. Passing -rc here would be an unknown option.
- libopenh264: nothing. `-rc_mode bitrate` changed no measurable output on this
  build (3,832 vs 3,830 bytes), so it stays out rather than adding a line whose
  effect nobody can demonstrate.

Reference: YouTube's encoder spec requires CBR
(support.google.com/youtube/answer/2853702).

668 tests pass, build 0 warnings.
2026-11-12 07:57:36 -08:00
gramps 24077e58cf fix(stepdown): restart the audio pipe around encoder changes
A quality step-down now closes the named audio pipe before stopping/flushing the
pump, then opens a fresh server before restarting the pump. This prevents ffmpeg
from waiting on the old audio input and avoids reconnecting to a broken pipe.

EncoderRestartTests pins the audio-stop → video-stop → audio-start → video-start
order. 616 tests green, clean build with 0 warnings.
2026-11-11 09:52:27 -08:00
gramps e37215d9c4 feat(record): fragment the MP4 so a crash cannot destroy the whole take
TASK 32·4. A plain MP4 keeps its index (moov) at the end, so a power cut or a killed
process left the entire recording unreadable even though every video byte was on disk.
The record block now carries
`-movflags +frag_keyframe+empty_moov+default_base_moof`: index first, a playable
fragment flushed at every keyframe, so an interrupted take plays to the last fragment.

Record block only — the FLV stream output has no index and never gets the flags.
Rename-on-stop is untouched.

Test: `Args_RecordBlock_IsFragmentedMp4_SoACrashKeepsTheFile` asserts the flags on the
record block and their absence from the streaming block.

Not yet proven on a real interrupted take (kill ffmpeg mid-recording, play the result);
the args test is by construction.

Docs: task-32 slice 4 ticked with that caveat, TASKS row, output.md record note, HANDOFF.
verify.sh: 550 passed (mouse-injection test excluded, desktop in use), 0 warnings.
2026-11-05 08:58:48 -08:00
gramps 2670ac022b feat(media): play the clip's soundtrack and share one decode pipe
A video file was picture-only: the decode pipe passed `-an`, so a starting-soon
or BRB clip with music played silent everywhere. Adds sound to every clip and
collapses the two decoders into one.

- `Services/VideoClipPipe.cs`: the shared pipe behind `MediaVideoSource` (TASK 21)
  and `AlertClipDecoder` (TASK 47) — video (`rawvideo bgra`, scaled) + audio
  (`f32le -ac 2 -ar 48000`, 50 ms chunks, byte-carry across reads), both paced to
  real time, loop restarts both so picture and sound stay together. Alert
  behaviour is unchanged.
- `MediaVideoSourceManager` applies the layer's Level/Mute per session and raises
  `AudioAvailable`; `MainViewModel` feeds it to the new
  `AudioMixer.EnqueueDesktopMediaAudio`, which joins the DESKTOP/GAME ring so the
  desktop fader and the mic auto-ducker apply exactly as they do for game audio
  and TRAX (creator ruling 2026-11-05: audio joins the group).
- Per-layer `MediaMuted` + existing `MediaVolume` surfaced as Mute/Level in the
  properties panel; `MediaMuted` persisted (ALTER TABLE guard + SELECT/INSERT).
- Level/mute are per SESSION because one decoder is shared per file path — the
  last layer touched wins; one decoder per layer would play the file twice.

Test: `MediaSourceAudioTests` — the pipe emits 50 ms chunks at 48k stereo, the
manager scales by level and drops when muted, and a media layer's sound moves the
desktop meter. Alert/decode suites unchanged and green.

Docs: task-21 closed on item 10, the "silent soundtrack" facts corrected in
frame-pipeline/output/overlays, HANDOFF rewritten. verify.sh green (529 passed;
the two documented pre-existing failures only).
2026-11-05 07:54:06 -08:00
gramps 6310f50c27 perf(video): move tier scaling to ffmpeg
The frame pump now submits crop-sized BGRA frames; FFmpeg scales them to final tier dimensions. Bundled scaler benchmark: 300 608x1080 to 1080x1920 frames in 0.435s (scaler-only).

Reference: https://ffmpeg.org/ffmpeg-filters.html#scale-1
2026-11-04 10:52:35 -08:00
gramps a311c8feb1 docs(ai): partition ai.md into five subsystem files (verbatim moves)
ai.md 1,308 → 228 lines. No prose rewritten: every block was moved as-is so
nothing could be lost in translation, then replaced by a pointer.

New files:
- docs/ai/environment.md — LAN/SSH + Gitea push recipe, Windows dotnet
  build/run, test-suite map, hermetic real-MainWindow test rule
- docs/ai/product.md — brand/naming, AI transparency, GPL licensing
  prohibitions, free-forever monetization, auth/account assumptions,
  packaging & distribution
- docs/ai/youtube-api.md — go-live ordering, the broadcast-binding fix, the
  private-only lock, health parsing, end-stream pre-flight, Test mode,
  chat insert shape
- docs/ai/overlays.md — social bar, native alert box (animations, clip,
  ticker), chat box conventions
- docs/ai/ui-surface.md — control surface, scene strip, preview rail,
  hotkeys, broadcast drawer, toasts, transitions, brand logo, launch
  geometry, theme/properties conventions

ai.md keeps the response/derivation rules, the architecture table, the four
existing invariant stubs, and one pointer per moved subsystem.
docs/ai/index.md routes every file with a "read when" column.

References falsified by the move were fixed in the same commit: AGENTS.md
build note → environment.md → Run, and the "Related" footers in
frame-pipeline.md / output.md / capture.md now point at the owning files.

Docs-only unit: scope-check run, no build (no source touched).
2026-10-29 11:05:53 -07:00
gramps a4dfca6b6c docs(memory): split the output path (ffmpeg, encoder, recording, audio) out of ai.md
Unit 3. Verbatim move of 'FFmpeg locator', 'Live encoder + RTMP push',
'Local recording' and 'Live audio capture' into docs/ai/output.md, grouped as
encoder / recording / audio with a 'Related' footer.

ai.md's stub keeps the decisions that must not be re-litigated or rediscovered:
ffmpeg is bundled (never download/fetch; no-network test), args pure and no -re,
probe order NVENC->QSV->AMF->OpenH264 and never libx264, REC/ON-AIR radio-
exclusive by creator ruling, a modal's falsy result must never fall through into
its side effect, full StopStream teardown on every failure, audio pipe closes
BEFORE the video pump, and honest-by-measurement audio (true dBFS meters,
SpeechLeveler + -1 dBFS limiter, live Func gains).

- ai.md Services row and one 'FFmpeg locator section above' pointer repointed;
  MyMistakes.md dead-Pin entry repointed.
- docs/ai/index.md gains the output.md row.

ai.md 1,558 -> 1,289 lines. No source touched; no push.
2026-10-26 10:28:01 -07:00