Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions crates/rustmotion-core/src/engine/renderer/audio_analysis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,41 @@ pub struct AudioAnalysis {
pub amplitude: Vec<f32>,
/// 16 log-spaced frequency bands (20 Hz–16 kHz) per frame, normalized 0..1.
pub bands: Vec<[f32; 16]>,
/// Where the track sits on the scenario timeline (`AudioTrack::start`).
///
/// The analysis indexes the *file* from its own sample 0, while every
/// consumer asks in *scenario* time. Without this the two only line up when
/// `start == 0`: a track placed at 73 s played from its beginning while the
/// waveform drew the file's content at 73 s. Held here rather than at the
/// call sites because the cache is keyed by path and the painters never see
/// the `AudioTrack`.
pub start: f64,
/// `AudioTrack::end`, past which the track is cut and the visualisation
/// must go flat rather than keep drawing an envelope nobody hears.
pub end: Option<f64>,
}

impl AudioAnalysis {
/// Scenario time → offset into the file, or `None` when the track is not
/// playing at that moment. Every lookup below goes through this, so the
/// placement is applied in exactly one place.
fn track_time(&self, time: f64) -> Option<f64> {
if time < self.start {
return None;
}
if let Some(end) = self.end {
if time >= end {
return None;
}
}
Some(time - self.start)
}

/// Get the amplitude at a given time (seconds), clamped to valid range.
pub fn amplitude_at(&self, time: f64) -> f32 {
let Some(time) = self.track_time(time) else {
return 0.0;
};
let idx = (time * self.frame_rate as f64) as usize;
self.amplitude
.get(idx.min(self.amplitude.len().saturating_sub(1)))
Expand All @@ -24,6 +54,9 @@ impl AudioAnalysis {

/// Get a specific band [0..16) value at a given time.
pub fn band_at(&self, time: f64, band: u8) -> f32 {
let Some(time) = self.track_time(time) else {
return 0.0;
};
let idx = (time * self.frame_rate as f64) as usize;
let idx = idx.min(self.bands.len().saturating_sub(1));
self.bands
Expand All @@ -37,6 +70,9 @@ impl AudioAnalysis {
if smoothing_frames == 0 || self.amplitude.is_empty() {
return self.amplitude_at(time);
}
let Some(time) = self.track_time(time) else {
return 0.0;
};
let end_idx = (time * self.frame_rate as f64) as usize;
let end_idx = end_idx.min(self.amplitude.len().saturating_sub(1));
let start_idx = end_idx.saturating_sub(smoothing_frames as usize);
Expand All @@ -52,6 +88,9 @@ impl AudioAnalysis {
if smoothing_frames == 0 || self.bands.is_empty() {
return self.band_at(time, band);
}
let Some(time) = self.track_time(time) else {
return 0.0;
};
let end_idx = (time * self.frame_rate as f64) as usize;
let end_idx = end_idx.min(self.bands.len().saturating_sub(1));
let start_idx = end_idx.saturating_sub(smoothing_frames as usize);
Expand Down
24 changes: 20 additions & 4 deletions crates/rustmotion/src/encode/audio_analysis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,10 @@ impl std::fmt::Display for AudioAnalysisFailure {
/// up that way), so without this a track whose *content* changed under a stable
/// path — the normal case when someone re-exports a mix while the studio is
/// open — would keep serving the old envelope forever.
type SourceFingerprint = (u64, u128, u32);
/// Also carries the track's placement: the analysis content depends only
/// on the file, but the *lookup* now applies `start`/`end`, so an entry
/// computed for one placement must not be reused for another.
type SourceFingerprint = (u64, u128, u32, u64, u64);

static FINGERPRINTS: OnceLock<Mutex<HashMap<String, SourceFingerprint>>> = OnceLock::new();

Expand All @@ -42,15 +45,26 @@ fn fingerprints() -> &'static Mutex<HashMap<String, SourceFingerprint>> {
/// `None` when the file cannot be stat'ed — treated as "changed", so the next
/// analysis attempt runs and reports a real decode error instead of silently
/// reusing a stale entry.
fn source_fingerprint(src: &str, fps: u32) -> Option<SourceFingerprint> {
fn source_fingerprint(
src: &str,
fps: u32,
start: f64,
end: Option<f64>,
) -> Option<SourceFingerprint> {
let meta = std::fs::metadata(src).ok()?;
let mtime = meta
.modified()
.ok()?
.duration_since(std::time::UNIX_EPOCH)
.ok()?
.as_nanos();
Some((meta.len(), mtime, fps))
Some((
meta.len(),
mtime,
fps,
start.to_bits(),
end.unwrap_or(f64::INFINITY).to_bits(),
))
}

/// Build the 16 log-spaced band frequency boundaries (Hz) from 20..16000.
Expand Down Expand Up @@ -93,7 +107,7 @@ pub fn analyze_scenario_audio(scenario: &ResolvedScenario) -> Vec<AudioAnalysisF

for track in tracks {
let src = &track.src;
let fingerprint = source_fingerprint(src, fps);
let fingerprint = source_fingerprint(src, fps, track.start, track.end);
let cached_and_current = cache.contains_key(src)
&& fingerprint.is_some()
&& fps_of
Expand Down Expand Up @@ -208,6 +222,8 @@ pub fn analyze_scenario_audio(scenario: &ResolvedScenario) -> Vec<AudioAnalysisF
frame_rate: fps,
amplitude,
bands: bands_all,
start: track.start,
end: track.end,
});
cache.insert(src.clone(), analysis);
let mut fps_of = fps_of.lock().unwrap_or_else(|e| e.into_inner());
Expand Down
65 changes: 65 additions & 0 deletions crates/rustmotion/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1984,6 +1984,65 @@ mod audio_tests {
count
}

/// A track placed at `start` must be *read* from `start` too.
///
/// The mux places the file at `track.start` on the scenario timeline and
/// copies it from its own sample 0; the analysis indexes the file from its
/// own frame 0 while every painter asks in scenario time. The two only
/// lined up when `start == 0` — a soundtrack gated to one scene played its
/// opening while the waveform drew the file's content at that timestamp.
#[test]
fn a_track_start_offsets_the_analysis_lookup() {
let sample_rate = 44100u32;
// 1 s of sine then 1 s of silence, placed at t = 5 and cut at t = 6.5.
let wav_path = std::env::temp_dir().join(format!("rustmotion_test_offset_{}.wav", nanos()));
std::fs::write(
&wav_path,
make_sine_wav(sample_rate * 2, sample_rate, 440.0, sample_rate),
)
.expect("write fixture");
let wav_str = wav_path.to_str().unwrap().to_string();

let json = serde_json::json!({
"video": {"width": 32, "height": 32, "fps": 30},
"audio": [{"src": wav_str, "start": 5.0, "end": 6.5}],
"scenes": [{"duration": 8.0, "children": []}]
})
.to_string();
let scenario =
crate::loader::load_scenario_from_source(None, Some(&json)).expect("load scenario");
assert!(crate::encode::audio_analysis::analyze_scenario_audio(&scenario).is_empty());

let analysis = audio_analysis_cache().get(&wav_str).unwrap().clone();
std::fs::remove_file(&wav_path).ok();

assert_eq!(
analysis.amplitude_at(4.9),
0.0,
"before `start` the track is not playing"
);
assert!(
analysis.amplitude_at(5.2) > 0.5,
"0.2 s after `start` is 0.2 s into the file — inside the sine"
);
assert!(
analysis.amplitude_at(6.2) < 0.1,
"1.2 s after `start` is 1.2 s into the file — inside the silence"
);
assert_eq!(
analysis.amplitude_at(6.6),
0.0,
"past `end` the track is cut, so the visualisation must go flat \
rather than keep drawing an envelope nobody hears"
);

// The smoothed accessors take the same path, or a bound component
// would disagree with the waveform next to it.
assert_eq!(analysis.amplitude_smoothed(4.9, 3), 0.0);
assert_eq!(analysis.band_at(4.9, 4), 0.0);
assert_eq!(analysis.band_smoothed(4.9, 4, 3), 0.0);
}

/// A track that cannot be decoded must be *reported*, not swallowed:
/// silence here leaves `waveform`/`audio_spectrum` on their flat fallback
/// with nothing anywhere saying why.
Expand Down Expand Up @@ -2164,6 +2223,8 @@ mod audio_tests {
frame_rate: 30,
amplitude: vec![1.0; 30],
bands,
start: 0.0,
end: None,
}),
);

Expand Down Expand Up @@ -2220,6 +2281,8 @@ mod audio_tests {
frame_rate: 30,
amplitude,
bands: vec![[0.0f32; 16]; 60],
start: 0.0,
end: None,
}),
);

Expand Down Expand Up @@ -2256,6 +2319,8 @@ mod audio_tests {
frame_rate: 30,
amplitude,
bands: vec![[0.0f32; 16]; 90],
start: 0.0,
end: None,
}),
);

Expand Down
Loading