Skip to content

Commit a09db3a

Browse files
committed
feat(audio): analyse the mixed envelope, not the raw source
volume, volume_keyframes and the fades are what comes out of the speakers, and since #182 the studio plays exactly that — a waveform drawing the raw file's envelope while a fade takes the sound down contradicts what the viewer hears. The gain comes from the encoder's own envelope, factored out of the mixer loop as track_gain_at(track, t_in_track, audible) and expressed in seconds so the mixer (OUTPUT_SAMPLE_RATE, resampled) and the analysis (the file's own rate, decoded source) share one implementation. A second copy would drift, and the whole point is that the picture matches the sound. The cache fingerprint now hashes the serialised track rather than start/end alone: two scenarios can name the same file with different mixes, and adding a field to AudioTrack must not silently leave it out of the key. Not mergeable yet — see #201. The components read scene-local time against a scenario-time analysis, which is harmless while the envelope is flat and turns into a flat trace as soon as it is not.
1 parent d69e027 commit a09db3a

3 files changed

Lines changed: 175 additions & 36 deletions

File tree

crates/rustmotion/src/encode/audio.rs

Lines changed: 40 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -221,9 +221,6 @@ pub fn mix_audio_tracks_segment(
221221
.unwrap_or(scenario_samples)
222222
.min(scenario_samples);
223223

224-
let fade_in_samples = track.fade_in.unwrap_or(0.0) * TARGET_SAMPLE_RATE as f64;
225-
let fade_out_samples = track.fade_out.unwrap_or(0.0) * TARGET_SAMPLE_RATE as f64;
226-
227224
// How much of the track is ever audible in the scenario, regardless
228225
// of which segment we are materializing right now. Fades are
229226
// computed against this, not against the segment's own bounds.
@@ -251,25 +248,12 @@ pub fn mix_audio_tracks_segment(
251248
}
252249

253250
let frame = i / TARGET_CHANNELS as usize;
254-
let current_time = track.start + (frame as f64 / TARGET_SAMPLE_RATE as f64);
255-
let vol = if !track.volume_keyframes.is_empty() {
256-
interpolate_volume_keyframes(&track.volume_keyframes, current_time)
257-
} else {
258-
track.volume
259-
};
260-
let mut sample = src_sample * vol;
261-
262-
// Apply fade in
263-
if fade_in_samples > 0.0 && (frame as f64) < fade_in_samples {
264-
sample *= frame as f32 / fade_in_samples as f32;
265-
}
266-
267-
// Apply fade out — against the track's own total audible
268-
// frames in the scenario, not this segment's length.
269-
let frames_from_end = total_frames - frame;
270-
if fade_out_samples > 0.0 && (frames_from_end as f64) < fade_out_samples {
271-
sample *= frames_from_end as f32 / fade_out_samples as f32;
272-
}
251+
let sample = src_sample
252+
* track_gain_at(
253+
track,
254+
frame as f64 / TARGET_SAMPLE_RATE as f64,
255+
total_frames as f64 / TARGET_SAMPLE_RATE as f64,
256+
);
273257

274258
mix_buffer[dst_idx] += sample;
275259
}
@@ -286,6 +270,40 @@ pub fn mix_audio_tracks_segment(
286270
Ok(Some(pcm_bytes))
287271
}
288272

