use super::amt::tokens_to_events;
use super::hf_tokenizer::HfTokenizer;
use super::midi::{TimeSignature, write_midi};
use anyhow::{Context, Result, anyhow, bail};
use burn::tensor::DType;
use burn::tensor::{
Bool, DataError, Device, Int, Shape, Tensor, TensorData, activation::softmax, backend::Backend,
};
use burn_store::{
KeyRemapper, ModuleAdapter, ModuleSnapshot, PyTorchToBurnAdapter, SafetensorsStore,
TensorSnapshot,
};
use huggingface_hub::{Repo, RepoType, api::sync::ApiBuilder};
use maolan_llama::llama::{Llama, LlamaConfig, RopeConfig, RopeFrequencyScaling};
use maolan_llama::sampling::Sampler;
use maolan_llama::tokenizer::Tokenizer;
use std::path::{Path, PathBuf};
use std::rc::Rc;
pub const MIDI_TOKEN_START: u32 = 128_256;
pub const MIDI_TOKEN_COUNT: u32 = 55_026;
pub const MIDI_TOKEN_END: u32 = MIDI_TOKEN_START + MIDI_TOKEN_COUNT;
pub const MIDI_VOCAB_SIZE: u32 = 183_286;
pub const MIDI_BOS_TOKEN: u32 = MIDI_TOKEN_START + 55_026;
const SYSTEM_PROMPT: &str = "You are a world-class composer. Please compose some music according to the following description: ";
const MIDI_LLM_REPO_ID: &str = "slseanwu/MIDI-LLM_Llama-3.2-1B";
const TOKENIZER_FILENAME: &str = "tokenizer.json";
const SAFETENSORS_FILENAME: &str = "model.safetensors";
const CONFIG_FILENAME: &str = "config.json";
#[derive(Clone, Debug)]
pub struct MidiLlmConfig {
pub tokenizer_path: PathBuf,
pub checkpoint_path: PathBuf,
pub max_tokens: usize,
pub temperature: f32,
pub top_p: f32,
pub seed: u64,
pub max_seq_len: usize,
}
impl Default for MidiLlmConfig {
fn default() -> Self {
Self {
tokenizer_path: PathBuf::new(),
checkpoint_path: PathBuf::new(),
max_tokens: 1024,
temperature: 1.0,
top_p: 0.98,
seed: 0,
max_seq_len: 4096,
}
}
}
pub fn resolve_model_paths(model_dir_override: Option<&Path>) -> Result<(PathBuf, PathBuf)> {
if let Some(model_dir) = model_dir_override {
let tokenizer = model_dir.join(TOKENIZER_FILENAME);
let checkpoint = model_dir.join(SAFETENSORS_FILENAME);
if !tokenizer.exists() {
bail!("missing tokenizer file {}", tokenizer.display());
}
if !checkpoint.exists() {
bail!("missing checkpoint file {}", checkpoint.display());
}
return Ok((tokenizer, checkpoint));
}
let api = ApiBuilder::new()
.with_progress(true)
.build()
.context("failed to initialize Hugging Face client")?;
let repo = api.repo(Repo::new(MIDI_LLM_REPO_ID.to_string(), RepoType::Model));
let tokenizer = repo
.get(TOKENIZER_FILENAME)
.with_context(|| format!("failed to fetch {MIDI_LLM_REPO_ID}/{TOKENIZER_FILENAME}"))?;
let checkpoint = repo
.get(SAFETENSORS_FILENAME)
.with_context(|| format!("failed to fetch {MIDI_LLM_REPO_ID}/{SAFETENSORS_FILENAME}"))?;
let _ = repo.get(CONFIG_FILENAME);
Ok((tokenizer, checkpoint))
}
fn midi_llama_config(tokenizer_path: &Path) -> LlamaConfig {
LlamaConfig::new(
8192,
MIDI_VOCAB_SIZE as usize,
tokenizer_path.to_string_lossy().to_string(),
)
.with_d_model(2048)
.with_num_hidden_layers(16)
.with_num_attention_heads(32)
.with_num_key_value_heads(Some(8))
.with_norm_eps(1e-5)
.with_rope(
RopeConfig::new(500_000.0)
.with_scaled(Some(RopeFrequencyScaling::new().with_scale_factor(32.0))),
)
}
pub fn load_model<B: Backend>(
config: &MidiLlmConfig,
device: &Device<B>,
) -> Result<Llama<B, HfTokenizer>> {
let llama_config =
midi_llama_config(&config.tokenizer_path).with_max_seq_len(config.max_seq_len);
let mut llama = llama_config
.init::<B, HfTokenizer>(device)
.map_err(|err| anyhow!("failed to initialize MIDI-LLM model: {err}"))?;
load_safetensors_into_model(&mut llama, &config.checkpoint_path)?;
Ok(llama)
}
#[derive(Debug, Clone, Default)]
struct Bf16ToF32Adapter;
impl ModuleAdapter for Bf16ToF32Adapter {
fn adapt(&self, snapshot: &TensorSnapshot) -> TensorSnapshot {
if snapshot.dtype != DType::BF16 {
return snapshot.clone();
}
let original_data_fn = snapshot.clone_data_fn();
let cast_data_fn = Rc::new(move || {
let data = original_data_fn()?;
Ok(data.convert_dtype(DType::F32))
});
TensorSnapshot::from_closure(
cast_data_fn,
DType::F32,
snapshot.shape.clone(),
snapshot.path_stack.clone().unwrap_or_default(),
snapshot.container_stack.clone().unwrap_or_default(),
snapshot.tensor_id.unwrap_or_default(),
)
}
fn clone_box(&self) -> Box<dyn ModuleAdapter> {
Box::new(self.clone())
}
}
fn load_safetensors_into_model<B: Backend>(
llama: &mut Llama<B, HfTokenizer>,
checkpoint_path: &Path,
) -> Result<()> {
let patterns: Vec<(&str, &str)> = vec![
(r"^model\.embed_tokens\.(.+)$", "tok_embeddings.$1"),
(r"^lm_head\.(.+)$", "output.$1"),
(r"^model\.norm\.weight$", "norm.gamma"),
(
r"^model\.layers\.([0-9]+)\.input_layernorm\.weight$",
"layers.$1.attention_norm.gamma",
),
(
r"^model\.layers\.([0-9]+)\.post_attention_layernorm\.weight$",
"layers.$1.ffn_norm.gamma",
),
(
r"^model\.layers\.([0-9]+)\.self_attn\.q_proj\.(.+)$",
"layers.$1.attention.wq.$2",
),
(
r"^model\.layers\.([0-9]+)\.self_attn\.k_proj\.(.+)$",
"layers.$1.attention.wk.$2",
),
(
r"^model\.layers\.([0-9]+)\.self_attn\.v_proj\.(.+)$",
"layers.$1.attention.wv.$2",
),
(
r"^model\.layers\.([0-9]+)\.self_attn\.o_proj\.(.+)$",
"layers.$1.attention.wo.$2",
),
(
r"^model\.layers\.([0-9]+)\.mlp\.gate_proj\.(.+)$",
"layers.$1.feed_forward.swiglu.linear_inner.$2",
),
(
r"^model\.layers\.([0-9]+)\.mlp\.up_proj\.(.+)$",
"layers.$1.feed_forward.swiglu.linear_outer.$2",
),
(
r"^model\.layers\.([0-9]+)\.mlp\.down_proj\.(.+)$",
"layers.$1.feed_forward.w2.$2",
),
];
let remapper = KeyRemapper::from_patterns(patterns)
.map_err(|err| anyhow!("failed to build MIDI-LLM key remapper: {err}"))?;
if !checkpoint_path.exists() {
bail!(
"checkpoint file {} does not exist",
checkpoint_path.display()
);
}
let mut store = SafetensorsStore::from_file(checkpoint_path)
.with_from_adapter(PyTorchToBurnAdapter.chain(Bf16ToF32Adapter))
.remap(remapper);
let result = llama
.model
.load_from(&mut store)
.with_context(|| format!("failed to load weights from {}", checkpoint_path.display()))?;
if !result.missing.is_empty() {
let missing: Vec<String> = result.missing.iter().map(|m| m.0.clone()).collect();
bail!(
"MIDI-LLM checkpoint is missing required tensors: {}",
missing.join(", ")
);
}
Ok(())
}
pub fn generate_midi_file<B: Backend>(
config: &MidiLlmConfig,
device: &Device<B>,
prompt: &str,
bpm: f32,
time_signature: Option<TimeSignature>,
output_path: &Path,
) -> Result<()> {
let mut llama = load_model::<B>(config, device)?;
let text_prompt = format!("{SYSTEM_PROMPT}{prompt} ");
let mut input_tokens = llama.tokenizer.encode(&text_prompt, true, false);
input_tokens.push(MIDI_BOS_TOKEN);
let disallowed_mask = build_disallowed_mask(device, MIDI_TOKEN_START, MIDI_VOCAB_SIZE);
let mut sampler = Sampler::new_top_p(config.top_p as f64, config.seed);
let mut generated = Vec::with_capacity(config.max_tokens);
let mut input_tensor = tokens_to_tensor::<B>(device, &input_tokens);
let mut logits = llama
.model
.forward(input_tensor, &mut llama.cache, &llama.rope);
for _ in 0..config.max_tokens {
let next_token =
sample_next_token(&logits, &disallowed_mask, config.temperature, &mut sampler)?;
generated.push(next_token);
input_tensor = tokens_to_tensor::<B>(device, &[next_token]);
logits = llama
.model
.forward(input_tensor, &mut llama.cache, &llama.rope);
}
let amt_tokens: Vec<u32> = generated
.iter()
.map(|t| t.saturating_sub(MIDI_TOKEN_START))
.collect();
let events = tokens_to_events(&amt_tokens);
write_midi(output_path, &events, bpm, time_signature)
.with_context(|| "failed to write generated MIDI file")
}
fn tokens_to_tensor<B: Backend>(device: &Device<B>, tokens: &[u32]) -> Tensor<B, 2, Int> {
let data = TensorData::new(tokens.to_vec(), Shape::new([1, tokens.len()]));
Tensor::<B, 2, Int>::from_data(data, device)
}
fn build_disallowed_mask<B: Backend>(
device: &Device<B>,
start: u32,
end: u32,
) -> Tensor<B, 1, Bool> {
let vocab_size = end as usize;
let mut mask = vec![true; vocab_size];
for i in start..end {
mask[i as usize] = false;
}
Tensor::<B, 1, Bool>::from_data(TensorData::new(mask, Shape::new([vocab_size])), device)
}
fn sample_next_token<B: Backend>(
logits: &Tensor<B, 3>,
disallowed_mask: &Tensor<B, 1, Bool>,
temperature: f32,
sampler: &mut Sampler,
) -> Result<u32> {
let [batch_size, seq_len, vocab_size] = logits.dims();
if batch_size != 1 {
bail!("unexpected batch size {batch_size}, expected 1");
}
let mut next_logits = logits
.clone()
.slice([0..batch_size, (seq_len - 1)..seq_len, 0..vocab_size])
.squeeze_dim(1);
let mask = disallowed_mask.clone().unsqueeze_dim(0); next_logits = next_logits.mask_fill(mask, f32::NEG_INFINITY);
if temperature > 0.0 && temperature != 1.0 {
next_logits = next_logits.div_scalar(temperature);
}
let probs = softmax(next_logits, 1);
let sampled = sampler.sample(probs);
let value = sampled
.into_data()
.as_slice::<i64>()
.map_err(|e: DataError| anyhow!("failed to read sampled token: {e}"))?
.first()
.copied()
.unwrap_or(0);
Ok(value as u32)
}
#[cfg(test)]
mod tests {
use super::*;
use burn::backend::NdArray;
#[test]
fn midi_token_constants_match_discussion() {
assert_eq!(MIDI_TOKEN_START, 128_256);
assert_eq!(MIDI_TOKEN_COUNT, 55_026);
assert_eq!(MIDI_TOKEN_END, 183_282);
assert_eq!(MIDI_BOS_TOKEN, 183_282);
assert_eq!(MIDI_VOCAB_SIZE, 183_286);
}
#[test]
fn disallowed_mask_blocks_text_tokens() {
type B = NdArray<f32>;
let device = Default::default();
let mask = build_disallowed_mask::<B>(&device, MIDI_TOKEN_START, MIDI_VOCAB_SIZE);
let data = mask.into_data().to_vec::<bool>().unwrap();
assert_eq!(data.len(), MIDI_VOCAB_SIZE as usize);
assert!(data[MIDI_TOKEN_START as usize - 1]);
assert!(!data[MIDI_TOKEN_START as usize]);
assert!(!data[MIDI_VOCAB_SIZE as usize - 1]);
}
#[test]
fn midi_llama_config_has_extended_vocab() {
let config = midi_llama_config(Path::new("/dummy/tokenizer.model"));
assert_eq!(config.vocab_size, MIDI_VOCAB_SIZE as usize);
assert_eq!(config.d_model, 2048);
assert_eq!(config.num_hidden_layers, 16);
assert_eq!(config.num_attention_heads, 32);
}
}