1
0

pads.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452
  1. package project
  2. import (
  3. "fmt"
  4. "path/filepath"
  5. "strings"
  6. "unicode"
  7. "git.monoexp.com/bajdev/mpckit/internal/wav"
  8. "git.monoexp.com/bajdev/mpckit/internal/xpj"
  9. )
  10. // Values the device writes when it assigns a sample with no tempo or key of
  11. // its own (format doc section 3, build plan 4.3).
  12. const (
  13. padTempo = 20.0
  14. newSampleKey = "C Natural Minor"
  15. newSampleRoot = 60
  16. crossfadeLength = -1
  17. )
  18. // Pad is a read-only view of one pad.
  19. type Pad struct {
  20. Slot int
  21. Label string // "A1" .. "H16"
  22. SampleName string // empty for an empty pad
  23. File string // pool file name
  24. Coarse int
  25. Fine int
  26. MuteGroup int
  27. Start, End int64 // slice start and end frame, for the preview
  28. }
  29. // Empty reports whether the pad has no sample.
  30. func (p Pad) Empty() bool { return p.File == "" }
  31. // Pad returns the pad in slot.
  32. func (p *Project) Pad(slot int) (Pad, error) {
  33. if err := checkSlot(slot); err != nil {
  34. return Pad{}, err
  35. }
  36. pad := p.pad(slot)
  37. l, err := layer(pad)
  38. if err != nil {
  39. return Pad{}, fmt.Errorf("project: pad %s: %w", SlotName(slot), err)
  40. }
  41. v := Pad{Slot: slot, Label: SlotName(slot)}
  42. v.SampleName = text(l.Lookup("sampleName"))
  43. v.File = text(l.Lookup("sampleFile"))
  44. v.Coarse = int(num(l.Lookup("coarseTune")))
  45. v.Fine = int(num(l.Lookup("fineTune")))
  46. v.MuteGroup = int(num(pad.Lookup("whichMuteGroup")))
  47. if si := l.Lookup("sliceInfo"); si != nil {
  48. v.Start = num(si.Lookup("Start"))
  49. v.End = num(si.Lookup("End"))
  50. }
  51. return v, nil
  52. }
  53. func text(n *xpj.Node) string {
  54. if n == nil || n.Kind != xpj.String {
  55. return ""
  56. }
  57. return n.Text
  58. }
  59. // num and float return a number field's value, or 0 if it is missing or
  60. // not a number of that kind.
  61. func num(n *xpj.Node) int64 {
  62. i, _ := n.Int()
  63. return i
  64. }
  65. func float(n *xpj.Node) float64 {
  66. f, _ := n.Float()
  67. return f
  68. }
  69. // padFiles returns the pool files used by any layer of pad.
  70. func padFiles(pad *xpj.Node) []string {
  71. var files []string
  72. layers := pad.Lookup("layersv")
  73. if layers == nil {
  74. return nil
  75. }
  76. for _, l := range layers.Items {
  77. if f := text(l.Lookup("sampleFile")); f != "" {
  78. files = append(files, f)
  79. }
  80. }
  81. return files
  82. }
  83. // usage counts the pad layers that use the pool file.
  84. func (p *Project) usage(file string) int {
  85. n := 0
  86. for _, pad := range p.pads().Items {
  87. for _, f := range padFiles(pad) {
  88. if f == file {
  89. n++
  90. }
  91. }
  92. }
  93. return n
  94. }
  95. // SampleCount returns the number of pool entries.
  96. func (p *Project) SampleCount() int { return len(p.pool().Items) }
  97. // AssignSample puts the WAV at src on the pad in slot, replacing what was
  98. // there. A file already in the pool from the same source is reused; a
  99. // different file with a name already in the pool gets a 001, 002 suffix.
  100. func (p *Project) AssignSample(slot int, src string) error {
  101. if err := checkSlot(slot); err != nil {
  102. return err
  103. }
  104. abs, err := filepath.Abs(src)
  105. if err != nil {
  106. return err
  107. }
  108. name := filepath.Base(abs)
  109. // Export refuses a source that is a link, so that a link planted in a
  110. // shared kit's data folder cannot pull in other files. A link the user
  111. // picks themselves is fine: record the file it points to.
  112. target, err := filepath.EvalSymlinks(abs)
  113. if err != nil {
  114. return fmt.Errorf("%s: %w", name, err)
  115. }
  116. info, err := wav.ReadFile(target)
  117. if err != nil {
  118. return fmt.Errorf("%s: %w", name, err)
  119. }
  120. if err := info.Validate(); err != nil {
  121. return fmt.Errorf("%s: %w", name, err)
  122. }
  123. // Work out every change before making any, so that a failure leaves
  124. // the project as it was. Reuse the pool entry already copied from this
  125. // file, even if this pad was its only user.
  126. file := p.fileForSource(target)
  127. freed := p.freedBy(slot, file)
  128. newFile := file
  129. if file == "" {
  130. if p.SampleCount()-len(freed) >= MaxSamples {
  131. return fmt.Errorf("the project already has %d samples, the most the device allows", MaxSamples)
  132. }
  133. newFile = p.uniqueFile(poolFileName(name), freed)
  134. }
  135. pad := template.pad.Clone()
  136. l, err := layer(pad)
  137. if err != nil {
  138. return err
  139. }
  140. var e setter
  141. e.set(pad, "tempo", xpj.NewFloat(padTempo))
  142. e.set(l, "sampleName", xpj.NewString(stem(newFile)))
  143. e.set(l, "sampleFile", xpj.NewString(newFile))
  144. e.set(l, "sliceInfo.End", xpj.NewInt(info.Frames-1))
  145. e.set(l, "sliceInfo.LoopCrossfadeLength", xpj.NewInt(crossfadeLength))
  146. if e.err != nil {
  147. return e.err
  148. }
  149. // Apply. None of these steps can fail: fromFile checked that the pool
  150. // and the pads are lists.
  151. p.clear(slot, file)
  152. p.pads().Items[slot] = pad
  153. if file == "" {
  154. p.addPoolEntry(newFile)
  155. p.sources[newFile] = target
  156. }
  157. p.dirty = true
  158. return nil
  159. }
  160. // freedBy returns the pool files that clearing the pad in slot would drop:
  161. // those no other pad uses, except keep.
  162. func (p *Project) freedBy(slot int, keep string) map[string]bool {
  163. here := map[string]int{} // layers of this pad using each file
  164. for _, f := range padFiles(p.pad(slot)) {
  165. here[f]++
  166. }
  167. freed := map[string]bool{}
  168. for f, n := range here {
  169. if f != keep && p.usage(f) == n {
  170. freed[f] = true
  171. }
  172. }
  173. return freed
  174. }
  175. // fileForSource returns the pool file already copied from src, if any.
  176. func (p *Project) fileForSource(src string) string {
  177. for file, s := range p.sources {
  178. if s == src && p.inPool(file) {
  179. return file
  180. }
  181. }
  182. return ""
  183. }
  184. func (p *Project) inPool(file string) bool {
  185. for _, e := range p.pool().Items {
  186. if text(e.Lookup("path")) == file {
  187. return true
  188. }
  189. }
  190. return false
  191. }
  192. // poolFileName turns the name of a WAV the user picked into a pool file
  193. // name that passes PlainFileName on every system and is legal on the
  194. // device's FAT card. Linux and macOS allow names such as "kick\one.wav",
  195. // "a:b.wav" or "con.wav"; exported as they are, the backslash reads as a
  196. // folder on Windows and the card, the colon cannot be written to the card,
  197. // Windows cannot create a file named after a device, and Load refuses the
  198. // project. Each such character becomes "_", and a device name gets "_"
  199. // added ("con.wav" becomes "con_.wav").
  200. func poolFileName(name string) string {
  201. clean := strings.Map(func(r rune) rune {
  202. if unicode.IsControl(r) || strings.ContainsRune(`/\:*?"<>|`, r) {
  203. return '_'
  204. }
  205. return r
  206. }, name)
  207. if clean == "" || clean == "." || clean == ".." {
  208. return "sample.wav"
  209. }
  210. if reservedOnWindows(clean) {
  211. base, ext, _ := strings.Cut(clean, ".")
  212. clean = base + "_"
  213. if ext != "" {
  214. clean += "." + ext
  215. }
  216. }
  217. return clean
  218. }
  219. func stem(file string) string { return strings.TrimSuffix(file, filepath.Ext(file)) }
  220. // uniqueFile returns base, or base with a 001, 002 ... suffix before the
  221. // extension, so that no two pool files share a file name or a sample name
  222. // (the file name without its extension). Names are compared without case,
  223. // as on Windows, macOS and FAT.
  224. func (p *Project) uniqueFile(base string, freed map[string]bool) string {
  225. takenFiles := map[string]bool{}
  226. takenNames := map[string]bool{}
  227. for _, e := range p.pool().Items {
  228. if freed[text(e.Lookup("path"))] {
  229. continue // about to be dropped
  230. }
  231. takenFiles[strings.ToLower(text(e.Lookup("path")))] = true
  232. takenNames[strings.ToLower(text(e.Lookup("name")))] = true
  233. }
  234. free := func(f string) bool {
  235. return !takenFiles[strings.ToLower(f)] && !takenNames[strings.ToLower(stem(f))]
  236. }
  237. if free(base) {
  238. return base
  239. }
  240. ext := filepath.Ext(base)
  241. for i := 1; ; i++ {
  242. f := fmt.Sprintf("%s%03d%s", stem(base), i, ext)
  243. if free(f) {
  244. return f
  245. }
  246. }
  247. }
  248. // poolEntry builds a new pool entry in the device's key order.
  249. func poolEntry(file string) *xpj.Node {
  250. meta := xpj.NewObject()
  251. meta.Members = []xpj.Member{
  252. {Key: "tempo", Value: xpj.NewFloat(0)},
  253. {Key: "rootNote", Value: xpj.NewInt(newSampleRoot)},
  254. {Key: "tune", Value: xpj.NewFloat(0)},
  255. {Key: "key", Value: xpj.NewString(newSampleKey)},
  256. }
  257. e := xpj.NewObject()
  258. e.Members = []xpj.Member{
  259. {Key: "version", Value: xpj.NewInt(1)},
  260. {Key: "name", Value: xpj.NewString(stem(file))},
  261. {Key: "path", Value: xpj.NewString(file)},
  262. {Key: "loadImpl", Value: xpj.NewInt(0)},
  263. {Key: "metadata", Value: meta},
  264. }
  265. return e
  266. }
  267. // addPoolEntry adds file to both pools. Both are lists (checked by fromFile),
  268. // so this cannot fail.
  269. func (p *Project) addPoolEntry(file string) {
  270. _ = p.pool().Append(poolEntry(file))
  271. _ = p.trackPool().Insert(0, poolEntry(file))
  272. }
  273. func (p *Project) removePoolEntry(file string) {
  274. for _, arr := range []*xpj.Node{p.pool(), p.trackPool()} {
  275. for i := len(arr.Items) - 1; i >= 0; i-- {
  276. if text(arr.Items[i].Lookup("path")) == file {
  277. _ = arr.Remove(i)
  278. }
  279. }
  280. }
  281. delete(p.sources, file)
  282. }
  283. // ClearPad resets the pad in slot to the blank pad and drops pool entries
  284. // no other pad uses.
  285. func (p *Project) ClearPad(slot int) error {
  286. if err := checkSlot(slot); err != nil {
  287. return err
  288. }
  289. if len(padFiles(p.pad(slot))) == 0 {
  290. return nil
  291. }
  292. p.clear(slot, "")
  293. p.dirty = true
  294. return nil
  295. }
  296. // clear resets the pad in slot and drops pool entries left unused, except keep.
  297. func (p *Project) clear(slot int, keep string) {
  298. files := padFiles(p.pad(slot))
  299. p.pads().Items[slot] = template.pad.Clone()
  300. for _, f := range files {
  301. if f != keep && p.usage(f) == 0 {
  302. p.removePoolEntry(f)
  303. }
  304. }
  305. }
  306. // CopyPad copies the whole pad in from onto to, replacing what was there.
  307. // Both pads share the pool entry.
  308. func (p *Project) CopyPad(from, to int) error {
  309. if err := checkSlot(from); err != nil {
  310. return err
  311. }
  312. if err := checkSlot(to); err != nil {
  313. return err
  314. }
  315. if from == to {
  316. return nil
  317. }
  318. src := p.pad(from).Clone()
  319. p.clear(to, "")
  320. p.pads().Items[to] = src
  321. p.dirty = true
  322. return nil
  323. }
  324. // MovePad copies from onto to, then resets from to the blank pad. Recorded
  325. // notes stay on their slot; check PadHasNotes and warn first.
  326. func (p *Project) MovePad(from, to int) error {
  327. if from == to {
  328. return checkSlot(from)
  329. }
  330. if err := p.CopyPad(from, to); err != nil {
  331. return err
  332. }
  333. p.pads().Items[from] = template.pad.Clone()
  334. return nil
  335. }
  336. // SwapPads exchanges two pads.
  337. func (p *Project) SwapPads(a, b int) error {
  338. if err := checkSlot(a); err != nil {
  339. return err
  340. }
  341. if err := checkSlot(b); err != nil {
  342. return err
  343. }
  344. if a == b {
  345. return nil
  346. }
  347. items := p.pads().Items
  348. items[a], items[b] = items[b], items[a]
  349. p.dirty = true
  350. return nil
  351. }
  352. // SetTune sets the pad's tuning, clamped to -24..+24 semitones and
  353. // -90..+90 cents. pitch is stored as a 32-bit float.
  354. func (p *Project) SetTune(slot, coarse, fine int) error {
  355. if err := checkSlot(slot); err != nil {
  356. return err
  357. }
  358. coarse = max(MinCoarse, min(MaxCoarse, coarse))
  359. fine = max(MinFine, min(MaxFine, fine))
  360. l, err := layer(p.pad(slot))
  361. if err != nil {
  362. return err
  363. }
  364. if l.Kind != xpj.Object {
  365. return fmt.Errorf("project: pad %s layer 1 is %s, not an object", SlotName(slot), l.Kind)
  366. }
  367. // Setting keys on an object cannot fail, so all three are written.
  368. var e setter
  369. e.set(l, "coarseTune", xpj.NewInt(int64(coarse)))
  370. e.set(l, "fineTune", xpj.NewInt(int64(fine)))
  371. e.set(l, "pitch", xpj.NewFloat32(float64(coarse)+float64(fine)/100))
  372. if e.err != nil {
  373. return e.err
  374. }
  375. p.dirty = true
  376. return nil
  377. }
  378. // SetMuteGroup sets the pad's mute group; 0 is off.
  379. func (p *Project) SetMuteGroup(slot, group int) error {
  380. if err := checkSlot(slot); err != nil {
  381. return err
  382. }
  383. if group < 0 || group > MaxMuteGroup {
  384. return fmt.Errorf("mute group %d out of range 0-%d", group, MaxMuteGroup)
  385. }
  386. if err := p.pad(slot).SetKey("whichMuteGroup", xpj.NewInt(int64(group))); err != nil {
  387. return fmt.Errorf("project: pad %s: %w", SlotName(slot), err)
  388. }
  389. p.dirty = true
  390. return nil
  391. }
  392. // PadHasNotes reports whether any sequence has a note or automation event
  393. // for the pad in slot (note 36 + slot on the drum track).
  394. func (p *Project) PadHasNotes(slot int) bool {
  395. note := int64(FirstNote + slot)
  396. found := false
  397. p.eachDrumEvent(func(ev *xpj.Node) bool {
  398. var n *xpj.Node
  399. switch num(ev.Lookup("type")) {
  400. case eventNote:
  401. n = ev.Lookup("note")
  402. case eventAutomation:
  403. n = ev.Lookup("automation")
  404. }
  405. if n != nil && num(n.Lookup("note")) == note {
  406. found = true
  407. }
  408. return !found
  409. })
  410. return found
  411. }
  412. // PoolFiles returns the file name of every pool entry, in pool order.
  413. func (p *Project) PoolFiles() []string {
  414. var files []string
  415. for _, e := range p.pool().Items {
  416. files = append(files, text(e.Lookup("path")))
  417. }
  418. return files
  419. }