// 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) }) }