| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354 |
- // Package project is the MPCkit domain model: one MPC Sample project and
- // every edit 0.5.0 makes to it. Edits follow the verified recipes in
- // docs/xpj-format-and-scope.md section 3 and change only the fields those
- // recipes name; everything else is written back as read.
- package project
- import (
- "bytes"
- _ "embed"
- "errors"
- "fmt"
- "path/filepath"
- "strings"
- "unicode"
- "git.monoexp.com/bajdev/mpckit/internal/xpj"
- )
- //go:embed template/DemoBlank.xpj
- var templateXPJ []byte
- // Device and format limits.
- const (
- Pads = 128 // 8 banks x 16
- MaxSamples = 128
- MaxSequences = 128
- PulsesPerBar = 3840 // 4/4 at 960 pulses per beat
- FirstNote = 36 // a note event for pad slot n stores note 36 + n
- TestedVersion = 28 // data.version of firmware 1.3.0.12
- DataSuffix = "_[ProjectData]"
- )
- // Tuning ranges for 0.5.0 (format doc, open question 1a).
- const (
- MinCoarse, MaxCoarse = -24, 24
- MinFine, MaxFine = -90, 90
- )
- // MaxMuteGroup is the highest mute group the device offers; 0 is off.
- // Device checkpoint A3, 2026-11-10.
- const MaxMuteGroup = 16
- // BPM range of the device's tempo control (device checkpoint A5, 2026-11-10).
- const (
- MinBPM = 20.0
- MaxBPM = 999.0
- )
- // DefaultBPM is used for a new sequence when no sequence has been edited.
- const DefaultBPM = 120.0
- // Project is one project: the parsed file plus what the file does not hold.
- type Project struct {
- // Name is the project name: the .xpj file name and data folder prefix.
- Name string
- // SourceDir is the data folder of a loaded project; empty for a new one.
- SourceDir string
- // Sources maps each pool file name to the absolute path it is copied
- // from at export.
- Sources map[string]string
- // Dirty is set by every edit.
- Dirty bool
- file *xpj.File
- data *xpj.Node
- // The lists every operation works on, found once by wrap.
- nodes struct{ pool, trackPool, pads, sequences *xpj.Node }
- lastBPM float64 // BPM of the most recently edited sequence; 0 if none
- }
- var template = mustTemplate()
- type tmpl struct {
- pad *xpj.Node // one blank pad (all 128 are identical)
- sequence *xpj.Node // the blank project's single sequence entry {key, value}
- }
- func mustTemplate() tmpl {
- f, err := xpj.Read(bytes.NewReader(templateXPJ))
- if err != nil {
- panic("project: embedded template: " + err.Error())
- }
- d, _ := f.Data()
- pad, err := d.Get("tracks[0].program.drum.instruments[0]")
- if err != nil {
- panic("project: embedded template: " + err.Error())
- }
- seq, err := d.Get("sequences[0]")
- if err != nil {
- panic("project: embedded template: " + err.Error())
- }
- return tmpl{pad: pad, sequence: seq}
- }
- // New returns a new project built from the blank template.
- func New(name string) (*Project, error) {
- if err := ValidName(name); err != nil {
- return nil, err
- }
- f, err := xpj.Read(bytes.NewReader(templateXPJ))
- if err != nil {
- return nil, err
- }
- return wrap(f, name, "")
- }
- // Load reads the .xpj at path. The project name is taken from the file name
- // and samples are expected in the sibling "<name>_[ProjectData]" folder.
- // The returned warnings describe differences from the tested firmware;
- // the project is still usable.
- func Load(path string) (*Project, []string, error) {
- abs, err := filepath.Abs(path)
- if err != nil {
- return nil, nil, err
- }
- f, err := xpj.ReadFile(abs)
- if err != nil {
- return nil, nil, err
- }
- name := strings.TrimSuffix(filepath.Base(abs), filepath.Ext(abs))
- dataDir := filepath.Join(filepath.Dir(abs), name+DataSuffix)
- p, err := wrap(f, name, dataDir)
- if err != nil {
- return nil, nil, err
- }
- var warnings []string
- if f.Header[1] != xpj.TestedHeader[1] {
- warnings = append(warnings, fmt.Sprintf("saved by firmware %s; MPCkit is tested with %s", f.Header[1], xpj.TestedHeader[1]))
- }
- if v, err := p.data.Lookup("version").Int(); err != nil {
- warnings = append(warnings, fmt.Sprintf("project data version is missing; MPCkit is tested with %d", TestedVersion))
- } else if v != TestedVersion {
- warnings = append(warnings, fmt.Sprintf("project data version %d; MPCkit is tested with %d", v, TestedVersion))
- }
- for _, file := range p.PoolFiles() {
- p.Sources[file] = filepath.Join(dataDir, file)
- }
- return p, warnings, nil
- }
- func wrap(f *xpj.File, name, sourceDir string) (*Project, error) {
- d, err := f.Data()
- if err != nil {
- return nil, err
- }
- p := &Project{Name: name, SourceDir: sourceDir, Sources: map[string]string{}, file: f, data: d}
- for _, l := range []struct {
- path string
- node **xpj.Node
- }{
- {"samples", &p.nodes.pool},
- {"tracks[0].samples", &p.nodes.trackPool},
- {"tracks[0].program.drum.instruments", &p.nodes.pads},
- {"sequences", &p.nodes.sequences},
- } {
- n, err := d.Get(l.path)
- if err != nil {
- return nil, fmt.Errorf("project: %w", err)
- }
- if n.Kind != xpj.Array {
- return nil, fmt.Errorf("project: %s is not a list", l.path)
- }
- *l.node = n
- }
- if n := len(p.pads().Items); n != Pads {
- return nil, fmt.Errorf("project: %d pads, want %d", n, Pads)
- }
- if err := p.check(); err != nil {
- return nil, fmt.Errorf("project: %w", err)
- }
- return p, nil
- }
- // check refuses a file whose sample names or sequences the operations
- // cannot use safely. Pool file names are joined to folders at load and
- // export, so a name such as "../x.wav" would reach outside the project.
- func (p *Project) check() error {
- pools := []struct {
- path string
- node *xpj.Node
- }{{"samples", p.pool()}, {"tracks[0].samples", p.trackPool()}}
- for _, pool := range pools {
- for i, e := range pool.node.Items {
- at := fmt.Sprintf("%s[%d].path", pool.path, i)
- n := e.Lookup("path")
- if n == nil || n.Kind != xpj.String {
- return fmt.Errorf("%s is missing or not text", at)
- }
- if err := PlainFileName(n.Text); err != nil {
- return fmt.Errorf("%s: %w", at, err)
- }
- }
- }
- for slot, pad := range p.pads().Items {
- layers := pad.Lookup("layersv")
- if layers == nil {
- continue
- }
- for i, l := range layers.Items {
- // An empty name is an empty layer.
- n := l.Lookup("sampleFile")
- if n == nil {
- continue
- }
- at := fmt.Sprintf("pad %s layer %d sampleFile", SlotName(slot), i+1)
- if n.Kind != xpj.String {
- return fmt.Errorf("%s is not text", at)
- }
- if n.Text == "" {
- continue
- }
- if err := PlainFileName(n.Text); err != nil {
- return fmt.Errorf("%s: %w", at, err)
- }
- }
- }
- for i, s := range p.sequences().Items {
- if err := checkSequence(s); err != nil {
- return fmt.Errorf("sequences[%d]: %w", i, err)
- }
- }
- return nil
- }
- // checkSequence checks the fields Sequences and the sequence edits read.
- func checkSequence(s *xpj.Node) error {
- if _, err := s.Lookup("key").Int(); err != nil {
- return errors.New("key is missing or not a whole number")
- }
- v := s.Lookup("value")
- if v == nil || v.Kind != xpj.Object {
- return errors.New("value is missing or not an object")
- }
- if _, err := v.Lookup("bpm").Float(); err != nil {
- return errors.New("bpm is missing or not a number")
- }
- if _, err := v.Lookup("lengthBars").Int(); err != nil {
- return errors.New("lengthBars is missing or not a whole number")
- }
- if n := v.Lookup("name"); n == nil || n.Kind != xpj.String {
- return errors.New("name is missing or not text")
- }
- return nil
- }
- // PlainFileName checks that name is a single file name with no folder part,
- // safe to join to a folder on any system: not empty, not "." or "..", and
- // free of slashes, backslashes and control characters.
- func PlainFileName(name string) error {
- switch {
- case name == "":
- return errors.New("file name is empty")
- case name == "." || name == "..":
- return fmt.Errorf("%q is not a file name", name)
- case strings.ContainsAny(name, `/\`):
- return fmt.Errorf("file name %q contains a folder separator", name)
- case strings.IndexFunc(name, unicode.IsControl) >= 0:
- return fmt.Errorf("file name %q contains a control character", name)
- case filepath.Base(name) != name:
- return fmt.Errorf("%q is not a plain file name", name)
- }
- return nil
- }
- // File returns the project file for writing. Callers must not edit it.
- func (p *Project) File() *xpj.File { return p.file }
- // SetName renames the project. Nothing inside the file changes.
- func (p *Project) SetName(name string) error {
- if err := ValidName(name); err != nil {
- return err
- }
- if name != p.Name {
- p.Name = name
- p.Dirty = true
- }
- return nil
- }
- // ValidName checks a project or sequence name: not empty, and free of
- // characters that are illegal in file names on Windows or FAT.
- func ValidName(name string) error {
- if strings.TrimSpace(name) == "" {
- return errors.New("name is empty")
- }
- for _, r := range name {
- if r < 0x20 || strings.ContainsRune(`<>:"/\|?*`, r) {
- if r < 0x20 {
- return errors.New("name contains a control character")
- }
- return fmt.Errorf("name contains %q, which is not allowed in file names", r)
- }
- }
- if strings.HasSuffix(name, ".") || strings.HasSuffix(name, " ") {
- return errors.New("name cannot end with a dot or a space")
- }
- base := strings.ToUpper(name)
- if i := strings.IndexByte(base, '.'); i >= 0 {
- base = base[:i]
- }
- switch base {
- case "CON", "PRN", "AUX", "NUL",
- "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
- "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9":
- return fmt.Errorf("%q is a reserved name on Windows", name)
- }
- return nil
- }
- // Slot helpers. A slot is 0-127: bank x 16 + pad - 1.
- // SlotName returns the pad label for a slot, e.g. 0 -> "A1", 17 -> "B2".
- func SlotName(slot int) string {
- return fmt.Sprintf("%c%d", 'A'+slot/16, slot%16+1)
- }
- func checkSlot(slot int) error {
- if slot < 0 || slot >= Pads {
- return fmt.Errorf("pad slot %d out of range 0-%d", slot, Pads-1)
- }
- return nil
- }
- func (p *Project) pads() *xpj.Node { return p.nodes.pads }
- func (p *Project) pad(slot int) *xpj.Node { return p.pads().Items[slot] }
- func layer(pad *xpj.Node) (*xpj.Node, error) {
- return pad.Get("layersv[0]")
- }
- // pool is samples[]; trackPool is tracks[0].samples[]. The device keeps
- // the same entries in both, appending to the first and prepending to the
- // second.
- func (p *Project) pool() *xpj.Node { return p.nodes.pool }
- func (p *Project) trackPool() *xpj.Node { return p.nodes.trackPool }
- func (p *Project) sequences() *xpj.Node { return p.nodes.sequences }
- // edit applies several Sets and keeps the first error, so an operation can
- // list its writes plainly. Paths come from the template; a failure means the
- // loaded file has a different shape.
- type edit struct{ err error }
- func (e *edit) set(n *xpj.Node, path string, v *xpj.Node) {
- if e.err == nil {
- if err := n.Set(path, v); err != nil {
- e.err = fmt.Errorf("project: %w", err)
- }
- }
- }
|