Skip to main content

sound_to_patch/render/
vita.rs

1//! The open Vital engine as a [`Renderer`], for single notes in tests and
2//! CI with no Vital licence.
3//!
4//! `vita`, the GPL-3.0 Python binding of the open engine, runs only as a
5//! separate process: each request starts `tools/vita/render.py` with a
6//! Python that has the package, and no code of this crate links it. The
7//! engine renders one note per call and takes no MIDI file, so a job with
8//! more than one note, or with controller changes, is refused.
9//!
10//! Each render starts clean: the engine keeps no sound between calls. The
11//! renderer loads the job's state, plays the recipe's warm-up note and
12//! throws it away, so smoothed parameters settle, then renders the note.
13//! The engine has no call that renders silence, so the recipe's settle
14//! steps add to the warm-up render's length. A note that starts after 0 s
15//! gets that much silence before it. The clip ends where the recipe's
16//! [`super::TailWatch`] ends it.
17//!
18//! The engine reads no `tuning` from a preset, so a state with a custom
19//! tuning is refused rather than rendered in the default tuning. A preset
20//! the engine does not load, or samples that are not finite, come back as
21//! [`RenderError::State`]; a Python that does not run, a script that
22//! fails, hangs past [`SCRIPT_TIMEOUT`] or gives a reply this program
23//! cannot read, as [`RenderError::Engine`].
24
25use 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
42/// The environment variable that names a Python with the `vita` package.
43pub const VITA_PYTHON_ENV: &str = "VITA_PYTHON";
44/// The tempo a state is saved at before any render: 120 BPM.
45const DEFAULT_BPM: f64 = 120.0;
46/// The exit code of the script for a refusal of the engine.
47const REFUSED: i32 = 3;
48/// How long one run of the script may take: a Python start, the import of
49/// the engine and the render of one note take a few seconds.
50pub const SCRIPT_TIMEOUT: Duration = Duration::from_secs(120);
51/// How long the pipe readers may run on after the script ended. A reader
52/// whose pipe a child of the script still holds open is left behind.
53const READER_WAIT: Duration = Duration::from_secs(1);
54
55/// Plays single notes through the open engine in a Python process.
56#[derive(Debug)]
57pub struct VitaRenderer {
58    python: PathBuf,
59    script: PathBuf,
60    identity: HostIdentity,
61    loaded: Option<Vec<u8>>,
62    /// The tempo of the last job, which [`Renderer::save_state`] loads at.
63    bpm: f64,
64    renders: u64,
65    scratch: tempfile::TempDir,
66}
67
68impl VitaRenderer {
69    /// Starts a renderer that runs `script`, `tools/vita/render.py`, with
70    /// `python`.
71    ///
72    /// # Errors
73    ///
74    /// Returns [`RenderError::Engine`] when the script does not run or names
75    /// no `vita` version.
76    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    /// Writes `state` as preset JSON to a scratch file and returns its
106    /// path.
107    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    /// Renders the job's note, with its state or with the engine's init
131    /// patch.
132    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
227/// The one note of `job`, or the refusal of a job the engine cannot play:
228/// another synth's state, more than one note, or controller changes.
229fn 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
252/// Runs `script` with `python` on one request and reads its reply, within
253/// [`SCRIPT_TIMEOUT`].
254fn ask(python: &Path, script: &Path, request: &Value) -> Result<Value, RenderError> {
255    ask_within(python, script, request, SCRIPT_TIMEOUT)
256}
257
258/// Runs `script` with `python` on one request and reads its reply. A
259/// script that runs longer than `timeout` is stopped.
260///
261/// An exit of [`REFUSED`] is a refusal of the engine, which comes back as
262/// [`RenderError::State`] with the script's message. A Python that does
263/// not start, a script that fails otherwise or runs too long, and a reply
264/// that is not JSON are [`RenderError::Engine`].
265fn 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    // The pipes are read on threads, so a script that writes much output
288    // cannot block on a full pipe while this waits for its exit.
289    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
359/// Reads `pipe` to its end on a thread into a shared buffer, so the bytes
360/// read so far stay readable when a child of the script holds the pipe
361/// open and the thread is left behind. A read that a signal interrupts is
362/// retried.
363fn 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
385/// The warm-up request: the recipe's warm-up note, in a render as long as
386/// the whole pre-roll, in frames at `rate`.
387fn 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        // The script loads the state again at the last job's tempo, as the
455        // load check compares the saved tempo with that job.
456        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        // The engine saves no tuning, but it plays the default tuning, the
471        // only one a loaded state may hold here.
472        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    /// A renderer that never started a script: every refusal comes before
494    /// the first request, so none reaches the missing Python.
495    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    /// `python3`, or `None` with a note when it does not run, so the stub
584    /// tests skip on a machine without it.
585    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    /// A Python script with `body`, in `dir`.
600    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    /// A Python script that prints `message` to standard error and exits
607    /// with `code`.
608    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    /// A renderer that runs `script` with `python`, never started.
617    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        // The literal exit code of tools/vita/render.py, not the constant.
631        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, &not_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        // The child inherits the script's output pipes and outlives the
709        // reader wait of 1 s, but only by about a second after the test.
710        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        // Writes a clip of 10 stereo frames and says so, whatever it was
754        // asked for.
755        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}