Squash of 54 work commits (Sep 1–12):6251f7cai-hub: body domain — live pose packets ride the realtime sessionea50c77chat_ui: the feed's session gets its profile brief backf51b5f3ai-body: the crate for the native SAM 3D Body port, with its weights reader8211ae6ai-body: the MHR rig and the pose head's parameter decoding, oracle-exact9e343a8ai-body: the DINOv3 ViT-H+/16 backbone, crop and ray conditioning; Metal gains rope-half and affine layer norm69d842cai-body: the promptable pose decoder and its refinement loop, oracle-matched on Metal66e5e2fai-hub: SAM 3D Body runs natively — `sam3dbody` on the body domain, oracle-matched end to enda634198ai-hub: the body-native commit carried a peer's in-flight hub hunks; put them back where they were9ff44e8ai-hub: the body-native wiring, this time only the lane's hunks6a1c16bai-body: third-party notices — what the port is implemented after, and what it is notd78411aai-body: the per-step work moves to the GPUb22259bai-body: the context stays on the GPU; only the pose token leaves the loop346f31fai-body: flash attention for the head-dim-64 blocks45b5b98ai-body: the crop size is a runtime knob, and the loop reports where its time goes4be6d19ai-body: the test modules import the grid constants they still use7598346ai-body: tensor-core GEMMs for the backbone, and the rig's correctives only where they counta9ce596ai-body: the crop warp runs across cores8964ba6ai-body: an FP8 backbone mode, off by default, measured against the oraclea2aaa8fai-body: the FP8 bias rides a column-broadcast add on the deviced53c77dmetal: a device-resident ViT stack, and the body backbone rides itd006d0ametal: resident f32 linears keep their weight on the device525ba1cmetal: a device-resident two-way decoder layer, and the body decoder rides itc9e6d88ai-body: the hands pass — hand crops, the hand decoder, the hand-mode rig and the wrist fusion62dff26ai-body: the mask prompt — a person's segmentation mask conditions the body passa648cf8ai-hub: body session options — hands, detect, persons=N8c568dfai-hub: drop the SAM 3D Body reference worker backend7ff875aai-hub: keep a peer's in-flight beats/notes/local work out of the body commits31e5faaai-hub: local model runner, licence acknowledgements, a shared install panel; Beat This!, Basic Pitch and the Salamander drum-kit entriesb94bc58ai-services: the wire, the app port and the panel state — one conversation, many apps2acb798ai-services: wire v2 — endpoints, receiver-side caps, result disposition8ae0ffbai-services: the engine core — registry, router and conversation, tested against a scripted model2308736ai-services: the real models behind the engine feature — local through the hub, Claude, and nonec3f631dlivepipe: one reusable pipe from a camera to a fleet node and backff62db3ai libs: the runtime env-var cleanup — precision is a per-caller policy, not an environment side channel04a94efrealtime: one service-log line when a live session opens and one when it closes0ecb81cai models: the model-crates env-var cleanup — 172 research knobs gone, the unset default is the code4ca36c1ai hub + services: the assistant's model comes from wherever it is resident — the fleet chat box, with tools, then the local weights432121eaichat engine + wm: launch, then use — the assistant continues in the same turn once the app it started is on the bus7a5bf69ai-hub registry: the Salamander drumkit samples come from the makepad.nl mirror — the GitHub repo only carries the .sfz files102ffc5ai-services: messages on the bus — a manifest declares topics, the engine subscribes on a tool's behalf or by ToolResult.subscribe, a service publishes Message frames, an idle conversation wakes on a message as an event turn under rate laws; the WM bus forwards the new frames; every app that matches the wire gets its arma837792hub + flow: a whitespace-only chat completion is retried once and then fails instead of passing as an answer; a flow's model is a fleet model id unless it names a weight file on disk; chat models show under the text domain in /v1/modelsbc6c620hub + flow: what the chat review found — the in-process route retries an empty completion too, a node says whether its prefill opened thinking so a brief-mode answer is never discarded, a preferred model falls back to normal election when no node has it, discovery keeps looking for the preferred model until patience runs out75c3441hub: the PRO 6000 serves image as well as chat and textad5e98bhub registry: flux2-dev's VRAM estimate is its measured peak, 30 GBc7241e0hub: a node that evicted every resident releases its cached allocator pool before refusing a load or publishing usable VRAM30575f0flow: route generation by request workload1be1e21ai-hub: gate downloads by disk capacity and recover fleet admission df6b394 filesystem_watcher, bounded_http, ai services: live and tool prerequisites 79ebdb9 ai-hub: add a native Pixal3D image-to-3D backend 0ba0d74 ai-hub: propagate typed refusals under reject queue policy cc6c872 Speed up H3 conditioning and video decoding e512059 Fix Qwen vision residency and generated material colors 2864f68 ai-hub http client: bound every plain TCP connect to 3 s per address 3d93229 ai: CUDA is a Linux/Windows-only dependency; the hub library defaults to llm + stt Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
412 lines
14 KiB
Rust
412 lines
14 KiB
Rust
//! Model ownership, reference chunk stitching, and minimal postprocessing.
|
|
|
|
use crate::config::*;
|
|
use crate::graph::{build_graph, BeatsGraph};
|
|
use crate::mel::LogMelSpect;
|
|
use crate::weights::{BeatsWeights, DEFAULT_GRAPH_EXTRA_BYTES};
|
|
use makepad_ai_common::backend::{
|
|
BufferStorageMode, DeviceGraphSession, DeviceRuntime, GraphDevice,
|
|
};
|
|
use makepad_ai_common::{DiffusionError, Result};
|
|
use std::cmp::Ordering;
|
|
use std::path::Path;
|
|
|
|
#[derive(Clone, Debug, Default, PartialEq)]
|
|
pub struct BeatAnalysis {
|
|
pub beats_secs: Vec<f64>,
|
|
pub downbeats_secs: Vec<f64>,
|
|
pub bpm: f64,
|
|
pub confidence: f32,
|
|
pub frame_rate: f64,
|
|
pub beat_prob: Vec<f32>,
|
|
pub downbeat_prob: Vec<f32>,
|
|
}
|
|
|
|
pub struct BeatsModel {
|
|
weights: BeatsWeights,
|
|
graph: BeatsGraph,
|
|
session: DeviceGraphSession,
|
|
mel_frontend: LogMelSpect,
|
|
chunk_mel: Vec<f32>,
|
|
}
|
|
|
|
impl BeatsModel {
|
|
pub fn load(checkpoint: impl AsRef<Path>) -> Result<Self> {
|
|
Self::load_with_runtime(checkpoint, DeviceRuntime::new()?)
|
|
}
|
|
|
|
pub fn load_with_runtime(
|
|
checkpoint: impl AsRef<Path>,
|
|
runtime: DeviceRuntime,
|
|
) -> Result<Self> {
|
|
let f16 = match runtime.device() {
|
|
GraphDevice::Metal => crate::weights::f16_weights_enabled(),
|
|
GraphDevice::Cuda => crate::weights::f16_weights_requested(),
|
|
};
|
|
let mut weights =
|
|
BeatsWeights::load_with_options(checkpoint, DEFAULT_GRAPH_EXTRA_BYTES, f16)?;
|
|
let graph = build_graph(&mut weights)?;
|
|
let session = runtime.compile_graph(
|
|
&weights.ctx,
|
|
&graph.graph,
|
|
&[graph.logits],
|
|
BufferStorageMode::Shared,
|
|
BufferStorageMode::Shared,
|
|
)?;
|
|
Ok(Self {
|
|
weights,
|
|
graph,
|
|
session,
|
|
mel_frontend: LogMelSpect::new(),
|
|
chunk_mel: vec![0.0; CHUNK_FRAMES * MEL_BINS],
|
|
})
|
|
}
|
|
|
|
pub fn checkpoint_path(&self) -> &Path {
|
|
&self.weights.path
|
|
}
|
|
|
|
pub fn analyze(&mut self, mono_22k: &[f32]) -> Result<BeatAnalysis> {
|
|
self.analyze_with_progress(mono_22k, &mut |_, _| Ok(()))
|
|
}
|
|
|
|
/// As [`Self::analyze`], with one cancellation/progress boundary before
|
|
/// inference and after every chunk.
|
|
pub fn analyze_with_progress(
|
|
&mut self,
|
|
mono_22k: &[f32],
|
|
progress: &mut dyn FnMut(usize, usize) -> Result<()>,
|
|
) -> Result<BeatAnalysis> {
|
|
if mono_22k.is_empty() {
|
|
return Ok(BeatAnalysis {
|
|
frame_rate: FRAME_RATE,
|
|
..BeatAnalysis::default()
|
|
});
|
|
}
|
|
let (mel, frames) = self.mel_frontend.compute(mono_22k);
|
|
let starts = chunk_starts(frames);
|
|
let total_chunks = starts.len();
|
|
progress(0, total_chunks)?;
|
|
let mut beat_logits = vec![-1000.0f32; frames];
|
|
let mut downbeat_logits = vec![-1000.0f32; frames];
|
|
|
|
for (chunk_index, &start) in starts.iter().enumerate() {
|
|
self.chunk_mel.fill(0.0);
|
|
for local in 0..CHUNK_FRAMES {
|
|
let global = start + local as isize;
|
|
if global >= 0 && (global as usize) < frames {
|
|
let source = global as usize * MEL_BINS;
|
|
let destination = local * MEL_BINS;
|
|
self.chunk_mel[destination..destination + MEL_BINS]
|
|
.copy_from_slice(&mel[source..source + MEL_BINS]);
|
|
}
|
|
}
|
|
let execution = self.session.execute(
|
|
&self.weights.ctx,
|
|
&[(self.graph.mel, as_bytes(&self.chunk_mel))],
|
|
&[self.graph.logits],
|
|
)?;
|
|
let bytes = execution.outputs.get(&self.graph.logits).ok_or_else(|| {
|
|
DiffusionError::model("beats graph returned no logits tensor")
|
|
})?;
|
|
if bytes.len() != CHUNK_FRAMES * 2 * 4 {
|
|
return Err(DiffusionError::model(format!(
|
|
"beats graph returned {} logit bytes, expected {}",
|
|
bytes.len(),
|
|
CHUNK_FRAMES * 2 * 4
|
|
)));
|
|
}
|
|
|
|
// Forward order + write-if-empty is reference `keep_first`.
|
|
for local in BORDER_FRAMES..CHUNK_FRAMES - BORDER_FRAMES {
|
|
let global = start + local as isize;
|
|
if global < 0 || global as usize >= frames {
|
|
continue;
|
|
}
|
|
let global = global as usize;
|
|
if beat_logits[global] != -1000.0 {
|
|
continue;
|
|
}
|
|
beat_logits[global] = read_f32(bytes, local * 2)?;
|
|
downbeat_logits[global] = read_f32(bytes, local * 2 + 1)?;
|
|
}
|
|
progress(chunk_index + 1, total_chunks)?;
|
|
}
|
|
|
|
let beat_prob: Vec<f32> = beat_logits.iter().copied().map(sigmoid).collect();
|
|
let downbeat_prob: Vec<f32> = downbeat_logits.iter().copied().map(sigmoid).collect();
|
|
Ok(postprocess(beat_prob, downbeat_prob))
|
|
}
|
|
}
|
|
|
|
pub fn chunk_starts(frames: usize) -> Vec<isize> {
|
|
if frames == 0 {
|
|
return Vec::new();
|
|
}
|
|
let mut starts = Vec::new();
|
|
let mut start = -(BORDER_FRAMES as isize);
|
|
let stop = frames as isize - BORDER_FRAMES as isize;
|
|
while start < stop {
|
|
starts.push(start);
|
|
start += CHUNK_STRIDE as isize;
|
|
}
|
|
if frames > CHUNK_STRIDE {
|
|
*starts.last_mut().unwrap() = frames as isize - (CHUNK_FRAMES - BORDER_FRAMES) as isize;
|
|
}
|
|
starts
|
|
}
|
|
|
|
pub fn postprocess(beat_prob: Vec<f32>, downbeat_prob: Vec<f32>) -> BeatAnalysis {
|
|
let beat_peaks = local_peaks(&beat_prob);
|
|
let downbeat_peaks = local_peaks(&downbeat_prob);
|
|
let beat_frames = deduplicate_adjacent(&beat_peaks);
|
|
let mut downbeat_frames = deduplicate_adjacent(&downbeat_peaks);
|
|
|
|
if !beat_frames.is_empty() {
|
|
for frame in &mut downbeat_frames {
|
|
*frame = beat_frames
|
|
.iter()
|
|
.copied()
|
|
.min_by(|a, b| {
|
|
(a - *frame)
|
|
.abs()
|
|
.partial_cmp(&(b - *frame).abs())
|
|
.unwrap_or(Ordering::Equal)
|
|
})
|
|
.unwrap();
|
|
}
|
|
downbeat_frames.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
|
|
downbeat_frames.dedup_by(|a, b| *a == *b);
|
|
}
|
|
|
|
let beats_secs: Vec<f64> = beat_frames.iter().map(|frame| frame / FRAME_RATE).collect();
|
|
let downbeats_secs: Vec<f64> = downbeat_frames
|
|
.iter()
|
|
.map(|frame| frame / FRAME_RATE)
|
|
.collect();
|
|
let bpm = estimate_bpm(&beats_secs);
|
|
let confidence = if beat_frames.is_empty() {
|
|
0.0
|
|
} else {
|
|
beat_frames
|
|
.iter()
|
|
.map(|frame| {
|
|
let index = frame.round().clamp(0.0, beat_prob.len().saturating_sub(1) as f64);
|
|
beat_prob[index as usize]
|
|
})
|
|
.sum::<f32>()
|
|
/ beat_frames.len() as f32
|
|
};
|
|
BeatAnalysis {
|
|
beats_secs,
|
|
downbeats_secs,
|
|
bpm,
|
|
confidence,
|
|
frame_rate: FRAME_RATE,
|
|
beat_prob,
|
|
downbeat_prob,
|
|
}
|
|
}
|
|
|
|
fn local_peaks(probability: &[f32]) -> Vec<usize> {
|
|
let mut peaks = Vec::new();
|
|
for (frame, &value) in probability.iter().enumerate() {
|
|
if value <= 0.5 {
|
|
continue;
|
|
}
|
|
let from = frame.saturating_sub(3);
|
|
let to = (frame + 3).min(probability.len().saturating_sub(1));
|
|
if probability[from..=to]
|
|
.iter()
|
|
.all(|&candidate| value >= candidate)
|
|
{
|
|
peaks.push(frame);
|
|
}
|
|
}
|
|
peaks
|
|
}
|
|
|
|
fn deduplicate_adjacent(peaks: &[usize]) -> Vec<f64> {
|
|
let Some((&first, rest)) = peaks.split_first() else {
|
|
return Vec::new();
|
|
};
|
|
let mut result = Vec::new();
|
|
let mut mean = first as f64;
|
|
let mut previous = first;
|
|
let mut count = 1usize;
|
|
for &peak in rest {
|
|
if peak - previous <= 1 {
|
|
count += 1;
|
|
mean += (peak as f64 - mean) / count as f64;
|
|
} else {
|
|
result.push(mean);
|
|
mean = peak as f64;
|
|
count = 1;
|
|
}
|
|
previous = peak;
|
|
}
|
|
result.push(mean);
|
|
result
|
|
}
|
|
|
|
fn estimate_bpm(beats: &[f64]) -> f64 {
|
|
if beats.len() < 2 {
|
|
return 0.0;
|
|
}
|
|
let mut intervals: Vec<f64> = beats.windows(2).map(|pair| pair[1] - pair[0]).collect();
|
|
intervals.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
|
|
let middle = intervals.len() / 2;
|
|
let median = if intervals.len() % 2 == 0 {
|
|
(intervals[middle - 1] + intervals[middle]) * 0.5
|
|
} else {
|
|
intervals[middle]
|
|
};
|
|
if median > 0.0 {
|
|
60.0 / median
|
|
} else {
|
|
0.0
|
|
}
|
|
}
|
|
|
|
fn sigmoid(value: f32) -> f32 {
|
|
if value >= 0.0 {
|
|
1.0 / (1.0 + (-value).exp())
|
|
} else {
|
|
let exponential = value.exp();
|
|
exponential / (1.0 + exponential)
|
|
}
|
|
}
|
|
|
|
fn read_f32(bytes: &[u8], index: usize) -> Result<f32> {
|
|
let at = index
|
|
.checked_mul(4)
|
|
.ok_or_else(|| DiffusionError::model("beats logit index overflow"))?;
|
|
let value = bytes
|
|
.get(at..at + 4)
|
|
.ok_or_else(|| DiffusionError::model("beats logit output is truncated"))?;
|
|
Ok(f32::from_le_bytes([value[0], value[1], value[2], value[3]]))
|
|
}
|
|
|
|
fn as_bytes(values: &[f32]) -> &[u8] {
|
|
unsafe { std::slice::from_raw_parts(values.as_ptr().cast(), values.len() * 4) }
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::f64::consts::PI;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
const WEIGHTS: &str = concat!(
|
|
env!("CARGO_MANIFEST_DIR"),
|
|
"/../../../../local/models/weights/beat_this/final0.ckpt"
|
|
);
|
|
|
|
fn checkpoint() -> PathBuf {
|
|
Path::new(WEIGHTS).to_path_buf()
|
|
}
|
|
|
|
fn click_track(seconds: usize, bpm: f64) -> Vec<f32> {
|
|
let mut audio = vec![0.0f32; seconds * SAMPLE_RATE as usize];
|
|
let beat_samples = (60.0 / bpm * SAMPLE_RATE as f64).round() as usize;
|
|
for (beat, at) in (0..audio.len()).step_by(beat_samples).enumerate() {
|
|
let amplitude = if beat % 4 == 0 { 1.0 } else { 0.55 };
|
|
for offset in 0..220usize.min(audio.len() - at) {
|
|
let envelope = (-(offset as f64) / 45.0).exp();
|
|
audio[at + offset] +=
|
|
(amplitude * envelope * (2.0 * PI * 1000.0 * offset as f64 / SAMPLE_RATE as f64).sin()) as f32;
|
|
}
|
|
}
|
|
audio
|
|
}
|
|
|
|
#[test]
|
|
fn chunk_starts_match_reference_shifted_tail() {
|
|
assert_eq!(chunk_starts(1000), vec![-6]);
|
|
assert_eq!(chunk_starts(1501), vec![-6, 7]);
|
|
assert_eq!(chunk_starts(4501), vec![-6, 1482, 2970, 3007]);
|
|
}
|
|
|
|
#[test]
|
|
fn minimal_postprocessor_snaps_downbeats_and_estimates_bpm() {
|
|
let mut beat = vec![0.0f32; 501];
|
|
let mut downbeat = vec![0.0f32; 501];
|
|
for frame in (0..=500).step_by(25) {
|
|
beat[frame] = 0.9;
|
|
}
|
|
for frame in (1..=500).step_by(100) {
|
|
downbeat[frame] = 0.8;
|
|
}
|
|
let analysis = postprocess(beat, downbeat);
|
|
assert!((analysis.bpm - 120.0).abs() < 1e-9);
|
|
assert!(analysis
|
|
.downbeats_secs
|
|
.iter()
|
|
.all(|time| analysis.beats_secs.contains(time)));
|
|
assert_eq!(analysis.frame_rate, 50.0);
|
|
}
|
|
|
|
#[test]
|
|
fn silence_probabilities_produce_no_events() {
|
|
let analysis = postprocess(vec![0.5; 100], vec![0.5; 100]);
|
|
assert!(analysis.beats_secs.is_empty());
|
|
assert!(analysis.downbeats_secs.is_empty());
|
|
assert_eq!(analysis.bpm, 0.0);
|
|
assert_eq!(analysis.confidence, 0.0);
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires seeded final0.ckpt and Metal/CUDA graph execution"]
|
|
fn synthetic_120_bpm_click_track_tracks_beats_and_downbeats() {
|
|
let expected: Vec<f64> = (0..40).map(|beat| beat as f64 * 0.5).collect();
|
|
let analysis = BeatsModel::load(checkpoint())
|
|
.unwrap()
|
|
.analyze(&click_track(20, 120.0))
|
|
.unwrap();
|
|
let matched = expected
|
|
.iter()
|
|
.filter(|&&time| {
|
|
analysis
|
|
.beats_secs
|
|
.iter()
|
|
.any(|beat| (beat - time).abs() <= 0.03)
|
|
})
|
|
.count();
|
|
assert!(matched >= expected.len() * 3 / 4, "matched {matched}/{}", expected.len());
|
|
assert!((analysis.bpm - 120.0).abs() <= 1.0);
|
|
let downbeats: Vec<f64> = expected.iter().step_by(4).copied().collect();
|
|
let precise = analysis
|
|
.downbeats_secs
|
|
.iter()
|
|
.filter(|&&time| downbeats.iter().any(|want| (time - want).abs() <= 0.03))
|
|
.count();
|
|
assert!(analysis.downbeats_secs.is_empty() || precise * 4 >= analysis.downbeats_secs.len() * 3);
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires seeded final0.ckpt and Metal/CUDA graph execution"]
|
|
fn model_silence_has_no_beats() {
|
|
let analysis = BeatsModel::load(checkpoint())
|
|
.unwrap()
|
|
.analyze(&vec![0.0; SAMPLE_RATE as usize * 20])
|
|
.unwrap();
|
|
assert!(analysis.beats_secs.is_empty());
|
|
assert!(analysis.downbeats_secs.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires seeded final0.ckpt and Metal/CUDA graph execution"]
|
|
fn ninety_seconds_is_continuous_across_chunk_seams() {
|
|
let analysis = BeatsModel::load(checkpoint())
|
|
.unwrap()
|
|
.analyze(&click_track(90, 120.0))
|
|
.unwrap();
|
|
assert!(analysis
|
|
.beats_secs
|
|
.windows(2)
|
|
.all(|pair| pair[1] - pair[0] > 0.03 && pair[1] - pair[0] < 0.8));
|
|
for seam in [1488.0 / 50.0, 2976.0 / 50.0] {
|
|
assert!(analysis.beats_secs.iter().any(|beat| (beat - seam).abs() < 0.55));
|
|
}
|
|
}
|
|
}
|