Compare commits

...

19 Commits

Author SHA1 Message Date
filifa
2d0a10c645 remove ./ 2024-09-07 21:09:04 -05:00
filifa
38016be3d3 add width and height flags 2024-09-07 20:56:02 -05:00
filifa
d13abec803 add soft cut duration flag 2024-09-07 20:50:06 -05:00
filifa
ebb4d7102b use CheckErr 2024-09-07 19:45:50 -05:00
filifa
6a85dcb816 update readme 2024-09-07 19:33:41 -05:00
filifa
92c3b48fa7 refactor preset validation 2024-09-07 19:26:26 -05:00
filifa
96e962b56a adjust usage message 2024-09-07 19:22:10 -05:00
filifa
1e87393d07 don't crash when no presets are passed 2024-09-07 19:20:09 -05:00
filifa
6d045ff304 pass presets with args 2024-09-07 19:16:08 -05:00
filifa
d59a2859b6 use projectm_pcm_get_max_samples to set buffer size 2024-09-07 15:54:40 -05:00
filifa
afb198052e remove bufio 2024-09-05 23:16:14 -05:00
filifa
6f5f8b1818 move buffering stuff to root 2024-09-05 22:48:19 -05:00
filifa
3882ffe4f7 get rid of bufSize 2024-09-05 22:21:12 -05:00
filifa
23cb23de15 update readme 2024-09-04 21:55:33 -05:00
filifa
54f1665325 replace panic with log.Fatal 2024-09-04 21:38:23 -05:00
filifa
82e2b05925 refactor stdin check 2024-09-04 21:35:38 -05:00
filifa
9d16b2970c refactor main loop 2024-09-04 21:31:52 -05:00
filifa
6410b5ee6f refactor preset validation 2024-09-04 21:17:29 -05:00
filifa
9e01387460 add comments 2024-09-04 21:10:30 -05:00
3 changed files with 169 additions and 63 deletions

View File

@@ -20,13 +20,16 @@ milkbucket reads a PCM stream from standard input to generate visualizations.
If you have an audio file and a preset in mind, you can use `ffmpeg` to If you have an audio file and a preset in mind, you can use `ffmpeg` to
generate the PCM stream, then pipe to milkbucket, like so: generate the PCM stream, then pipe to milkbucket, like so:
``` ```
ffmpeg -i $audio -ar 44100 -f s16le - | ./milkbucket -p $preset ffmpeg -i $audio -ar 44100 -f s16le - | milkbucket $preset
``` ```
Note that you can pass in multiple presets, then use the arrow keys to cycle
through the presets while running.
Note that neither of these commands will output any audio! If you want to hear Note that neither of these commands (ffmpeg or milkbucket) will output any
the audio at the same time (and assuming your machine uses pipewire), run: audio! If you want to hear the audio at the same time (and assuming your
machine uses pipewire), run:
``` ```
ffmpeg -i $audio -ar 44100 -f s16le - | tee >(pw-play --rate=44100 --format=s16 -) | ./milkbucket -p $preset ffmpeg -i $audio -ar 44100 -f s16le - | tee >(pw-play --rate=44100 --format=s16 -) | milkbucket $preset
``` ```
(If you don't use pipewire try using `aplay` instead of `pw-play`, or some (If you don't use pipewire try using `aplay` instead of `pw-play`, or some
other command for playing PCM streams.) other command for playing PCM streams.)
@@ -34,5 +37,5 @@ other command for playing PCM streams.)
You can also generate a visualization from your system sound. Assuming pipewire You can also generate a visualization from your system sound. Assuming pipewire
again, and that you have audio coming from Firefox, run: again, and that you have audio coming from Firefox, run:
``` ```
pw-record --target Firefox - | ./milkbucket -p $preset pw-record --target Firefox - | milkbucket $preset
``` ```

View File

