├── gif ├── move-tool.gif └── move-tool.tape ├── internal ├── ablmodels │ ├── audio.go │ ├── drumrack.go │ ├── chain.go │ ├── drum_sampler.go │ ├── drum_cell_chain.go │ ├── device_preset.go │ ├── mixer.go │ ├── instrument_rack.go │ ├── device.go │ ├── device_preset_test.go │ ├── drum_sampler_test.go │ ├── saturator.go │ ├── device_test.go │ ├── reverb.go │ └── drum_sampler_parameters.go ├── app.go ├── fileutils.go ├── audioutils.go ├── audioutils_test.go ├── app_test.go └── fileutils_test.go ├── main.go ├── cmd ├── root.go ├── root_test.go ├── slice.go └── slice_test.go ├── .github └── workflows │ ├── pr-checks.yml │ └── release.yml ├── go.mod ├── LICENSE ├── go.sum ├── .gitignore └── Readme.md /gif/move-tool.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexfedosov/move-tool/HEAD/gif/move-tool.gif -------------------------------------------------------------------------------- /internal/ablmodels/audio.go: -------------------------------------------------------------------------------- 1 | package ablmodels 2 | 3 | type AudioFile struct { 4 | FilePath *string 5 | Duration float64 6 | } 7 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/alexfedosov/move-tool/cmd" 6 | ) 7 | 8 | func main() { 9 | err := cmd.Execute() 10 | if err != nil { 11 | fmt.Println("Error:", err) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /gif/move-tool.tape: -------------------------------------------------------------------------------- 1 | Output move-tool.gif 2 | 3 | Require ./move-tool 4 | 5 | Set Shell "zsh" 6 | Set FontSize 18 7 | Set TypingSpeed 25ms 8 | Set Width 1200 9 | Set Height 600 10 | 11 | Type "./move-tool slice -i /Users/awesome/sample.wav -n 16 -o /Users/awesome/Desktop" 12 | 13 | Sleep 500ms 14 | 15 | Enter 16 | 17 | Sleep 7s 18 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | var ( 8 | rootCmd = &cobra.Command{ 9 | Use: "move-tool", 10 | Short: "move-tool helps you mangle your Ableton Move files.", 11 | Long: `move-tool is a CLI for mangling your Ableton Move files.`, 12 | } 13 | ) 14 | 15 | func Execute() error { 16 | return rootCmd.Execute() 17 | } 18 | -------------------------------------------------------------------------------- /internal/ablmodels/drumrack.go: -------------------------------------------------------------------------------- 1 | package ablmodels 2 | 3 | const DrumRackDeviceKind = "drumRack" 4 | 5 | type DrumRack = Device 6 | 7 | func NewDrumRack() *DrumRack { 8 | return NewDevice(DrumRackDeviceKind).WithParameters(DefaultInstrumentRackParameters()).AddReturnChain(NewChain().WithDevice(NewReverb())) 9 | } 10 | 11 | func (d *DrumRack) AddSample(sample AudioFile) *DrumRack { 12 | return d.AddChain(NewDrumCellChain(len(d.Chains), sample)) 13 | } 14 | -------------------------------------------------------------------------------- /internal/ablmodels/chain.go: -------------------------------------------------------------------------------- 1 | package ablmodels 2 | 3 | type Chain struct { 4 | Name string `json:"name"` 5 | Color int `json:"color"` 6 | Devices []interface{} `json:"devices"` 7 | Mixer Mixer `json:"mixer"` 8 | } 9 | 10 | func NewChain() *Chain { 11 | return &Chain{ 12 | Name: "", 13 | Color: 2, 14 | Devices: make([]interface{}, 0), 15 | Mixer: *NewMixer(), 16 | } 17 | } 18 | 19 | func (c *Chain) WithDevice(device interface{}) *Chain { 20 | c.Devices = append(c.Devices, device) 21 | return c 22 | } 23 | -------------------------------------------------------------------------------- /.github/workflows/pr-checks.yml: -------------------------------------------------------------------------------- 1 | name: PR Checks 2 | 3 | on: 4 | pull_request: 5 | branches: [main] 6 | workflow_dispatch: 7 | 8 | jobs: 9 | test: 10 | name: Run Tests 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v3 15 | 16 | - name: Set up Go 17 | uses: actions/setup-go@v4 18 | with: 19 | go-version: '1.22' 20 | 21 | - name: Install dependencies 22 | run: go mod download 23 | 24 | - name: Run tests 25 | run: go test -v ./... -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/alexfedosov/move-tool 2 | 3 | go 1.22 4 | 5 | require ( 6 | github.com/brianvoe/gofakeit/v7 v7.0.4 7 | github.com/go-audio/audio v1.0.0 8 | github.com/go-audio/wav v1.1.0 9 | github.com/spf13/cobra v1.8.1 10 | github.com/stretchr/testify v1.10.0 11 | ) 12 | 13 | require ( 14 | github.com/davecgh/go-spew v1.1.1 // indirect 15 | github.com/go-audio/riff v1.0.0 // indirect 16 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 17 | github.com/pmezard/go-difflib v1.0.0 // indirect 18 | github.com/spf13/pflag v1.0.5 // indirect 19 | gopkg.in/yaml.v3 v3.0.1 // indirect 20 | ) 21 | -------------------------------------------------------------------------------- /internal/ablmodels/drum_sampler.go: -------------------------------------------------------------------------------- 1 | package ablmodels 2 | 3 | const DrumSamplerDeviceKind = "drumCell" 4 | 5 | type DrumSampler struct { 6 | Device 7 | DeviceData DeviceData `json:"deviceData"` 8 | } 9 | 10 | type DeviceData struct { 11 | SampleURI *string `json:"sampleUri"` 12 | } 13 | 14 | func NewDrumSampler() *DrumSampler { 15 | return &DrumSampler{ 16 | *NewDevice(DrumSamplerDeviceKind), 17 | DeviceData{nil}, 18 | } 19 | } 20 | 21 | func (s *DrumSampler) WithSample(file AudioFile) *DrumSampler { 22 | s.DeviceData.SampleURI = file.FilePath 23 | s.Parameters = DefaultDrumSamplerParameters().WithVoiceEnvelopeHold(file.Duration).WithGateMode() 24 | return s 25 | } 26 | -------------------------------------------------------------------------------- /internal/ablmodels/drum_cell_chain.go: -------------------------------------------------------------------------------- 1 | package ablmodels 2 | 3 | type DrumCellChain struct { 4 | Chain 5 | DrumZoneSettings *DrumZoneSettings `json:"drumZoneSettings"` 6 | } 7 | 8 | type DrumZoneSettings struct { 9 | ReceivingNote int `json:"receivingNote"` 10 | SendingNote int `json:"sendingNote"` 11 | ChokeGroup interface{} `json:"chokeGroup"` 12 | } 13 | 14 | func NewDrumCellChain(padIndex int, sample AudioFile) *DrumCellChain { 15 | chain := NewChain().WithDevice(NewDrumSampler().WithSample(sample)) 16 | chain.Mixer = *NewMixer().WithDefaultSend() 17 | return &DrumCellChain{ 18 | *chain, 19 | &DrumZoneSettings{ 20 | SendingNote: 60, 21 | ReceivingNote: 36 + padIndex, 22 | }, 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /internal/ablmodels/device_preset.go: -------------------------------------------------------------------------------- 1 | package ablmodels 2 | 3 | type DevicePreset struct { 4 | Schema string `json:"$schema"` 5 | Device 6 | } 7 | 8 | const DevicePresetSchema = "http://tech.ableton.com/schema/song/1.4.4/devicePreset.json" 9 | 10 | func NewDrumRackDevicePresetWithSamples(samples []AudioFile) *DevicePreset { 11 | drumRack := NewDrumRack() 12 | for i := 0; i < 16; i++ { 13 | if i < len(samples) { 14 | drumRack.AddSample(samples[i]) 15 | } else { 16 | drumRack.AddSample(AudioFile{ 17 | FilePath: nil, 18 | Duration: 0, 19 | }) 20 | } 21 | } 22 | return &DevicePreset{ 23 | DevicePresetSchema, 24 | *NewInstrumentRack().AddChain(NewChain().WithDevice(drumRack).WithDevice(NewSaturator())), 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /internal/ablmodels/mixer.go: -------------------------------------------------------------------------------- 1 | package ablmodels 2 | 3 | type Mixer struct { 4 | Pan float64 `json:"pan"` 5 | SoloCue bool `json:"solo-cue"` 6 | SpeakerOn bool `json:"speakerOn"` 7 | Volume float64 `json:"volume"` 8 | Sends []Send `json:"sends"` 9 | } 10 | 11 | type Send struct { 12 | IsEnabled bool `json:"isEnabled"` 13 | Amount float64 `json:"amount"` 14 | } 15 | 16 | func NewMixer() *Mixer { 17 | return &Mixer{ 18 | Pan: 0, 19 | SoloCue: false, 20 | SpeakerOn: true, 21 | Volume: 0, 22 | Sends: make([]Send, 0), 23 | } 24 | } 25 | 26 | func (m *Mixer) WithDefaultSend() *Mixer { 27 | m.Sends = append(m.Sends, Send{ 28 | IsEnabled: true, 29 | Amount: 0, 30 | }) 31 | return m 32 | } 33 | -------------------------------------------------------------------------------- /cmd/root_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | // TestRootCommand verifies that the root cobra command is properly initialized 10 | // with correct values and contains the expected subcommands. 11 | func TestRootCommand(t *testing.T) { 12 | assert.NotNil(t, rootCmd, "Root command should not be nil") 13 | assert.NotEmpty(t, rootCmd.Use, "Root command should have a Use value") 14 | assert.NotEmpty(t, rootCmd.Short, "Root command should have a Short description") 15 | 16 | found := false 17 | for _, cmd := range rootCmd.Commands() { 18 | if cmd.Use == "slice" { 19 | found = true 20 | break 21 | } 22 | } 23 | assert.True(t, found, "Root command should have 'slice' as a subcommand") 24 | } 25 | -------------------------------------------------------------------------------- /internal/ablmodels/instrument_rack.go: -------------------------------------------------------------------------------- 1 | package ablmodels 2 | 3 | type InstrumentRackParameters struct { 4 | Enabled bool `json:"Enabled"` 5 | Macro0 float64 `json:"Macro0"` 6 | Macro1 float64 `json:"Macro1"` 7 | Macro2 float64 `json:"Macro2"` 8 | Macro3 float64 `json:"Macro3"` 9 | Macro4 float64 `json:"Macro4"` 10 | Macro5 float64 `json:"Macro5"` 11 | Macro6 float64 `json:"Macro6"` 12 | Macro7 float64 `json:"Macro7"` 13 | } 14 | 15 | func DefaultInstrumentRackParameters() *InstrumentRackParameters { 16 | return &InstrumentRackParameters{ 17 | Enabled: true, 18 | Macro0: 0, 19 | Macro1: 0, 20 | Macro2: 0, 21 | Macro3: 0, 22 | Macro4: 0, 23 | Macro5: 0, 24 | Macro6: 0, 25 | Macro7: 0, 26 | } 27 | } 28 | 29 | func NewInstrumentRack() *Device { 30 | return NewDevice("instrumentRack").WithParameters(DefaultInstrumentRackParameters()) 31 | } 32 | -------------------------------------------------------------------------------- /internal/ablmodels/device.go: -------------------------------------------------------------------------------- 1 | package ablmodels 2 | 3 | type Device struct { 4 | PresetURI interface{} `json:"presetUri"` 5 | Kind string `json:"kind"` 6 | Name string `json:"name"` 7 | Parameters interface{} `json:"parameters"` 8 | Chains []interface{} `json:"chains,omitempty"` 9 | ReturnChains []interface{} `json:"returnChains,omitempty"` 10 | } 11 | 12 | func NewDevice(kind string) *Device { 13 | return &Device{ 14 | PresetURI: nil, 15 | Kind: kind, 16 | Name: "", 17 | Parameters: nil, 18 | Chains: make([]interface{}, 0), 19 | ReturnChains: make([]interface{}, 0), 20 | } 21 | } 22 | 23 | func (d *Device) AddChain(chain interface{}) *Device { 24 | d.Chains = append(d.Chains, chain) 25 | return d 26 | } 27 | 28 | func (d *Device) AddReturnChain(chain interface{}) *Device { 29 | d.ReturnChains = append(d.ReturnChains, chain) 30 | return d 31 | } 32 | 33 | func (d *Device) WithParameters(parameters interface{}) *Device { 34 | d.Parameters = parameters 35 | return d 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Alex Fedosov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /internal/ablmodels/device_preset_test.go: -------------------------------------------------------------------------------- 1 | package ablmodels 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | // TestNewDrumRackDevicePresetWithSamples verifies that creating drum rack presets works correctly 10 | // with both empty samples and provided sample data. 11 | func TestNewDrumRackDevicePresetWithSamples(t *testing.T) { 12 | emptyPreset := NewDrumRackDevicePresetWithSamples([]AudioFile{}) 13 | 14 | assert.Equal(t, DevicePresetSchema, emptyPreset.Schema, "Schema should be set correctly") 15 | assert.Len(t, emptyPreset.Chains, 1, "Should create 1 chain") 16 | 17 | testPath1 := "sample1.wav" 18 | testPath2 := "sample2.wav" 19 | samples := []AudioFile{ 20 | { 21 | FilePath: &testPath1, 22 | Duration: 1000.0, 23 | }, 24 | { 25 | FilePath: &testPath2, 26 | Duration: 2000.0, 27 | }, 28 | } 29 | 30 | preset := NewDrumRackDevicePresetWithSamples(samples) 31 | 32 | assert.Equal(t, DevicePresetSchema, preset.Schema, "Schema should be set correctly") 33 | assert.Equal(t, "instrumentRack", preset.Kind, "Kind should be instrumentRack") 34 | assert.Len(t, preset.Chains, 1, "Should create 1 chain") 35 | } 36 | -------------------------------------------------------------------------------- /cmd/slice.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/alexfedosov/move-tool/internal" 5 | "github.com/spf13/cobra" 6 | ) 7 | 8 | var ( 9 | input string 10 | output string 11 | numberOfSlices int 12 | presetName string 13 | 14 | sliceCmd = &cobra.Command{ 15 | Use: "slice", 16 | Short: "Slices long sample into drum rack", 17 | Long: `Slice long sample into given number of equal numberOfSlices and creates a drum rack preset`, 18 | RunE: func(cmd *cobra.Command, args []string) error { 19 | return internal.SliceSampleIntoDrumRack(input, output, numberOfSlices, presetName) 20 | }, 21 | } 22 | ) 23 | 24 | func init() { 25 | sliceCmd.Flags().StringVarP(&input, "input", "i", "", "input file") 26 | _ = sliceCmd.MarkFlagRequired("input") 27 | sliceCmd.Flags().StringVarP(&output, "output", "o", "", "Output directory") 28 | _ = sliceCmd.MarkFlagRequired("output") 29 | sliceCmd.Flags().IntVarP(&numberOfSlices, "numberOfSlices", "n", 16, "Number of numberOfSlices") 30 | _ = sliceCmd.MarkFlagRequired("numberOfSlices") 31 | sliceCmd.Flags().StringVarP(&presetName, "preset-name", "p", "", "Custom name for the preset (without extension)") 32 | rootCmd.AddCommand(sliceCmd) 33 | } 34 | -------------------------------------------------------------------------------- /internal/ablmodels/drum_sampler_test.go: -------------------------------------------------------------------------------- 1 | package ablmodels 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | // TestNewDrumSampler verifies that a new DrumSampler is created with the correct initial values 10 | // including kind, empty sample URI, and nil parameters. 11 | func TestNewDrumSampler(t *testing.T) { 12 | sampler := NewDrumSampler() 13 | 14 | assert.Equal(t, DrumSamplerDeviceKind, sampler.Kind, "Kind should be drumCell") 15 | assert.Nil(t, sampler.DeviceData.SampleURI, "SampleURI should be nil initially") 16 | assert.Nil(t, sampler.Parameters, "Parameters should be nil initially") 17 | } 18 | 19 | // TestDrumSamplerWithSample verifies that adding a sample to a DrumSampler works correctly 20 | // by setting the SampleURI and initializing parameters based on sample duration. 21 | func TestDrumSamplerWithSample(t *testing.T) { 22 | sampler := NewDrumSampler() 23 | filePath := "test/sample.wav" 24 | duration := 2000.0 25 | 26 | audioFile := AudioFile{ 27 | FilePath: &filePath, 28 | Duration: duration, 29 | } 30 | 31 | result := sampler.WithSample(audioFile) 32 | 33 | assert.NotNil(t, sampler.DeviceData.SampleURI, "SampleURI should not be nil after setting sample") 34 | assert.Equal(t, filePath, *sampler.DeviceData.SampleURI, "SampleURI should be set to the file path") 35 | assert.NotNil(t, sampler.Parameters, "Parameters should be set") 36 | assert.Same(t, sampler, result, "WithSample should return the same sampler instance") 37 | } 38 | -------------------------------------------------------------------------------- /cmd/slice_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | // TestSliceCommandFlags verifies that the slice command has all required flags configured 11 | // with correct shorthand values and default settings. 12 | func TestSliceCommandFlags(t *testing.T) { 13 | cmd := sliceCmd 14 | 15 | flag := cmd.Flag("input") 16 | assert.NotNil(t, flag, "'input' flag should exist") 17 | assert.Equal(t, "i", flag.Shorthand, "'input' flag should have shorthand 'i'") 18 | 19 | flag = cmd.Flag("output") 20 | assert.NotNil(t, flag, "'output' flag should exist") 21 | assert.Equal(t, "o", flag.Shorthand, "'output' flag should have shorthand 'o'") 22 | 23 | flag = cmd.Flag("numberOfSlices") 24 | assert.NotNil(t, flag, "'numberOfSlices' flag should exist") 25 | assert.Equal(t, "n", flag.Shorthand, "'numberOfSlices' flag should have shorthand 'n'") 26 | assert.Equal(t, "16", flag.DefValue, "'numberOfSlices' flag should have default value '16'") 27 | 28 | flag = cmd.Flag("preset-name") 29 | assert.NotNil(t, flag, "'preset-name' flag should exist") 30 | assert.Equal(t, "p", flag.Shorthand, "'preset-name' flag should have shorthand 'p'") 31 | } 32 | 33 | // TestSliceCommandMarkRequiredFlags verifies that required flags are properly marked 34 | // so they can be validated before command execution. 35 | func TestSliceCommandMarkRequiredFlags(t *testing.T) { 36 | inputFlag := sliceCmd.Flags().Lookup("input") 37 | require.NotNil(t, inputFlag, "input flag not found") 38 | 39 | if sliceCmd.PreRunE == nil { 40 | t.Skip("Skipping required flag check - command uses a different mechanism for validation") 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /internal/app.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "fmt" 5 | "github.com/alexfedosov/move-tool/internal/ablmodels" 6 | "github.com/brianvoe/gofakeit/v7" 7 | "strings" 8 | ) 9 | 10 | func sanitizePresetName(presetName string) string { 11 | var result strings.Builder 12 | 13 | for _, char := range presetName { 14 | if (char >= 'a' && char <= 'z') || char == '_' { 15 | result.WriteRune(char) 16 | } else { 17 | result.WriteRune('_') 18 | } 19 | } 20 | 21 | return result.String() 22 | } 23 | 24 | func SliceSampleIntoDrumRack(inputFilePath string, outputFolderPath string, numberOfSlices int, customPresetName string) (err error) { 25 | err = gofakeit.Seed(0) 26 | if err != nil { 27 | return err 28 | } 29 | 30 | var presetName string 31 | if customPresetName != "" { 32 | presetName = customPresetName 33 | } else { 34 | presetName = strings.ToLower(fmt.Sprintf("%s_%s", gofakeit.HipsterWord(), gofakeit.AdverbPlace())) 35 | presetName = sanitizePresetName(presetName) 36 | } 37 | 38 | presetFolderPath, err := createFolderIfNotExist(outputFolderPath, presetName) 39 | if err != nil { 40 | return err 41 | } 42 | samplesFolderPath, err := createFolderIfNotExist(presetFolderPath, "Samples") 43 | if err != nil { 44 | return err 45 | } 46 | samples, err := writeAudioFileSlices(inputFilePath, samplesFolderPath, numberOfSlices, presetName) 47 | if err != nil { 48 | return err 49 | } 50 | 51 | preset := ablmodels.NewDrumRackDevicePresetWithSamples(*samples) 52 | 53 | err = writePresetFile(preset, presetFolderPath) 54 | if err != nil { 55 | return err 56 | } 57 | err = archivePresetBundle(presetName, presetFolderPath, outputFolderPath) 58 | if err != nil { 59 | return err 60 | } 61 | 62 | err = removeDirectory(presetFolderPath) 63 | if err != nil { 64 | return err 65 | } 66 | 67 | return nil 68 | } 69 | -------------------------------------------------------------------------------- /internal/fileutils.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "archive/zip" 5 | "encoding/json" 6 | "fmt" 7 | "github.com/alexfedosov/move-tool/internal/ablmodels" 8 | "io" 9 | "os" 10 | "path" 11 | "path/filepath" 12 | "strings" 13 | ) 14 | 15 | func createFolderIfNotExist(basePath string, folderName string) (path string, err error) { 16 | path = filepath.Join(basePath, folderName) 17 | _, err = os.Stat(path) 18 | if os.IsNotExist(err) { 19 | err = os.Mkdir(path, os.ModePerm) 20 | } 21 | return path, err 22 | } 23 | 24 | func archivePresetBundle(presetName string, directory string, output string) error { 25 | zipFilePath := path.Join(output, fmt.Sprintf("%s.ablpresetbundle", presetName)) 26 | fmt.Printf("Creating preset bundle %s\n", zipFilePath) 27 | zipFile, err := os.Create(zipFilePath) 28 | if err != nil { 29 | return err 30 | } 31 | defer zipFile.Close() 32 | zipWriter := zip.NewWriter(zipFile) 33 | defer zipWriter.Close() 34 | err = filepath.Walk(directory, func(path string, info os.FileInfo, err error) error { 35 | if err != nil { 36 | return err 37 | } 38 | relativePath := strings.TrimPrefix(path, fmt.Sprintf("%s/", directory)) 39 | if info.IsDir() { 40 | return nil 41 | } 42 | zipFileWriter, err := zipWriter.Create(relativePath) 43 | if err != nil { 44 | return err 45 | } 46 | file, err := os.Open(path) 47 | if err != nil { 48 | return err 49 | } 50 | defer file.Close() 51 | _, err = io.Copy(zipFileWriter, file) 52 | if err != nil { 53 | return err 54 | } 55 | return nil 56 | }) 57 | return err 58 | } 59 | 60 | func writePresetFile(preset *ablmodels.DevicePreset, presetFolderPath string) error { 61 | presentJSON, err := json.MarshalIndent(preset, "", " ") 62 | if err != nil { 63 | return err 64 | } 65 | filePath := fmt.Sprintf("%s/Preset.ablpreset", presetFolderPath) 66 | file, err := os.Create(filePath) 67 | if err != nil { 68 | return err 69 | } 70 | defer file.Close() 71 | 72 | _, err = file.Write(presentJSON) 73 | if err != nil { 74 | return err 75 | } 76 | return nil 77 | } 78 | 79 | func removeDirectory(path string) error { 80 | return os.RemoveAll(path) 81 | } 82 | -------------------------------------------------------------------------------- /internal/ablmodels/saturator.go: -------------------------------------------------------------------------------- 1 | package ablmodels 2 | 3 | type SaturatorParameters struct { 4 | BaseDrive float64 `json:"BaseDrive"` 5 | BassShaperThreshold float64 `json:"BassShaperThreshold"` 6 | ColorDepth float64 `json:"ColorDepth"` 7 | ColorFrequency float64 `json:"ColorFrequency"` 8 | ColorOn bool `json:"ColorOn"` 9 | ColorWidth float64 `json:"ColorWidth"` 10 | DryWet float64 `json:"DryWet"` 11 | Enabled bool `json:"Enabled"` 12 | Oversampling bool `json:"Oversampling"` 13 | PostClip string `json:"PostClip"` 14 | PostDrive float64 `json:"PostDrive"` 15 | PreDcFilter bool `json:"PreDcFilter"` 16 | PreDrive float64 `json:"PreDrive"` 17 | Type string `json:"Type"` 18 | WsCurve float64 `json:"WsCurve"` 19 | WsDamp float64 `json:"WsDamp"` 20 | WsDepth float64 `json:"WsDepth"` 21 | WsDrive float64 `json:"WsDrive"` 22 | WsLin float64 `json:"WsLin"` 23 | WsPeriod float64 `json:"WsPeriod"` 24 | } 25 | 26 | func DefaultSaturatorParameters() SaturatorParameters { 27 | return SaturatorParameters{ 28 | BaseDrive: -20.25, 29 | BassShaperThreshold: -50.0, 30 | ColorDepth: 0.0, 31 | ColorFrequency: 999.9998779296876, 32 | ColorOn: true, 33 | ColorWidth: 0.30000001192092896, 34 | DryWet: 0.2936508059501648, 35 | Enabled: true, 36 | Oversampling: true, 37 | PostClip: "off", 38 | PostDrive: -23.714284896850582, 39 | PreDcFilter: false, 40 | PreDrive: 20.571426391601563, 41 | Type: "Soft Sine", 42 | WsCurve: 0.05000000074505806, 43 | WsDamp: 0.0, 44 | WsDepth: 0.0, 45 | WsDrive: 1.0, 46 | WsLin: 0.5, 47 | WsPeriod: 0.0, 48 | } 49 | } 50 | 51 | const SaturatorDeviceKind = "saturator" 52 | 53 | func NewSaturator() *Device { 54 | return NewDevice(SaturatorDeviceKind).WithParameters(DefaultSaturatorParameters()) 55 | } 56 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/brianvoe/gofakeit/v7 v7.0.4 h1:Mkxwz9jYg8Ad8NvT9HA27pCMZGFQo08MK6jD0QTKEww= 2 | github.com/brianvoe/gofakeit/v7 v7.0.4/go.mod h1:QXuPeBw164PJCzCUZVmgpgHJ3Llj49jSLVkKPMtxtxA= 3 | github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 4 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 5 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/go-audio/audio v1.0.0 h1:zS9vebldgbQqktK4H0lUqWrG8P0NxCJVqcj7ZpNnwd4= 7 | github.com/go-audio/audio v1.0.0/go.mod h1:6uAu0+H2lHkwdGsAY+j2wHPNPpPoeg5AaEFh9FlA+Zs= 8 | github.com/go-audio/riff v1.0.0 h1:d8iCGbDvox9BfLagY94fBynxSPHO80LmZCaOsmKxokA= 9 | github.com/go-audio/riff v1.0.0/go.mod h1:l3cQwc85y79NQFCRB7TiPoNiaijp6q8Z0Uv38rVG498= 10 | github.com/go-audio/wav v1.1.0 h1:jQgLtbqBzY7G+BM8fXF7AHUk1uHUviWS4X39d5rsL2g= 11 | github.com/go-audio/wav v1.1.0/go.mod h1:mpe9qfwbScEbkd8uybLuIpTgHyrISw/OTuvjUW2iGtE= 12 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 13 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 14 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 15 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 16 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 17 | github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= 18 | github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= 19 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 20 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 21 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 22 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 23 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 24 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 25 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 26 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 27 | -------------------------------------------------------------------------------- /internal/audioutils.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "fmt" 5 | "github.com/alexfedosov/move-tool/internal/ablmodels" 6 | "github.com/go-audio/audio" 7 | "github.com/go-audio/wav" 8 | "os" 9 | "path" 10 | ) 11 | 12 | func writeAudioFileSlices(filePath string, outputDir string, parts int, filenamePrefix string) (*[]ablmodels.AudioFile, error) { 13 | file, err := os.Open(filePath) 14 | if err != nil { 15 | return nil, fmt.Errorf("could not open source file: %v", err) 16 | } 17 | 18 | decoder := wav.NewDecoder(file) 19 | decoder.ReadInfo() 20 | 21 | // Read the entire wave data 22 | buf, err := decoder.FullPCMBuffer() 23 | if err != nil { 24 | return nil, fmt.Errorf("could not read wave data: %v", err) 25 | } 26 | 27 | // Calculate the number of samples per part 28 | samplesPerPart := len(buf.Data) / parts 29 | 30 | sampleDurationMilliseconds := float64(decoder.SampleRate) * float64(decoder.BitDepth) / 8 * float64(samplesPerPart) / 1000 31 | 32 | result := make([]ablmodels.AudioFile, parts) 33 | 34 | // Loop through each part and save it as a new file 35 | for i := 0; i < parts; i++ { 36 | start := i * samplesPerPart 37 | end := start + samplesPerPart 38 | if i == parts-1 { 39 | end = len(buf.Data) // Make sure the last part gets all remaining samples 40 | } 41 | 42 | partBuffer := &audio.IntBuffer{ 43 | Format: buf.Format, 44 | Data: buf.Data[start:end], 45 | } 46 | 47 | // Generate output file path 48 | partFileName := fmt.Sprintf("%s_part_%d.wav", filenamePrefix, i+1) 49 | outputFilePath := path.Join(outputDir, partFileName) 50 | partFile, err := os.Create(outputFilePath) 51 | if err != nil { 52 | return nil, fmt.Errorf("could not create part file: %v", err) 53 | } 54 | 55 | // Create a new encoder 56 | encoder := wav.NewEncoder(partFile, buf.Format.SampleRate, int(decoder.BitDepth), buf.Format.NumChannels, 1) 57 | 58 | if err := encoder.Write(partBuffer); err != nil { 59 | return nil, fmt.Errorf("could not write part buffer: %v", err) 60 | } 61 | 62 | if err := encoder.Close(); err != nil { 63 | return nil, fmt.Errorf("could not close encoder: %v", err) 64 | } 65 | 66 | partFile.Close() 67 | fmt.Printf("Slice %d saved as %s\n", i+1, outputFilePath) 68 | sampleFilePath := fmt.Sprintf("Samples/%s", partFileName) 69 | result[i] = ablmodels.AudioFile{ 70 | FilePath: &sampleFilePath, 71 | Duration: sampleDurationMilliseconds, 72 | } 73 | } 74 | 75 | return &result, nil 76 | } 77 | -------------------------------------------------------------------------------- /internal/audioutils_test.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | // TestWriteAudioFileSlices verifies that audio files are correctly sliced 14 | // into smaller WAV files with the expected naming pattern and metadata. 15 | func TestWriteAudioFileSlices(t *testing.T) { 16 | inputDir, err := os.MkdirTemp("", "test-input") 17 | require.NoError(t, err, "Failed to create input directory") 18 | defer os.RemoveAll(inputDir) 19 | 20 | outputDir, err := os.MkdirTemp("", "test-output") 21 | require.NoError(t, err, "Failed to create output directory") 22 | defer os.RemoveAll(outputDir) 23 | 24 | inputFilePath := filepath.Join(inputDir, "test.wav") 25 | createTestWAVFile(t, inputFilePath, 44100) 26 | 27 | filenamePrefix := "test_prefix" 28 | numberOfSlices := 4 29 | 30 | audioFiles, err := writeAudioFileSlices(inputFilePath, outputDir, numberOfSlices, filenamePrefix) 31 | require.NoError(t, err, "writeAudioFileSlices should not fail") 32 | require.NotNil(t, audioFiles, "writeAudioFileSlices should not return nil audioFiles") 33 | assert.Len(t, *audioFiles, numberOfSlices, "Should create correct number of audio files") 34 | 35 | for i := 0; i < numberOfSlices; i++ { 36 | partNum := i + 1 37 | sliceFilename := filepath.Join(outputDir, filenamePrefix+"_part_"+fmt.Sprintf("%d", partNum)+".wav") 38 | 39 | _, err = os.Stat(sliceFilename) 40 | assert.False(t, os.IsNotExist(err), "Slice file should exist at %s", sliceFilename) 41 | 42 | require.NotNil(t, (*audioFiles)[i].FilePath, "Audio file %d should not have nil FilePath", i) 43 | 44 | expectedPath := "Samples/" + filenamePrefix + "_part_" + fmt.Sprintf("%d", partNum) + ".wav" 45 | assert.Equal(t, expectedPath, *(*audioFiles)[i].FilePath, "FilePath should match expected pattern") 46 | assert.Greater(t, (*audioFiles)[i].Duration, 0.0, "Duration should be positive") 47 | } 48 | } 49 | 50 | // TestWriteAudioFileSlicesWithNonExistentFile verifies that the function correctly handles 51 | // errors when the input file doesn't exist. 52 | func TestWriteAudioFileSlicesWithNonExistentFile(t *testing.T) { 53 | outputDir, err := os.MkdirTemp("", "test-output") 54 | require.NoError(t, err, "Failed to create output directory") 55 | defer os.RemoveAll(outputDir) 56 | 57 | // Use a non-existent file path 58 | nonExistentFilePath := "/path/to/nonexistent/file.wav" 59 | 60 | audioFiles, err := writeAudioFileSlices(nonExistentFilePath, outputDir, 4, "test_prefix") 61 | 62 | // Verify that the function returns an error 63 | assert.Error(t, err, "writeAudioFileSlices should fail with non-existent file") 64 | assert.Nil(t, audioFiles, "audioFiles should be nil when an error occurs") 65 | assert.Contains(t, err.Error(), "could not open source file", "Error message should indicate the file couldn't be opened") 66 | } 67 | -------------------------------------------------------------------------------- /internal/ablmodels/device_test.go: -------------------------------------------------------------------------------- 1 | package ablmodels 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | // TestNewDevice verifies that a new Device is created with the correct initial values 11 | // and that all fields are properly initialized. 12 | func TestNewDevice(t *testing.T) { 13 | kind := "testDevice" 14 | device := NewDevice(kind) 15 | 16 | assert.Equal(t, kind, device.Kind, "Kind should be set correctly") 17 | assert.Nil(t, device.PresetURI, "PresetURI should be nil") 18 | assert.Empty(t, device.Name, "Name should be empty") 19 | assert.Nil(t, device.Parameters, "Parameters should be nil") 20 | assert.Empty(t, device.Chains, "Chains should be empty") 21 | assert.Empty(t, device.ReturnChains, "ReturnChains should be empty") 22 | } 23 | 24 | // TestDeviceAddChain verifies that adding a chain to a device works correctly 25 | // and that the chain is properly stored and retrievable. 26 | func TestDeviceAddChain(t *testing.T) { 27 | device := NewDevice("testDevice") 28 | 29 | type mockChain struct { 30 | Name string 31 | } 32 | chain := mockChain{Name: "TestChain"} 33 | 34 | result := device.AddChain(chain) 35 | 36 | assert.Len(t, device.Chains, 1, "Chain should be added") 37 | assert.Same(t, device, result, "AddChain should return the same device instance") 38 | addedChain, ok := device.Chains[0].(mockChain) 39 | assert.True(t, ok, "Added chain should be of expected type") 40 | assert.Equal(t, chain.Name, addedChain.Name, "Chain name should match") 41 | } 42 | 43 | // TestDeviceAddReturnChain verifies that adding a return chain to a device works correctly 44 | // and that the return chain is properly stored in the ReturnChains collection. 45 | func TestDeviceAddReturnChain(t *testing.T) { 46 | device := NewDevice("testDevice") 47 | 48 | type mockChain struct { 49 | Name string 50 | } 51 | returnChain := mockChain{Name: "TestReturnChain"} 52 | 53 | result := device.AddReturnChain(returnChain) 54 | 55 | assert.Len(t, device.ReturnChains, 1, "Return chain should be added") 56 | assert.Same(t, device, result, "AddReturnChain should return the same device instance") 57 | addedChain, ok := device.ReturnChains[0].(mockChain) 58 | assert.True(t, ok, "Added return chain should be of expected type") 59 | assert.Equal(t, returnChain.Name, addedChain.Name, "Return chain name should match") 60 | } 61 | 62 | // TestDeviceWithParameters verifies that setting parameters on a device works correctly 63 | // and that the device instance is returned for method chaining. 64 | func TestDeviceWithParameters(t *testing.T) { 65 | device := NewDevice("testDevice") 66 | 67 | params := map[string]interface{}{ 68 | "param1": 123, 69 | "param2": "value", 70 | } 71 | 72 | result := device.WithParameters(params) 73 | 74 | assert.True(t, reflect.DeepEqual(device.Parameters, params), "Parameters should be set correctly") 75 | assert.Same(t, device, result, "WithParameters should return the same device instance") 76 | } 77 | -------------------------------------------------------------------------------- /internal/ablmodels/reverb.go: -------------------------------------------------------------------------------- 1 | package ablmodels 2 | 3 | type ReverbParameters struct { 4 | AllPassGain float64 `json:"AllPassGain"` 5 | AllPassSize float64 `json:"AllPassSize"` 6 | BandFreq float64 `json:"BandFreq"` 7 | BandHighOn bool `json:"BandHighOn"` 8 | BandLowOn bool `json:"BandLowOn"` 9 | BandWidth float64 `json:"BandWidth"` 10 | ChorusOn bool `json:"ChorusOn"` 11 | CutOn bool `json:"CutOn"` 12 | DecayTime float64 `json:"DecayTime"` 13 | DiffuseDelay float64 `json:"DiffuseDelay"` 14 | EarlyReflectModDepth float64 `json:"EarlyReflectModDepth"` 15 | EarlyReflectModFreq float64 `json:"EarlyReflectModFreq"` 16 | Enabled bool `json:"Enabled"` 17 | FlatOn bool `json:"FlatOn"` 18 | FreezeOn bool `json:"FreezeOn"` 19 | HighFilterType string `json:"HighFilterType"` 20 | MixDiffuse float64 `json:"MixDiffuse"` 21 | MixDirect float64 `json:"MixDirect"` 22 | MixReflect float64 `json:"MixReflect"` 23 | PreDelay float64 `json:"PreDelay"` 24 | RoomSize float64 `json:"RoomSize"` 25 | RoomType string `json:"RoomType"` 26 | ShelfHiFreq float64 `json:"ShelfHiFreq"` 27 | ShelfHiGain float64 `json:"ShelfHiGain"` 28 | ShelfHighOn bool `json:"ShelfHighOn"` 29 | ShelfLoFreq float64 `json:"ShelfLoFreq"` 30 | ShelfLoGain float64 `json:"ShelfLoGain"` 31 | ShelfLowOn bool `json:"ShelfLowOn"` 32 | SizeModDepth float64 `json:"SizeModDepth"` 33 | SizeModFreq float64 `json:"SizeModFreq"` 34 | SizeSmoothing string `json:"SizeSmoothing"` 35 | SpinOn bool `json:"SpinOn"` 36 | StereoSeparation float64 `json:"StereoSeparation"` 37 | } 38 | 39 | func DefaultReverbParameters() ReverbParameters { 40 | return ReverbParameters{ 41 | AllPassGain: 0.6000000238418579, 42 | AllPassSize: 0.4000000059604645, 43 | BandFreq: 829.999755859375, 44 | BandHighOn: false, 45 | BandLowOn: true, 46 | BandWidth: 5.849999904632568, 47 | ChorusOn: true, 48 | CutOn: true, 49 | DecayTime: 1200.0001220703125, 50 | DiffuseDelay: 0.5, 51 | EarlyReflectModDepth: 17.5, 52 | EarlyReflectModFreq: 0.29770001769065857, 53 | Enabled: true, 54 | FlatOn: true, 55 | FreezeOn: false, 56 | HighFilterType: "Shelf", 57 | MixDiffuse: 1.0, 58 | MixDirect: 0.550000011920929, 59 | MixReflect: 1.0, 60 | PreDelay: 2.5, 61 | RoomSize: 99.99999237060548, 62 | RoomType: "SuperEco", 63 | ShelfHiFreq: 4500.00146484375, 64 | ShelfHiGain: 0.699999988079071, 65 | ShelfHighOn: true, 66 | ShelfLoFreq: 90.0, 67 | ShelfLoGain: 1.0, 68 | ShelfLowOn: true, 69 | SizeModDepth: 0.019999999552965164, 70 | SizeModFreq: 0.020000001415610313, 71 | SizeSmoothing: "Fast", 72 | SpinOn: true, 73 | StereoSeparation: 100.0, 74 | } 75 | } 76 | 77 | const ReverbDeviceKind = "reverb" 78 | 79 | func NewReverb() *Device { 80 | return NewDevice(ReverbDeviceKind).WithParameters(DefaultReverbParameters()) 81 | } 82 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release move-tool 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | workflow_dispatch: 8 | inputs: 9 | version: 10 | description: 'Version number for this release (e.g., v1.0.0)' 11 | required: true 12 | default: 'v1.0.0' 13 | 14 | jobs: 15 | test: 16 | name: Run Tests 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Checkout code 20 | uses: actions/checkout@v4 21 | 22 | - name: Set up Go 23 | uses: actions/setup-go@v5.0.2 24 | with: 25 | go-version: '1.22' 26 | 27 | - name: Install dependencies 28 | run: go mod download 29 | 30 | - name: Run tests 31 | run: go test -v ./... 32 | 33 | build: 34 | name: Build and Release 35 | needs: test 36 | runs-on: ubuntu-latest 37 | strategy: 38 | matrix: 39 | include: 40 | - goos: linux 41 | goarch: amd64 42 | artifact_name: linux-amd64 43 | - goos: linux 44 | goarch: arm64 45 | artifact_name: linux-arm64 46 | - goos: windows 47 | goarch: amd64 48 | artifact_name: windows-amd64 49 | - goos: windows 50 | goarch: arm64 51 | artifact_name: windows-arm64 52 | - goos: darwin 53 | goarch: amd64 54 | artifact_name: macos-intel 55 | - goos: darwin 56 | goarch: arm64 57 | artifact_name: macos-apple-silicon 58 | steps: 59 | - uses: actions/checkout@v4 60 | 61 | - name: Set up Go 62 | uses: actions/setup-go@v5.0.2 63 | with: 64 | go-version: '1.22' 65 | 66 | - name: Build 67 | env: 68 | GOOS: ${{ matrix.goos }} 69 | GOARCH: ${{ matrix.goarch }} 70 | run: | 71 | BINARY_NAME=move-tool 72 | if [ "${{ matrix.goos }}" = "windows" ]; then 73 | BINARY_NAME="${BINARY_NAME}.exe" 74 | fi 75 | go build -v -o ${BINARY_NAME} 76 | 77 | - name: Zip artifact 78 | run: | 79 | ZIP_NAME=move-tool-${{ matrix.artifact_name }}.zip 80 | BINARY_NAME=move-tool 81 | if [ "${{ matrix.goos }}" = "windows" ]; then 82 | BINARY_NAME="${BINARY_NAME}.exe" 83 | fi 84 | zip ${ZIP_NAME} ${BINARY_NAME} 85 | 86 | - name: Upload artifacts 87 | uses: actions/upload-artifact@v4 88 | with: 89 | name: move-tool-${{ matrix.artifact_name }} 90 | path: move-tool-${{ matrix.artifact_name }}.zip 91 | 92 | release: 93 | needs: build 94 | runs-on: ubuntu-latest 95 | steps: 96 | - name: Download artifacts 97 | uses: actions/download-artifact@v4 98 | with: 99 | path: artifacts 100 | 101 | - name: Display structure of downloaded files 102 | run: ls -R artifacts 103 | 104 | - name: Prepare release files 105 | run: | 106 | mkdir release_files 107 | find artifacts -type f -name "move-tool-*" -exec cp {} release_files/ \; 108 | 109 | - name: Release 110 | uses: softprops/action-gh-release@v2 111 | with: 112 | name: ${{ github.event.inputs.version || github.ref }} 113 | files: release_files/* 114 | tag_name: ${{ github.event.inputs.version || github.ref }} 115 | env: 116 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 117 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/goland,macos 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=goland,macos 3 | 4 | ### GoLand ### 5 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 6 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 7 | .idea 8 | 9 | # User-specific stuff 10 | .idea/**/workspace.xml 11 | .idea/**/tasks.xml 12 | .idea/**/usage.statistics.xml 13 | .idea/**/dictionaries 14 | .idea/**/shelf 15 | 16 | # AWS User-specific 17 | .idea/**/aws.xml 18 | 19 | # Generated files 20 | .idea/**/contentModel.xml 21 | 22 | # Sensitive or high-churn files 23 | .idea/**/dataSources/ 24 | .idea/**/dataSources.ids 25 | .idea/**/dataSources.local.xml 26 | .idea/**/sqlDataSources.xml 27 | .idea/**/dynamic.xml 28 | .idea/**/uiDesigner.xml 29 | .idea/**/dbnavigator.xml 30 | 31 | # Gradle 32 | .idea/**/gradle.xml 33 | .idea/**/libraries 34 | 35 | # Gradle and Maven with auto-import 36 | # When using Gradle or Maven with auto-import, you should exclude module files, 37 | # since they will be recreated, and may cause churn. Uncomment if using 38 | # auto-import. 39 | # .idea/artifacts 40 | # .idea/compiler.xml 41 | # .idea/jarRepositories.xml 42 | # .idea/modules.xml 43 | # .idea/*.iml 44 | # .idea/modules 45 | # *.iml 46 | # *.ipr 47 | 48 | # CMake 49 | cmake-build-*/ 50 | 51 | # Mongo Explorer plugin 52 | .idea/**/mongoSettings.xml 53 | 54 | # File-based project format 55 | *.iws 56 | 57 | # IntelliJ 58 | out/ 59 | 60 | # mpeltonen/sbt-idea plugin 61 | .idea_modules/ 62 | 63 | # JIRA plugin 64 | atlassian-ide-plugin.xml 65 | 66 | # Cursive Clojure plugin 67 | .idea/replstate.xml 68 | 69 | # SonarLint plugin 70 | .idea/sonarlint/ 71 | 72 | # Crashlytics plugin (for Android Studio and IntelliJ) 73 | com_crashlytics_export_strings.xml 74 | crashlytics.properties 75 | crashlytics-build.properties 76 | fabric.properties 77 | 78 | # Editor-based Rest Client 79 | .idea/httpRequests 80 | 81 | # Android studio 3.1+ serialized cache file 82 | .idea/caches/build_file_checksums.ser 83 | 84 | ### GoLand Patch ### 85 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 86 | 87 | # *.iml 88 | # modules.xml 89 | # .idea/misc.xml 90 | # *.ipr 91 | 92 | # Sonarlint plugin 93 | # https://plugins.jetbrains.com/plugin/7973-sonarlint 94 | .idea/**/sonarlint/ 95 | 96 | # SonarQube Plugin 97 | # https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin 98 | .idea/**/sonarIssues.xml 99 | 100 | # Markdown Navigator plugin 101 | # https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced 102 | .idea/**/markdown-navigator.xml 103 | .idea/**/markdown-navigator-enh.xml 104 | .idea/**/markdown-navigator/ 105 | 106 | # Cache file creation bug 107 | # See https://youtrack.jetbrains.com/issue/JBR-2257 108 | .idea/$CACHE_FILE$ 109 | 110 | # CodeStream plugin 111 | # https://plugins.jetbrains.com/plugin/12206-codestream 112 | .idea/codestream.xml 113 | 114 | # Azure Toolkit for IntelliJ plugin 115 | # https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij 116 | .idea/**/azureSettings.xml 117 | 118 | ### macOS ### 119 | # General 120 | .DS_Store 121 | .AppleDouble 122 | .LSOverride 123 | 124 | # Icon must end with two \r 125 | Icon 126 | 127 | 128 | # Thumbnails 129 | ._* 130 | 131 | # Files that might appear in the root of a volume 132 | .DocumentRevisions-V100 133 | .fseventsd 134 | .Spotlight-V100 135 | .TemporaryItems 136 | .Trashes 137 | .VolumeIcon.icns 138 | .com.apple.timemachine.donotpresent 139 | 140 | # Directories potentially created on remote AFP share 141 | .AppleDB 142 | .AppleDesktop 143 | Network Trash Folder 144 | Temporary Items 145 | .apdisk 146 | 147 | ### macOS Patch ### 148 | # iCloud generated files 149 | *.icloud 150 | 151 | # Go binaries 152 | move-tool 153 | *.exe 154 | *.exe~ 155 | *.dll 156 | *.so 157 | *.dylib 158 | 159 | # End of https://www.toptal.com/developers/gitignore/api/goland,macos 160 | -------------------------------------------------------------------------------- /internal/app_test.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | // TestSanitizePresetName verifies that preset names are properly sanitized 13 | // by replacing non-lowercase-letters and non-underscores with underscores. 14 | func TestSanitizePresetName(t *testing.T) { 15 | tests := []struct { 16 | name string 17 | input string 18 | expected string 19 | }{ 20 | { 21 | name: "lowercase_letters_only", 22 | input: "abcdefghijklmnopqrstuvwxyz", 23 | expected: "abcdefghijklmnopqrstuvwxyz", 24 | }, 25 | { 26 | name: "underscore_preserved", 27 | input: "drum_sample_kit", 28 | expected: "drum_sample_kit", 29 | }, 30 | { 31 | name: "uppercase_letters_converted", 32 | input: "DrumKit", 33 | expected: "_rum_it", 34 | }, 35 | { 36 | name: "numbers_converted", 37 | input: "kit123", 38 | expected: "kit___", 39 | }, 40 | { 41 | name: "special_chars_converted", 42 | input: "kit@#$%^&", 43 | expected: "kit______", 44 | }, 45 | { 46 | name: "mixed_content", 47 | input: "Drum-Kit_2023!", 48 | expected: "_rum__it______", 49 | }, 50 | { 51 | name: "empty_string", 52 | input: "", 53 | expected: "", 54 | }, 55 | { 56 | name: "all_uppercase_letters", 57 | input: "ABCDEFG", 58 | expected: "_______", 59 | }, 60 | { 61 | name: "mixed_case_with_valid_chars", 62 | input: "aBcDeFg_123", 63 | expected: "a_c_e_g____", 64 | }, 65 | } 66 | 67 | for _, tt := range tests { 68 | t.Run(tt.name, func(t *testing.T) { 69 | result := sanitizePresetName(tt.input) 70 | assert.Equal(t, tt.expected, result, "sanitizePresetName(%q) should return %q", tt.input, tt.expected) 71 | }) 72 | } 73 | } 74 | 75 | // TestSliceSampleIntoDrumRackWithCustomPresetName verifies that slicing with a custom preset name works 76 | // by creating temporary files and checking if the preset bundle is generated correctly. 77 | func TestSliceSampleIntoDrumRackWithCustomPresetName(t *testing.T) { 78 | inputDir, err := os.MkdirTemp("", "test-input") 79 | require.NoError(t, err, "Failed to create input directory") 80 | defer os.RemoveAll(inputDir) 81 | 82 | outputDir, err := os.MkdirTemp("", "test-output") 83 | require.NoError(t, err, "Failed to create output directory") 84 | defer os.RemoveAll(outputDir) 85 | 86 | inputFilePath := filepath.Join(inputDir, "test.wav") 87 | createTestWAVFile(t, inputFilePath, 44100) 88 | 89 | customPresetName := "my_custom_preset" 90 | 91 | err = SliceSampleIntoDrumRack(inputFilePath, outputDir, 4, customPresetName) 92 | require.NoError(t, err, "SliceSampleIntoDrumRack should not fail") 93 | 94 | bundlePath := filepath.Join(outputDir, customPresetName+".ablpresetbundle") 95 | _, err = os.Stat(bundlePath) 96 | assert.False(t, os.IsNotExist(err), "Preset bundle should exist at %s", bundlePath) 97 | } 98 | 99 | // createTestWAVFile generates a minimal valid WAV file for testing 100 | // with the specified sample rate and 8 bytes of silent sample data. 101 | func createTestWAVFile(t *testing.T, filePath string, sampleRate int) { 102 | header := []byte{ 103 | 'R', 'I', 'F', 'F', // ChunkID 104 | 52, 0, 0, 0, // ChunkSize (36 + SubChunk2Size) 105 | 'W', 'A', 'V', 'E', // Format 106 | 'f', 'm', 't', ' ', // Subchunk1ID 107 | 16, 0, 0, 0, // Subchunk1Size 108 | 1, 0, // AudioFormat (1 = PCM) 109 | 1, 0, // NumChannels 110 | byte(sampleRate & 0xff), byte((sampleRate >> 8) & 0xff), byte((sampleRate >> 16) & 0xff), byte((sampleRate >> 24) & 0xff), // SampleRate 111 | byte(sampleRate & 0xff), byte((sampleRate >> 8) & 0xff), byte((sampleRate >> 16) & 0xff), byte((sampleRate >> 24) & 0xff), // ByteRate (SampleRate * NumChannels * BitsPerSample/8) 112 | 2, 0, // BlockAlign (NumChannels * BitsPerSample/8) 113 | 16, 0, // BitsPerSample 114 | 'd', 'a', 't', 'a', // Subchunk2ID 115 | 8, 0, 0, 0, // Subchunk2Size (NumSamples * NumChannels * BitsPerSample/8) 116 | 0, 0, 0, 0, 0, 0, 0, 0, // Sample data (8 bytes of silence) 117 | } 118 | 119 | err := os.WriteFile(filePath, header, 0644) 120 | require.NoError(t, err, "Failed to create test WAV file") 121 | } 122 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # Move tool 2 | 3 | A simple CLI for slicing long samples into Ableton Note / Ableton Move presets 4 | 5 | ## Quick Start for Non-Developers 6 | 7 | ### Using Move Tool 8 | 9 | 1. Prepare an audio sample containing up to 16 parts (e.g., a sample with 16 equal-length slices). 10 | 2. Open Terminal (macOS) or Command Prompt (Windows). 11 | 3. Run the Move Tool command: 12 | - For macOS: 13 | ``` 14 | ./move-tool slice -i /path/to/your/sample.wav -n 16 -o /Users/your-username/Desktop 15 | ``` 16 | ![Output](gif/move-tool.gif) 17 | - For Windows: 18 | ``` 19 | move-tool.exe slice -i C:\path\to\your\sample.wav -n 16 -o C:\Users\YourUsername\Desktop 20 | ``` 21 | 4. Move Tool will slice the original sample into 16 pieces and create a Move/Note preset with a random name in the specified output directory. 22 | 5. If you find this tool helpful, please star the repository! 23 | 24 | ### Quick start for macOS Users 25 | 26 | 1. Download the latest macOS version of Move Tool for [Apple Silicon (M chips)](https://github.com/alexfedosov/move-tool/releases/latest/download/move-tool-macos-apple-silicon.zip) or [Intel](https://github.com/alexfedosov/move-tool/releases/latest/download/move-tool-macos-intel.zip) 27 | 2. Open Finder and navigate to your Downloads folder. 28 | 3. Double-click the downloaded file (it should be named something like `move-tool-macos.zip`) to unzip it. 29 | 4. Open Terminal (you can find it by pressing Cmd + Space and typing "Terminal"). 30 | 5. In Terminal, type `cd ` (with a space after it), then drag the folder containing the unzipped `move-tool` into the Terminal window. Press Enter. 31 | 32 | - **Note**: The Move Tool CLI is not signed, which means macOS may prevent it from running due to security measures. To run the tool, you'll need to follow these additional steps: 33 | 34 | - Right-click on the `move-tool` executable and select "Open" from the context menu. You'll see a security warning. Click "Open" to run the tool for the first time. 35 | 36 | - If the above method doesn't work, you can try the following: 37 | 1. Open "System Preferences" > "Security & Privacy" > "General" tab. 38 | 2. Look for a message about `move-tool` being blocked and click "Open Anyway". 39 | 40 | - If you're comfortable using the Terminal, you can remove the quarantine attribute: 41 | ``` 42 | xattr -r -d com.apple.quarantine ./move-tool 43 | ``` 44 | This command removes the quarantine flag, allowing the tool to run. 45 | 46 | - Before using the tool, ensure it has the correct permissions. Run the following command in the Terminal: 47 | ``` 48 | chmod +x ./move-tool 49 | ``` 50 | This sets the executable permission on the file, allowing you to run it from the command line. The `chmod +x` command is crucial as it grants execute permissions to the file, which is necessary for running command-line tools. 51 | 52 | 6. Now you can use Move Tool. For example, to slice a sample, type: 53 | ``` 54 | ./move-tool slice -i /path/to/your/sample.wav -n 16 -o /Users/your-username/Desktop 55 | ``` 56 | Replace `/path/to/your/sample.wav` with the actual path to your audio file, and `/Users/your-username/Desktop` with where you want to save the output. 57 | 58 | ### Quick start for Windows Users 59 | 1. Download the latest Windows version of Move Tool for [Intel/AMD processors](https://github.com/alexfedosov/move-tool/releases/latest/download/move-tool-windows-amd64.zip) or [ARM](https://github.com/alexfedosov/move-tool/releases/latest/download/move-tool-windows-arm64.zip) 60 | 2. Open File Explorer and navigate to your Downloads folder. 61 | 3. Right-click the downloaded file (it should be named something like `move-tool-windows.zip`) and select "Extract All". Choose a location to extract the files. 62 | 4. Open Command Prompt (you can find it by pressing Win + R, typing "cmd", and pressing Enter). 63 | 5. In Command Prompt, type `cd ` (with a space after it), then type the path to the folder where you extracted Move Tool. For example: 64 | ``` 65 | cd C:\Users\YourUsername\Downloads\move-tool 66 | ``` 67 | 6. Now you can use Move Tool. For example, to slice a sample, type: 68 | ``` 69 | move-tool.exe slice -i C:\path\to\your\sample.wav -n 16 -o C:\Users\YourUsername\Desktop 70 | ``` 71 | Replace `C:\path\to\your\sample.wav` with the actual path to your audio file, and `C:\Users\YourUsername\Desktop` with where you want to save the output. 72 | 73 | 74 | ### Quick start for Linux Users 75 | 1. You know what to do folks. Welcome home. 76 | 77 | ## For Developers 78 | 79 | - [Installation](#installation) 80 | - [Usage](#usage) 81 | - [Contributing](#contributing) 82 | - [License](#license) 83 | 84 | ### Installation 85 | 86 | 1. Ensure that you have [Go 1.22](https://golang.org/dl/) (or newer) installed. 87 | 2. Install: 88 | 89 | ```sh 90 | go install github.com/alexfedosov/move-tool@latest 91 | ``` 92 | 93 | ### Usage 94 | 95 | ```sh 96 | move-tool slice -i -n -o 97 | ``` 98 | 99 | Ensure that the output directory exists before running `move-tool`. 100 | 101 | ### Contributing 102 | 103 | 1. Fork the repository. 104 | 2. Create a new branch (`git checkout -b feature/YourFeature`). 105 | 3. Commit your changes (`git commit -m 'Add some feature'`). 106 | 4. Push to the branch (`git push origin feature/YourFeature`). 107 | 5. Open a Pull Request. 108 | 109 | ### License 110 | 111 | This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. 112 | -------------------------------------------------------------------------------- /internal/ablmodels/drum_sampler_parameters.go: -------------------------------------------------------------------------------- 1 | package ablmodels 2 | 3 | type DrumCellParameters struct { 4 | Effect_EightBitFilterDecay float64 `json:"Effect_EightBitFilterDecay"` 5 | Effect_EightBitResamplingRate float64 `json:"Effect_EightBitResamplingRate"` 6 | Effect_FmAmount float64 `json:"Effect_FmAmount"` 7 | Effect_FmFrequency float64 `json:"Effect_FmFrequency"` 8 | Effect_LoopLength float64 `json:"Effect_LoopLength"` 9 | Effect_LoopOffset float64 `json:"Effect_LoopOffset"` 10 | Effect_NoiseAmount float64 `json:"Effect_NoiseAmount"` 11 | Effect_NoiseFrequency float64 `json:"Effect_NoiseFrequency"` 12 | Effect_On bool `json:"Effect_On"` 13 | Effect_PitchEnvelopeAmount float64 `json:"Effect_PitchEnvelopeAmount"` 14 | Effect_PitchEnvelopeDecay float64 `json:"Effect_PitchEnvelopeDecay"` 15 | Effect_PunchAmount float64 `json:"Effect_PunchAmount"` 16 | Effect_PunchTime float64 `json:"Effect_PunchTime"` 17 | Effect_RingModAmount float64 `json:"Effect_RingModAmount"` 18 | Effect_RingModFrequency float64 `json:"Effect_RingModFrequency"` 19 | Effect_StretchFactor float64 `json:"Effect_StretchFactor"` 20 | Effect_StretchGrainSize float64 `json:"Effect_StretchGrainSize"` 21 | Effect_SubOscAmount float64 `json:"Effect_SubOscAmount"` 22 | Effect_SubOscFrequency float64 `json:"Effect_SubOscFrequency"` 23 | Effect_Type string `json:"Effect_Type"` 24 | Enabled bool `json:"Enabled"` 25 | NotePitchBend bool `json:"NotePitchBend"` 26 | Pan float64 `json:"Pan"` 27 | Voice_Detune float64 `json:"Voice_Detune"` 28 | Voice_Envelope_Attack float64 `json:"Voice_Envelope_Attack"` 29 | Voice_Envelope_Decay float64 `json:"Voice_Envelope_Decay"` 30 | Voice_Envelope_Hold float64 `json:"Voice_Envelope_Hold"` 31 | Voice_Envelope_Mode string `json:"Voice_Envelope_Mode"` 32 | Voice_Filter_Frequency float64 `json:"Voice_Filter_Frequency"` 33 | Voice_Filter_On bool `json:"Voice_Filter_On"` 34 | Voice_Filter_PeakGain float64 `json:"Voice_Filter_PeakGain"` 35 | Voice_Filter_Resonance float64 `json:"Voice_Filter_Resonance"` 36 | Voice_Filter_Type string `json:"Voice_Filter_Type"` 37 | Voice_Gain float64 `json:"Voice_Gain"` 38 | Voice_ModulationAmount float64 `json:"Voice_ModulationAmount"` 39 | Voice_ModulationSource string `json:"Voice_ModulationSource"` 40 | Voice_ModulationTarget string `json:"Voice_ModulationTarget"` 41 | Voice_PlaybackLength float64 `json:"Voice_PlaybackLength"` 42 | Voice_PlaybackStart float64 `json:"Voice_PlaybackStart"` 43 | Voice_Transpose int `json:"Voice_Transpose"` 44 | Voice_VelocityToVolume float64 `json:"Voice_VelocityToVolume"` 45 | Volume float64 `json:"Volume"` 46 | } 47 | 48 | func DefaultDrumSamplerParameters() *DrumCellParameters { 49 | p := &DrumCellParameters{ 50 | Effect_EightBitFilterDecay: 5.0, 51 | Effect_EightBitResamplingRate: 14080.0, 52 | Effect_FmAmount: 0.0, 53 | Effect_FmFrequency: 999.9998779296876, 54 | Effect_LoopLength: 0.30000001192092896, 55 | Effect_LoopOffset: 0.019999997690320015, 56 | Effect_NoiseAmount: 0.0, 57 | Effect_NoiseFrequency: 10000.0009765625, 58 | Effect_On: true, 59 | Effect_PitchEnvelopeAmount: 0.0, 60 | Effect_PitchEnvelopeDecay: 0.29999998211860657, 61 | Effect_PunchAmount: 0.0, 62 | Effect_PunchTime: 0.12015999853610992, 63 | Effect_RingModAmount: 0.0, 64 | Effect_RingModFrequency: 999.9998168945313, 65 | Effect_StretchFactor: 1.0, 66 | Effect_StretchGrainSize: 0.09999999403953552, 67 | Effect_SubOscAmount: 0.0, 68 | Effect_SubOscFrequency: 59.99999237060547, 69 | Effect_Type: "Stretch", 70 | Enabled: true, 71 | NotePitchBend: true, 72 | Pan: 0.0, 73 | Voice_Detune: 0.0, 74 | Voice_Envelope_Attack: 0.00009999999747378752, 75 | Voice_Envelope_Decay: 0.3, 76 | Voice_Envelope_Hold: 1, 77 | Voice_Envelope_Mode: "A-S-R", 78 | Voice_Filter_Frequency: 21999.990234375, 79 | Voice_Filter_On: true, 80 | Voice_Filter_PeakGain: 1.0, 81 | Voice_Filter_Resonance: 0.0, 82 | Voice_Filter_Type: "Lowpass", 83 | Voice_Gain: 1.0, 84 | Voice_ModulationAmount: 0.0, 85 | Voice_ModulationSource: "Velocity", 86 | Voice_ModulationTarget: "Filter", 87 | Voice_PlaybackLength: 1.0, 88 | Voice_PlaybackStart: 0.0, 89 | Voice_Transpose: 0, 90 | Voice_VelocityToVolume: 0.3499999940395355, 91 | Volume: 0, 92 | } 93 | return p.WithTriggerMode() 94 | } 95 | 96 | func (p *DrumCellParameters) WithGateMode() *DrumCellParameters { 97 | p.Voice_Envelope_Mode = "A-S-R" 98 | return p 99 | } 100 | 101 | func (p *DrumCellParameters) WithTriggerMode() *DrumCellParameters { 102 | p.Voice_Envelope_Mode = "A-H-D" 103 | return p 104 | } 105 | 106 | func (p *DrumCellParameters) WithVoiceEnvelopeHold(value float64) *DrumCellParameters { 107 | p.Voice_Envelope_Hold = value 108 | return p 109 | } 110 | -------------------------------------------------------------------------------- /internal/fileutils_test.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "archive/zip" 5 | ablmodels2 "github.com/alexfedosov/move-tool/internal/ablmodels" 6 | "io" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | "testing" 11 | 12 | "github.com/stretchr/testify/assert" 13 | "github.com/stretchr/testify/require" 14 | ) 15 | 16 | // TestCreateFolderIfNotExist verifies that folders are correctly created when needed 17 | // and returns the correct path even when the folder already exists. 18 | func TestCreateFolderIfNotExist(t *testing.T) { 19 | testDir, err := os.MkdirTemp("", "test-move-tool") 20 | require.NoError(t, err, "Failed to create temp directory") 21 | defer os.RemoveAll(testDir) // Clean up after test 22 | 23 | folderName := "test-folder" 24 | expectedPath := filepath.Join(testDir, folderName) 25 | 26 | resultPath, err := createFolderIfNotExist(testDir, folderName) 27 | require.NoError(t, err, "createFolderIfNotExist should not fail") 28 | assert.Equal(t, expectedPath, resultPath, "Returned path should match expected path") 29 | 30 | _, err = os.Stat(expectedPath) 31 | assert.False(t, os.IsNotExist(err), "Folder should exist at %s", expectedPath) 32 | 33 | resultPath2, err := createFolderIfNotExist(testDir, folderName) 34 | require.NoError(t, err, "createFolderIfNotExist should not fail on existing folder") 35 | assert.Equal(t, expectedPath, resultPath2, "Returned path should match expected path") 36 | } 37 | 38 | // TestRemoveDirectory verifies that directories are properly removed 39 | // including their contents (files and subdirectories). 40 | func TestRemoveDirectory(t *testing.T) { 41 | testDir, err := os.MkdirTemp("", "test-move-tool") 42 | require.NoError(t, err, "Failed to create temp directory") 43 | 44 | testFile := filepath.Join(testDir, "test.txt") 45 | err = os.WriteFile(testFile, []byte("test content"), 0644) 46 | require.NoError(t, err, "Failed to create test file") 47 | 48 | err = removeDirectory(testDir) 49 | require.NoError(t, err, "removeDirectory should not fail") 50 | 51 | _, err = os.Stat(testDir) 52 | assert.True(t, os.IsNotExist(err), "Directory should be removed") 53 | } 54 | 55 | // TestWritePresetFile verifies that device presets are correctly serialized to JSON 56 | // and written to the filesystem at the expected location. 57 | func TestWritePresetFile(t *testing.T) { 58 | testDir, err := os.MkdirTemp("", "test-move-tool") 59 | require.NoError(t, err, "Failed to create temp directory") 60 | defer os.RemoveAll(testDir) // Clean up after test 61 | 62 | filePath := "TestPath" 63 | audioFile := []ablmodels2.AudioFile{ 64 | { 65 | FilePath: &filePath, 66 | Duration: 1000.0, 67 | }, 68 | } 69 | preset := ablmodels2.NewDrumRackDevicePresetWithSamples(audioFile) 70 | 71 | err = writePresetFile(preset, testDir) 72 | require.NoError(t, err, "writePresetFile should not fail") 73 | 74 | presetPath := filepath.Join(testDir, "Preset.ablpreset") 75 | _, err = os.Stat(presetPath) 76 | assert.False(t, os.IsNotExist(err), "Preset file should exist at %s", presetPath) 77 | 78 | content, err := os.ReadFile(presetPath) 79 | require.NoError(t, err, "Should be able to read preset file") 80 | 81 | assert.Greater(t, len(content), 0, "Preset file should not be empty") 82 | } 83 | 84 | // TestWritePresetFileWithInvalidDirectory verifies that the function correctly handles 85 | // errors when the output directory doesn't exist. 86 | func TestWritePresetFileWithInvalidDirectory(t *testing.T) { 87 | // Use a non-existent directory 88 | nonExistentDir := "/path/to/nonexistent/directory" 89 | 90 | filePath := "TestPath" 91 | audioFile := []ablmodels2.AudioFile{ 92 | { 93 | FilePath: &filePath, 94 | Duration: 1000.0, 95 | }, 96 | } 97 | preset := ablmodels2.NewDrumRackDevicePresetWithSamples(audioFile) 98 | 99 | err := writePresetFile(preset, nonExistentDir) 100 | 101 | // Verify that the function returns an error 102 | assert.Error(t, err, "writePresetFile should fail with non-existent directory") 103 | } 104 | 105 | // TestArchivePresetBundle verifies that directories are correctly zipped into preset bundles 106 | // with the expected file structure and naming convention. 107 | func TestArchivePresetBundle(t *testing.T) { 108 | sourceDir, err := os.MkdirTemp("", "test-source") 109 | require.NoError(t, err, "Failed to create source directory") 110 | defer os.RemoveAll(sourceDir) 111 | 112 | outputDir, err := os.MkdirTemp("", "test-output") 113 | require.NoError(t, err, "Failed to create output directory") 114 | defer os.RemoveAll(outputDir) 115 | 116 | testFiles := []string{"test1.txt", "test2.txt", "subfolder/test3.txt"} 117 | testContent := []byte("test content") 118 | 119 | for _, filename := range testFiles { 120 | filePath := filepath.Join(sourceDir, filename) 121 | dirPath := filepath.Dir(filePath) 122 | 123 | if dirPath != sourceDir { 124 | err := os.MkdirAll(dirPath, os.ModePerm) 125 | require.NoError(t, err, "Failed to create directory %s", dirPath) 126 | } 127 | 128 | err := os.WriteFile(filePath, testContent, 0644) 129 | require.NoError(t, err, "Failed to create test file %s", filePath) 130 | } 131 | 132 | presetName := "test_preset" 133 | err = archivePresetBundle(presetName, sourceDir, outputDir) 134 | require.NoError(t, err, "archivePresetBundle should not fail") 135 | 136 | zipPath := filepath.Join(outputDir, presetName+".ablpresetbundle") 137 | _, err = os.Stat(zipPath) 138 | assert.False(t, os.IsNotExist(err), "Archive file should exist at %s", zipPath) 139 | 140 | // Verify the contents of the archive 141 | extractDir, err := os.MkdirTemp("", "test-extract") 142 | require.NoError(t, err, "Failed to create extraction directory") 143 | defer os.RemoveAll(extractDir) 144 | 145 | // Open the zip file 146 | reader, err := zip.OpenReader(zipPath) 147 | require.NoError(t, err, "Failed to open zip file") 148 | defer reader.Close() 149 | 150 | // Check that all expected files are in the archive 151 | var foundFiles []string 152 | for _, file := range reader.File { 153 | foundFiles = append(foundFiles, file.Name) 154 | 155 | // Extract and verify content of each file 156 | rc, err := file.Open() 157 | require.NoError(t, err, "Failed to open file in archive") 158 | 159 | content, err := io.ReadAll(rc) 160 | require.NoError(t, err, "Failed to read file content") 161 | rc.Close() 162 | 163 | // Verify content for non-directory entries 164 | if !strings.HasSuffix(file.Name, "/") { 165 | assert.Equal(t, testContent, content, "File content should match for %s", file.Name) 166 | } 167 | } 168 | 169 | // Verify all expected files are in the archive 170 | for _, expectedFile := range testFiles { 171 | assert.Contains(t, foundFiles, expectedFile, "Archive should contain %s", expectedFile) 172 | } 173 | } 174 | 175 | // TestArchivePresetBundleWithInvalidOutputDir verifies that the function correctly handles 176 | // errors when the output directory doesn't exist. 177 | func TestArchivePresetBundleWithInvalidOutputDir(t *testing.T) { 178 | sourceDir, err := os.MkdirTemp("", "test-source") 179 | require.NoError(t, err, "Failed to create source directory") 180 | defer os.RemoveAll(sourceDir) 181 | 182 | // Create a test file in the source directory 183 | testFilePath := filepath.Join(sourceDir, "test.txt") 184 | err = os.WriteFile(testFilePath, []byte("test content"), 0644) 185 | require.NoError(t, err, "Failed to create test file") 186 | 187 | // Use a non-existent output directory 188 | nonExistentDir := "/path/to/nonexistent/directory" 189 | 190 | err = archivePresetBundle("test_preset", sourceDir, nonExistentDir) 191 | 192 | // Verify that the function returns an error 193 | assert.Error(t, err, "archivePresetBundle should fail with non-existent output directory") 194 | } 195 | --------------------------------------------------------------------------------