app.go 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507
  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. "path/filepath"
  11. "strings"
  12. "sync"
  13. "git.monoexp.com/bajdev/mpckit/internal/export"
  14. "git.monoexp.com/bajdev/mpckit/internal/project"
  15. )
  16. // Version and Commit are set by production builds (Taskfile.yml) with
  17. // -ldflags "-X git.monoexp.com/bajdev/mpckit/internal/app.Version=0.5.3
  18. // -X git.monoexp.com/bajdev/mpckit/internal/app.Commit=2354f57".
  19. var (
  20. Version = "dev"
  21. Commit = ""
  22. )
  23. // DefaultName is the name of a new project.
  24. const DefaultName = "NewKit"
  25. // Pad is one pad as the frontend shows it.
  26. type Pad struct {
  27. Slot int `json:"slot"`
  28. Label string `json:"label"` // "A1" .. "H16"
  29. Name string `json:"name"` // sample name; empty for an empty pad
  30. File string `json:"file"` // pool file name
  31. Coarse int `json:"coarse"`
  32. Fine int `json:"fine"`
  33. MuteGroup int `json:"muteGroup"`
  34. HasNotes bool `json:"hasNotes"`
  35. // Audio is the URL the preview loads the sample from; empty for an
  36. // empty pad. It changes when the pad's source file changes.
  37. Audio string `json:"audio"`
  38. // Start and End are the slice's first and last frame. The preview
  39. // plays only this region, as the device does.
  40. Start int64 `json:"start"`
  41. End int64 `json:"end"`
  42. // Source is where the sample is copied from at export; Missing is set
  43. // when that file no longer exists.
  44. Source string `json:"source"`
  45. Missing bool `json:"missing"`
  46. }
  47. // Sequence is one existing sequence.
  48. type Sequence struct {
  49. Slot int `json:"slot"`
  50. Label string `json:"label"` // slot label, "A1" .. "H16"
  51. Name string `json:"name"`
  52. BPM float64 `json:"bpm"`
  53. Bars int `json:"bars"`
  54. HasNotes bool `json:"hasNotes"`
  55. }
  56. // Limits are the ranges the UI offers, from the device checks.
  57. type Limits struct {
  58. Pads int `json:"pads"`
  59. MaxSamples int `json:"maxSamples"`
  60. MaxSequences int `json:"maxSequences"`
  61. MinCoarse int `json:"minCoarse"`
  62. MaxCoarse int `json:"maxCoarse"`
  63. MinFine int `json:"minFine"`
  64. MaxFine int `json:"maxFine"`
  65. MaxMuteGroup int `json:"maxMuteGroup"`
  66. MinBPM float64 `json:"minBPM"`
  67. MaxBPM float64 `json:"maxBPM"`
  68. MaxBars int `json:"maxBars"`
  69. }
  70. // State is everything the frontend shows about the project.
  71. type State struct {
  72. Name string `json:"name"`
  73. Dirty bool `json:"dirty"`
  74. Pads []Pad `json:"pads"` // all 128, in slot order
  75. Sequences []Sequence `json:"sequences"` // existing ones, in slot order
  76. SampleCount int `json:"sampleCount"`
  77. MissingCount int `json:"missingCount"` // pool files whose source is missing
  78. SequenceCount int `json:"sequenceCount"`
  79. Limits Limits `json:"limits"`
  80. }
  81. // About is the text for the About dialog.
  82. type About struct {
  83. Version string `json:"version"`
  84. Commit string `json:"commit"` // empty in dev builds
  85. License string `json:"license"`
  86. Repo string `json:"repo"`
  87. }
  88. // LoadResult is what Load returns. Path is empty if the dialog was
  89. // cancelled. Warnings describe differences from the tested firmware.
  90. type LoadResult struct {
  91. Path string `json:"path"`
  92. State State `json:"state"`
  93. Warnings []string `json:"warnings"`
  94. }
  95. // ExportResult is what an export call returns. If Exists is not empty,
  96. // nothing was written: the frontend asks the user, then calls again with
  97. // overwrite set.
  98. type ExportResult struct {
  99. Path string `json:"path"`
  100. Exists []string `json:"exists"`
  101. }
  102. // Dialogs are the native file dialogs. Each returns "" when the user
  103. // cancels. main.go implements them with Wails; tests use a fake.
  104. type Dialogs interface {
  105. OpenWAV() (string, error)
  106. OpenProject() (string, error)
  107. ChooseFolder() (string, error)
  108. SaveZip(defaultName string) (string, error)
  109. }
  110. // Service is the type main.go binds. Wails registers every exported method
  111. // of a bound type by reflection, whatever the comments say, so the window's
  112. // scripts can call each one by name. Service therefore has only the methods
  113. // the frontend needs, none of which takes a file path; TestBoundMethods
  114. // keeps that list on purpose. Go-only methods are on Host.
  115. type Service struct {
  116. mu sync.Mutex
  117. p *project.Project
  118. dialogs Dialogs
  119. closer func() // closes the window without asking; set by main.go
  120. // Where the next export goes, as chosen in the dialog. The frontend
  121. // never passes a path itself, so a script in the window cannot make
  122. // MPCkit write (or, with overwrite, delete) anywhere else.
  123. exportDir, zipPath string
  124. }
  125. // Host gives main.go what the window must not reach: loading and
  126. // assigning by path, the dirty flag, the window closer and the audio
  127. // server. It shares the Service's state but is not bound.
  128. type Host struct{ s *Service }
  129. // SetCloser sets what CloseWindow calls.
  130. func (h *Host) SetCloser(f func()) { h.s.closer = f }
  131. // Dirty reports whether the project has changes not yet exported.
  132. func (h *Host) Dirty() bool {
  133. h.s.mu.Lock()
  134. defer h.s.mu.Unlock()
  135. return h.s.p.Dirty()
  136. }
  137. // LoadPath opens the project at path in place of the current one.
  138. func (h *Host) LoadPath(path string) (LoadResult, error) { return h.s.loadPath(path) }
  139. // AssignSamplePath puts the WAV at path on the pad in slot. The file drop
  140. // handler uses it.
  141. func (h *Host) AssignSamplePath(slot int, path string) (State, error) {
  142. return h.s.assignSample(slot, path)
  143. }
  144. // CloseWindow closes the app without asking about unsaved changes. The
  145. // frontend calls it after the user chose to discard them.
  146. func (s *Service) CloseWindow() {
  147. if s.closer != nil {
  148. s.closer()
  149. }
  150. }
  151. // NewService returns a service holding a new, empty project, and the Host
  152. // that shares it. Bind only the Service.
  153. func NewService(d Dialogs) (*Service, *Host) {
  154. s := &Service{dialogs: d}
  155. s.reset()
  156. return s, &Host{s: s}
  157. }
  158. func (s *Service) reset() {
  159. p, err := project.New(DefaultName)
  160. if err != nil {
  161. panic(err) // the embedded template and a constant name
  162. }
  163. s.use(p)
  164. }
  165. // use makes p the current project and forgets the export targets chosen
  166. // for the previous one. Callers hold s.mu.
  167. func (s *Service) use(p *project.Project) {
  168. s.p = p
  169. s.exportDir, s.zipPath = "", ""
  170. }
  171. // New replaces the current project with a new, empty one.
  172. func (s *Service) New() State {
  173. s.mu.Lock()
  174. defer s.mu.Unlock()
  175. s.reset()
  176. return s.state()
  177. }
  178. // State returns the current project's state.
  179. func (s *Service) State() State {
  180. s.mu.Lock()
  181. defer s.mu.Unlock()
  182. return s.state()
  183. }
  184. // About returns the About dialog text.
  185. func (s *Service) About() About {
  186. return About{
  187. Version: Version,
  188. Commit: Commit,
  189. License: "MIT License, copyright (c) 2026 bajdev",
  190. Repo: "https://git.monoexp.com/bajdev/mpckit",
  191. }
  192. }
  193. // state builds what the frontend shows about the project. Callers hold s.mu.
  194. func (s *Service) state() State {
  195. proj := s.p
  196. missing := missingSources(proj)
  197. st := State{
  198. Name: proj.Name,
  199. Dirty: proj.Dirty(),
  200. Pads: make([]Pad, 0, project.Pads),
  201. Sequences: sequenceStates(proj),
  202. SampleCount: proj.SampleCount(),
  203. MissingCount: len(missing),
  204. Limits: limits(),
  205. }
  206. for slot := 0; slot < project.Pads; slot++ {
  207. st.Pads = append(st.Pads, padState(proj, slot, missing))
  208. }
  209. st.SequenceCount = len(st.Sequences)
  210. return st
  211. }
  212. // limits returns the device and format limits the frontend enforces.
  213. func limits() Limits {
  214. return Limits{
  215. Pads: project.Pads,
  216. MaxSamples: project.MaxSamples,
  217. MaxSequences: project.MaxSequences,
  218. MinCoarse: project.MinCoarse,
  219. MaxCoarse: project.MaxCoarse,
  220. MinFine: project.MinFine,
  221. MaxFine: project.MaxFine,
  222. MaxMuteGroup: project.MaxMuteGroup,
  223. MinBPM: project.MinBPM,
  224. MaxBPM: project.MaxBPM,
  225. MaxBars: project.MaxBars,
  226. }
  227. }
  228. // missingSources returns the pool files that export could not copy: no
  229. // source recorded, or one export refuses (see export.Usable).
  230. func missingSources(proj *project.Project) map[string]bool {
  231. missing := map[string]bool{}
  232. for _, file := range proj.PoolFiles() {
  233. if src, _ := proj.Source(file); src == "" || !export.Usable(src) {
  234. missing[file] = true
  235. }
  236. }
  237. return missing
  238. }
  239. // padState describes the pad in slot. missing is from missingSources.
  240. func padState(proj *project.Project, slot int, missing map[string]bool) Pad {
  241. view, err := proj.Pad(slot)
  242. if err != nil {
  243. // A loaded file with an odd pad; show it as empty.
  244. return Pad{Slot: slot, Label: project.SlotName(slot)}
  245. }
  246. pad := Pad{
  247. Slot: slot,
  248. Label: project.SlotName(slot),
  249. Name: view.SampleName,
  250. File: view.File,
  251. Coarse: view.Coarse,
  252. Fine: view.Fine,
  253. MuteGroup: view.MuteGroup,
  254. Start: view.Start,
  255. End: view.End,
  256. }
  257. if view.Empty() {
  258. return pad
  259. }
  260. pad.HasNotes = proj.PadHasNotes(slot)
  261. pad.Source, _ = proj.Source(view.File)
  262. pad.Missing = missing[view.File]
  263. if !pad.Missing {
  264. pad.Audio = audioURL(slot, pad.Source)
  265. }
  266. return pad
  267. }
  268. // sequenceStates describes the project's sequences, in slot order.
  269. func sequenceStates(proj *project.Project) []Sequence {
  270. seqs := []Sequence{}
  271. for _, seq := range proj.Sequences() {
  272. seqs = append(seqs, Sequence{
  273. Slot: seq.Slot,
  274. Label: project.SlotName(seq.Slot),
  275. Name: seq.Name,
  276. BPM: seq.BPM,
  277. Bars: seq.Bars,
  278. HasNotes: seq.HasNotes,
  279. })
  280. }
  281. return seqs
  282. }
  283. // audioURL is the preview URL for a pad. The key in the query changes with
  284. // the source file, so the frontend's cache never plays a stale sample.
  285. func audioURL(slot int, src string) string {
  286. sum := sha256.Sum256([]byte(src))
  287. return fmt.Sprintf("%s%d?k=%s", AudioPrefix, slot, hex.EncodeToString(sum[:6]))
  288. }
  289. // apply runs fn on the project under the lock and returns the new state.
  290. // The state is returned even on error, so the frontend stays in step.
  291. func (s *Service) apply(fn func(p *project.Project) error) (State, error) {
  292. s.mu.Lock()
  293. defer s.mu.Unlock()
  294. err := fn(s.p)
  295. return s.state(), err
  296. }
  297. // SetName renames the project.
  298. func (s *Service) SetName(name string) (State, error) {
  299. return s.apply(func(p *project.Project) error { return p.SetName(strings.TrimSpace(name)) })
  300. }
  301. // AssignSampleDialog asks for a WAV file and puts it on the pad in slot.
  302. // Cancelling the dialog changes nothing.
  303. func (s *Service) AssignSampleDialog(slot int) (State, error) {
  304. path, err := s.dialogs.OpenWAV()
  305. if err != nil || path == "" {
  306. return s.State(), err
  307. }
  308. return s.assignSample(slot, path)
  309. }
  310. func (s *Service) assignSample(slot int, path string) (State, error) {
  311. return s.apply(func(p *project.Project) error { return p.AssignSample(slot, path) })
  312. }
  313. // ClearPad empties the pad in slot.
  314. func (s *Service) ClearPad(slot int) (State, error) {
  315. return s.apply(func(p *project.Project) error { return p.ClearPad(slot) })
  316. }
  317. // SetTune sets a pad's tuning in semitones and cents.
  318. func (s *Service) SetTune(slot, coarse, fine int) (State, error) {
  319. return s.apply(func(p *project.Project) error { return p.SetTune(slot, coarse, fine) })
  320. }
  321. // SetMuteGroup sets a pad's mute group; 0 is off.
  322. func (s *Service) SetMuteGroup(slot, group int) (State, error) {
  323. return s.apply(func(p *project.Project) error { return p.SetMuteGroup(slot, group) })
  324. }
  325. // ChooseExportFolder asks for the folder to export into and remembers it
  326. // for ExportFolder. It returns the folder, or "" if cancelled.
  327. func (s *Service) ChooseExportFolder() (string, error) {
  328. dir, err := s.dialogs.ChooseFolder()
  329. s.mu.Lock()
  330. defer s.mu.Unlock()
  331. s.exportDir = ""
  332. if err == nil {
  333. s.exportDir = dir
  334. }
  335. return dir, err
  336. }
  337. // ChooseZipPath asks where to save the zip and remembers it for ExportZip.
  338. // It returns the path, or "" if cancelled.
  339. func (s *Service) ChooseZipPath() (string, error) {
  340. s.mu.Lock()
  341. name := s.p.Name
  342. s.mu.Unlock()
  343. path, err := s.dialogs.SaveZip(name + ".zip")
  344. if path != "" && !strings.EqualFold(filepath.Ext(path), ".zip") {
  345. path += ".zip"
  346. }
  347. s.mu.Lock()
  348. defer s.mu.Unlock()
  349. s.zipPath = ""
  350. if err == nil {
  351. s.zipPath = path
  352. }
  353. return path, err
  354. }
  355. // errNoTarget is returned by an export with no target chosen.
  356. var errNoTarget = errors.New("choose where to export first")
  357. // ExportFolder writes the project into the folder ChooseExportFolder
  358. // chose. If the project is already there and overwrite is false, nothing
  359. // is written and Exists lists it.
  360. func (s *Service) ExportFolder(overwrite bool) (ExportResult, error) {
  361. s.mu.Lock()
  362. defer s.mu.Unlock()
  363. if s.exportDir == "" {
  364. return ExportResult{}, errNoTarget
  365. }
  366. path, err := export.Folder(s.p, s.exportDir, overwrite)
  367. return s.exported(path, err)
  368. }
  369. // ExportZip writes the project as a zip where ChooseZipPath chose. If the
  370. // file exists and overwrite is false, nothing is written and Exists lists it.
  371. func (s *Service) ExportZip(overwrite bool) (ExportResult, error) {
  372. s.mu.Lock()
  373. defer s.mu.Unlock()
  374. if s.zipPath == "" {
  375. return ExportResult{}, errNoTarget
  376. }
  377. path := s.zipPath
  378. err := export.Zip(s.p, path, overwrite)
  379. return s.exported(path, err)
  380. }
  381. // CancelExport forgets the chosen export targets. The frontend calls it
  382. // when the user declines to replace an existing export, so that a later
  383. // ExportFolder(true) or ExportZip(true) cannot overwrite it without asking.
  384. func (s *Service) CancelExport() {
  385. s.mu.Lock()
  386. defer s.mu.Unlock()
  387. s.exportDir, s.zipPath = "", ""
  388. }
  389. func (s *Service) exported(path string, err error) (ExportResult, error) {
  390. var exists *export.ExistsError
  391. if errors.As(err, &exists) {
  392. return ExportResult{Exists: exists.Paths}, nil
  393. }
  394. if err != nil {
  395. return ExportResult{}, err
  396. }
  397. s.p.MarkExported()
  398. s.exportDir, s.zipPath = "", "" // the next export asks again
  399. return ExportResult{Path: path}, nil
  400. }
  401. // MovePad moves the pad in from to slot to, replacing what was there.
  402. // Recorded notes stay on their slots; the frontend warns first.
  403. func (s *Service) MovePad(from, to int) (State, error) {
  404. return s.apply(func(p *project.Project) error { return p.MovePad(from, to) })
  405. }
  406. // CopyPad copies the pad in from to slot to, replacing what was there.
  407. func (s *Service) CopyPad(from, to int) (State, error) {
  408. return s.apply(func(p *project.Project) error { return p.CopyPad(from, to) })
  409. }
  410. // SwapPads exchanges two pads.
  411. func (s *Service) SwapPads(a, b int) (State, error) {
  412. return s.apply(func(p *project.Project) error { return p.SwapPads(a, b) })
  413. }
  414. // Load asks for a project file and opens it in place of the current one.
  415. // The frontend asks about unsaved changes first.
  416. func (s *Service) Load() (LoadResult, error) {
  417. path, err := s.dialogs.OpenProject()
  418. if err != nil || path == "" {
  419. return LoadResult{State: s.State()}, err
  420. }
  421. return s.loadPath(path)
  422. }
  423. // loadPath opens the project at path in place of the current one.
  424. func (s *Service) loadPath(path string) (LoadResult, error) {
  425. p, warnings, err := project.Load(path)
  426. if err != nil {
  427. return LoadResult{State: s.State()}, fmt.Errorf("%s: %w", filepath.Base(path), err)
  428. }
  429. s.mu.Lock()
  430. defer s.mu.Unlock()
  431. s.use(p)
  432. if warnings == nil {
  433. warnings = []string{}
  434. }
  435. return LoadResult{Path: path, State: s.state(), Warnings: warnings}, nil
  436. }
  437. // AddSequence adds a sequence in an empty slot: "Sequence NN", 2 bars, at
  438. // the BPM of the most recently edited sequence (else 120).
  439. func (s *Service) AddSequence(slot int) (State, error) {
  440. return s.apply(func(p *project.Project) error { return p.AddSequence(slot) })
  441. }
  442. // RemoveSequence deletes a sequence and its recorded notes. The last
  443. // sequence cannot be removed.
  444. func (s *Service) RemoveSequence(slot int) (State, error) {
  445. return s.apply(func(p *project.Project) error { return p.RemoveSequence(slot) })
  446. }
  447. // SetSequence sets a sequence's name, BPM and length in bars. Notes past a
  448. // new, shorter end are left in the file.
  449. func (s *Service) SetSequence(slot int, name string, bpm float64, bars int) (State, error) {
  450. return s.apply(func(p *project.Project) error {
  451. return p.SetSequence(slot, strings.TrimSpace(name), bpm, bars)
  452. })
  453. }