1
0

project.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  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. "unicode"
  14. "git.monoexp.com/bajdev/mpckit/internal/xpj"
  15. )
  16. //go:embed template/DemoBlank.xpj
  17. var templateXPJ []byte
  18. // Device and format limits.
  19. const (
  20. Pads = 128 // 8 banks x 16
  21. MaxSamples = 128
  22. MaxSequences = 128
  23. PulsesPerBar = 3840 // 4/4 at 960 pulses per beat
  24. FirstNote = 36 // a note event for pad slot n stores note 36 + n
  25. TestedVersion = 28 // data.version of firmware 1.3.0.12
  26. DataSuffix = "_[ProjectData]"
  27. )
  28. // Tuning ranges for 0.5.0 (format doc, open question 1a).
  29. const (
  30. MinCoarse, MaxCoarse = -24, 24
  31. MinFine, MaxFine = -90, 90
  32. )
  33. // MaxMuteGroup is the highest mute group the device offers; 0 is off.
  34. // Device checkpoint A3, 2026-11-10.
  35. const MaxMuteGroup = 16
  36. // BPM range of the device's tempo control (device checkpoint A5, 2026-11-10).
  37. const (
  38. MinBPM = 20.0
  39. MaxBPM = 999.0
  40. )
  41. // DefaultBPM is used for a new sequence when no sequence has been edited.
  42. const DefaultBPM = 120.0
  43. // Project is one project: the parsed file plus what the file does not hold.
  44. type Project struct {
  45. // Name is the project name: the .xpj file name and data folder prefix.
  46. Name string
  47. // SourceDir is the data folder of a loaded project; empty for a new one.
  48. SourceDir string
  49. // sources maps each pool file name to the absolute path it is copied
  50. // from at export.
  51. sources map[string]string
  52. // dirty is set by every edit that succeeds; see Dirty.
  53. dirty bool
  54. file *xpj.File
  55. data *xpj.Node
  56. // The lists every operation works on, found once by wrap.
  57. nodes struct{ pool, trackPool, pads, sequences *xpj.Node }
  58. lastBPM float64 // BPM of the most recently edited sequence; 0 if none
  59. }
  60. var template = mustTemplate()
  61. type tmpl struct {
  62. pad *xpj.Node // one blank pad (all 128 are identical)
  63. sequence *xpj.Node // the blank project's single sequence entry {key, value}
  64. }
  65. func mustTemplate() tmpl {
  66. f, err := xpj.Read(bytes.NewReader(templateXPJ))
  67. if err != nil {
  68. panic("project: embedded template: " + err.Error())
  69. }
  70. d, _ := f.Data()
  71. pad, err := d.Get("tracks[0].program.drum.instruments[0]")
  72. if err != nil {
  73. panic("project: embedded template: " + err.Error())
  74. }
  75. seq, err := d.Get("sequences[0]")
  76. if err != nil {
  77. panic("project: embedded template: " + err.Error())
  78. }
  79. return tmpl{pad: pad, sequence: seq}
  80. }
  81. // New returns a new project built from the blank template.
  82. func New(name string) (*Project, error) {
  83. if err := ValidName(name); err != nil {
  84. return nil, err
  85. }
  86. f, err := xpj.Read(bytes.NewReader(templateXPJ))
  87. if err != nil {
  88. return nil, err
  89. }
  90. return wrap(f, name, "")
  91. }
  92. // Load reads the .xpj at path. The project name is taken from the file name
  93. // and samples are expected in the sibling "<name>_[ProjectData]" folder.
  94. // The returned warnings describe differences from the tested firmware;
  95. // the project is still usable.
  96. func Load(path string) (*Project, []string, error) {
  97. abs, err := filepath.Abs(path)
  98. if err != nil {
  99. return nil, nil, err
  100. }
  101. f, err := xpj.ReadFile(abs)
  102. if err != nil {
  103. return nil, nil, err
  104. }
  105. name := strings.TrimSuffix(filepath.Base(abs), filepath.Ext(abs))
  106. dataDir := filepath.Join(filepath.Dir(abs), name+DataSuffix)
  107. p, err := wrap(f, name, dataDir)
  108. if err != nil {
  109. return nil, nil, err
  110. }
  111. var warnings []string
  112. if f.Header[1] != xpj.TestedHeader[1] {
  113. warnings = append(warnings, fmt.Sprintf("saved by firmware %s; MPCkit is tested with %s", f.Header[1], xpj.TestedHeader[1]))
  114. }
  115. if v, err := p.data.Lookup("version").Int(); err != nil {
  116. warnings = append(warnings, fmt.Sprintf("project data version is missing; MPCkit is tested with %d", TestedVersion))
  117. } else if v != TestedVersion {
  118. warnings = append(warnings, fmt.Sprintf("project data version %d; MPCkit is tested with %d", v, TestedVersion))
  119. }
  120. for _, file := range p.PoolFiles() {
  121. p.sources[file] = filepath.Join(dataDir, file)
  122. }
  123. return p, warnings, nil
  124. }
  125. func wrap(f *xpj.File, name, sourceDir string) (*Project, error) {
  126. d, err := f.Data()
  127. if err != nil {
  128. return nil, err
  129. }
  130. p := &Project{Name: name, SourceDir: sourceDir, sources: map[string]string{}, file: f, data: d}
  131. for _, l := range []struct {
  132. path string
  133. node **xpj.Node
  134. }{
  135. {"samples", &p.nodes.pool},
  136. {"tracks[0].samples", &p.nodes.trackPool},
  137. {"tracks[0].program.drum.instruments", &p.nodes.pads},
  138. {"sequences", &p.nodes.sequences},
  139. } {
  140. n, err := d.Get(l.path)
  141. if err != nil {
  142. return nil, fmt.Errorf("project: %w", err)
  143. }
  144. if n.Kind != xpj.Array {
  145. return nil, fmt.Errorf("project: %s is not a list", l.path)
  146. }
  147. *l.node = n
  148. }
  149. if n := len(p.pads().Items); n != Pads {
  150. return nil, fmt.Errorf("project: %d pads, want %d", n, Pads)
  151. }
  152. if err := p.check(); err != nil {
  153. return nil, fmt.Errorf("project: %w", err)
  154. }
  155. return p, nil
  156. }
  157. // check refuses a file whose sample names or sequences the operations
  158. // cannot use safely. Pool file names are joined to folders at load and
  159. // export, so a name such as "../x.wav" would reach outside the project.
  160. func (p *Project) check() error {
  161. pools := []struct {
  162. path string
  163. node *xpj.Node
  164. }{{"samples", p.pool()}, {"tracks[0].samples", p.trackPool()}}
  165. for _, pool := range pools {
  166. for i, e := range pool.node.Items {
  167. at := fmt.Sprintf("%s[%d].path", pool.path, i)
  168. n := e.Lookup("path")
  169. if n == nil || n.Kind != xpj.String {
  170. return fmt.Errorf("%s is missing or not text", at)
  171. }
  172. if err := PlainFileName(n.Text); err != nil {
  173. return fmt.Errorf("%s: %w", at, err)
  174. }
  175. }
  176. }
  177. for slot, pad := range p.pads().Items {
  178. layers := pad.Lookup("layersv")
  179. if layers == nil {
  180. continue
  181. }
  182. for i, l := range layers.Items {
  183. // An empty name is an empty layer.
  184. n := l.Lookup("sampleFile")
  185. if n == nil {
  186. continue
  187. }
  188. at := fmt.Sprintf("pad %s layer %d sampleFile", SlotName(slot), i+1)
  189. if n.Kind != xpj.String {
  190. return fmt.Errorf("%s is not text", at)
  191. }
  192. if n.Text == "" {
  193. continue
  194. }
  195. if err := PlainFileName(n.Text); err != nil {
  196. return fmt.Errorf("%s: %w", at, err)
  197. }
  198. }
  199. }
  200. for i, s := range p.sequences().Items {
  201. if err := checkSequence(s); err != nil {
  202. return fmt.Errorf("sequences[%d]: %w", i, err)
  203. }
  204. }
  205. return nil
  206. }
  207. // checkSequence checks the fields Sequences and the sequence edits read.
  208. func checkSequence(s *xpj.Node) error {
  209. if _, err := s.Lookup("key").Int(); err != nil {
  210. return errors.New("key is missing or not a whole number")
  211. }
  212. v := s.Lookup("value")
  213. if v == nil || v.Kind != xpj.Object {
  214. return errors.New("value is missing or not an object")
  215. }
  216. if _, err := v.Lookup("bpm").Float(); err != nil {
  217. return errors.New("bpm is missing or not a number")
  218. }
  219. if _, err := v.Lookup("lengthBars").Int(); err != nil {
  220. return errors.New("lengthBars is missing or not a whole number")
  221. }
  222. if n := v.Lookup("name"); n == nil || n.Kind != xpj.String {
  223. return errors.New("name is missing or not text")
  224. }
  225. return nil
  226. }
  227. // PlainFileName checks that name is a single file name with no folder part,
  228. // safe to join to a folder on any system: not empty, not "." or "..", and
  229. // free of slashes, backslashes and control characters.
  230. func PlainFileName(name string) error {
  231. switch {
  232. case name == "":
  233. return errors.New("file name is empty")
  234. case name == "." || name == "..":
  235. return fmt.Errorf("%q is not a file name", name)
  236. case strings.ContainsAny(name, `/\`):
  237. return fmt.Errorf("file name %q contains a folder separator", name)
  238. case strings.IndexFunc(name, unicode.IsControl) >= 0:
  239. return fmt.Errorf("file name %q contains a control character", name)
  240. case filepath.Base(name) != name:
  241. return fmt.Errorf("%q is not a plain file name", name)
  242. }
  243. return nil
  244. }
  245. // Dirty reports whether the project has changes not yet exported.
  246. func (p *Project) Dirty() bool { return p.dirty }
  247. // MarkExported records that the project as it is now has been exported.
  248. func (p *Project) MarkExported() { p.dirty = false }
  249. // Source returns the absolute path the pool file is copied from at export,
  250. // and whether one is recorded.
  251. func (p *Project) Source(file string) (string, bool) {
  252. src, ok := p.sources[file]
  253. return src, ok
  254. }
  255. // File returns the project file for writing. Callers must not edit it.
  256. func (p *Project) File() *xpj.File { return p.file }
  257. // SetName renames the project. Nothing inside the file changes.
  258. func (p *Project) SetName(name string) error {
  259. if err := ValidName(name); err != nil {
  260. return err
  261. }
  262. if name != p.Name {
  263. p.Name = name
  264. p.dirty = true
  265. }
  266. return nil
  267. }
  268. // ValidName checks a project or sequence name: not empty, and free of
  269. // characters that are illegal in file names on Windows or FAT.
  270. func ValidName(name string) error {
  271. if strings.TrimSpace(name) == "" {
  272. return errors.New("name is empty")
  273. }
  274. for _, r := range name {
  275. if r < 0x20 || strings.ContainsRune(`<>:"/\|?*`, r) {
  276. if r < 0x20 {
  277. return errors.New("name contains a control character")
  278. }
  279. return fmt.Errorf("name contains %q, which is not allowed in file names", r)
  280. }
  281. }
  282. if strings.HasSuffix(name, ".") || strings.HasSuffix(name, " ") {
  283. return errors.New("name cannot end with a dot or a space")
  284. }
  285. if reservedOnWindows(name) {
  286. return fmt.Errorf("%q is a reserved name on Windows", name)
  287. }
  288. return nil
  289. }
  290. // reservedOnWindows reports whether Windows treats name as a device, such as
  291. // "CON" or "com1.wav": the part before the first dot is what counts.
  292. func reservedOnWindows(name string) bool {
  293. base, _, _ := strings.Cut(strings.ToUpper(name), ".")
  294. switch base {
  295. case "CON", "PRN", "AUX", "NUL",
  296. "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
  297. "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9":
  298. return true
  299. }
  300. return false
  301. }
  302. // Slot helpers. A slot is 0-127: bank x 16 + pad - 1.
  303. // SlotName returns the pad label for a slot, e.g. 0 -> "A1", 17 -> "B2".
  304. func SlotName(slot int) string {
  305. return fmt.Sprintf("%c%d", 'A'+slot/16, slot%16+1)
  306. }
  307. func checkSlot(slot int) error {
  308. if slot < 0 || slot >= Pads {
  309. return fmt.Errorf("pad slot %d out of range 0-%d", slot, Pads-1)
  310. }
  311. return nil
  312. }
  313. func (p *Project) pads() *xpj.Node { return p.nodes.pads }
  314. func (p *Project) pad(slot int) *xpj.Node { return p.pads().Items[slot] }
  315. func layer(pad *xpj.Node) (*xpj.Node, error) {
  316. return pad.Get("layersv[0]")
  317. }
  318. // pool is samples[]; trackPool is tracks[0].samples[]. The device keeps
  319. // the same entries in both, appending to the first and prepending to the
  320. // second.
  321. func (p *Project) pool() *xpj.Node { return p.nodes.pool }
  322. func (p *Project) trackPool() *xpj.Node { return p.nodes.trackPool }
  323. func (p *Project) sequences() *xpj.Node { return p.nodes.sequences }
  324. // edit applies several Sets and keeps the first error, so an operation can
  325. // list its writes plainly. Paths come from the template; a failure means the
  326. // loaded file has a different shape.
  327. type edit struct{ err error }
  328. func (e *edit) set(n *xpj.Node, path string, v *xpj.Node) {
  329. if e.err == nil {
  330. if err := n.Set(path, v); err != nil {
  331. e.err = fmt.Errorf("project: %w", err)
  332. }
  333. }
  334. }