| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385 |
- package project
- import (
- "fmt"
- "path/filepath"
- "strings"
- "git.monoexp.com/bajdev/mpckit/internal/wav"
- "git.monoexp.com/bajdev/mpckit/internal/xpj"
- )
- // Values the device writes when it assigns a sample with no tempo or key of
- // its own (format doc section 3, build plan 4.3).
- const (
- padTempo = 20.0
- newSampleKey = "C Natural Minor"
- newSampleRoot = 60
- crossfadeLength = -1
- )
- // Pad is a read-only view of one pad.
- type Pad struct {
- Slot int
- Label string // "A1" .. "H16"
- SampleName string // empty for an empty pad
- File string // pool file name
- Coarse int
- Fine int
- MuteGroup int
- Start, End int64 // slice start and end frame, for the preview
- }
- // Empty reports whether the pad has no sample.
- func (p Pad) Empty() bool { return p.File == "" }
- // Pad returns the pad in slot.
- func (p *Project) Pad(slot int) (Pad, error) {
- if err := checkSlot(slot); err != nil {
- return Pad{}, err
- }
- pad := p.pad(slot)
- l, err := layer(pad)
- if err != nil {
- return Pad{}, fmt.Errorf("project: pad %s: %w", SlotName(slot), err)
- }
- v := Pad{Slot: slot, Label: SlotName(slot)}
- v.SampleName = text(l.Lookup("sampleName"))
- v.File = text(l.Lookup("sampleFile"))
- v.Coarse = int(num(l.Lookup("coarseTune")))
- v.Fine = int(num(l.Lookup("fineTune")))
- v.MuteGroup = int(num(pad.Lookup("whichMuteGroup")))
- if si := l.Lookup("sliceInfo"); si != nil {
- v.Start = num(si.Lookup("Start"))
- v.End = num(si.Lookup("End"))
- }
- return v, nil
- }
- func text(n *xpj.Node) string {
- if n == nil || n.Kind != xpj.String {
- return ""
- }
- return n.Text
- }
- // num and float return a number field's value, or 0 if it is missing or
- // not a number of that kind.
- func num(n *xpj.Node) int64 {
- i, _ := n.Int()
- return i
- }
- func float(n *xpj.Node) float64 {
- f, _ := n.Float()
- return f
- }
- // padFiles returns the pool files used by any layer of pad.
- func padFiles(pad *xpj.Node) []string {
- var files []string
- layers := pad.Lookup("layersv")
- if layers == nil {
- return nil
- }
- for _, l := range layers.Items {
- if f := text(l.Lookup("sampleFile")); f != "" {
- files = append(files, f)
- }
- }
- return files
- }
- // usage counts the pad layers that use the pool file.
- func (p *Project) usage(file string) int {
- n := 0
- for _, pad := range p.pads().Items {
- for _, f := range padFiles(pad) {
- if f == file {
- n++
- }
- }
- }
- return n
- }
- // SampleCount returns the number of pool entries.
- func (p *Project) SampleCount() int { return len(p.pool().Items) }
- // AssignSample puts the WAV at src on the pad in slot, replacing what was
- // there. A file already in the pool from the same source is reused; a
- // different file with a name already in the pool gets a 001, 002 suffix.
- func (p *Project) AssignSample(slot int, src string) error {
- if err := checkSlot(slot); err != nil {
- return err
- }
- abs, err := filepath.Abs(src)
- if err != nil {
- return err
- }
- info, err := wav.ReadFile(abs)
- if err != nil {
- return fmt.Errorf("%s: %w", filepath.Base(abs), err)
- }
- if err := info.Validate(); err != nil {
- return fmt.Errorf("%s: %w", filepath.Base(abs), err)
- }
- file := p.fileForSource(abs)
- if file == "" {
- // A new pool entry is needed. Clearing the pad first may free one.
- freed := 0
- for _, f := range padFiles(p.pad(slot)) {
- if p.usage(f) == 1 {
- freed++
- }
- }
- if p.SampleCount()-freed >= MaxSamples {
- return fmt.Errorf("the project already has %d samples, the most the device allows", MaxSamples)
- }
- }
- // Keep the reused entry even if this pad was its only user.
- p.clear(slot, file)
- if file == "" {
- file = p.uniqueFile(filepath.Base(abs))
- if err := p.addPoolEntry(file); err != nil {
- return err
- }
- p.Sources[file] = abs
- }
- pad := p.pad(slot)
- l, err := layer(pad)
- if err != nil {
- return err
- }
- var e edit
- e.set(pad, "tempo", xpj.NewFloat(padTempo))
- e.set(l, "sampleName", xpj.NewString(stem(file)))
- e.set(l, "sampleFile", xpj.NewString(file))
- e.set(l, "sliceInfo.End", xpj.NewInt(info.Frames-1))
- e.set(l, "sliceInfo.LoopCrossfadeLength", xpj.NewInt(crossfadeLength))
- p.Dirty = true
- return e.err
- }
- // fileForSource returns the pool file already copied from src, if any.
- func (p *Project) fileForSource(src string) string {
- for file, s := range p.Sources {
- if s == src && p.inPool(file) {
- return file
- }
- }
- return ""
- }
- func (p *Project) inPool(file string) bool {
- for _, e := range p.pool().Items {
- if text(e.Lookup("path")) == file {
- return true
- }
- }
- return false
- }
- func stem(file string) string { return strings.TrimSuffix(file, filepath.Ext(file)) }
- // uniqueFile returns base, or base with a 001, 002 ... suffix before the
- // extension, so that no two pool files share a name. Names are compared
- // without case, as on Windows, macOS and FAT.
- func (p *Project) uniqueFile(base string) string {
- taken := map[string]bool{}
- for _, e := range p.pool().Items {
- taken[strings.ToLower(text(e.Lookup("path")))] = true
- taken[strings.ToLower(text(e.Lookup("name")))+"\x00"] = true
- }
- free := func(f string) bool {
- return !taken[strings.ToLower(f)] && !taken[strings.ToLower(stem(f))+"\x00"]
- }
- if free(base) {
- return base
- }
- ext := filepath.Ext(base)
- for i := 1; ; i++ {
- f := fmt.Sprintf("%s%03d%s", stem(base), i, ext)
- if free(f) {
- return f
- }
- }
- }
- // poolEntry builds a new pool entry in the device's key order.
- func poolEntry(file string) *xpj.Node {
- meta := xpj.NewObject()
- meta.Members = []xpj.Member{
- {Key: "tempo", Value: xpj.NewFloat(0)},
- {Key: "rootNote", Value: xpj.NewInt(newSampleRoot)},
- {Key: "tune", Value: xpj.NewFloat(0)},
- {Key: "key", Value: xpj.NewString(newSampleKey)},
- }
- e := xpj.NewObject()
- e.Members = []xpj.Member{
- {Key: "version", Value: xpj.NewInt(1)},
- {Key: "name", Value: xpj.NewString(stem(file))},
- {Key: "path", Value: xpj.NewString(file)},
- {Key: "loadImpl", Value: xpj.NewInt(0)},
- {Key: "metadata", Value: meta},
- }
- return e
- }
- func (p *Project) addPoolEntry(file string) error {
- if err := p.pool().Append(poolEntry(file)); err != nil {
- return err
- }
- return p.trackPool().Insert(0, poolEntry(file))
- }
- func (p *Project) removePoolEntry(file string) {
- for _, arr := range []*xpj.Node{p.pool(), p.trackPool()} {
- for i := len(arr.Items) - 1; i >= 0; i-- {
- if text(arr.Items[i].Lookup("path")) == file {
- _ = arr.Remove(i)
- }
- }
- }
- delete(p.Sources, file)
- }
- // ClearPad resets the pad in slot to the blank pad and drops pool entries
- // no other pad uses.
- func (p *Project) ClearPad(slot int) error {
- if err := checkSlot(slot); err != nil {
- return err
- }
- if len(padFiles(p.pad(slot))) == 0 {
- return nil
- }
- p.clear(slot, "")
- p.Dirty = true
- return nil
- }
- // clear resets the pad in slot and drops pool entries left unused, except keep.
- func (p *Project) clear(slot int, keep string) {
- files := padFiles(p.pad(slot))
- p.pads().Items[slot] = template.pad.Clone()
- for _, f := range files {
- if f != keep && p.usage(f) == 0 {
- p.removePoolEntry(f)
- }
- }
- }
- // CopyPad copies the whole pad in from onto to, replacing what was there.
- // Both pads share the pool entry.
- func (p *Project) CopyPad(from, to int) error {
- if err := checkSlot(from); err != nil {
- return err
- }
- if err := checkSlot(to); err != nil {
- return err
- }
- if from == to {
- return nil
- }
- src := p.pad(from).Clone()
- p.clear(to, "")
- p.pads().Items[to] = src
- p.Dirty = true
- return nil
- }
- // MovePad copies from onto to, then resets from to the blank pad. Recorded
- // notes stay on their slot; check PadHasNotes and warn first.
- func (p *Project) MovePad(from, to int) error {
- if from == to {
- return checkSlot(from)
- }
- if err := p.CopyPad(from, to); err != nil {
- return err
- }
- p.pads().Items[from] = template.pad.Clone()
- return nil
- }
- // SwapPads exchanges two pads.
- func (p *Project) SwapPads(a, b int) error {
- if err := checkSlot(a); err != nil {
- return err
- }
- if err := checkSlot(b); err != nil {
- return err
- }
- if a == b {
- return nil
- }
- items := p.pads().Items
- items[a], items[b] = items[b], items[a]
- p.Dirty = true
- return nil
- }
- // SetTune sets the pad's tuning, clamped to -24..+24 semitones and
- // -90..+90 cents. pitch is stored as a 32-bit float.
- func (p *Project) SetTune(slot, coarse, fine int) error {
- if err := checkSlot(slot); err != nil {
- return err
- }
- coarse = max(MinCoarse, min(MaxCoarse, coarse))
- fine = max(MinFine, min(MaxFine, fine))
- l, err := layer(p.pad(slot))
- if err != nil {
- return err
- }
- var e edit
- e.set(l, "coarseTune", xpj.NewInt(int64(coarse)))
- e.set(l, "fineTune", xpj.NewInt(int64(fine)))
- e.set(l, "pitch", xpj.NewFloat32(float64(coarse)+float64(fine)/100))
- p.Dirty = true
- return e.err
- }
- // SetMuteGroup sets the pad's mute group; 0 is off.
- func (p *Project) SetMuteGroup(slot, group int) error {
- if err := checkSlot(slot); err != nil {
- return err
- }
- if group < 0 || group > MaxMuteGroup {
- return fmt.Errorf("mute group %d out of range 0-%d", group, MaxMuteGroup)
- }
- var e edit
- e.set(p.pad(slot), "whichMuteGroup", xpj.NewInt(int64(group)))
- p.Dirty = true
- return e.err
- }
- // PadHasNotes reports whether any sequence has a note or automation event
- // for the pad in slot (note 36 + slot on the drum track).
- func (p *Project) PadHasNotes(slot int) bool {
- note := int64(FirstNote + slot)
- found := false
- p.eachDrumEvent(func(ev *xpj.Node) bool {
- var n *xpj.Node
- switch num(ev.Lookup("type")) {
- case eventNote:
- n = ev.Lookup("note")
- case eventAutomation:
- n = ev.Lookup("automation")
- }
- if n != nil && num(n.Lookup("note")) == note {
- found = true
- }
- return !found
- })
- return found
- }
- // PoolFiles returns the file name of every pool entry, in pool order.
- func (p *Project) PoolFiles() []string {
- var files []string
- for _, e := range p.pool().Items {
- files = append(files, text(e.Lookup("path")))
- }
- return files
- }
|