Skip to content

cepthomas/Nebulua

Repository files navigation

Nebulua

A simplified version of Nebulator using Lua as the script flavor. While the primary intent is to generate music-by-code, runtime interaction is also supported using midi inputs.

Windows only. Requires VS2022, .NET8, Lua 5.4.

It's called Nebulator/Nebulua after a MarkS C++ noisemaker called Nebula which manipulated synth parameters via code.

logo

This application uses these FOSS components:

Usage

Currently this is a build-and-run-it-yourself configuration. Eventually an installer may be provided. Note that running in VS debugger has a very slow startup. Running from the exe or cli is ok.

Building this solution requires a folder named LBOT at the top level containing the contents of LuaBagOfTricks. This can be done one of several ways:

  • git submodule
  • copy of pertinent parts
  • symlink: mklink /d <current_folder>\LBOT <lbot_source_folder>\LuaBagOfTricks

The UI does have a terminal which can be used for debugging scripts using Lua debugger. See example for how-to.

Since the built-in Windows GM player sounds terrible, there are a couple of options for playing midi locally:

  • Replace it with virtualmidisynth and your favorite soundfont. Note that this app has a significant delay handling realtime midi inputs. This will not be a problem if you are just playing a midi file.
  • If you are using a DAW for sound generation, you can connect to it with a virtual midi loopback like loopMIDI Finally a real native service!.

Example Script Files

See the examples directory for material while perusing the docs.

File Description
example.lua Source file showing example of static sequence and loop definitions, and creating notes by script functions.
airport.lua A take on Eno's Music for Airports - adapted from this.

Definitions

Glossary

  • Since this is code, almost everything is 0-based, not 1-based like standard music notation.
  • Nebulua doesn't care about measures, that's up to you.
Name Type Description Range
chan_num int midi channel number 1 -> 16
chan_name char* name for ui
dev_index int index into windows midi device table 0 -> N
dev_name char* from windows midi device table
chan_hnd int internal opaque handle for channel id
controller int midi standard from midi_defs.lua 0 -> 127
value int controller payload 0 -> 127
note_num int midi standard 0 -> 127
volume double 0.0 -> 1.0, 0 means note off
velocity int 0 -> 127, 0 means note off
bar int absolute 0 -> N
beat int in bar - quarter note 0 -> 3
tick int in beat - musical 0 -> 7
tick int absolute time 0 -> N

Time

  • Midi DeltaTicksPerQuarterNote aka ticks per beat is fixed at 8. This provides 32nd note resolution which should be more than adequate.
  • The fast timer resolution is fixed at 1 msec giving a usable range of bpm of 40 (188 msec period) to 240 (31 msec period).
  • Each sequence is typically 8 beats. Each section is typically 4 sequences -> 32 beats. A 4 minute song at 80bpm is 320 beats -> 10 sections -> 40 sequences. If each sequence has an average of 8 notes for a total of 320 notes per instrument. A "typical" song with 6 instruments would then have about 4000 on/off events.

Standard Note Syntax

  • Scales and chords are specified by strings like "1 4 6 b13".
  • There are many builtin scales and chords which you can see by clicking '?'.
  • Users can add their own by using function create_definition("FOO", "1 4 6 b13").

Notes, chords, and scales can be specified in several ways:

Form Description
"F4" Named note with octave
"F4.m7" Named chord in the key of middle F
"F4.Aeolian" Named scale in the key of middle F
"F4.FOO" Custom chord or scale created with create_definition()
inst.SideStick Drum name from the definitions
57 Simple midi note number

Writing Scripts

Use your favorite external text editor, preferably with lua syntax highlighting and autocomplete.

Refer to this composition showing example of static sequence and loop definitions, and creating notes by script functions.

Scripts can also be dynamic, a take on Eno's Music for Airports. These have no canned sequences.

Almost all errors are considered fatal as they are usually things the user needs to fix before continuing such as script syntax errors. They are logged, written to the CLI, and then the application exits.

Internally, time is in units of tick. This is unwieldy for scripts so helpers are provided. Note that tick is a plain integer so normal algebraic operations (+ - etc) can be performed on them.

All volumes in the script are numbers in the range of 0.0 -> 1.0. These are mapped to standard midi values downstream. If the script uses out of range values, they are constrained and a warning is issued.

Imports

Scripts need this section.

