3 releases

0.1.2 Apr 28, 2024
0.1.1 Mar 9, 2024
0.1.0 Jan 14, 2024

#16 in #composer


Used in 5 crates (2 directly)

MIT license

15KB
55 lines

icon RedACT Composer

docs-badge crates.io-badge ci-badge license-badge

A Rust library for building modular musical composers.

Composers are built by creating a set of composition elements, and defining how each of these elements will generate further sub-elements. In this library's domain, these correspond to the Element and Renderer traits respectively.

This project adheres to Semantic Versioning. Most importantly at this time would be spec item #4.



Setup

cargo add redact-composer

If using the serde feature, typetag is also required:

cargo add typetag

Example

The basic capabilities can be demonstrated by creating a simple I-IV-V-I chord composer. The full code example is located at redact-composer/examples/simple.rs.

Building Blocks

This example composer will use some library-provided elements (Chord, Part, PlayNote) and two new elements:

#[derive(Element, Serialize, Deserialize, Debug)]
pub struct CompositionRoot;

#[derive(Element, Serialize, Deserialize, Debug)]
struct PlayChords;

Before moving ahead, some background: A composition is an n-ary tree structure and is "composed" by starting with a root Element, and calling its associated Renderer which generates additional Elements as children. These children then have their Renderers called, and this process continues until tree leaves are reached (i.e. elements that do not generate further children).

This composer will use the CompositionRoot element as a root. Defining a Renderer for this then looks like:

struct CompositionRenderer;
impl Renderer for CompositionRenderer {
    type Element = CompositionRoot;

    fn render(
        &self, composition: SegmentRef<CompositionRoot>, context: CompositionContext,
    ) -> Result<Vec<Segment>> {
        let chords: [Chord; 4] = [
            (C, maj).into(),
            (F, maj).into(),
            (G, maj).into(),
            (C, maj).into(),
        ];

        Ok(
            // Repeat the four chords over the composition -- one every two beats
            Rhythm::from([2 * context.beat_length()])
                .iter_over(composition)
                .zip(chords.into_iter().cycle())
                .map(|(subdivision, chord)| chord.over(subdivision))
                .chain([
                    // Also include the new component, spanning the whole composition
                    Part::instrument(PlayChords).over(composition),
                ])
                .collect(),
        )
    }
}

Note: Part::instrument(...) is just a wrapper for another element, indicating that notes generated within the wrapped element are to be played by a single instrument at a time.

This Renderer takes a CompositionRoot element (via a SegmentRef) and generates several children including Chord elements (with a Rhythm of one every two beats over the composition), and newly defined PlayChords element. These children are returned as Segments, which defines where they are located in the composition's timeline.

At this stage, the Chord and PlayChords elements are just abstract concepts however, and need to produce something concrete. This is done with another Renderer for PlayChords:

struct PlayChordsRenderer;
impl Renderer for PlayChordsRenderer {
    type Element = PlayChords;

    fn render(
        &self, play_chords: SegmentRef<PlayChords>, context: CompositionContext,
    ) -> Result<Vec<Segment>> {
        // `CompositionContext` enables finding previously rendered elements
        let chord_segments = context.find::<Chord>()
            .with_timing(Within, play_chords)
            .require_all()?;
        // As well as random number generation
        let mut rng = context.rng();

        // Map Chord notes to PlayNote elements, forming a triad
        let notes = chord_segments
            .iter()
            .flat_map(|chord| {
                chord.element
                    .iter_notes_in_range(Note::from((C, 4))..Note::from((C, 5)))
                    .map(|note|
                        // Add subtle nuance striking the notes with different velocities
                        note.play(rng.gen_range(80..110) /* velocity */)
                            .over(chord))
                    .collect::<Vec<_>>()
            })
            .collect();

        Ok(notes)
    }
}

Here, CompositionContext is used to reference the previously created Chord segments. Then the Notes from each Chord within an octave range are played over the Chord segment's timing.

Creating the Composer

In essence, a Composer is just a set of Renderers, and can be constructed with just a little bit of glue:

let composer = Composer::from(
    RenderEngine::new() + CompositionRenderer + PlayChordsRenderer,
);

And finally the magic unfolds by passing a root Segment to its compose() method.

// Create a 16-beat length composition
let composition_length = composer.options.ticks_per_beat * 16;
let composition = composer.compose(CompositionRoot.over(0..composition_length));

// Convert it to a MIDI file and save it
MidiConverter::convert(&composition).save("./composition.mid").unwrap();

When plugged into your favorite midi player, the composition.mid file should sound somewhat like this:

https://github.com/dousto/redact-composer/assets/5882189/9928539f-2e15-4049-96ad-f536784ee7a1

Additionally, composition outputs support serialization/deserialization (with serde feature, enabled by default).

// Write the composition output in json format
fs::write("./composition.json", serde_json::to_string_pretty(&composition).unwrap()).unwrap();

Much bigger example

Check out this repo for a more in depth example which utilizes additional features to create a full length composition.

Inspector

Debugging composition outputs can quickly get unwieldy with larger compositions. redact-composer-inspector is a simple web tool that helps to visualize and navigate the structure of Composition outputs (currently only compatible with json output).

For example, here is the simple example loaded in the inspector.

Feature Flags

default

derive, musical, midi, serde

derive default

Enable derive macro for Element.

musical default

Include musical domain module. (Key, Chord, Rhythm, etc..).

midi default

Include midi module containing MIDI-related Elements and MIDI converter for Compositions.

serde default

Enables serialization and deserialization of Composition outputs via (as you may have guessed) serde.

Dependencies

~0.6–1.2MB
~26K SLoC