From 1b4f1a8c5e9979fe1739d86d0432c9dd20478ca7 Mon Sep 17 00:00:00 2001 From: vsariola <5684185+vsariola@users.noreply.github.com> Date: Sat, 17 Apr 2021 14:24:05 +0300 Subject: [PATCH] feat(tracker): add menu item to export .wav Also refactor the common functions for .wav export into base package so that both sointu-play and tracker can use same functions. --- audioexport.go | 101 +++++++++++++++++++++++++++++++++++++ cmd/sointu-play/main.go | 89 +++----------------------------- tracker/gioui/files.go | 31 ++++++++++++ tracker/gioui/layout.go | 16 ++++++ tracker/gioui/songpanel.go | 3 ++ tracker/gioui/tracker.go | 4 ++ 6 files changed, 163 insertions(+), 81 deletions(-) create mode 100644 audioexport.go diff --git a/audioexport.go b/audioexport.go new file mode 100644 index 0000000..fa6cedb --- /dev/null +++ b/audioexport.go @@ -0,0 +1,101 @@ +package sointu + +import ( + "bytes" + "encoding/binary" + "fmt" + "math" +) + +func Wav(buffer []float32, pcm16 bool) ([]byte, error) { + buf := new(bytes.Buffer) + wavHeader(len(buffer), pcm16, buf) + err := rawToBuffer(buffer, pcm16, buf) + if err != nil { + return nil, fmt.Errorf("Wav failed: %v", err) + } + return buf.Bytes(), nil +} + +func Raw(buffer []float32, pcm16 bool) ([]byte, error) { + buf := new(bytes.Buffer) + err := rawToBuffer(buffer, pcm16, buf) + if err != nil { + return nil, fmt.Errorf("Raw failed: %v", err) + } + return buf.Bytes(), nil +} + +func rawToBuffer(data []float32, pcm16 bool, buf *bytes.Buffer) error { + var err error + if pcm16 { + int16data := make([]int16, len(data)) + for i, v := range data { + int16data[i] = int16(clamp(int(v*math.MaxInt16), math.MinInt16, math.MaxInt16)) + } + err = binary.Write(buf, binary.LittleEndian, int16data) + } else { + err = binary.Write(buf, binary.LittleEndian, data) + } + if err != nil { + return fmt.Errorf("could not binary write data to binary buffer: %v", err) + } + return nil +} + +// wavHeader writes a wave header for either float32 or int16 .wav file into the +// bytes.buffer. It needs to know the length of the buffer and assumes stereo +// sound, so the length in stereo samples (L + R) is bufferlength / 2. If pcm16 +// = true, then the header is for int16 audio; pcm16 = false means the header is +// for float32 audio. Assumes 44100 Hz sample rate. +func wavHeader(bufferLength int, pcm16 bool, buf *bytes.Buffer) { + // Refer to: http://www-mmsp.ece.mcgill.ca/Documents/AudioFormats/WAVE/WAVE.html + numChannels := 2 + sampleRate := 44100 + var bytesPerSample, chunkSize, fmtChunkSize, waveFormat int + var factChunk bool + if pcm16 { + bytesPerSample = 2 + chunkSize = 36 + bytesPerSample*bufferLength + fmtChunkSize = 16 + waveFormat = 1 // PCM + factChunk = false + } else { + bytesPerSample = 4 + chunkSize = 50 + bytesPerSample*bufferLength + fmtChunkSize = 18 + waveFormat = 3 // IEEE float + factChunk = true + } + buf.Write([]byte("RIFF")) + binary.Write(buf, binary.LittleEndian, uint32(chunkSize)) + buf.Write([]byte("WAVE")) + buf.Write([]byte("fmt ")) + binary.Write(buf, binary.LittleEndian, uint32(fmtChunkSize)) + binary.Write(buf, binary.LittleEndian, uint16(waveFormat)) + binary.Write(buf, binary.LittleEndian, uint16(numChannels)) + binary.Write(buf, binary.LittleEndian, uint32(sampleRate)) + binary.Write(buf, binary.LittleEndian, uint32(sampleRate*numChannels*bytesPerSample)) // avgBytesPerSec + binary.Write(buf, binary.LittleEndian, uint16(numChannels*bytesPerSample)) // blockAlign + binary.Write(buf, binary.LittleEndian, uint16(8*bytesPerSample)) // bits per sample + if fmtChunkSize > 16 { + binary.Write(buf, binary.LittleEndian, uint16(0)) // size of extension + } + if factChunk { + buf.Write([]byte("fact")) + binary.Write(buf, binary.LittleEndian, uint32(4)) // fact chunk size + binary.Write(buf, binary.LittleEndian, uint32(bufferLength)) // sample length + } + buf.Write([]byte("data")) + binary.Write(buf, binary.LittleEndian, uint32(bytesPerSample*bufferLength)) +} + +func clamp(value, min, max int) int { + if value < min { + return min + } + if value > max { + return max + } + return value +} diff --git a/cmd/sointu-play/main.go b/cmd/sointu-play/main.go index 68d7080..5cb9db9 100644 --- a/cmd/sointu-play/main.go +++ b/cmd/sointu-play/main.go @@ -1,13 +1,10 @@ package main import ( - "bytes" - "encoding/binary" "encoding/json" "flag" "fmt" "io/ioutil" - "math" "os" "path/filepath" "strings" @@ -111,38 +108,22 @@ func main() { return fmt.Errorf("error playing: %v", err) } } - var data interface{} - data = buffer - if *pcm { - int16buffer := make([]int16, len(buffer)) - for i, v := range buffer { - int16buffer[i] = int16(clamp(int(v*math.MaxInt16), math.MinInt16, math.MaxInt16)) - } - data = int16buffer - } if *rawOut { - buf := new(bytes.Buffer) - err := binary.Write(buf, binary.LittleEndian, data) + raw, err := sointu.Raw(buffer, *pcm) if err != nil { - return fmt.Errorf("could not binary write data to binary buffer: %v", err) + return fmt.Errorf("could not generate .raw file: %v", err) } - if err := output(".raw", buf.Bytes()); err != nil { - return fmt.Errorf("error outputting raw audio file: %v", err) + if err := output(".raw", raw); err != nil { + return fmt.Errorf("error outputting .raw file: %v", err) } } if *wavOut { - buf := new(bytes.Buffer) - header := createWavHeader(len(buffer), *pcm) - err := binary.Write(buf, binary.LittleEndian, header) + wav, err := sointu.Wav(buffer, *pcm) if err != nil { - return fmt.Errorf("could not binary write header to binary buffer: %v", err) + return fmt.Errorf("could not generate .wav file: %v", err) } - err = binary.Write(buf, binary.LittleEndian, data) - if err != nil { - return fmt.Errorf("could not binary write data to binary buffer: %v", err) - } - if err := output(".wav", buf.Bytes()); err != nil { - return fmt.Errorf("error outputting wav audio file: %v", err) + if err := output(".wav", wav); err != nil { + return fmt.Errorf("error outputting .wav file: %v", err) } } return nil @@ -185,57 +166,3 @@ func printUsage() { fmt.Fprintf(os.Stderr, "Sointu command line utility for playing .asm/.json song files.\nUsage: %s [flags] [path ...]\n", os.Args[0]) flag.PrintDefaults() } - -func createWavHeader(bufferLength int, pcm bool) []byte { - // Refer to: http://www-mmsp.ece.mcgill.ca/Documents/AudioFormats/WAVE/WAVE.html - numChannels := 2 - sampleRate := 44100 - var bytesPerSample, chunkSize, fmtChunkSize, waveFormat int - var factChunk bool - if pcm { - bytesPerSample = 2 - chunkSize = 36 + bytesPerSample*bufferLength - fmtChunkSize = 16 - waveFormat = 1 // PCM - factChunk = false - } else { - bytesPerSample = 4 - chunkSize = 50 + bytesPerSample*bufferLength - fmtChunkSize = 18 - waveFormat = 3 // IEEE float - factChunk = true - } - buf := new(bytes.Buffer) - buf.Write([]byte("RIFF")) - binary.Write(buf, binary.LittleEndian, uint32(chunkSize)) - buf.Write([]byte("WAVE")) - buf.Write([]byte("fmt ")) - binary.Write(buf, binary.LittleEndian, uint32(fmtChunkSize)) - binary.Write(buf, binary.LittleEndian, uint16(waveFormat)) - binary.Write(buf, binary.LittleEndian, uint16(numChannels)) - binary.Write(buf, binary.LittleEndian, uint32(sampleRate)) - binary.Write(buf, binary.LittleEndian, uint32(sampleRate*numChannels*bytesPerSample)) // avgBytesPerSec - binary.Write(buf, binary.LittleEndian, uint16(numChannels*bytesPerSample)) // blockAlign - binary.Write(buf, binary.LittleEndian, uint16(8*bytesPerSample)) // bits per sample - if fmtChunkSize > 16 { - binary.Write(buf, binary.LittleEndian, uint16(0)) // size of extension - } - if factChunk { - buf.Write([]byte("fact")) - binary.Write(buf, binary.LittleEndian, uint32(4)) // fact chunk size - binary.Write(buf, binary.LittleEndian, uint32(bufferLength)) // sample length - } - buf.Write([]byte("data")) - binary.Write(buf, binary.LittleEndian, uint32(bytesPerSample*bufferLength)) - return buf.Bytes() -} - -func clamp(value, min, max int) int { - if value < min { - return min - } - if value > max { - return max - } - return value -} diff --git a/tracker/gioui/files.go b/tracker/gioui/files.go index 52ac8f3..910cef7 100644 --- a/tracker/gioui/files.go +++ b/tracker/gioui/files.go @@ -7,6 +7,7 @@ import ( "fmt" "io/ioutil" "path/filepath" + "time" "gioui.org/app" "gopkg.in/yaml.v3" @@ -82,3 +83,33 @@ func (t *Tracker) saveSong(filename string) bool { t.SetChangedSinceSave(false) return true } + +func (t *Tracker) exportWav(pcm16 bool) { + filename, err := dialog.File().Filter(".wav file", "wav").Title("Export .wav").Save() + if err != nil { + return + } + var extension = filepath.Ext(filename) + if extension == "" { + filename = filename + ".wav" + } + synth, err := t.synthService.Compile(t.Song().Patch) + if err != nil { + t.Alert.Update(fmt.Sprintf("Error compiling the patch during export: %v", err), Error, time.Second*3) + return + } + for i := 0; i < 32; i++ { + synth.Release(i) + } + data, _, err := sointu.Play(synth, t.Song()) // render the song to calculate its length + if err != nil { + t.Alert.Update(fmt.Sprintf("Error rendering the song during export: %v", err), Error, time.Second*3) + return + } + buffer, err := sointu.Wav(data, pcm16) + if err != nil { + t.Alert.Update(fmt.Sprintf("Error converting to .wav: %v", err), Error, time.Second*3) + return + } + ioutil.WriteFile(filename, buffer, 0644) +} diff --git a/tracker/gioui/layout.go b/tracker/gioui/layout.go index a5dcd05..f242b73 100644 --- a/tracker/gioui/layout.go +++ b/tracker/gioui/layout.go @@ -39,6 +39,22 @@ func (t *Tracker) Layout(gtx layout.Context) { for t.ConfirmSongDialog.BtnCancel.Clicked() { t.ConfirmSongDialog.Visible = false } + dstyle = ConfirmDialog(t.Theme, t.WaveTypeDialog, "Export .wav in int16 or float32 sample format?") + dstyle.ShowAlt = true + dstyle.OkStyle.Text = "Int16" + dstyle.AltStyle.Text = "Float32" + dstyle.Layout(gtx) + for t.WaveTypeDialog.BtnOk.Clicked() { + t.exportWav(true) + t.WaveTypeDialog.Visible = false + } + for t.WaveTypeDialog.BtnAlt.Clicked() { + t.exportWav(false) + t.WaveTypeDialog.Visible = false + } + for t.WaveTypeDialog.BtnCancel.Clicked() { + t.WaveTypeDialog.Visible = false + } } func (t *Tracker) confirmedSongAction() { diff --git a/tracker/gioui/songpanel.go b/tracker/gioui/songpanel.go index 7923ea0..431b44a 100644 --- a/tracker/gioui/songpanel.go +++ b/tracker/gioui/songpanel.go @@ -61,6 +61,8 @@ func (t *Tracker) layoutMenuBar(gtx C) D { case 3: t.SaveSongAsFile() case 4: + t.WaveTypeDialog.Visible = true + case 5: t.TryQuit() } clickedItem, hasClicked = t.Menus[0].Clicked() @@ -93,6 +95,7 @@ func (t *Tracker) layoutMenuBar(gtx C) D { MenuItem{IconBytes: icons.FileFolder, Text: "Open Song", ShortcutText: shortcutKey + "O"}, MenuItem{IconBytes: icons.ContentSave, Text: "Save Song", ShortcutText: shortcutKey + "S"}, MenuItem{IconBytes: icons.ContentSave, Text: "Save Song As..."}, + MenuItem{IconBytes: icons.ImageAudiotrack, Text: "Export Wav..."}, MenuItem{IconBytes: icons.ActionExitToApp, Text: "Quit"}, )), layout.Rigid(t.layoutMenu("Edit", &t.MenuBar[1], &t.Menus[1], unit.Dp(160), diff --git a/tracker/gioui/tracker.go b/tracker/gioui/tracker.go index 5f10425..e217901 100644 --- a/tracker/gioui/tracker.go +++ b/tracker/gioui/tracker.go @@ -70,6 +70,7 @@ type Tracker struct { PatternOrderScrollBar *ScrollBar ConfirmInstrDelete *Dialog ConfirmSongDialog *Dialog + WaveTypeDialog *Dialog ConfirmSongActionType int window *app.Window @@ -82,6 +83,7 @@ type Tracker struct { errorChannel chan error quitted bool audioContext sointu.AudioContext + synthService sointu.SynthService *tracker.Model } @@ -165,8 +167,10 @@ func New(audioContext sointu.AudioContext, synthService sointu.SynthService, syn PatternOrderScrollBar: &ScrollBar{Axis: layout.Vertical}, ConfirmInstrDelete: new(Dialog), ConfirmSongDialog: new(Dialog), + WaveTypeDialog: new(Dialog), errorChannel: make(chan error, 32), window: window, + synthService: synthService, } t.Model = tracker.NewModel() vuBufferObserver := make(chan []float32)