diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 1e7835b..552e9a4 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -14,8 +14,8 @@ use std::sync::{Arc, Mutex}; use palaclips_engine::EngineConfig; use palaclips_engine::engine::{Engine, EngineHandle, LevelRate, track_descriptors}; use palaclips_engine::events::{ - CAPTURE_ALERTS, EngineEvent, EngineState, Localized, Severity, SourceMode, TrackDescriptor, - TrackKind, TrackState, + CAPTURE_ALERTS, EngineEvent, EngineState, Localized, SaveOrigin, Severity, SourceMode, + TrackDescriptor, TrackKind, TrackState, }; use serde::Serialize; use tauri::menu::{Menu, MenuItem}; @@ -574,12 +574,18 @@ impl EngineSlot { EngineEvent::ClipSaved { path, duration_secs, + origin, .. } => { self.library_changed(app, &[std::path::PathBuf::from(path)]); let feedback = self.feedback(); - if feedback.sound { - feedback::play(feedback::Sound::Saved); + let sound = match origin { + SaveOrigin::Replay => Some(feedback::Sound::Saved), + SaveOrigin::PostRoll => Some(feedback::Sound::ClipFinished), + SaveOrigin::Recording => None, + }; + if let Some(sound) = sound.filter(|_| feedback.sound) { + feedback::play(sound); } if feedback.toast { let game = std::path::Path::new(path) @@ -1306,6 +1312,7 @@ mod tests { duration_secs: 12.0, save_latency_ms: 20, thumbnail: None, + origin: SaveOrigin::Replay, }); assert!(status.alerts.is_empty()); } diff --git a/app/src/lib/ipc.ts b/app/src/lib/ipc.ts index 5e2f3bc..6118eb6 100644 --- a/app/src/lib/ipc.ts +++ b/app/src/lib/ipc.ts @@ -768,6 +768,7 @@ export type EngineEvent = duration_secs: number; save_latency_ms: number; thumbnail: string | null; + origin: "replay" | "post_roll" | "recording"; } | { type: "audio_levels"; levels: number[] } | { type: "replay_stats"; buffered_secs: number; bytes: number } diff --git a/crates/cli/src/replay.rs b/crates/cli/src/replay.rs index 11d3192..de311a8 100644 --- a/crates/cli/src/replay.rs +++ b/crates/cli/src/replay.rs @@ -10,7 +10,7 @@ use palaclips_engine::config::{ AppTrack, AudioSettings, RecordSettings, RecordSplit, ReplaySettings, ReplayStorage, }; use palaclips_engine::engine::{Engine, LevelRate}; -use palaclips_engine::events::{EngineEvent, EngineState}; +use palaclips_engine::events::{EngineEvent, EngineState, SaveOrigin}; use palaclips_engine::paths::private_bytes; const STATS_EVERY: u32 = 5; @@ -253,10 +253,14 @@ pub fn run_replay(args: &ReplayArgs) -> Result<()> { recv(engine.events()) -> event => { let Ok(event) = event else { break }; match event { - EngineEvent::ClipSaved { path, duration_secs, save_latency_ms, .. } => { + EngineEvent::ClipSaved { path, duration_secs, save_latency_ms, origin, .. } => { finished += 1; latencies.push(save_latency_ms); - chime_on(Sound::Saved); + match origin { + SaveOrigin::Replay => chime_on(Sound::Saved), + SaveOrigin::PostRoll => chime_on(Sound::ClipFinished), + SaveOrigin::Recording => {} + } println!( "[{:>6} s] clip {finished} : {duration_secs:.3} s, prêt en {save_latency_ms} ms ; mémoire privée {:.1} Mo → {path}", started.elapsed().as_secs(), diff --git a/crates/engine/src/chime.rs b/crates/engine/src/chime.rs index 378dec3..fc0e638 100644 --- a/crates/engine/src/chime.rs +++ b/crates/engine/src/chime.rs @@ -9,6 +9,7 @@ pub enum Sound { Saved, RecordingStarted, RecordingStopped, + ClipFinished, } impl Sound { @@ -17,6 +18,11 @@ impl Sound { Self::Saved => &[(880.0, 0.0, 0.11), (1318.5, 0.075, 0.2)], Self::RecordingStarted => &[(523.25, 0.0, 0.1), (783.99, 0.075, 0.2)], Self::RecordingStopped => &[(783.99, 0.0, 0.1), (523.25, 0.075, 0.2)], + Self::ClipFinished => &[ + (880.0, 0.0, 0.1), + (1318.5, 0.075, 0.1), + (1760.0, 0.15, 0.22), + ], } } @@ -25,6 +31,7 @@ impl Sound { Self::Saved => 0, Self::RecordingStarted => 1, Self::RecordingStopped => 2, + Self::ClipFinished => 3, } } } @@ -80,7 +87,7 @@ pub fn play(sound: Sound) { use windows::Win32::Media::Audio::{PlaySoundW, SND_ASYNC, SND_MEMORY, SND_NODEFAULT}; use windows::core::PCWSTR; - static SOUNDS: [OnceLock>; 3] = [OnceLock::new(), OnceLock::new(), OnceLock::new()]; + static SOUNDS: [OnceLock>; 4] = [const { OnceLock::new() }; 4]; let chime = SOUNDS[sound.index()].get_or_init(|| wav(&sound_samples(sound.notes()))); // SAFETY: SND_MEMORY reads the WAV from `chime`, a static buffer that outlives the // asynchronous playback. @@ -100,13 +107,39 @@ pub fn play(sound: Sound) { mod tests { use super::*; + const ALL: [Sound; 4] = [ + Sound::Saved, + Sound::RecordingStarted, + Sound::RecordingStopped, + Sound::ClipFinished, + ]; + + #[test] + fn all_sounds_differ() { + let samples: Vec<_> = ALL.map(|sound| sound_samples(sound.notes())).into(); + for (index, sound) in samples.iter().enumerate() { + for other in &samples[index + 1..] { + assert_ne!(sound, other); + } + } + let indices: std::collections::HashSet<_> = ALL.map(Sound::index).into(); + assert_eq!(indices.len(), ALL.len()); + } + #[test] - fn recording_sounds_differ_from_the_save_chime() { - let saved = sound_samples(Sound::Saved.notes()); - let started = sound_samples(Sound::RecordingStarted.notes()); - let stopped = sound_samples(Sound::RecordingStopped.notes()); - assert_ne!(saved, started); - assert_ne!(started, stopped); + fn the_finished_clip_sound_keeps_the_timbre_and_volume_of_the_chime() { + let peak = |sound: Sound| { + sound_samples(sound.notes()) + .iter() + .map(|sample| sample.unsigned_abs()) + .max() + .unwrap_or(0) + }; + let saved = f32::from(peak(Sound::Saved)); + let finished = f32::from(peak(Sound::ClipFinished)); + assert!((finished / saved - 1.0).abs() < 0.15, "{saved} {finished}"); + let seconds = sound_samples(Sound::ClipFinished.notes()).len() as f32 / SAMPLE_RATE as f32; + assert!(seconds < 0.4, "{seconds}"); } #[test] diff --git a/crates/engine/src/engine.rs b/crates/engine/src/engine.rs index c9e7384..2e2913f 100644 --- a/crates/engine/src/engine.rs +++ b/crates/engine/src/engine.rs @@ -25,8 +25,8 @@ use crate::encode::{ }; use crate::error::{EngineError, Result}; use crate::events::{ - CapturePerformance, EngineEvent, EngineState, Localized, Severity, SourceMode, TrackDescriptor, - TrackKind, + CapturePerformance, EngineEvent, EngineState, Localized, SaveOrigin, Severity, SourceMode, + TrackDescriptor, TrackKind, }; use crate::library::ensure_free_space; use crate::mux::{Mp4Writer, MuxStream}; @@ -1828,6 +1828,7 @@ impl SaveJob { duration_secs: self.duration.as_secs_f64(), save_latency_ms: (latency.0 / 10_000) as u64, thumbnail, + origin: SaveOrigin::Replay, }) } } diff --git a/crates/engine/src/events.rs b/crates/engine/src/events.rs index b3d5b82..cbd40b4 100644 --- a/crates/engine/src/events.rs +++ b/crates/engine/src/events.rs @@ -87,6 +87,14 @@ pub enum TrackState { Unavailable, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum SaveOrigin { + Replay, + PostRoll, + Recording, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] pub struct SourceMode { pub width: u32, @@ -129,6 +137,7 @@ pub enum EngineEvent { duration_secs: f64, save_latency_ms: u64, thumbnail: Option, + origin: SaveOrigin, }, AudioLevels { levels: Vec, diff --git a/crates/engine/src/record.rs b/crates/engine/src/record.rs index 889b395..a459190 100644 --- a/crates/engine/src/record.rs +++ b/crates/engine/src/record.rs @@ -14,7 +14,7 @@ use crate::clock::{HNS_PER_SECOND, Hns}; use crate::config::RecordSplit; use crate::encode::{route_ffmpeg_logs, write_thumbnail}; use crate::error::{EngineError, Result}; -use crate::events::{EngineEvent, Localized, Severity}; +use crate::events::{EngineEvent, Localized, SaveOrigin, Severity}; use crate::library::{STOP_FREE_BYTES, WARN_FREE_BYTES, disk_space}; use crate::mux::{Mp4Writer, MuxStream}; use crate::paths; @@ -347,7 +347,12 @@ impl Segment { self.writer.write(stream, packet) } - fn finish(self, layout: &RecordLayout, journal: &Journal) -> Result { + fn finish( + self, + layout: &RecordLayout, + journal: &Journal, + kind: RecordKind, + ) -> Result { self.writer.finish()?; journal.remove(&self.path)?; let duration = self.newest_video + layout.frame - self.start; @@ -371,6 +376,10 @@ impl Segment { duration_secs: duration.as_secs_f64(), save_latency_ms: 0, thumbnail, + origin: match kind { + RecordKind::Recording => SaveOrigin::Recording, + RecordKind::Replay => SaveOrigin::PostRoll, + }, }) } } @@ -457,7 +466,7 @@ impl Writer { fn close_previous(&mut self) -> Result<()> { self.splitter.close_previous(); if let Some(previous) = self.previous.take() { - let event = previous.finish(&self.layout, &self.journal)?; + let event = previous.finish(&self.layout, &self.journal, self.target.kind)?; self.events.try_send(event).ok(); } Ok(()) @@ -465,7 +474,9 @@ impl Writer { fn finish(mut self) -> Result<()> { let previous = self.close_previous(); - let event = self.current.finish(&self.layout, &self.journal)?; + let event = self + .current + .finish(&self.layout, &self.journal, self.target.kind)?; self.events.try_send(event).ok(); previous } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 03de870..a09a4bd 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -360,7 +360,7 @@ aussi l'`AppStatus` renvoyé par `get_state`. `tracks_renamed { tracks: [{ title, kind }] }` (nom de piste ou langue changés pendant le replay : titres mis à jour sans redémarrage, appliqués au prochain clip), `game_named { exe_name, name }` (nom de jeu choisi et figé, ADR-024 ; l'app l'ajoute à `settings.json`), `track_changed { track, title, state: waiting | capturing | silent | unavailable, source }` (appli ou -périphérique rattaché ; émis à chaque changement), `clip_saved`, `audio_levels { levels }` (crête linéaire 0–1 de chaque piste depuis +périphérique rattaché ; émis à chaque changement), `clip_saved { path, duration_secs, save_latency_ms, thumbnail, origin }` (`origin` = `replay`, `post_roll` ou `recording` : le shell joue le carillon pour un replay, le son « clip terminé » à la fin d'un « après », rien pour un enregistrement, qui a déjà son son de fin), `audio_levels { levels }` (crête linéaire 0–1 de chaque piste depuis le relevé précédent, un nombre par piste dans l'ordre des pistes : le front indexe par numéro de piste, jamais par titre ; 20 Hz fenêtre focalisée, 10 Hz sans focus, coupé fenêtre réduite ou fermée : `EngineHandle::set_level_rate`, piloté par les événements de la diff --git a/docs/SPEC.md b/docs/SPEC.md index 9b2b260..364f780 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -77,7 +77,7 @@ sans lui, encodage par le processeur (ENC-06, v1.1). | RPL-07 | Changer de sources ou de zone redémarre le buffer ; l'UI le dit avant d'appliquer. | | RPL-08 | RAM stable en replay armé : pas de croissance avec le temps ni avec le nombre de sauvegardes. | | RPL-09 | Deux sauvegardes successives ne se répètent pas : le clip suivant commence où le précédent s'est arrêté (au plus un intervalle d'images clés en commun) ; « En mémoire » repart de zéro après chaque sauvegarde. Moins de 3 s en mémoire : pas de clip, message discret dans l'app (ni notification ni son en jeu). | -| RPL-10 | **Durée « après »** (v1.2) : 0 s par défaut, préréglages 10 s, 30 s, 1 min, 5 min ou valeur libre jusqu'à 10 min, appliquée au raccourci de sauvegarde. L'avant est écrit tout de suite dans un MP4 hybride, puis le direct y est ajouté jusqu'à la fin de la durée (aucune RAM en plus, fichier récupérable après un arrêt brutal). Carillon et notification « Clip en cours, +X s » à l'appui, « Clip sauvegardé » à la fin ; un nouvel appui prolonge le même clip ; « Terminer maintenant » sur État et dans la zone de notification. Pas de trou ni d'image en double à la jonction, synchro A/V au niveau des clips actuels. | +| RPL-10 | **Durée « après »** (v1.2) : 0 s par défaut, préréglages 10 s, 30 s, 1 min, 5 min ou valeur libre jusqu'à 10 min, appliquée au raccourci de sauvegarde. L'avant est écrit tout de suite dans un MP4 hybride, puis le direct y est ajouté jusqu'à la fin de la durée (aucune RAM en plus, fichier récupérable après un arrêt brutal). Carillon et notification « Clip en cours, +X s » à l'appui, son « clip terminé » (v1.4, distinct du carillon et des sons d'enregistrement, même réglage « Son de confirmation ») et notification « Clip sauvegardé » à la fin ; un nouvel appui prolonge le même clip ; « Terminer maintenant » sur État et dans la zone de notification. Pas de trou ni d'image en double à la jonction, synchro A/V au niveau des clips actuels. | ### 2.5 Enregistrement manuel (REC) diff --git a/scripts/find-chimes.py b/scripts/find-chimes.py index e135384..93eb9f7 100644 --- a/scripts/find-chimes.py +++ b/scripts/find-chimes.py @@ -18,6 +18,7 @@ "sauvegarde": [(880.0, 0.0, 0.11), (1318.5, 0.075, 0.2)], "début d'enregistrement": [(523.25, 0.0, 0.1), (783.99, 0.075, 0.2)], "fin d'enregistrement": [(783.99, 0.0, 0.1), (523.25, 0.075, 0.2)], + "clip terminé": [(880.0, 0.0, 0.1), (1318.5, 0.075, 0.1), (1760.0, 0.15, 0.22)], }