app.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420
  1. // Package app is the Wails service the frontend calls. It owns the current
  2. // project and returns a fresh State after every call; the frontend keeps no
  3. // project data of its own (build plan 4.6 and 5).
  4. package app
  5. import (
  6. "crypto/sha256"
  7. "encoding/hex"
  8. "errors"
  9. "fmt"
  10. "os"
  11. "path/filepath"
  12. "strings"
  13. "sync"
  14. "git.monoexp.com/bajdev/mpckit/internal/export"
  15. "git.monoexp.com/bajdev/mpckit/internal/project"
  16. )
  17. // Version and Commit are set by production builds (Taskfile.yml) with
  18. // -ldflags "-X git.monoexp.com/bajdev/mpckit/internal/app.Version=0.5.0
  19. // -X git.monoexp.com/bajdev/mpckit/internal/app.Commit=2354f57".
  20. var (
  21. Version = "dev"
  22. Commit = ""
  23. )
  24. // DefaultName is the name of a new project.
  25. const DefaultName = "NewKit"
  26. // Pad is one pad as the frontend shows it.
  27. type Pad struct {
  28. Slot int `json:"slot"`
  29. Label string `json:"label"` // "A01" .. "H16"
  30. Name string `json:"name"` // sample name; empty for an empty pad
  31. File string `json:"file"` // pool file name
  32. Coarse int `json:"coarse"`
  33. Fine int `json:"fine"`
  34. MuteGroup int `json:"muteGroup"`
  35. HasNotes bool `json:"hasNotes"`
  36. // Audio is the URL the preview loads the sample from; empty for an
  37. // empty pad. It changes when the pad's source file changes.
  38. Audio string `json:"audio"`
  39. // Start and End are the slice's first and last frame. The preview
  40. // plays only this region, as the device does.
  41. Start int64 `json:"start"`
  42. End int64 `json:"end"`
  43. // Source is where the sample is copied from at export; Missing is set
  44. // when that file no longer exists.
  45. Source string `json:"source"`
  46. Missing bool `json:"missing"`
  47. }
  48. // Sequence is one existing sequence.
  49. type Sequence struct {
  50. Slot int `json:"slot"`
  51. Label string `json:"label"` // slot label, "A01" .. "H16"
  52. Name string `json:"name"`
  53. BPM float64 `json:"bpm"`
  54. Bars int `json:"bars"`
  55. HasNotes bool `json:"hasNotes"`
  56. }
  57. // Limits are the ranges the UI offers, from the device checks.
  58. type Limits struct {
  59. Pads int `json:"pads"`
  60. MaxSamples int `json:"maxSamples"`
  61. MaxSequences int `json:"maxSequences"`
  62. MinCoarse int `json:"minCoarse"`
  63. MaxCoarse int `json:"maxCoarse"`
  64. MinFine int `json:"minFine"`
  65. MaxFine int `json:"maxFine"`
  66. MaxMuteGroup int `json:"maxMuteGroup"`
  67. MinBPM float64 `json:"minBPM"`
  68. MaxBPM float64 `json:"maxBPM"`
  69. MaxBars int `json:"maxBars"`
  70. }
  71. // State is everything the frontend shows about the project.
  72. type State struct {
  73. Name string `json:"name"`
  74. Dirty bool `json:"dirty"`
  75. Pads []Pad `json:"pads"` // all 128, in slot order
  76. Sequences []Sequence `json:"sequences"` // existing ones, in slot order
  77. SampleCount int `json:"sampleCount"`
  78. MissingCount int `json:"missingCount"` // pool files whose source is missing
  79. SequenceCount int `json:"sequenceCount"`
  80. Limits Limits `json:"limits"`
  81. }
  82. // About is the text for the About dialog.
  83. type About struct {
  84. Version string `json:"version"`
  85. Commit string `json:"commit"` // empty in dev builds
  86. License string `json:"license"`
  87. Repo string `json:"repo"`
  88. }
  89. // LoadResult is what Load returns. Path is empty if the dialog was
  90. // cancelled. Warnings describe differences from the tested firmware.
  91. type LoadResult struct {
  92. Path string `json:"path"`
  93. State State `json:"state"`
  94. Warnings []string `json:"warnings"`
  95. }
  96. // ExportResult is what an export call returns. If Exists is not empty,
  97. // nothing was written: the frontend asks the user, then calls again with
  98. // overwrite set.
  99. type ExportResult struct {
  100. Path string `json:"path"`
  101. Exists []string `json:"exists"`
  102. }
  103. // Dialogs are the native file dialogs. Each returns "" when the user
  104. // cancels. main.go implements them with Wails; tests use a fake.
  105. type Dialogs interface {
  106. OpenWAV() (string, error)
  107. OpenProject() (string, error)
  108. ChooseFolder() (string, error)
  109. SaveZip(defaultName string) (string, error)
  110. }
  111. // Service is registered with Wails; its exported methods become the
  112. // frontend bindings.
  113. type Service struct {
  114. mu sync.Mutex
  115. p *project.Project
  116. dialogs Dialogs
  117. closer func() // closes the window without asking; set by main.go
  118. }
  119. // SetCloser sets what CloseWindow calls.
  120. //
  121. //wails:ignore
  122. func (s *Service) SetCloser(f func()) { s.closer = f }
  123. // Dirty reports whether the project has changes not yet exported.
  124. //
  125. //wails:ignore
  126. func (s *Service) Dirty() bool {
  127. s.mu.Lock()
  128. defer s.mu.Unlock()
  129. return s.p.Dirty
  130. }
  131. // CloseWindow closes the app without asking about unsaved changes. The
  132. // frontend calls it after the user chose to discard them.
  133. func (s *Service) CloseWindow() {
  134. if s.closer != nil {
  135. s.closer()
  136. }
  137. }
  138. // NewService returns a service holding a new, empty project.
  139. func NewService(d Dialogs) *Service {
  140. s := &Service{dialogs: d}
  141. s.reset()
  142. return s
  143. }
  144. func (s *Service) reset() {
  145. p, err := project.New(DefaultName)
  146. if err != nil {
  147. panic(err) // the embedded template and a constant name
  148. }
  149. s.p = p
  150. }
  151. // New replaces the current project with a new, empty one.
  152. func (s *Service) New() State {
  153. s.mu.Lock()
  154. defer s.mu.Unlock()
  155. s.reset()
  156. return s.state()
  157. }
  158. // State returns the current project's state.
  159. func (s *Service) State() State {
  160. s.mu.Lock()
  161. defer s.mu.Unlock()
  162. return s.state()
  163. }
  164. // Version returns the app version.
  165. func (s *Service) Version() string { return Version }
  166. // About returns the About dialog text.
  167. func (s *Service) About() About {
  168. return About{
  169. Version: Version,
  170. Commit: Commit,
  171. License: "MIT License, copyright (c) 2026 bajdev",
  172. Repo: "https://git.monoexp.com/bajdev/mpckit",
  173. }
  174. }
  175. // Label returns the label the UI and the device use for a pad or sequence
  176. // slot: bank letter and two-digit pad number, e.g. 0 -> "A01", 17 -> "B02".
  177. func Label(slot int) string {
  178. return fmt.Sprintf("%c%02d", 'A'+slot/16, slot%16+1)
  179. }
  180. func (s *Service) state() State {
  181. p := s.p
  182. st := State{
  183. Name: p.Name,
  184. Dirty: p.Dirty,
  185. Pads: make([]Pad, 0, project.Pads),
  186. Sequences: []Sequence{},
  187. SampleCount: p.SampleCount(),
  188. Limits: Limits{
  189. Pads: project.Pads,
  190. MaxSamples: project.MaxSamples,
  191. MaxSequences: project.MaxSequences,
  192. MinCoarse: project.MinCoarse,
  193. MaxCoarse: project.MaxCoarse,
  194. MinFine: project.MinFine,
  195. MaxFine: project.MaxFine,
  196. MaxMuteGroup: project.MaxMuteGroup,
  197. MinBPM: project.MinBPM,
  198. MaxBPM: project.MaxBPM,
  199. MaxBars: project.MaxBars,
  200. },
  201. }
  202. missing := map[string]bool{} // pool file -> source missing
  203. for _, f := range p.PoolFiles() {
  204. src := p.Sources[f]
  205. info, err := os.Stat(src)
  206. missing[f] = src == "" || err != nil || !info.Mode().IsRegular()
  207. }
  208. for slot := 0; slot < project.Pads; slot++ {
  209. v, err := p.Pad(slot)
  210. if err != nil {
  211. // A loaded file with an odd pad; show it as empty.
  212. st.Pads = append(st.Pads, Pad{Slot: slot, Label: Label(slot)})
  213. continue
  214. }
  215. pad := Pad{Slot: slot, Label: Label(slot), Name: v.SampleName, File: v.File,
  216. Coarse: v.Coarse, Fine: v.Fine, MuteGroup: v.MuteGroup, Start: v.Start, End: v.End}
  217. if !v.Empty() {
  218. pad.HasNotes = p.PadHasNotes(slot)
  219. pad.Source = p.Sources[v.File]
  220. pad.Missing = missing[v.File]
  221. if !pad.Missing {
  222. pad.Audio = audioURL(slot, pad.Source)
  223. }
  224. }
  225. st.Pads = append(st.Pads, pad)
  226. }
  227. for _, q := range p.Sequences() {
  228. st.Sequences = append(st.Sequences, Sequence{Slot: q.Slot, Label: Label(q.Slot),
  229. Name: q.Name, BPM: q.BPM, Bars: q.Bars, HasNotes: q.HasNotes})
  230. }
  231. st.SequenceCount = len(st.Sequences)
  232. for _, m := range missing {
  233. if m {
  234. st.MissingCount++
  235. }
  236. }
  237. return st
  238. }
  239. // audioURL is the preview URL for a pad. The key in the query changes with
  240. // the source file, so the frontend's cache never plays a stale sample.
  241. func audioURL(slot int, src string) string {
  242. sum := sha256.Sum256([]byte(src))
  243. return fmt.Sprintf("%s%d?k=%s", AudioPrefix, slot, hex.EncodeToString(sum[:6]))
  244. }
  245. // edit runs fn on the project under the lock and returns the new state.
  246. // The state is returned even on error, so the frontend stays in step.
  247. func (s *Service) edit(fn func(p *project.Project) error) (State, error) {
  248. s.mu.Lock()
  249. defer s.mu.Unlock()
  250. err := fn(s.p)
  251. return s.state(), err
  252. }
  253. // SetName renames the project.
  254. func (s *Service) SetName(name string) (State, error) {
  255. return s.edit(func(p *project.Project) error { return p.SetName(strings.TrimSpace(name)) })
  256. }
  257. // AssignSampleDialog asks for a WAV file and puts it on the pad in slot.
  258. // Cancelling the dialog changes nothing.
  259. func (s *Service) AssignSampleDialog(slot int) (State, error) {
  260. path, err := s.dialogs.OpenWAV()
  261. if err != nil || path == "" {
  262. return s.State(), err
  263. }
  264. return s.AssignSamplePath(slot, path)
  265. }
  266. // AssignSamplePath puts the WAV at path on the pad in slot. File drop uses it.
  267. func (s *Service) AssignSamplePath(slot int, path string) (State, error) {
  268. return s.edit(func(p *project.Project) error { return p.AssignSample(slot, path) })
  269. }
  270. // ClearPad empties the pad in slot.
  271. func (s *Service) ClearPad(slot int) (State, error) {
  272. return s.edit(func(p *project.Project) error { return p.ClearPad(slot) })
  273. }
  274. // SetTune sets a pad's tuning in semitones and cents.
  275. func (s *Service) SetTune(slot, coarse, fine int) (State, error) {
  276. return s.edit(func(p *project.Project) error { return p.SetTune(slot, coarse, fine) })
  277. }
  278. // SetMuteGroup sets a pad's mute group; 0 is off.
  279. func (s *Service) SetMuteGroup(slot, group int) (State, error) {
  280. return s.edit(func(p *project.Project) error { return p.SetMuteGroup(slot, group) })
  281. }
  282. // ChooseExportFolder asks for the folder to export into; "" if cancelled.
  283. func (s *Service) ChooseExportFolder() (string, error) {
  284. return s.dialogs.ChooseFolder()
  285. }
  286. // ChooseZipPath asks where to save the zip; "" if cancelled.
  287. func (s *Service) ChooseZipPath() (string, error) {
  288. s.mu.Lock()
  289. name := s.p.Name
  290. s.mu.Unlock()
  291. return s.dialogs.SaveZip(name + ".zip")
  292. }
  293. // ExportFolder writes the project into dir. If the project is already
  294. // there and overwrite is false, nothing is written and Exists lists it.
  295. func (s *Service) ExportFolder(dir string, overwrite bool) (ExportResult, error) {
  296. s.mu.Lock()
  297. defer s.mu.Unlock()
  298. path, err := export.Folder(s.p, dir, overwrite)
  299. return s.exported(path, err)
  300. }
  301. // ExportZip writes the project as a zip at path. If the file exists and
  302. // overwrite is false, nothing is written and Exists lists it.
  303. func (s *Service) ExportZip(path string, overwrite bool) (ExportResult, error) {
  304. s.mu.Lock()
  305. defer s.mu.Unlock()
  306. if !strings.EqualFold(filepath.Ext(path), ".zip") {
  307. path += ".zip"
  308. }
  309. err := export.Zip(s.p, path, overwrite)
  310. return s.exported(path, err)
  311. }
  312. func (s *Service) exported(path string, err error) (ExportResult, error) {
  313. var exists *export.ExistsError
  314. if errors.As(err, &exists) {
  315. return ExportResult{Exists: exists.Paths}, nil
  316. }
  317. if err != nil {
  318. return ExportResult{}, err
  319. }
  320. s.p.Dirty = false
  321. return ExportResult{Path: path}, nil
  322. }
  323. // MovePad moves the pad in from to slot to, replacing what was there.
  324. // Recorded notes stay on their slots; the frontend warns first.
  325. func (s *Service) MovePad(from, to int) (State, error) {
  326. return s.edit(func(p *project.Project) error { return p.MovePad(from, to) })
  327. }
  328. // CopyPad copies the pad in from to slot to, replacing what was there.
  329. func (s *Service) CopyPad(from, to int) (State, error) {
  330. return s.edit(func(p *project.Project) error { return p.CopyPad(from, to) })
  331. }
  332. // SwapPads exchanges two pads.
  333. func (s *Service) SwapPads(a, b int) (State, error) {
  334. return s.edit(func(p *project.Project) error { return p.SwapPads(a, b) })
  335. }
  336. // Load asks for a project file and opens it in place of the current one.
  337. // The frontend asks about unsaved changes first.
  338. func (s *Service) Load() (LoadResult, error) {
  339. path, err := s.dialogs.OpenProject()
  340. if err != nil || path == "" {
  341. return LoadResult{State: s.State()}, err
  342. }
  343. return s.LoadPath(path)
  344. }
  345. // LoadPath opens the project at path in place of the current one.
  346. func (s *Service) LoadPath(path string) (LoadResult, error) {
  347. p, warnings, err := project.Load(path)
  348. if err != nil {
  349. return LoadResult{State: s.State()}, fmt.Errorf("%s: %w", filepath.Base(path), err)
  350. }
  351. s.mu.Lock()
  352. defer s.mu.Unlock()
  353. s.p = p
  354. if warnings == nil {
  355. warnings = []string{}
  356. }
  357. return LoadResult{Path: path, State: s.state(), Warnings: warnings}, nil
  358. }
  359. // AddSequence adds a sequence in an empty slot: "Sequence NN", 2 bars, at
  360. // the BPM of the most recently edited sequence (else 120).
  361. func (s *Service) AddSequence(slot int) (State, error) {
  362. return s.edit(func(p *project.Project) error { return p.AddSequence(slot) })
  363. }
  364. // RemoveSequence deletes a sequence and its recorded notes. The last
  365. // sequence cannot be removed.
  366. func (s *Service) RemoveSequence(slot int) (State, error) {
  367. return s.edit(func(p *project.Project) error { return p.RemoveSequence(slot) })
  368. }
  369. // SetSequence sets a sequence's name, BPM and length in bars. Notes past a
  370. // new, shorter end are left in the file.
  371. func (s *Service) SetSequence(slot int, name string, bpm float64, bars int) (State, error) {
  372. return s.edit(func(p *project.Project) error {
  373. return p.SetSequence(slot, strings.TrimSpace(name), bpm, bars)
  374. })
  375. }