273+
/// The gain applied to a track `t_in_track` seconds after its own first sample,
274+
/// given that `audible` seconds of it are ever heard.
275+
///
276+
/// Expressed in seconds rather than sample indices so the mixer (which works at
277+
/// `OUTPUT_SAMPLE_RATE` on resampled audio) and the analysis (which works at the
278+
/// file's own rate on the decoded source) can share it. They must: a waveform
279+
/// that draws an envelope the mix does not produce is the component lying about
280+
/// the very track it claims to react to.
281+
pub(crate) fn track_gain_at(
282+
track: &crate::schema::AudioTrack,
283+
t_in_track: f64,
284+
audible: f64,
285+
) -> f32 {
286+
let mut gain = if track.volume_keyframes.is_empty() {
287+
track.volume
288+
} else {
289+
// Keyframe times are on the *scenario* timeline, not the track's.
290+
interpolate_volume_keyframes(&track.volume_keyframes, track.start + t_in_track)
291+
};
292+
293+
if let Some(fade_in) = track.fade_in {
294+
if fade_in > 0.0 && t_in_track < fade_in {
295+
gain *= (t_in_track / fade_in) as f32;
296+
}
297+
}
298+
if let Some(fade_out) = track.fade_out {
299+
let remaining = audible - t_in_track;
300+
if fade_out > 0.0 && remaining < fade_out {
301+
gain *= (remaining.max(0.0) / fade_out) as f32;
302+
}
303+
}
304+
gain
305+
}
306+
289307
/// Interpolate volume at a given time using volume keyframes with easing
290308
fn interpolate_volume_keyframes(keyframes: &[crate::schema::VolumeKeyframe], time: f64) -> f32 {
291309
if keyframes.is_empty() {

crates/rustmotion/src/encode/audio_analysis.rs

Lines changed: 43 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,12 @@ impl std::fmt::Display for AudioAnalysisFailure {
3131
/// up that way), so without this a track whose *content* changed under a stable
3232
/// path — the normal case when someone re-exports a mix while the studio is
3333
/// open — would keep serving the old envelope forever.
34-
/// Also carries the track's placement: the analysis content depends only
35-
/// on the file, but the *lookup* now applies `start`/`end`, so an entry
36-
/// computed for one placement must not be reused for another.
37-
type SourceFingerprint = (u64, u128, u32, u64, u64);
34+
/// File identity (length, mtime), the fps it was bucketed at, and a hash of
35+
/// everything about the *track* that changes the result: `start`/`end` move the
36+
/// lookup, and `volume`/`volume_keyframes`/the fades are baked into the
37+
/// amplitudes. An entry computed for one of those must never be served for
38+
/// another — two scenarios can name the same file with different mixes.
39+
type SourceFingerprint = (u64, u128, u32, u64);
3840

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

@@ -48,8 +50,7 @@ fn fingerprints() -> &'static Mutex<HashMap<String, SourceFingerprint>> {
4850
fn source_fingerprint(
4951
src: &str,
5052
fps: u32,
51-
start: f64,
52-
end: Option<f64>,
53+
track: &rustmotion_core::schema::AudioTrack,
5354
) -> Option<SourceFingerprint> {
5455
let meta = std::fs::metadata(src).ok()?;
5556
let mtime = meta
@@ -58,13 +59,19 @@ fn source_fingerprint(
5859
.duration_since(std::time::UNIX_EPOCH)
5960
.ok()?
6061
.as_nanos();
61-
Some((
62-
meta.len(),
63-
mtime,
64-
fps,
65-
start.to_bits(),
66-
end.unwrap_or(f64::INFINITY).to_bits(),
67-
))
62+
Some((meta.len(), mtime, fps, track_hash(track)))
63+
}
64+
65+
/// Hash the track's placement and volume envelope. Serialised rather than
66+
/// hashed field by field so adding a field to `AudioTrack` cannot silently
67+
/// leave it out of the key.
68+
fn track_hash(track: &rustmotion_core::schema::AudioTrack) -> u64 {
69+
use std::hash::{Hash, Hasher};
70+
let mut hasher = std::collections::hash_map::DefaultHasher::new();
71+
serde_json::to_string(track)
72+
.unwrap_or_default()
73+
.hash(&mut hasher);
74+
hasher.finish()
6875
}
6976

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

108115
for track in tracks {
109116
let src = &track.src;
110-
let fingerprint = source_fingerprint(src, fps, track.start, track.end);
117+
let fingerprint = source_fingerprint(src, fps, track);
111118
let cached_and_current = cache.contains_key(src)
112119
&& fingerprint.is_some()
113120
&& fps_of
@@ -141,6 +148,28 @@ pub fn analyze_scenario_audio(scenario: &ResolvedScenario) -> Vec<AudioAnalysisF
141148
.collect(),
142149
};
143150

151+
// Follow the *mix*, not the source. `volume`, `volume_keyframes` and the
152+
// fades are what comes out of the speakers, and since #182 the studio
153+
// plays exactly that — a waveform drawing the raw file's envelope while
154+
// a fade takes the sound down contradicts what the viewer hears. The
155+
// gain comes from the encoder's own `track_gain_at`, so the picture
156+
// cannot drift from the audio.
157+
let audible = {
158+
let file_seconds = mono.len() as f64 / sample_rate as f64;
159+
match track.end {
160+
Some(end) => file_seconds.min((end - track.start).max(0.0)),
161+
None => file_seconds,
162+
}
163+
};
164+
let mono: Vec<f32> = mono
165+
.into_iter()
166+
.enumerate()
167+
.map(|(i, s)| {
168+
let t = i as f64 / sample_rate as f64;
169+
s * crate::encode::audio::track_gain_at(track, t, audible)
170+
})
171+
.collect();
172+
144173
let samples_per_frame = (sample_rate as f64 / fps as f64).ceil() as usize;
145174
let num_frames = (mono.len() as f64 / samples_per_frame as f64).ceil() as usize;
146175

crates/rustmotion/src/tests.rs

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1984,6 +1984,98 @@ mod audio_tests {
19841984
count
19851985
}
19861986

1987+
/// The analysis must describe the **mix**, not the source.
1988+
///
1989+
/// `volume`, `volume_keyframes` and the fades are what comes out of the
1990+
/// speakers, and since #182 the studio plays exactly that. A waveform
1991+
/// drawing the raw file's envelope while a keyframe takes the sound to
1992+
/// zero contradicts what the viewer hears.
1993+
#[test]
1994+
fn the_analysis_follows_the_mixed_envelope_not_the_raw_file() {
1995+
let sample_rate = 44100u32;
1996+
// 2 s of unbroken sine: any variation in the analysis comes from the
1997+
// envelope, never from the source.
1998+
let wav_path = std::env::temp_dir().join(format!("rustmotion_test_mix_{}.wav", nanos()));
1999+
std::fs::write(
2000+
&wav_path,
2001+
make_sine_wav(sample_rate * 2, sample_rate * 2, 440.0, sample_rate),
2002+
)
2003+
.expect("write fixture");
2004+
let wav_str = wav_path.to_str().unwrap().to_string();
2005+
2006+
// Full for the first second, silent for the second.
2007+
let json = serde_json::json!({
2008+
"video": {"width": 32, "height": 32, "fps": 30},
2009+
"audio": [{
2010+
"src": wav_str,
2011+
"volume_keyframes": [
2012+
{"time": 0.0, "volume": 1.0},
2013+
{"time": 1.0, "volume": 1.0},
2014+
{"time": 1.05, "volume": 0.0},
2015+
{"time": 2.0, "volume": 0.0}
2016+
]
2017+
}],
2018+
"scenes": [{"duration": 2.0, "children": []}]
2019+
})
2020+
.to_string();
2021+
let scenario =
2022+
crate::loader::load_scenario_from_source(None, Some(&json)).expect("load scenario");
2023+
assert!(crate::encode::audio_analysis::analyze_scenario_audio(&scenario).is_empty());
2024+
2025+
let analysis = audio_analysis_cache().get(&wav_str).unwrap().clone();
2026+
std::fs::remove_file(&wav_path).ok();
2027+
2028+
assert!(
2029+
analysis.amplitude_at(0.5) > 0.5,
2030+
"inside the audible half the envelope is open"
2031+
);
2032+
assert!(
2033+
analysis.amplitude_at(1.5) < 0.05,
2034+
"the keyframes take the track to silence — the visualisation must \
2035+
follow it, not keep drawing the sine underneath"
2036+
);
2037+
}
2038+
2039+
/// The envelope is part of the cache key: two scenarios naming the same
2040+
/// file with different mixes must not share an analysis.
2041+
#[test]
2042+
fn changing_the_envelope_re_analyses_the_same_file() {
2043+
let sample_rate = 44100u32;
2044+
let wav_path = std::env::temp_dir().join(format!("rustmotion_test_env_{}.wav", nanos()));
2045+
std::fs::write(
2046+
&wav_path,
2047+
make_sine_wav(sample_rate, sample_rate, 440.0, sample_rate),
2048+
)
2049+
.expect("write fixture");
2050+
let wav_str = wav_path.to_str().unwrap().to_string();
2051+
2052+
let with_volume = |v: f32| {
2053+
let json = serde_json::json!({
2054+
"video": {"width": 32, "height": 32, "fps": 30},
2055+
"audio": [{"src": wav_str, "volume": v}],
2056+
"scenes": [{"duration": 1.0, "children": []}]
2057+
})
2058+
.to_string();
2059+
let scenario =
2060+
crate::loader::load_scenario_from_source(None, Some(&json)).expect("load");
2061+
crate::encode::audio_analysis::analyze_scenario_audio(&scenario);
2062+
audio_analysis_cache().get(&wav_str).unwrap().amplitude[10]
2063+
};
2064+
2065+
// Amplitudes are normalised per analysis, so a flat gain change cannot
2066+
// be read off the values — assert the *entry* was replaced instead.
2067+
let loud = with_volume(1.0);
2068+
let quiet = with_volume(0.0);
2069+
std::fs::remove_file(&wav_path).ok();
2070+
2071+
assert!(loud > 0.5, "the full-volume take is audible, got {loud}");
2072+
assert_eq!(
2073+
quiet, 0.0,
2074+
"at volume 0 the analysis must be silent — a stale entry would \
2075+
still report {loud}"
2076+
);
2077+
}
2078+
19872079
/// The failure the whole rewrite exists for: a scenario naming an asset
19882080
/// beside itself must load identically whatever directory the process
19892081
/// runs from. Authoring from the scenario's folder worked; the studio,

0 commit comments

Comments
 (0)