makepad/libs/ai/models/beats/src/model.rs
Admin 6ba1b9425c libs: ai hub/models/cuda, speech, chat_ui
Squash of 54 work commits (Sep 1–12):
  6251f7c  ai-hub: body domain — live pose packets ride the realtime session
  ea50c77  chat_ui: the feed's session gets its profile brief back
  f51b5f3  ai-body: the crate for the native SAM 3D Body port, with its weights reader
  8211ae6  ai-body: the MHR rig and the pose head's parameter decoding, oracle-exact
  9e343a8  ai-body: the DINOv3 ViT-H+/16 backbone, crop and ray conditioning; Metal gains rope-half and affine layer norm
  69d842c  ai-body: the promptable pose decoder and its refinement loop, oracle-matched on Metal
  66e5e2f  ai-hub: SAM 3D Body runs natively — `sam3dbody` on the body domain, oracle-matched end to end
  a634198  ai-hub: the body-native commit carried a peer's in-flight hub hunks; put them back where they were
  9ff44e8  ai-hub: the body-native wiring, this time only the lane's hunks
  6a1c16b  ai-body: third-party notices — what the port is implemented after, and what it is not
  d78411a  ai-body: the per-step work moves to the GPU
  b22259b  ai-body: the context stays on the GPU; only the pose token leaves the loop
  346f31f  ai-body: flash attention for the head-dim-64 blocks
  45b5b98  ai-body: the crop size is a runtime knob, and the loop reports where its time goes
  4be6d19  ai-body: the test modules import the grid constants they still use
  7598346  ai-body: tensor-core GEMMs for the backbone, and the rig's correctives only where they count
  a9ce596  ai-body: the crop warp runs across cores
  8964ba6  ai-body: an FP8 backbone mode, off by default, measured against the oracle
  a2aaa8f  ai-body: the FP8 bias rides a column-broadcast add on the device
  d53c77d  metal: a device-resident ViT stack, and the body backbone rides it
  d006d0a  metal: resident f32 linears keep their weight on the device
  525ba1c  metal: a device-resident two-way decoder layer, and the body decoder rides it
  c9e6d88  ai-body: the hands pass — hand crops, the hand decoder, the hand-mode rig and the wrist fusion
  62dff26  ai-body: the mask prompt — a person's segmentation mask conditions the body pass
  a648cf8  ai-hub: body session options — hands, detect, persons=N
  8c568df  ai-hub: drop the SAM 3D Body reference worker backend
  7ff875a  ai-hub: keep a peer's in-flight beats/notes/local work out of the body commits
  31e5faa  ai-hub: local model runner, licence acknowledgements, a shared install panel; Beat This!, Basic Pitch and the Salamander drum-kit entries
  b94bc58  ai-services: the wire, the app port and the panel state — one conversation, many apps
  2acb798  ai-services: wire v2 — endpoints, receiver-side caps, result disposition
  8ae0ffb  ai-services: the engine core — registry, router and conversation, tested against a scripted model
  2308736  ai-services: the real models behind the engine feature — local through the hub, Claude, and none
  c3f631d  livepipe: one reusable pipe from a camera to a fleet node and back
  ff62db3  ai libs: the runtime env-var cleanup — precision is a per-caller policy, not an environment side channel
  04a94ef  realtime: one service-log line when a live session opens and one when it closes
  0ecb81c  ai models: the model-crates env-var cleanup — 172 research knobs gone, the unset default is the code
  4ca36c1  ai hub + services: the assistant's model comes from wherever it is resident — the fleet chat box, with tools, then the local weights
  432121e  aichat engine + wm: launch, then use — the assistant continues in the same turn once the app it started is on the bus
  7a5bf69  ai-hub registry: the Salamander drumkit samples come from the makepad.nl mirror — the GitHub repo only carries the .sfz files
  102ffc5  ai-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 arm
  a837792  hub + 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/models
  bc6c620  hub + 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 out
  75c3441  hub: the PRO 6000 serves image as well as chat and text
  ad5e98b  hub registry: flux2-dev's VRAM estimate is its measured peak, 30 GB
  c7241e0  hub: a node that evicted every resident releases its cached allocator pool before refusing a load or publishing usable VRAM
  30575f0  flow: route generation by request workload
  1be1e21  ai-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>
2026-09-15 13:40:31 +02:00

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));
}
}
}