local api = require("script_api") -- lua api
local def = require("defs_api")   -- lua api
local mt  = require("music_time") -- music time utility
local ut  = require("lbot_utils") -- misc utilities
-- These also may be useful:
local mid = require("midi_defs")  -- GM midi instrument definitions
local mus = require("music_defs") -- chords, scales, etc

Time

function mt.mt_to_tick(bar, beat, tick)

Create from explicit music_time parts.

  • bar: Bar number 0 - 1000
  • beat: Beat number 0 - 3
  • tick: Subbeat number 0 - 7
  • return: Corresponding tick
function mt.beats_to_tick(beat, tick)

Create from explicit music_time parts.

  • beat: Beat number 0 - 1000
  • tick: Subbeat number 0 - 7
  • return: Corresponding tick
function mt.str_to_tick(str)

Parse from string like "1.2.3" or "1.2" or "1".

  • str: The string
  • return: Corresponding tick
function mt.tick_to_str(tick)

Format the value like "1.2.3".

  • tick: To format
  • return: The string
function mt.tick_to_mt(tick)

Translate tick into music_time parts.

  • tick: To translate
  • return: bar, beat, tick

Script Functions

Call these from your script.

function api.open_output_channel(dev_name, chan_num, chan_name, patch)

Register an output channel.

  • dev_name: The system name.
  • chan_num: Specific channel number.
  • chan_name: Name for channel.
  • patch: Send this patch number.
  • return: A channel handle to use in subsequent functions.
function api.open_input_channel(dev_name, chan_num, chan_name)

Register an input midi channel.

  • dev_name: The system name.
  • chan_num: Specific channel number.
  • chan_name: Name for channel.
  • return: A channel handle to use in subsequent functions.
function api.send_note(chan_hnd, note_num, volume, dur)

Send a note on/off immediately. Adds a note off if dur is specified and tick clock is running.

  • chan_hnd: The channel handle to send it on.
  • note_num: Which.
  • volume: Note volume. 0.0 -> 1.0. 0.0 means note off.
  • dur: How long it lasts in music time. Optional for e.g drums.
function api.send_controller(chan_hnd, controller, value)

Send a controller immediately. Useful for things like panning and bank select.

  • chan_hnd: The channel handle to send it on.
  • controller: Which.
  • value: What.
function api.set_volume(chan_hnd, volume)

Set volume for the channel.

  • chan_hnd: The channel handle to set.
  • volume: Channel volume. 0.0 -> 1.0.
function api.log_error(msg)
function api.log_info(msg)
function api.log_debug(msg)
function api.log_trace(msg)

Log to the application log. Several flavors.

  • msg: Text.
function api.set_tempo(bpm)

Change the play tempo.

  • bpm: New tempo.
function api.process_comp()

If it's a static composition call this in setup().

  • return: Meta info about the composition for internal use.
function api.process_step(tick)

Call this in step(tick) to process internal things e.g. note offs.

  • tick: current tick.
function api.parse_sequence_steps(chan_hnd, sequence)

Create a dynamic object from a sequence. See Composition.

  • chan_hnd: Specific channel.
  • sequence: The sequence to parse.
  • return: An object for use by send_sequence_steps().
function api.send_sequence_steps(seq_steps, tick)

Send the object created in parse_sequence_steps(). See Composition.

  • seq_steps: when to send it, usually current tick.
  • tick: when to send it, usually current tick.

Script Callbacks

These are called by the system for implementing in the script.

function setup()

Called once to initialize your script stuff. Required.

function step(tick)

Called every subbeat/tick. Required.

  • tick: current tick.
function receive_note(chan_hnd, note_num, volume)

Called when input note arrives. Optional.

  • chan_hnd: Input channel handle.
  • note_num: Note number 0 -> 127.
  • volume: Volume 0.0 -> 1.0.
  • return: Nebulua status.
function receive_control(chan_hnd, controller, value)

Called when input controller arrives.

  • chan_hnd: Input channel handle.
  • controller: Specific controller id 0 -> 127.
  • value: Payload 0 -> 127.
  • return: Nebulua status.

Composition

A composition is comprised of one or more sections, each of which has one or more sequences of notes. You first create your sequences like this:

