1
0

pads.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450
  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 edit
  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: wrap 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 name. Names are compared
  222. // without case, as on Windows, macOS and FAT.
  223. func (p *Project) uniqueFile(base string, freed map[string]bool) string {
  224. taken := map[string]bool{}
  225. for _, e := range p.pool().Items {
  226. if freed[text(e.Lookup("path"))] {
  227. continue // about to be dropped
  228. }
  229. taken[strings.ToLower(text(e.Lookup("path")))] = true
  230. taken[strings.ToLower(text(e.Lookup("name")))+"\x00"] = true
  231. }
  232. free := func(f string) bool {
  233. return !taken[strings.ToLower(f)] && !taken[strings.ToLower(stem(f))+"\x00"]
  234. }
  235. if free(base) {
  236. return base
  237. }
  238. ext := filepath.Ext(base)
  239. for i := 1; ; i++ {
  240. f := fmt.Sprintf("%s%03d%s", stem(base), i, ext)
  241. if free(f) {
  242. return f
  243. }
  244. }
  245. }
  246. // poolEntry builds a new pool entry in the device's key order.
  247. func poolEntry(file string) *xpj.Node {
  248. meta := xpj.NewObject()
  249. meta.Members = []xpj.Member{
  250. {Key: "tempo", Value: xpj.NewFloat(0)},
  251. {Key: "rootNote", Value: xpj.NewInt(newSampleRoot)},
  252. {Key: "tune", Value: xpj.NewFloat(0)},
  253. {Key: "key", Value: xpj.NewString(newSampleKey)},
  254. }
  255. e := xpj.NewObject()
  256. e.Members = []xpj.Member{
  257. {Key: "version", Value: xpj.NewInt(1)},
  258. {Key: "name", Value: xpj.NewString(stem(file))},
  259. {Key: "path", Value: xpj.NewString(file)},
  260. {Key: "loadImpl", Value: xpj.NewInt(0)},
  261. {Key: "metadata", Value: meta},
  262. }
  263. return e
  264. }
  265. // addPoolEntry adds file to both pools. Both are lists (checked by wrap),
  266. // so this cannot fail.
  267. func (p *Project) addPoolEntry(file string) {
  268. _ = p.pool().Append(poolEntry(file))
  269. _ = p.trackPool().Insert(0, poolEntry(file))
  270. }
  271. func (p *Project) removePoolEntry(file string) {
  272. for _, arr := range []*xpj.Node{p.pool(), p.trackPool()} {
  273. for i := len(arr.Items) - 1; i >= 0; i-- {
  274. if text(arr.Items[i].Lookup("path")) == file {
  275. _ = arr.Remove(i)
  276. }
  277. }
  278. }
  279. delete(p.sources, file)
  280. }
  281. // ClearPad resets the pad in slot to the blank pad and drops pool entries
  282. // no other pad uses.
  283. func (p *Project) ClearPad(slot int) error {
  284. if err := checkSlot(slot); err != nil {
  285. return err
  286. }
  287. if len(padFiles(p.pad(slot))) == 0 {
  288. return nil
  289. }
  290. p.clear(slot, "")
  291. p.dirty = true
  292. return nil
  293. }
  294. // clear resets the pad in slot and drops pool entries left unused, except keep.
  295. func (p *Project) clear(slot int, keep string) {
  296. files := padFiles(p.pad(slot))
  297. p.pads().Items[slot] = template.pad.Clone()
  298. for _, f := range files {
  299. if f != keep && p.usage(f) == 0 {
  300. p.removePoolEntry(f)
  301. }
  302. }
  303. }
  304. // CopyPad copies the whole pad in from onto to, replacing what was there.
  305. // Both pads share the pool entry.
  306. func (p *Project) CopyPad(from, to int) error {
  307. if err := checkSlot(from); err != nil {
  308. return err
  309. }
  310. if err := checkSlot(to); err != nil {
  311. return err
  312. }
  313. if from == to {
  314. return nil
  315. }
  316. src := p.pad(from).Clone()
  317. p.clear(to, "")
  318. p.pads().Items[to] = src
  319. p.dirty = true
  320. return nil
  321. }
  322. // MovePad copies from onto to, then resets from to the blank pad. Recorded
  323. // notes stay on their slot; check PadHasNotes and warn first.
  324. func (p *Project) MovePad(from, to int) error {
  325. if from == to {
  326. return checkSlot(from)
  327. }
  328. if err := p.CopyPad(from, to); err != nil {
  329. return err
  330. }
  331. p.pads().Items[from] = template.pad.Clone()
  332. return nil
  333. }
  334. // SwapPads exchanges two pads.
  335. func (p *Project) SwapPads(a, b int) error {
  336. if err := checkSlot(a); err != nil {
  337. return err
  338. }
  339. if err := checkSlot(b); err != nil {
  340. return err
  341. }
  342. if a == b {
  343. return nil
  344. }
  345. items := p.pads().Items
  346. items[a], items[b] = items[b], items[a]
  347. p.dirty = true
  348. return nil
  349. }
  350. // SetTune sets the pad's tuning, clamped to -24..+24 semitones and
  351. // -90..+90 cents. pitch is stored as a 32-bit float.
  352. func (p *Project) SetTune(slot, coarse, fine int) error {
  353. if err := checkSlot(slot); err != nil {
  354. return err
  355. }
  356. coarse = max(MinCoarse, min(MaxCoarse, coarse))
  357. fine = max(MinFine, min(MaxFine, fine))
  358. l, err := layer(p.pad(slot))
  359. if err != nil {
  360. return err
  361. }
  362. if l.Kind != xpj.Object {
  363. return fmt.Errorf("project: pad %s layer 1 is %s, not an object", SlotName(slot), l.Kind)
  364. }
  365. // Setting keys on an object cannot fail, so all three are written.
  366. var e edit
  367. e.set(l, "coarseTune", xpj.NewInt(int64(coarse)))
  368. e.set(l, "fineTune", xpj.NewInt(int64(fine)))
  369. e.set(l, "pitch", xpj.NewFloat32(float64(coarse)+float64(fine)/100))
  370. if e.err != nil {
  371. return e.err
  372. }
  373. p.dirty = true
  374. return nil
  375. }
  376. // SetMuteGroup sets the pad's mute group; 0 is off.
  377. func (p *Project) SetMuteGroup(slot, group int) error {
  378. if err := checkSlot(slot); err != nil {
  379. return err
  380. }
  381. if group < 0 || group > MaxMuteGroup {
  382. return fmt.Errorf("mute group %d out of range 0-%d", group, MaxMuteGroup)
  383. }
  384. if err := p.pad(slot).SetKey("whichMuteGroup", xpj.NewInt(int64(group))); err != nil {
  385. return fmt.Errorf("project: pad %s: %w", SlotName(slot), err)
  386. }
  387. p.dirty = true
  388. return nil
  389. }
  390. // PadHasNotes reports whether any sequence has a note or automation event
  391. // for the pad in slot (note 36 + slot on the drum track).
  392. func (p *Project) PadHasNotes(slot int) bool {
  393. note := int64(FirstNote + slot)
  394. found := false
  395. p.eachDrumEvent(func(ev *xpj.Node) bool {
  396. var n *xpj.Node
  397. switch num(ev.Lookup("type")) {
  398. case eventNote:
  399. n = ev.Lookup("note")
  400. case eventAutomation:
  401. n = ev.Lookup("automation")
  402. }
  403. if n != nil && num(n.Lookup("note")) == note {
  404. found = true
  405. }
  406. return !found
  407. })
  408. return found
  409. }
  410. // PoolFiles returns the file name of every pool entry, in pool order.
  411. func (p *Project) PoolFiles() []string {
  412. var files []string
  413. for _, e := range p.pool().Items {
  414. files = append(files, text(e.Lookup("path")))
  415. }
  416. return files
  417. }