| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465 |
- // Package app is the Wails service the frontend calls. It owns the current
- // project and returns a fresh State after every call; the frontend keeps no
- // project data of its own (build plan 4.6 and 5).
- package app
- import (
- "crypto/sha256"
- "encoding/hex"
- "errors"
- "fmt"
- "path/filepath"
- "strings"
- "sync"
- "git.monoexp.com/bajdev/mpckit/internal/export"
- "git.monoexp.com/bajdev/mpckit/internal/project"
- )
- // Version and Commit are set by production builds (Taskfile.yml) with
- // -ldflags "-X git.monoexp.com/bajdev/mpckit/internal/app.Version=0.5.2
- // -X git.monoexp.com/bajdev/mpckit/internal/app.Commit=2354f57".
- var (
- Version = "dev"
- Commit = ""
- )
- // DefaultName is the name of a new project.
- const DefaultName = "NewKit"
- // Pad is one pad as the frontend shows it.
- type Pad struct {
- Slot int `json:"slot"`
- Label string `json:"label"` // "A01" .. "H16"
- Name string `json:"name"` // sample name; empty for an empty pad
- File string `json:"file"` // pool file name
- Coarse int `json:"coarse"`
- Fine int `json:"fine"`
- MuteGroup int `json:"muteGroup"`
- HasNotes bool `json:"hasNotes"`
- // Audio is the URL the preview loads the sample from; empty for an
- // empty pad. It changes when the pad's source file changes.
- Audio string `json:"audio"`
- // Start and End are the slice's first and last frame. The preview
- // plays only this region, as the device does.
- Start int64 `json:"start"`
- End int64 `json:"end"`
- // Source is where the sample is copied from at export; Missing is set
- // when that file no longer exists.
- Source string `json:"source"`
- Missing bool `json:"missing"`
- }
- // Sequence is one existing sequence.
- type Sequence struct {
- Slot int `json:"slot"`
- Label string `json:"label"` // slot label, "A01" .. "H16"
- Name string `json:"name"`
- BPM float64 `json:"bpm"`
- Bars int `json:"bars"`
- HasNotes bool `json:"hasNotes"`
- }
- // Limits are the ranges the UI offers, from the device checks.
- type Limits struct {
- Pads int `json:"pads"`
- MaxSamples int `json:"maxSamples"`
- MaxSequences int `json:"maxSequences"`
- MinCoarse int `json:"minCoarse"`
- MaxCoarse int `json:"maxCoarse"`
- MinFine int `json:"minFine"`
- MaxFine int `json:"maxFine"`
- MaxMuteGroup int `json:"maxMuteGroup"`
- MinBPM float64 `json:"minBPM"`
- MaxBPM float64 `json:"maxBPM"`
- MaxBars int `json:"maxBars"`
- }
- // State is everything the frontend shows about the project.
- type State struct {
- Name string `json:"name"`
- Dirty bool `json:"dirty"`
- Pads []Pad `json:"pads"` // all 128, in slot order
- Sequences []Sequence `json:"sequences"` // existing ones, in slot order
- SampleCount int `json:"sampleCount"`
- MissingCount int `json:"missingCount"` // pool files whose source is missing
- SequenceCount int `json:"sequenceCount"`
- Limits Limits `json:"limits"`
- }
- // About is the text for the About dialog.
- type About struct {
- Version string `json:"version"`
- Commit string `json:"commit"` // empty in dev builds
- License string `json:"license"`
- Repo string `json:"repo"`
- }
- // LoadResult is what Load returns. Path is empty if the dialog was
- // cancelled. Warnings describe differences from the tested firmware.
- type LoadResult struct {
- Path string `json:"path"`
- State State `json:"state"`
- Warnings []string `json:"warnings"`
- }
- // ExportResult is what an export call returns. If Exists is not empty,
- // nothing was written: the frontend asks the user, then calls again with
- // overwrite set.
- type ExportResult struct {
- Path string `json:"path"`
- Exists []string `json:"exists"`
- }
- // Dialogs are the native file dialogs. Each returns "" when the user
- // cancels. main.go implements them with Wails; tests use a fake.
- type Dialogs interface {
- OpenWAV() (string, error)
- OpenProject() (string, error)
- ChooseFolder() (string, error)
- SaveZip(defaultName string) (string, error)
- }
- // Service is registered with Wails; its exported methods become the
- // frontend bindings.
- type Service struct {
- mu sync.Mutex
- p *project.Project
- dialogs Dialogs
- closer func() // closes the window without asking; set by main.go
- // Where the next export goes, as chosen in the dialog. The frontend
- // never passes a path itself, so a script in the window cannot make
- // MPCkit write (or, with overwrite, delete) anywhere else.
- exportDir, zipPath string
- }
- // SetCloser sets what CloseWindow calls.
- //
- //wails:ignore
- func (s *Service) SetCloser(f func()) { s.closer = f }
- // Dirty reports whether the project has changes not yet exported.
- //
- //wails:ignore
- func (s *Service) Dirty() bool {
- s.mu.Lock()
- defer s.mu.Unlock()
- return s.p.Dirty()
- }
- // CloseWindow closes the app without asking about unsaved changes. The
- // frontend calls it after the user chose to discard them.
- func (s *Service) CloseWindow() {
- if s.closer != nil {
- s.closer()
- }
- }
- // NewService returns a service holding a new, empty project.
- func NewService(d Dialogs) *Service {
- s := &Service{dialogs: d}
- s.reset()
- return s
- }
- func (s *Service) reset() {
- p, err := project.New(DefaultName)
- if err != nil {
- panic(err) // the embedded template and a constant name
- }
- s.use(p)
- }
- // use makes p the current project and forgets the export targets chosen
- // for the previous one. Callers hold s.mu.
- func (s *Service) use(p *project.Project) {
- s.p = p
- s.exportDir, s.zipPath = "", ""
- }
- // New replaces the current project with a new, empty one.
- func (s *Service) New() State {
- s.mu.Lock()
- defer s.mu.Unlock()
- s.reset()
- return s.state()
- }
- // State returns the current project's state.
- func (s *Service) State() State {
- s.mu.Lock()
- defer s.mu.Unlock()
- return s.state()
- }
- // Version returns the app version.
- func (s *Service) Version() string { return Version }
- // About returns the About dialog text.
- func (s *Service) About() About {
- return About{
- Version: Version,
- Commit: Commit,
- License: "MIT License, copyright (c) 2026 bajdev",
- Repo: "https://git.monoexp.com/bajdev/mpckit",
- }
- }
- // Label returns the label the UI and the device use for a pad or sequence
- // slot: bank letter and two-digit pad number, e.g. 0 -> "A01", 17 -> "B02".
- func Label(slot int) string {
- return fmt.Sprintf("%c%02d", 'A'+slot/16, slot%16+1)
- }
- func (s *Service) state() State {
- p := s.p
- st := State{
- Name: p.Name,
- Dirty: p.Dirty(),
- Pads: make([]Pad, 0, project.Pads),
- Sequences: []Sequence{},
- SampleCount: p.SampleCount(),
- Limits: Limits{
- Pads: project.Pads,
- MaxSamples: project.MaxSamples,
- MaxSequences: project.MaxSequences,
- MinCoarse: project.MinCoarse,
- MaxCoarse: project.MaxCoarse,
- MinFine: project.MinFine,
- MaxFine: project.MaxFine,
- MaxMuteGroup: project.MaxMuteGroup,
- MinBPM: project.MinBPM,
- MaxBPM: project.MaxBPM,
- MaxBars: project.MaxBars,
- },
- }
- missing := map[string]bool{} // pool file -> source missing, as export sees it
- for _, f := range p.PoolFiles() {
- src, _ := p.Source(f)
- missing[f] = src == "" || !export.Usable(src)
- }
- for slot := 0; slot < project.Pads; slot++ {
- v, err := p.Pad(slot)
- if err != nil {
- // A loaded file with an odd pad; show it as empty.
- st.Pads = append(st.Pads, Pad{Slot: slot, Label: Label(slot)})
- continue
- }
- pad := Pad{Slot: slot, Label: Label(slot), Name: v.SampleName, File: v.File,
- Coarse: v.Coarse, Fine: v.Fine, MuteGroup: v.MuteGroup, Start: v.Start, End: v.End}
- if !v.Empty() {
- pad.HasNotes = p.PadHasNotes(slot)
- pad.Source, _ = p.Source(v.File)
- pad.Missing = missing[v.File]
- if !pad.Missing {
- pad.Audio = audioURL(slot, pad.Source)
- }
- }
- st.Pads = append(st.Pads, pad)
- }
- for _, q := range p.Sequences() {
- st.Sequences = append(st.Sequences, Sequence{Slot: q.Slot, Label: Label(q.Slot),
- Name: q.Name, BPM: q.BPM, Bars: q.Bars, HasNotes: q.HasNotes})
- }
- st.SequenceCount = len(st.Sequences)
- for _, m := range missing {
- if m {
- st.MissingCount++
- }
- }
- return st
- }
- // audioURL is the preview URL for a pad. The key in the query changes with
- // the source file, so the frontend's cache never plays a stale sample.
- func audioURL(slot int, src string) string {
- sum := sha256.Sum256([]byte(src))
- return fmt.Sprintf("%s%d?k=%s", AudioPrefix, slot, hex.EncodeToString(sum[:6]))
- }
- // edit runs fn on the project under the lock and returns the new state.
- // The state is returned even on error, so the frontend stays in step.
- func (s *Service) edit(fn func(p *project.Project) error) (State, error) {
- s.mu.Lock()
- defer s.mu.Unlock()
- err := fn(s.p)
- return s.state(), err
- }
- // SetName renames the project.
- func (s *Service) SetName(name string) (State, error) {
- return s.edit(func(p *project.Project) error { return p.SetName(strings.TrimSpace(name)) })
- }
- // AssignSampleDialog asks for a WAV file and puts it on the pad in slot.
- // Cancelling the dialog changes nothing.
- func (s *Service) AssignSampleDialog(slot int) (State, error) {
- path, err := s.dialogs.OpenWAV()
- if err != nil || path == "" {
- return s.State(), err
- }
- return s.AssignSamplePath(slot, path)
- }
- // AssignSamplePath puts the WAV at path on the pad in slot. main.go's file
- // drop handler uses it; it is not bound, so the window cannot pass a path.
- //
- //wails:ignore
- func (s *Service) AssignSamplePath(slot int, path string) (State, error) {
- return s.edit(func(p *project.Project) error { return p.AssignSample(slot, path) })
- }
- // ClearPad empties the pad in slot.
- func (s *Service) ClearPad(slot int) (State, error) {
- return s.edit(func(p *project.Project) error { return p.ClearPad(slot) })
- }
- // SetTune sets a pad's tuning in semitones and cents.
- func (s *Service) SetTune(slot, coarse, fine int) (State, error) {
- return s.edit(func(p *project.Project) error { return p.SetTune(slot, coarse, fine) })
- }
- // SetMuteGroup sets a pad's mute group; 0 is off.
- func (s *Service) SetMuteGroup(slot, group int) (State, error) {
- return s.edit(func(p *project.Project) error { return p.SetMuteGroup(slot, group) })
- }
- // ChooseExportFolder asks for the folder to export into and remembers it
- // for ExportFolder. It returns the folder, or "" if cancelled.
- func (s *Service) ChooseExportFolder() (string, error) {
- dir, err := s.dialogs.ChooseFolder()
- s.mu.Lock()
- defer s.mu.Unlock()
- s.exportDir = ""
- if err == nil {
- s.exportDir = dir
- }
- return dir, err
- }
- // ChooseZipPath asks where to save the zip and remembers it for ExportZip.
- // It returns the path, or "" if cancelled.
- func (s *Service) ChooseZipPath() (string, error) {
- s.mu.Lock()
- name := s.p.Name
- s.mu.Unlock()
- path, err := s.dialogs.SaveZip(name + ".zip")
- if path != "" && !strings.EqualFold(filepath.Ext(path), ".zip") {
- path += ".zip"
- }
- s.mu.Lock()
- defer s.mu.Unlock()
- s.zipPath = ""
- if err == nil {
- s.zipPath = path
- }
- return path, err
- }
- // errNoTarget is returned by an export with no target chosen.
- var errNoTarget = errors.New("choose where to export first")
- // ExportFolder writes the project into the folder ChooseExportFolder
- // chose. If the project is already there and overwrite is false, nothing
- // is written and Exists lists it.
- func (s *Service) ExportFolder(overwrite bool) (ExportResult, error) {
- s.mu.Lock()
- defer s.mu.Unlock()
- if s.exportDir == "" {
- return ExportResult{}, errNoTarget
- }
- path, err := export.Folder(s.p, s.exportDir, overwrite)
- return s.exported(path, err)
- }
- // ExportZip writes the project as a zip where ChooseZipPath chose. If the
- // file exists and overwrite is false, nothing is written and Exists lists it.
- func (s *Service) ExportZip(overwrite bool) (ExportResult, error) {
- s.mu.Lock()
- defer s.mu.Unlock()
- if s.zipPath == "" {
- return ExportResult{}, errNoTarget
- }
- path := s.zipPath
- err := export.Zip(s.p, path, overwrite)
- return s.exported(path, err)
- }
- func (s *Service) exported(path string, err error) (ExportResult, error) {
- var exists *export.ExistsError
- if errors.As(err, &exists) {
- return ExportResult{Exists: exists.Paths}, nil
- }
- if err != nil {
- return ExportResult{}, err
- }
- s.p.MarkExported()
- s.exportDir, s.zipPath = "", "" // the next export asks again
- return ExportResult{Path: path}, nil
- }
- // MovePad moves the pad in from to slot to, replacing what was there.
- // Recorded notes stay on their slots; the frontend warns first.
- func (s *Service) MovePad(from, to int) (State, error) {
- return s.edit(func(p *project.Project) error { return p.MovePad(from, to) })
- }
- // CopyPad copies the pad in from to slot to, replacing what was there.
- func (s *Service) CopyPad(from, to int) (State, error) {
- return s.edit(func(p *project.Project) error { return p.CopyPad(from, to) })
- }
- // SwapPads exchanges two pads.
- func (s *Service) SwapPads(a, b int) (State, error) {
- return s.edit(func(p *project.Project) error { return p.SwapPads(a, b) })
- }
- // Load asks for a project file and opens it in place of the current one.
- // The frontend asks about unsaved changes first.
- func (s *Service) Load() (LoadResult, error) {
- path, err := s.dialogs.OpenProject()
- if err != nil || path == "" {
- return LoadResult{State: s.State()}, err
- }
- return s.LoadPath(path)
- }
- // LoadPath opens the project at path in place of the current one. Load
- // uses it after the dialog; it is not bound, so the window cannot pass a
- // path.
- //
- //wails:ignore
- func (s *Service) LoadPath(path string) (LoadResult, error) {
- p, warnings, err := project.Load(path)
- if err != nil {
- return LoadResult{State: s.State()}, fmt.Errorf("%s: %w", filepath.Base(path), err)
- }
- s.mu.Lock()
- defer s.mu.Unlock()
- s.use(p)
- if warnings == nil {
- warnings = []string{}
- }
- return LoadResult{Path: path, State: s.state(), Warnings: warnings}, nil
- }
- // AddSequence adds a sequence in an empty slot: "Sequence NN", 2 bars, at
- // the BPM of the most recently edited sequence (else 120).
- func (s *Service) AddSequence(slot int) (State, error) {
- return s.edit(func(p *project.Project) error { return p.AddSequence(slot) })
- }
- // RemoveSequence deletes a sequence and its recorded notes. The last
- // sequence cannot be removed.
- func (s *Service) RemoveSequence(slot int) (State, error) {
- return s.edit(func(p *project.Project) error { return p.RemoveSequence(slot) })
- }
- // SetSequence sets a sequence's name, BPM and length in bars. Notes past a
- // new, shorter end are left in the file.
- func (s *Service) SetSequence(slot int, name string, bpm float64, bars int) (State, error) {
- return s.edit(func(p *project.Project) error {
- return p.SetSequence(slot, strings.TrimSpace(name), bpm, bars)
- })
- }
|