use std::path::Path;
const USAGE: &str = "\
treble โ the Treble language toolchain
USAGE:
treble check <file.rt>
treble info <file.rt> [--json [--bars]]
treble render <file.rt> [-o <out.wav>] [options]
treble serve
COMMANDS:
check Parse a buffer and report errors. Exit 1 if any.
info Print a piece's structure โ sections, arrangement, metadata.
render Render a piece to a WAV file.
serve Speak LSP on stdin/stdout, with a player, for an editor.
INFO OPTIONS:
--json Print the structure as JSON on stdout: metadata, tail,
seed, arrangement, timeline, and per section its
timing, first cycle, occurrences and lines.
--bars With --json: give each line of a section the bars it
strikes on, as the section first plays. Needs the
renderer.
RENDER OPTIONS:
-o, --out <path> Output file. Defaults to the input with a .wav suffix.
-r, --rate <hz> Sample rate. Default 44100.
-m, --meta <k=v> Add or override a metadata tag. Repeatable.
-s, --section <name> Render only this section, starting on the cycle it
first plays on, so it sounds as it does in the song.
-p, --passes <n> With --section: play it n times back to back and keep
only the last pass and its tail โ a loop whose start
carries what the pass before it left ringing.
Default 1.
-t, --tail <seconds> Ring out this long past the last cycle instead of the
piece's own `tail` (ยง8.7).
--only <a,b> Mute every line whose instrument is not listed. An
instrument no rendered line plays is an error.
--no-limit Skip the master limiter, so renders of parts add up
to the render of the whole. Peaks may pass 1.0; the
WAV is 32-bit float, so they are kept, not clipped.
-f, --force Overwrite the output if it exists.
-q, --quiet Only report errors.
--json Emit a machine-readable summary on stdout.
Every option has a long form; `--` stops option parsing.
";
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
if args.is_empty() || args[0] == "-h" || args[0] == "--help" {
print!("{USAGE}");
std::process::exit(if args.is_empty() { 1 } else { 0 });
}
if args[0] == "-V" || args[0] == "--version" {
println!("treble-lang {}", env!("CARGO_PKG_VERSION"));
return;
}
let (command, rest) = (args[0].as_str(), &args[1..]);
let result = match command {
"check" => cmd_check(rest),
"info" => cmd_info(rest),
"render" => cmd_render(rest),
"serve" => cmd_serve(),
other if !other.starts_with('-') => cmd_check(&args),
other => Err(format!("unknown command '{other}'\n\n{USAGE}")),
};
if let Err(message) = result {
eprintln!("error: {message}");
std::process::exit(1);
}
}
fn read(path: &str) -> Result<String, String> {
std::fs::read_to_string(path).map_err(|error| format!("could not read '{path}': {error}"))
}
fn cmd_check(args: &[String]) -> Result<(), String> {
let path = args.first().ok_or("check: expected a file")?;
let source = read(path)?;
let mut session = treble_lang::Session::new();
let result = session.evaluate(&source);
for error in &result.errors {
eprintln!("{path}:{}: {}", error.location.line, error.message);
}
if !result.errors.is_empty() {
return Err(format!(
"{} problem{} in {path}",
result.errors.len(),
if result.errors.len() == 1 { "" } else { "s" }
));
}
let loaded = match loaded_definitions(&session, Path::new(path)) {
Ok(definitions) => definitions,
Err(problem) => {
eprintln!("{path}: {problem}");
return Err(format!("a load line would not resolve in {path}"));
}
};
let mut lowering = check_definitions(&session);
lowering.extend(check_loaded(&loaded));
lowering.sort();
for problem in &lowering {
eprintln!("{path}: {problem}");
}
if !lowering.is_empty() {
return Err(format!(
"{} instrument definition{} would not build",
lowering.len(),
if lowering.len() == 1 { "" } else { "s" }
));
}
match session.piece() {
Some(piece) => println!(
"{path}: piece โ {} sections, {} occurrences, {} cycles, {}",
piece.sections.len(),
piece.timeline.len(),
piece.total_cycles(),
clock(piece.render_seconds())
),
None => println!(
"{path}: live buffer โ {} active, {} muted",
result.patterns_active, result.patterns_muted
),
}
Ok(())
}
fn cmd_info(args: &[String]) -> Result<(), String> {
let mut path: Option<&str> = None;
let mut json = false;
let mut bars = false;
let mut only_positional = false;
for arg in args {
match arg.as_str() {
other if only_positional => path = Some(other),
"--" => only_positional = true,
"--json" => json = true,
"--bars" => bars = true,
other if other.starts_with('-') => {
return Err(format!("info: unknown option '{other}'"));
}
other => path = Some(other),
}
}
if bars && !json {
return Err("--bars adds to the JSON, so it needs --json".into());
}
let path = path.ok_or("info: expected a file")?;
let source = read(path)?;
let mut session = treble_lang::Session::new();
let result = session.evaluate(&source);
for error in &result.errors {
eprintln!("{path}:{}: {}", error.location.line, error.message);
}
let piece = session
.piece()
.ok_or("that buffer has no sections, so there is no piece to describe")?;
if json {
let bars = if bars {
Some(struck_bars(&session, piece, path)?)
} else {
None
};
let described = describe(piece, path, bars.as_deref());
println!(
"{}",
serde_json::to_string_pretty(&described).map_err(|error| error.to_string())?
);
return Ok(());
}
if !piece.metadata.is_empty() {
for (key, value) in &piece.metadata {
println!("{key:>12} {value}");
}
println!();
}
println!(
"{:>12} {} sections, {} played, {} cycles",
"structure",
piece.sections.len(),
piece.timeline.len(),
piece.total_cycles()
);
println!(
"{:>12} {} + {:.1}s tail = {}",
"length",
clock(piece.seconds()),
piece.tail,
clock(piece.render_seconds())
);
println!("{:>12} {}", "seed", piece.seed);
println!();
for section in &piece.sections {
println!(
" {:<12} {:>3} cycles {:>3} bpm {}/{} {:>6.1}s ร{}",
section.name,
section.cycles,
section.bpm,
section.sig.0,
section.sig.1,
section.seconds(),
piece
.timeline
.iter()
.filter(|o| piece.sections[o.section].name == section.name)
.count()
);
for line in §ion.patterns {
let span = match line.span {
Some(span) if span.start() == span.end(section.cycles) => {
format!("@ {}", span.start())
}
Some(span) => format!("@ {}..{}", span.start(), span.end(section.cycles)),
None => String::new(),
};
println!(" {:<10} {:<10} {}", line.name, line.instrument, span);
}
}
if !piece.unused.is_empty() {
println!("\n never played: {}", piece.unused.join(", "));
}
Ok(())
}
fn describe(
piece: &treble_lang::Piece,
path: &str,
bars: Option<&[SectionBars]>,
) -> serde_json::Value {
use serde_json::{Map, Value, json};
let line = |line: &treble_lang::PatternDef, cycles: Option<u32>| {
json!({
"name": line.name,
"instrument": line.instrument,
"group": line.group,
"muted": line.muted,
"span": match (line.span, cycles) {
(Some(span), Some(cycles)) => json!({
"from": span.start(),
"to": span.end(cycles).min(cycles),
}),
_ => Value::Null,
},
})
};
let metadata: Map<String, Value> = piece
.metadata
.iter()
.map(|(key, value)| (key.clone(), Value::from(value.as_str())))
.collect();
let sections: Vec<Value> = piece
.sections
.iter()
.enumerate()
.map(|(index, section)| {
let occurrences = piece.timeline.iter().filter(|o| o.section == index).count();
json!({
"name": section.name,
"muted": section.muted,
"cycles": section.cycles,
"bpm": section.bpm,
"sig": [section.sig.0, section.sig.1],
"seconds": section.seconds(),
"cycleSeconds": section.cycle_seconds(),
"firstCycle": piece.first_playing(index),
"occurrences": occurrences,
"groups": section.groups.iter().map(|group| json!({
"name": group.name,
"muted": group.muted,
})).collect::<Vec<_>>(),
"lines": section.patterns.iter()
.map(|member| {
let mut described = line(member, Some(section.cycles));
if let Some(struck) = bars
.and_then(|bars| bars.get(index))
.and_then(|lines| lines.iter().find(|(name, _)| *name == member.name))
{
described["bars"] = json!(struck.1);
}
described
})
.collect::<Vec<_>>(),
})
})
.collect();
let timeline: Vec<Value> = piece
.walk()
.map(|placed| {
json!({
"section": placed.section.name,
"startCycle": placed.start_cycle,
"startSeconds": placed.start_seconds,
})
})
.collect();
json!({
"path": path,
"title": piece.meta("title"),
"metadata": metadata,
"tail": piece.tail,
"seed": piece.seed,
"cycles": piece.total_cycles(),
"seconds": piece.seconds(),
"renderSeconds": piece.render_seconds(),
"arrangement": piece.arrangement.iter().map(|item| json!({
"section": item.section,
"repeat": item.repeat,
})).collect::<Vec<_>>(),
"timeline": timeline,
"sections": sections,
"throughout": piece.throughout.iter()
.map(|member| line(member, None))
.collect::<Vec<_>>(),
"unused": piece.unused,
})
}
type SectionBars = Vec<(String, Vec<bool>)>;
#[cfg(feature = "render")]
fn struck_bars(
session: &treble_lang::Session,
piece: &treble_lang::Piece,
path: &str,
) -> Result<Vec<SectionBars>, String> {
let registry = instrument_registry(session, piece, path)?;
(0..piece.sections.len())
.map(|index| {
let cycles = piece.sections[index].cycles as usize;
let lanes = treble_lang::render::section_strikes(piece, ®istry, index, None)?;
Ok(lanes
.into_iter()
.map(|lane| {
let mut bars = vec![false; cycles];
for (cycle, _) in &lane.strikes {
if let Some(bar) = bars.get_mut(*cycle as usize) {
*bar = true;
}
}
(lane.line, bars)
})
.collect())
})
.collect()
}
#[cfg(not(feature = "render"))]
fn struck_bars(
_session: &treble_lang::Session,
_piece: &treble_lang::Piece,
_path: &str,
) -> Result<Vec<SectionBars>, String> {
Err("--bars reads the strikes the renderer schedules โ rebuild with `--features render`".into())
}
#[cfg(feature = "render")]
fn loaded_definitions(
session: &treble_lang::Session,
piece_path: &Path,
) -> Result<Vec<treble_lang::ast::InstrumentDef>, String> {
let base = piece_path.parent().unwrap_or(Path::new("."));
let mut definitions = Vec::new();
for load in session.loads() {
let path = Path::new(load);
let resolved = if path.is_absolute() {
path.to_path_buf()
} else {
base.join(path)
};
let source = std::fs::read_to_string(&resolved)
.map_err(|error| format!("load \"{load}\": {error}"))?;
let (program, errors) = treble_lang::parser::parse_program(&source);
if let Some(error) = errors.first() {
return Err(format!(
"load \"{load}\": line {}: {}",
error.location.line, error.message
));
}
for line in program.lines {
match line {
treble_lang::SourceLine::Def(definition) => definitions.push(*definition),
treble_lang::SourceLine::Comment(_) | treble_lang::SourceLine::Blank => {}
_ => {
return Err(format!(
"load \"{load}\": a loaded file holds `def` blocks only โ \
a line that sounds or configures belongs in the buffer"
));
}
}
}
}
Ok(definitions)
}
#[cfg(feature = "render")]
fn check_definitions(session: &treble_lang::Session) -> Vec<String> {
let cycle = session
.piece()
.and_then(|piece| piece.sections.first().map(|s| s.cycle_seconds()))
.unwrap_or(2.0);
let mut problems: Vec<String> = session
.definitions()
.values()
.filter_map(|definition| {
treble_lang::render::compile::lower_instrument_def(definition, cycle)
.err()
.map(|error| format!("def {}: {error}", definition.name))
})
.collect();
problems.sort();
problems
}
#[cfg(feature = "render")]
fn check_loaded(definitions: &[treble_lang::ast::InstrumentDef]) -> Vec<String> {
definitions
.iter()
.filter_map(|definition| {
treble_lang::render::compile::lower_instrument_def(definition, 2.0)
.err()
.map(|error| format!("def {}: {error}", definition.name))
})
.collect()
}
#[cfg(not(feature = "render"))]
fn check_loaded(_definitions: &[treble_lang::ast::InstrumentDef]) -> Vec<String> {
Vec::new()
}
#[cfg(not(feature = "render"))]
fn check_definitions(_session: &treble_lang::Session) -> Vec<String> {
Vec::new()
}
#[cfg(not(feature = "render"))]
fn loaded_definitions(
_session: &treble_lang::Session,
_piece_path: &Path,
) -> Result<Vec<treble_lang::ast::InstrumentDef>, String> {
Ok(Vec::new())
}
fn clock(seconds: f64) -> String {
let whole = seconds.max(0.0) as u64;
format!("{}:{:02}", whole / 60, whole % 60)
}
#[cfg(feature = "serve")]
fn cmd_serve() -> Result<(), String> {
treble_lang::serve::run()
}
#[cfg(not(feature = "serve"))]
fn cmd_serve() -> Result<(), String> {
Err("this build has no server โ rebuild with `--features serve`".into())
}
#[cfg(not(feature = "render"))]
fn cmd_render(_args: &[String]) -> Result<(), String> {
Err("this build has no renderer โ rebuild with `--features render`".into())
}
#[cfg(feature = "render")]
fn cmd_render(args: &[String]) -> Result<(), String> {
use std::io::Write;
use std::path::PathBuf;
use treble_lang::render::{
Progress, RenderOptions, occurrence_start_frame, render_with_options, wav,
};
let mut input: Option<String> = None;
let mut out: Option<PathBuf> = None;
let mut rate: u32 = 44_100;
let mut extra: Vec<(String, String)> = Vec::new();
let mut section: Option<String> = None;
let mut passes: u32 = 1;
let mut only: Vec<String> = Vec::new();
let mut tail: Option<f64> = None;
let mut limit = true;
let (mut force, mut quiet, mut json) = (false, false, false);
let mut i = 0;
let mut only_positional = false;
while i < args.len() {
let arg = args[i].as_str();
let mut take = |name: &str| -> Result<String, String> {
i += 1;
args.get(i)
.cloned()
.ok_or_else(|| format!("{name}: expected a value"))
};
match arg {
_ if only_positional => input = Some(arg.to_string()),
"--" => only_positional = true,
"-o" | "--out" => out = Some(PathBuf::from(take("--out")?)),
"-r" | "--rate" => {
let value = take("--rate")?;
rate = value
.parse()
.map_err(|_| format!("--rate: expected a sample rate, got '{value}'"))?;
}
"-m" | "--meta" => {
let value = take("--meta")?;
let (key, text) = value
.split_once('=')
.ok_or_else(|| format!("--meta: expected key=value, got '{value}'"))?;
extra.push((key.to_ascii_lowercase(), text.to_string()));
}
"-s" | "--section" => section = Some(take("--section")?),
"-p" | "--passes" => {
let value = take("--passes")?;
passes = value.parse().ok().filter(|&n| n >= 1).ok_or_else(|| {
format!("--passes: expected a count of 1 or more, got '{value}'")
})?;
}
"-t" | "--tail" => {
let value = take("--tail")?;
tail = Some(
value
.parse::<f64>()
.ok()
.filter(|seconds| seconds.is_finite() && *seconds >= 0.0)
.ok_or_else(|| {
format!("--tail: expected seconds, 0 or more, got '{value}'")
})?,
);
}
"--only" => {
let value = take("--only")?;
only.extend(
value
.split(',')
.map(str::trim)
.filter(|name| !name.is_empty())
.map(str::to_string),
);
if only.is_empty() {
return Err("--only: expected one or more instruments".into());
}
}
"--no-limit" => limit = false,
"-f" | "--force" => force = true,
"-q" | "--quiet" => quiet = true,
"--json" => json = true,
other if other.starts_with('-') => {
return Err(format!("render: unknown option '{other}'"));
}
other => input = Some(other.to_string()),
}
i += 1;
}
if passes > 1 && section.is_none() {
return Err("--passes repeats one section, so it needs --section".into());
}
let input = input.ok_or("render: expected a file")?;
let source = read(&input)?;
let out = out.unwrap_or_else(|| Path::new(&input).with_extension("wav"));
let mut session = treble_lang::Session::new();
let result = session.evaluate(&source);
for error in &result.errors {
eprintln!("{input}:{}: {}", error.location.line, error.message);
}
if !result.errors.is_empty() {
return Err(format!("{} problem(s) in {input}", result.errors.len()));
}
let mut piece = session
.piece()
.ok_or(
"that buffer has no sections, so there is nothing to render โ add a `section` block",
)?
.clone();
for (key, value) in extra {
piece.metadata.retain(|(name, _)| name != &key);
piece.metadata.push((key, value));
}
let registry = instrument_registry(&session, &piece, &input)?;
if let Some(name) = §ion {
let index = section_named(&piece, name, &input)?;
piece = piece.isolate(index, passes);
}
if !only.is_empty() {
keep_only(&mut piece, &only)?;
}
if let Some(seconds) = tail {
piece.tail = seconds;
}
if !quiet {
eprintln!(
"{} {} sections, {} occurrences, {} cycles, {}",
piece.meta("title").unwrap_or(&input),
piece.sections.len(),
piece.timeline.len(),
piece.total_cycles(),
clock(piece.render_seconds())
);
}
let started = std::time::Instant::now();
let mut last_line = String::new();
let mut report = |progress: Progress| {
if quiet {
return;
}
let line = match progress {
Progress::Compiling { done, total } => {
format!("compiling {}", bar(done as f64 / total.max(1) as f64))
}
Progress::Building { slots } => format!("building {slots} voices"),
Progress::Rendering {
frames,
total_frames,
} => format!(
"rendering {} {}",
bar(frames as f64 / total_frames.max(1) as f64),
clock(frames as f64 / f64::from(rate))
),
Progress::Done { .. } => "rendering done".to_string(),
};
if line != last_line {
eprint!("\r\x1b[2K{line}");
let _ = std::io::stderr().flush();
last_line = line;
}
};
let options = RenderOptions {
master_limiter: limit,
};
let mut rendered = render_with_options(&piece, ®istry, rate, options, &mut report)?;
if !quiet {
eprint!("\r\x1b[2K");
}
if passes > 1 {
rendered.trim_start(occurrence_start_frame(
&piece,
piece.timeline.len() - 1,
rate,
));
}
wav::write(&out, &rendered, &piece, force)?;
let elapsed = started.elapsed().as_secs_f64();
if json {
println!(
"{{\"path\":{:?},\"section\":{},\"passes\":{},\"seconds\":{:.3},\"renderedSeconds\":{:.3},\"sampleRate\":{},\
\"sections\":{},\"occurrences\":{},\"cycles\":{},\"notes\":{},\"elapsed\":{:.3},\
\"islands\":{},\"slots\":{},\"workers\":{},\"preLimiterPeak\":{:.6},\
\"postLimiterPeak\":{:.6},\"maxGainReductionDb\":{:.3},\"limitedSamples\":{},\
\"limited\":{},\"peak\":{:.6}}}",
out.display().to_string(),
section
.as_ref()
.map_or("null".to_string(), |name| format!("{name:?}")),
passes,
rendered.seconds,
rendered.rendered_seconds,
rendered.sample_rate,
piece.sections.len(),
rendered.occurrences,
piece.total_cycles(),
rendered.notes,
elapsed,
rendered.telemetry.islands,
rendered.telemetry.slots,
rendered.telemetry.worker_threads,
rendered.telemetry.pre_limiter_peak,
rendered.telemetry.post_limiter_peak,
rendered.telemetry.max_gain_reduction_db,
rendered.telemetry.limited_samples,
rendered.limited,
rendered.peak()
);
} else if !quiet {
eprintln!(
"wrote {} ({}, {} notes, {:.1}ร realtime, {} islands on {} workers)",
out.display(),
clock(rendered.rendered_seconds),
rendered.notes,
rendered.rendered_seconds / elapsed.max(1e-9),
rendered.telemetry.islands,
rendered.telemetry.worker_threads
);
if rendered.telemetry.limited_samples > 0 {
eprintln!(
" master: pre-limit {:.3}, peak {:.3}, max reduction {:.1} dB, {} limited samples",
rendered.telemetry.pre_limiter_peak,
rendered.telemetry.post_limiter_peak,
rendered.telemetry.max_gain_reduction_db,
rendered.telemetry.limited_samples
);
}
if !rendered.limited && rendered.peak() > 1.0 {
eprintln!(
" note: unlimited, peaks at {:.3} โ kept in the float WAV, but mix it \
down before converting it to integer PCM",
rendered.peak()
);
}
for name in &rendered.unused {
eprintln!(" note: section '{name}' is never played");
}
}
Ok(())
}
#[cfg(feature = "render")]
fn instrument_registry(
session: &treble_lang::Session,
piece: &treble_lang::Piece,
input: &str,
) -> Result<treble::instruments::prelude::InstrumentRegistry, String> {
let mut registry = treble::instruments::prelude::InstrumentRegistry::built_in();
let cycle = piece
.sections
.first()
.map_or(2.0, |section| section.cycle_seconds());
let loaded = loaded_definitions(session, Path::new(input))?;
for definition in loaded.iter().chain(session.definitions().values()) {
let spec = treble_lang::render::compile::lower_instrument_def(definition, cycle)
.map_err(|error| format!("def {}: {error}", definition.name))?;
registry
.register(spec)
.map_err(|error| format!("def {}: {error}", definition.name))?;
}
Ok(registry)
}
#[cfg(feature = "render")]
fn section_named(piece: &treble_lang::Piece, name: &str, input: &str) -> Result<usize, String> {
piece.section_index(name).ok_or_else(|| {
let names: Vec<&str> = piece.sections.iter().map(|s| s.name.as_str()).collect();
format!(
"--section: '{name}' is not a section in {input} โ it has {}",
names.join(", ")
)
})
}
#[cfg(feature = "render")]
fn keep_only(piece: &mut treble_lang::Piece, instruments: &[String]) -> Result<(), String> {
let played = piece.played_instruments();
let missing: Vec<&str> = instruments
.iter()
.map(String::as_str)
.filter(|name| !played.contains(name))
.collect();
if !missing.is_empty() {
return Err(format!(
"--only: no line plays {} here โ the lines play {}",
missing.join(", "),
played.join(", ")
));
}
let keep: Vec<&str> = instruments.iter().map(String::as_str).collect();
piece.mute_except(&keep);
Ok(())
}
#[cfg(feature = "render")]
fn bar(fraction: f64) -> String {
const WIDTH: usize = 24;
let filled = ((fraction.clamp(0.0, 1.0)) * WIDTH as f64).round() as usize;
format!(
"[{}{}] {:>3.0}%",
"โ".repeat(filled),
"ยท".repeat(WIDTH - filled),
fraction.clamp(0.0, 1.0) * 100.0
)
}
#[cfg(all(test, feature = "render"))]
mod load_resolution_tests {
use super::*;
#[test]
fn load_lines_resolve_beside_the_piece_file() {
let dir = std::env::temp_dir().join(format!("treble-load-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("temp dir");
std::fs::write(
dir.join("sounds.trbl"),
"-- a comment\ndef lamp {\n tone sine gain 0.5 identity\n env adsr 0.01 0.1 0.5 0.1\n}\n",
)
.expect("write defs");
let piece = dir.join("piece.rt");
std::fs::write(
&piece,
"bpm 120\nload \"sounds.trbl\"\nsection s 1 {\n l lamp \"0\"\n}\n",
)
.expect("write piece");
let mut session = treble_lang::Session::new();
session.evaluate(&std::fs::read_to_string(&piece).unwrap());
let definitions = loaded_definitions(&session, &piece).expect("resolve");
assert_eq!(definitions.len(), 1);
assert_eq!(definitions[0].name, "lamp");
std::fs::write(dir.join("sounds.trbl"), "bpm 99\n").expect("rewrite");
let error = loaded_definitions(&session, &piece).expect_err("a directive must be refused");
assert!(error.contains("def` blocks only"), "{error}");
std::fs::remove_dir_all(&dir).ok();
}
}
#[cfg(all(test, feature = "render"))]
mod render_option_tests {
use super::*;
fn piece_of(source: &str) -> treble_lang::Piece {
let mut session = treble_lang::Session::new();
let result = session.evaluate(source);
assert!(result.errors.is_empty(), "{:?}", result.errors);
session.piece().expect("a piece").clone()
}
const TWO_SECTIONS: &str = "bpm 120\n\
section intro 2 {\n k kick \"x\"\n}\n\
section verse 4 {\n lead sine \"c4 e4\"\n}\n\
arrange intro verse\n";
#[test]
fn passes_without_a_section_are_refused_before_anything_is_read() {
let args: Vec<String> = ["missing.rt", "--passes", "2"]
.iter()
.map(|arg| arg.to_string())
.collect();
let error = cmd_render(&args).expect_err("--passes needs --section");
assert!(error.contains("needs --section"), "{error}");
let zero: Vec<String> = ["missing.rt", "--passes", "0"]
.iter()
.map(|arg| arg.to_string())
.collect();
let error = cmd_render(&zero).expect_err("zero passes");
assert!(error.contains("1 or more"), "{error}");
}
#[test]
fn only_mutes_the_other_instruments_and_refuses_a_stranger() {
let mut piece = piece_of(
"bpm 120\nsection a 2 {\n k kick \"x\"\n b bass \"c2\"\n}\n\
section sketch 1 {\n p pad \"c3\"\n}\n\
hum sine \"c1\"\n\
arrange a\n",
);
let error =
keep_only(&mut piece.clone(), &["bass".into(), "basss".into()]).expect_err("a typo");
assert!(error.contains("basss"), "{error}");
assert!(error.contains("bass, kick, sine"), "{error}");
assert!(keep_only(&mut piece.clone(), &["pad".into()]).is_err());
keep_only(&mut piece, &["bass".into(), "sine".into()]).expect("both play");
let muted: Vec<(&str, bool)> = piece.sections[0]
.patterns
.iter()
.chain(&piece.throughout)
.map(|line| (line.name.as_str(), line.muted))
.collect();
assert_eq!(muted, vec![("k", true), ("b", false), ("hum", false)]);
}
#[test]
fn a_tail_must_be_a_length() {
for bad in ["-1", "soon", "inf", "NaN"] {
let args: Vec<String> = ["missing.rt", "--tail", bad]
.iter()
.map(|arg| arg.to_string())
.collect();
let error = cmd_render(&args).expect_err(bad);
assert!(error.contains("--tail"), "{bad}: {error}");
}
}
#[test]
fn an_unknown_section_is_refused_with_the_names_there_are() {
let piece = piece_of(TWO_SECTIONS);
assert_eq!(section_named(&piece, "verse", "p.rt"), Ok(1));
let error = section_named(&piece, "chorus", "p.rt").expect_err("no chorus");
assert!(error.contains("'chorus'"), "{error}");
assert!(error.contains("intro, verse"), "{error}");
}
}
#[cfg(test)]
mod info_tests {
use super::*;
#[test]
fn the_json_describes_sections_where_they_first_play() {
let mut session = treble_lang::Session::new();
let result = session.evaluate(
"meta title \"Drift\"\nbpm 120\ntail 1.5\nseed 7\n\
section intro 2 {\n k kick \"x\"\n}\n\
section verse 4 {\n group keys {\n e epiano \"c4\" @ 3..\n } | reverb 0.2\n\
\x20 ; b bass \"c2\"\n}\n\
hum sine \"c1\"\n\
arrange intro*2 verse intro\n",
);
assert!(result.errors.is_empty(), "{:?}", result.errors);
let described = describe(session.piece().expect("a piece"), "p.rt", None);
assert_eq!(described["title"], "Drift");
assert_eq!(described["metadata"]["title"], "Drift");
assert_eq!(described["tail"], 1.5);
assert_eq!(described["seed"], 7);
assert_eq!(described["cycles"], 10);
assert_eq!(described["arrangement"][0]["repeat"], 2);
assert_eq!(described["timeline"][2]["section"], "verse");
assert_eq!(described["timeline"][2]["startCycle"], 4);
assert_eq!(described["timeline"][2]["startSeconds"], 8.0);
let verse = &described["sections"][1];
assert_eq!(verse["name"], "verse");
assert_eq!(verse["sig"], serde_json::json!([4, 4]));
assert_eq!(verse["cycleSeconds"], 2.0);
assert_eq!(verse["seconds"], 8.0);
assert_eq!(verse["firstCycle"], 4);
assert_eq!(verse["occurrences"], 1);
assert_eq!(described["sections"][0]["occurrences"], 3);
assert_eq!(
verse["lines"],
serde_json::json!([
{"name": "e", "instrument": "epiano", "group": "keys", "muted": false,
"span": {"from": 3, "to": 4}},
{"name": "b", "instrument": "bass", "group": null, "muted": true, "span": null},
])
);
assert_eq!(verse["groups"][0]["name"], "keys");
assert_eq!(described["throughout"][0]["instrument"], "sine");
}
}
#[cfg(all(test, feature = "render"))]
mod bars_tests {
use super::*;
#[test]
fn each_line_says_which_bars_it_strikes_on() {
let mut session = treble_lang::Session::new();
let result = session.evaluate(
"bpm 120\nsection a 4 {\n k kick \"x\"\n late sine \"c4\" @ 3\n \
gap sine \"<c4 ~ c4 ~>\"\n}\n",
);
assert!(result.errors.is_empty(), "{:?}", result.errors);
let piece = session.piece().expect("a piece");
let bars = struck_bars(&session, piece, "p.rt").expect("bars");
let described = describe(piece, "p.rt", Some(&bars));
let lines = &described["sections"][0]["lines"];
assert_eq!(
lines[0]["bars"],
serde_json::json!([true, true, true, true])
);
assert_eq!(
lines[1]["bars"],
serde_json::json!([false, false, true, false])
);
assert_eq!(
lines[2]["bars"],
serde_json::json!([true, false, true, false])
);
}
}