mirror of
https://github.com/storytold/deckcraft.git
synced 2026-11-11 16:37:06 -06:00
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:
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user