1use anyhow::{Context, Result, anyhow, bail};
2use sentencepiece_rust::SentencePieceProcessor;
3use serde::{Deserialize, Serialize, de::DeserializeOwned};
4use std::ffi::OsString;
5use std::io::{Read, Write};
6use std::path::{Path, PathBuf};
7
8pub mod heartcodec;
9pub mod heartmula_runtime;
10pub mod text_to_midi;
11
12pub mod acestep;
13
14pub const DEFAULT_MAX_PROMPT_TOKENS: usize = 128;
15pub const DEFAULT_CFG_SCALE: f32 = 1.5;
16pub const IPC_MODE_ENV: &str = "MAOLAN_BURN_SOCKETPAIR";
17
18pub fn stderr_logging_enabled() -> bool {
19 std::env::var_os(IPC_MODE_ENV).is_none()
20}
21
22#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
23#[serde(rename_all = "lowercase")]
24pub enum BackendChoice {
25 Cpu,
26 #[default]
27 Vulkan,
28}
29
30#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
31pub enum ModelChoice {
32 #[serde(rename = "happy-new-year")]
33 #[default]
34 HappyNewYear,
35 #[serde(rename = "RL")]
36 Rl,
37 #[serde(rename = "acestep-turbo")]
38 AceStepTurbo,
39 #[serde(rename = "acestep-sft")]
40 AceStepSft,
41 #[serde(rename = "text-to-midi")]
42 TextToMidi,
43 #[serde(rename = "midi-llm")]
44 MidiLlm,
45}
46
47#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
48pub enum AceStepLmSize {
49 #[serde(rename = "0.6B")]
50 #[default]
51 B0_6,
52 #[serde(rename = "1.7B")]
53 B1_7,
54 #[serde(rename = "4B")]
55 B4,
56}
57
58#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
59pub struct GenerateRequest {
60 #[serde(default)]
61 pub model: ModelChoice,
62 pub prompt: String,
63 #[serde(default)]
64 pub model_dir: Option<PathBuf>,
65 #[serde(default = "default_output_path")]
66 pub output_path: PathBuf,
67 #[serde(default)]
68 pub inspect_only: bool,
69 pub backend: BackendChoice,
70 pub cfg_scale: f32,
71 #[serde(alias = "seconds_total", alias = "max_audio_length_ms")]
72 pub length: usize,
73
74 #[serde(default = "default_ode_steps")]
75 pub ode_steps: usize,
76
77 #[serde(default)]
78 pub lyrics: Option<String>,
79
80 #[serde(default)]
81 pub tags: Option<String>,
82
83 #[serde(default = "default_topk")]
84 pub topk: usize,
85
86 #[serde(default = "default_temperature")]
87 pub temperature: f32,
88
89 #[serde(default)]
90 pub decode_only: bool,
91
92 #[serde(default)]
93 pub frames_json: Option<PathBuf>,
94
95 #[serde(default)]
96 pub decode_threads: Option<usize>,
97
98 #[serde(default)]
99 pub decoder_seed: u64,
100
101 #[serde(default)]
103 pub bpm: Option<f32>,
104
105 #[serde(default)]
107 pub key_scale: Option<String>,
108
109 #[serde(default)]
111 pub time_signature: Option<String>,
112
113 #[serde(default)]
115 pub acestep_lm: AceStepLmSize,
116
117 #[serde(default = "default_midi_length_seconds")]
119 pub midi_length_seconds: f32,
120
121 #[serde(default)]
123 pub midi_seed: u64,
124
125 #[serde(default = "default_midi_max_tokens")]
127 pub midi_max_tokens: usize,
128
129 #[serde(default = "default_midi_top_p")]
131 pub midi_top_p: f32,
132
133 #[serde(default, skip)]
135 pub output_path_explicit: bool,
136}
137
138fn default_ode_steps() -> usize {
139 10
140}
141
142fn default_midi_length_seconds() -> f32 {
143 10.0
144}
145
146fn default_midi_max_tokens() -> usize {
147 1024
148}
149
150fn default_midi_top_p() -> f32 {
151 0.98
152}
153
154fn default_topk() -> usize {
155 50
156}
157
158fn default_temperature() -> f32 {
159 1.0
160}
161
162pub type CliOptions = GenerateRequest;
163
164#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
165pub struct GenerateResponseHeader {
166 pub backend: BackendChoice,
167 pub channels: usize,
168 pub frames: usize,
169 pub guidance_scale: f32,
170 pub prompt_tokens: i64,
171 pub sample_rate_hz: u32,
172 pub length: usize,
173 pub steps: usize,
174}
175
176#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
177pub struct GenerateError {
178 pub error: String,
179}
180
181#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
182pub struct GenerateProgress {
183 pub phase: String,
184 pub progress: f32,
185 pub operation: String,
186}
187
188fn default_output_path() -> PathBuf {
189 PathBuf::from("output.wav")
190}
191
192pub fn help_text() -> &'static str {
193 "\
194maolan-generate
195
196Usage:
197 maolan-generate [options] <prompt-or-lyrics>
198
199Options:
200 --model <happy-new-year|RL|acestep-turbo|acestep-sft|text-to-midi|midi-llm>
201 --model-dir <path>
202 --output <path>
203 --inspect
204 --backend <cpu|vulkan> Select the runtime backend
205 --lyrics <text> Prompt / lyrics (positional argument also accepted)
206 --tags <text> Style tags for HeartMula
207 --cfg-scale <float> CFG scale (1.0=no guidance, 2.0=weak, 6.0=strong)
208 --length <int> HeartMula: output length in milliseconds
209 --topk <int> HeartMula: top-k sampling (default: 50)
210 --temperature <float> HeartMula / MIDI-LLM: sampling temperature (default: 1.0)
211 --ode-steps <int> HeartMula: flow matching steps (5=fast, 10=default, 20=best)
212 --decoder-seed <int> Seed for deterministic HeartCodec decoder latents
213 --decode-only Decode an existing frames JSON instead of generating tokens
214 --frames-json <path> Frames JSON input for --decode-only
215 --decode-threads <int> Number of worker threads for decode-only CPU decoding
216 --bpm <float> ACE-Step / text-to-MIDI / MIDI-LLM: tempo in beats per minute (20-400)
217 --key-scale <text> ACE-Step / text-to-MIDI: musical key, e.g. 'C major' or 'A minor'
218 --time-signature <N/D> ACE-Step / text-to-MIDI / MIDI-LLM: time signature, e.g. '4/4' or '6/8'
219 --acestep-lm <0.6B|1.7B|4B>
220 --midi-length <float> Text-to-MIDI: output length in seconds (default: 10)
221 --midi-seed <int> Text-to-MIDI / MIDI-LLM: seed for deterministic output
222 --midi-max-tokens <int> MIDI-LLM: maximum music tokens to generate (default: 1024)
223 --midi-top-p <float> MIDI-LLM: top-p nucleus-sampling threshold (default: 0.98)
224 -h, --help
225"
226}
227
228pub fn parse_options(args: impl IntoIterator<Item = OsString>) -> Result<CliOptions> {
229 let mut args = args.into_iter();
230 let _program = args.next();
231 let mut prompt = None;
232 let mut model_dir = None;
233 let mut output_path = default_output_path();
234 let mut inspect_only = false;
235 let mut model = ModelChoice::HappyNewYear;
236 let mut backend = BackendChoice::Vulkan;
237 let mut cfg_scale = DEFAULT_CFG_SCALE;
238 let mut length = 6_000_usize;
239 let mut ode_steps = 10_usize;
240 let mut lyrics = None;
241 let mut tags = None;
242 let mut topk = default_topk();
243 let mut temperature = default_temperature();
244 let mut decode_only = false;
245 let mut frames_json = None;
246 let mut decode_threads = None;
247 let mut decoder_seed = 0_u64;
248 let mut bpm = None;
249 let mut key_scale = None;
250 let mut time_signature = None;
251 let mut acestep_lm = AceStepLmSize::default();
252 let mut midi_length_seconds = default_midi_length_seconds();
253 let mut midi_seed = 0_u64;
254 let mut midi_max_tokens = default_midi_max_tokens();
255 let mut midi_top_p = default_midi_top_p();
256 let mut output_path_explicit = false;
257
258 while let Some(arg) = args.next() {
259 let arg = arg
260 .into_string()
261 .map_err(|_| anyhow!("arguments must be valid UTF-8"))?;
262
263 if matches!(arg.as_str(), "-h" | "--help") {
264 bail!(help_text());
265 }
266
267 if arg == "--backend" {
268 let value = args
269 .next()
270 .ok_or_else(|| anyhow!("missing value after --backend"))?
271 .into_string()
272 .map_err(|_| anyhow!("backend value must be valid UTF-8"))?;
273 backend = match value.as_str() {
274 "cpu" => BackendChoice::Cpu,
275 "vulkan" => BackendChoice::Vulkan,
276 _ => bail!("unsupported backend '{value}', expected one of: cpu, vulkan"),
277 };
278 continue;
279 }
280
281 if arg == "--model-dir" {
282 model_dir = Some(PathBuf::from(
283 args.next()
284 .ok_or_else(|| anyhow!("missing value after --model-dir"))?,
285 ));
286 continue;
287 }
288
289 if arg == "--output" {
290 output_path = PathBuf::from(
291 args.next()
292 .ok_or_else(|| anyhow!("missing value after --output"))?,
293 );
294 output_path_explicit = true;
295 continue;
296 }
297
298 if arg == "--lyrics" {
299 let value = args
300 .next()
301 .ok_or_else(|| anyhow!("missing value after --lyrics"))?
302 .into_string()
303 .map_err(|_| anyhow!("lyrics value must be valid UTF-8"))?;
304 lyrics = Some(value);
305 continue;
306 }
307
308 if arg == "--tags" {
309 let value = args
310 .next()
311 .ok_or_else(|| anyhow!("missing value after --tags"))?
312 .into_string()
313 .map_err(|_| anyhow!("tags value must be valid UTF-8"))?;
314 tags = Some(value);
315 continue;
316 }
317
318 if arg == "--length" {
319 let value = args
320 .next()
321 .ok_or_else(|| anyhow!("missing value after --length"))?
322 .into_string()
323 .map_err(|_| anyhow!("length value must be valid UTF-8"))?;
324 length = value
325 .parse::<usize>()
326 .map_err(|_| anyhow!("length must be a whole number"))?;
327 continue;
328 }
329
330 if arg == "--topk" {
331 let value = args
332 .next()
333 .ok_or_else(|| anyhow!("missing value after --topk"))?
334 .into_string()
335 .map_err(|_| anyhow!("topk value must be valid UTF-8"))?;
336 topk = value
337 .parse::<usize>()
338 .map_err(|_| anyhow!("topk must be a whole number"))?;
339 if topk == 0 {
340 bail!("topk must be greater than zero");
341 }
342 continue;
343 }
344
345 if arg == "--temperature" {
346 let value = args
347 .next()
348 .ok_or_else(|| anyhow!("missing value after --temperature"))?
349 .into_string()
350 .map_err(|_| anyhow!("temperature value must be valid UTF-8"))?;
351 temperature = value
352 .parse::<f32>()
353 .map_err(|_| anyhow!("temperature must be a number"))?;
354 if !temperature.is_finite() || temperature < 0.0 {
355 bail!("temperature must be a finite non-negative number");
356 }
357 continue;
358 }
359
360 if arg == "--inspect" {
361 inspect_only = true;
362 continue;
363 }
364
365 if arg == "--decode-only" {
366 decode_only = true;
367 continue;
368 }
369
370 if arg == "--frames-json" {
371 frames_json =
372 Some(PathBuf::from(args.next().ok_or_else(|| {
373 anyhow!("missing value after --frames-json")
374 })?));
375 continue;
376 }
377
378 if arg == "--decode-threads" {
379 let value = args
380 .next()
381 .ok_or_else(|| anyhow!("missing value after --decode-threads"))?
382 .into_string()
383 .map_err(|_| anyhow!("decode-threads value must be valid UTF-8"))?;
384 decode_threads = Some(
385 value
386 .parse::<usize>()
387 .map_err(|_| anyhow!("decode-threads must be a whole number"))?,
388 );
389 continue;
390 }
391
392 if arg == "--decoder-seed" {
393 let value = args
394 .next()
395 .ok_or_else(|| anyhow!("missing value after --decoder-seed"))?
396 .into_string()
397 .map_err(|_| anyhow!("decoder-seed value must be valid UTF-8"))?;
398 decoder_seed = value
399 .parse::<u64>()
400 .map_err(|_| anyhow!("decoder-seed must be a whole number"))?;
401 continue;
402 }
403
404 if arg == "--bpm" {
405 let value = args
406 .next()
407 .ok_or_else(|| anyhow!("missing value after --bpm"))?
408 .into_string()
409 .map_err(|_| anyhow!("bpm value must be valid UTF-8"))?;
410 let parsed = value
411 .parse::<f32>()
412 .map_err(|_| anyhow!("bpm must be a number"))?;
413 bpm = Some(parsed);
414 continue;
415 }
416
417 if arg == "--key-scale" {
418 let value = args
419 .next()
420 .ok_or_else(|| anyhow!("missing value after --key-scale"))?
421 .into_string()
422 .map_err(|_| anyhow!("key-scale value must be valid UTF-8"))?;
423 key_scale = Some(value);
424 continue;
425 }
426
427 if arg == "--time-signature" {
428 let value = args
429 .next()
430 .ok_or_else(|| anyhow!("missing value after --time-signature"))?
431 .into_string()
432 .map_err(|_| anyhow!("time-signature value must be valid UTF-8"))?;
433 time_signature = Some(value);
434 continue;
435 }
436
437 if arg == "--acestep-lm" {
438 let value = args
439 .next()
440 .ok_or_else(|| anyhow!("missing value after --acestep-lm"))?
441 .into_string()
442 .map_err(|_| anyhow!("acestep-lm value must be valid UTF-8"))?;
443 acestep_lm = parse_acestep_lm_size(&value)?;
444 continue;
445 }
446
447 if arg == "--midi-length" {
448 let value = args
449 .next()
450 .ok_or_else(|| anyhow!("missing value after --midi-length"))?
451 .into_string()
452 .map_err(|_| anyhow!("midi-length value must be valid UTF-8"))?;
453 midi_length_seconds = value
454 .parse::<f32>()
455 .map_err(|_| anyhow!("midi-length must be a number"))?;
456 if !midi_length_seconds.is_finite() || midi_length_seconds <= 0.0 {
457 bail!("midi-length must be a positive finite number");
458 }
459 continue;
460 }
461
462 if arg == "--midi-seed" {
463 let value = args
464 .next()
465 .ok_or_else(|| anyhow!("missing value after --midi-seed"))?
466 .into_string()
467 .map_err(|_| anyhow!("midi-seed value must be valid UTF-8"))?;
468 midi_seed = value
469 .parse::<u64>()
470 .map_err(|_| anyhow!("midi-seed must be a whole number"))?;
471 continue;
472 }
473
474 if arg == "--midi-max-tokens" {
475 let value = args
476 .next()
477 .ok_or_else(|| anyhow!("missing value after --midi-max-tokens"))?
478 .into_string()
479 .map_err(|_| anyhow!("midi-max-tokens value must be valid UTF-8"))?;
480 midi_max_tokens = value
481 .parse::<usize>()
482 .map_err(|_| anyhow!("midi-max-tokens must be a whole number"))?;
483 if midi_max_tokens == 0 {
484 bail!("midi-max-tokens must be greater than zero");
485 }
486 continue;
487 }
488
489 if arg == "--midi-top-p" {
490 let value = args
491 .next()
492 .ok_or_else(|| anyhow!("missing value after --midi-top-p"))?
493 .into_string()
494 .map_err(|_| anyhow!("midi-top-p value must be valid UTF-8"))?;
495 midi_top_p = value
496 .parse::<f32>()
497 .map_err(|_| anyhow!("midi-top-p must be a number"))?;
498 if !midi_top_p.is_finite() || !(0.0..=1.0).contains(&midi_top_p) {
499 bail!("midi-top-p must be between 0 and 1");
500 }
501 continue;
502 }
503
504 if arg == "--model" {
505 let value = args
506 .next()
507 .ok_or_else(|| anyhow!("missing value after --model"))?
508 .into_string()
509 .map_err(|_| anyhow!("model value must be valid UTF-8"))?;
510 model = match value.as_str() {
511 "happy-new-year" => ModelChoice::HappyNewYear,
512 "RL" => ModelChoice::Rl,
513 "acestep-turbo" => ModelChoice::AceStepTurbo,
514 "acestep-sft" => ModelChoice::AceStepSft,
515 "text-to-midi" => ModelChoice::TextToMidi,
516 "midi-llm" => ModelChoice::MidiLlm,
517 _ => {
518 bail!(
519 "unsupported model '{value}', expected one of: happy-new-year, RL, acestep-turbo, acestep-sft, text-to-midi, midi-llm"
520 )
521 }
522 };
523 continue;
524 }
525
526 if arg == "--cfg-scale" {
527 let value = args
528 .next()
529 .ok_or_else(|| anyhow!("missing value after --cfg-scale"))?
530 .into_string()
531 .map_err(|_| anyhow!("cfg-scale value must be valid UTF-8"))?;
532 cfg_scale = value
533 .parse::<f32>()
534 .map_err(|_| anyhow!("cfg-scale must be a number"))?;
535 if !cfg_scale.is_finite() || cfg_scale < 0.0 {
536 bail!("cfg-scale must be a finite non-negative number");
537 }
538 continue;
539 }
540
541 if arg == "--ode-steps" {
542 let value = args
543 .next()
544 .ok_or_else(|| anyhow!("missing value after --ode-steps"))?
545 .into_string()
546 .map_err(|_| anyhow!("ode-steps value must be valid UTF-8"))?;
547 ode_steps = value
548 .parse::<usize>()
549 .map_err(|_| anyhow!("ode-steps must be a whole number"))?;
550 if ode_steps == 0 || ode_steps > 50 {
551 bail!("ode-steps must be between 1 and 50");
552 }
553 continue;
554 }
555
556 if prompt.is_some() {
557 bail!("expected exactly one positional argument: the prompt");
558 }
559 prompt = Some(arg);
560 }
561
562 let prompt = if decode_only {
563 prompt.unwrap_or_default()
564 } else if let Some(lyrics) = lyrics {
565 lyrics
566 } else {
567 prompt.ok_or_else(|| {
568 anyhow!("missing prompt argument; provide a positional argument or --lyrics")
569 })?
570 };
571 let trimmed = prompt.trim();
572
573 if !decode_only && trimmed.is_empty() {
574 bail!("prompt argument cannot be empty");
575 }
576
577 validate_options(CliOptions {
578 model,
579 prompt: trimmed.to_owned(),
580 model_dir,
581 output_path,
582 inspect_only,
583 backend,
584 cfg_scale,
585 length,
586 ode_steps,
587 lyrics: None,
588 tags,
589 topk,
590 temperature,
591 decode_only,
592 frames_json,
593 decode_threads,
594 decoder_seed,
595 bpm,
596 key_scale,
597 time_signature,
598 acestep_lm,
599 midi_length_seconds,
600 midi_seed,
601 midi_max_tokens,
602 midi_top_p,
603 output_path_explicit,
604 })
605}
606
607fn parse_acestep_lm_size(value: &str) -> Result<AceStepLmSize> {
608 match value {
609 "0.6B" | "0.6b" => Ok(AceStepLmSize::B0_6),
610 "1.7B" | "1.7b" => Ok(AceStepLmSize::B1_7),
611 "4B" | "4b" => Ok(AceStepLmSize::B4),
612 _ => bail!("unsupported --acestep-lm '{value}', expected one of: 0.6B, 1.7B, 4B"),
613 }
614}
615
616pub fn validate_options(mut options: CliOptions) -> Result<CliOptions> {
617 let prompt = options.prompt.trim();
618 if prompt.is_empty() && !options.decode_only {
619 bail!("prompt argument cannot be empty");
620 }
621 options.prompt = prompt.to_owned();
622
623 options.tags = options
624 .tags
625 .as_deref()
626 .map(str::trim)
627 .filter(|value| !value.is_empty())
628 .map(str::to_owned);
629
630 options.model_dir = options
631 .model_dir
632 .as_deref()
633 .map(Path::new)
634 .map(Path::to_path_buf);
635
636 if !options.cfg_scale.is_finite() || options.cfg_scale < 0.0 {
637 bail!("cfg-scale must be a finite non-negative number");
638 }
639 if options.length == 0 {
640 bail!("length must be greater than zero");
641 }
642 if options.output_path.as_os_str().is_empty() {
643 bail!("output path cannot be empty");
644 }
645 if matches!(
646 options.model,
647 ModelChoice::TextToMidi | ModelChoice::MidiLlm
648 ) && !options.output_path_explicit
649 {
650 options.output_path = PathBuf::from("output.mid");
651 }
652 if !options.midi_length_seconds.is_finite() || options.midi_length_seconds <= 0.0 {
653 bail!("midi-length must be a positive finite number");
654 }
655 if options.midi_max_tokens == 0 {
656 bail!("midi-max-tokens must be greater than zero");
657 }
658 if !options.midi_top_p.is_finite() || !(0.0..=1.0).contains(&options.midi_top_p) {
659 bail!("midi-top-p must be between 0 and 1");
660 }
661 if options.decode_only && options.frames_json.is_none() {
662 bail!("--decode-only requires --frames-json");
663 }
664 if options.frames_json.is_some() && !options.decode_only {
665 bail!("--frames-json can only be used with --decode-only");
666 }
667 if let Some(threads) = options.decode_threads
668 && threads == 0
669 {
670 bail!("--decode-threads must be greater than zero");
671 }
672
673 if let Some(bpm) = options.bpm
674 && (!bpm.is_finite() || !(20.0..=400.0).contains(&bpm))
675 {
676 bail!("bpm must be a finite number between 20 and 400");
677 }
678
679 options.key_scale = options
680 .key_scale
681 .as_deref()
682 .map(str::trim)
683 .filter(|value| !value.is_empty())
684 .map(str::to_owned);
685 if let Some(key_scale) = options.key_scale.as_deref() {
686 validate_key_scale(key_scale)?;
687 }
688
689 options.time_signature = options
690 .time_signature
691 .as_deref()
692 .map(str::trim)
693 .filter(|value| !value.is_empty())
694 .map(str::to_owned);
695 if let Some(time_signature) = options.time_signature.as_deref() {
696 validate_time_signature(time_signature)?;
697 }
698
699 Ok(options)
700}
701
702fn validate_key_scale(key_scale: &str) -> Result<()> {
703 let mut parts = key_scale.split_whitespace();
704 let (Some(root), Some(mode), None) = (parts.next(), parts.next(), parts.next()) else {
705 bail!("key-scale must be formatted as \"<note> <major|minor>\", e.g. \"C major\"");
706 };
707 let mut chars = root.chars();
708 let letter = chars.next().unwrap_or_default();
709 if !matches!(letter, 'A'..='G' | 'a'..='g') {
710 bail!("key-scale root must be a note letter A-G, got '{root}'");
711 }
712 let accidental: String = chars.collect();
713 if !accidental.is_empty() && accidental != "#" && accidental != "b" {
714 bail!("key-scale root may only have a '#' or 'b' accidental, got '{root}'");
715 }
716 if !matches!(mode.to_ascii_lowercase().as_str(), "major" | "minor") {
717 bail!("key-scale mode must be 'major' or 'minor', got '{mode}'");
718 }
719 Ok(())
720}
721
722fn validate_time_signature(time_signature: &str) -> Result<()> {
723 let Some((numerator, denominator)) = time_signature.split_once('/') else {
724 bail!("time-signature must be formatted as \"N/D\", e.g. \"4/4\"");
725 };
726 let numerator = numerator
727 .parse::<u8>()
728 .map_err(|_| anyhow!("time-signature numerator must be a whole number"))?;
729 let denominator = denominator
730 .parse::<u8>()
731 .map_err(|_| anyhow!("time-signature denominator must be a whole number"))?;
732 if numerator == 0 || denominator == 0 {
733 bail!("time-signature numerator and denominator must be greater than zero");
734 }
735 Ok(())
736}
737
738pub fn read_ipc_message<T: DeserializeOwned>(reader: &mut impl Read) -> Result<T> {
739 let mut len_bytes = [0_u8; 8];
740 reader
741 .read_exact(&mut len_bytes)
742 .context("failed to read IPC message length")?;
743 let len = u64::from_le_bytes(len_bytes);
744 let len = usize::try_from(len).context("IPC message length is too large")?;
745 let mut payload = vec![0_u8; len];
746 reader
747 .read_exact(&mut payload)
748 .context("failed to read IPC message payload")?;
749 serde_json::from_slice(&payload).context("failed to decode IPC JSON message")
750}
751
752pub fn write_ipc_message<T: Serialize>(writer: &mut impl Write, value: &T) -> Result<()> {
753 let payload = serde_json::to_vec(value).context("failed to encode IPC JSON message")?;
754 let len = u64::try_from(payload.len()).context("IPC payload is too large")?;
755 writer
756 .write_all(&len.to_le_bytes())
757 .context("failed to write IPC message length")?;
758 writer
759 .write_all(&payload)
760 .context("failed to write IPC message payload")?;
761 writer.flush().context("failed to flush IPC JSON message")?;
762 Ok(())
763}
764
765pub fn write_ipc_bytes(writer: &mut impl Write, bytes: &[u8]) -> Result<()> {
766 let len = u64::try_from(bytes.len()).context("IPC byte payload is too large")?;
767 writer
768 .write_all(&len.to_le_bytes())
769 .context("failed to write IPC byte length")?;
770 writer
771 .write_all(bytes)
772 .context("failed to write IPC byte payload")?;
773 writer.flush().context("failed to flush IPC byte payload")?;
774 Ok(())
775}
776
777pub fn tokenizer_path() -> PathBuf {
778 Path::new(env!("CARGO_MANIFEST_DIR"))
779 .join("assets")
780 .join("t5-base-spiece.model")
781}
782
783pub fn load_tokenizer() -> Result<SentencePieceProcessor> {
784 SentencePieceProcessor::open(tokenizer_path())
785 .context("failed to open the bundled T5 sentencepiece model")
786}
787
788pub fn encode_prompt(
789 tokenizer: &SentencePieceProcessor,
790 prompt: &str,
791 max_tokens: usize,
792) -> Result<(Vec<i64>, Vec<i64>)> {
793 let mut token_ids = Vec::with_capacity(max_tokens);
794
795 if tokenizer.vocab().bos_id >= 0 {
796 token_ids.push(i64::from(tokenizer.vocab().bos_id));
797 }
798
799 for id in tokenizer
800 .encode(prompt)
801 .context("failed to tokenize prompt")?
802 {
803 if token_ids.len() >= max_tokens {
804 break;
805 }
806 token_ids.push(i64::from(id));
807 }
808
809 if token_ids.len() < max_tokens && tokenizer.vocab().eos_id >= 0 {
810 token_ids.push(i64::from(tokenizer.vocab().eos_id));
811 }
812
813 if token_ids.len() > max_tokens {
814 token_ids.truncate(max_tokens);
815 }
816
817 let attention_len = token_ids.len();
818 let mut attention_mask = vec![1_i64; attention_len];
819 token_ids.resize(max_tokens, 0);
820 attention_mask.resize(max_tokens, 0);
821
822 Ok((token_ids, attention_mask))
823}
824
825#[cfg(test)]
826mod tests {
827 use super::{
828 AceStepLmSize, BackendChoice, DEFAULT_MAX_PROMPT_TOKENS, ModelChoice, parse_options,
829 };
830 use std::ffi::OsString;
831
832 #[test]
833 fn parses_single_prompt_argument() {
834 let args = [OsString::from("generate"), OsString::from("warm tape hiss")];
835 let options = parse_options(args).expect("options should parse");
836 assert_eq!(options.prompt, "warm tape hiss");
837 assert_eq!(options.model, ModelChoice::HappyNewYear);
838 assert_eq!(options.backend, BackendChoice::Vulkan);
839 assert_eq!(options.cfg_scale, 1.5);
840 assert_eq!(options.length, 6_000);
841 }
842
843 #[test]
844 fn trims_surrounding_whitespace() {
845 let args = [
846 OsString::from("generate"),
847 OsString::from(" foley footsteps "),
848 ];
849 let options = parse_options(args).expect("options should parse");
850 assert_eq!(options.prompt, "foley footsteps");
851 }
852
853 #[test]
854 fn rejects_missing_prompt() {
855 let args = [OsString::from("generate")];
856 assert!(parse_options(args).is_err());
857 }
858
859 #[test]
860 fn parses_backend_flag_after_prompt() {
861 let args = [
862 OsString::from("generate"),
863 OsString::from("warm tape hiss"),
864 OsString::from("--backend"),
865 OsString::from("vulkan"),
866 ];
867 let options = parse_options(args).expect("options should parse");
868 assert_eq!(options.backend, BackendChoice::Vulkan);
869 }
870
871 #[test]
872 fn parses_model_flag() {
873 let args = [
874 OsString::from("generate"),
875 OsString::from("--model"),
876 OsString::from("happy-new-year"),
877 OsString::from("verse and chorus"),
878 ];
879 let options = parse_options(args).expect("options should parse");
880 assert_eq!(options.model, ModelChoice::HappyNewYear);
881 }
882
883 #[test]
884 fn parses_rl_model_flag() {
885 let args = [
886 OsString::from("generate"),
887 OsString::from("--model"),
888 OsString::from("RL"),
889 OsString::from("verse and chorus"),
890 ];
891 let options = parse_options(args).expect("options should parse");
892 assert_eq!(options.model, ModelChoice::Rl);
893 }
894
895 #[test]
896 fn parses_acestep_turbo_model_flag() {
897 let args = [
898 OsString::from("generate"),
899 OsString::from("--model"),
900 OsString::from("acestep-turbo"),
901 OsString::from("funky bassline"),
902 ];
903 let options = parse_options(args).expect("options should parse");
904 assert_eq!(options.model, ModelChoice::AceStepTurbo);
905 }
906
907 #[test]
908 fn parses_tags_cfg_and_length() {
909 let args = [
910 OsString::from("generate"),
911 OsString::from("--tags"),
912 OsString::from("warm tape hiss"),
913 OsString::from("--cfg-scale"),
914 OsString::from("4.5"),
915 OsString::from("--ode-steps"),
916 OsString::from("20"),
917 OsString::from("--length"),
918 OsString::from("8000"),
919 OsString::from("verse and chorus"),
920 ];
921 let options = parse_options(args).expect("options should parse");
922 assert_eq!(options.cfg_scale, 4.5);
923 assert_eq!(options.ode_steps, 20);
924 assert_eq!(options.length, 8_000);
925 }
926
927 #[test]
928 fn parses_decode_only_without_prompt() {
929 let args = [
930 OsString::from("generate"),
931 OsString::from("--decode-only"),
932 OsString::from("--frames-json"),
933 OsString::from("/tmp/frames.json"),
934 ];
935 let options = parse_options(args).expect("options should parse");
936 assert!(options.decode_only);
937 assert_eq!(
938 options.frames_json.as_deref(),
939 Some(std::path::Path::new("/tmp/frames.json"))
940 );
941 assert!(options.prompt.is_empty());
942 }
943
944 #[test]
945 fn parses_decode_threads() {
946 let args = [
947 OsString::from("generate"),
948 OsString::from("--decode-only"),
949 OsString::from("--frames-json"),
950 OsString::from("/tmp/frames.json"),
951 OsString::from("--decode-threads"),
952 OsString::from("8"),
953 ];
954 let options = parse_options(args).expect("options should parse");
955 assert_eq!(options.decode_threads, Some(8));
956 }
957
958 const _: () = assert!(DEFAULT_MAX_PROMPT_TOKENS == 128);
959
960 #[test]
961 fn parses_cpu_backend_flag() {
962 let args = [
963 OsString::from("generate"),
964 OsString::from("--backend"),
965 OsString::from("cpu"),
966 OsString::from("test prompt"),
967 ];
968 let options = parse_options(args).expect("options should parse");
969 assert_eq!(options.backend, BackendChoice::Cpu);
970 }
971
972 #[test]
973 fn rejects_invalid_backend() {
974 let args = [
975 OsString::from("generate"),
976 OsString::from("--backend"),
977 OsString::from("invalid"),
978 OsString::from("test prompt"),
979 ];
980 assert!(parse_options(args).is_err());
981 }
982
983 #[test]
984 fn parses_cfg_scale_validation() {
985 let args = [
986 OsString::from("generate"),
987 OsString::from("--cfg-scale"),
988 OsString::from("2.5"),
989 OsString::from("test prompt"),
990 ];
991 let options = parse_options(args).expect("options should parse");
992 assert_eq!(options.cfg_scale, 2.5);
993 }
994
995 #[test]
996 fn rejects_negative_cfg_scale() {
997 let args = [
998 OsString::from("generate"),
999 OsString::from("--cfg-scale"),
1000 OsString::from("-1.0"),
1001 OsString::from("test prompt"),
1002 ];
1003 assert!(parse_options(args).is_err());
1004 }
1005
1006 #[test]
1007 fn rejects_invalid_cfg_scale() {
1008 let args = [
1009 OsString::from("generate"),
1010 OsString::from("--cfg-scale"),
1011 OsString::from("not-a-number"),
1012 OsString::from("test prompt"),
1013 ];
1014 assert!(parse_options(args).is_err());
1015 }
1016
1017 #[test]
1018 fn parses_temperature() {
1019 let args = [
1020 OsString::from("generate"),
1021 OsString::from("--temperature"),
1022 OsString::from("0.8"),
1023 OsString::from("test prompt"),
1024 ];
1025 let options = parse_options(args).expect("options should parse");
1026 assert_eq!(options.temperature, 0.8);
1027 }
1028
1029 #[test]
1030 fn rejects_negative_temperature() {
1031 let args = [
1032 OsString::from("generate"),
1033 OsString::from("--temperature"),
1034 OsString::from("-0.5"),
1035 OsString::from("test prompt"),
1036 ];
1037 assert!(parse_options(args).is_err());
1038 }
1039
1040 #[test]
1041 fn parses_topk() {
1042 let args = [
1043 OsString::from("generate"),
1044 OsString::from("--topk"),
1045 OsString::from("25"),
1046 OsString::from("test prompt"),
1047 ];
1048 let options = parse_options(args).expect("options should parse");
1049 assert_eq!(options.topk, 25);
1050 }
1051
1052 #[test]
1053 fn rejects_zero_topk() {
1054 let args = [
1055 OsString::from("generate"),
1056 OsString::from("--topk"),
1057 OsString::from("0"),
1058 OsString::from("test prompt"),
1059 ];
1060 assert!(parse_options(args).is_err());
1061 }
1062
1063 #[test]
1064 fn parses_ode_steps() {
1065 let args = [
1066 OsString::from("generate"),
1067 OsString::from("--ode-steps"),
1068 OsString::from("15"),
1069 OsString::from("test prompt"),
1070 ];
1071 let options = parse_options(args).expect("options should parse");
1072 assert_eq!(options.ode_steps, 15);
1073 }
1074
1075 #[test]
1076 fn rejects_zero_ode_steps() {
1077 let args = [
1078 OsString::from("generate"),
1079 OsString::from("--ode-steps"),
1080 OsString::from("0"),
1081 OsString::from("test prompt"),
1082 ];
1083 assert!(parse_options(args).is_err());
1084 }
1085
1086 #[test]
1087 fn rejects_too_many_ode_steps() {
1088 let args = [
1089 OsString::from("generate"),
1090 OsString::from("--ode-steps"),
1091 OsString::from("51"),
1092 OsString::from("test prompt"),
1093 ];
1094 assert!(parse_options(args).is_err());
1095 }
1096
1097 #[test]
1098 fn parses_output_path() {
1099 let args = [
1100 OsString::from("generate"),
1101 OsString::from("--output"),
1102 OsString::from("/tmp/output.wav"),
1103 OsString::from("test prompt"),
1104 ];
1105 let options = parse_options(args).expect("options should parse");
1106 assert_eq!(
1107 options.output_path,
1108 std::path::PathBuf::from("/tmp/output.wav")
1109 );
1110 }
1111
1112 #[test]
1113 fn parses_model_dir() {
1114 let args = [
1115 OsString::from("generate"),
1116 OsString::from("--model-dir"),
1117 OsString::from("/tmp/models"),
1118 OsString::from("test prompt"),
1119 ];
1120 let options = parse_options(args).expect("options should parse");
1121 assert_eq!(
1122 options.model_dir,
1123 Some(std::path::PathBuf::from("/tmp/models"))
1124 );
1125 }
1126
1127 #[test]
1128 fn parses_decoder_seed() {
1129 let args = [
1130 OsString::from("generate"),
1131 OsString::from("--decoder-seed"),
1132 OsString::from("42"),
1133 OsString::from("test prompt"),
1134 ];
1135 let options = parse_options(args).expect("options should parse");
1136 assert_eq!(options.decoder_seed, 42);
1137 }
1138
1139 #[test]
1140 fn parses_bpm_key_scale_and_time_signature() {
1141 let args = [
1142 OsString::from("generate"),
1143 OsString::from("--bpm"),
1144 OsString::from("128"),
1145 OsString::from("--key-scale"),
1146 OsString::from("A minor"),
1147 OsString::from("--time-signature"),
1148 OsString::from("6/8"),
1149 OsString::from("test prompt"),
1150 ];
1151 let options = parse_options(args).expect("options should parse");
1152 assert_eq!(options.bpm, Some(128.0));
1153 assert_eq!(options.key_scale.as_deref(), Some("A minor"));
1154 assert_eq!(options.time_signature.as_deref(), Some("6/8"));
1155 }
1156
1157 #[test]
1158 fn parses_acestep_lm_size() {
1159 let args = [
1160 OsString::from("generate"),
1161 OsString::from("--model"),
1162 OsString::from("acestep-turbo"),
1163 OsString::from("--acestep-lm"),
1164 OsString::from("1.7B"),
1165 OsString::from("test prompt"),
1166 ];
1167 let options = parse_options(args).expect("options should parse");
1168 assert_eq!(options.acestep_lm, AceStepLmSize::B1_7);
1169 }
1170
1171 #[test]
1172 fn rejects_unknown_acestep_lm_size() {
1173 let args = [
1174 OsString::from("generate"),
1175 OsString::from("--acestep-lm"),
1176 OsString::from("2B"),
1177 OsString::from("test prompt"),
1178 ];
1179 assert!(parse_options(args).is_err());
1180 }
1181
1182 #[test]
1183 fn rejects_out_of_range_bpm() {
1184 for bpm in ["10", "500", "nan"] {
1185 let args = [
1186 OsString::from("generate"),
1187 OsString::from("--bpm"),
1188 OsString::from(bpm),
1189 OsString::from("test prompt"),
1190 ];
1191 assert!(parse_options(args).is_err(), "bpm {bpm} should fail");
1192 }
1193 }
1194
1195 #[test]
1196 fn rejects_malformed_key_scale() {
1197 for key in ["major", "H major", "C# dorian", "C major extra", "C#"] {
1198 let args = [
1199 OsString::from("generate"),
1200 OsString::from("--key-scale"),
1201 OsString::from(key),
1202 OsString::from("test prompt"),
1203 ];
1204 assert!(parse_options(args).is_err(), "key '{key}' should fail");
1205 }
1206 }
1207
1208 #[test]
1209 fn accepts_sharp_and_flat_key_scales() {
1210 for key in ["C major", "F# minor", "Bb major", "g minor"] {
1211 let args = [
1212 OsString::from("generate"),
1213 OsString::from("--key-scale"),
1214 OsString::from(key),
1215 OsString::from("test prompt"),
1216 ];
1217 assert!(parse_options(args).is_ok(), "key '{key}' should pass");
1218 }
1219 }
1220
1221 #[test]
1222 fn rejects_malformed_time_signature() {
1223 for ts in ["4", "4-4", "0/4", "4/0", "x/y", "4/4/2"] {
1224 let args = [
1225 OsString::from("generate"),
1226 OsString::from("--time-signature"),
1227 OsString::from(ts),
1228 OsString::from("test prompt"),
1229 ];
1230 assert!(
1231 parse_options(args).is_err(),
1232 "time signature '{ts}' should fail"
1233 );
1234 }
1235 }
1236
1237 #[test]
1238 fn parses_lyrics_alias() {
1239 let args = [
1240 OsString::from("generate"),
1241 OsString::from("--lyrics"),
1242 OsString::from("custom lyrics text"),
1243 ];
1244 let options = parse_options(args).expect("options should parse");
1245 assert_eq!(options.prompt, "custom lyrics text");
1246 }
1247
1248 #[test]
1249 fn parses_inspect_flag() {
1250 let args = [
1251 OsString::from("generate"),
1252 OsString::from("--inspect"),
1253 OsString::from("test prompt"),
1254 ];
1255 let options = parse_options(args).expect("options should parse");
1256 assert!(options.inspect_only);
1257 }
1258
1259 #[test]
1260 fn rejects_multiple_positional_args() {
1261 let args = [
1262 OsString::from("generate"),
1263 OsString::from("first prompt"),
1264 OsString::from("second prompt"),
1265 ];
1266 assert!(parse_options(args).is_err());
1267 }
1268
1269 #[test]
1270 fn rejects_empty_prompt() {
1271 let args = [OsString::from("generate"), OsString::from(" ")];
1272 assert!(parse_options(args).is_err());
1273 }
1274
1275 #[test]
1276 fn validate_options_trims_prompt() {
1277 let options = super::CliOptions {
1278 model: ModelChoice::HappyNewYear,
1279 prompt: " test prompt ".to_owned(),
1280 model_dir: None,
1281 output_path: std::path::PathBuf::from("output.wav"),
1282 inspect_only: false,
1283 backend: BackendChoice::Vulkan,
1284 cfg_scale: 1.5,
1285 length: 6000,
1286 ode_steps: 10,
1287 lyrics: None,
1288 tags: None,
1289 topk: 50,
1290 temperature: 1.0,
1291 decode_only: false,
1292 frames_json: None,
1293 decode_threads: None,
1294 decoder_seed: 0,
1295 bpm: None,
1296 key_scale: None,
1297 time_signature: None,
1298 acestep_lm: AceStepLmSize::default(),
1299 midi_length_seconds: 10.0,
1300 midi_seed: 0,
1301 midi_max_tokens: 1024,
1302 midi_top_p: 0.98,
1303 output_path_explicit: false,
1304 };
1305 let validated = super::validate_options(options).expect("validation should pass");
1306 assert_eq!(validated.prompt, "test prompt");
1307 }
1308
1309 #[test]
1310 fn validate_options_rejects_empty_output_path() {
1311 let options = super::CliOptions {
1312 model: ModelChoice::HappyNewYear,
1313 prompt: "test".to_owned(),
1314 model_dir: None,
1315 output_path: std::path::PathBuf::from(""),
1316 inspect_only: false,
1317 backend: BackendChoice::Vulkan,
1318 cfg_scale: 1.5,
1319 length: 6000,
1320 ode_steps: 10,
1321 lyrics: None,
1322 tags: None,
1323 topk: 50,
1324 temperature: 1.0,
1325 decode_only: false,
1326 frames_json: None,
1327 decode_threads: None,
1328 decoder_seed: 0,
1329 bpm: None,
1330 key_scale: None,
1331 time_signature: None,
1332 acestep_lm: AceStepLmSize::default(),
1333 midi_length_seconds: 10.0,
1334 midi_seed: 0,
1335 midi_max_tokens: 1024,
1336 midi_top_p: 0.98,
1337 output_path_explicit: false,
1338 };
1339 assert!(super::validate_options(options).is_err());
1340 }
1341
1342 #[test]
1343 fn validate_options_rejects_zero_length() {
1344 let options = super::CliOptions {
1345 model: ModelChoice::HappyNewYear,
1346 prompt: "test".to_owned(),
1347 model_dir: None,
1348 output_path: std::path::PathBuf::from("output.wav"),
1349 inspect_only: false,
1350 backend: BackendChoice::Vulkan,
1351 cfg_scale: 1.5,
1352 length: 0,
1353 ode_steps: 10,
1354 lyrics: None,
1355 tags: None,
1356 topk: 50,
1357 temperature: 1.0,
1358 decode_only: false,
1359 frames_json: None,
1360 decode_threads: None,
1361 decoder_seed: 0,
1362 bpm: None,
1363 key_scale: None,
1364 time_signature: None,
1365 acestep_lm: AceStepLmSize::default(),
1366 midi_length_seconds: 10.0,
1367 midi_seed: 0,
1368 midi_max_tokens: 1024,
1369 midi_top_p: 0.98,
1370 output_path_explicit: false,
1371 };
1372 assert!(super::validate_options(options).is_err());
1373 }
1374
1375 #[test]
1376 fn validate_options_rejects_decode_only_without_frames() {
1377 let options = super::CliOptions {
1378 model: ModelChoice::HappyNewYear,
1379 prompt: "".to_owned(),
1380 model_dir: None,
1381 output_path: std::path::PathBuf::from("output.wav"),
1382 inspect_only: false,
1383 backend: BackendChoice::Vulkan,
1384 cfg_scale: 1.5,
1385 length: 6000,
1386 ode_steps: 10,
1387 lyrics: None,
1388 tags: None,
1389 topk: 50,
1390 temperature: 1.0,
1391 decode_only: true,
1392 frames_json: None,
1393 decode_threads: None,
1394 decoder_seed: 0,
1395 bpm: None,
1396 key_scale: None,
1397 time_signature: None,
1398 acestep_lm: AceStepLmSize::default(),
1399 midi_length_seconds: 10.0,
1400 midi_seed: 0,
1401 midi_max_tokens: 1024,
1402 midi_top_p: 0.98,
1403 output_path_explicit: false,
1404 };
1405 assert!(super::validate_options(options).is_err());
1406 }
1407
1408 #[test]
1409 fn validate_options_rejects_zero_decode_threads() {
1410 let options = super::CliOptions {
1411 model: ModelChoice::HappyNewYear,
1412 prompt: "test".to_owned(),
1413 model_dir: None,
1414 output_path: std::path::PathBuf::from("output.wav"),
1415 inspect_only: false,
1416 backend: BackendChoice::Vulkan,
1417 cfg_scale: 1.5,
1418 length: 6000,
1419 ode_steps: 10,
1420 lyrics: None,
1421 tags: None,
1422 topk: 50,
1423 temperature: 1.0,
1424 decode_only: false,
1425 frames_json: None,
1426 decode_threads: Some(0),
1427 decoder_seed: 0,
1428 bpm: None,
1429 key_scale: None,
1430 time_signature: None,
1431 acestep_lm: AceStepLmSize::default(),
1432 midi_length_seconds: 10.0,
1433 midi_seed: 0,
1434 midi_max_tokens: 1024,
1435 midi_top_p: 0.98,
1436 output_path_explicit: false,
1437 };
1438 assert!(super::validate_options(options).is_err());
1439 }
1440
1441 #[test]
1442 fn validate_options_trims_tags() {
1443 let options = super::CliOptions {
1444 model: ModelChoice::HappyNewYear,
1445 prompt: "test".to_owned(),
1446 model_dir: None,
1447 output_path: std::path::PathBuf::from("output.wav"),
1448 inspect_only: false,
1449 backend: BackendChoice::Vulkan,
1450 cfg_scale: 1.5,
1451 length: 6000,
1452 ode_steps: 10,
1453 lyrics: None,
1454 tags: Some(" tag1, tag2 ".to_owned()),
1455 topk: 50,
1456 temperature: 1.0,
1457 decode_only: false,
1458 frames_json: None,
1459 decode_threads: None,
1460 decoder_seed: 0,
1461 bpm: None,
1462 key_scale: None,
1463 time_signature: None,
1464 acestep_lm: AceStepLmSize::default(),
1465 midi_length_seconds: 10.0,
1466 midi_seed: 0,
1467 midi_max_tokens: 1024,
1468 midi_top_p: 0.98,
1469 output_path_explicit: false,
1470 };
1471 let validated = super::validate_options(options).expect("validation should pass");
1472 assert_eq!(validated.tags, Some("tag1, tag2".to_owned()));
1473 }
1474
1475 #[test]
1476 fn validate_options_filters_empty_tags() {
1477 let options = super::CliOptions {
1478 model: ModelChoice::HappyNewYear,
1479 prompt: "test".to_owned(),
1480 model_dir: None,
1481 output_path: std::path::PathBuf::from("output.wav"),
1482 inspect_only: false,
1483 backend: BackendChoice::Vulkan,
1484 cfg_scale: 1.5,
1485 length: 6000,
1486 ode_steps: 10,
1487 lyrics: None,
1488 tags: Some(" ".to_owned()),
1489 topk: 50,
1490 temperature: 1.0,
1491 decode_only: false,
1492 frames_json: None,
1493 decode_threads: None,
1494 decoder_seed: 0,
1495 bpm: None,
1496 key_scale: None,
1497 time_signature: None,
1498 acestep_lm: AceStepLmSize::default(),
1499 midi_length_seconds: 10.0,
1500 midi_seed: 0,
1501 midi_max_tokens: 1024,
1502 midi_top_p: 0.98,
1503 output_path_explicit: false,
1504 };
1505 let validated = super::validate_options(options).expect("validation should pass");
1506 assert_eq!(validated.tags, None);
1507 }
1508
1509 #[test]
1510 fn default_output_path_is_output_wav() {
1511 let args = [OsString::from("generate"), OsString::from("test prompt")];
1512 let options = parse_options(args).expect("options should parse");
1513 assert_eq!(options.output_path, std::path::PathBuf::from("output.wav"));
1514 }
1515
1516 #[test]
1517 fn default_length_is_6000() {
1518 let args = [OsString::from("generate"), OsString::from("test prompt")];
1519 let options = parse_options(args).expect("options should parse");
1520 assert_eq!(options.length, 6000);
1521 }
1522
1523 #[test]
1524 fn default_ode_steps_is_10() {
1525 let args = [OsString::from("generate"), OsString::from("test prompt")];
1526 let options = parse_options(args).expect("options should parse");
1527 assert_eq!(options.ode_steps, 10);
1528 }
1529
1530 #[test]
1531 fn default_topk_is_50() {
1532 let args = [OsString::from("generate"), OsString::from("test prompt")];
1533 let options = parse_options(args).expect("options should parse");
1534 assert_eq!(options.topk, 50);
1535 }
1536
1537 #[test]
1538 fn default_temperature_is_1() {
1539 let args = [OsString::from("generate"), OsString::from("test prompt")];
1540 let options = parse_options(args).expect("options should parse");
1541 assert_eq!(options.temperature, 1.0);
1542 }
1543
1544 #[test]
1545 fn default_cfg_scale_is_1_5() {
1546 let args = [OsString::from("generate"), OsString::from("test prompt")];
1547 let options = parse_options(args).expect("options should parse");
1548 assert_eq!(options.cfg_scale, 1.5);
1549 }
1550
1551 #[test]
1552 fn default_decoder_seed_is_0() {
1553 let args = [OsString::from("generate"), OsString::from("test prompt")];
1554 let options = parse_options(args).expect("options should parse");
1555 assert_eq!(options.decoder_seed, 0);
1556 }
1557
1558 #[test]
1559 fn parse_midi_llm_model_defaults_output_to_mid() {
1560 let args = [
1561 OsString::from("generate"),
1562 OsString::from("--model"),
1563 OsString::from("midi-llm"),
1564 OsString::from("upbeat piano"),
1565 ];
1566 let options = parse_options(args).expect("options should parse");
1567 assert_eq!(options.model, ModelChoice::MidiLlm);
1568 assert_eq!(options.output_path, std::path::PathBuf::from("output.mid"));
1569 assert_eq!(options.midi_max_tokens, 1024);
1570 assert_eq!(options.midi_top_p, 0.98);
1571 }
1572
1573 #[test]
1574 fn parse_midi_llm_options() {
1575 let args = [
1576 OsString::from("generate"),
1577 OsString::from("--model"),
1578 OsString::from("midi-llm"),
1579 OsString::from("--midi-max-tokens"),
1580 OsString::from("512"),
1581 OsString::from("--midi-top-p"),
1582 OsString::from("0.95"),
1583 OsString::from("--midi-seed"),
1584 OsString::from("42"),
1585 OsString::from("upbeat piano"),
1586 ];
1587 let options = parse_options(args).expect("options should parse");
1588 assert_eq!(options.model, ModelChoice::MidiLlm);
1589 assert_eq!(options.midi_max_tokens, 512);
1590 assert_eq!(options.midi_top_p, 0.95);
1591 assert_eq!(options.midi_seed, 42);
1592 }
1593
1594 #[test]
1595 fn help_text_contains_usage() {
1596 let help = super::help_text();
1597 assert!(help.contains("maolan-generate"));
1598 assert!(help.contains("Usage:"));
1599 assert!(help.contains("Options:"));
1600 }
1601
1602 #[test]
1603 fn stderr_logging_disabled_in_ipc_mode() {
1604 let _ = super::stderr_logging_enabled();
1605 }
1606
1607 #[test]
1608 fn write_and_read_ipc_message_roundtrip() {
1609 use super::{read_ipc_message, write_ipc_message};
1610 use std::io::Cursor;
1611
1612 let original = super::GenerateResponseHeader {
1613 backend: BackendChoice::Cpu,
1614 channels: 2,
1615 frames: 48000,
1616 guidance_scale: 2.0,
1617 prompt_tokens: 10,
1618 sample_rate_hz: 48000,
1619 length: 6000,
1620 steps: 10,
1621 };
1622
1623 let mut buffer = Vec::new();
1624 write_ipc_message(&mut buffer, &original).expect("write should succeed");
1625
1626 let mut cursor = Cursor::new(buffer);
1627 let decoded: super::GenerateResponseHeader =
1628 read_ipc_message(&mut cursor).expect("read should succeed");
1629
1630 assert_eq!(decoded.backend, original.backend);
1631 assert_eq!(decoded.channels, original.channels);
1632 assert_eq!(decoded.frames, original.frames);
1633 assert_eq!(decoded.guidance_scale, original.guidance_scale);
1634 assert_eq!(decoded.prompt_tokens, original.prompt_tokens);
1635 assert_eq!(decoded.sample_rate_hz, original.sample_rate_hz);
1636 assert_eq!(decoded.length, original.length);
1637 assert_eq!(decoded.steps, original.steps);
1638 }
1639
1640 #[test]
1641 fn write_and_read_ipc_progress_roundtrip() {
1642 use super::{read_ipc_message, write_ipc_message};
1643 use std::io::Cursor;
1644
1645 let original = super::GenerateProgress {
1646 phase: "generator".to_owned(),
1647 progress: 0.5,
1648 operation: "Processing".to_owned(),
1649 };
1650
1651 let mut buffer = Vec::new();
1652 write_ipc_message(&mut buffer, &original).expect("write should succeed");
1653
1654 let mut cursor = Cursor::new(buffer);
1655 let decoded: super::GenerateProgress =
1656 read_ipc_message(&mut cursor).expect("read should succeed");
1657
1658 assert_eq!(decoded.phase, original.phase);
1659 assert_eq!(decoded.progress, original.progress);
1660 assert_eq!(decoded.operation, original.operation);
1661 }
1662
1663 #[test]
1664 fn write_and_read_ipc_error_roundtrip() {
1665 use super::{read_ipc_message, write_ipc_message};
1666 use std::io::Cursor;
1667
1668 let original = super::GenerateError {
1669 error: "Test error message".to_owned(),
1670 };
1671
1672 let mut buffer = Vec::new();
1673 write_ipc_message(&mut buffer, &original).expect("write should succeed");
1674
1675 let mut cursor = Cursor::new(buffer);
1676 let decoded: super::GenerateError =
1677 read_ipc_message(&mut cursor).expect("read should succeed");
1678
1679 assert_eq!(decoded.error, original.error);
1680 }
1681
1682 #[test]
1683 fn write_ipc_bytes_roundtrip() {
1684 use super::write_ipc_bytes;
1685 use std::io::Cursor;
1686
1687 let original = b"Hello, World!";
1688
1689 let mut buffer = Vec::new();
1690 write_ipc_bytes(&mut buffer, original).expect("write should succeed");
1691
1692 let mut cursor = Cursor::new(buffer);
1693 let mut len_bytes = [0_u8; 8];
1694 std::io::Read::read_exact(&mut cursor, &mut len_bytes).expect("read length should succeed");
1695 let len = u64::from_le_bytes(len_bytes) as usize;
1696 assert_eq!(len, original.len());
1697
1698 let mut payload = vec![0_u8; len];
1699 std::io::Read::read_exact(&mut cursor, &mut payload).expect("read payload should succeed");
1700 assert_eq!(&payload[..], &original[..]);
1701 }
1702
1703 #[test]
1704 fn read_ipc_message_fails_on_truncated_data() {
1705 use super::read_ipc_message;
1706 use std::io::Cursor;
1707
1708 let len_bytes = 100_u64.to_le_bytes();
1709 let buffer = len_bytes.to_vec();
1710
1711 let mut cursor = Cursor::new(buffer);
1712 let result: Result<super::GenerateResponseHeader, _> = read_ipc_message(&mut cursor);
1713 assert!(result.is_err());
1714 }
1715
1716 #[test]
1717 fn read_ipc_message_fails_on_invalid_json() {
1718 use super::read_ipc_message;
1719 use std::io::Cursor;
1720
1721 let payload = b"not valid json";
1722 let len_bytes = (payload.len() as u64).to_le_bytes();
1723 let mut buffer = Vec::new();
1724 buffer.extend_from_slice(&len_bytes);
1725 buffer.extend_from_slice(payload);
1726
1727 let mut cursor = Cursor::new(buffer);
1728 let result: Result<super::GenerateResponseHeader, _> = read_ipc_message(&mut cursor);
1729 assert!(result.is_err());
1730 }
1731
1732 #[test]
1733 fn serialize_generate_request() {
1734 let request = super::GenerateRequest {
1735 model: ModelChoice::Rl,
1736 prompt: "test prompt".to_owned(),
1737 model_dir: Some(std::path::PathBuf::from("/tmp/models")),
1738 output_path: std::path::PathBuf::from("/tmp/output.wav"),
1739 inspect_only: true,
1740 backend: BackendChoice::Cpu,
1741 cfg_scale: 2.5,
1742 length: 8000,
1743 ode_steps: 15,
1744 lyrics: Some("lyrics text".to_owned()),
1745 tags: Some("tag1,tag2".to_owned()),
1746 topk: 25,
1747 temperature: 0.8,
1748 decode_only: false,
1749 frames_json: None,
1750 decode_threads: Some(4),
1751 decoder_seed: 42,
1752 bpm: Some(120.0),
1753 key_scale: Some("A minor".to_owned()),
1754 time_signature: Some("4/4".to_owned()),
1755 acestep_lm: AceStepLmSize::B1_7,
1756 midi_length_seconds: 5.0,
1757 midi_seed: 7,
1758 midi_max_tokens: 1024,
1759 midi_top_p: 0.98,
1760 output_path_explicit: true,
1761 };
1762
1763 let json = serde_json::to_string(&request).expect("serialization should succeed");
1764 assert!(json.contains("test prompt"));
1765 assert!(json.contains("cpu"));
1766 assert!(json.contains("RL"));
1767 assert!(json.contains("A minor"));
1768 assert!(json.contains("4/4"));
1769 assert!(json.contains("120"));
1770 }
1771
1772 #[test]
1773 fn deserialize_generate_request() {
1774 let json = r#"{
1775 "model": "RL",
1776 "prompt": "test prompt",
1777 "output_path": "/tmp/output.wav",
1778 "backend": "cpu",
1779 "cfg_scale": 2.5,
1780 "length": 8000,
1781 "ode_steps": 15,
1782 "topk": 25,
1783 "temperature": 0.8,
1784 "decoder_seed": 42
1785 }"#;
1786
1787 let request: super::GenerateRequest =
1788 serde_json::from_str(json).expect("deserialization should succeed");
1789 assert_eq!(request.model, ModelChoice::Rl);
1790 assert_eq!(request.prompt, "test prompt");
1791 assert_eq!(request.backend, BackendChoice::Cpu);
1792 assert_eq!(request.cfg_scale, 2.5);
1793 assert_eq!(request.length, 8000);
1794 assert_eq!(request.ode_steps, 15);
1795 assert_eq!(request.topk, 25);
1796 assert_eq!(request.temperature, 0.8);
1797 assert_eq!(request.decoder_seed, 42);
1798 }
1799
1800 #[test]
1801 fn deserialize_generate_request_with_aliases() {
1802 let json1 =
1803 r#"{"prompt": "test", "backend": "cpu", "cfg_scale": 1.5, "seconds_total": 5000}"#;
1804 let request1: super::GenerateRequest =
1805 serde_json::from_str(json1).expect("deserialization should succeed");
1806 assert_eq!(request1.length, 5000);
1807
1808 let json2 = r#"{"prompt": "test", "backend": "cpu", "cfg_scale": 1.5, "max_audio_length_ms": 7000}"#;
1809 let request2: super::GenerateRequest =
1810 serde_json::from_str(json2).expect("deserialization should succeed");
1811 assert_eq!(request2.length, 7000);
1812 }
1813
1814 #[test]
1815 fn backend_choice_default_is_vulkan() {
1816 let default: BackendChoice = Default::default();
1817 assert_eq!(default, BackendChoice::Vulkan);
1818 }
1819
1820 #[test]
1821 fn model_choice_default_is_happy_new_year() {
1822 let default: ModelChoice = Default::default();
1823 assert_eq!(default, ModelChoice::HappyNewYear);
1824 }
1825
1826 #[test]
1827 fn default_output_path_function() {
1828 let path = super::default_output_path();
1829 assert_eq!(path, std::path::PathBuf::from("output.wav"));
1830 }
1831
1832 #[test]
1833 fn tokenizer_path_returns_valid_path() {
1834 let path = super::tokenizer_path();
1835 assert!(path.to_string_lossy().contains("t5-base-spiece.model"));
1836 }
1837}