Transitions: built-in sounds, Stop Previous Sound and Loop Until Next

Follow-up to the transition sound picker. The Sound box now lists
[Stop Previous Sound] and six built-in sounds (Chime, Click, Camera,
Whoosh, Drum Roll, Laser) that are synthesised as small WAVs when
chosen, so no sound files ship with the app; the same built-in sound is
embedded once however many slides use it. Loop Until Next Sound repeats
a sound in the show until another slide's sound or Stop Previous Sound.
PPTX reads and writes p:stSnd loop="1" and p:endSnd, keeps the sound's
file name in p:snd/@name, and setting a slide's transition to None no
longer drops its sound.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Nikita Rakitin
2026-10-13 22:30:19 +03:00
co-authored by Cursor
parent 460a2712e4
commit 69a4424e75
11 changed files with 254 additions and 28 deletions
+57 -13
View File
@@ -35,7 +35,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<Value> {
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<Value> {
/// Transitions › Sound: embed an audio file as the slides' transition sound, or `none` to remove it.
fn sound(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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<Value> {
+1
View File
@@ -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;
+107
View File
@@ -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<Vec<u8>> {
let mut noise = Lcg(0x9E37_79B9);
let samples: Vec<f64> = 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<f64> {
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<u8> {
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());
}
}
+8
View File
@@ -21,6 +21,12 @@ pub struct Transition {
pub advance_after_ms: Option<u32>,
/// Embedded sound media.
pub sound: Option<crate::MediaId>,
/// 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<String>,
@@ -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,
}
}
+3 -1
View File
@@ -41,8 +41,10 @@ pub fn transition(imp: &mut Imp, part: &Part, t: &El) -> Option<Transition> {
});
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)
+9 -4
View File
@@ -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();
+35
View File
@@ -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#"<p:stSnd loop="1">"#) && 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());
}
+23 -4
View File
@@ -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}));
}
});
});
});
+8 -3
View File
@@ -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<ShapeId> = 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 {
+2 -2
View File
@@ -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) |
+1 -1
View File
@@ -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 |