project.go 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259
  1. // Package project is the MPCkit domain model: one MPC Sample project and
  2. // every edit 0.5.0 makes to it. Edits follow the verified recipes in
  3. // docs/xpj-format-and-scope.md section 3 and change only the fields those
  4. // recipes name; everything else is written back as read.
  5. package project
  6. import (
  7. "bytes"
  8. _ "embed"
  9. "errors"
  10. "fmt"
  11. "path/filepath"
  12. "strings"
  13. "git.monoexp.com/bajdev/mpckit/internal/xpj"
  14. )
  15. //go:embed template/DemoBlank.xpj
  16. var templateXPJ []byte
  17. // Device and format limits.
  18. const (
  19. Pads = 128 // 8 banks x 16
  20. MaxSamples = 128
  21. MaxSequences = 128
  22. PulsesPerBar = 3840 // 4/4 at 960 pulses per beat
  23. FirstNote = 36 // a note event for pad slot n stores note 36 + n
  24. TestedVersion = 28 // data.version of firmware 1.3.0.12
  25. DataSuffix = "_[ProjectData]"
  26. )
  27. // Tuning ranges for 0.5.0 (format doc, open question 1a).
  28. const (
  29. MinCoarse, MaxCoarse = -24, 24
  30. MinFine, MaxFine = -90, 90
  31. )
  32. // MaxMuteGroup is the highest mute group the device offers; 0 is off.
  33. // Device checkpoint A3, 2026-11-10.
  34. const MaxMuteGroup = 16
  35. // BPM range of the device's tempo control (device checkpoint A5, 2026-11-10).
  36. const (
  37. MinBPM = 20.0
  38. MaxBPM = 999.0
  39. )
  40. // DefaultBPM is used for a new sequence when no sequence has been edited.
  41. const DefaultBPM = 120.0
  42. // Project is one project: the parsed file plus what the file does not hold.
  43. type Project struct {
  44. // Name is the project name: the .xpj file name and data folder prefix.
  45. Name string
  46. // SourceDir is the data folder of a loaded project; empty for a new one.
  47. SourceDir string
  48. // Sources maps each pool file name to the absolute path it is copied
  49. // from at export.
  50. Sources map[string]string
  51. // Dirty is set by every edit.
  52. Dirty bool
  53. file *xpj.File
  54. data *xpj.Node
  55. lastBPM float64 // BPM of the most recently edited sequence; 0 if none
  56. }
  57. var template = mustTemplate()
  58. type tmpl struct {
  59. pad *xpj.Node // one blank pad (all 128 are identical)
  60. sequence *xpj.Node // the blank project's single sequence entry {key, value}
  61. }
  62. func mustTemplate() tmpl {
  63. f, err := xpj.Read(bytes.NewReader(templateXPJ))
  64. if err != nil {
  65. panic("project: embedded template: " + err.Error())
  66. }
  67. d, _ := f.Data()
  68. pad, err := d.Get("tracks[0].program.drum.instruments[0]")
  69. if err != nil {
  70. panic("project: embedded template: " + err.Error())
  71. }
  72. seq, err := d.Get("sequences[0]")
  73. if err != nil {
  74. panic("project: embedded template: " + err.Error())
  75. }
  76. return tmpl{pad: pad, sequence: seq}
  77. }
  78. // New returns a new project built from the blank template.
  79. func New(name string) (*Project, error) {
  80. if err := ValidName(name); err != nil {
  81. return nil, err
  82. }
  83. f, err := xpj.Read(bytes.NewReader(templateXPJ))
  84. if err != nil {
  85. return nil, err
  86. }
  87. return wrap(f, name, "")
  88. }
  89. // Load reads the .xpj at path. The project name is taken from the file name
  90. // and samples are expected in the sibling "<name>_[ProjectData]" folder.
  91. // The returned warnings describe differences from the tested firmware;
  92. // the project is still usable.
  93. func Load(path string) (*Project, []string, error) {
  94. abs, err := filepath.Abs(path)
  95. if err != nil {
  96. return nil, nil, err
  97. }
  98. f, err := xpj.ReadFile(abs)
  99. if err != nil {
  100. return nil, nil, err
  101. }
  102. name := strings.TrimSuffix(filepath.Base(abs), filepath.Ext(abs))
  103. dataDir := filepath.Join(filepath.Dir(abs), name+DataSuffix)
  104. p, err := wrap(f, name, dataDir)
  105. if err != nil {
  106. return nil, nil, err
  107. }
  108. var warnings []string
  109. if f.Header[1] != xpj.TestedHeader[1] {
  110. warnings = append(warnings, fmt.Sprintf("saved by firmware %s; MPCkit is tested with %s", f.Header[1], xpj.TestedHeader[1]))
  111. }
  112. if v, err := p.data.Lookup("version").Int(); err != nil || v != TestedVersion {
  113. warnings = append(warnings, fmt.Sprintf("project data version %d; MPCkit is tested with %d", v, TestedVersion))
  114. }
  115. for _, e := range p.pool().Items {
  116. file := e.Lookup("path").Text
  117. p.Sources[file] = filepath.Join(dataDir, file)
  118. }
  119. return p, warnings, nil
  120. }
  121. func wrap(f *xpj.File, name, sourceDir string) (*Project, error) {
  122. d, err := f.Data()
  123. if err != nil {
  124. return nil, err
  125. }
  126. p := &Project{Name: name, SourceDir: sourceDir, Sources: map[string]string{}, file: f, data: d}
  127. for _, path := range []string{"samples", "tracks[0].samples", "tracks[0].program.drum.instruments", "sequences"} {
  128. n, err := d.Get(path)
  129. if err != nil {
  130. return nil, fmt.Errorf("project: %w", err)
  131. }
  132. if n.Kind != xpj.Array {
  133. return nil, fmt.Errorf("project: %s is not a list", path)
  134. }
  135. }
  136. if n := len(p.pads().Items); n != Pads {
  137. return nil, fmt.Errorf("project: %d pads, want %d", n, Pads)
  138. }
  139. return p, nil
  140. }
  141. // File returns the project file for writing. Callers must not edit it.
  142. func (p *Project) File() *xpj.File { return p.file }
  143. // SetName renames the project. Nothing inside the file changes.
  144. func (p *Project) SetName(name string) error {
  145. if err := ValidName(name); err != nil {
  146. return err
  147. }
  148. if name != p.Name {
  149. p.Name = name
  150. p.Dirty = true
  151. }
  152. return nil
  153. }
  154. // ValidName checks a project or sequence name: not empty, and free of
  155. // characters that are illegal in file names on Windows or FAT.
  156. func ValidName(name string) error {
  157. if strings.TrimSpace(name) == "" {
  158. return errors.New("name is empty")
  159. }
  160. for _, r := range name {
  161. if r < 0x20 || strings.ContainsRune(`<>:"/\|?*`, r) {
  162. if r < 0x20 {
  163. return errors.New("name contains a control character")
  164. }
  165. return fmt.Errorf("name contains %q, which is not allowed in file names", r)
  166. }
  167. }
  168. if strings.HasSuffix(name, ".") || strings.HasSuffix(name, " ") {
  169. return errors.New("name cannot end with a dot or a space")
  170. }
  171. base := strings.ToUpper(name)
  172. if i := strings.IndexByte(base, '.'); i >= 0 {
  173. base = base[:i]
  174. }
  175. switch base {
  176. case "CON", "PRN", "AUX", "NUL",
  177. "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
  178. "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9":
  179. return fmt.Errorf("%q is a reserved name on Windows", name)
  180. }
  181. return nil
  182. }
  183. // Slot helpers. A slot is 0-127: bank x 16 + pad - 1.
  184. // SlotName returns the pad label for a slot, e.g. 0 -> "A1", 17 -> "B2".
  185. func SlotName(slot int) string {
  186. return fmt.Sprintf("%c%d", 'A'+slot/16, slot%16+1)
  187. }
  188. func checkSlot(slot int) error {
  189. if slot < 0 || slot >= Pads {
  190. return fmt.Errorf("pad slot %d out of range 0-%d", slot, Pads-1)
  191. }
  192. return nil
  193. }
  194. func (p *Project) pads() *xpj.Node {
  195. n, _ := p.data.Get("tracks[0].program.drum.instruments")
  196. return n
  197. }
  198. func (p *Project) pad(slot int) *xpj.Node { return p.pads().Items[slot] }
  199. func layer(pad *xpj.Node) (*xpj.Node, error) {
  200. return pad.Get("layersv[0]")
  201. }
  202. // pool is samples[]; trackPool is tracks[0].samples[]. The device keeps
  203. // the same entries in both, appending to the first and prepending to the
  204. // second.
  205. func (p *Project) pool() *xpj.Node {
  206. n, _ := p.data.Get("samples")
  207. return n
  208. }
  209. func (p *Project) trackPool() *xpj.Node {
  210. n, _ := p.data.Get("tracks[0].samples")
  211. return n
  212. }
  213. func (p *Project) sequences() *xpj.Node {
  214. n, _ := p.data.Get("sequences")
  215. return n
  216. }
  217. // edit applies several Sets and keeps the first error, so an operation can
  218. // list its writes plainly. Paths come from the template; a failure means the
  219. // loaded file has a different shape.
  220. type edit struct{ err error }
  221. func (e *edit) set(n *xpj.Node, path string, v *xpj.Node) {
  222. if e.err == nil {
  223. if err := n.Set(path, v); err != nil {
  224. e.err = fmt.Errorf("project: %w", err)
  225. }
  226. }
  227. }