From dc268775874bcbb51eb12978857aeb957c2524bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=80=E4=B8=AA=E6=88=90=E7=86=9F=E7=9A=84=E5=89=AA?= =?UTF-8?q?=E8=BE=91=E7=8C=BF?= Date: Mon, 5 Oct 2026 23:07:44 +0800 Subject: [PATCH 1/5] feat(cursor): play a click sound on every recorded click The take's cursor sidecar already carries each click's timestamp, the same table the click bounce and the auto-zoom read. This turns it into sound: the hits travel on the scene description in finished-programme seconds and the compositor sums them, per hit, over the assembled audio. They are placed by the editor's own projection rather than baked into one rendered file, because an imported audio track is moved by a speed region and never stretched: a bed laid down against one edit falls behind every click after it. Nothing to re-render, nothing to keep in step. The cursor pane gets a "Mouse clicks" toggle and a level slider under the click-impact row; the preview plays the same hits live through the audio graph it already runs, so what you hear is what the file gets. Two bundled hits (Kenney "UI Audio", CC0) ship in public/sounds, stripped of leading silence and levelled to a matching peak; the notice file records the set, the pack and the processing. --- THIRD-PARTY-NOTICES.md | 14 + crates/compositor/src/audio.rs | 125 +++++- crates/compositor/src/pipeline_linux.rs | 13 +- crates/compositor/src/pipeline_macos.rs | 13 +- crates/compositor/src/pipeline_windows.rs | 13 +- crates/compositor/src/scene.rs | 59 +++ .../ai-edition/style-preset-service.test.ts | 2 + electron/electron-env.d.ts | 11 + electron/ipc/handlers.ts | 34 ++ electron/ipc/nativeBridge.presets.test.ts | 2 + electron/preload.ts | 3 + public/sounds/click-down.wav | Bin 0 -> 11766 bytes public/sounds/click-up.wav | Bin 0 -> 8998 bytes src/components/ai-edition/ExportDialog.tsx | 6 + src/components/ai-edition/RightPanes.tsx | 44 +++ src/components/ai-edition/VirtualPreview.tsx | 28 ++ src/components/video-editor/editorDefaults.ts | 2 + src/components/video-editor/types.ts | 4 + src/hooks/useClickSound.ts | 58 +++ src/i18n/locales/ar/settings.json | 3 + src/i18n/locales/cs/settings.json | 3 + src/i18n/locales/de/settings.json | 3 + src/i18n/locales/en/settings.json | 3 + src/i18n/locales/es/settings.json | 3 + src/i18n/locales/fr/settings.json | 3 + src/i18n/locales/it/settings.json | 3 + src/i18n/locales/ja-JP/settings.json | 3 + src/i18n/locales/ko-KR/settings.json | 3 + src/i18n/locales/pt-BR/settings.json | 3 + src/i18n/locales/ru/settings.json | 3 + src/i18n/locales/tr/settings.json | 3 + src/i18n/locales/vi/settings.json | 3 + src/i18n/locales/zh-CN/settings.json | 3 + src/i18n/locales/zh-TW/settings.json | 3 + src/lib/ai-edition/clickSound.test.ts | 150 ++++++++ src/lib/ai-edition/clickSound.ts | 364 ++++++++++++++++++ src/lib/ai-edition/store/editorSettings.ts | 10 + src/lib/ai-edition/stylePresets.test.ts | 19 +- src/lib/ai-edition/stylePresets.ts | 13 + src/lib/ai-edition/stylePresetsEditor.test.ts | 2 + src/lib/ai-edition/stylePresetsEditor.ts | 2 + src/lib/projectDefaults.ts | 7 + src/native/browserShim.presets.test.ts | 2 + src/native/sceneDescription.ts | 22 +- 44 files changed, 1057 insertions(+), 10 deletions(-) create mode 100644 public/sounds/click-down.wav create mode 100644 public/sounds/click-up.wav create mode 100644 src/hooks/useClickSound.ts create mode 100644 src/lib/ai-edition/clickSound.test.ts create mode 100644 src/lib/ai-edition/clickSound.ts 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 0000000000000000000000000000000000000000..301e921589bff352f850c4edb6e866a01f5fcf38 GIT binary patch literal 11766 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 literal 0 HcmV?d00001 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..9ac772540 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,28 @@ 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. + // ponytail: the cues are the primary take's, so a project that interleaves several + // takes would need the mounted asset's id here to pick the right list. + if (v.paused) { + resetClickPlayhead(); + } else { + const crossed = takeCrossedClickHits(sourceTime); + const clickGraph = audioGraphRef.current; + if (crossed.length && clickGraph) { + playClickHits( + clickGraph.context, + clickGraph.gain, + clickHitBuffers(clickGraph.context), + crossed, + ); + } + } } // À L'ARRÊT, le `