@@ -17,16 +17,68 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
package cmd package cmd
import ( import (
"log" "encoding/binary"
"errors"
"io"
"os" "os"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/veandco/go-sdl2/sdl" "github.com/veandco/go-sdl2/sdl"
) )
var presets []string
var transition bool var transition bool
var softCutDuration float64
var width int32
var height int32
/*
* checkStdin checks if any data has been passed in through stdin and returns
* an error if we don't believe so.
*/
func checkStdin() error {
stat, err := os.Stdin.Stat()
if err != nil {
return err
} else if (stat.Mode() & os.ModeCharDevice) != 0 {
return errors.New("nothing to read from stdin")
}
return nil
}
/*
* validatePreset performs some basic checks on the preset passed in and
* returns an error if it finds a problem.
*/
func validatePreset(preset string) error {
info, err := os.Stat(preset)
if err != nil {
return err
} else if info.IsDir() {
return errors.New("preset " + preset + " is a directory")
}
return nil
}
/*
* validatePresets validates each preset passed in and returns an error if it
* finds a problem.
*/
func validatePresets(cmd *cobra.Command, args []string) error {
for _, p := range args {
err := validatePreset(p)
if err != nil {
return err
}
}
return nil
}
/*
* handleWindowEvent handles window events like resizing.
*/
func handleWindowEvent(event *sdl.WindowEvent, m *milkDropWindow) { func handleWindowEvent(event *sdl.WindowEvent, m *milkDropWindow) {
switch event.Event { switch event.Event {
case sdl.WINDOWEVENT_RESIZED: case sdl.WINDOWEVENT_RESIZED:
@@ -35,6 +87,9 @@ func handleWindowEvent(event *sdl.WindowEvent, m *milkDropWindow) {
} }
} }
/*
* handleKeyboardEvent handles keyboard inputs.
*/
func handleKeyboardEvent(event *sdl.KeyboardEvent, m *milkDropWindow) { func handleKeyboardEvent(event *sdl.KeyboardEvent, m *milkDropWindow) {
scancode := event.Keysym.Scancode scancode := event.Keysym.Scancode
if event.Type == sdl.KEYDOWN && scancode == sdl.SCANCODE_RIGHT && event.Repeat == 0 { if event.Type == sdl.KEYDOWN && scancode == sdl.SCANCODE_RIGHT && event.Repeat == 0 {
@@ -44,6 +99,10 @@ func handleKeyboardEvent(event *sdl.KeyboardEvent, m *milkDropWindow) {
} }
} }
/*
* handleEvent passes the event to a more specific function based on its type.
* It returns false if the program should quit, true otherwise.
*/
func handleEvent(event sdl.Event, m *milkDropWindow) bool { func handleEvent(event sdl.Event, m *milkDropWindow) bool {
switch event.(type) { switch event.(type) {
case *sdl.QuitEvent: case *sdl.QuitEvent:
@@ -59,65 +118,75 @@ func handleEvent(event sdl.Event, m *milkDropWindow) bool {
return true return true
} }
/*
* update handles events and renders new frames of the visualization. It
* returns a bool indicating whether the program should keep running and an
* error, if any.
*/
func update(m *milkDropWindow) (bool, error) {
for event := sdl.PollEvent(); event != nil; event = sdl.PollEvent() {
keepRunning := handleEvent(event, m)
if !keepRunning {
return false, nil
}
}
audioData := make([]int16, maxSamples(2, 2))
err := binary.Read(os.Stdin, binary.LittleEndian, audioData)
if err == io.ErrUnexpectedEOF {
return false, nil
} else if err != nil {
return true, err
}
m.render(audioData)
return true, nil
}
/*
* milkbucket sets up the program and starts a rendering loop.
*/
func milkbucket(cmd *cobra.Command, args []string) { func milkbucket(cmd *cobra.Command, args []string) {
stat, err := os.Stdin.Stat() err := checkStdin()
if err != nil { if err != nil {
panic(err) cobra.CheckErr(err)
} else if (stat.Mode() & os.ModeCharDevice) != 0 {
log.Fatal("nothing to read from stdin")
} }
err = sdl.Init(sdl.INIT_VIDEO) err = sdl.Init(sdl.INIT_VIDEO)
if err != nil { if err != nil {
panic(err) cobra.CheckErr(err)
} }
defer sdl.Quit() defer sdl.Quit()
for _, p := range presets { m, err := newMilkDropWindow(width, height, args, softCutDuration)
info, err := os.Stat(p)
if err != nil { if err != nil {
panic(err) cobra.CheckErr(err)
} else if info.IsDir() {
panic("preset " + p + " is a directory")
}
}
m, err := newMilkDropWindow(800, 600, presets)
if err != nil {
panic(err)
} }
defer m.destroy() defer m.destroy()
if len(args) > 0 {
m.loadPreset(false) m.loadPreset(false)
}
running := true running := true
for running { for running {
for event := sdl.PollEvent(); event != nil; event = sdl.PollEvent() { running, err = update(m)
running = handleEvent(event, m)
if !running {
break
}
}
if !running {
break
}
running, err = m.render()
if err != nil { if err != nil {
panic(err) cobra.CheckErr(err)
} }
} }
} }
// rootCmd represents the base command when called without any subcommands // rootCmd represents the base command when called without any subcommands
var rootCmd = &cobra.Command{ var rootCmd = &cobra.Command{
Use: "milkbucket", Use: "milkbucket [presets]",
Short: "Audio visualizer", Short: "Audio visualizer",
Long: `milkbucket is an audio visualizer. Long: `milkbucket is an audio visualizer.
It uses Milkdrop preset files to generate visualizations from standard input.`, It uses Milkdrop preset files to generate visualizations from standard input.`,
Run: milkbucket, Run: milkbucket,
Args: validatePresets,
} }
// Execute adds all child commands to the root command and sets flags appropriately. // Execute adds all child commands to the root command and sets flags appropriately.
@@ -137,6 +206,7 @@ func init() {
// Cobra also supports local flags, which will only run // Cobra also supports local flags, which will only run
// when this action is called directly. // when this action is called directly.
rootCmd.Flags().BoolVarP(&transition, "transition", "t", false, "smoothly transition between presets") rootCmd.Flags().BoolVarP(&transition, "transition", "t", false, "smoothly transition between presets")
rootCmd.Flags().Float64VarP(&softCutDuration, "soft-cut-duration", "s", 3, "time in seconds for a soft transition between two presets (use with --transition)")
rootCmd.Flags().StringArrayVarP(&presets, "presets", "p", []string{}, "preset files to use") rootCmd.Flags().Int32Var(&width, "width", 800, "window width")
rootCmd.Flags().Int32Var(&height, "height", 600, "window height")
} }

View File

@@ -25,17 +25,25 @@ import "C"
import ( import (
"container/ring" "container/ring"
"encoding/binary"
"errors" "errors"
"io"
"os"
"unsafe" "unsafe"
"github.com/veandco/go-sdl2/sdl" "github.com/veandco/go-sdl2/sdl"
) )
const bufSize uint = 2048 /*
* maxSamples returns the maximum number of audio samples stored by projectm
* when passing it audio data, given the number of bytes in one sample and the
* number of channels.
*/
func maxSamples(bytesPerSample uint, numChannels uint) uint {
// NOTE: not 100% sure about this, but I think
// projectm_pcm_get_max_samples assumes 4 bytes per sample, so we
// multiply by 4/bytesPerSample to convert
return 4 / bytesPerSample * numChannels * uint(C.projectm_pcm_get_max_samples())
}
// milkDropWindow represents the window projectm will render visualizations in.
type milkDropWindow struct { type milkDropWindow struct {
window *sdl.Window window *sdl.Window
context sdl.GLContext context sdl.GLContext
@@ -43,6 +51,9 @@ type milkDropWindow struct {
preset *ring.Ring preset *ring.Ring
} }
/*
* setupPresets initializes a ring to store the passed presets in.
*/
func (m *milkDropWindow) setupPresets(presets []string) { func (m *milkDropWindow) setupPresets(presets []string) {
m.preset = ring.New(len(presets)) m.preset = ring.New(len(presets))
for _, preset := range presets { for _, preset := range presets {
@@ -51,17 +62,33 @@ func (m *milkDropWindow) setupPresets(presets []string) {
} }
} }
/*
* prevPreset sets the milkDropWindow's preset to the one after the current
* preset.
*/
func (m *milkDropWindow) nextPreset(smooth bool) { func (m *milkDropWindow) nextPreset(smooth bool) {
if m.preset.Len() > 0 {
m.preset = m.preset.Next() m.preset = m.preset.Next()
m.loadPreset(smooth) m.loadPreset(smooth)
} }
}
/*
* prevPreset sets the milkDropWindow's preset to the one before the current
* preset.
*/
func (m *milkDropWindow) prevPreset(smooth bool) { func (m *milkDropWindow) prevPreset(smooth bool) {
if m.preset.Len() > 0 {
m.preset = m.preset.Prev() m.preset = m.preset.Prev()
m.loadPreset(smooth) m.loadPreset(smooth)
} }
}
func newMilkDropWindow(width, height int32, presets []string) (*milkDropWindow, error) { /*
* newMilkDropWindow returns a new milkDropWindow with the given width and
* height, and sets presets available to the window.
*/
func newMilkDropWindow(width, height int32, presets []string, softCutDuration float64) (*milkDropWindow, error) {
var m milkDropWindow var m milkDropWindow
var err error var err error
@@ -84,15 +111,25 @@ func newMilkDropWindow(width, height int32, presets []string) (*milkDropWindow,
C.projectm_set_window_size(m.handle, C.ulong(width), C.ulong(height)) C.projectm_set_window_size(m.handle, C.ulong(width), C.ulong(height))
C.projectm_set_soft_cut_duration(m.handle, C.double(softCutDuration))
return &m, nil return &m, nil
} }
/*
* destroy handles cleanup of the milkDropWindow.
*/
func (m *milkDropWindow) destroy() { func (m *milkDropWindow) destroy() {
C.projectm_destroy(m.handle) C.projectm_destroy(m.handle)
sdl.GLDeleteContext(m.context) sdl.GLDeleteContext(m.context)
m.window.Destroy() m.window.Destroy()
} }
/*
* loadPreset loads the preset file currently pointed to by m.preset. smooth
* determines whether there is a smooth transition between the current preset
* and the new preset.
*/
func (m *milkDropWindow) loadPreset(smooth bool) { func (m *milkDropWindow) loadPreset(smooth bool) {
preset := m.preset.Value.(string) preset := m.preset.Value.(string)
cPreset := C.CString(preset) cPreset := C.CString(preset)
@@ -100,24 +137,20 @@ func (m *milkDropWindow) loadPreset(smooth bool) {
C.projectm_load_preset_file(m.handle, cPreset, C.bool(smooth)) C.projectm_load_preset_file(m.handle, cPreset, C.bool(smooth))
} }
func (m *milkDropWindow) render() (bool, error) { /*
var audioData [bufSize]int16 * render passes data to projectm for rendering. It returns a bool for if the
* program should keep running and an error.
err := binary.Read(os.Stdin, binary.LittleEndian, &audioData) */
if err == io.ErrUnexpectedEOF { func (m *milkDropWindow) render(data []int16) {
return false, nil C.projectm_pcm_add_int16(m.handle, (*C.int16_t)(&data[0]), C.uint(len(data)), C.PROJECTM_STEREO)
} else if err != nil {
return true, err
}
C.projectm_pcm_add_int16(m.handle, (*C.int16_t)(&audioData[0]), C.uint(bufSize), C.PROJECTM_STEREO)
C.projectm_opengl_render_frame(m.handle) C.projectm_opengl_render_frame(m.handle)
m.window.GLSwap() m.window.GLSwap()
return true, nil
} }
/*
* resize informs projectm of the new window size.
*/
func (m *milkDropWindow) resize(w, h int32) { func (m *milkDropWindow) resize(w, h int32) {
C.projectm_set_window_size(m.handle, C.ulong(w), C.ulong(h)) C.projectm_set_window_size(m.handle, C.ulong(w), C.ulong(h))
} }