main.go 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274
  1. // Command mpckit-cli builds an MPC Sample project from flags, for device
  2. // tests before the app exists (build plan M3). Edits run in the order given.
  3. //
  4. // go run ./cmd/mpckit-cli -name Kit -out ~/kits \
  5. // -pad A1=kick.wav -pad A2=snare.wav -tune A2=-24,0 -mute A3=1 \
  6. // -seq 1=Verse,96,8 -seq 2=Chorus,96,4
  7. package main
  8. import (
  9. "errors"
  10. "flag"
  11. "fmt"
  12. "os"
  13. "strconv"
  14. "strings"
  15. "git.monoexp.com/bajdev/mpckit/internal/export"
  16. "git.monoexp.com/bajdev/mpckit/internal/project"
  17. )
  18. type op struct {
  19. flag, arg string
  20. run func(p *project.Project) error
  21. }
  22. var ops []op
  23. // opFlag is a repeatable flag whose values become ops.
  24. type opFlag struct {
  25. name string
  26. parse func(arg string) (func(p *project.Project) error, error)
  27. }
  28. func (f opFlag) String() string { return "" }
  29. func (f opFlag) Set(arg string) error {
  30. run, err := f.parse(arg)
  31. if err != nil {
  32. return err
  33. }
  34. ops = append(ops, op{flag: f.name, arg: arg, run: run})
  35. return nil
  36. }
  37. func addOp(name, usage string, parse func(arg string) (func(p *project.Project) error, error)) {
  38. flag.Var(opFlag{name: name, parse: parse}, name, usage)
  39. }
  40. // parseSlot reads a pad label such as A1 or h16.
  41. func parseSlot(s string) (int, error) {
  42. s = strings.ToUpper(strings.TrimSpace(s))
  43. if len(s) < 2 || s[0] < 'A' || s[0] > 'H' {
  44. return 0, fmt.Errorf("bad pad %q: want A1-H16", s)
  45. }
  46. n, err := strconv.Atoi(s[1:])
  47. if err != nil || n < 1 || n > 16 {
  48. return 0, fmt.Errorf("bad pad %q: want A1-H16", s)
  49. }
  50. return int(s[0]-'A')*16 + n - 1, nil
  51. }
  52. func parsePair(arg string) (int, int, error) {
  53. a, b, ok := strings.Cut(arg, ":")
  54. if !ok {
  55. return 0, 0, fmt.Errorf("want FROM:TO, e.g. A1:B5")
  56. }
  57. from, err := parseSlot(a)
  58. if err != nil {
  59. return 0, 0, err
  60. }
  61. to, err := parseSlot(b)
  62. return from, to, err
  63. }
  64. func parseSeqNum(s string) (int, error) {
  65. n, err := strconv.Atoi(strings.TrimSpace(s))
  66. if err != nil || n < 1 || n > project.MaxSequences {
  67. return 0, fmt.Errorf("bad sequence number %q: want 1-%d", s, project.MaxSequences)
  68. }
  69. return n - 1, nil
  70. }
  71. func init() {
  72. addOp("pad", "assign a WAV: `PAD=FILE` (e.g. A1=kick.wav)", func(arg string) (func(*project.Project) error, error) {
  73. label, file, ok := strings.Cut(arg, "=")
  74. if !ok {
  75. return nil, errors.New("want PAD=FILE")
  76. }
  77. slot, err := parseSlot(label)
  78. if err != nil {
  79. return nil, err
  80. }
  81. return func(p *project.Project) error { return p.AssignSample(slot, file) }, nil
  82. })
  83. addOp("tune", "tune a pad: `PAD=SEMITONES,CENTS` (e.g. B1=-24,0)", func(arg string) (func(*project.Project) error, error) {
  84. label, val, ok := strings.Cut(arg, "=")
  85. c, f, ok2 := strings.Cut(val, ",")
  86. if !ok || !ok2 {
  87. return nil, errors.New("want PAD=SEMITONES,CENTS")
  88. }
  89. slot, err := parseSlot(label)
  90. if err != nil {
  91. return nil, err
  92. }
  93. coarse, err1 := strconv.Atoi(strings.TrimSpace(c))
  94. fine, err2 := strconv.Atoi(strings.TrimSpace(f))
  95. if err1 != nil || err2 != nil {
  96. return nil, errors.New("semitones and cents must be integers")
  97. }
  98. if coarse < project.MinCoarse || coarse > project.MaxCoarse || fine < project.MinFine || fine > project.MaxFine {
  99. return nil, fmt.Errorf("tuning out of range (%d..%d semitones, %d..%d cents)", project.MinCoarse, project.MaxCoarse, project.MinFine, project.MaxFine)
  100. }
  101. return func(p *project.Project) error { return p.SetTune(slot, coarse, fine) }, nil
  102. })
  103. addOp("mute", "set a pad's mute group: `PAD=GROUP` (0 is off)", func(arg string) (func(*project.Project) error, error) {
  104. label, val, ok := strings.Cut(arg, "=")
  105. if !ok {
  106. return nil, errors.New("want PAD=GROUP")
  107. }
  108. slot, err := parseSlot(label)
  109. if err != nil {
  110. return nil, err
  111. }
  112. g, err := strconv.Atoi(val)
  113. if err != nil {
  114. return nil, errors.New("group must be an integer")
  115. }
  116. return func(p *project.Project) error { return p.SetMuteGroup(slot, g) }, nil
  117. })
  118. addOp("clear", "clear a pad: `PAD`", func(arg string) (func(*project.Project) error, error) {
  119. slot, err := parseSlot(arg)
  120. return func(p *project.Project) error { return p.ClearPad(slot) }, err
  121. })
  122. for _, name := range []string{"copy", "move", "swap"} {
  123. addOp(name, name+" pads: `FROM:TO`", func(arg string) (func(*project.Project) error, error) {
  124. from, to, err := parsePair(arg)
  125. if err != nil {
  126. return nil, err
  127. }
  128. return func(p *project.Project) error {
  129. switch name {
  130. case "copy":
  131. return p.CopyPad(from, to)
  132. case "move":
  133. if p.PadHasNotes(from) {
  134. fmt.Fprintf(os.Stderr, "warning: %s has recorded notes; they stay on %s\n", project.SlotName(from), project.SlotName(from))
  135. }
  136. return p.MovePad(from, to)
  137. }
  138. return p.SwapPads(from, to)
  139. }, nil
  140. })
  141. }
  142. addOp("seq", "set a sequence, adding it if needed: `N=NAME,BPM,BARS` (N is 1-128)", func(arg string) (func(*project.Project) error, error) {
  143. num, val, ok := strings.Cut(arg, "=")
  144. parts := strings.Split(val, ",")
  145. if !ok || len(parts) != 3 {
  146. return nil, errors.New("want N=NAME,BPM,BARS")
  147. }
  148. slot, err := parseSeqNum(num)
  149. if err != nil {
  150. return nil, err
  151. }
  152. bpm, err1 := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64)
  153. bars, err2 := strconv.Atoi(strings.TrimSpace(parts[2]))
  154. if err1 != nil || err2 != nil {
  155. return nil, errors.New("BPM must be a number and BARS an integer")
  156. }
  157. return func(p *project.Project) error {
  158. exists := false
  159. for _, s := range p.Sequences() {
  160. exists = exists || s.Slot == slot
  161. }
  162. if !exists {
  163. if err := p.AddSequence(slot); err != nil {
  164. return err
  165. }
  166. }
  167. return p.SetSequence(slot, parts[0], bpm, bars)
  168. }, nil
  169. })
  170. addOp("rmseq", "remove a sequence: `N`", func(arg string) (func(*project.Project) error, error) {
  171. slot, err := parseSeqNum(arg)
  172. return func(p *project.Project) error { return p.RemoveSequence(slot) }, err
  173. })
  174. }
  175. func main() {
  176. load := flag.String("load", "", "start from an existing `project.xpj` instead of a blank project")
  177. name := flag.String("name", "", "project `name` (default: the loaded project's name)")
  178. out := flag.String("out", "", "export to this `folder`")
  179. zipPath := flag.String("zip", "", "export to this zip `file`")
  180. force := flag.Bool("force", false, "replace an existing export")
  181. flag.Usage = func() {
  182. fmt.Fprintf(os.Stderr, "usage: mpckit-cli [-load FILE] -name NAME (-out DIR | -zip FILE) [edits...]\n\nEdits run in the order given and may repeat.\n\n")
  183. flag.PrintDefaults()
  184. }
  185. flag.Parse()
  186. if flag.NArg() > 0 {
  187. fail(fmt.Errorf("unexpected argument %q", flag.Arg(0)))
  188. }
  189. if (*out == "") == (*zipPath == "") {
  190. fail(errors.New("give exactly one of -out or -zip"))
  191. }
  192. var p *project.Project
  193. var err error
  194. if *load != "" {
  195. var warnings []string
  196. p, warnings, err = project.Load(*load)
  197. for _, w := range warnings {
  198. fmt.Fprintln(os.Stderr, "warning:", w)
  199. }
  200. } else {
  201. if *name == "" {
  202. fail(errors.New("-name is required for a new project"))
  203. }
  204. p, err = project.New(*name)
  205. }
  206. if err != nil {
  207. fail(err)
  208. }
  209. if *name != "" {
  210. if err := p.SetName(*name); err != nil {
  211. fail(err)
  212. }
  213. }
  214. for _, o := range ops {
  215. if err := o.run(p); err != nil {
  216. fail(fmt.Errorf("-%s %s: %w", o.flag, o.arg, err))
  217. }
  218. }
  219. var dest string
  220. if *out != "" {
  221. dest, err = export.Folder(p, *out, *force)
  222. } else {
  223. dest, err = *zipPath, export.Zip(p, *zipPath, *force)
  224. }
  225. var exists *export.ExistsError
  226. if errors.As(err, &exists) {
  227. fail(fmt.Errorf("%w (use -force to replace)", err))
  228. }
  229. if err != nil {
  230. fail(err)
  231. }
  232. summary(p)
  233. fmt.Println("wrote", dest)
  234. }
  235. func summary(p *project.Project) {
  236. fmt.Printf("%s: %d samples\n", p.Name, p.SampleCount())
  237. for slot := 0; slot < project.Pads; slot++ {
  238. v, _ := p.Pad(slot)
  239. if v.Empty() {
  240. continue
  241. }
  242. fmt.Printf(" %-4s %s", v.Label, v.File)
  243. if v.Coarse != 0 || v.Fine != 0 {
  244. fmt.Printf(" tune %+d st %+d ct", v.Coarse, v.Fine)
  245. }
  246. if v.MuteGroup != 0 {
  247. fmt.Printf(" mute group %d", v.MuteGroup)
  248. }
  249. fmt.Println()
  250. }
  251. for _, s := range p.Sequences() {
  252. fmt.Printf(" seq %-3d %s, %g BPM, %d bars\n", s.Slot+1, s.Name, s.BPM, s.Bars)
  253. }
  254. }
  255. func fail(err error) {
  256. fmt.Fprintln(os.Stderr, "mpckit-cli:", err)
  257. os.Exit(1)
  258. }