Keep the voice-over start point when recording past the end of the timeline (#960)
This commit is contained in:
parent
4e2d0bc2d3
commit
da4a086676
|
|
@ -891,7 +891,12 @@ impl FilmcraftApp {
|
|||
// restart from the end → from the start (Settings ▸ Timeline ▸ "At playback end, return to
|
||||
// beginning when restarting playback")
|
||||
let dur = self.session.active_sequence().map(|q| q.duration()).unwrap_or_default();
|
||||
if speed > 0.0 && self.session.prefs.timeline.return_to_beginning && self.session.playhead() >= dur - self.session.sequence_rate().frame_duration() {
|
||||
// (not while a voice-over take starts: it was put exactly where the take must begin, e.g. on empty timeline past the end)
|
||||
if speed > 0.0
|
||||
&& self.session.prefs.timeline.return_to_beginning
|
||||
&& !self.session.voiceover.recording()
|
||||
&& self.session.playhead() >= dur - self.session.sequence_rate().frame_duration()
|
||||
{
|
||||
self.session.set_playhead(Tick::ZERO);
|
||||
}
|
||||
// A loop restart keeps counting into the same meter.
|
||||
|
|
@ -1081,7 +1086,8 @@ impl FilmcraftApp {
|
|||
if let Some(end) = self.playback.stop_at.filter(|e| t >= *e && self.playback.speed > 0.0 && !self.playback.looping) {
|
||||
self.session.set_playhead(end);
|
||||
self.stop();
|
||||
} else if t >= hi && self.playback.speed > 0.0 {
|
||||
} else if t >= hi && self.playback.speed > 0.0 && (self.playback.looping || !self.session.voiceover.recording()) {
|
||||
// a voice-over take keeps playing (and recording) into the empty timeline past the end
|
||||
if self.playback.looping {
|
||||
self.session.set_playhead(lo);
|
||||
self.play(self.playback.speed);
|
||||
|
|
@ -2226,6 +2232,38 @@ mod audio_recovery_tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod voiceover_playback_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_voice_over_take_records_into_the_empty_timeline_past_the_end() {
|
||||
let mut session = Session::default();
|
||||
session.execute("file.openDemoProject", json!({})).unwrap();
|
||||
session.execute("audio.voiceover.settings", json!({"prerollSeconds": 0.0})).unwrap();
|
||||
session.prefs.timeline.return_to_beginning = true;
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = FilmcraftApp::new(session);
|
||||
app.audio = None;
|
||||
let dur = app.session.active_sequence().map(|q| q.duration()).unwrap();
|
||||
let from = app.session.sequence_rate().snap(dur + Tick::from_seconds_f64(60.0));
|
||||
app.session.set_playhead(from);
|
||||
panels::voiceover::toggle(&mut app, None).unwrap();
|
||||
// play() keeps the record point instead of returning to the beginning
|
||||
assert!(app.playback.playing);
|
||||
assert_eq!(app.session.playhead(), from);
|
||||
app.end_preroll(0.0);
|
||||
// and one second later the take is still playing and recording, not stopped at the end
|
||||
let mut output = ctx.run_ui(egui::RawInput { time: Some(1.0), ..Default::default() }, |ui| app.advance_playback(ui.ctx()));
|
||||
output.textures_delta.clear();
|
||||
assert!(app.playback.playing);
|
||||
assert!(app.session.voiceover.recording());
|
||||
assert!(app.session.playhead() > from, "{:?} <= {from:?}", app.session.playhead());
|
||||
app.session.execute("audio.voiceover.stop", json!({"discard": true})).unwrap();
|
||||
app.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/// A physical-pixel screen position as a point in the window's UI space, given the window's content origin in points.
|
||||
///
|
||||
/// Assumes the whole virtual screen uses the window's `pixels_per_point`: with monitors of mixed
|
||||
|
|
|
|||
|
|
@ -174,3 +174,24 @@ fn microphone_button_records_onto_its_track() {
|
|||
assert_eq!(d.ok("ui.inspect", json!({}))["playback"]["playing"], json!(false));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recording_past_the_end_of_the_timeline_starts_there_not_at_the_beginning() {
|
||||
let mut d = Driver::demo();
|
||||
let dir = std::env::temp_dir().join(format!("filmcraft-vo-ui-end-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
d.exec("file.projectSettings.scratchDisks", json!({"captured": dir.to_string_lossy()}));
|
||||
d.exec("audio.voiceover.settings", json!({"prerollSeconds": 0.5}));
|
||||
d.harness.state_mut().session.prefs.timeline.return_to_beginning = true;
|
||||
// far beyond the last clip: empty timeline
|
||||
d.exec("playhead.set", json!({"seconds": 10_000.0}));
|
||||
d.click("timeline.track.A2.voiceover");
|
||||
let rec = d.exec("audio.voiceover.settings", json!({}))["recording"].clone();
|
||||
assert!(rec.is_object(), "recording started: {rec}");
|
||||
let ps = d.ok("ui.inspect", json!({}));
|
||||
assert_eq!(ps["playback"]["playing"], json!(true));
|
||||
let playhead = ps["playhead"].as_i64().unwrap();
|
||||
assert!(playhead > 9_000 * 254_016_000_000i64, "playhead jumped back to {playhead}");
|
||||
d.click("timeline.track.A2.voiceover");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -457,7 +457,7 @@ stereo / mono track ──5.1 panner───┼─► 5.1 submix / 5.1 Mix ─
|
|||
|
||||
### 5.1.2 Voice-over recording
|
||||
|
||||
**Voice-over recording** (`engine::voiceover`, `audio.voiceover.*`). The record point R is the playhead, or the In point when In/Out are set (punch-in; punch-out at Out). Playback starts the pre-roll before it (C = max(0, R − pre-roll)) and the input is captured from C. When the UI's audio clock really starts, `audio.voiceover.sync` restarts the capture there. `audio.voiceover.stop` keeps the audio from R to min(stop, Out), writes a mono 32-bit float WAV `<Name> <n>.wav` (Scratch Disks ▸ Captured, else next to the project, else the data or temporary directory), imports it and overwrites it onto the record track at R as one undo step. The record track is the given one, else the record-armed one, else the first targeted one. Input goes through the `AudioInput` trait: cpal in the desktop app (`apps/filmcraft/src/audio_in.rs`, on its own thread), and `SyntheticInput` headless, which produces exactly the samples the timeline asks for so recordings land sample-accurately. Voice-Over Record Settings (Source, Input channel, Name, Countdown Sound Cues, Pre-/Post-roll) are preferences (`voiceOver`). The track header's microphone records or stops, and a right-click opens the settings dialog. Countdown beeps (1 kHz, 100 ms, each whole second of pre-roll and at R) are mixed into playback, an overlay counts down, and playback stops at Out + post-roll.
|
||||
**Voice-over recording** (`engine::voiceover`, `audio.voiceover.*`). The record point R is the playhead, or the In point when In/Out are set (punch-in; punch-out at Out). Playback starts the pre-roll before it (C = max(0, R − pre-roll)) and the input is captured from C. When the UI's audio clock really starts, `audio.voiceover.sync` restarts the capture there. `audio.voiceover.stop` keeps the audio from R to min(stop, Out), writes a mono 32-bit float WAV `<Name> <n>.wav` (Scratch Disks ▸ Captured, else next to the project, else the data or temporary directory), imports it and overwrites it onto the record track at R as one undo step. The record track is the given one, else the record-armed one, else the first targeted one. Input goes through the `AudioInput` trait: cpal in the desktop app (`apps/filmcraft/src/audio_in.rs`, on its own thread), and `SyntheticInput` headless, which produces exactly the samples the timeline asks for so recordings land sample-accurately. Voice-Over Record Settings (Source, Input channel, Name, Countdown Sound Cues, Pre-/Post-roll) are preferences (`voiceOver`). The track header's microphone records or stops, and a right-click opens the settings dialog. Countdown beeps (1 kHz, 100 ms, each whole second of pre-roll and at R) are mixed into playback, an overlay counts down, and playback stops at Out + post-roll. A take records anywhere, also on the empty timeline past the end of the sequence: starting it never returns the playhead to the beginning, and playback keeps going past the end until the take is stopped.
|
||||
|
||||
### 5.1.2a Text to Speech (narrations)
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ FILMCRAFT_REQUIRE_ORACLES=1 cargo test --workspace # CI: missing ffmpeg fails
|
|||
| Mixer | `crates/render/src/mixer_tests.rs`, `crates/engine/src/mixer_tests.rs` | sample-exact fader gain and pan laws, automation at block boundaries (bit-identical however requests are cut), solo/mute/solo-safe, sends and submix routing, latency-compensation alignment, render-vs-playback identity, Touch ramp-back, recorder modes (Latch/Touch/Write), thinning, Audio Gain, transition curves; `perf_24_tracks_3_effects_realtime_factor` prints the realtime factor |
|
||||
| 5.1 / multichannel | `crates/audio-dsp/src/channels.rs`, `crates/render/src/mixer_tests.rs`, `crates/export/src/surround_tests.rs`, `crates/engine/src/mixer_tests.rs`, `crates/ui-egui/tests/audio51_ui.rs` | BS.775 downmix / upmix coefficients exact, mixdown types; 5.1 panner point gains equal-power (Σg² = 1 over a 21×21 grid × 3 centre settings), speaker positions exact, 5.1 through the default puck bit-exact identity; graph: stereo/mono tracks into a 5.1 Mix (L/R, C, rear right, room centre −6 dB each, LFE = mean), 5.1 track through a 5.1 Mix and folded into stereo, a 5.1 submix, `pan51` automation block-invariant, six-channel meters; export: 6-ch WAV (`WAVE_FORMAT_EXTENSIBLE`, mask 0x3F) and ProRes MOV (6 × 24-bit PCM) decoded by ffmpeg **sample-exact**, ffprobe `channels` 6 / `channel_layout` 5.1; AAC 5.1 in MP4: every decoded channel's own tone ≥ 30 dB above the others' (no swapped channels), stereo export of a 5.1 Mix = BS.775 within 2·10⁻⁶; `sequence.settings {mix}`, `file.newSequence {mix, trackType}`, panner lanes, Write pass records the puck; UI: puck drag (one undo step), Center / LFE, six meter bars |
|
||||
| Audio Clip Mixer automation, Track Mixer view | `crates/engine/src/mixer_tests.rs`, `crates/ui-egui/tests/audio51_ui.rs` | Touch: a 201-point fader ramp thins to ≤ 6 clip keyframes, values within 0.1 dB, live override while held, values outside the gesture unchanged, one undo step; Latch holds until stop; Write records volume and pan from the start; a gesture across two clips writes both; Read commits once without a pass; UI: Clip Mixer mode dropdown, a recorded fader drag writes clip keyframes; Show/Hide Tracks, Meter Input(s) Only, transport record button |
|
||||
| Voice-over | `crates/engine/src/voiceover_tests.rs`, `crates/ui-egui/tests/voiceover_ui.rs` | synthetic click train lands in `mix_sequence` exactly at R + k·period (pre-roll dropped, exact sample count), punch-in between In/Out (ramp source, every sample checked), one undo step + undo/redo, take numbering, record-arm/targeting track choice, sync/discard/zero-length/locked cases, cue times; UI: right-click Mic → Voice-Over Record Settings dialog (edits persist on OK, Cancel discards), the Mic button starts and stops a take on its track with pre-roll and overlay |
|
||||
| Voice-over | `crates/engine/src/voiceover_tests.rs`, `crates/ui-egui/tests/voiceover_ui.rs` | synthetic click train lands in `mix_sequence` exactly at R + k·period (pre-roll dropped, exact sample count), punch-in between In/Out (ramp source, every sample checked), one undo step + undo/redo, take numbering, record-arm/targeting track choice, sync/discard/zero-length/locked cases, cue times; UI: right-click Mic → Voice-Over Record Settings dialog (edits persist on OK, Cancel discards), the Mic button starts and stops a take on its track with pre-roll and overlay, and records past the end of the timeline (also `voiceover_playback_tests` in `crates/ui-egui/src/lib.rs`) |
|
||||
| Text to Speech | `crates/tts/src/**/*.rs` (Kokoro tests need `FILMCRAFT_KOKORO_DIR`), `crates/tts-text`, `crates/engine/src/narration_tests.rs`, `crates/ui-egui/tests/tts_ui.rs` | built-in voices: determinism, finite samples, exact-silence pause markers, pace scales length, measured pitch per voice and per semitone, hostile scripts; engine: narration placed at the playhead with clip length = speech (sample-exact), never overwrites (busy tracks → next free track → new track), occupied/locked/unknown track refused with no change, edit keeps start/length/speed (longer speech: whole speech in the file; shorter: file padded with exact zeros), edits write beside the previous file, undo/redo restore the project exactly, enabled only with a narration clip (or `clip`), 16 hostile parameter sets refused without change, preview changes nothing, save/reload keeps narrations, the narration is audible in `mix_sequence`; UI: type + Add pause + Add to timeline, the new clip is selected and loaded, clicking the clip on the timeline loads its script, Save edits it in place as one undo step, Advanced twirl, panel opens beside Properties; natural voices: 50-sentence Whisper-base WER 2.1–3.6 % for all 9 voices (crate README), loaders fuzzed with hostile files, `tts.render` cache, download refused without the feature |
|
||||
| Remix | `crates/audio-dsp/src/remix.rs`, `crates/render/src/remix.rs`, `crates/engine/src/remix_tests.rs`, `crates/ui-egui/tests/remix_ui.rs` | generated rhythmic music (Rust-synthesised drums, bass, chord sections at 90–128 BPM, 22.05/44.1/48 kHz): tempo within 0.3 BPM, every beat within 10 ms (measured ≤ 8.9 ms); remix targets from 0.4× to 2.2× the source within one beat (measured ≤ 12 ms at default sliders), every cut on a detected beat, intro and outro kept, pieces ≥ 4 beats, deterministic; silence, tone and noise refused; rendering independent of request cuts, source-exact outside the 20 ms equal-power crossfades, no clicks; the engine's mix equals the source pieces (< 1e-5); undo, redo and revert exact; overlaps refused; Remix Properties dialog and Remix tool drag driven by automation id |
|
||||
| Oracle | `crates/*/tests/*oracle*.rs`, `conformance.rs` | compare with ffmpeg/ffprobe (§2) |
|
||||
|
|
|
|||
Loading…
Reference in New Issue