diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index f80ec2361..8d2ca5b91 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -257,3 +257,17 @@ and are covered by [LICENSE](LICENSE). To report an omission or request source for anything bundled here, open an issue at . +--- + +## Kenney — UI sound effects (mouse click sound) + +- **Files**: `public/sounds/click-down.wav` (from `click3.wav`) and + `public/sounds/click-up.wav` (from `switch12.wav`) of the "UI Audio" set. Both were + trimmed of their leading silence and levelled to a 0.7 peak, so a hit lands on the + click and the two samples play at matching loudness without the mixer measuring them. +- **Used by**: the "Mouse clicks" editor toggle, which lays these two hits along the + click timestamps recorded with the take. +- **License**: **CC0 1.0 Universal (Public Domain Dedication)**, + . The pack ships a readme stating + personal and commercial use is permitted and that credit is optional; upstream: + . diff --git a/crates/compositor/src/audio.rs b/crates/compositor/src/audio.rs index 6b13df4c5..de845541d 100644 --- a/crates/compositor/src/audio.rs +++ b/crates/compositor/src/audio.rs @@ -5,7 +5,9 @@ use crate::ffi::*; use crate::regions::SpeedSegment; -use crate::scene::{SceneAudio, SceneAudioTrack, SceneAudioTrackKind}; +use crate::scene::{ + SceneAudio, SceneAudioTrack, SceneAudioTrackKind, SceneClickHit, SceneClickSound, +}; use anyhow::{bail, Result}; use std::collections::{HashMap, VecDeque}; use std::f32::consts::PI; @@ -1732,6 +1734,67 @@ fn overlay_external_track(programme: &mut PlanarPcm, track: &SceneAudioTrack, du ); } +/// How much of a hit file gets decoded. The bundled samples are a few hundred milliseconds; +/// the window is what keeps a wrong path from buffering an hour of "click". +/// ponytail: fixed window, read the file's own duration if longer hits are ever shipped. +const CLICK_HIT_WINDOW_SEC: f64 = 1.0; + +/// Sum the take's click hits over the assembled programme: two decodes for the whole take, +/// then one add per hit — unlike an imported track, which decodes once per placement. +/// +/// The hits arrive already placed on the programme by the app, through the projection it lays +/// imported audio tracks out with, so speeding a section up cannot leave the sound behind. +pub fn mix_click_hits(mut programme: PlanarPcm, clicks: &SceneClickSound) -> PlanarPcm { + if clicks.hits.is_empty() + || clicks.down_path.is_empty() + || clicks.up_path.is_empty() + || programme.first().map_or(true, Vec::is_empty) + { + return programme; + } + let (down, up) = ( + decode_click_hit(&clicks.down_path), + decode_click_hit(&clicks.up_path), + ); + if down.is_empty() || up.is_empty() { + eprintln!("[audio] click hits undecodable; export keeps the picture only"); + return programme; + } + overlay_click_hits(&mut programme, &down, &up, &clicks.hits); + programme +} + +fn decode_click_hit(path: &str) -> PlanarPcm { + decode_clip_audio(path, 0.0, CLICK_HIT_WINDOW_SEC) + .ok() + .flatten() + .unwrap_or_default() +} + +/// Placement and level, with no ffmpeg in sight. `overlay_track_pcm` truncates at the +/// programme's end, so a hit the edit pushed past it is simply not heard. +fn overlay_click_hits( + programme: &mut PlanarPcm, + down: &PlanarPcm, + up: &PlanarPcm, + hits: &[SceneClickHit], +) { + for hit in hits { + let offset = (hit.time_sec.max(0.0) * AUDIO_OUTPUT_SAMPLE_RATE as f64).round() as usize; + let gain = hit.gain.clamp(0.0, 4.0); + overlay_track_pcm( + programme, + if hit.release { up } else { down }, + offset, + gain, + 0.0, + 0.0, + 0, + &[], + ); + } +} + /// How far a music bed dips while someone speaks. pub const DUCK_DEPTH_DB: f32 = -10.0; /// A 10 ms stretch of the voice counts as speech above this RMS level. The voice is already @@ -2268,6 +2331,66 @@ mod tests { assert_eq!(programme[0], vec![0.3, 0.3]); } + fn hit(time_sec: f64, gain: f32, release: bool) -> SceneClickHit { + SceneClickHit { time_sec, gain, release } + } + + #[test] + fn click_hits_sum_at_their_time_and_pick_their_sample() { + // down is 1.0 and up is 0.5, so the level heard at each offset says which sample played. + let (down, up) = (planar(&[1.0]), planar(&[0.5])); + let tick = 1.0 / AUDIO_OUTPUT_SAMPLE_RATE as f64; + let mut programme = planar(&[0.0, 0.0, 0.0]); + overlay_click_hits( + &mut programme, + &down, + &up, + &[ + hit(0.0, 1.0, false), + hit(tick, 1.0, true), + hit(2.0 * tick, 0.5, false), + ], + ); + assert_eq!(programme[0], vec![1.0, 0.5, 0.5]); + } + + #[test] + fn a_click_hit_the_edit_pushed_past_the_programme_is_not_heard() { + let mut programme = planar(&[0.25, 0.25]); + overlay_click_hits( + &mut programme, + &planar(&[1.0]), + &planar(&[1.0]), + &[hit(5.0, 1.0, false)], + ); + assert_eq!(programme[0], vec![0.25, 0.25]); + } + + #[test] + fn click_hits_are_decoded_from_files_and_summed_at_their_time() { + // The whole point of the scene carrying hits instead of a baked bed: the samples live in + // their own files and are read here, so this walks the same decode a real track does. + let dir = std::env::temp_dir(); + let down = dir.join(format!("openscreen-click-down-{}.wav", std::process::id())); + let up = dir.join(format!("openscreen-click-up-{}.wav", std::process::id())); + write_wav(&down, &planar(&[0.5, 0.5])); + write_wav(&up, &planar(&[0.25, 0.25])); + let clicks = SceneClickSound { + down_path: down.to_str().unwrap().into(), + up_path: up.to_str().unwrap().into(), + hits: vec![hit(0.0, 1.0, false), hit(1.0 / 48_000.0, 1.0, true)], + }; + let out = mix_click_hits(planar(&[0.0; 4]), &clicks); + let _ = std::fs::remove_file(&down); + let _ = std::fs::remove_file(&up); + // Each sample is two frames long: the press covers 0..1 and the hit placed one frame in + // covers 1..2, so the seam carries both and the programme is untouched past them. + assert_eq!( + out[0].iter().map(|v| (v * 100.0).round() / 100.0).collect::>(), + vec![0.5, 0.75, 0.25, 0.0] + ); + } + #[test] fn mix_external_tracks_skips_empty_windows() { let programme = planar(&[0.4, 0.4]); diff --git a/crates/compositor/src/pipeline_linux.rs b/crates/compositor/src/pipeline_linux.rs index 1c47e0b0f..189115363 100644 --- a/crates/compositor/src/pipeline_linux.rs +++ b/crates/compositor/src/pipeline_linux.rs @@ -27,7 +27,8 @@ use std::ffi::CString; use std::ptr; use crate::audio::{ - assemble_concatenated_pcm, build_audio_concat_plan, finish_audio, mix_external_tracks, + assemble_concatenated_pcm, build_audio_concat_plan, finish_audio, mix_click_hits, + mix_external_tracks, AacEncoder, PlanarPcm, }; use crate::audio_jobs::{decode_and_stretch_clip_audio, ClipAudioJobs}; @@ -1019,6 +1020,11 @@ pub fn run_composited_multi( .as_ref() .map(|scene| scene.audio_tracks.clone()) .unwrap_or_default(); + // Click hits (the "mouse clicks" toggle), laid on the programme by the app. + let click_sound = scene + .as_ref() + .map(|scene| scene.click_sound.clone()) + .unwrap_or_default(); // Ring de staging a 2 : l'export ne veut que du debit, une frame de latence // ne se voit pas dans un fichier. Voir `Compositor::set_readback_depth` pour // la raison pour laquelle la preview, elle, reste a 1. @@ -1208,7 +1214,10 @@ pub fn run_composited_multi( let octx = mux.octx; mux.aac.encode( &finish_audio( - mix_external_tracks(assemble_concatenated_pcm(&clip_pcm, &plan), &audio_tracks), + mix_click_hits( + mix_external_tracks(assemble_concatenated_pcm(&clip_pcm, &plan), &audio_tracks), + &click_sound, + ), audio_settings, ), octx, diff --git a/crates/compositor/src/pipeline_macos.rs b/crates/compositor/src/pipeline_macos.rs index ad43084a0..d0abbc580 100644 --- a/crates/compositor/src/pipeline_macos.rs +++ b/crates/compositor/src/pipeline_macos.rs @@ -30,7 +30,8 @@ //! décodeurs, symétrique. use crate::audio::{ - assemble_concatenated_pcm, build_audio_concat_plan, finish_audio, mix_external_tracks, + assemble_concatenated_pcm, build_audio_concat_plan, finish_audio, mix_click_hits, + mix_external_tracks, AacEncoder, PlanarPcm, }; use crate::audio_jobs::{decode_and_stretch_clip_audio, ClipAudioJobs}; @@ -1231,6 +1232,11 @@ pub fn run_composited_multi( .as_ref() .map(|scene| scene.audio_tracks.clone()) .unwrap_or_default(); + // Click hits (the "mouse clicks" toggle), laid on the programme by the app. + let click_sound = scene + .as_ref() + .map(|scene| scene.click_sound.clone()) + .unwrap_or_default(); frames = unsafe { crate::timeline_walk::walk_composited_timeline( clips, @@ -1307,7 +1313,10 @@ pub fn run_composited_multi( let plan = build_audio_concat_plan(&clip_frame_counts, &declared_audio, out_fps as f64); audio_encoder.encode( &finish_audio( - mix_external_tracks(assemble_concatenated_pcm(&clip_pcm, &plan), &audio_tracks), + mix_click_hits( + mix_external_tracks(assemble_concatenated_pcm(&clip_pcm, &plan), &audio_tracks), + &click_sound, + ), audio_settings, ), octx, diff --git a/crates/compositor/src/pipeline_windows.rs b/crates/compositor/src/pipeline_windows.rs index a232f06b7..8b9de0b32 100644 --- a/crates/compositor/src/pipeline_windows.rs +++ b/crates/compositor/src/pipeline_windows.rs @@ -3,7 +3,8 @@ //! tout le run, deux lectures seulement. Rien dans la boucle ne peut fausser le fps. use crate::audio::{ - assemble_concatenated_pcm, build_audio_concat_plan, finish_audio, mix_external_tracks, + assemble_concatenated_pcm, build_audio_concat_plan, finish_audio, mix_click_hits, + mix_external_tracks, AacEncoder, PlanarPcm, }; use crate::audio_jobs::{decode_and_stretch_clip_audio, ClipAudioJobs}; @@ -1668,6 +1669,11 @@ unsafe fn run_multi_inner( .as_ref() .map(|scene| scene.audio_tracks.clone()) .unwrap_or_default(); + // Click hits (the "mouse clicks" toggle), laid on the programme by the app. + let click_sound = scene + .as_ref() + .map(|scene| scene.click_sound.clone()) + .unwrap_or_default(); // ---- encodeur (choisi à l'exécution, cf. ExportCodec::candidates) + mux ---- // Backend CPU : pas de pool D3D11 du tout. `av_hwdevice_ctx_init(D3D11VA)` échoue sur @@ -1804,7 +1810,10 @@ unsafe fn run_multi_inner( out_fps as f64, ); let assembled_audio = finish_audio( - mix_external_tracks(assemble_concatenated_pcm(&clip_pcm, &audio_plan), &audio_tracks), + mix_click_hits( + mix_external_tracks(assemble_concatenated_pcm(&clip_pcm, &audio_plan), &audio_tracks), + &click_sound, + ), audio_settings, ); audio_encoder.encode(&assembled_audio, octx)?; diff --git a/crates/compositor/src/scene.rs b/crates/compositor/src/scene.rs index bddd7d55c..8cd90f7d5 100644 --- a/crates/compositor/src/scene.rs +++ b/crates/compositor/src/scene.rs @@ -666,6 +666,43 @@ pub enum SceneAudioTrackKind { Voiceover, } +/// One click to be heard: `time_sec` is its place on the OUTPUT programme, `gain` the linear +/// level of the hit (side clicks a little quieter than the left), `release` which of the two +/// samples to play. +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SceneClickHit { + pub time_sec: f64, + #[serde(default = "default_one")] + pub gain: f32, + #[serde(default)] + pub release: bool, +} + +/// The "mouse clicks" toggle: the recorded click times, already projected onto the programme, +/// plus the two hit samples to play them with. +/// +/// Deliberately not an imported audio track. A bed baked into one file cannot follow an edit — +/// `SceneAudioTrack`'s content is placed, never stretched, so every hit after a speed region +/// drifts — while hits laid out here are positioned by the app's own projection, the same one +/// imported audio tracks are placed with, so they cannot disagree with the picture. +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SceneClickSound { + /// Absolute paths to the press and release samples, 48 kHz-friendly WAV files. `default` for + /// the usual reason: a half-written block must cost the sound, never the whole scene parse. + #[serde(default)] + pub down_path: String, + #[serde(default)] + pub up_path: String, + #[serde(default)] + pub hits: Vec, +} + +fn default_one() -> f32 { + 1.0 +} + #[derive(Debug, Clone, Copy, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SceneOutput { @@ -738,6 +775,10 @@ pub struct Scene { /// absent from every scene written before this, and from a project with none. #[serde(default)] pub audio_tracks: Vec, + /// Click hits for the "mouse clicks" toggle. `#[serde(default)]`: an empty `hits` is the + /// same as the field being absent, which is every scene written before this. + #[serde(default)] + pub click_sound: SceneClickSound, /// Crop écran par clip, dans le même ordre que `clips` (`cropByClip` côté TS). #[serde(default)] pub crop_by_clip: Vec>, @@ -912,6 +953,24 @@ mod tests { assert_eq!(effects(r#","frame":"laptop""#).frame.theme_override(), None); } + #[test] + fn a_click_sound_payload_reads_the_apps_camel_case_names() { + let clicks: SceneClickSound = serde_json::from_str( + r#"{"downPath":"/u/click-down.wav","upPath":"/u/click-up.wav","hits":[ + {"timeSec":2.5,"gain":0.7,"release":true},{"timeSec":4.0}]}"#, + ) + .unwrap(); + assert_eq!(clicks.down_path, "/u/click-down.wav"); + assert_eq!(clicks.hits.len(), 2); + assert_eq!(clicks.hits[0].time_sec, 2.5); + assert!(clicks.hits[0].release); + // A hit that carries no level is a full-strength press. + assert_eq!(clicks.hits[1].gain, 1.0); + // And a scene from before the field reads as no hits, not as a parse failure. + assert!(SceneClickSound::default().hits.is_empty()); + assert!(serde_json::from_str::("{}").unwrap().hits.is_empty()); + } + #[test] fn parses_color_and_image_backgrounds() { let color = r##"{"clips":[],"layout":{"preset":"no-webcam","webcamSize":1,"webcamShape":"rectangle","webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false},"effects":{"padding":0,"blur":false,"shadow":0,"roundnessFrac":0,"motionBlur":0},"background":{"kind":"color","color":"#123456"},"zoomRegions":[],"cursor":{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0,"clipToBounds":false,"theme":"default"},"cropByClip":[],"output":{"width":1280,"height":720,"fps":30}}"##; diff --git a/electron/ai-edition/style-preset-service.test.ts b/electron/ai-edition/style-preset-service.test.ts index 0f2e430f7..60c9025da 100644 --- a/electron/ai-edition/style-preset-service.test.ts +++ b/electron/ai-edition/style-preset-service.test.ts @@ -34,6 +34,8 @@ const APPEARANCE: StylePresetAppearance = { model3d: false, asArrow: [], clickImpact: false, + clickSound: false, + clickSoundGainDb: 0, }, cursorShow: true, cursorAutoHide: false, diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 26f2f5308..6045c88d6 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -338,6 +338,17 @@ interface Window { message?: string; error?: string; }>; + // Stage one of the two bundled click-sound samples where the compositor can read it + // by path. Only the two names the app ships are accepted, so nothing is sanitised here. + stageClickSoundHit: ( + name: string, + data: ArrayBuffer, + ) => Promise<{ + success: boolean; + path?: string; + message?: string; + error?: string; + }>; setCurrentVideoPath: (path: string) => Promise<{ success: boolean }>; setCurrentRecordingSession: ( session: import("../src/lib/recordingSession").RecordingSession | null, diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 81e706c08..3def434de 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -165,6 +165,12 @@ const PREVIEW_AUDIO_DIR = path.join(app.getPath("userData"), "preview-audio"); // See the save-recorded-voiceover handler: an upper bound on renderer-supplied // bytes written to disk, well past any plausible take. const MAX_RECORDED_VOICEOVER_BYTES = 512 * 1024 * 1024; +// The bundled click-sound samples, staged for the compositor, which reads audio by path and not +// by renderer URL. These are the only names the staging handler accepts, and they match +// HIT_FILES in src/lib/ai-edition/clickSound.ts. +const CLICK_SOUND_DIR = path.join(app.getPath("userData"), "click-sfx"); +const CLICK_SOUND_HIT_FILES: string[] = ["click-down.wav", "click-up.wav"]; +const MAX_CLICK_HIT_BYTES = 4 * 1024 * 1024; const nativeMacCaptureEvents = new EventEmitter(); // Enumeration walks every display and window and grabs a thumbnail of each, so it @@ -4253,6 +4259,34 @@ export function registerIpcHandlers( } }); + // The two click-sound samples the editor ships (src/lib/ai-edition/clickSound.ts). The + // compositor reads its audio from the filesystem, not from a renderer URL, so the bundled + // bytes are staged here once and the absolute path handed back. Renderer bytes like the + // recorded voiceover, but with nothing to sanitise: the name is checked against the two the + // app knows, so the payload cannot steer where anything lands. + ipcMain.handle("stage-click-sound-hit", async (_event, name: string, data: ArrayBuffer) => { + try { + if (!CLICK_SOUND_HIT_FILES.includes(name)) { + return { success: false, message: `Unknown click hit ${name}` }; + } + if (!(data instanceof ArrayBuffer) || data.byteLength === 0) { + return { success: false, message: "Empty click hit" }; + } + if (data.byteLength > MAX_CLICK_HIT_BYTES) { + return { success: false, message: "Click hit too large" }; + } + const target = path.join(CLICK_SOUND_DIR, name); + await fs.mkdir(CLICK_SOUND_DIR, { recursive: true }); + // Written fresh each time rather than checked for existence: the file the app shipped + // is the one the compositor must hear, and an update may have changed it. + await fs.writeFile(target, Buffer.from(data)); + return { success: true, path: target }; + } catch (error) { + console.error("Failed to stage click sound hit:", error); + return { success: false, message: "Failed to stage click sound hit", error: String(error) }; + } + }); + ipcMain.handle("reveal-in-folder", async (_, filePath: string) => { try { // showItemInFolder returns nothing, it throws on error diff --git a/electron/ipc/nativeBridge.presets.test.ts b/electron/ipc/nativeBridge.presets.test.ts index bd8a7f8b9..6f97b66e1 100644 --- a/electron/ipc/nativeBridge.presets.test.ts +++ b/electron/ipc/nativeBridge.presets.test.ts @@ -54,6 +54,8 @@ const APPEARANCE: StylePresetAppearance = { model3d: false, asArrow: [], clickImpact: false, + clickSound: false, + clickSoundGainDb: 0, }, cursorShow: true, cursorAutoHide: false, diff --git a/electron/preload.ts b/electron/preload.ts index feb3942df..2564d6ec4 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -362,6 +362,9 @@ contextBridge.exposeInMainWorld("electronAPI", { saveRecordedVoiceover: (data: ArrayBuffer) => { return ipcRenderer.invoke("save-recorded-voiceover", data); }, + stageClickSoundHit: (name: string, data: ArrayBuffer) => { + return ipcRenderer.invoke("stage-click-sound-hit", name, data); + }, setCurrentVideoPath: (path: string) => { return ipcRenderer.invoke("set-current-video-path", path); }, diff --git a/public/sounds/click-down.wav b/public/sounds/click-down.wav new file mode 100644 index 000000000..301e92158 Binary files /dev/null and b/public/sounds/click-down.wav differ diff --git a/public/sounds/click-up.wav b/public/sounds/click-up.wav new file mode 100644 index 000000000..546e1ec8d Binary files /dev/null and b/public/sounds/click-up.wav differ diff --git a/src/cli/CliExportRunner.tsx b/src/cli/CliExportRunner.tsx index de080359e..85b066851 100644 --- a/src/cli/CliExportRunner.tsx +++ b/src/cli/CliExportRunner.tsx @@ -13,6 +13,7 @@ import { toFileUrl, validateProjectData, } from "@/components/video-editor/projectPersistence"; +import { prepareClickSound } from "@/lib/ai-edition/clickSound"; import { migrateProjectDataToAxcutDocument } from "@/lib/ai-edition/document/migrate"; import { collectEffectiveClipDims, @@ -281,6 +282,10 @@ async function runExport(request: CliExportRequest): Promise { if (builtClips.length === 0) { throw new Error("The project's timeline has no visible clips to export"); } + // The scene carries the click hits, whose cues and staged samples are read out of caches a + // scene build cannot await — without this a cold CLI export builds its scene from empty + // caches and comes out silent. Clicks that will not load must not cost the export. + await prepareClickSound(axcutDocument).catch(() => undefined); const sceneDesc = buildSceneDescription(axcutDocument); // The webcam background effect is applied by the compositor from the scene, so the clip diff --git a/src/components/ai-edition/ExportDialog.tsx b/src/components/ai-edition/ExportDialog.tsx index 47b08b08a..6aa657be8 100644 --- a/src/components/ai-edition/ExportDialog.tsx +++ b/src/components/ai-edition/ExportDialog.tsx @@ -10,6 +10,7 @@ import { Download, FileVideo, FolderOpen, Loader2, Star } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { useScopedT } from "@/contexts/I18nContext"; +import { prepareClickSound } from "@/lib/ai-edition/clickSound"; import { collectEffectiveClipDims, type Dims, @@ -366,6 +367,11 @@ export function ExportDialog({ open, onClose, document }: ExportDialogProps) { // invites. Background/layout/webcam/cursor/effects come from the same scene // as the live preview, so an export can no longer disagree with what the // user previewed. + // + // The click sound's hits are read out of the take's sidecar into a cache the scene build + // reads synchronously, so the read happens here — before anything is composed. Clicks + // that will not load must not cost the user their export. + await prepareClickSound(document).catch(() => undefined); { const job: ActiveExport = { id: format === "gif" ? crypto.randomUUID() : undefined, diff --git a/src/components/ai-edition/RightPanes.tsx b/src/components/ai-edition/RightPanes.tsx index fe1f00bf6..d2e074425 100644 --- a/src/components/ai-edition/RightPanes.tsx +++ b/src/components/ai-edition/RightPanes.tsx @@ -148,6 +148,7 @@ import { import { ROUNDNESS_SLIDER_MAX_PX } from "@/native/paramUnits"; import { wallpaperAcceptsMotion } from "@/native/sceneDescription"; import { ASPECT_RATIO_PRESETS, type AspectRatio } from "@/utils/aspectRatioUtils"; +import { useClickSound } from "../../hooks/useClickSound"; import { useCanSegmentCamera } from "../../native/hooks/useSegmentationSupport"; import { CaptionsPane } from "./CaptionsPane"; import { ColorField } from "./ColorField"; @@ -3684,6 +3685,7 @@ export function CursorPane() { [settings.cursorTheme], ); const recordedTypes = useRecordedCursorTypes(); + const clickSound = useClickSound(); // Each kind the video shows, pictured by the first of its states it shows. const recordedKinds = CURSOR_KIND_IDS.flatMap((kind) => { const type = CURSOR_KINDS[kind].find((state) => recordedTypes?.has(state)); @@ -3923,6 +3925,48 @@ export function CursorPane() { /> ) : null} + {/* The same clicks, heard. Offered only once the take is known to contain some. It is a + setting rather than a track on the timeline: the hits are placed by the same + projection the click zoom uses, so they follow every edit instead of going stale. */} + {settings.cursorShow && clickSound.hasClicks ? ( + <> +
+ {ts("cursor.clickSound")} + { + void set({ cursor: { clickSound: v } }); + // Read the cues and stage the samples on the way in, so the first + // export asked for after this is not a race with a background read. + if (v) void clickSound.prepare(); + }} + /> +
+ {/* How loud. Offered only once there is a sound to turn down, and bounded by the + same table the mixer reads, so the slider cannot ask for a level the export + would flatten. */} + {settings.cursor.clickSound ? ( +
+ setLive({ cursor: { clickSoundGainDb } })} + onCommit={() => void commit()} + /> +
+ ) : null} + + ) : null} ); } diff --git a/src/components/ai-edition/VirtualPreview.tsx b/src/components/ai-edition/VirtualPreview.tsx index beda87868..9c80d9c9f 100644 --- a/src/components/ai-edition/VirtualPreview.tsx +++ b/src/components/ai-edition/VirtualPreview.tsx @@ -4,6 +4,12 @@ import { DEFAULT_CROP_REGION, MAX_NATIVE_PLAYBACK_RATE, } from "@/components/video-editor/types"; +import { + clickHitBuffers, + playClickHits, + resetClickPlayhead, + takeCrossedClickHits, +} from "@/lib/ai-edition/clickSound"; import { collapseTracksToPills, resolveFadeSecs, @@ -1212,6 +1218,30 @@ export function VirtualPreview({ // where a reload has to put the playhead back if it cannot // resolve one from the timeline. lastGoodSourceTimeRef.current = sourceTime; + // The click sound, heard live. Read off the SOURCE clock — the one the cursor sprite + // above is sampled on — because a click is recorded in the take's own seconds: this + // clock races with the picture under a 2x region, so a hit fired where it passes a + // click lands on the click being drawn. A clip swap or a cut moves it by more than a + // frame can, which reads as a seek and fires nothing; and while stopped the anchor is + // dropped, so the next run does not repay a stack of old clicks. + // The cues are the primary take's, so they only belong over that take's picture. + if (v.paused) { + resetClickPlayhead(); + } else { + const crossed = takeCrossedClickHits( + sourceTime, + videoSourcesRef.current[sourceIndexRef.current]?.filePath, + ); + const clickGraph = audioGraphRef.current; + if (crossed.length && clickGraph) { + playClickHits( + clickGraph.context, + clickGraph.gain, + clickHitBuffers(clickGraph.context), + crossed, + ); + } + } } // À L'ARRÊT, le `