I've created a new program, lua-music-visualizer
,
that works with local music files as well as MPD, I'm focusing my free time on that.
Leaving the original documentation below.
This is a program primarily for creating videos from MPD, using Lua. It's suitable for using in a never-ending livestream. However, you can use it without MPD and create videos offline.
It reads audio data from a file, pipe, or FIFO, and runs one or more Lua scripts to create a video.
Video is output to a FIFO or pipe as an AVI stream with raw audio and video. This AVI FIFO can be read by ffmpeg and encoded to an appropriate format. It will refuse to write the video to a regular file, as its a very, very high bitrate (though you could always just output to stdout and redirect to a file if you really want to).
mpd-visualizer \
-w (width) \
-h (height) \
-f (framerate) \
-r (audio samplerate) \
-c (audio channels) \
-s (audio samplesize (in bytes)) \
-b (number of visualizer bars to calculate) \
-i /path/to/audio.fifo (or - for stdin) \
-o /path/to/video.fifo (or - for stdout) \
-l /path/to/your/lua/scripts/folder \
-m (1|0) enable/disable mpd polling (default enabled) \
# Following options only valid when -m=0 \
-t title \
-a artist \
-A album \
-F filename \
-T totaltime (in seconds) \
-- optional process to launch
-w (width)
: Video width, ie,-w 1280
-h (height)
: Video height, ie,-h 720
-f (framerate)
: Video framerate, ie,-f 30
-r (samplerate)
: Audio samplerate, in Hz, ie:-r 48000
-c (channels)
: Audio channels, ie:-c 2
-s (samplesize)
: Audio samplesize in bytes, ie-s 2
for 16-bit audio-b (bars)
: number of visualizer bars to calculate-i /path
: Path to your MPD FIFO (or - for stdin)-o /path
: Path to your video FIFO (or - for stdin)-l /path
: Path to folder of Lua scripts-m (1|0)
: Enable/disable MPD polling (default enabled)
If you disable MPD polling, you can manually set a few properties, these
will show up in Lua's song
object.
-t title
-a artist
-A album
-F filename
-T totaltime (in seconds)
Additionally, anything given on the command line after your options
will be launched as a child process, and video data will be input to
its standard input. In this mode, whatever you gave for -o
is ignored.
This allows you do things like:
mpd-visualizer \
-w 1280 \
-h 720 \
-f 30 \
-r 48000 \
-c 2 \
-s 2 \
-b 20 \
-i /some-fifo \
-l some-folder \
-- \
ffmpeg \
-re \
-i pipe:0 \
-c:v libx264 \
-c:a aac \
-strict -2 \
-f flv rtmp://some-host/whatever
This way, you can use MPD's "pipe" output type with mpd-visualizer. So MPD will launch mpd-visualizer, and mpd-visualizer will launch ffmpeg.
Additional ideas:
Turn a single song into a video (without MPD)
ffmpeg -i some-song.mp3 -f s16le -ac 2 -ar 48000 - | \
mpd-visualizer \
-w 1280 \
-h 720 \
-f 30 \
-r 48000 \
-c 2 \
-s 2 \
-b 20 \
-i - \
-o - \
-l some-folder \
-m 0 \
-t "Some Song" \
-a "Some Artist" \
-A "Some Album" \
-- \
ffmpeg -i pipe:0 -c:v libx264 -c:a aac -strict -2 -y some-file.mp4
mpd-visualizer
will connect to host 127.0.0.1
on port 6600
without a password.
You can use the MPD_HOST
and MPD_PORT
environment variables to override this.
MPD_HOST
-- used to connect to hosts besides127.0.0.1
, or to UNIX sockets.- To connect to a UNIX socket, use
MPD_HOST=/path/to/socket
- To specify a password, use
MPD_HOST=password@hostname
orMPD_HOST=password@/path/to/socket
- To connect to a UNIX socket, use
MPD_PORT
-- used to specify a port other than6600
, ignored ifMPD_HOST
is an absolute path
- LuaJIT or Lua 5.3.
- This may work with Lua 5.1 or Lua 5.2, so long as you have either Lua BitOp or Bit32, untested
- FFTW
- skalibs
- s6-dns
Hopefully, you can just type make
and compile mpd-visualizer
If you need to customize your compiler, cflags, ldflags, etc
copy config.mak.dist
to config.mak
and edit as-needed.
When mpd-visualizer
starts up, it will start reading in audio from the MPD FIFO (or stdin). As
soon as it has enough audio to generate frames of video, it will start doing so. If your
video FIFO does not exist, it will create it (and automatically delete it when it exits).
If the video FIFO already exists, it uses it, and does NOT delete it when it exits.
It also connects to MPD as a client to poll song metadata, it only polls when MPD reports the song has changed in some way. You can also disable MPD polling entirely.
At startup, it will iterate through your Lua scripts folder and try loading scripts. Your scripts should return either a Lua function, or a table of functions, like:
return function()
print('making video frame')
end
Or for the table of functions:
return {
onload = function()
print('loaded!')
end,
onreload = function()
print('reloading!')
end,
onframe = function()
print('making video frame')
end,
}
There's 3 functions that mpd-visualizer
looks for when you return a table, the only required function is onframe
.
If you only return a function, it's treated as the onframe
function.
onload()
- this function is called only once, when the script is loaded whilempd-visualizer
is starting up.onreload()
- whenevermpd-visualizer
receives aUSR1
signal, it will reload the Lua script and callonreload()
onframe()
- this function is called every timempd-visualizer
wants to make a frame of video.
On every frame, mpd-visualizer
will calculate a Fast Fourier Transform on the available
audio samples, creating an array of frequencies and amplitudes for Lua. This is useful
for drawing a frequency visualization in your video. It will then call all loaded onframe
functions
from the loaded Lua scripts.
When it receives a USR1
signal, it will reload all Lua
scripts.
mpd-visualizer
will keep running until either:
- the input audio stream ends
mpd-visualizer
receives aINT
orTERM
signal.
Before any script is called, your Lua folder is added to the package.path
variable,
meaning you can create submodules within your Lua folder and load them using require
.
Within your Lua script, you have a few pre-defined global variables:
stream
- a table representing the video streamimage
- a module for loading image filesfont
- a module for loading BDF fontsfile
- a module for filesystem operationssong
- a table of what's playing, from MPD.
The stream
table has two keys:
stream.video
- this represents the current frame of video, it's actually an instance of aframe
which has more details belowstream.video.framerate
- the video framerate
stream.audio
- a table of audio datastream.audio.samplerate
- audio samplerate, like48000
stream.audio.channels
- audio channels, like2
stream.audio.samplesize
- sample size in bytes, like2
for 16-bit audiostream.audio.freqs
- an array of available frequencies, suitable for making a visualizerstream.audio.amps
- an array of available amplitudes, suitable for making a visualizer - values between 0.0 and 1.0stream.audio.spectrum_len
- the number of available amplitudes/frequencies
The image
module can load most images, including GIFs. All images have a 2-stage loading process. Initially, it
just probes the image for information like height, width, etc. You can then load the image synchronously or asynchronously.
If you're loading images in the onload
function (that is, at the very beginning of the program's execution), its safe
to load images synchronously. Otherwise, you should load images asynchronously.
img = image.new(filename, width, height, channels)
- Either filename is required, or
width/height/channels
if you passnil
for the filename - If filename is given, this will probe an image file. Returns an image object on success, nil on failure
- If width, height, or channels is 0 or nil, then the image will not be resized or processed
- If width or height are set, the image will be resized
- If channels is set, the image will be forced to use that number of channels
- Basically, channels = 3 for most bitmaps, channels = 4 for transparent images.
- The actual image data is NOT loaded, use
img:load()
to load data.
- If filename is nil, then an empty image is created with the given width/height/channels
- Either filename is required, or
Scroll down to "Image Instances" for details on image methods like img:load()
The font
object can load BDF (bitmap) fonts.
f = font.new(filename)
- Loads a BDF font and returns a font object
Scroll down to "Font Instances" for details on font methods
The file
object has methods for common file operations:
-
dir = file.ls(path)
- Lists files in a directory
- Returns an array of file objects with two keys:
file
- the actual file pathmtime
- file modification time
-
dirname = file.dirname(path)
- Equivalent to the dirname call
-
basename = file.basename(path)
- Equivalent to the basename call
-
realpath = file.realpath(path)
- Equivalent to the realpath call
-
cwd = file.getcwd()
- Equivalent to the getcwd call
-
ok = file.exists(path)
- Returns
true
if a path exists,nil
otherwise.
- Returns
The song
object has metadata on the current song. The only guaranteed key is elapsed
. Everything else can be nil (if you're connected to MPD, then file
, id
, and total
are also guaranteed).
song.file
- the filename of the playing songsong.id
- the id of the playing songsong.elapsed
- the elapsed time of the current song, in secondssong.total
- the total time of the current song, in secondssong.title
- the title of the current songsong.artist
- the artist of the current songsong.album
- the album of the current songsong.message
-mpd-visualizer
uses MPD's client-to-client functionality, It listens on a channel namedvisualizer
, if there's a new message on that channel, it will appear here in the song object.
An image instance has the following methods and properties
img.state
- one oferror
,unloaded
,loading
,loaded
,fixed
img.width
- the image widthimg.height
- the image heightimg.channels
- the image channels (3 for RGB, 4 for RGBA)img.frames
- only available after callingimg:load
, an array of one or more framesimg.framecount
- only available after callingimg:load
, total number of frames in theframes
arrayimg.delays
- only available afte callingimg:load
- an array of frame delays (only applicable to gifs)img:load(async)
- loads an image into memory- If
async
is true, image is loaded in the background and available on some future iteration ofonframe
- else, image is loaded immediately
- If
img:unload()
- unloads an image from memory
If img:load()
fails, either asynchronously or synchronously, then the state
key will be set to error
Once the image is loaded, it will contain an array of frames. Additionally, stream.video
is an instance of a frame
For convenience, most frame
functions can be used on the stream
object directly, instead of stream.video
, ie,
stream:get_pixel(x,y)
can be used in place of stream.video:get_pixel(x,y)
frame.width
- same asimg.width
frame.height
- same asimg.height
frame.channels
- same asimg.channels
frame.state
- all frames arefixed
imagesr, g, b, a = frame:get_pixel(x,y)
- retrieves the red, green, blue, and alpha values for a given pixel
x,y
starts at1,1
for the top-left corner of the image
frame:set_pixel(x,y,r,g,b,a)
- sets an individual pixel of an imagex,y
starts at1,1
for the top-left corner of the imager, g, b
represents the red, green, and blue values, 0 - 255a
is an optional alpha value, 0 - 255
frame:set_pixel_hsl(x,y,r,g,b,a)
- sets a pixel using Hue, Saturation, Lightnessx,y
starts at1,1
for the top-left corner of the imageh, s, l
represents hue (0-360), saturation (0-100), and lightness (0-100)a
is an optional alpha value, 0 - 255
frame:draw_rectangle(x1,y1,x2,y2,r,g,b,a)
- draws a rectangle from x1,y1 to x2, y2x,y
starts at1,1
for the top-left corner of the imager, g, b
represents the red, green, and blue values, 0 - 255a
is an optional alpha value, 0 - 255
frame:draw_rectangle_hsl(x1,y1,x2,y2,h,s,l,a)
- draws a rectangle from x1,y1 to x2, y2 using hue, saturation, and lightnessx,y
starts at1,1
for the top-left corner of the imageh, s, l
represents hue (0-360), saturation (0-100), and lightness (0-100)a
is an optional alpha value, 0 - 255
frame:set(frame)
- copies a whole frame as-is to the frame
- the source and destination frame must have the same width, height, and channels values
frame:stamp(stamp,x,y,flip,mask,a)
- stamps a frame (
stamp
) on top offrame
atx,y
x,y
starts at1,1
for the top-left corner of the imageflip
is an optional table with the following keys:hflip
- flipstamp
horizontallyvflip
- flipstamp
vertically
mask
is an optional table with the following keys:left
- maskstamp
's pixels leftright
- maskstamp
's pixels righttop
- maskstamp
's pixels topbottom
- maskstamp
's pixels bottom
a
is an optional alpha value- if
stamp is an RGBA image,
ais only applied for
stamp`'s pixels with >0 alpha
- if
- stamps a frame (
frame:blend(f,a)
- blends
f
ontoframe
, usinga
as the alpha paramter
- blends
frame:stamp_string(font,str,scale,x,y,r,g,b,max,lmask,rmask)
- renders
str
on top of theframe
, usingfont
(a font object) scale
controls how many pixels to scroll the font, ie,1
for the default resolution,2
for double resolution, etc.x,y
starts at1,1
for the top-left corner of the imager, g, b
represents the red, green, and blue values, 0 - 255max
is the maximum pixel (width) to render the string at. If the would have gone past this pixel, it is truncatedlmask
- mask the string by this many pixels on the left (after scaling)rmask
- mask the string by this many pixels on the right (after scaling)
- renders
frame:stamp_string_hsl(font,str,scale,x,y,h,s,l,max,lmask,rmask)
- same as
stamp_string
, but with hue, saturation, and lightness values instead of red, green, and blue
- same as
frame:stamp_string_adv(str,props,userdata)
- renders
str
on top of theframe
props
can be a table of per-frame properties, or a function- in the case of a table, you need frame 1 defined at a minimum
- in the case of a function, the function will receive three arguments - the index, and the current properties (may be nil), and the
userdata
value
- renders
frame:stamp_letter(font,codepoint,scale,x,y,r,g,b,lmask,rmask,tmask,bmask)
- renders an individual letter
- the letter is a UTF-8 codepoint, NOT a character. Ie, 'A' is 65
- lmask specifies pixels to mask on the left (after scaling)
- rmask specifies pixels to mask on the right (after scaling)
- tmask specifies pixels to mask on the top (after scaling)
- bmask specifies pixels to mask on the bottom (after scaling)
frame:stamp_letter(font,codepoint,scale,x,y,h,s,l,lmask,rmask,tmask,bmask)
- same as
stamp_letter
, but with hue, saturation, and lightness values instead of red, green, blue
- same as
Loaded fonts have the following properties/methods:
f:pixel(codepoint,x,y)
- returns true if the pixel at
x,y
is active - codepoint is UTF-8 codepoint, ie, 'A' is 65
- returns true if the pixel at
f:pixeli(codepoint,x,y)
- same as
pixel()
, but inverted
- same as
f:get_string_width(str,scale)
- calculates the width of a rendered string
- scale needs to be 1 or greater
f:utf8_to_table(str)
- converts a string to a table of UTF-8 codepoints
Draw a white square in the top-left corner:
return function()
stream.video:draw_rectangle(1,1,200,200,255,255,255)
end
Load an image and stamp it over the video
-- register a global "img" to use
-- globals can presist across script reloads
img = img or nil
return {
onload = function()
img = image.new('something.jpg')
img:load(false) -- load immediately
end,
onframe = function()
stream.video:stamp_image(img.frames[1],1,1)
end
}
-- register a global 'bg' variable
bg = bg or nil
return {
onload = function()
bg = image.new('something.jpg',stream.video.width,stream.video.height,stream.video.channels)
bg:load(false) -- load immediately
-- image will be resized to fill the video frame
end,
onframe = function()
stream.video:set(bg)
end
}
-- register a global 'f' to use for a font
f = f or nil
return {
onload = function()
f = font.new('some-font.bdf')
end,
onframe = function()
if song.title then
stream.video:stamp_string(f,song.title,3,1,1)
-- places the song title at top-left (1,1), with a 3x scale
end
end
}
return {
onframe = function()
-- draws visualizer bars
-- each bar is 10px wide
-- bar height is between 0 and 90
for i=1,stream.audio.spectrum_len,1 do
stream.video:draw_rectangle((i-1)*20, 680 ,10 + (i-1)*20, 680 - (ceil(stream.audio.amps[i])) , 255, 255, 255)
end
end
}
local frametime = 1000 / stream.video.framerate
-- frametime is how long each frame of video lasts in milliseconds
-- we'll use this to figure out when to advance to the next
-- frame of the gif
-- register a global 'gif' variable
gif = gif or nil
return {
onload = function()
gif = image.new('agif.gif')
gif:load(false) -- load immediately
-- initialize the gif with the first frame and frametime
gif.frameno = 1
gif.nextframe = gif.delays[gif.frameno]
end,
onframe = function()
stream.video:stamp_image(gif.frames[gif.frameno],1,1)
gif.nextframe = gif.nextframe - frametime
if gif.nextframe <= 0 then
-- advance to the next frame
gif.frameno = gif.frameno + 1
if gif.frameno > gif.framecount then
gif.frameno = 1
end
gif.nextframe = gif.delays[gif.frameno]
end
end
}
local vga
local colorcounter = 0
local colorprops = {}
local function cycle_color(i, props)
if i == 1 then
-- at the beginning of the string, increase our color counter
colorcounter = colorcounter + 1
props = {
x = 1,
}
end
if colorcounter == 36 then
-- one cycle is 30 degrees
-- we move 10 degrees per frame, so 36 frames for a full cycle
colorcounter = 0
end
-- use the color counter offset + i to change per-letter colors
local r, g, b = image.hsl_to_rgb((colorcounter + (i-1) ) * 10, 50, 50)
-- also for fun, we make each letter drop down
return {
x = props.x,
y = 50 + i * (vga.height/2),
font = vga,
scale = 3,
r = r,
g = g,
b = b,
}
end
local function onload()
vga = font.load('demos/fonts/7x14.bdf')
end
local function onframe()
stream:stamp_string(vga, "Just some text", 3, 1, 1, 255, 255, 255)
stream:stamp_string_adv("Some more text", cycle_color )
end
return {
onload = onload,
onframe = onframe,
}
Output:
local vga
local sin = math.sin
local ceil = math.ceil
local sincounter = -1
local default_y = 30
local wiggleprops = {}
local function wiggle_letters(i, props)
if i == 1 then
sincounter = sincounter + 1
props = {
x = 10,
}
end
if sincounter == (26) then
sincounter = 0
end
return {
x = props.x,
y = default_y + ceil( sin((sincounter / 4) + i - 1) * 10),
font = vga,
scale = 3,
r = 255,
g = 255,
b = 255,
}
end
local function onload()
vga = font.load('demos/fonts/7x14.bdf')
end
local function onframe()
stream:stamp_string_adv("Do the wave", wiggle_letters )
end
return {
onload = onload,
onframe = onframe,
}
Output:
Unless otherwise stated, all files are released under
an MIT-style license. Details in LICENSE
Some exceptions:
src/ringbuf.h
andsrc/ringbuf.c
- retains their original licensing, -seeLICENSE.ringbuf
for full details.src/tinydir.h
- retains original licensing (simplified BSD), details found within the file.src/stb_image.h
andsrc/stb_image_resize.h
- remains in the public domainsrc/thread.h
- available under an MIT-style license or Public Domain, see file for details.