app.go 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300
  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 is set at build time with
  17. // -ldflags "-X git.monoexp.com/bajdev/mpckit/internal/app.Version=v1.0.0".
  18. var Version = "dev"
  19. // DefaultName is the name of a new project.
  20. const DefaultName = "NewKit"
  21. // Pad is one pad as the frontend shows it.
  22. type Pad struct {
  23. Slot int `json:"slot"`
  24. Label string `json:"label"` // "A01" .. "H16"
  25. Name string `json:"name"` // sample name; empty for an empty pad
  26. File string `json:"file"` // pool file name
  27. Coarse int `json:"coarse"`
  28. Fine int `json:"fine"`
  29. MuteGroup int `json:"muteGroup"`
  30. HasNotes bool `json:"hasNotes"`
  31. // Audio is the URL the preview loads the sample from; empty for an
  32. // empty pad. It changes when the pad's source file changes.
  33. Audio string `json:"audio"`
  34. // Start and End are the slice's first and last frame. The preview
  35. // plays only this region, as the device does.
  36. Start int64 `json:"start"`
  37. End int64 `json:"end"`
  38. }
  39. // Sequence is one existing sequence.
  40. type Sequence struct {
  41. Slot int `json:"slot"`
  42. Label string `json:"label"` // slot label, "A01" .. "H16"
  43. Name string `json:"name"`
  44. BPM float64 `json:"bpm"`
  45. Bars int `json:"bars"`
  46. HasNotes bool `json:"hasNotes"`
  47. }
  48. // Limits are the ranges the UI offers, from the device checks.
  49. type Limits struct {
  50. Pads int `json:"pads"`
  51. MaxSamples int `json:"maxSamples"`
  52. MaxSequences int `json:"maxSequences"`
  53. MinCoarse int `json:"minCoarse"`
  54. MaxCoarse int `json:"maxCoarse"`
  55. MinFine int `json:"minFine"`
  56. MaxFine int `json:"maxFine"`
  57. MaxMuteGroup int `json:"maxMuteGroup"`
  58. MinBPM float64 `json:"minBPM"`
  59. MaxBPM float64 `json:"maxBPM"`
  60. MaxBars int `json:"maxBars"`
  61. }
  62. // State is everything the frontend shows about the project.
  63. type State struct {
  64. Name string `json:"name"`
  65. Dirty bool `json:"dirty"`
  66. Pads []Pad `json:"pads"` // all 128, in slot order
  67. Sequences []Sequence `json:"sequences"` // existing ones, in slot order
  68. SampleCount int `json:"sampleCount"`
  69. SequenceCount int `json:"sequenceCount"`
  70. Limits Limits `json:"limits"`
  71. }
  72. // About is the text for the About dialog.
  73. type About struct {
  74. Version string `json:"version"`
  75. License string `json:"license"`
  76. Repo string `json:"repo"`
  77. }
  78. // ExportResult is what an export call returns. If Exists is not empty,
  79. // nothing was written: the frontend asks the user, then calls again with
  80. // overwrite set.
  81. type ExportResult struct {
  82. Path string `json:"path"`
  83. Exists []string `json:"exists"`
  84. }
  85. // Dialogs are the native file dialogs. Each returns "" when the user
  86. // cancels. main.go implements them with Wails; tests use a fake.
  87. type Dialogs interface {
  88. OpenWAV() (string, error)
  89. ChooseFolder() (string, error)
  90. SaveZip(defaultName string) (string, error)
  91. }
  92. // Service is registered with Wails; its exported methods become the
  93. // frontend bindings.
  94. type Service struct {
  95. mu sync.Mutex
  96. p *project.Project
  97. dialogs Dialogs
  98. }
  99. // NewService returns a service holding a new, empty project.
  100. func NewService(d Dialogs) *Service {
  101. s := &Service{dialogs: d}
  102. s.reset()
  103. return s
  104. }
  105. func (s *Service) reset() {
  106. p, err := project.New(DefaultName)
  107. if err != nil {
  108. panic(err) // the embedded template and a constant name
  109. }
  110. s.p = p
  111. }
  112. // New replaces the current project with a new, empty one.
  113. func (s *Service) New() State {
  114. s.mu.Lock()
  115. defer s.mu.Unlock()
  116. s.reset()
  117. return s.state()
  118. }
  119. // State returns the current project's state.
  120. func (s *Service) State() State {
  121. s.mu.Lock()
  122. defer s.mu.Unlock()
  123. return s.state()
  124. }
  125. // Version returns the app version.
  126. func (s *Service) Version() string { return Version }
  127. // About returns the About dialog text.
  128. func (s *Service) About() About {
  129. return About{
  130. Version: Version,
  131. License: "MIT License, copyright (c) 2026 bajdev",
  132. Repo: "https://git.monoexp.com/bajdev/mpckit",
  133. }
  134. }
  135. // Label returns the label the UI and the device use for a pad or sequence
  136. // slot: bank letter and two-digit pad number, e.g. 0 -> "A01", 17 -> "B02".
  137. func Label(slot int) string {
  138. return fmt.Sprintf("%c%02d", 'A'+slot/16, slot%16+1)
  139. }
  140. func (s *Service) state() State {
  141. p := s.p
  142. st := State{
  143. Name: p.Name,
  144. Dirty: p.Dirty,
  145. Pads: make([]Pad, 0, project.Pads),
  146. Sequences: []Sequence{},
  147. SampleCount: p.SampleCount(),
  148. Limits: Limits{
  149. Pads: project.Pads,
  150. MaxSamples: project.MaxSamples,
  151. MaxSequences: project.MaxSequences,
  152. MinCoarse: project.MinCoarse,
  153. MaxCoarse: project.MaxCoarse,
  154. MinFine: project.MinFine,
  155. MaxFine: project.MaxFine,
  156. MaxMuteGroup: project.MaxMuteGroup,
  157. MinBPM: project.MinBPM,
  158. MaxBPM: project.MaxBPM,
  159. MaxBars: project.MaxBars,
  160. },
  161. }
  162. for slot := 0; slot < project.Pads; slot++ {
  163. v, err := p.Pad(slot)
  164. if err != nil {
  165. // A loaded file with an odd pad; show it as empty.
  166. st.Pads = append(st.Pads, Pad{Slot: slot, Label: Label(slot)})
  167. continue
  168. }
  169. pad := Pad{Slot: slot, Label: Label(slot), Name: v.SampleName, File: v.File,
  170. Coarse: v.Coarse, Fine: v.Fine, MuteGroup: v.MuteGroup, Start: v.Start, End: v.End}
  171. if !v.Empty() {
  172. pad.HasNotes = p.PadHasNotes(slot)
  173. pad.Audio = audioURL(slot, p.Sources[v.File])
  174. }
  175. st.Pads = append(st.Pads, pad)
  176. }
  177. for _, q := range p.Sequences() {
  178. st.Sequences = append(st.Sequences, Sequence{Slot: q.Slot, Label: Label(q.Slot),
  179. Name: q.Name, BPM: q.BPM, Bars: q.Bars, HasNotes: q.HasNotes})
  180. }
  181. st.SequenceCount = len(st.Sequences)
  182. return st
  183. }
  184. // audioURL is the preview URL for a pad. The key in the query changes with
  185. // the source file, so the frontend's cache never plays a stale sample.
  186. func audioURL(slot int, src string) string {
  187. sum := sha256.Sum256([]byte(src))
  188. return fmt.Sprintf("%s%d?k=%s", AudioPrefix, slot, hex.EncodeToString(sum[:6]))
  189. }
  190. // edit runs fn on the project under the lock and returns the new state.
  191. // The state is returned even on error, so the frontend stays in step.
  192. func (s *Service) edit(fn func(p *project.Project) error) (State, error) {
  193. s.mu.Lock()
  194. defer s.mu.Unlock()
  195. err := fn(s.p)
  196. return s.state(), err
  197. }
  198. // SetName renames the project.
  199. func (s *Service) SetName(name string) (State, error) {
  200. return s.edit(func(p *project.Project) error { return p.SetName(strings.TrimSpace(name)) })
  201. }
  202. // AssignSampleDialog asks for a WAV file and puts it on the pad in slot.
  203. // Cancelling the dialog changes nothing.
  204. func (s *Service) AssignSampleDialog(slot int) (State, error) {
  205. path, err := s.dialogs.OpenWAV()
  206. if err != nil || path == "" {
  207. return s.State(), err
  208. }
  209. return s.AssignSamplePath(slot, path)
  210. }
  211. // AssignSamplePath puts the WAV at path on the pad in slot. File drop uses it.
  212. func (s *Service) AssignSamplePath(slot int, path string) (State, error) {
  213. return s.edit(func(p *project.Project) error { return p.AssignSample(slot, path) })
  214. }
  215. // ClearPad empties the pad in slot.
  216. func (s *Service) ClearPad(slot int) (State, error) {
  217. return s.edit(func(p *project.Project) error { return p.ClearPad(slot) })
  218. }
  219. // SetTune sets a pad's tuning in semitones and cents.
  220. func (s *Service) SetTune(slot, coarse, fine int) (State, error) {
  221. return s.edit(func(p *project.Project) error { return p.SetTune(slot, coarse, fine) })
  222. }
  223. // SetMuteGroup sets a pad's mute group; 0 is off.
  224. func (s *Service) SetMuteGroup(slot, group int) (State, error) {
  225. return s.edit(func(p *project.Project) error { return p.SetMuteGroup(slot, group) })
  226. }
  227. // ChooseExportFolder asks for the folder to export into; "" if cancelled.
  228. func (s *Service) ChooseExportFolder() (string, error) {
  229. return s.dialogs.ChooseFolder()
  230. }
  231. // ChooseZipPath asks where to save the zip; "" if cancelled.
  232. func (s *Service) ChooseZipPath() (string, error) {
  233. s.mu.Lock()
  234. name := s.p.Name
  235. s.mu.Unlock()
  236. return s.dialogs.SaveZip(name + ".zip")
  237. }
  238. // ExportFolder writes the project into dir. If the project is already
  239. // there and overwrite is false, nothing is written and Exists lists it.
  240. func (s *Service) ExportFolder(dir string, overwrite bool) (ExportResult, error) {
  241. s.mu.Lock()
  242. defer s.mu.Unlock()
  243. path, err := export.Folder(s.p, dir, overwrite)
  244. return s.exported(path, err)
  245. }
  246. // ExportZip writes the project as a zip at path. If the file exists and
  247. // overwrite is false, nothing is written and Exists lists it.
  248. func (s *Service) ExportZip(path string, overwrite bool) (ExportResult, error) {
  249. s.mu.Lock()
  250. defer s.mu.Unlock()
  251. if !strings.EqualFold(filepath.Ext(path), ".zip") {
  252. path += ".zip"
  253. }
  254. err := export.Zip(s.p, path, overwrite)
  255. return s.exported(path, err)
  256. }
  257. func (s *Service) exported(path string, err error) (ExportResult, error) {
  258. var exists *export.ExistsError
  259. if errors.As(err, &exists) {
  260. return ExportResult{Exists: exists.Paths}, nil
  261. }
  262. if err != nil {
  263. return ExportResult{}, err
  264. }
  265. s.p.Dirty = false
  266. return ExportResult{Path: path}, nil
  267. }