Compare commits
19 Commits
3981512edb
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d0a10c645 | ||
|
|
38016be3d3 | ||
|
|
d13abec803 | ||
|
|
ebb4d7102b | ||
|
|
6a85dcb816 | ||
|
|
92c3b48fa7 | ||
|
|
96e962b56a | ||
|
|
1e87393d07 | ||
|
|
6d045ff304 | ||
|
|
d59a2859b6 | ||
|
|
afb198052e | ||
|
|
6f5f8b1818 | ||
|
|
3882ffe4f7 | ||
|
|
23cb23de15 | ||
|
|
54f1665325 | ||
|
|
82e2b05925 | ||
|
|
9d16b2970c | ||
|
|
6410b5ee6f | ||
|
|
9e01387460 |
13
README.md
13
README.md
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
138
cmd/root.go
138
cmd/root.go
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user