diff --git a/crates/engine/src/cmd/transition.rs b/crates/engine/src/cmd/transition.rs index 28db7607..4c798125 100644 --- a/crates/engine/src/cmd/transition.rs +++ b/crates/engine/src/cmd/transition.rs @@ -35,7 +35,7 @@ pub fn specs() -> Vec { "Sound", ["Transitions", "Timing"], None, - "{path? | data: base64, name?; none?: true (No Sound); index?} — the sound plays as the slide comes in", + "{builtin?: chime|click|camera|whoosh|drumRoll|laser | path? | data: base64, name?; none?: true (No Sound); stopPrevious?: true; loop?: bool (Loop Until Next Sound); index?} — the sound plays as the slide comes in", has_slide, sound ), @@ -69,7 +69,11 @@ fn set(s: &mut Session, p: &Value) -> Result { t.option = option.clone(); t.duration_ms = duration.unwrap_or(info.3); t.raw = None; - sl.transition = if info.0 == "none" && t.advance_after_ms.is_none() && t.advance_on_click { None } else { Some(t) }; + sl.transition = if info.0 == "none" && t.advance_after_ms.is_none() && t.advance_on_click && t.sound.is_none() && !t.stop_sound { + None + } else { + Some(t) + }; } } Ok(()) @@ -120,20 +124,51 @@ fn timing(s: &mut Session, p: &Value) -> Result { /// Transitions › Sound: embed an audio file as the slides' transition sound, or `none` to remove it. fn sound(s: &mut Session, p: &Value) -> Result { let list = slide_indices(s, p)?; - if bool_param(p, "none") == Some(true) { + let looping = bool_param(p, "loop"); + let edit_all = |s: &mut Session, f: &dyn Fn(&mut Transition)| { s.edit(|doc, _| { - for i in list { - if let Some(sl) = doc.slides.get_mut(i) - && let Some(t) = Arc::make_mut(sl).transition.as_mut() - { - t.sound = None; + for &i in &list { + if let Some(sl) = doc.slides.get_mut(i) { + f(Arc::make_mut(sl).transition.get_or_insert_with(Transition::default)); } } Ok(()) + }) + }; + if bool_param(p, "none") == Some(true) { + edit_all(s, &|t| { + t.sound = None; + t.stop_sound = false; + t.sound_loop = false; })?; return Ok(json!({"sound": null})); } - let (name, bytes) = super::insert::media_bytes(p, "transition.sound")?; + if bool_param(p, "stopPrevious") == Some(true) { + edit_all(s, &|t| { + t.sound = None; + t.sound_loop = false; + t.stop_sound = true; + })?; + return Ok(json!({"sound": null, "stopPrevious": true})); + } + let wants_file = p.get("path").is_some() || p.get("data").is_some() || p.get("builtin").is_some(); + if !wants_file { + // Only Loop Until Next Sound changes. + let Some(l) = looping else { return Err(bad("transition.sound", "give `path`, `data`, `builtin`, `none`, `stopPrevious` or `loop`")) }; + edit_all(s, &|t| t.sound_loop = l && t.sound.is_some())?; + return Ok(json!({"loop": l})); + } + let (name, bytes) = match str_param(p, "builtin") { + Some(id) => { + let (id, label) = crate::sounds::BUILTIN.iter().find(|b| b.0.eq_ignore_ascii_case(id)).ok_or_else(|| { + let ids: Vec<&str> = crate::sounds::BUILTIN.iter().map(|b| b.0).collect(); + bad("transition.sound", format!("unknown built-in sound `{id}` (one of {})", ids.join(", "))) + })?; + let bytes = crate::sounds::builtin(id).ok_or_else(|| bad("transition.sound", "built-in sound failed"))?; + (format!("{label}.wav"), bytes) + } + None => super::insert::media_bytes(p, "transition.sound")?, + }; let ct = super::insert::content_type(&name, &bytes); let bytes = Arc::new(bytes); let audible = deckcraft_media::probe(&bytes).is_ok_and(|i| i.audio.is_some()); @@ -141,16 +176,25 @@ fn sound(s: &mut Session, p: &Value) -> Result { if !audible && !ct.starts_with("audio/") { return Err(bad("transition.sound", format!("{name} is not a sound file ({ct})"))); } + let lp = looping.unwrap_or(false); let media = s.edit(|doc, _| { - let media = doc.add_media(&name, ct, bytes); - for i in list { + // The same built-in sound is embedded once. + let existing = doc.media.iter().find(|m| m.name == name && m.data.as_slice() == bytes.as_slice()).map(|m| m.id); + let media = match existing { + Some(m) => m, + None => doc.add_media(&name, ct, bytes), + }; + for &i in &list { if let Some(sl) = doc.slides.get_mut(i) { - Arc::make_mut(sl).transition.get_or_insert_with(Transition::default).sound = Some(media); + let t = Arc::make_mut(sl).transition.get_or_insert_with(Transition::default); + t.sound = Some(media); + t.stop_sound = false; + t.sound_loop = lp; } } Ok(media) })?; - Ok(json!({"sound": name, "media": media.0, "playable": audible})) + Ok(json!({"sound": name, "media": media.0, "playable": audible, "loop": lp})) } fn apply_all(s: &mut Session, _p: &Value) -> Result { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c2b56c7f..bda4d182 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -15,6 +15,7 @@ pub mod keys; pub mod links; pub mod recovery; pub mod sample; +pub mod sounds; pub mod tools; use std::sync::Arc; diff --git a/crates/engine/src/sounds.rs b/crates/engine/src/sounds.rs new file mode 100644 index 00000000..8b97c758 --- /dev/null +++ b/crates/engine/src/sounds.rs @@ -0,0 +1,107 @@ +//! Built-in transition sounds, synthesised on demand so DeckCraft ships no sound files: short +//! mono 16-bit WAVs named like PowerPoint's (Chime, Click, Camera, Whoosh, Drum Roll, Laser). + +use std::f64::consts::TAU; + +/// (id, label) of the built-in sounds, in menu order. +pub const BUILTIN: [(&str, &str); 6] = + [("chime", "Chime"), ("click", "Click"), ("camera", "Camera"), ("whoosh", "Whoosh"), ("drumRoll", "Drum Roll"), ("laser", "Laser")]; + +const RATE: u32 = 22_050; + +/// The WAV bytes of built-in sound `id`, or `None` for an unknown id. +pub fn builtin(id: &str) -> Option> { + let mut noise = Lcg(0x9E37_79B9); + let samples: Vec = match id { + "chime" => tone(1.2, |t| { + let env = (-t * 3.0).exp() * attack(t, 0.005); + env * (0.6 * (TAU * 880.0 * t).sin() + 0.3 * (TAU * 1318.5 * t).sin() + 0.15 * (TAU * 1760.0 * t).sin()) + }), + "click" => tone(0.03, |t| (-t * 300.0).exp() * noise.next()), + "camera" => tone(0.35, |t| { + let a = (-t * 250.0).exp(); + let b = if t > 0.18 { (-(t - 0.18) * 180.0).exp() } else { 0.0 }; + (a + 0.8 * b) * noise.next() + }), + "whoosh" => { + let mut lp = 0.0; + tone(0.7, |t| { + // Noise through a low-pass filter that opens and closes. + let k = 0.02 + 0.25 * (std::f64::consts::PI * t / 0.7).sin(); + lp += k * (noise.next() - lp); + 3.0 * lp * (std::f64::consts::PI * t / 0.7).sin() + }) + } + "drumRoll" => tone(1.4, |t| { + let beat = (t * 24.0).fract() / 24.0; + let swell = 0.4 + 0.6 * (t / 1.4); + swell * (-beat * 90.0).exp() * (0.7 * noise.next() + 0.5 * (TAU * 110.0 * t).sin()) + }), + "laser" => { + let mut phase = 0.0; + tone(0.5, |t| { + phase += TAU * (2400.0 * (-t * 5.0).exp() + 200.0) / RATE as f64; + (-t * 4.0).exp() * attack(t, 0.003) * phase.sin().signum() * 0.35 + }) + } + _ => return None, + }; + Some(wav(&samples)) +} + +fn attack(t: f64, len: f64) -> f64 { + (t / len).min(1.0) +} + +fn tone(secs: f64, mut f: impl FnMut(f64) -> f64) -> Vec { + let n = (secs * RATE as f64) as usize; + (0..n).map(|i| f(i as f64 / RATE as f64)).collect() +} + +/// A small deterministic noise source (-1..1). +struct Lcg(u32); +impl Lcg { + fn next(&mut self) -> f64 { + self.0 = self.0.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + (self.0 >> 8) as f64 / (1u32 << 23) as f64 - 1.0 + } +} + +fn wav(samples: &[f64]) -> Vec { + let data_len = (samples.len() * 2) as u32; + let mut v = Vec::with_capacity(44 + data_len as usize); + v.extend_from_slice(b"RIFF"); + v.extend_from_slice(&(36 + data_len).to_le_bytes()); + v.extend_from_slice(b"WAVEfmt "); + v.extend_from_slice(&16u32.to_le_bytes()); + v.extend_from_slice(&1u16.to_le_bytes()); // PCM + v.extend_from_slice(&1u16.to_le_bytes()); // mono + v.extend_from_slice(&RATE.to_le_bytes()); + v.extend_from_slice(&(RATE * 2).to_le_bytes()); + v.extend_from_slice(&2u16.to_le_bytes()); + v.extend_from_slice(&16u16.to_le_bytes()); + v.extend_from_slice(b"data"); + v.extend_from_slice(&data_len.to_le_bytes()); + for s in samples { + let x = (s.clamp(-1.0, 1.0) * 0.8 * i16::MAX as f64) as i16; + v.extend_from_slice(&x.to_le_bytes()); + } + v +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_builtin_sound_is_a_playable_wav() { + for (id, _) in BUILTIN { + let bytes = builtin(id).unwrap_or_default(); + assert!(bytes.len() > 44, "{id}"); + let info = deckcraft_media::probe(&std::sync::Arc::new(bytes)).expect(id); + assert!(info.audio.is_some() && info.playable(), "{id}"); + assert!(info.duration() > 0.01 && info.duration() < 2.0, "{id}: {}", info.duration()); + } + assert!(builtin("nope").is_none()); + } +} diff --git a/crates/model/src/anim.rs b/crates/model/src/anim.rs index 7c45aa0f..ff5bf6fa 100644 --- a/crates/model/src/anim.rs +++ b/crates/model/src/anim.rs @@ -21,6 +21,12 @@ pub struct Transition { pub advance_after_ms: Option, /// Embedded sound media. pub sound: Option, + /// Loop Until Next Sound (`p:stSnd loop="1"`). + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub sound_loop: bool, + /// [Stop Previous Sound] (`p:endSnd`): the slide stops a sound still playing. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub stop_sound: bool, /// Unmodelled transition XML kept for round-trip. #[serde(skip_serializing_if = "Option::is_none")] pub raw: Option, @@ -35,6 +41,8 @@ impl Default for Transition { advance_on_click: true, advance_after_ms: None, sound: None, + sound_loop: false, + stop_sound: false, raw: None, } } diff --git a/crates/pptx/src/read/timing.rs b/crates/pptx/src/read/timing.rs index 500bb485..e50f79b6 100644 --- a/crates/pptx/src/read/timing.rs +++ b/crates/pptx/src/read/timing.rs @@ -41,8 +41,10 @@ pub fn transition(imp: &mut Imp, part: &Part, t: &El) -> Option { }); if let Some(snd) = t.path(&["sndAc", "stSnd", "snd"]) { tr.sound = dml::media_ref(imp, part, snd); + tr.sound_loop = t.path(&["sndAc", "stSnd"]).and_then(|s| s.attr("loop")).is_some_and(|v| v == "1" || v == "true"); } - if tr.kind == "none" && tr.advance_on_click && tr.advance_after_ms.is_none() && tr.sound.is_none() { + tr.stop_sound = t.path(&["sndAc", "endSnd"]).is_some(); + if tr.kind == "none" && tr.advance_on_click && tr.advance_after_ms.is_none() && tr.sound.is_none() && !tr.stop_sound { return None; } Some(tr) diff --git a/crates/pptx/src/write/timing.rs b/crates/pptx/src/write/timing.rs index aa577ca7..0832f492 100644 --- a/crates/pptx/src/write/timing.rs +++ b/crates/pptx/src/write/timing.rs @@ -16,14 +16,19 @@ pub fn transition(w: &mut W, x: &mut Exp, o: &mut Out, t: &Transition) { 601..=900 => "med", _ => "slow", }; - let sound = t.sound.and_then(|m| x.media_rel(o, m, "audio")); + let sound = t.sound.and_then(|m| x.media_rel(o, m, "audio").map(|r| (r, x.p.media(m).map(|i| i.name.clone()).unwrap_or_default()))); let snd = |w: &mut W| { - if let Some(r) = &sound { + if let Some((r, name)) = &sound { w.open0("p:sndAc"); - w.open0("p:stSnd"); - w.empty("p:snd", A::new().a("r:embed", r).a("name", "sound.wav")); + w.open("p:stSnd", A::new().t("loop", t.sound_loop)); + let name = if name.is_empty() { "sound.wav" } else { name.as_str() }; + w.empty("p:snd", A::new().a("r:embed", r).a("name", name)); w.close("p:stSnd"); w.close("p:sndAc"); + } else if t.stop_sound { + w.open0("p:sndAc"); + w.empty0("p:endSnd"); + w.close("p:sndAc"); } }; let mut effect = W::frag(); diff --git a/crates/pptx/tests/roundtrip.rs b/crates/pptx/tests/roundtrip.rs index f9c8bc91..0542db18 100644 --- a/crates/pptx/tests/roundtrip.rs +++ b/crates/pptx/tests/roundtrip.rs @@ -1036,3 +1036,38 @@ fn transition_sound_is_set_saved_and_removed() { let not_audio = deckcraft_engine::cmd::base64_encode(b"hello, not a sound"); assert!(s.execute("transition.sound", &json!({"data": not_audio, "name": "notes.txt"})).is_err()); } + +/// Built-in transition sounds, Loop Until Next Sound and [Stop Previous Sound] are set, saved as +/// PowerPoint writes them (`p:stSnd loop="1"`, `p:endSnd`) and read back. +#[test] +fn transition_sound_options_round_trip() { + use deckcraft_engine::Session; + use serde_json::json; + let mut s = Session::with_new(); + s.execute("slide.new", &json!({})).unwrap(); + s.execute("slide.new", &json!({})).unwrap(); + s.execute("transition.sound", &json!({"builtin": "chime", "loop": true, "index": 0})).unwrap(); + s.execute("transition.sound", &json!({"builtin": "Chime", "index": 1})).unwrap(); + s.execute("transition.sound", &json!({"stopPrevious": true, "index": 2})).unwrap(); + let doc = s.doc().unwrap().doc.clone(); + assert_eq!(doc.media.len(), 1, "the same built-in sound is embedded once"); + assert_eq!(doc.media[0].name, "Chime.wav"); + let bytes = deckcraft_pptx::export(&doc).unwrap(); + let slide1 = { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap(); + let mut x = String::new(); + std::io::Read::read_to_string(&mut z.by_name("ppt/slides/slide1.xml").unwrap(), &mut x).unwrap(); + x + }; + assert!(slide1.contains(r#""#) && slide1.contains(r#"name="Chime.wav""#), "{slide1}"); + let back = deckcraft_pptx::import(&bytes).unwrap(); + let t: Vec<_> = back.slides.iter().map(|s| s.transition.clone().unwrap_or_default()).collect(); + assert!(t[0].sound.is_some() && t[0].sound_loop && !t[0].stop_sound); + assert!(t[1].sound.is_some() && !t[1].sound_loop); + assert!(t[2].sound.is_none() && t[2].stop_sound, "{:?}", t[2]); + // Turning the loop off alone keeps the sound. + s.execute("transition.sound", &json!({"loop": false, "index": 0})).unwrap(); + let t0 = s.doc().unwrap().doc.slides[0].transition.clone().unwrap(); + assert!(t0.sound.is_some() && !t0.sound_loop); + assert!(s.execute("transition.sound", &json!({"builtin": "foghorn"})).is_err()); +} diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 90535b56..7e931890 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1393,21 +1393,40 @@ fn transitions(app: &mut SlideApp, ui: &mut Ui) { rows(ui, |ui| { ui.horizontal(|ui| { ui.label("Sound:"); + let stop = cur.as_ref().is_some_and(|t| t.stop_sound); let name = cur .as_ref() .and_then(|t| t.sound) - .and_then(|m| app.session.active().and_then(|d| d.doc.media(m).map(|i| i.name.clone()))) - .unwrap_or_else(|| "[No Sound]".into()); - egui::ComboBox::from_id_salt("transition_sound").width(120.0).selected_text(name).show_ui(ui, |ui| { - if ui.selectable_label(cur.as_ref().is_none_or(|t| t.sound.is_none()), "[No Sound]").clicked() { + .and_then(|m| app.session.active().and_then(|d| d.doc.media(m).map(|i| i.name.trim_end_matches(".wav").to_string()))) + .unwrap_or_else(|| if stop { "[Stop Previous Sound]".into() } else { "[No Sound]".into() }); + egui::ComboBox::from_id_salt("transition_sound").width(140.0).selected_text(name).show_ui(ui, |ui| { + let none = cur.as_ref().is_none_or(|t| t.sound.is_none() && !t.stop_sound); + if ui.selectable_label(none, "[No Sound]").clicked() { run(app, "transition.sound", json!({"none": true})); } + if ui.selectable_label(stop, "[Stop Previous Sound]").clicked() { + run(app, "transition.sound", json!({"stopPrevious": true})); + } + ui.separator(); + for (id, label) in deckcraft_engine::sounds::BUILTIN { + if ui.button(label).clicked() { + let lp = cur.as_ref().is_some_and(|t| t.sound_loop); + run(app, "transition.sound", json!({"builtin": id, "loop": lp})); + } + } + ui.separator(); let can_pick = app.services.pick_open.is_some(); let r = ui.add_enabled(can_pick, egui::Button::new("Other Sound…")); if r.clicked() { app.pick_transition_sound(); } r.on_disabled_hover_text("Choosing a sound file needs the desktop app"); + ui.separator(); + let has = cur.as_ref().is_some_and(|t| t.sound.is_some()); + let mut lp = cur.as_ref().is_some_and(|t| t.sound_loop); + if ui.add_enabled(has, egui::Checkbox::new(&mut lp, "Loop Until Next Sound")).changed() { + run(app, "transition.sound", json!({"loop": lp})); + } }); }); }); diff --git a/crates/ui-egui/src/show.rs b/crates/ui-egui/src/show.rs index aee3168a..275861a6 100644 --- a/crates/ui-egui/src/show.rs +++ b/crates/ui-egui/src/show.rs @@ -697,7 +697,11 @@ fn show_media(app: &mut SlideApp, show: &mut Show, doc: &Presentation) { return; } show.media_slide = cur; - let sound = cur.and_then(|i| doc.slides.get(i)).and_then(|s| s.transition.as_ref()).and_then(|t| t.sound); + let trans = cur.and_then(|i| doc.slides.get(i)).and_then(|s| s.transition.as_ref()); + let sound = trans.and_then(|t| t.sound); + let sound_loop = trans.is_some_and(|t| t.sound_loop); + // [Stop Previous Sound] ends a transition sound still playing. + let stop_previous = trans.is_some_and(|t| t.stop_sound); let keep: Vec = match cur { Some(_) => app .media @@ -705,7 +709,7 @@ fn show_media(app: &mut SlideApp, show: &mut Show, doc: &Presentation) { .into_iter() .filter(|id| crate::media::find_clip(doc, *id).is_some_and(|c| c.play_across_slides)) // A transition sound plays to its end, as in PowerPoint, unless the new slide has its own. - .chain(sound.is_none().then_some(TRANSITION_SOUND).filter(|id| app.media.is_playing(*id))) + .chain((sound.is_none() && !stop_previous).then_some(TRANSITION_SOUND).filter(|id| app.media.is_playing(*id))) .collect(), None => vec![], }; @@ -715,7 +719,8 @@ fn show_media(app: &mut SlideApp, show: &mut Show, doc: &Presentation) { } } if let Some(media) = sound { - let clip = deckcraft_model::MediaClip { media, volume: 1.0, ..Default::default() }; + // Loop Until Next Sound: it repeats until another slide's sound or Stop Previous Sound. + let clip = deckcraft_model::MediaClip { media, volume: 1.0, loop_play: sound_loop, ..Default::default() }; app.media.play(doc, TRANSITION_SOUND, &clip, None, crate::media::Owner::Show); } if let Some(i) = cur { diff --git a/docs/animation-parity.md b/docs/animation-parity.md index 436a0dee..1ff7d0bd 100644 --- a/docs/animation-parity.md +++ b/docs/animation-parity.md @@ -39,7 +39,7 @@ perspective-correct subdivided meshes. |---|---|---| | Effect Options per transition | yes | yes | | Duration, advance on click / after time, Apply To All | yes | yes | -| Transition sounds | yes | partly: Transitions › Sound › Other Sound… embeds a file, it plays as the slide comes in and round-trips; no built-in sounds, Stop Previous Sound or Loop Until Next Sound | +| Transition sounds | yes | yes: Transitions › Sound offers six built-in sounds (synthesised, no sound files shipped), Other Sound…, [Stop Previous Sound] and Loop Until Next Sound; they play in the show and round-trip | | Preview in the editor | yes | yes | | Morph: objects move, resize, recolour | yes | yes | | Morph: Words and Characters | yes | yes (#5) | @@ -84,7 +84,7 @@ In, Wipe, Zoom, Float In, Grow/Shrink, Pulse, Spin, motion lines): weighted, ~85 | Animation Pane timeline, preview from selected, effect dialog depth | 5–8 | | The remaining ~126 effects (most are parameter variations of existing ones) | 3–6 | | Rewind and other effect options on round trip (#77) | 1–2 | -| Transition sounds: built-in sounds, Stop Previous Sound, Loop Until Next Sound | 1–2 | +| Transition sounds: the rest of PowerPoint's built-in list (Applause, Arrow, Bomb…) | 1 | | Morph across pictures, `!!` names, align once per transition (#67) | 2–4 | | Cloth, paper and particle transitions as real meshes (Curtains, Peel Off, Page Curl, Airplane, Origami, Vortex, Shred, Honeycomb) | 4–8 | | **Total** | **~16–30** (the Transitions and Animations rows of the feature table; part of the Features dimension's 140–240 h) | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 5906ee5a..a90c9b43 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -199,7 +199,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Transitions | Transition gallery (fade, push, wipe, split, cover, uncover, zoom, morph…) | P0 | D | | Transitions | Effect options and duration | P0 | D | | Transitions | Advance on click / after time, apply to all | P0 | D | -| Transitions | Transition sounds | P2 | P | +| Transitions | Transition sounds | P2 | D | | Transitions | Morph | P1 | D | | Animations | Entrance, emphasis, exit effects | P0 | D | | Animations | Motion paths | P1 | P |