local example_seq =
{
    -- | beat 1 | beat 2 |........|........|........|........|........|........|,  WHAT_TO_PLAY
    { "|M-------|--      |        |        |7-------|--      |        |        |", "G4.m7" },
    { "|7-------|--      |        |        |7-------|--      |        |        |",  84 },
    { "|        |        |        |5---    |        |        |        |5-8---  |", "D6" },
    { "|        |        |        |5---    |        |        |        |5-8---  |",  seq_func }
},
  • |7-------| is one beat with 8 subs.
  • 1 to 9 (volume level) starts a note which is held for subsequent -. The note is ended with any other character than -.
  • |, . and are ignored, used for visual assist only. These are particularly useful for drum patterns.
  • WHAT_TO_PLAY is a standard string, integer, or function.
  • Pattern: describes a sequence of notes, kind of like a piano roll.

Then you group sequences into sections, typically things like verse, chorus, bridge, etc.

sections =
{
    beginning =
    {
        { hnd_keys,  keys_verse,  keys_verse,  keys_verse,  keys_verse },
        { hnd_drums, drums_verse, drums_verse, drums_verse, drums_verse },
        { hnd_bass,  bass_verse,  bass_verse,  bass_verse,  bass_verse }
    },
    middle = { ... },
    ending = { ... }
}

Sequences can also be loaded dynamically and triggered at arbitrary times in the script.

local example_seq_steps = api.parse_sequence_steps(hnd_keys, example_seq)

function step(tick)
    local bar, beat, sub = mt.tick_to_mt(tick)

    if bar == 1 and beat == 0 and sub == 0 then
        api.send_sequence_steps(example_seq_steps, tick)
    end

    -- Do this now.
    api.process_step(tick)
end

Utilities

Some helpers are found in music_defs.lua. The main useful ones are these.

function def.get_notes_from_string(snote)

Parse note or notes from input value.

function def.create_definition(name, intervals)

Define a group of notes for use as a chord or scale. Then it can be used by get_notes_from_string().

  • name: reference name.
  • intervals: string of note definitions. Like "1 +3 4 -b7".

Tech Notes

  • Windows 64 bit only. Build it with VS2022 and .NET8.
  • Uses 64 bit Lua 5.4.2 from here.
  • Uses C code conventions.
  • The app is slow to start up in the VS debugger - some mysterious machinations under the hood. Running from the cmd line is fine.

Architecture

  • There are 3 threads:
    • Main which does window event loop and the cli.
    • Midi receive events callback.
    • Timer periodic events callback.
  • The shared resource that requires synchronization is a singleton Interop. All calls to it need to be protected - typically lock() mechanism is best.
  • All lua api code is in modules except for the interop functions such as step() which are in global _G.

Going up and down the stacks is a bit convoluted. Here are some examples that help (hopefully).

Host -> lua

MmTimer_Callback(double totalElapsed, double periodElapsed)  [in App\Core.cs]
    Interop.Step(tick)  [in interop\Interop.cpp]
        luainterop_Step(_l, tick)  [in interop\luainterop.c]
            function step(tick)  [in my_lua_script.lua]

Lua -> host

neb.send_note(hnd_synth, note_num, volume)  [in my_lua_script.lua]
    luainterop_SendNote(lua_State* l, int chan_hnd, int note_num, double volume)  [in interop\luainterop.c]
        Interop::Notify(args)  [in interop\Interop.cpp]
            calls driver...

Error Handling

  • Interop throws LuaException for non-fatal things like user syntax errors and fatal things like C runtime errors. The former can be edited by the user and reloaded without restarting the app.
  • Nebulua may throw AppException for non-fatal things.
  • Lua functions defined in C do not call luaL_error(). Only call luaL_error() in code that is called from the lua side. C side needs to handle function returns manually via status codes, error msgs, etc.

Files

Source dir:

Nebulua
|   - standard C# project for the main app
|   README.md - hello!
|   *.cs
|   etc...
+---Interop - .NET binding to C/Lua
|   *.c/cpp/h
+---lua - lua modules for application
|       music_time.lua
|       midi_defs.lua
|       music_defs.lua
|       defs_api.lua
|       script_api.lua
|       step_types.lua
+---LBOT - LuaBagOfTricks modules for application - link or copy or ...
+---examples
|       airport.lua
|       example.lua
|       ex2.lua
+---lib - .NET dependencies
\---Test - various test code

About

An experimental version of Nebulator using Lua as the script flavor.

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors