pads.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422
  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. file := p.fileForSource(target)
  124. if file == "" {
  125. // A new pool entry is needed. Clearing the pad first may free one.
  126. freed := 0
  127. for _, f := range padFiles(p.pad(slot)) {
  128. if p.usage(f) == 1 {
  129. freed++
  130. }
  131. }
  132. if p.SampleCount()-freed >= MaxSamples {
  133. return fmt.Errorf("the project already has %d samples, the most the device allows", MaxSamples)
  134. }
  135. }
  136. // Keep the reused entry even if this pad was its only user.
  137. p.clear(slot, file)
  138. if file == "" {
  139. file = p.uniqueFile(poolFileName(name))
  140. if err := p.addPoolEntry(file); err != nil {
  141. return err
  142. }
  143. p.Sources[file] = target
  144. }
  145. pad := p.pad(slot)
  146. l, err := layer(pad)
  147. if err != nil {
  148. return err
  149. }
  150. var e edit
  151. e.set(pad, "tempo", xpj.NewFloat(padTempo))
  152. e.set(l, "sampleName", xpj.NewString(stem(file)))
  153. e.set(l, "sampleFile", xpj.NewString(file))
  154. e.set(l, "sliceInfo.End", xpj.NewInt(info.Frames-1))
  155. e.set(l, "sliceInfo.LoopCrossfadeLength", xpj.NewInt(crossfadeLength))
  156. p.Dirty = true
  157. return e.err
  158. }
  159. // fileForSource returns the pool file already copied from src, if any.
  160. func (p *Project) fileForSource(src string) string {
  161. for file, s := range p.Sources {
  162. if s == src && p.inPool(file) {
  163. return file
  164. }
  165. }
  166. return ""
  167. }
  168. func (p *Project) inPool(file string) bool {
  169. for _, e := range p.pool().Items {
  170. if text(e.Lookup("path")) == file {
  171. return true
  172. }
  173. }
  174. return false
  175. }
  176. // poolFileName turns the name of a WAV the user picked into a pool file
  177. // name that passes PlainFileName on every system and is legal on the
  178. // device's FAT card. Linux and macOS allow names such as "kick\one.wav",
  179. // "a:b.wav" or "con.wav"; exported as they are, the backslash reads as a
  180. // folder on Windows and the card, the colon cannot be written to the card,
  181. // Windows cannot create a file named after a device, and Load refuses the
  182. // project. Each such character becomes "_", and a device name gets "_"
  183. // added ("con.wav" becomes "con_.wav").
  184. func poolFileName(name string) string {
  185. clean := strings.Map(func(r rune) rune {
  186. if unicode.IsControl(r) || strings.ContainsRune(`/\:*?"<>|`, r) {
  187. return '_'
  188. }
  189. return r
  190. }, name)
  191. if clean == "" || clean == "." || clean == ".." {
  192. return "sample.wav"
  193. }
  194. if reservedOnWindows(clean) {
  195. base, ext, _ := strings.Cut(clean, ".")
  196. clean = base + "_"
  197. if ext != "" {
  198. clean += "." + ext
  199. }
  200. }
  201. return clean
  202. }
  203. func stem(file string) string { return strings.TrimSuffix(file, filepath.Ext(file)) }
  204. // uniqueFile returns base, or base with a 001, 002 ... suffix before the
  205. // extension, so that no two pool files share a name. Names are compared
  206. // without case, as on Windows, macOS and FAT.
  207. func (p *Project) uniqueFile(base string) string {
  208. taken := map[string]bool{}
  209. for _, e := range p.pool().Items {
  210. taken[strings.ToLower(text(e.Lookup("path")))] = true
  211. taken[strings.ToLower(text(e.Lookup("name")))+"\x00"] = true
  212. }
  213. free := func(f string) bool {
  214. return !taken[strings.ToLower(f)] && !taken[strings.ToLower(stem(f))+"\x00"]
  215. }
  216. if free(base) {
  217. return base
  218. }
  219. ext := filepath.Ext(base)
  220. for i := 1; ; i++ {
  221. f := fmt.Sprintf("%s%03d%s", stem(base), i, ext)
  222. if free(f) {
  223. return f
  224. }
  225. }
  226. }
  227. // poolEntry builds a new pool entry in the device's key order.
  228. func poolEntry(file string) *xpj.Node {
  229. meta := xpj.NewObject()
  230. meta.Members = []xpj.Member{
  231. {Key: "tempo", Value: xpj.NewFloat(0)},
  232. {Key: "rootNote", Value: xpj.NewInt(newSampleRoot)},
  233. {Key: "tune", Value: xpj.NewFloat(0)},
  234. {Key: "key", Value: xpj.NewString(newSampleKey)},
  235. }
  236. e := xpj.NewObject()
  237. e.Members = []xpj.Member{
  238. {Key: "version", Value: xpj.NewInt(1)},
  239. {Key: "name", Value: xpj.NewString(stem(file))},
  240. {Key: "path", Value: xpj.NewString(file)},
  241. {Key: "loadImpl", Value: xpj.NewInt(0)},
  242. {Key: "metadata", Value: meta},
  243. }
  244. return e
  245. }
  246. func (p *Project) addPoolEntry(file string) error {
  247. if err := p.pool().Append(poolEntry(file)); err != nil {
  248. return err
  249. }
  250. return p.trackPool().Insert(0, poolEntry(file))
  251. }
  252. func (p *Project) removePoolEntry(file string) {
  253. for _, arr := range []*xpj.Node{p.pool(), p.trackPool()} {
  254. for i := len(arr.Items) - 1; i >= 0; i-- {
  255. if text(arr.Items[i].Lookup("path")) == file {
  256. _ = arr.Remove(i)
  257. }
  258. }
  259. }
  260. delete(p.Sources, file)
  261. }
  262. // ClearPad resets the pad in slot to the blank pad and drops pool entries
  263. // no other pad uses.
  264. func (p *Project) ClearPad(slot int) error {
  265. if err := checkSlot(slot); err != nil {
  266. return err
  267. }
  268. if len(padFiles(p.pad(slot))) == 0 {
  269. return nil
  270. }
  271. p.clear(slot, "")
  272. p.Dirty = true
  273. return nil
  274. }
  275. // clear resets the pad in slot and drops pool entries left unused, except keep.
  276. func (p *Project) clear(slot int, keep string) {
  277. files := padFiles(p.pad(slot))
  278. p.pads().Items[slot] = template.pad.Clone()
  279. for _, f := range files {
  280. if f != keep && p.usage(f) == 0 {
  281. p.removePoolEntry(f)
  282. }
  283. }
  284. }
  285. // CopyPad copies the whole pad in from onto to, replacing what was there.
  286. // Both pads share the pool entry.
  287. func (p *Project) CopyPad(from, to int) error {
  288. if err := checkSlot(from); err != nil {
  289. return err
  290. }
  291. if err := checkSlot(to); err != nil {
  292. return err
  293. }
  294. if from == to {
  295. return nil
  296. }
  297. src := p.pad(from).Clone()
  298. p.clear(to, "")
  299. p.pads().Items[to] = src
  300. p.Dirty = true
  301. return nil
  302. }
  303. // MovePad copies from onto to, then resets from to the blank pad. Recorded
  304. // notes stay on their slot; check PadHasNotes and warn first.
  305. func (p *Project) MovePad(from, to int) error {
  306. if from == to {
  307. return checkSlot(from)
  308. }
  309. if err := p.CopyPad(from, to); err != nil {
  310. return err
  311. }
  312. p.pads().Items[from] = template.pad.Clone()
  313. return nil
  314. }
  315. // SwapPads exchanges two pads.
  316. func (p *Project) SwapPads(a, b int) error {
  317. if err := checkSlot(a); err != nil {
  318. return err
  319. }
  320. if err := checkSlot(b); err != nil {
  321. return err
  322. }
  323. if a == b {
  324. return nil
  325. }
  326. items := p.pads().Items
  327. items[a], items[b] = items[b], items[a]
  328. p.Dirty = true
  329. return nil
  330. }
  331. // SetTune sets the pad's tuning, clamped to -24..+24 semitones and
  332. // -90..+90 cents. pitch is stored as a 32-bit float.
  333. func (p *Project) SetTune(slot, coarse, fine int) error {
  334. if err := checkSlot(slot); err != nil {
  335. return err
  336. }
  337. coarse = max(MinCoarse, min(MaxCoarse, coarse))
  338. fine = max(MinFine, min(MaxFine, fine))
  339. l, err := layer(p.pad(slot))
  340. if err != nil {
  341. return err
  342. }
  343. var e edit
  344. e.set(l, "coarseTune", xpj.NewInt(int64(coarse)))
  345. e.set(l, "fineTune", xpj.NewInt(int64(fine)))
  346. e.set(l, "pitch", xpj.NewFloat32(float64(coarse)+float64(fine)/100))
  347. p.Dirty = true
  348. return e.err
  349. }
  350. // SetMuteGroup sets the pad's mute group; 0 is off.
  351. func (p *Project) SetMuteGroup(slot, group int) error {
  352. if err := checkSlot(slot); err != nil {
  353. return err
  354. }
  355. if group < 0 || group > MaxMuteGroup {
  356. return fmt.Errorf("mute group %d out of range 0-%d", group, MaxMuteGroup)
  357. }
  358. var e edit
  359. e.set(p.pad(slot), "whichMuteGroup", xpj.NewInt(int64(group)))
  360. p.Dirty = true
  361. return e.err
  362. }
  363. // PadHasNotes reports whether any sequence has a note or automation event
  364. // for the pad in slot (note 36 + slot on the drum track).
  365. func (p *Project) PadHasNotes(slot int) bool {
  366. note := int64(FirstNote + slot)
  367. found := false
  368. p.eachDrumEvent(func(ev *xpj.Node) bool {
  369. var n *xpj.Node
  370. switch num(ev.Lookup("type")) {
  371. case eventNote:
  372. n = ev.Lookup("note")
  373. case eventAutomation:
  374. n = ev.Lookup("automation")
  375. }
  376. if n != nil && num(n.Lookup("note")) == note {
  377. found = true
  378. }
  379. return !found
  380. })
  381. return found
  382. }
  383. // PoolFiles returns the file name of every pool entry, in pool order.
  384. func (p *Project) PoolFiles() []string {
  385. var files []string
  386. for _, e := range p.pool().Items {
  387. files = append(files, text(e.Lookup("path")))
  388. }
  389. return files
  390. }