main.go 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344
  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. // opFunc is one change to the project, made from a flag's value.
  19. type opFunc func(p *project.Project) error
  20. // parseFunc turns a flag's value into an opFunc, or explains what is wrong.
  21. type parseFunc func(arg string) (opFunc, error)
  22. type op struct {
  23. flag, arg string
  24. run opFunc
  25. }
  26. var ops []op
  27. // opFlag is a repeatable flag whose values become ops.
  28. type opFlag struct {
  29. name string
  30. parse parseFunc
  31. }
  32. func (f opFlag) String() string { return "" }
  33. func (f opFlag) Set(arg string) error {
  34. run, err := f.parse(arg)
  35. if err != nil {
  36. return err
  37. }
  38. ops = append(ops, op{flag: f.name, arg: arg, run: run})
  39. return nil
  40. }
  41. func addOp(name, usage string, parse parseFunc) {
  42. flag.Var(opFlag{name: name, parse: parse}, name, usage)
  43. }
  44. // parseSlot reads a pad label such as A1 or h16.
  45. func parseSlot(s string) (int, error) {
  46. s = strings.ToUpper(strings.TrimSpace(s))
  47. if len(s) < 2 || s[0] < 'A' || s[0] > 'H' {
  48. return 0, fmt.Errorf("bad pad %q: want A1-H16", s)
  49. }
  50. n, err := strconv.Atoi(s[1:])
  51. if err != nil || n < 1 || n > 16 {
  52. return 0, fmt.Errorf("bad pad %q: want A1-H16", s)
  53. }
  54. return int(s[0]-'A')*16 + n - 1, nil
  55. }
  56. func parsePair(arg string) (int, int, error) {
  57. a, b, ok := strings.Cut(arg, ":")
  58. if !ok {
  59. return 0, 0, fmt.Errorf("want FROM:TO, e.g. A1:B5")
  60. }
  61. from, err := parseSlot(a)
  62. if err != nil {
  63. return 0, 0, err
  64. }
  65. to, err := parseSlot(b)
  66. return from, to, err
  67. }
  68. func parseSeqNum(s string) (int, error) {
  69. n, err := strconv.Atoi(strings.TrimSpace(s))
  70. if err != nil || n < 1 || n > project.MaxSequences {
  71. return 0, fmt.Errorf("bad sequence number %q: want 1-%d", s, project.MaxSequences)
  72. }
  73. return n - 1, nil
  74. }
  75. func init() {
  76. for _, f := range []struct {
  77. name, usage string
  78. parse parseFunc
  79. }{
  80. {"pad", "assign a WAV: `PAD=FILE` (e.g. A1=kick.wav)", parsePadOp},
  81. {"tune", "tune a pad: `PAD=SEMITONES,CENTS` (e.g. B1=-24,0)", parseTuneOp},
  82. {"mute", "set a pad's mute group: `PAD=GROUP` (0 is off)", parseMuteOp},
  83. {"clear", "clear a pad: `PAD`", parseClearOp},
  84. {"copy", "copy pads: `FROM:TO`", parseCopyOp},
  85. {"move", "move pads: `FROM:TO`", parseMoveOp},
  86. {"swap", "swap pads: `FROM:TO`", parseSwapOp},
  87. {"seq", "set a sequence, adding it if needed: `N=NAME,BPM,BARS` (N is 1-128)", parseSeqOp},
  88. {"rmseq", "remove a sequence: `N`", parseRmseqOp},
  89. } {
  90. addOp(f.name, f.usage, f.parse)
  91. }
  92. }
  93. // parsePadOp reads -pad PAD=FILE.
  94. func parsePadOp(arg string) (opFunc, error) {
  95. label, file, ok := strings.Cut(arg, "=")
  96. if !ok {
  97. return nil, errors.New("want PAD=FILE")
  98. }
  99. slot, err := parseSlot(label)
  100. if err != nil {
  101. return nil, err
  102. }
  103. return func(p *project.Project) error { return p.AssignSample(slot, file) }, nil
  104. }
  105. // parseTuneOp reads -tune PAD=SEMITONES,CENTS.
  106. func parseTuneOp(arg string) (opFunc, error) {
  107. label, val, ok := strings.Cut(arg, "=")
  108. c, f, ok2 := strings.Cut(val, ",")
  109. if !ok || !ok2 {
  110. return nil, errors.New("want PAD=SEMITONES,CENTS")
  111. }
  112. slot, err := parseSlot(label)
  113. if err != nil {
  114. return nil, err
  115. }
  116. coarse, err1 := strconv.Atoi(strings.TrimSpace(c))
  117. fine, err2 := strconv.Atoi(strings.TrimSpace(f))
  118. if err1 != nil || err2 != nil {
  119. return nil, errors.New("semitones and cents must be integers")
  120. }
  121. if coarse < project.MinCoarse || coarse > project.MaxCoarse || fine < project.MinFine || fine > project.MaxFine {
  122. return nil, fmt.Errorf("tuning out of range (%d..%d semitones, %d..%d cents)",
  123. project.MinCoarse, project.MaxCoarse, project.MinFine, project.MaxFine)
  124. }
  125. return func(p *project.Project) error { return p.SetTune(slot, coarse, fine) }, nil
  126. }
  127. // parseMuteOp reads -mute PAD=GROUP.
  128. func parseMuteOp(arg string) (opFunc, error) {
  129. label, val, ok := strings.Cut(arg, "=")
  130. if !ok {
  131. return nil, errors.New("want PAD=GROUP")
  132. }
  133. slot, err := parseSlot(label)
  134. if err != nil {
  135. return nil, err
  136. }
  137. group, err := strconv.Atoi(val)
  138. if err != nil {
  139. return nil, errors.New("group must be an integer")
  140. }
  141. return func(p *project.Project) error { return p.SetMuteGroup(slot, group) }, nil
  142. }
  143. // parseClearOp reads -clear PAD.
  144. func parseClearOp(arg string) (opFunc, error) {
  145. slot, err := parseSlot(arg)
  146. if err != nil {
  147. return nil, err
  148. }
  149. return func(p *project.Project) error { return p.ClearPad(slot) }, nil
  150. }
  151. // parseCopyOp reads -copy FROM:TO.
  152. func parseCopyOp(arg string) (opFunc, error) {
  153. from, to, err := parsePair(arg)
  154. if err != nil {
  155. return nil, err
  156. }
  157. return func(p *project.Project) error { return p.CopyPad(from, to) }, nil
  158. }
  159. // parseMoveOp reads -move FROM:TO. Recorded notes stay on FROM, as in the
  160. // app, so it warns when there are any.
  161. func parseMoveOp(arg string) (opFunc, error) {
  162. from, to, err := parsePair(arg)
  163. if err != nil {
  164. return nil, err
  165. }
  166. return func(p *project.Project) error {
  167. if p.PadHasNotes(from) {
  168. fmt.Fprintf(os.Stderr, "warning: %s has recorded notes; they stay on %s\n",
  169. project.SlotName(from), project.SlotName(from))
  170. }
  171. return p.MovePad(from, to)
  172. }, nil
  173. }
  174. // parseSwapOp reads -swap FROM:TO.
  175. func parseSwapOp(arg string) (opFunc, error) {
  176. from, to, err := parsePair(arg)
  177. if err != nil {
  178. return nil, err
  179. }
  180. return func(p *project.Project) error { return p.SwapPads(from, to) }, nil
  181. }
  182. // parseSeqOp reads -seq N=NAME,BPM,BARS. The sequence is added first if
  183. // slot N is empty.
  184. func parseSeqOp(arg string) (opFunc, error) {
  185. num, val, ok := strings.Cut(arg, "=")
  186. parts := strings.Split(val, ",")
  187. if !ok || len(parts) != 3 {
  188. return nil, errors.New("want N=NAME,BPM,BARS")
  189. }
  190. slot, err := parseSeqNum(num)
  191. if err != nil {
  192. return nil, err
  193. }
  194. bpm, err1 := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64)
  195. bars, err2 := strconv.Atoi(strings.TrimSpace(parts[2]))
  196. if err1 != nil || err2 != nil {
  197. return nil, errors.New("BPM must be a number and BARS an integer")
  198. }
  199. return func(p *project.Project) error {
  200. exists := false
  201. for _, seq := range p.Sequences() {
  202. exists = exists || seq.Slot == slot
  203. }
  204. if !exists {
  205. if err := p.AddSequence(slot); err != nil {
  206. return err
  207. }
  208. }
  209. return p.SetSequence(slot, parts[0], bpm, bars)
  210. }, nil
  211. }
  212. // parseRmseqOp reads -rmseq N.
  213. func parseRmseqOp(arg string) (opFunc, error) {
  214. slot, err := parseSeqNum(arg)
  215. if err != nil {
  216. return nil, err
  217. }
  218. return func(p *project.Project) error { return p.RemoveSequence(slot) }, nil
  219. }
  220. func main() {
  221. load := flag.String("load", "", "start from an existing `project.xpj` instead of a blank project")
  222. name := flag.String("name", "", "project `name` (default: the loaded project's name)")
  223. out := flag.String("out", "", "export to this `folder`")
  224. zipPath := flag.String("zip", "", "export to this zip `file`")
  225. force := flag.Bool("force", false, "replace an existing export")
  226. flag.Usage = func() {
  227. 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")
  228. flag.PrintDefaults()
  229. }
  230. flag.Parse()
  231. if flag.NArg() > 0 {
  232. fail(fmt.Errorf("unexpected argument %q", flag.Arg(0)))
  233. }
  234. if (*out == "") == (*zipPath == "") {
  235. fail(errors.New("give exactly one of -out or -zip"))
  236. }
  237. p := openProject(*load, *name)
  238. for _, o := range ops {
  239. if err := o.run(p); err != nil {
  240. fail(fmt.Errorf("-%s %s: %w", o.flag, o.arg, err))
  241. }
  242. }
  243. dest := write(p, *out, *zipPath, *force)
  244. summary(p)
  245. fmt.Println("wrote", dest)
  246. }
  247. // openProject loads the project at load, or starts a blank one, and names
  248. // it. It exits on error.
  249. func openProject(load, name string) *project.Project {
  250. var p *project.Project
  251. var err error
  252. if load != "" {
  253. var warnings []string
  254. p, warnings, err = project.Load(load)
  255. for _, w := range warnings {
  256. fmt.Fprintln(os.Stderr, "warning:", w)
  257. }
  258. } else {
  259. if name == "" {
  260. fail(errors.New("-name is required for a new project"))
  261. }
  262. p, err = project.New(name)
  263. }
  264. if err != nil {
  265. fail(err)
  266. }
  267. if name != "" {
  268. if err := p.SetName(name); err != nil {
  269. fail(err)
  270. }
  271. }
  272. return p
  273. }
  274. // write exports p to the folder out or the zip file zipPath, whichever is
  275. // set, and returns where it went. It exits on error.
  276. func write(p *project.Project, out, zipPath string, force bool) string {
  277. var dest string
  278. var err error
  279. if out != "" {
  280. dest, err = export.Folder(p, out, force)
  281. } else {
  282. dest, err = zipPath, export.Zip(p, zipPath, force)
  283. }
  284. var exists *export.ExistsError
  285. if errors.As(err, &exists) {
  286. fail(fmt.Errorf("%w (use -force to replace)", err))
  287. }
  288. if err != nil {
  289. fail(err)
  290. }
  291. return dest
  292. }
  293. func summary(p *project.Project) {
  294. fmt.Printf("%s: %d samples\n", p.Name, p.SampleCount())
  295. for slot := 0; slot < project.Pads; slot++ {
  296. v, _ := p.Pad(slot)
  297. if v.Empty() {
  298. continue
  299. }
  300. fmt.Printf(" %-4s %s", v.Label, v.File)
  301. if v.Coarse != 0 || v.Fine != 0 {
  302. fmt.Printf(" tune %+d st %+d ct", v.Coarse, v.Fine)
  303. }
  304. if v.MuteGroup != 0 {
  305. fmt.Printf(" mute group %d", v.MuteGroup)
  306. }
  307. fmt.Println()
  308. }
  309. for _, s := range p.Sequences() {
  310. fmt.Printf(" seq %-3d %s, %g BPM, %d bars\n", s.Slot+1, s.Name, s.BPM, s.Bars)
  311. }
  312. }
  313. func fail(err error) {
  314. fmt.Fprintln(os.Stderr, "mpckit-cli:", err)
  315. os.Exit(1)
  316. }