Skip to main content

maolan_generate/
lib.rs

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    /// Tempo in beats per minute (ACE-Step metadata conditioning).
102    #[serde(default)]
103    pub bpm: Option<f32>,
104
105    /// Musical key, e.g. "C major" or "A minor" (ACE-Step metadata conditioning).
106    #[serde(default)]
107    pub key_scale: Option<String>,
108
109    /// Time signature formatted as "N/D", e.g. "4/4" (ACE-Step metadata conditioning).
110    #[serde(default)]
111    pub time_signature: Option<String>,
112
113    /// ACE-Step turbo 5 Hz LM planner size.
114    #[serde(default)]
115    pub acestep_lm: AceStepLmSize,
116
117    /// Text-to-MIDI: generated length in seconds.
118    #[serde(default = "default_midi_length_seconds")]
119    pub midi_length_seconds: f32,
120
121    /// Text-to-MIDI: seed for deterministic generation.
122    #[serde(default)]
123    pub midi_seed: u64,
124
125    /// Text-to-MIDI / MIDI-LLM: maximum number of music tokens to generate.
126    #[serde(default = "default_midi_max_tokens")]
127    pub midi_max_tokens: usize,
128
129    /// MIDI-LLM: top-p nucleus-sampling threshold.
130    #[serde(default = "default_midi_top_p")]
131    pub midi_top_p: f32,
132
133    /// Whether the output path was explicitly set by the user.
134    #[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}