diff --git a/apps/cli/src/selftest/playback.rs b/apps/cli/src/selftest/playback.rs index 57e997d9471..20a93cb4bd1 100644 --- a/apps/cli/src/selftest/playback.rs +++ b/apps/cli/src/selftest/playback.rs @@ -903,6 +903,7 @@ mod fixture { timescale: 1.0, name: None, speed_audio_mode: None, + audio_muted: false, hide_cursor: None, volume: None, }], diff --git a/apps/desktop-gpui/src/editor_clips.rs b/apps/desktop-gpui/src/editor_clips.rs index 72f90015b6e..264aedeedda 100644 --- a/apps/desktop-gpui/src/editor_clips.rs +++ b/apps/desktop-gpui/src/editor_clips.rs @@ -1967,6 +1967,7 @@ impl PreparedMp4Import { end: self.duration, name: None, speed_audio_mode: None, + audio_muted: false, hide_cursor: None, volume: None, }); @@ -2447,6 +2448,7 @@ fn full_timeline_for_segments( end: duration, name: None, speed_audio_mode: None, + audio_muted: false, hide_cursor: None, volume: None, }) @@ -3044,6 +3046,7 @@ fn full_timeline_for_source_segments( end: duration, name: None, speed_audio_mode: None, + audio_muted: false, hide_cursor: None, volume: None, }) @@ -3137,6 +3140,7 @@ fn source_timeline_segments_for_import( speed_audio_mode: segment.speed_audio_mode, hide_cursor: segment.hide_cursor, volume: segment.volume, + audio_muted: segment.audio_muted, }); } @@ -3367,6 +3371,7 @@ pub(crate) fn append_cap_project_to_editor( end: source_segment.end, name: None, speed_audio_mode: source_segment.speed_audio_mode, + audio_muted: source_segment.audio_muted, hide_cursor: source_segment.hide_cursor, volume: source_segment.volume, }); @@ -3808,6 +3813,7 @@ mod tests { end, name: None, speed_audio_mode: None, + audio_muted: false, hide_cursor: None, volume: None, } diff --git a/apps/desktop-gpui/src/editor_edits.rs b/apps/desktop-gpui/src/editor_edits.rs index 93e1889d773..65a897717f2 100644 --- a/apps/desktop-gpui/src/editor_edits.rs +++ b/apps/desktop-gpui/src/editor_edits.rs @@ -1809,6 +1809,7 @@ pub fn ensure_timeline(project: &mut ProjectConfiguration, clip_display_duration end: *duration, name: None, speed_audio_mode: None, + audio_muted: false, hide_cursor: None, volume: None, }) @@ -2417,11 +2418,12 @@ pub fn set_clip_segment_timescale( } pub fn clip_is_muted(segment: &TimelineSegment) -> bool { - if (segment.timescale - 1.0).abs() < f64::EPSILON { + let speed_mode_muted = if (segment.timescale - 1.0).abs() < f64::EPSILON { segment.speed_audio_mode == Some(ClipSpeedAudioMode::Mute) } else { segment.speed_audio_mode.unwrap_or(ClipSpeedAudioMode::Mute) == ClipSpeedAudioMode::Mute - } + }; + segment.audio_muted || speed_mode_muted } pub fn set_clip_volume(timeline: &mut TimelineConfiguration, index: usize, volume: f64) -> bool { @@ -2443,17 +2445,23 @@ pub fn set_clip_muted(timeline: &mut TimelineConfiguration, index: usize, muted: let Some(segment) = timeline.segments.get_mut(index) else { return false; }; - let next = if muted { - Some(ClipSpeedAudioMode::Mute) - } else if (segment.timescale - 1.0).abs() < f64::EPSILON { - None - } else { - Some(ClipSpeedAudioMode::MaintainPitch) - }; - if segment.speed_audio_mode == next { + let unmute_speed = !muted + && if (segment.timescale - 1.0).abs() < f64::EPSILON { + segment.speed_audio_mode == Some(ClipSpeedAudioMode::Mute) + } else { + segment.speed_audio_mode.unwrap_or_default() == ClipSpeedAudioMode::Mute + }; + if segment.audio_muted == muted && !unmute_speed { return false; } - segment.speed_audio_mode = next; + segment.audio_muted = muted; + if unmute_speed { + segment.speed_audio_mode = if (segment.timescale - 1.0).abs() < f64::EPSILON { + None + } else { + Some(ClipSpeedAudioMode::MaintainPitch) + }; + } true } @@ -2465,10 +2473,11 @@ pub fn set_clip_segment_speed_audio_mode( let Some(segment) = timeline.segments.get_mut(index) else { return false; }; - if segment.speed_audio_mode == Some(mode) { + if segment.speed_audio_mode == Some(mode) && !segment.audio_muted { return false; } segment.speed_audio_mode = Some(mode); + segment.audio_muted = false; true } @@ -2593,6 +2602,7 @@ pub fn merge_clip_segments( .or_else(|| custom_name(&right.name)); left.end = right.end; left.speed_audio_mode = settings.speed_audio_mode; + left.audio_muted = settings.audio_muted; left.volume = settings.volume; left.hide_cursor = settings.hide_cursor; @@ -3923,6 +3933,7 @@ mod tests { assert_eq!(timeline.segments.len(), 3); assert!(set_clip_hide_cursor(timeline, 1, true)); assert!(set_clip_volume(timeline, 1, 0.5)); + assert!(set_clip_muted(timeline, 1, true)); assert!(set_clip_name(timeline, 0, "Intro")); timeline.transitions = vec![cap_project::ClipTransition { segment_index: 2, @@ -3939,6 +3950,7 @@ mod tests { assert_eq!((merged.start, merged.end), (0.0, 10.0)); assert_eq!(merged.hide_cursor, Some(true)); assert_eq!(merged.volume, Some(0.5)); + assert!(merged.audio_muted); assert_eq!(merged.name.as_deref(), Some("Intro")); assert_eq!(timeline.transitions.len(), 1); assert_eq!(timeline.transitions[0].segment_index, 1); @@ -4017,21 +4029,47 @@ mod tests { } #[test] - fn muting_a_1x_clip_sets_speed_audio_mode() { + fn muting_a_1x_clip_sets_audio_muted() { let mut project = zoom_fixture(); let timeline = project.timeline.as_mut().unwrap(); assert!(!clip_is_muted(&timeline.segments[0])); assert!(set_clip_muted(timeline, 0, true)); assert!(clip_is_muted(&timeline.segments[0])); - assert_eq!( - timeline.segments[0].speed_audio_mode, - Some(ClipSpeedAudioMode::Mute) - ); + assert!(timeline.segments[0].audio_muted); + assert_eq!(timeline.segments[0].speed_audio_mode, None); assert!(set_clip_muted(timeline, 0, false)); assert!(!clip_is_muted(&timeline.segments[0])); + assert!(!timeline.segments[0].audio_muted); assert_eq!(timeline.segments[0].speed_audio_mode, None); } + #[test] + fn split_mute_and_unmute_preserve_independent_audio_settings() { + let mut project = zoom_fixture(); + let timeline = project.timeline.as_mut().unwrap(); + timeline.segments[0].speed_audio_mode = Some(ClipSpeedAudioMode::MatchSpeed); + assert!(set_clip_muted(timeline, 0, true)); + assert!(split_clip_segment(timeline, 2.0, Some(0))); + assert!(timeline.segments[0].audio_muted); + assert!(timeline.segments[1].audio_muted); + assert!(set_clip_muted(timeline, 1, false)); + assert!(timeline.segments[0].audio_muted); + assert!(!timeline.segments[1].audio_muted); + assert_eq!( + timeline.segments[1].speed_audio_mode, + Some(ClipSpeedAudioMode::MatchSpeed) + ); + timeline.segments[1].speed_audio_mode = Some(ClipSpeedAudioMode::Mute); + assert!(set_clip_muted(timeline, 1, false)); + assert!(!clip_is_muted(&timeline.segments[1])); + assert!(set_clip_segment_speed_audio_mode( + timeline, + 0, + ClipSpeedAudioMode::MatchSpeed + )); + assert!(!timeline.segments[0].audio_muted); + } + #[test] fn setting_clip_speed_audio_mode_writes_the_segment() { let mut project = zoom_fixture(); diff --git a/apps/desktop-gpui/src/editor_window.rs b/apps/desktop-gpui/src/editor_window.rs index 5cbec1e496e..905fe952e38 100644 --- a/apps/desktop-gpui/src/editor_window.rs +++ b/apps/desktop-gpui/src/editor_window.rs @@ -430,6 +430,7 @@ pub fn preflight(path: &std::path::Path) -> Result { end: segment.duration(), name: None, speed_audio_mode: None, + audio_muted: false, hide_cursor: None, volume: None, }) @@ -8263,8 +8264,11 @@ impl EditorWindow { } else { chip_row() .children(audio_modes.into_iter().map(|(mode, label)| { - let selected = audio_mode == mode - || (mode == ClipSpeedAudioMode::Mute && muted); + let selected = if muted { + mode == ClipSpeedAudioMode::Mute + } else { + audio_mode == mode + }; chip( SharedString::from(format!("clip-speed-audio-{label}")), selected, @@ -11971,6 +11975,7 @@ mod tests { timescale: 1.0, name: None, speed_audio_mode: None, + audio_muted: false, hide_cursor: None, volume: None, }], @@ -12026,6 +12031,7 @@ mod tests { timescale: 1.0, name: None, speed_audio_mode: None, + audio_muted: false, hide_cursor: None, volume: None, }, @@ -12036,6 +12042,7 @@ mod tests { timescale: 1.0, name: None, speed_audio_mode: None, + audio_muted: false, hide_cursor: None, volume: None, }, diff --git a/apps/desktop-gpui/src/transcription.rs b/apps/desktop-gpui/src/transcription.rs index ce2ed9dc1e6..ff41f840a58 100644 --- a/apps/desktop-gpui/src/transcription.rs +++ b/apps/desktop-gpui/src/transcription.rs @@ -2071,6 +2071,7 @@ pub fn apply_caption_result( end: recording_duration, name: None, speed_audio_mode: None, + audio_muted: false, hide_cursor: None, volume: None, }], diff --git a/apps/desktop/src-tauri/examples/desktop-display-transport-benchmark.rs b/apps/desktop/src-tauri/examples/desktop-display-transport-benchmark.rs index f7449e2f940..35b0419aa82 100644 --- a/apps/desktop/src-tauri/examples/desktop-display-transport-benchmark.rs +++ b/apps/desktop/src-tauri/examples/desktop-display-transport-benchmark.rs @@ -133,6 +133,7 @@ async fn load_recording( timescale: 1.0, name: None, speed_audio_mode: None, + audio_muted: false, hide_cursor: None, volume: None, }] @@ -154,6 +155,7 @@ async fn load_recording( timescale: 1.0, name: None, speed_audio_mode: None, + audio_muted: false, hide_cursor: None, volume: None, }) diff --git a/apps/desktop/src-tauri/src/editor_preparing.rs b/apps/desktop/src-tauri/src/editor_preparing.rs index 7a213388e47..4898701deea 100644 --- a/apps/desktop/src-tauri/src/editor_preparing.rs +++ b/apps/desktop/src-tauri/src/editor_preparing.rs @@ -1364,6 +1364,7 @@ mod presentation_guard_tests { timescale: 1.0, name: None, speed_audio_mode: None, + audio_muted: false, hide_cursor: None, volume: None, }; diff --git a/apps/desktop/src-tauri/src/import.rs b/apps/desktop/src-tauri/src/import.rs index cebe7d9c065..0e4a714c07d 100644 --- a/apps/desktop/src-tauri/src/import.rs +++ b/apps/desktop/src-tauri/src/import.rs @@ -324,6 +324,7 @@ fn full_timeline_for_segments( end: duration, name: None, speed_audio_mode: None, + audio_muted: false, hide_cursor: None, volume: None, }) @@ -360,6 +361,7 @@ fn full_timeline_for_source_segments( end: duration, name: None, speed_audio_mode: None, + audio_muted: false, hide_cursor: None, volume: None, }) @@ -928,6 +930,7 @@ fn source_timeline_segments_for_import( speed_audio_mode: segment.speed_audio_mode, hide_cursor: segment.hide_cursor, volume: segment.volume, + audio_muted: segment.audio_muted, }); } @@ -1737,6 +1740,7 @@ async fn append_mp4_to_editor_project( end: duration, name: None, speed_audio_mode: None, + audio_muted: false, hide_cursor: None, volume: None, }); @@ -1788,13 +1792,21 @@ async fn append_cap_project_to_editor_project( }; }; + append_studio_project_to_editor_project(target_project_path, &source_meta, source_studio_meta) +} + +fn append_studio_project_to_editor_project( + target_project_path: PathBuf, + source_meta: &RecordingMeta, + source_studio_meta: &StudioRecordingMeta, +) -> Result { let source_segments = studio_segments_for_import(source_studio_meta); if source_segments.is_empty() { return Err("Source Cap project has no recording segments".to_string()); } - let source_timeline = source_timeline_segments_for_import(&source_meta, &source_segments)?; - let source_cursors = match source_studio_meta.as_ref() { + let source_timeline = source_timeline_segments_for_import(source_meta, &source_segments)?; + let source_cursors = match source_studio_meta { StudioRecordingMeta::MultipleSegments { inner } => Some(&inner.cursors), StudioRecordingMeta::SingleSegment { .. } => None, }; @@ -1870,6 +1882,7 @@ async fn append_cap_project_to_editor_project( end: source_segment.end, name: None, speed_audio_mode: source_segment.speed_audio_mode, + audio_muted: source_segment.audio_muted, hide_cursor: source_segment.hide_cursor, volume: source_segment.volume, }); @@ -2106,6 +2119,131 @@ pub async fn check_import_ready(project_path: PathBuf) -> Result { mod tests { use super::*; + #[test] + fn cap_project_import_preserves_segment_mute() { + let _ = ffmpeg::init(); + let source_project = tempfile::tempdir().unwrap(); + let target_project = tempfile::tempdir().unwrap(); + let display_path = RelativePathBuf::from("content/segments/segment-0/display.mp4"); + let absolute_display_path = display_path.to_path(source_project.path()); + std::fs::create_dir_all(absolute_display_path.parent().unwrap()).unwrap(); + std::fs::write( + &absolute_display_path, + include_bytes!("../../../media-server/src/__tests__/fixtures/test-no-audio.mp4"), + ) + .unwrap(); + + let source_segment = MultipleSegment { + display: VideoMeta { + path: display_path, + fps: 30, + start_time: Some(0.0), + device_id: None, + }, + camera: None, + mic: None, + system_audio: None, + cursor: None, + keyboard: None, + display_notch: None, + }; + let source_meta = RecordingMeta { + platform: Some(Platform::default()), + project_path: source_project.path().to_path_buf(), + pretty_name: "Muted import fixture".to_string(), + sharing: None, + inner: RecordingMetaInner::Studio(Box::new(StudioRecordingMeta::MultipleSegments { + inner: MultipleSegments { + segments: vec![source_segment], + cursors: Cursors::default(), + status: Some(StudioRecordingStatus::Complete), + }, + })), + upload: None, + }; + source_meta.save_for_project().unwrap(); + ProjectConfiguration { + timeline: Some(TimelineConfiguration { + segments: vec![TimelineSegment { + recording_clip: 0, + timescale: 1.0, + start: 0.0, + end: 1.0, + name: None, + speed_audio_mode: None, + audio_muted: true, + hide_cursor: None, + volume: Some(0.35), + }], + transitions: Vec::new(), + zoom_segments: Vec::new(), + scene_segments: Vec::new(), + mask_segments: Vec::new(), + text_segments: Vec::new(), + caption_segments: Vec::new(), + keyboard_segments: Vec::new(), + audio_segments: Vec::new(), + style_segments: Vec::new(), + image_segments: Vec::new(), + camera3d_segments: Vec::new(), + }), + ..Default::default() + } + .write(source_project.path()) + .unwrap(); + let source_meta = RecordingMeta::load_for_project(source_project.path()).unwrap(); + + RecordingMeta { + platform: Some(Platform::default()), + project_path: target_project.path().to_path_buf(), + pretty_name: "Target import fixture".to_string(), + sharing: None, + inner: RecordingMetaInner::Studio(Box::new(StudioRecordingMeta::MultipleSegments { + inner: MultipleSegments { + segments: Vec::new(), + cursors: Cursors::default(), + status: Some(StudioRecordingStatus::Complete), + }, + })), + upload: None, + } + .save_for_project() + .unwrap(); + + let RecordingMetaInner::Studio(source_studio) = &source_meta.inner else { + panic!("expected Studio source metadata"); + }; + let imported = append_studio_project_to_editor_project( + target_project.path().to_path_buf(), + &source_meta, + source_studio, + ) + .unwrap(); + + let target_config = ProjectConfiguration::load(target_project.path()).unwrap(); + let target_timeline = target_config.timeline.unwrap(); + let target_meta = RecordingMeta::load_for_project(target_project.path()).unwrap(); + let RecordingMetaInner::Studio(target_studio) = target_meta.inner else { + panic!("expected Studio recording metadata"); + }; + let StudioRecordingMeta::MultipleSegments { inner } = *target_studio else { + panic!("expected multiple recording segments"); + }; + + assert_eq!(imported, 1); + assert_eq!(target_timeline.segments.len(), 1); + assert!(target_timeline.segments[0].audio_muted); + assert_eq!(target_timeline.segments[0].volume(), 0.35); + assert_eq!(inner.segments.len(), 1); + assert!( + inner.segments[0] + .display + .path + .to_path(target_project.path()) + .is_file() + ); + } + #[test] fn imported_video_frames_share_reference_counted_pixel_storage() { let mut source = ffmpeg::frame::Video::new(ffmpeg::format::Pixel::YUV420P, 16, 12); diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index 87cae9cedb8..ee8fde276c8 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -6584,6 +6584,7 @@ fn project_config_from_recording( timescale: 1.0, name: None, speed_audio_mode: None, + audio_muted: false, hide_cursor: None, volume: None, }) @@ -10168,6 +10169,7 @@ mod preparing_presentation_parity_tests { timescale: 1.0, name: None, speed_audio_mode: None, + audio_muted: false, hide_cursor: None, volume: None, }] diff --git a/apps/desktop/src/routes/editor/Timeline/ClipTrack.tsx b/apps/desktop/src/routes/editor/Timeline/ClipTrack.tsx index bb18c3c86e5..eb648015325 100644 --- a/apps/desktop/src/routes/editor/Timeline/ClipTrack.tsx +++ b/apps/desktop/src/routes/editor/Timeline/ClipTrack.tsx @@ -673,7 +673,9 @@ function ClipSettingsControl(props: { ["matchSpeed", "Match speed"], ] as const } - value={props.segment.speedAudioMode ?? "mute"} + value={ + muted() ? "mute" : (props.segment.speedAudioMode ?? "mute") + } onChange={(mode) => projectActions.setClipSegmentSpeedAudioMode(props.index, mode) } diff --git a/apps/desktop/src/routes/editor/clip-audio.test.ts b/apps/desktop/src/routes/editor/clip-audio.test.ts index e5a783c3718..96ae5d07e4a 100644 --- a/apps/desktop/src/routes/editor/clip-audio.test.ts +++ b/apps/desktop/src/routes/editor/clip-audio.test.ts @@ -20,4 +20,15 @@ describe("clip audio", () => { expect(clipAudioMuted({ timescale: 2, speedAudioMode })).toBe(false); } }); + it("keeps explicit segment mute independent of speed and adjacent clips", () => { + for (const timescale of [0.5, 1, 2]) { + for (const speedAudioMode of ["maintainPitch", "matchSpeed"] as const) { + const original = { timescale, speedAudioMode, audioMuted: true }; + const split = { ...original }; + split.audioMuted = false; + expect(clipAudioMuted(original)).toBe(true); + expect(clipAudioMuted(split)).toBe(false); + } + } + }); }); diff --git a/apps/desktop/src/routes/editor/clip-audio.ts b/apps/desktop/src/routes/editor/clip-audio.ts index 847e8338bbd..5030b47f617 100644 --- a/apps/desktop/src/routes/editor/clip-audio.ts +++ b/apps/desktop/src/routes/editor/clip-audio.ts @@ -6,8 +6,9 @@ export function clipVolume(segment: Pick): number { } export function clipAudioMuted( - segment: Pick, + segment: Pick, ): boolean { + if (segment.audioMuted) return true; return segment.timescale === 1 ? segment.speedAudioMode === "mute" : (segment.speedAudioMode ?? "mute") === "mute"; diff --git a/apps/desktop/src/routes/editor/clip-merge.ts b/apps/desktop/src/routes/editor/clip-merge.ts index 3c3ab3c34a4..cc80c419b11 100644 --- a/apps/desktop/src/routes/editor/clip-merge.ts +++ b/apps/desktop/src/routes/editor/clip-merge.ts @@ -133,13 +133,18 @@ if (import.meta.vitest) { describe("mergedClipSegment", () => { it("spans both clips and keeps the acted-on clip's settings", () => { const left = clip(0, 4, { volume: 0.5, name: "Intro" }); - const right = clip(4, 8, { speedAudioMode: "mute", hideCursor: true }); + const right = clip(4, 8, { + speedAudioMode: "mute", + audioMuted: true, + hideCursor: true, + }); expect(mergedClipSegment(left, right, right)).toEqual({ recordingSegment: 0, timescale: 1, start: 0, end: 8, speedAudioMode: "mute", + audioMuted: true, hideCursor: true, name: "Intro", }); diff --git a/apps/desktop/src/routes/editor/context.ts b/apps/desktop/src/routes/editor/context.ts index c8b3c478184..2d5fc1bb3bf 100644 --- a/apps/desktop/src/routes/editor/context.ts +++ b/apps/desktop/src/routes/editor/context.ts @@ -64,6 +64,7 @@ import { MIN_AUDIO_SEGMENT_DURATION, } from "./audio"; import { deriveCaptionTrackSegments, mapEditedTimeToSource } from "./captions"; +import { clipAudioMuted } from "./clip-audio"; import { type ClipMergeDirection, clipMergeBlocker, @@ -1599,13 +1600,17 @@ export const [EditorContextProvider, useBaseEditorContext] = index: number, speedAudioMode: ClipSpeedAudioMode, ) => { - setProject( - "timeline", - "segments", - index, - "speedAudioMode", - speedAudioMode, - ); + if (!project.timeline?.segments[index]) return; + batch(() => { + setProject("timeline", "segments", index, "audioMuted", false); + setProject( + "timeline", + "segments", + index, + "speedAudioMode", + speedAudioMode, + ); + }); }, setClipSegmentVolume: (index: number, volume: number) => { if (!Number.isFinite(volume) || !project.timeline?.segments[index]) { @@ -1623,13 +1628,18 @@ export const [EditorContextProvider, useBaseEditorContext] = setClipSegmentMuted: (index: number, muted: boolean) => { const segment = project.timeline?.segments[index]; if (!segment) return; - setProject( - "timeline", - "segments", - index, - "speedAudioMode", - muted ? "mute" : segment.timescale === 1 ? null : "maintainPitch", - ); + batch(() => { + setProject("timeline", "segments", index, "audioMuted", muted); + if (!muted && clipAudioMuted(segment)) { + setProject( + "timeline", + "segments", + index, + "speedAudioMode", + segment.timescale === 1 ? null : "maintainPitch", + ); + } + }); }, setClipSegmentHideCursor: (index: number, hidden: boolean) => { if (!project.timeline?.segments[index]) return; diff --git a/apps/desktop/src/utils/tauri.ts b/apps/desktop/src/utils/tauri.ts index 6f27d9856cb..46a766f2907 100644 --- a/apps/desktop/src/utils/tauri.ts +++ b/apps/desktop/src/utils/tauri.ts @@ -1351,7 +1351,7 @@ letterSpacing?: number; lineHeight?: number; opacity?: number; shadow?: number; */ layoutTransition?: number } export type TimelineConfiguration = { segments: TimelineSegment[]; transitions: ClipTransition[]; zoomSegments: ZoomSegment[]; sceneSegments?: SceneSegment[]; maskSegments?: MaskSegment[]; textSegments?: TextSegment[]; captionSegments?: CaptionTrackSegment[]; keyboardSegments?: KeyboardTrackSegment[]; audioSegments?: AudioTrackSegment[]; styleSegments: StyleSegment[]; imageSegments: ImageSegment[]; camera3dSegments?: Camera3DSegment[] } -export type TimelineSegment = { recordingSegment?: number; timescale: number; start: number; end: number; name?: string | null; speedAudioMode?: ClipSpeedAudioMode | null; volume?: number | null; hideCursor?: boolean | null } +export type TimelineSegment = { recordingSegment?: number; timescale: number; start: number; end: number; name?: string | null; speedAudioMode?: ClipSpeedAudioMode | null; audioMuted?: boolean; volume?: number | null; hideCursor?: boolean | null } export type TranscriptionEngine = "Whisper" | "Parakeet" export type Trigger = "screenshotTaken" | "studioRecordingFinished" | "instantRecordingFinished" | "recordingStarted" | "uploadCompleted" | "videoImported" | "recordingDeleted" export type UpdateChannel = "stable" | "nightly" diff --git a/crates/editor/examples/editor-playback-benchmark.rs b/crates/editor/examples/editor-playback-benchmark.rs index 14305d6c61a..21527f5f467 100644 --- a/crates/editor/examples/editor-playback-benchmark.rs +++ b/crates/editor/examples/editor-playback-benchmark.rs @@ -353,6 +353,7 @@ async fn load_recording( timescale: 1.0, name: None, speed_audio_mode: None, + audio_muted: false, hide_cursor: None, volume: None, }] @@ -374,6 +375,7 @@ async fn load_recording( timescale: 1.0, name: None, speed_audio_mode: None, + audio_muted: false, hide_cursor: None, volume: None, }) diff --git a/crates/editor/examples/playback-pipeline-benchmark.rs b/crates/editor/examples/playback-pipeline-benchmark.rs index 8e772d10372..a04c464391d 100644 --- a/crates/editor/examples/playback-pipeline-benchmark.rs +++ b/crates/editor/examples/playback-pipeline-benchmark.rs @@ -276,6 +276,7 @@ async fn load_recording( timescale: 1.0, name: None, speed_audio_mode: None, + audio_muted: false, hide_cursor: None, volume: None, }] @@ -300,6 +301,7 @@ async fn load_recording( timescale: 1.0, name: None, speed_audio_mode: None, + audio_muted: false, hide_cursor: None, volume: None, }) diff --git a/crates/editor/src/audio.rs b/crates/editor/src/audio.rs index 73433bd3657..de641a17cd1 100644 --- a/crates/editor/src/audio.rs +++ b/crates/editor/src/audio.rs @@ -353,7 +353,9 @@ impl AudioRenderer { output.fill(0.0); match mapping { TimelineFrameMapping::Single { source, .. } => { - if source.segment.speed_audio_mode != Some(ClipSpeedAudioMode::Mute) { + if !source.segment.audio_muted + && source.segment.speed_audio_mode != Some(ClipSpeedAudioMode::Mute) + { sources.render( project, source.segment.recording_clip, @@ -377,7 +379,9 @@ impl AudioRenderer { let incoming_buffer = &mut incoming_buffer[..count * 2]; outgoing_buffer.fill(0.0); incoming_buffer.fill(0.0); - if outgoing.segment.speed_audio_mode != Some(ClipSpeedAudioMode::Mute) { + if !outgoing.segment.audio_muted + && outgoing.segment.speed_audio_mode != Some(ClipSpeedAudioMode::Mute) + { sources.render( project, outgoing.segment.recording_clip, @@ -387,7 +391,9 @@ impl AudioRenderer { )?; apply_clip_volume(outgoing_buffer, outgoing.segment); } - if incoming.segment.speed_audio_mode != Some(ClipSpeedAudioMode::Mute) { + if !incoming.segment.audio_muted + && incoming.segment.speed_audio_mode != Some(ClipSpeedAudioMode::Mute) + { sources.render( project, incoming.segment.recording_clip, @@ -687,6 +693,10 @@ impl AudioRenderer { out_offset: usize, out: &mut [f32], ) -> usize { + if samples == 0 || project.audio.mute || source.segment.audio_muted { + return 0; + } + let rendered = if source.segment.timescale == 1.0 { if source.segment.speed_audio_mode == Some(ClipSpeedAudioMode::Mute) { return 0; @@ -711,9 +721,7 @@ impl AudioRenderer { out_offset: usize, out: &mut [f32], ) -> usize { - if samples == 0 - || project.audio.mute - || source.segment.speed_audio_mode.unwrap_or_default() == ClipSpeedAudioMode::Mute + if source.segment.speed_audio_mode.unwrap_or_default() == ClipSpeedAudioMode::Mute || !source.segment.timescale.is_finite() || !(0.25..=8.0).contains(&source.segment.timescale) { @@ -2589,6 +2597,7 @@ mod tests { end: 1.0, name: None, speed_audio_mode: None, + audio_muted: false, hide_cursor: None, volume: None, }, @@ -2599,6 +2608,7 @@ mod tests { end: 2.0, name: None, speed_audio_mode: None, + audio_muted: false, hide_cursor: None, volume: None, }, @@ -2609,6 +2619,7 @@ mod tests { end: 3.0, name: None, speed_audio_mode: None, + audio_muted: false, hide_cursor: None, volume: None, }, @@ -2619,6 +2630,7 @@ mod tests { end: 1.0, name: None, speed_audio_mode: None, + audio_muted: false, hide_cursor: None, volume: None, }, @@ -2629,6 +2641,7 @@ mod tests { end: 2.0, name: None, speed_audio_mode: None, + audio_muted: false, hide_cursor: None, volume: None, }, @@ -2639,6 +2652,7 @@ mod tests { end: 3.0, name: None, speed_audio_mode: None, + audio_muted: false, hide_cursor: None, volume: None, }, @@ -3044,6 +3058,32 @@ mod tests { } } + #[test] + fn segment_mute_does_not_mute_adjacent_segments() { + let mut muted = segment(0, 0.0, 1.0, 1.0); + muted.audio_muted = true; + let (_dir, mut renderer, project) = + single_clip_fixture(&[8_000, 16_000], vec![muted, segment(0, 1.0, 2.0, 1.0)]); + + let stream = render_export_audio(&mut renderer, &project, 30, 60); + + assert!(left_at_second(&stream, 0).abs() < 0.0001); + assert!((left_at_second(&stream, 1) - expected(16_000)).abs() < 0.01); + } + + #[test] + fn segment_mute_silences_retimed_audio() { + let mut muted = segment(0, 0.0, 2.0, 2.0); + muted.speed_audio_mode = Some(ClipSpeedAudioMode::MaintainPitch); + muted.audio_muted = true; + let (_dir, mut renderer, project) = single_clip_fixture(&[8_000, 16_000], vec![muted]); + + let stream = render_export_audio(&mut renderer, &project, 30, 30); + + assert!(mean_abs(&stream) < 0.0001); + assert!(renderer.speed_audio_processors.iter().all(Option::is_none)); + } + /// One clip per second `section_values`, on a timeline made of `segments`. fn single_clip_fixture( section_values: &[i16], @@ -3097,6 +3137,7 @@ mod tests { end, name: None, speed_audio_mode: None, + audio_muted: false, hide_cursor: None, volume: None, } diff --git a/crates/editor/src/editor_instance.rs b/crates/editor/src/editor_instance.rs index f670c27a4fb..27d798e058f 100644 --- a/crates/editor/src/editor_instance.rs +++ b/crates/editor/src/editor_instance.rs @@ -378,6 +378,7 @@ impl EditorInstance { timescale: 1.0, name: None, speed_audio_mode: None, + audio_muted: false, hide_cursor: None, volume: None, }], @@ -413,6 +414,7 @@ impl EditorInstance { timescale: 1.0, name: None, speed_audio_mode: None, + audio_muted: false, hide_cursor: None, volume: None, }) diff --git a/crates/editor/src/export_audio.rs b/crates/editor/src/export_audio.rs index 8df5e4df7f3..cc694b99179 100644 --- a/crates/editor/src/export_audio.rs +++ b/crates/editor/src/export_audio.rs @@ -1076,6 +1076,7 @@ mod tests { timescale: 1.0, name: None, speed_audio_mode: None, + audio_muted: false, hide_cursor: None, volume: None, } @@ -1465,6 +1466,11 @@ mod tests { assert_bounded_sink_variants(22..26, &[1, 997, 4_800, 48_001]); } + #[test] + fn segment_mute_matches_preview_in_streaming_exports_and_transitions() { + assert_bounded_sink_variants(26..31, &[1, 997, 4_800, 48_001]); + } + #[test] fn studio_sound_transition_with_single_sample_requests_keeps_lookahead() { assert_bounded_sink_variants([19], &[1]); @@ -1570,6 +1576,22 @@ mod tests { }); } } + 26..=30 => { + let timeline = project.timeline.as_mut().unwrap(); + timeline.segments[usize::from(variant == 28 || variant == 30)].audio_muted = + true; + if variant >= 27 { + timeline.transitions.push(ClipTransition { + segment_index: 1, + kind: if variant >= 29 { + ClipTransitionType::FadeThroughBlack + } else { + ClipTransitionType::CrossFade + }, + duration: 0.131_234_567, + }); + } + } _ => unreachable!(), } for &request in requests { diff --git a/crates/editor/src/playback.rs b/crates/editor/src/playback.rs index 95696fa680e..88a189fefd1 100644 --- a/crates/editor/src/playback.rs +++ b/crates/editor/src/playback.rs @@ -57,6 +57,7 @@ fn clip_audio_changed(previous: &ProjectConfiguration, next: &ProjectConfigurati segment.end, segment.timescale, segment.speed_audio_mode, + segment.audio_muted, segment.volume(), ) }; @@ -2048,6 +2049,11 @@ mod tests { next.timeline.as_mut().unwrap().segments[0].name = Some("Renamed".into()); next.timeline.as_mut().unwrap().segments[0].volume = Some(1.0); assert!(!clip_audio_changed(&project, &next)); + next.timeline.as_mut().unwrap().segments[0].audio_muted = true; + assert!(clip_audio_changed(&project, &next)); + assert!(clip_audio_changed(&next, &project)); + next.timeline.as_mut().unwrap().segments[0].audio_muted = false; + assert!(!clip_audio_changed(&project, &next)); next.timeline.as_mut().unwrap().segments[0].volume = Some(0.5); assert!(clip_audio_changed(&project, &next)); next.timeline.as_mut().unwrap().segments[0].volume = None; diff --git a/crates/editor/src/preparing_audio/mixer.rs b/crates/editor/src/preparing_audio/mixer.rs index 9a309333db2..14ddfc50c8c 100644 --- a/crates/editor/src/preparing_audio/mixer.rs +++ b/crates/editor/src/preparing_audio/mixer.rs @@ -74,6 +74,7 @@ impl PreparingAudioSources { || segment.start != 0.0 || segment.timescale != 1.0 || segment.speed_audio_mode.is_some() + || segment.audio_muted || segment.volume() != 1.0 || !segment.end.is_finite() || segment.end <= 0.0 @@ -752,8 +753,16 @@ mod tests { fn validation_rejects_unbounded_or_changed_audio_layout() { let original = sources(&[1.0], vec![[None, None]]); original.validate().unwrap(); - let cases: [fn(&mut PreparingAudioSources); 7] = [ + let cases: [fn(&mut PreparingAudioSources); 8] = [ |sources| sources.required.clear(), + |sources| { + Arc::make_mut(&mut sources.project) + .timeline + .as_mut() + .unwrap() + .segments[0] + .audio_muted = true + }, |sources| sources.repairs[0].mic_offset_secs = f32::NAN, |sources| Arc::make_mut(&mut sources.project).clips[0].index = 1, |sources| Arc::make_mut(&mut sources.project).audio.mic_volume_db = f32::INFINITY, diff --git a/crates/editor/src/preparing_preview.rs b/crates/editor/src/preparing_preview.rs index caf5bf4c801..1f0f4eefc82 100644 --- a/crates/editor/src/preparing_preview.rs +++ b/crates/editor/src/preparing_preview.rs @@ -355,6 +355,7 @@ pub(crate) fn validate_input(input: &PreparingPreviewInput) -> Result, #[serde(default, skip_serializing_if = "Option::is_none")] pub speed_audio_mode: Option, + #[serde(default)] + pub audio_muted: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub volume: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -3081,6 +3083,7 @@ mod tests { end: 4.0, name: None, speed_audio_mode: None, + audio_muted: false, volume: None, hide_cursor: None, }, @@ -3091,6 +3094,7 @@ mod tests { end: 16.0, name: None, speed_audio_mode: None, + audio_muted: false, volume: None, hide_cursor: None, }, @@ -3740,6 +3744,26 @@ mod tests { ); } + #[test] + fn timeline_segment_defaults_audio_muted_for_existing_projects() { + let mut segment: TimelineSegment = serde_json::from_value(serde_json::json!({ + "recordingSegment": 0, + "timescale": 1.0, + "start": 0.0, + "end": 1.0 + })) + .unwrap(); + + assert!(!segment.audio_muted); + segment.audio_muted = true; + segment.volume = Some(0.35); + let saved = serde_json::to_value(&segment).unwrap(); + assert_eq!(saved["audioMuted"], true); + let restored: TimelineSegment = serde_json::from_value(saved).unwrap(); + assert!(restored.audio_muted); + assert_eq!(restored.volume(), 0.35); + } + #[test] fn timeline_maps_both_sources_inside_transition() { let timeline = timeline_with_transitions(vec![ClipTransition { @@ -3960,6 +3984,7 @@ mod tests { end, name: None, speed_audio_mode: None, + audio_muted: false, volume: None, hide_cursor: hide_cursor.then_some(true), } diff --git a/crates/recording/src/recovery.rs b/crates/recording/src/recovery.rs index 9eddbcbeb2e..23a6c3f6e23 100644 --- a/crates/recording/src/recovery.rs +++ b/crates/recording/src/recovery.rs @@ -2330,6 +2330,7 @@ impl RecoveryManager { timescale: 1.0, name: None, speed_audio_mode: None, + audio_muted: false, hide_cursor: None, volume: None, }) diff --git a/crates/recording/src/studio_recording.rs b/crates/recording/src/studio_recording.rs index 1ff44d06f1b..d802d64842d 100644 --- a/crates/recording/src/studio_recording.rs +++ b/crates/recording/src/studio_recording.rs @@ -2744,6 +2744,7 @@ async fn stop_recording( timescale: 1.0, name: None, speed_audio_mode: None, + audio_muted: false, hide_cursor: None, volume: None, }) diff --git a/crates/recording/src/track_heal.rs b/crates/recording/src/track_heal.rs index 9ebfba3046f..ed07f0315d6 100644 --- a/crates/recording/src/track_heal.rs +++ b/crates/recording/src/track_heal.rs @@ -1050,6 +1050,7 @@ mod tests { end: 202.220711, name: None, speed_audio_mode: None, + audio_muted: false, hide_cursor: None, volume: None, }], @@ -1108,6 +1109,7 @@ mod tests { end: 202.220711, name: None, speed_audio_mode: None, + audio_muted: false, hide_cursor: None, volume: None, }], @@ -1186,6 +1188,7 @@ mod tests { end: 100.0, name: None, speed_audio_mode: None, + audio_muted: false, hide_cursor: None, volume: None, }, @@ -1196,6 +1199,7 @@ mod tests { end: 50.0, name: None, speed_audio_mode: None, + audio_muted: false, hide_cursor: None, volume: None, }, diff --git a/crates/rendering/src/zoom_spring.rs b/crates/rendering/src/zoom_spring.rs index 462c931d3d7..aaa4c36a040 100644 --- a/crates/rendering/src/zoom_spring.rs +++ b/crates/rendering/src/zoom_spring.rs @@ -1329,6 +1329,7 @@ mod tests { end: 14.0, name: None, speed_audio_mode: None, + audio_muted: false, hide_cursor: None, volume: None, }, @@ -1339,6 +1340,7 @@ mod tests { end: 34.0, name: None, speed_audio_mode: None, + audio_muted: false, hide_cursor: None, volume: None, }, @@ -2107,6 +2109,7 @@ mod tests { end: 20.0, name: None, speed_audio_mode: None, + audio_muted: false, hide_cursor: None, volume: None, }], @@ -2168,6 +2171,7 @@ mod tests { end: 4.0, name: None, speed_audio_mode: None, + audio_muted: false, hide_cursor: None, volume: None, }, @@ -2178,6 +2182,7 @@ mod tests { end: 14.0, name: None, speed_audio_mode: None, + audio_muted: false, hide_cursor: None, volume: None, },