1use std::io::{Read, Write as _};
26use std::path::{Path, PathBuf};
27use std::process::{Command, Stdio};
28use std::sync::{Arc, Mutex};
29use std::thread;
30use std::time::{Duration, Instant};
31
32use serde_json::{Value, json};
33
34use super::state::saved_preset;
35use super::{
36 ClipEnd, HostIdentity, PlayError, PreRoll, RenderError, RenderJob, RenderNote, RenderRecord,
37 Rendered, Renderer, frame_of,
38};
39use crate::audio::{Audio, SampleRate, decode};
40use crate::synth::Synth;
41
42pub const VITA_PYTHON_ENV: &str = "VITA_PYTHON";
44const DEFAULT_BPM: f64 = 120.0;
46const REFUSED: i32 = 3;
48pub const SCRIPT_TIMEOUT: Duration = Duration::from_secs(120);
51const READER_WAIT: Duration = Duration::from_secs(1);
54
55#[derive(Debug)]
57pub struct VitaRenderer {
58 python: PathBuf,
59 script: PathBuf,
60 identity: HostIdentity,
61 loaded: Option<Vec<u8>>,
62 bpm: f64,
64 renders: u64,
65 scratch: tempfile::TempDir,
66}
67
68impl VitaRenderer {
69 pub fn start(python: &Path, script: &Path) -> Result<Self, RenderError> {
77 let scratch = tempfile::tempdir().map_err(|source| {
78 vita_error(format!("cannot make a scratch folder ({source})"), FIX_TEMP)
79 })?;
80 let version = ask(python, script, &json!({ "op": "version" }))?;
81 let field = |key: &str| version[key].as_str().unwrap_or_default().to_owned();
82 let vita = field("vita");
83 if vita.is_empty() {
84 return Err(vita_error(
85 format!("the script named no vita version: {version}"),
86 &fix_python(),
87 ));
88 }
89 Ok(Self {
90 python: python.to_owned(),
91 script: script.to_owned(),
92 identity: HostIdentity {
93 synth: Synth::Vital,
94 build: format!("open engine, vita {vita}"),
95 host: format!("tools/vita/render.py, Python {}", field("python")),
96 arch: field("arch"),
97 },
98 loaded: None,
99 bpm: DEFAULT_BPM,
100 renders: 0,
101 scratch,
102 })
103 }
104
105 fn state_file(&self, state: &[u8]) -> Result<PathBuf, RenderError> {
108 let root = saved_preset(state)
109 .ok_or_else(|| RenderError::Internal("the state holds no preset JSON".to_owned()))?;
110 if let Some(tuning) = root.get("tuning")
111 && tuning.get("default").and_then(Value::as_bool) != Some(true)
112 {
113 return Err(RenderError::State(
114 "the preset has a custom tuning, and the open engine reads no tuning; render it through the plug-in".to_owned(),
115 ));
116 }
117 let path = self.scratch.path().join("state.json");
118 let text = serde_json::to_vec(&Value::Object(root)).map_err(|source| {
119 RenderError::Internal(format!("cannot encode the preset state ({source})"))
120 })?;
121 std::fs::write(&path, text).map_err(|source| {
122 vita_error(
123 format!("cannot write {} ({source})", path.display()),
124 FIX_TEMP,
125 )
126 })?;
127 Ok(path)
128 }
129
130 fn play(&mut self, job: &RenderJob, load: bool) -> Result<Rendered, RenderError> {
133 let note = single_note(job)?;
134 let preset = if load {
135 let path = self.state_file(job.state().bytes())?;
136 Value::from(path.to_string_lossy().into_owned())
137 } else {
138 Value::Null
139 };
140 let recipe = job.recipe();
141 let rate = recipe.sample_rate();
142 let mut watch = recipe.tail_watch(job.notes());
143 let onset = frame_of(note.onset(), rate);
144 let last_off = frame_of(note.offset(), rate).max(onset + 1);
145 let until =
146 (last_off + frame_of(recipe.tail_cap(), rate)).max(frame_of(recipe.clip(), rate));
147 self.renders += 1;
148 let out = self
149 .scratch
150 .path()
151 .join(format!("clip-{}.wav", self.renders));
152 let render_frames = until - onset;
153 let reply = ask(
154 &self.python,
155 &self.script,
156 &json!({
157 "op": "render",
158 "preset": preset,
159 "bpm": job.bpm().get(),
160 "sample_rate": rate.get(),
161 "key": note.key(),
162 "velocity": note.velocity().get(),
163 "note_frames": last_off - onset,
164 "render_frames": render_frames,
165 "warm_up": warm_up(recipe.pre_roll(), rate),
166 "out": out.to_string_lossy(),
167 }),
168 )?;
169 if load {
170 self.loaded = Some(job.state().bytes().to_vec());
171 }
172 self.bpm = job.bpm().get();
173 let decoded = decode(&out).map_err(|source| {
174 vita_error(
175 format!("the script wrote a clip that does not decode ({source})"),
176 FIX_SCRIPT,
177 )
178 });
179 let _ = std::fs::remove_file(&out);
180 let note_audio = decoded?.into_audio();
181 let replied = reply["frames"].as_u64();
182 if replied != u64::try_from(render_frames).ok()
183 || u64::try_from(note_audio.frames()).ok() != replied
184 {
185 return Err(vita_error(
186 format!(
187 "the script was asked for {render_frames} frames, replied {}, and wrote {}",
188 reply["frames"],
189 note_audio.frames()
190 ),
191 FIX_SCRIPT,
192 ));
193 }
194 let mut channels: Vec<Vec<f64>> = note_audio
195 .channels()
196 .iter()
197 .map(|channel| {
198 std::iter::repeat_n(0.0, onset)
199 .chain(channel.iter().copied())
200 .collect()
201 })
202 .collect();
203 let frames = channels.first().map_or(0, Vec::len);
204 let mut end = ClipEnd {
205 frames,
206 capped: true,
207 };
208 let mut frame = Vec::with_capacity(channels.len());
209 for index in 0..frames {
210 frame.clear();
211 frame.extend(channels.iter().map(|channel| channel[index]));
212 if let Some(found) = watch.push(&frame)? {
213 end = found;
214 break;
215 }
216 }
217 for channel in &mut channels {
218 channel.truncate(end.frames);
219 }
220 Ok(Rendered {
221 audio: Audio::new(rate, channels)?,
222 record: RenderRecord::new(self.identity(), job, end),
223 })
224 }
225}
226
227fn single_note(job: &RenderJob) -> Result<&RenderNote, RenderError> {
230 if job.state().synth() != Synth::Vital {
231 return Err(RenderError::WrongSynth {
232 state: job.state().synth(),
233 renderer: Synth::Vital,
234 });
235 }
236 let [note] = job.notes() else {
237 return Err(PlayError::Note(format!(
238 "the open engine plays one note per render, and the job has {}; render a MIDI file with one note, or render through the plug-in",
239 job.notes().len()
240 ))
241 .into());
242 };
243 if !job.controls().is_empty() {
244 return Err(PlayError::Control(
245 "the open engine takes no controller changes; render through the plug-in".to_owned(),
246 )
247 .into());
248 }
249 Ok(note)
250}
251
252fn ask(python: &Path, script: &Path, request: &Value) -> Result<Value, RenderError> {
255 ask_within(python, script, request, SCRIPT_TIMEOUT)
256}
257
258fn ask_within(
266 python: &Path,
267 script: &Path,
268 request: &Value,
269 timeout: Duration,
270) -> Result<Value, RenderError> {
271 let mut child = Command::new(python)
272 .arg("-I")
273 .arg(script)
274 .stdin(Stdio::piped())
275 .stdout(Stdio::piped())
276 .stderr(Stdio::piped())
277 .spawn()
278 .map_err(|source| {
279 vita_error(
280 format!("cannot start {} ({source})", python.display()),
281 &fix_python(),
282 )
283 })?;
284 if let Some(mut stdin) = child.stdin.take() {
285 let _ = stdin.write_all(request.to_string().as_bytes());
286 }
287 let stdout = read(child.stdout.take().map(|pipe| Box::new(pipe) as _));
290 let stderr = read(child.stderr.take().map(|pipe| Box::new(pipe) as _));
291 let deadline = Instant::now() + timeout;
292 let status = loop {
293 match child.try_wait() {
294 Ok(Some(status)) => break Some(status),
295 Ok(None) if Instant::now() < deadline => thread::sleep(Duration::from_millis(10)),
296 Ok(None) => {
297 let _ = child.kill();
298 let _ = child.wait();
299 break None;
300 }
301 Err(source) => {
302 let _ = child.kill();
303 let _ = child.wait();
304 return Err(vita_error(
305 format!("the open-engine script did not finish ({source})"),
306 &fix_python(),
307 ));
308 }
309 }
310 };
311 let readers = Instant::now() + READER_WAIT;
312 while !(stdout.1.is_finished() && stderr.1.is_finished()) && Instant::now() < readers {
313 thread::sleep(Duration::from_millis(5));
314 }
315 let take =
316 |buffer: &Mutex<Vec<u8>>| buffer.lock().map(|bytes| bytes.clone()).unwrap_or_default();
317 let stdout = take(&stdout.0);
318 let stderr = take(&stderr.0);
319 let stderr = String::from_utf8_lossy(&stderr);
320 let last = stderr
321 .lines()
322 .rev()
323 .find(|line| !line.trim().is_empty())
324 .unwrap_or("")
325 .to_owned();
326 let Some(status) = status else {
327 let output = if last.is_empty() { "none" } else { &last };
328 return Err(vita_error(
329 format!(
330 "the open-engine script ran for more than {} s and was stopped; its last output: {output}",
331 timeout.as_secs_f64()
332 ),
333 &format!(
334 "Check that {VITA_PYTHON_ENV} names a Python whose `import vita` finishes, with the packages of tools/vita/requirements.txt"
335 ),
336 ));
337 };
338 if status.code() == Some(REFUSED) {
339 return Err(RenderError::State(if last.is_empty() {
340 format!("the open engine refused it with {status} and printed no message")
341 } else {
342 last
343 }));
344 }
345 if !status.success() {
346 return Err(vita_error(
347 format!("the open-engine script failed ({status}): {last}"),
348 &fix_python(),
349 ));
350 }
351 serde_json::from_slice(&stdout).map_err(|source| {
352 vita_error(
353 format!("the open-engine script gave a reply that is not JSON ({source})"),
354 FIX_SCRIPT,
355 )
356 })
357}
358
359fn read(pipe: Option<Box<dyn Read + Send>>) -> (Arc<Mutex<Vec<u8>>>, thread::JoinHandle<()>) {
364 let buffer = Arc::new(Mutex::new(Vec::new()));
365 let into = Arc::clone(&buffer);
366 let reader = thread::spawn(move || {
367 let Some(mut pipe) = pipe else { return };
368 let mut chunk = [0; 8192];
369 loop {
370 match pipe.read(&mut chunk) {
371 Ok(0) => break,
372 Ok(read) => {
373 if let Ok(mut bytes) = into.lock() {
374 bytes.extend_from_slice(&chunk[..read]);
375 }
376 }
377 Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
378 Err(_) => break,
379 }
380 }
381 });
382 (buffer, reader)
383}
384
385fn warm_up(pre_roll: &[PreRoll], rate: SampleRate) -> Value {
388 let length: usize = pre_roll
389 .iter()
390 .map(|step| match step {
391 PreRoll::Settle { length } | PreRoll::WarmUp { length, .. } => frame_of(*length, rate),
392 })
393 .sum();
394 pre_roll
395 .iter()
396 .find_map(|step| match step {
397 PreRoll::WarmUp {
398 key,
399 velocity,
400 start,
401 end,
402 ..
403 } => Some(json!({
404 "key": key,
405 "velocity": velocity,
406 "note_frames": frame_of(*end, rate) - frame_of(*start, rate),
407 "render_frames": length,
408 })),
409 PreRoll::Settle { .. } => None,
410 })
411 .unwrap_or(Value::Null)
412}
413
414fn fix_python() -> String {
415 format!("Set {VITA_PYTHON_ENV} to a Python with the packages of tools/vita/requirements.txt")
416}
417
418const FIX_TEMP: &str = "Check that the temporary folder is writable";
419
420const FIX_SCRIPT: &str =
421 "Run the tools/vita/render.py of this release, which speaks the same requests";
422
423fn vita_error(cause: String, fix: &str) -> RenderError {
424 RenderError::Engine {
425 cause,
426 fix: fix.to_owned(),
427 }
428}
429
430impl Renderer for VitaRenderer {
431 fn identity(&self) -> HostIdentity {
432 self.identity.clone()
433 }
434
435 fn render(&mut self, job: &RenderJob) -> Result<Rendered, RenderError> {
436 self.play(job, true)
437 }
438
439 fn render_init(&mut self, job: &RenderJob) -> Result<Rendered, RenderError> {
440 if self.loaded.is_some() {
441 return Err(vita_error(
442 "the renderer already loaded a state".to_owned(),
443 "Render the init patch before the first render of a preset",
444 ));
445 }
446 self.play(job, false)
447 }
448
449 fn save_state(&mut self) -> Result<Vec<u8>, RenderError> {
450 let preset = match &self.loaded {
451 Some(state) => Value::from(self.state_file(state)?.to_string_lossy().into_owned()),
452 None => Value::Null,
453 };
454 let reply = ask(
457 &self.python,
458 &self.script,
459 &json!({
460 "op": "save",
461 "preset": preset,
462 "bpm": self.bpm,
463 "sample_rate": super::Recipe::current().sample_rate().get(),
464 }),
465 )?;
466 let mut saved: Value = reply["state"]
467 .as_str()
468 .and_then(|state| serde_json::from_str(state).ok())
469 .ok_or_else(|| vita_error(format!("the script saved no state: {reply}"), FIX_SCRIPT))?;
470 let tuning = self
473 .loaded
474 .as_deref()
475 .and_then(saved_preset)
476 .and_then(|root| root.get("tuning").cloned());
477 if let (Some(tuning), Some(root)) = (tuning, saved.as_object_mut()) {
478 root.insert("tuning".to_owned(), tuning);
479 }
480 serde_json::to_vec(&saved).map_err(|source| {
481 RenderError::Internal(format!("cannot encode the saved state ({source})"))
482 })
483 }
484}
485
486#[cfg(test)]
487mod tests {
488 use super::*;
489 use crate::render::state::preset_state;
490 use crate::render::{Control, ControlKind, Material, Recipe, RenderNote};
491 use crate::units::{Bpm, Seconds};
492
493 fn unstarted() -> VitaRenderer {
496 VitaRenderer {
497 python: PathBuf::from("no-such-python"),
498 script: PathBuf::from("no-such-script.py"),
499 identity: HostIdentity {
500 synth: Synth::Vital,
501 build: String::new(),
502 host: String::new(),
503 arch: String::new(),
504 },
505 loaded: None,
506 bpm: DEFAULT_BPM,
507 renders: 0,
508 scratch: tempfile::tempdir().unwrap(),
509 }
510 }
511
512 fn probe(edit: impl FnOnce(&mut Value)) -> crate::render::PresetState {
513 let mut preset: Value =
514 serde_json::from_slice(include_bytes!("../../tests/fixtures/vital/minimal.vital"))
515 .unwrap();
516 edit(&mut preset);
517 preset_state(
518 Synth::Vital,
519 &serde_json::to_vec(&preset).unwrap(),
520 &Recipe::current(),
521 )
522 .unwrap()
523 }
524
525 fn bpm() -> Bpm {
526 Bpm::new(120.0).unwrap()
527 }
528
529 fn note(key: u8) -> RenderNote {
530 RenderNote::new(
531 key,
532 Seconds::new(0.0).unwrap(),
533 Seconds::new(0.5).unwrap(),
534 crate::notes::Velocity::new(100).unwrap(),
535 )
536 .unwrap()
537 }
538
539 #[test]
540 fn the_four_refusals_come_before_the_script() {
541 let mut renderer = unstarted();
542 let vital = probe(|_| {});
543 let other = crate::render::PresetState::new(Synth::Serum2, vital.bytes().to_vec());
544 let error = renderer
545 .render(&RenderJob::for_material(other, Material::Lead, bpm()))
546 .unwrap_err();
547 assert!(matches!(error, RenderError::WrongSynth { .. }), "{error}");
548 let two = RenderJob::new(vital.clone(), vec![note(60), note(64)], bpm()).unwrap();
549 let error = renderer.render(&two).unwrap_err();
550 assert!(
551 matches!(error, RenderError::Play(PlayError::Note(_))),
552 "{error}"
553 );
554 assert!(error.to_string().contains("the job has 2"), "{error}");
555 let wheel = RenderJob::new(vital, vec![note(60)], bpm())
556 .unwrap()
557 .with_controls(vec![Control {
558 time: Seconds::new(0.1).unwrap(),
559 kind: ControlKind::ModWheel(64),
560 }])
561 .unwrap();
562 let error = renderer.render(&wheel).unwrap_err();
563 assert!(
564 matches!(error, RenderError::Play(PlayError::Control(_))),
565 "{error}"
566 );
567 let tuned = probe(|preset| {
568 preset["tuning"] = json!({
569 "scale_start_midi_note": 60, "reference_midi_note": 2, "tuning_name": "",
570 "mapping_name": "", "default": false,
571 "scale": [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0]
572 });
573 });
574 let error = renderer
575 .render(&RenderJob::for_material(tuned, Material::Lead, bpm()))
576 .unwrap_err();
577 assert!(matches!(error, RenderError::State(_)), "{error}");
578 assert!(error.to_string().contains("reads no tuning"), "{error}");
579 assert_eq!(renderer.loaded, None);
580 assert_eq!(renderer.renders, 0);
581 }
582
583 fn python3() -> Option<&'static Path> {
586 let python = Path::new("python3");
587 let runs = Command::new(python)
588 .arg("--version")
589 .output()
590 .is_ok_and(|output| output.status.success());
591 if !runs {
592 eprintln!(
593 "skipped: the stub tests run their scripts with python3, which does not run here"
594 );
595 }
596 runs.then_some(python)
597 }
598
599 fn script(dir: &Path, name: &str, body: &str) -> PathBuf {
601 let path = dir.join(name);
602 std::fs::write(&path, body).unwrap();
603 path
604 }
605
606 fn stub(dir: &Path, code: i32, message: &str) -> PathBuf {
609 script(
610 dir,
611 &format!("exit-{code}.py"),
612 &format!("import sys\nprint({message:?}, file=sys.stderr)\nsys.exit({code})\n"),
613 )
614 }
615
616 fn with_script(python: &Path, script: PathBuf) -> VitaRenderer {
618 VitaRenderer {
619 python: python.to_owned(),
620 script,
621 ..unstarted()
622 }
623 }
624
625 #[test]
626 fn a_refusal_of_the_engine_is_a_state_error_and_other_failures_an_engine_error() {
627 let Some(python) = python3() else { return };
628 let dir = tempfile::tempdir().unwrap();
629 let request = json!({ "op": "render" });
630 let refused = ask(python, &stub(dir.path(), 3, "not loaded"), &request);
632 assert!(
633 matches!(&refused, Err(RenderError::State(message)) if message == "not loaded"),
634 "{refused:?}"
635 );
636 let failed = ask(python, &stub(dir.path(), 1, "no module"), &request);
637 assert!(
638 matches!(&failed, Err(RenderError::Engine { fix, .. }) if fix.contains(VITA_PYTHON_ENV)),
639 "{failed:?}"
640 );
641 let missing = ask(Path::new("no-such-python"), Path::new("x.py"), &request);
642 assert!(
643 matches!(&missing, Err(RenderError::Engine { cause, .. }) if cause.starts_with("cannot start")),
644 "{missing:?}"
645 );
646 }
647
648 #[test]
649 fn a_refusal_without_a_message_names_the_exit_code() {
650 let Some(python) = python3() else { return };
651 let dir = tempfile::tempdir().unwrap();
652 let silent = script(dir.path(), "silent.py", "import sys\nsys.exit(3)\n");
653 let message = ask(python, &silent, &json!({ "op": "render" }))
654 .unwrap_err()
655 .to_string();
656 assert!(message.contains("exit status: 3"), "{message}");
657 assert!(!message.contains("preset: ."), "{message}");
658 }
659
660 #[test]
661 fn a_failure_of_the_script_names_the_open_engine() {
662 let Some(python) = python3() else { return };
663 let dir = tempfile::tempdir().unwrap();
664 let request = json!({ "op": "render" });
665 let not_json = script(dir.path(), "not-json.py", "print('hello')\n");
666 for error in [
667 ask(python, &stub(dir.path(), 1, "no module"), &request),
668 ask(python, ¬_json, &request),
669 ask(Path::new("no-such-python"), Path::new("x.py"), &request),
670 ] {
671 let message = error.unwrap_err().to_string();
672 assert!(message.contains("open engine"), "{message}");
673 assert!(!message.contains("plug-in host"), "{message}");
674 }
675 }
676
677 #[test]
678 fn a_script_that_hangs_is_stopped_and_reported() {
679 let Some(python) = python3() else { return };
680 let dir = tempfile::tempdir().unwrap();
681 let hang = script(
682 dir.path(),
683 "hang.py",
684 "import sys, time\nprint('importing', file=sys.stderr, flush=True)\ntime.sleep(30)\n",
685 );
686 let before = std::time::Instant::now();
687 let message = ask_within(
688 python,
689 &hang,
690 &json!({ "op": "render" }),
691 std::time::Duration::from_millis(500),
692 )
693 .unwrap_err()
694 .to_string();
695 assert!(
696 before.elapsed() < std::time::Duration::from_secs(10),
697 "{:?}",
698 before.elapsed()
699 );
700 assert!(message.contains("was stopped"), "{message}");
701 assert!(message.contains("importing"), "{message}");
702 }
703
704 #[test]
705 fn a_child_that_holds_the_pipes_does_not_hold_the_reply() {
706 let Some(python) = python3() else { return };
707 let dir = tempfile::tempdir().unwrap();
708 let child = "import subprocess, sys, time\nsubprocess.Popen(['sleep', '3'])\n";
711 let hang = script(dir.path(), "hang.py", &format!("{child}time.sleep(30)\n"));
712 let done = script(dir.path(), "done.py", &format!("{child}print('{{}}')\n"));
713 let request = json!({ "op": "render" });
714 let short = std::time::Duration::from_millis(500);
715 let before = std::time::Instant::now();
716 let message = ask_within(python, &hang, &request, short)
717 .unwrap_err()
718 .to_string();
719 assert!(message.contains("was stopped"), "{message}");
720 assert!(
721 before.elapsed() < std::time::Duration::from_secs(4),
722 "{:?}",
723 before.elapsed()
724 );
725 let before = std::time::Instant::now();
726 assert_eq!(
727 ask_within(python, &done, &request, short * 10).unwrap(),
728 json!({})
729 );
730 assert!(
731 before.elapsed() < std::time::Duration::from_secs(4),
732 "{:?}",
733 before.elapsed()
734 );
735 }
736
737 #[test]
738 fn a_refused_load_leaves_no_loaded_state_and_no_tempo() {
739 let Some(python) = python3() else { return };
740 let dir = tempfile::tempdir().unwrap();
741 let mut renderer = with_script(python, stub(dir.path(), 3, "not loaded"));
742 let job = RenderJob::for_material(probe(|_| {}), Material::Lead, Bpm::new(140.0).unwrap());
743 let error = renderer.render(&job).unwrap_err();
744 assert!(matches!(error, RenderError::State(_)), "{error}");
745 assert_eq!(renderer.loaded, None);
746 assert_eq!(renderer.bpm, DEFAULT_BPM);
747 }
748
749 #[test]
750 fn a_clip_of_another_length_than_asked_is_an_engine_error() {
751 let Some(python) = python3() else { return };
752 let dir = tempfile::tempdir().unwrap();
753 let short = script(
756 dir.path(),
757 "short.py",
758 "import json, sys, wave\n\
759 request = json.load(sys.stdin)\n\
760 with wave.open(request['out'], 'wb') as out:\n\
761 \x20 out.setnchannels(2)\n\
762 \x20 out.setsampwidth(2)\n\
763 \x20 out.setframerate(request['sample_rate'])\n\
764 \x20 out.writeframes(bytes(40))\n\
765 print(json.dumps({'frames': 10}))\n",
766 );
767 let mut renderer = with_script(python, short);
768 let job = RenderJob::for_material(probe(|_| {}), Material::Lead, bpm());
769 let error = renderer.render(&job).unwrap_err();
770 assert!(
771 matches!(&error, RenderError::Engine { cause, .. } if cause.contains("replied 10, and wrote 10")),
772 "{error}"
773 );
774 }
775}