Yann commited on
Commit
f23825d
·
1 Parent(s): 7e76b3e
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitignore +46 -0
  2. .gitmodules +0 -0
  3. package.json +99 -0
  4. src/@types/emotion.d.ts +5 -0
  5. src/@types/index.d.ts +6 -0
  6. src/common/geometry/Point.ts +20 -0
  7. src/common/geometry/Rect.test.ts +117 -0
  8. src/common/geometry/Rect.ts +77 -0
  9. src/common/geometry/Size.ts +4 -0
  10. src/common/geometry/index.ts +3 -0
  11. src/common/helpers/Downloader.ts +18 -0
  12. src/common/helpers/array.ts +33 -0
  13. src/common/helpers/bpm.ts +9 -0
  14. src/common/helpers/filterEvents.test.ts +33 -0
  15. src/common/helpers/filterEvents.ts +49 -0
  16. src/common/helpers/mapBeats.test.ts +64 -0
  17. src/common/helpers/mapBeats.ts +107 -0
  18. src/common/helpers/noteAssembler.test.ts +70 -0
  19. src/common/helpers/noteAssembler.ts +80 -0
  20. src/common/helpers/noteNumberString.ts +32 -0
  21. src/common/helpers/pojo.ts +7 -0
  22. src/common/helpers/songToSynthEvents.ts +56 -0
  23. src/common/helpers/toRawEvents.ts +37 -0
  24. src/common/helpers/toTrackEvents.ts +56 -0
  25. src/common/helpers/valueEvent.ts +41 -0
  26. src/common/localize/envString.ts +14 -0
  27. src/common/localize/localization.ts +588 -0
  28. src/common/localize/localizedString.ts +36 -0
  29. src/common/measure/Measure.ts +29 -0
  30. src/common/measure/MeasureList.ts +59 -0
  31. src/common/measure/mbt.ts +35 -0
  32. src/common/midi/GM.ts +21 -0
  33. src/common/midi/MidiEvent.ts +273 -0
  34. src/common/midi/midiConversion.test.ts +133 -0
  35. src/common/midi/midiConversion.ts +170 -0
  36. src/common/player/EventScheduler.test.ts +41 -0
  37. src/common/player/EventScheduler.ts +139 -0
  38. src/common/player/Player.ts +319 -0
  39. src/common/player/PlayerEvent.ts +27 -0
  40. src/common/player/index.ts +2 -0
  41. src/common/quantizer/Quantizer.ts +50 -0
  42. src/common/quantizer/index.ts +1 -0
  43. src/common/selection/ArrangeSelection.ts +72 -0
  44. src/common/selection/ControlSelection.ts +4 -0
  45. src/common/selection/Selection.ts +71 -0
  46. src/common/selection/TempoSelection.ts +21 -0
  47. src/common/song/Song.test.ts +40 -0
  48. src/common/song/Song.ts +107 -0
  49. src/common/song/SongFactory.ts +11 -0
  50. src/common/song/index.ts +2 -0
.gitignore ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Logs
2
+ logs
3
+ *.log
4
+ npm-debug.log*
5
+
6
+ # Runtime data
7
+ pids
8
+ *.pid
9
+ *.seed
10
+
11
+ # Directory for instrumented libs generated by jscoverage/JSCover
12
+ lib-cov
13
+
14
+ # Coverage directory used by tools like istanbul
15
+ coverage
16
+
17
+ # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
18
+ .grunt
19
+
20
+ # node-waf configuration
21
+ .lock-wscript
22
+
23
+ # Compiled binary addons (http://nodejs.org/api/addons.html)
24
+ build/Release
25
+
26
+ # Dependency directories
27
+ node_modules
28
+ jspm_packages
29
+
30
+ # Optional npm cache directory
31
+ .npm
32
+
33
+ # Optional REPL history
34
+ .node_repl_history
35
+
36
+ static/bundle.js
37
+ esdoc
38
+ pkg
39
+ build
40
+ dist
41
+ testdata/tracks.mid
42
+ testdata/format0.mid
43
+ .vscode/extensions.json
44
+ .vscode/launch.json
45
+ .vscode/settings.json
46
+ .vscode/tasks.json
.gitmodules ADDED
File without changes
package.json ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "signal",
3
+ "version": "0.0.1",
4
+ "description": "A friendly music sequencer application for OS X and Windows.",
5
+ "scripts": {
6
+ "start": "webpack serve --config webpack.dev.js",
7
+ "build": "webpack --config webpack.prod.js",
8
+ "serve": "npx http-server dist",
9
+ "test": "jest",
10
+ "format": "prettier --write src",
11
+ "lint": "prettier --check src",
12
+ "firebase": "firebase emulators:start",
13
+ "firebase:deploy": "firebase deploy"
14
+ },
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/ryohey/signal.git"
18
+ },
19
+ "author": "ryohey",
20
+ "license": "MIT",
21
+ "bugs": {
22
+ "url": "https://github.com/ryohey/signal/issues"
23
+ },
24
+ "homepage": "https://github.com/ryohey/signal/",
25
+ "private": true,
26
+ "dependencies": {
27
+ "@emotion/react": "^11.11.1",
28
+ "@emotion/styled": "^11.11.0",
29
+ "@radix-ui/react-checkbox": "^1.0.4",
30
+ "@radix-ui/react-dialog": "^1.0.4",
31
+ "@radix-ui/react-dropdown-menu": "^2.0.5",
32
+ "@radix-ui/react-popover": "^1.0.6",
33
+ "@radix-ui/react-portal": "^1.0.3",
34
+ "@radix-ui/react-slider": "^1.1.2",
35
+ "@radix-ui/react-tooltip": "^1.0.6",
36
+ "@rehooks/component-size": "1.0.3",
37
+ "@ryohey/react-split-pane": "^0.1.94",
38
+ "@ryohey/react-svg-spinners": "^0.3.2",
39
+ "@ryohey/wavelet": "^0.6.1",
40
+ "@ryohey/webgl-react": "^0.3.1",
41
+ "@sentry/react": "^7.69.0",
42
+ "@sentry/tracing": "^7.69.0",
43
+ "color": "^4.2.3",
44
+ "firebase": "^10.4.0",
45
+ "firebaseui": "^6.1.0",
46
+ "gl-matrix": "^3.4.3",
47
+ "lodash": "^4.17.21",
48
+ "mdi-react": "^9.2.0",
49
+ "midifile-ts": "1.5.1",
50
+ "mobx": "^6.10.2",
51
+ "mobx-persist-store": "^1.1.3",
52
+ "mobx-react-lite": "4.0.4",
53
+ "react": "^18.2.0",
54
+ "react-dom": "^18.2.0",
55
+ "react-helmet-async": "^1.3.0",
56
+ "react-window": "^1.8.9",
57
+ "serializr": "^3.0.2",
58
+ "wav-encoder": "^1.3.0"
59
+ },
60
+ "devDependencies": {
61
+ "@babel/core": "^7.22.19",
62
+ "@babel/preset-env": "7.22.15",
63
+ "@babel/preset-react": "7.22.15",
64
+ "@babel/preset-typescript": "7.22.15",
65
+ "@emotion/babel-plugin": "^11.11.0",
66
+ "@pmmmwh/react-refresh-webpack-plugin": "^0.5.11",
67
+ "@sentry/webpack-plugin": "^2.7.1",
68
+ "@types/color": "3.0.4",
69
+ "@types/jest": "29.5.5",
70
+ "@types/lodash": "^4.14.198",
71
+ "@types/node": "20.6.1",
72
+ "@types/react-dom": "18.2.7",
73
+ "@types/react-helmet": "6.1.6",
74
+ "@types/react-window": "^1.8.5",
75
+ "@types/wav-encoder": "^1.3.0",
76
+ "@types/webmidi": "2.0.7",
77
+ "@types/webpack-env": "1.18.1",
78
+ "@types/wicg-file-system-access": "^2020.9.6",
79
+ "babel-jest": "^29.7.0",
80
+ "babel-loader": "^9.1.3",
81
+ "babel-plugin-inline-react-svg": "^2.0.2",
82
+ "babel-plugin-lodash": "^3.3.4",
83
+ "copy-webpack-plugin": "11.0.0",
84
+ "fork-ts-checker-webpack-plugin": "8.0.0",
85
+ "html-webpack-plugin": "^5.5.3",
86
+ "jest": "^29.7.0",
87
+ "prettier": "^3.0.3",
88
+ "react-refresh": "0.14.0",
89
+ "ts-jest": "^29.1.1",
90
+ "ts-loader": "^9.4.4",
91
+ "typescript": "^5.2.2",
92
+ "url-loader": "^4.1.1",
93
+ "webpack": "^5.88.2",
94
+ "webpack-bundle-analyzer": "4.9.1",
95
+ "webpack-cli": "^5.1.4",
96
+ "webpack-dev-server": "^4.15.1",
97
+ "webpack-merge": "5.9.0"
98
+ }
99
+ }
src/@types/emotion.d.ts ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ import { Theme as BaseTheme } from "../common/theme/Theme"
2
+
3
+ declare module "@emotion/react" {
4
+ export interface Theme extends BaseTheme {}
5
+ }
src/@types/index.d.ts ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ declare module "*.png"
2
+ declare module "*.svg"
3
+
4
+ interface Window {
5
+ webkitAudioContext: typeof AudioContext
6
+ }
src/common/geometry/Point.ts ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export interface IPoint {
2
+ x: number
3
+ y: number
4
+ }
5
+
6
+ export function pointSub(v1: IPoint, v2: IPoint) {
7
+ return {
8
+ x: v1.x - v2.x,
9
+ y: v1.y - v2.y,
10
+ }
11
+ }
12
+
13
+ export function pointAdd(v1: IPoint, v2: IPoint) {
14
+ return {
15
+ x: v1.x + v2.x,
16
+ y: v1.y + v2.y,
17
+ }
18
+ }
19
+
20
+ export const zeroPoint = { x: 0, y: 0 }
src/common/geometry/Rect.test.ts ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { intersects } from "."
2
+
3
+ describe("Rect", () => {
4
+ describe("intersects", () => {
5
+ it("included", () => {
6
+ expect(
7
+ intersects(
8
+ {
9
+ x: 1,
10
+ y: 1,
11
+ width: 10,
12
+ height: 10,
13
+ },
14
+ {
15
+ x: 2,
16
+ y: 2,
17
+ width: 1,
18
+ height: 1,
19
+ },
20
+ ),
21
+ ).toBeTruthy()
22
+
23
+ expect(
24
+ intersects(
25
+ {
26
+ x: 1,
27
+ y: 1,
28
+ width: 10,
29
+ height: 10,
30
+ },
31
+ {
32
+ x: 1,
33
+ y: 1,
34
+ width: 1,
35
+ height: 1,
36
+ },
37
+ ),
38
+ ).toBeTruthy()
39
+ })
40
+
41
+ it("overlapped", () => {
42
+ expect(
43
+ intersects(
44
+ {
45
+ x: 1,
46
+ y: 1,
47
+ width: 2,
48
+ height: 2,
49
+ },
50
+ {
51
+ x: 2,
52
+ y: 2,
53
+ width: 1,
54
+ height: 1,
55
+ },
56
+ ),
57
+ ).toBeTruthy()
58
+ })
59
+
60
+ it("separated", () => {
61
+ expect(
62
+ intersects(
63
+ {
64
+ x: 0,
65
+ y: 0,
66
+ width: 1,
67
+ height: 1,
68
+ },
69
+ {
70
+ x: 2,
71
+ y: 2,
72
+ width: 1,
73
+ height: 1,
74
+ },
75
+ ),
76
+ ).toBeFalsy()
77
+ })
78
+
79
+ it("adjacent", () => {
80
+ expect(
81
+ intersects(
82
+ {
83
+ x: 1,
84
+ y: 1,
85
+ width: 1,
86
+ height: 1,
87
+ },
88
+ {
89
+ x: 2,
90
+ y: 2,
91
+ width: 1,
92
+ height: 1,
93
+ },
94
+ ),
95
+ ).toBeFalsy()
96
+ })
97
+
98
+ it("zero", () => {
99
+ expect(
100
+ intersects(
101
+ {
102
+ x: 1,
103
+ y: 1,
104
+ width: 0,
105
+ height: 0,
106
+ },
107
+ {
108
+ x: 1,
109
+ y: 1,
110
+ width: 0,
111
+ height: 0,
112
+ },
113
+ ),
114
+ ).toBeFalsy()
115
+ })
116
+ })
117
+ })
src/common/geometry/Rect.ts ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { IPoint } from "../geometry"
2
+
3
+ export interface IRect extends IPoint {
4
+ width: number
5
+ height: number
6
+ }
7
+
8
+ export function containsPoint(rect: IRect, point: IPoint) {
9
+ return (
10
+ point.x >= rect.x &&
11
+ point.x <= rect.x + rect.width &&
12
+ point.y >= rect.y &&
13
+ point.y <= rect.y + rect.height
14
+ )
15
+ }
16
+
17
+ export function right(rect: IRect) {
18
+ return rect.x + rect.width
19
+ }
20
+
21
+ export function bottom(rect: IRect) {
22
+ return rect.y + rect.height
23
+ }
24
+
25
+ export function intersects(rectA: IRect, rectB: IRect) {
26
+ return (
27
+ right(rectA) > rectB.x &&
28
+ right(rectB) > rectA.x &&
29
+ bottom(rectA) > rectB.y &&
30
+ bottom(rectB) > rectA.y
31
+ )
32
+ }
33
+
34
+ export function containsRect(rectA: IRect, rectB: IRect) {
35
+ return containsPoint(rectA, rectB) && containsPoint(rectA, br(rectB))
36
+ }
37
+
38
+ export function br(rect: IRect): IPoint {
39
+ return {
40
+ x: right(rect),
41
+ y: bottom(rect),
42
+ }
43
+ }
44
+
45
+ export function fromPoints(pointA: IPoint, pointB: IPoint): IRect {
46
+ const x1 = Math.min(pointA.x, pointB.x)
47
+ const x2 = Math.max(pointA.x, pointB.x)
48
+ const y1 = Math.min(pointA.y, pointB.y)
49
+ const y2 = Math.max(pointA.y, pointB.y)
50
+
51
+ return {
52
+ x: x1,
53
+ y: y1,
54
+ width: x2 - x1,
55
+ height: y2 - y1,
56
+ }
57
+ }
58
+
59
+ export function scale(rect: IRect, scaleX: number, scaleY: number): IRect {
60
+ return {
61
+ x: rect.x * scaleX,
62
+ y: rect.y * scaleY,
63
+ width: rect.width * scaleX,
64
+ height: rect.height * scaleY,
65
+ }
66
+ }
67
+
68
+ export const zeroRect: IRect = { x: 0, y: 0, width: 0, height: 0 }
69
+
70
+ export function moveRect(rect: IRect, p: IPoint): IRect {
71
+ return {
72
+ x: rect.x + p.x,
73
+ y: rect.y + p.y,
74
+ width: rect.width,
75
+ height: rect.height,
76
+ }
77
+ }
src/common/geometry/Size.ts ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ export interface ISize {
2
+ width: number
3
+ height: number
4
+ }
src/common/geometry/index.ts ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ export * from "./Point"
2
+ export * from "./Rect"
3
+ export * from "./Size"
src/common/helpers/Downloader.ts ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ function download(url: string, name = "noname") {
2
+ const a = document.createElement("a")
3
+ a.href = url
4
+ a.download = name
5
+ a.style.display = "none"
6
+ document.body.appendChild(a)
7
+ a.click()
8
+ a.remove()
9
+ }
10
+
11
+ // http://stackoverflow.com/a/33622881/1567777
12
+ export function downloadBlob(blob: Blob, fileName: string) {
13
+ const url = window.URL.createObjectURL(blob)
14
+ download(url, fileName)
15
+ setTimeout(() => {
16
+ return window.URL.revokeObjectURL(url)
17
+ }, 1000)
18
+ }
src/common/helpers/array.ts ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export function isNotNull<T>(a: T | null): a is T {
2
+ return a !== null
3
+ }
4
+
5
+ export function isNotUndefined<T>(a: T | undefined): a is T {
6
+ return a !== undefined
7
+ }
8
+
9
+ export function isNotNullOrUndefined<T>(a: T | null): a is T {
10
+ return a !== null && a !== undefined
11
+ }
12
+
13
+ export const joinObjects = <T extends {}>(
14
+ list: T[],
15
+ separator: (prev: T, next: T) => T,
16
+ ): T[] => {
17
+ const result = []
18
+ for (let i = 0; i < list.length; i++) {
19
+ result.push(list[i])
20
+ if (i < list.length - 1) {
21
+ result.push(separator(list[i], list[i + 1]))
22
+ }
23
+ }
24
+ return result
25
+ }
26
+
27
+ export const closedRange = (start: number, end: number, step: number) => {
28
+ const result = []
29
+ for (let i = start; i <= end; i += step) {
30
+ result.push(i)
31
+ }
32
+ return result
33
+ }
src/common/helpers/bpm.ts ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ const MINUTE = 60 * 1000000
2
+
3
+ export function uSecPerBeatToBPM(microsecondsPerBeat: number) {
4
+ return MINUTE / microsecondsPerBeat
5
+ }
6
+
7
+ export function bpmToUSecPerBeat(bpm: number) {
8
+ return MINUTE / bpm
9
+ }
src/common/helpers/filterEvents.test.ts ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { filterEventsOverlapRange, filterEventsWithRange } from "./filterEvents"
2
+
3
+ describe("filterEvents", () => {
4
+ const events = [
5
+ { tick: 0 },
6
+ { tick: 5, duration: 5 },
7
+ { tick: 5, duration: 6 },
8
+ { tick: 5, duration: 100 },
9
+ { tick: 10 },
10
+ { tick: 20 },
11
+ { tick: 50 },
12
+ ]
13
+
14
+ describe("filterEventsWithRange", () => {
15
+ it("should contain the event placed at the start tick but the end tick", () => {
16
+ expect(filterEventsWithRange(events, 10, 50)).toStrictEqual([
17
+ { tick: 10 },
18
+ { tick: 20 },
19
+ ])
20
+ })
21
+ })
22
+
23
+ describe("filterEventsWithinView", () => {
24
+ it("should contain events with duration", () => {
25
+ expect(filterEventsOverlapRange(events, 10, 50)).toStrictEqual([
26
+ { tick: 5, duration: 6 },
27
+ { tick: 5, duration: 100 },
28
+ { tick: 10 },
29
+ { tick: 20 },
30
+ ])
31
+ })
32
+ })
33
+ })
src/common/helpers/filterEvents.ts ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export type Range = [start: number, end: number]
2
+
3
+ const getRange = (
4
+ pixelsPerTick: number,
5
+ scrollLeft: number,
6
+ width: number,
7
+ ): Range => [scrollLeft / pixelsPerTick, (scrollLeft + width) / pixelsPerTick]
8
+
9
+ export const filterEventsWithRange = <T extends { tick: number }>(
10
+ events: T[],
11
+ ...range: Range
12
+ ): T[] => events.filter((e) => e.tick >= range[0] && e.tick < range[1])
13
+
14
+ export const filterEventsWithScroll = <T extends { tick: number }>(
15
+ events: T[],
16
+ pixelsPerTick: number,
17
+ scrollLeft: number,
18
+ width: number,
19
+ ): T[] =>
20
+ filterEventsWithRange(events, ...getRange(pixelsPerTick, scrollLeft, width))
21
+
22
+ export const filterEventsOverlapRange = <
23
+ T extends { tick: number; duration?: number },
24
+ >(
25
+ events: T[],
26
+ ...range: Range
27
+ ): T[] => {
28
+ return events.filter((e) => {
29
+ if ("duration" in e && typeof e.duration === "number") {
30
+ const eventTickEnd = e.tick + e.duration
31
+ return e.tick < range[1] && eventTickEnd > range[0]
32
+ }
33
+ return e.tick >= range[0] && e.tick < range[1]
34
+ })
35
+ }
36
+
37
+ export const filterEventsOverlapScroll = <
38
+ T extends { tick: number; duration?: number },
39
+ >(
40
+ events: T[],
41
+ pixelsPerTick: number,
42
+ scrollLeft: number,
43
+ width: number,
44
+ ): T[] => {
45
+ return filterEventsOverlapRange(
46
+ events,
47
+ ...getRange(pixelsPerTick, scrollLeft, width),
48
+ )
49
+ }
src/common/helpers/mapBeats.test.ts ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Measure } from "../measure/Measure"
2
+ import { createBeatsInRange } from "./mapBeats"
3
+
4
+ describe("createBeatsInRange", () => {
5
+ const timebase = 480
6
+
7
+ it("should create beats", () => {
8
+ const measures: Measure[] = [
9
+ { startTick: 0, measure: 0, numerator: 4, denominator: 4 },
10
+ { startTick: timebase * 4, measure: 1, numerator: 6, denominator: 8 },
11
+ { startTick: timebase * 7, measure: 2, numerator: 3, denominator: 4 },
12
+ ]
13
+
14
+ const beats = createBeatsInRange(measures, timebase, 0, timebase * 15)
15
+
16
+ expect(beats).toStrictEqual([
17
+ // first measure (4/4)
18
+ { measure: 0, beat: 0, tick: 0 },
19
+ { measure: 0, beat: 1, tick: timebase },
20
+ { measure: 0, beat: 2, tick: timebase * 2 },
21
+ { measure: 0, beat: 3, tick: timebase * 3 },
22
+
23
+ // second measure (6/8)
24
+ { measure: 1, beat: 0, tick: timebase * 4 },
25
+ { measure: 1, beat: 1, tick: timebase * 4.5 },
26
+ { measure: 1, beat: 2, tick: timebase * 5 },
27
+ { measure: 1, beat: 3, tick: timebase * 5.5 },
28
+ { measure: 1, beat: 4, tick: timebase * 6 },
29
+ { measure: 1, beat: 5, tick: timebase * 6.5 },
30
+
31
+ // third measure (3/4)
32
+ { measure: 2, beat: 0, tick: timebase * 7 },
33
+ { measure: 2, beat: 1, tick: timebase * 8 },
34
+ { measure: 2, beat: 2, tick: timebase * 9 },
35
+
36
+ // fourth measure (3/4)
37
+ { measure: 3, beat: 0, tick: timebase * 10 },
38
+ { measure: 3, beat: 1, tick: timebase * 11 },
39
+ { measure: 3, beat: 2, tick: timebase * 12 },
40
+
41
+ // fifth measure (3/4)
42
+ { measure: 4, beat: 0, tick: timebase * 13 },
43
+ { measure: 4, beat: 1, tick: timebase * 14 },
44
+ ])
45
+ })
46
+
47
+ it("should create default beats without the initial measure", () => {
48
+ const measures: Measure[] = [
49
+ {
50
+ startTick: timebase * 4, // starts at out of the range
51
+ measure: 0,
52
+ numerator: 5,
53
+ denominator: 8,
54
+ },
55
+ ]
56
+ const beats = createBeatsInRange(measures, timebase, 0, timebase * 4)
57
+ expect(beats).toStrictEqual([
58
+ { measure: 0, beat: 0, tick: 0 },
59
+ { measure: 0, beat: 1, tick: timebase },
60
+ { measure: 0, beat: 2, tick: timebase * 2 },
61
+ { measure: 0, beat: 3, tick: timebase * 3 },
62
+ ])
63
+ })
64
+ })
src/common/helpers/mapBeats.ts ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Measure } from "../measure/Measure"
2
+
3
+ export interface Beat {
4
+ measure: number
5
+ beat: number
6
+ tick: number
7
+ }
8
+
9
+ export type BeatWithX = Beat & {
10
+ x: number
11
+ }
12
+
13
+ // 範囲内の measure を探す。最初の要素は startTick 以前のものも含む
14
+ // Find Measure within the range.The first element also includes something before StartTick
15
+ const getMeasuresInRange = (
16
+ measures: Measure[],
17
+ startTick: number,
18
+ endTick: number,
19
+ ) => {
20
+ let i = 0
21
+ const result: Measure[] = []
22
+
23
+ for (const measure of measures) {
24
+ const nextMeasure = measures[i + 1]
25
+ i++
26
+
27
+ // 最初の measure を探す
28
+ // Find the first MEASURE
29
+ if (result.length === 0) {
30
+ if (nextMeasure !== undefined && nextMeasure.startTick <= startTick) {
31
+ continue // 次の measure が最初になりうる場合はスキップ
32
+ // Skip if the next Measure can be the first
33
+ }
34
+ if (measure.startTick > startTick) {
35
+ console.warn("There is no initial time signature. Use 4/4 by default")
36
+ result.push({ startTick: 0, measure: 0, numerator: 4, denominator: 4 })
37
+ } else {
38
+ result.push(measure)
39
+ }
40
+ }
41
+
42
+ // 残りの measure を探す. 最初の measure がない場合に正しく処理できるように else ではなくもう一度最初の measure があるか調べる
43
+ // Find the remaining MEASURE. If you can handle correctly if you do not have the first MEASURE, check if you do the first MEASURE, rather than ELSE
44
+ if (result.length !== 0) {
45
+ if (measure.startTick <= endTick) {
46
+ result.push(measure)
47
+ } else {
48
+ break
49
+ }
50
+ }
51
+ }
52
+
53
+ return result
54
+ }
55
+
56
+ export const createBeatsInRange = (
57
+ allMeasures: Measure[],
58
+ timebase: number,
59
+ startTick: number,
60
+ endTick: number,
61
+ ): Beat[] => {
62
+ const beats: Beat[] = []
63
+ const measures = getMeasuresInRange(allMeasures, startTick, endTick)
64
+
65
+ measures.forEach((measure, i) => {
66
+ const nextMeasure = measures[i + 1]
67
+
68
+ const ticksPerBeat = (timebase * 4) / measure.denominator
69
+
70
+ // 次の小節か曲の endTick まで拍を作る
71
+ // Make a beat up to the next bar or song EndTick
72
+ const lastTick = nextMeasure ? nextMeasure.startTick : endTick
73
+
74
+ const startBeat = Math.max(
75
+ 0,
76
+ Math.floor((startTick - measure.startTick) / ticksPerBeat),
77
+ )
78
+ const endBeat = (lastTick - measure.startTick) / ticksPerBeat
79
+
80
+ for (let beat = startBeat; beat < endBeat; beat++) {
81
+ const tick = measure.startTick + ticksPerBeat * beat
82
+ beats.push({
83
+ measure: measure.measure + Math.floor(beat / measure.numerator),
84
+ beat: beat % measure.numerator,
85
+ tick,
86
+ })
87
+ }
88
+ })
89
+
90
+ return beats
91
+ }
92
+
93
+ export const createBeatsWithXInRange = (
94
+ allMeasures: Measure[],
95
+ pixelsPerTick: number,
96
+ timebase: number,
97
+ startTick: number,
98
+ width: number,
99
+ ): BeatWithX[] => {
100
+ const endTick = startTick + width / pixelsPerTick
101
+ return createBeatsInRange(allMeasures, timebase, startTick, endTick).map(
102
+ (b) => ({
103
+ ...b,
104
+ x: Math.round(b.tick * pixelsPerTick),
105
+ }),
106
+ )
107
+ }
src/common/helpers/noteAssembler.test.ts ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { assemble, deassemble } from "./noteAssembler"
2
+
3
+ describe("deassemble", () => {
4
+ it("should deassemble to two notes", () => {
5
+ const note = {
6
+ type: "channel",
7
+ subtype: "note",
8
+ noteNumber: 32,
9
+ tick: 100,
10
+ duration: 60,
11
+ velocity: 50,
12
+ channel: 3,
13
+ }
14
+ const result = deassemble(note)
15
+ expect(result.length).toBe(2)
16
+ expect(result[0]).toStrictEqual({
17
+ type: "channel",
18
+ subtype: "noteOn",
19
+ noteNumber: 32,
20
+ deltaTime: 0,
21
+ tick: 100,
22
+ velocity: 50,
23
+ channel: 3,
24
+ })
25
+ expect(result[1]).toStrictEqual({
26
+ type: "channel",
27
+ subtype: "noteOff",
28
+ noteNumber: 32,
29
+ deltaTime: 0,
30
+ tick: 100 + 60,
31
+ velocity: 0,
32
+ channel: 3,
33
+ })
34
+ })
35
+ })
36
+
37
+ describe("assemble", () => {
38
+ it("should assemble to an note", () => {
39
+ const notes = [
40
+ {
41
+ type: "channel",
42
+ subtype: "noteOn",
43
+ noteNumber: 14,
44
+ tick: 93,
45
+ velocity: 120,
46
+ channel: 5,
47
+ },
48
+ {
49
+ type: "channel",
50
+ subtype: "noteOff",
51
+ noteNumber: 14,
52
+ tick: 193,
53
+ velocity: 0,
54
+ channel: 5,
55
+ },
56
+ ]
57
+ const result = assemble(notes)
58
+ expect(result.length).toBe(1)
59
+ expect(result[0]).toStrictEqual({
60
+ id: -1,
61
+ type: "channel",
62
+ subtype: "note",
63
+ noteNumber: 14,
64
+ tick: 93,
65
+ velocity: 120,
66
+ channel: 5,
67
+ duration: 100,
68
+ })
69
+ })
70
+ })
src/common/helpers/noteAssembler.ts ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NoteOffEvent, NoteOnEvent } from "midifile-ts"
2
+ import { noteOffMidiEvent, noteOnMidiEvent } from "../midi/MidiEvent"
3
+ import { NoteEvent, TickProvider } from "../track"
4
+
5
+ /**
6
+
7
+ assemble noteOn and noteOff to single note event to append duration
8
+
9
+ */
10
+ export function assemble<T extends {}>(
11
+ events: (T | TickNoteOffEvent | TickNoteOnEvent)[],
12
+ ): (T | NoteEvent)[] {
13
+ const noteOnEvents: TickNoteOnEvent[] = []
14
+
15
+ function findNoteOn(noteOff: TickNoteOffEvent): TickNoteOnEvent | null {
16
+ const i = noteOnEvents.findIndex((e) => {
17
+ return e.noteNumber === noteOff.noteNumber
18
+ })
19
+ if (i < 0) {
20
+ return null
21
+ }
22
+ const e = noteOnEvents[i]
23
+ noteOnEvents.splice(i, 1)
24
+ return e
25
+ }
26
+
27
+ const result: (T | NoteEvent)[] = []
28
+ events.forEach((e) => {
29
+ if ("subtype" in e) {
30
+ switch (e.subtype) {
31
+ case "noteOn":
32
+ noteOnEvents.push(e)
33
+ break
34
+ case "noteOff": {
35
+ const noteOn = findNoteOn(e)
36
+ if (noteOn != null) {
37
+ const note: NoteEvent = {
38
+ ...noteOn,
39
+ subtype: "note",
40
+ id: -1,
41
+ tick: noteOn.tick,
42
+ duration: e.tick - noteOn.tick,
43
+ }
44
+ result.push(note)
45
+ }
46
+ break
47
+ }
48
+ default:
49
+ result.push(e)
50
+ break
51
+ }
52
+ } else {
53
+ result.push(e)
54
+ }
55
+ })
56
+
57
+ return result
58
+ }
59
+
60
+ export type TickNoteOnEvent = Omit<NoteOnEvent, "channel" | "deltaTime"> &
61
+ TickProvider
62
+ export type TickNoteOffEvent = Omit<NoteOffEvent, "channel" | "deltaTime"> &
63
+ TickProvider
64
+
65
+ // separate note to noteOn + noteOff
66
+ export function deassemble<T extends {}>(
67
+ e: T | NoteEvent,
68
+ ): (T | TickNoteOnEvent | TickNoteOffEvent)[] {
69
+ if ("subtype" in e && e.subtype === "note") {
70
+ const channel = (e as any)["channel"] ?? -1
71
+ const noteOn = noteOnMidiEvent(0, channel, e.noteNumber, e.velocity)
72
+ const noteOff = noteOffMidiEvent(0, channel, e.noteNumber)
73
+ return [
74
+ { ...noteOn, tick: e.tick },
75
+ { ...noteOff, tick: e.tick + e.duration },
76
+ ]
77
+ } else {
78
+ return [e as T]
79
+ }
80
+ }
src/common/helpers/noteNumberString.ts ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { MIDIControlEventNames } from "midifile-ts"
2
+
3
+ const NOTE_NAMES = [
4
+ "C",
5
+ "C#",
6
+ "D",
7
+ "D#",
8
+ "E",
9
+ "F",
10
+ "F#",
11
+ "G",
12
+ "G#",
13
+ "A",
14
+ "A#",
15
+ "B",
16
+ ]
17
+
18
+ function noteNameWithOctString(noteNumber: number): string {
19
+ const oct = Math.floor(noteNumber / 12) - 1
20
+ const key = noteNumber % 12
21
+ return `${NOTE_NAMES[key]}${oct}`
22
+ }
23
+
24
+ function noteNumberString(noteNumber: number): string {
25
+ return `${noteNameWithOctString(noteNumber)} (${noteNumber})`
26
+ }
27
+
28
+ function controllerTypeString(controllerType: number): string {
29
+ return MIDIControlEventNames[controllerType]
30
+ }
31
+
32
+ export { noteNameWithOctString, noteNumberString, controllerTypeString }
src/common/helpers/pojo.ts ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ import { custom } from "serializr"
2
+
3
+ // https://github.com/mobxjs/serializr/issues/50#issuecomment-310831516
4
+ export const pojo = custom(
5
+ (val) => val,
6
+ (val) => val,
7
+ )
src/common/helpers/songToSynthEvents.ts ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { SynthEvent } from "@ryohey/wavelet"
2
+ import Song from "../song"
3
+
4
+ const tickToMillisec = (tick: number, bpm: number, timebase: number) =>
5
+ (tick / (timebase / 60) / bpm) * 1000
6
+
7
+ interface Keyframe {
8
+ tick: number
9
+ bpm: number
10
+ timestamp: number
11
+ }
12
+
13
+ export const songToSynthEvents = (
14
+ song: Song,
15
+ sampleRate: number,
16
+ ): SynthEvent[] => {
17
+ const events = [...song.allEvents].sort((a, b) => a.tick - b.tick)
18
+
19
+ let keyframe: Keyframe = {
20
+ tick: 0,
21
+ bpm: 120,
22
+ timestamp: 0,
23
+ }
24
+
25
+ const synthEvents: SynthEvent[] = []
26
+
27
+ // channel イベントを MIDI Output に送信
28
+ // Send Channel Event to MIDI OUTPUT
29
+ for (const e of events) {
30
+ const timestamp =
31
+ tickToMillisec(e.tick - keyframe.tick, keyframe.bpm, song.timebase) +
32
+ keyframe.timestamp
33
+ const delayTime = (timestamp / 1000) * sampleRate
34
+
35
+ switch (e.type) {
36
+ case "channel":
37
+ synthEvents.push({
38
+ type: "midi",
39
+ midi: e,
40
+ delayTime,
41
+ })
42
+ case "meta":
43
+ switch (e.subtype) {
44
+ case "setTempo":
45
+ keyframe = {
46
+ tick: e.tick,
47
+ bpm: (60 * 1000000) / e.microsecondsPerBeat,
48
+ timestamp,
49
+ }
50
+ break
51
+ }
52
+ }
53
+ }
54
+
55
+ return synthEvents
56
+ }
src/common/helpers/toRawEvents.ts ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import flatten from "lodash/flatten"
2
+ import { AnyEvent } from "midifile-ts"
3
+ import { DeltaTimeProvider, TickProvider, TrackEvent } from "../track"
4
+ import { isSignalEvent, mapFromSignalEvent } from "../track/signalEvents"
5
+ import { deassemble as deassembleNote } from "./noteAssembler"
6
+
7
+ // events in each tracks
8
+ export function addDeltaTime<T extends TickProvider>(
9
+ events: T[],
10
+ ): (T & DeltaTimeProvider)[] {
11
+ let prevTick = 0
12
+ return events
13
+ .sort((a, b) => a.tick - b.tick)
14
+ .map((e) => {
15
+ const newEvent = {
16
+ ...e,
17
+ deltaTime: e.tick - prevTick,
18
+ }
19
+ delete (newEvent as any).tick
20
+ prevTick = e.tick
21
+ return newEvent
22
+ })
23
+ }
24
+
25
+ // convert the signal event to the normal sequencer specific event
26
+ const fromSignalEvent = (e: TrackEvent): TrackEvent => {
27
+ if (isSignalEvent(e)) {
28
+ return mapFromSignalEvent(e)
29
+ }
30
+ return e
31
+ }
32
+
33
+ export function toRawEvents(events: TrackEvent[]): AnyEvent[] {
34
+ const a = flatten(events.map(fromSignalEvent).map(deassembleNote))
35
+ const c = addDeltaTime(a)
36
+ return c as AnyEvent[]
37
+ }
src/common/helpers/toTrackEvents.ts ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { AnyEvent } from "midifile-ts"
2
+ import {
3
+ AnyEventFeature,
4
+ DeltaTimeProvider,
5
+ isSequencerSpecificEvent,
6
+ TickProvider,
7
+ } from "../track"
8
+ import { mapToSignalEvent } from "../track/signalEvents"
9
+ import { DistributiveOmit } from "../types"
10
+ import { assemble as assembleNotes } from "./noteAssembler"
11
+
12
+ export function addTick<T extends DeltaTimeProvider>(events: T[]) {
13
+ let tick = 0
14
+ return events.map((e) => {
15
+ const { deltaTime, ...rest } = e
16
+ tick += deltaTime
17
+ return {
18
+ ...(rest as DistributiveOmit<T, "deltaTime">),
19
+ tick,
20
+ }
21
+ })
22
+ }
23
+
24
+ export const removeUnnecessaryProps = <T>(e: T): T => {
25
+ const { channel, ...ev } = e as any
26
+ return ev
27
+ }
28
+
29
+ export const isSupportedEvent = (e: AnyEventFeature): boolean =>
30
+ !(e.type === "meta" && e.subtype === "endOfTrack")
31
+
32
+ const toSignalEvent = <
33
+ T extends DistributiveOmit<AnyEvent, "deltaTime"> & TickProvider,
34
+ >(
35
+ e: T,
36
+ ) => {
37
+ if (isSequencerSpecificEvent(e)) {
38
+ return mapToSignalEvent(e)
39
+ }
40
+ return e
41
+ }
42
+
43
+ export function toTrackEvents(events: AnyEvent[]) {
44
+ return assembleNotes(
45
+ addTick(events.filter(isSupportedEvent)).map(toSignalEvent),
46
+ ).map(removeUnnecessaryProps)
47
+ }
48
+
49
+ // toTrackEvents without addTick
50
+ export function tickedEventsToTrackEvents(
51
+ events: (DistributiveOmit<AnyEvent, "deltaTime"> & TickProvider)[],
52
+ ) {
53
+ return assembleNotes(events.filter(isSupportedEvent).map(toSignalEvent)).map(
54
+ removeUnnecessaryProps,
55
+ )
56
+ }
src/common/helpers/valueEvent.ts ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // abstraction layer for pitch-bend and controller events
2
+
3
+ import { controllerMidiEvent, pitchBendMidiEvent } from "../midi/MidiEvent"
4
+ import { isControllerEventWithType, isPitchBendEvent } from "../track"
5
+
6
+ export type ValueEventType =
7
+ | { type: "pitchBend" }
8
+ | { type: "controller"; controllerType: number }
9
+
10
+ export const createValueEvent = (t: ValueEventType) => (value: number) => {
11
+ switch (t.type) {
12
+ case "pitchBend":
13
+ return pitchBendMidiEvent(0, 0, Math.round(value))
14
+ case "controller":
15
+ return controllerMidiEvent(0, 0, t.controllerType, Math.round(value))
16
+ }
17
+ }
18
+
19
+ export const isValueEvent = (t: ValueEventType) => {
20
+ switch (t.type) {
21
+ case "pitchBend":
22
+ return isPitchBendEvent
23
+ case "controller":
24
+ return isControllerEventWithType(t.controllerType)
25
+ }
26
+ }
27
+
28
+ export const isEqualValueEventType = (
29
+ item: ValueEventType,
30
+ other: ValueEventType,
31
+ ): boolean => {
32
+ switch (item.type) {
33
+ case "pitchBend":
34
+ return other.type === "pitchBend"
35
+ case "controller":
36
+ return (
37
+ other.type === "controller" &&
38
+ item.controllerType === other.controllerType
39
+ )
40
+ }
41
+ }
src/common/localize/envString.ts ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export const envString = (() => {
2
+ const os = navigator.userAgent.indexOf("Mac") != -1 ? "macOS" : "PC"
3
+
4
+ switch (os) {
5
+ case "macOS":
6
+ return {
7
+ cmdOrCtrl: "Cmd",
8
+ }
9
+ case "PC":
10
+ return {
11
+ cmdOrCtrl: "Ctrl",
12
+ }
13
+ }
14
+ })()
src/common/localize/localization.ts ADDED
@@ -0,0 +1,588 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export default {
2
+ ja: {
3
+ "landing-title": "signal - オンラインMIDIシーケンサ",
4
+ "app-intro": "ブラウザで音楽が作れる",
5
+ "app-desc": "すぐに使えるMIDI編集フリーソフト",
6
+ launch: "起動",
7
+ platform:
8
+ "対応ブラウザ (デスクトップ版のみ): Google Chrome / Firefox / Safari",
9
+ features: "機能",
10
+ "feature-midi-file": "フル機能のMIDIエディター",
11
+ "feature-midi-file-description":
12
+ "複数トラックに対応したピアノロールエディタを使って、自在に作曲しましょう。もちろんベロシティやピッチベンド、エクスプレッション、モジュレーションを使った豊かな表現ができます。",
13
+ "feature-gm-module": "GM互換音源搭載",
14
+ "feature-gm-module-description":
15
+ "WebAudio APIとAudioWorkletで作られた専用の音源モジュールにより、ブラウザ上で大量のMIDIノートを鳴らすことが可能になりました。",
16
+ "piano-roll": "ピアノロール",
17
+ tempo: "テンポ",
18
+ "tempo-track": "テンポトラック",
19
+ "feature-time-signature": "4/4以外の拍子・テンポチェンジに対応",
20
+ "feature-time-signature-description":
21
+ "グラフエディタを使って、曲の途中でテンポや拍子を自由に変えることができます。",
22
+ "feature-pwa": "PWA対応",
23
+ "feature-pwa-description":
24
+ "アドレスバーが邪魔ですか?アプリとしてインストールすることができます。",
25
+ "feature-export-wav": "WAVファイルへの書き出し",
26
+ "feature-midi-io": "MIDIキーボード対応",
27
+ "feature-midi-io-description":
28
+ "Web MIDI APIに対応したブラウザでは、MIDIキーボードを接続して演奏を録音したり、ハードウェアシンセで音を鳴らしたりすることができます。",
29
+ "feature-export-audio": "高速オーディオ書き出し機能",
30
+ "feature-export-audio-description":
31
+ "作った曲をWAVファイルに書き出して、スマートフォンで聴いたり動画のBGMに使ったり、DAWに取り込んだりすることができます。",
32
+ "time-signature": "拍子",
33
+ "add-time-signature": "拍子を追加",
34
+ "remove-time-signature": "拍子を削除",
35
+ support: "サポート",
36
+ "sponsor-intro":
37
+ "signalは週末に趣味で作っているアプリです。もしブラウザで動作する軽量な作曲ソフトというコンセプトに共感してもらえたら、ぜひご支援ください。",
38
+ "support-github-desctiption": "GitHubで不具合報告や要望を送る",
39
+ "become-sponsor": "スポンサーになりませんか?",
40
+ "open-github-sponsors": "GitHub Sponsorsを開く",
41
+ "follow-twitter": "Twitterで更新情報を確認する",
42
+ file: "ファイル",
43
+ "new-song": "新規作成",
44
+ "open-song": "開く",
45
+ "save-song": "保存",
46
+ "save-as": "名前を付けて保存",
47
+ "song-created": "楽曲が作成されました",
48
+ "song-saved": "楽曲が保存されました",
49
+ "download-midi": "MIDIファイルをダウンロード",
50
+ "untitled-song": "無題の楽曲",
51
+ name: "名前",
52
+ rename: "名前の変更",
53
+ track: "トラック",
54
+ tracks: "トラック",
55
+ "add-track": "トラックを追加",
56
+ "delete-track": "トラックを削除",
57
+ "duplicate-track": "トラックを複製",
58
+ channel: "チャンネル",
59
+ categories: "カテゴリ",
60
+ instruments: "楽器",
61
+ "rhythm-track": "リズムトラック",
62
+ "conductor-track": "コンダクタートラック",
63
+ "track-name": "トラック名",
64
+ cancel: "キャンセル",
65
+ ok: "OK",
66
+ reset: "リセット",
67
+ triplet: "三連符",
68
+ dotted: "付点",
69
+ "confirm-new": "編集中の楽曲が破棄されます。本当に新規作成しますか?",
70
+ "confirm-open": "編集中の楽曲が破棄されます。本当に開きますか?",
71
+ "confirm-close": "編集中の楽曲が破棄されます。本当にページを閉じますか?",
72
+ "auto-scroll": "自動でスクロール",
73
+ help: "ヘルプ",
74
+ close: "閉じる",
75
+ "keyboard-shortcut": "キーボードショートカット",
76
+ "play-pause": "再生・一時停止",
77
+ rewind: "巻戻し",
78
+ "fast-forward": "早送り",
79
+ "forward-rewind": "早送り・巻き戻し",
80
+ "next-previous-track": "次のトラック・前のトラック",
81
+ "solo-mute-ghost": "ソロ・ミュート・ゴーストトラックの切り替え",
82
+ stop: "停止",
83
+ record: "録音",
84
+ "pencil-tool": "鉛筆ツール",
85
+ "selection-tool": "選択ツール",
86
+ "copy-selection": "選択範囲をコピー",
87
+ "cut-selection": "選択範囲を切り取り",
88
+ "paste-selection": "コピーした選択範囲を現在位置に貼り付け",
89
+ "select-all": "すべて選択",
90
+ "delete-selection": "選択範囲を削除",
91
+ "move-selection": "選択範囲を移動",
92
+ undo: "元に戻す",
93
+ redo: "やり直し",
94
+ cut: "切り取り",
95
+ copy: "コピー",
96
+ paste: "ペースト",
97
+ duplicate: "複製",
98
+ delete: "削除",
99
+ "song-deleted": "曲を削除しました",
100
+ "song-delete-failed": "曲の削除に失敗しました",
101
+ "select-note": "ノートを選択",
102
+ "scroll-horizontally": "左右にスクロール",
103
+ "scroll-vertically": "上下にスクロール",
104
+ settings: "設定",
105
+ "midi-settings": "MIDI設定",
106
+ inputs: "入力",
107
+ outputs: "出力",
108
+ "no-inputs": "入力デバイスが見つかりません",
109
+ "one-octave-up": "+1オクターブ",
110
+ "one-octave-down": "-1オクターブ",
111
+ arrange: "アレンジ",
112
+ "arrangement-view": "アレンジビュー",
113
+ "open-help": "ヘルプを開く",
114
+ "event-list": "イベントリスト",
115
+ "open-chat": "チャットを開く",
116
+ property: "プロパティ",
117
+ quantize: "クオンタイズ",
118
+ "snap-to-grid": "グリッドにスナップ",
119
+ "export-audio": "オーディオ書き出し",
120
+ export: "書き出し",
121
+ "exporting-audio": "オーディオを書き出し中...",
122
+ "file-type": "ファイル形式",
123
+ "export-error-too-short": "曲が短すぎます",
124
+ "set-loop-start": "ループ開始位置を設定",
125
+ "set-loop-end": "ループ終了位置を設定",
126
+ "switch-tab": "タブを切り替え",
127
+ transpose: "トランスポーズ",
128
+ yes: "はい",
129
+ no: "いいえ",
130
+ "please-sign-up": "サインアップしてクラウドセーブを利用",
131
+ "save-changes": "曲への変更を保存しますか?",
132
+ "sign-in": "サインイン",
133
+ "sign-out": "サインアウト",
134
+ "success-sign-in": "サインインに成功しました",
135
+ files: "ファイル",
136
+ "created-date": "作成日時",
137
+ "modified-date": "更新日時",
138
+ "cloud-beta-warning":
139
+ "クラウド機能は開発中のベータ版のため、大事な楽曲はこまめにダウンロードをお願いします。",
140
+ "cloud-description":
141
+ "サインインすることで楽曲をクラウドに保存し、いつでもどこからでも作曲を再開することができます。",
142
+ "import-midi": "MIDI をインポート",
143
+ "export-midi": "MIDI をエクスポート",
144
+ general: "一般",
145
+ "change-track-color": "トラックの色を変更",
146
+ "control-settings": "コントロール設定",
147
+ add: "追加",
148
+ remove: "削除",
149
+ /* MIDI Instrument Categories */
150
+ Piano: "ピアノ",
151
+ "Chromatic Percussion": "クロマチック",
152
+ Organ: "オルガン",
153
+ Guitar: "ギター",
154
+ Bass: "ベース",
155
+ Strings: "ストリングス",
156
+ Ensemble: "アンサンブル",
157
+ Brass: "ブラス",
158
+ Reed: "リード",
159
+ Pipe: "笛",
160
+ "Synth Lead": "シンセリード",
161
+ "Synth Pad": "シンセパッド",
162
+ "Synth Effects": "シンセエフェクト",
163
+ Ethnic: "民族楽器",
164
+ Percussive: "打楽器",
165
+ "Sound effects": "効果音",
166
+ /* MIDI Instruments */
167
+ "Acoustic Grand Piano": "アコースティックピアノ",
168
+ "Bright Acoustic Piano": "ブライトピアノ",
169
+ "Electric Grand Piano": "エレクトリック・グランドピアノ",
170
+ "Honky-tonk Piano": "ホンキートンクピアノ",
171
+ "Electric Piano 1": "エレクトリックピアノ",
172
+ "Electric Piano 2": "FMエレクトリックピアノ",
173
+ Harpsichord: "ハープシコード",
174
+ Clavinet: "クラビネット",
175
+ Celesta: "チェレスタ",
176
+ Glockenspiel: "グロッケンシュピール",
177
+ "Music Box": "オルゴール",
178
+ Vibraphone: "ヴィブラフォン",
179
+ Marimba: "マリンバ",
180
+ Xylophone: "木琴",
181
+ "Tubular Bells": "チューブラーベル",
182
+ Dulcimer: "ダルシマー",
183
+ "Drawbar Organ": "ドローバーオルガン",
184
+ "Percussive Organ": "パーカッシブオルガン",
185
+ "Rock Organ": "ロックオルガン",
186
+ "Church Organ": "チャーチオルガン",
187
+ "Reed Organ": "リードオルガン",
188
+ Accordion: "アコーディオン",
189
+ Harmonica: "ハーモニカ",
190
+ "Tango Accordion": "タンゴアコーディオン",
191
+ "Acoustic Guitar (nylon)": "アコースティックギター(ナイロン弦)",
192
+ "Acoustic Guitar (steel)": "アコースティックギター(スチール弦)",
193
+ "Electric Guitar (jazz)": "ジャズギター",
194
+ "Electric Guitar (clean)": "クリーンギター",
195
+ "Electric Guitar (muted)": "ミュートギター",
196
+ "Overdriven Guitar": "オーバードライブギター",
197
+ "Distortion Guitar": "ディストーションギター",
198
+ "Guitar Harmonics": "ギターハーモニクス",
199
+ "Acoustic Bass": "アコースティックベース",
200
+ "Electric Bass (finger)": "フィンガーベース",
201
+ "Electric Bass (pick)": "ピックベース",
202
+ "Fretless Bass": "フレットレスベース",
203
+ "Slap Bass 1": "スラップベース 1",
204
+ "Slap Bass 2": "スラップベース 2",
205
+ "Synth Bass 1": "シンセベ���ス 1",
206
+ "Synth Bass 2": "シンセベース 2",
207
+ Violin: "ヴァイオリン",
208
+ Viola: "ヴィオラ",
209
+ Cello: "チェロ",
210
+ Contrabass: "コントラバス",
211
+ "Tremolo Strings": "トレモロストリングス",
212
+ "Pizzicato Strings": "ピッツィカートストリングス",
213
+ "Orchestral Harp": "ハープ",
214
+ Timpani: "ティンパニ",
215
+ "String Ensemble 1": "ストリングアンサンブル",
216
+ "String Ensemble 2": "スローストリングアンサンブル",
217
+ "Synth Strings 1": "シンセストリングス",
218
+ "Synth Strings 2": "シンセストリングス2",
219
+ "Choir Aahs": "声「アー」",
220
+ "Voice Oohs": "声「ドゥー」",
221
+ "Synth Choir": "シンセヴォイス",
222
+ "Orchestra Hit": "オーケストラヒット",
223
+ Trumpet: "トランペット",
224
+ Trombone: "トロンボーン",
225
+ Tuba: "チューバ",
226
+ "Muted Trumpet": "ミュートトランペット",
227
+ "French Horn": "フレンチ・ホルン",
228
+ "Brass Section": "ブラスセクション",
229
+ "Synth Brass 1": "シンセブラス 1",
230
+ "Synth Brass 2": "シンセブラス 2",
231
+ "Soprano Sax": "ソプラノサックス",
232
+ "Alto Sax": "アルトサックス",
233
+ "Tenor Sax": "テナーサックス",
234
+ "Baritone Sax": "バリトンサックス",
235
+ Oboe: "オーボエ",
236
+ "English Horn": "イングリッシュホルン",
237
+ Bassoon: "ファゴット",
238
+ Clarinet: "クラリネット",
239
+ Piccolo: "ピッコロ",
240
+ Flute: "フルート",
241
+ Recorder: "リコーダー",
242
+ "Pan Flute": "パンフルート",
243
+ "Blown Bottle": "ブロウンボトル",
244
+ Shakuhachi: "尺八",
245
+ Whistle: "口笛",
246
+ Ocarina: "オカリナ",
247
+ "Lead 1 (square)": "正方波",
248
+ "Lead 2 (sawtooth)": "ノコギリ波",
249
+ "Lead 3 (calliope)": "カリオペリード",
250
+ "Lead 4 (chiff)": "チフリード",
251
+ "Lead 5 (charang)": "チャランゴリード",
252
+ "Lead 6 (voice)": "ボイスリード",
253
+ "Lead 7 (fifths)": "フィフスズリード",
254
+ "Lead 8 (bass + lead)": "ベース + リード",
255
+ "Pad 1 (new age)": "ファンタジア",
256
+ "Pad 2 (warm)": "ウォーム",
257
+ "Pad 3 (polysynth)": "ポリシンセ",
258
+ "Pad 4 (choir)": "クワイア",
259
+ "Pad 5 (bowed)": "ボウ",
260
+ "Pad 6 (metallic)": "メタリック",
261
+ "Pad 7 (halo)": "ハロー",
262
+ "Pad 8 (sweep)": "スウィープ",
263
+ "FX 1 (rain)": "雨",
264
+ "FX 2 (soundtrack)": "サウンドトラック",
265
+ "FX 3 (crystal)": "クリスタル",
266
+ "FX 4 (atmosphere)": "アトモスフィア",
267
+ "FX 5 (brightness)": "ブライトネス",
268
+ "FX 6 (goblins)": "ゴブリン",
269
+ "FX 7 (echoes)": "エコー",
270
+ "FX 8 (sci-fi)": "サイファイ",
271
+ Sitar: "シタール",
272
+ Banjo: "バンジョー",
273
+ Shamisen: "三味線",
274
+ Koto: "琴",
275
+ Kalimba: "カリンバ",
276
+ Bagpipe: "バグパイプ",
277
+ Fiddle: "フィドル",
278
+ Shanai: "シャハナーイ",
279
+ "Tinkle Bell": "ティンクルベル",
280
+ Agogo: "アゴゴ",
281
+ "Steel Drums": "スチールドラム",
282
+ Woodblock: "ウッドブロック",
283
+ "Taiko Drum": "太鼓",
284
+ "Melodic Tom": "メロディックタム",
285
+ "Synth Drum": "シンセドラム",
286
+ "Reverse Cymbal": "リバースシンバル",
287
+ "Guitar Fret Noise": "ギターフレットノイズ",
288
+ "Breath Noise": "ブレスノイズ",
289
+ Seashore: "海岸",
290
+ "Bird Tweet": "鳥のさえずり",
291
+ "Telephone Ring": "電話のベル",
292
+ Helicopter: "ヘリコプター",
293
+ Applause: "拍手",
294
+ Gunshot: "銃声",
295
+ },
296
+ zh: {
297
+ "landing-title": "signal - 在线 MIDI 编辑器",
298
+ "app-intro": "在浏览器中编辑曲目",
299
+ "app-desc": "无需安装任何东西即可开始创作曲目",
300
+ launch: "开始",
301
+ platform: "支持浏览器(仅限桌面版):Google Chrome / Firefox / Safari",
302
+ features: "功能",
303
+ "feature-midi-file": "全功能 MIDI 编辑器",
304
+ "feature-midi-file-description":
305
+ "使用多轨钢琴卷编辑器自由编排。您可以使用力度、弯音、情绪和调制等控制器来创作出富有表现力的曲目。",
306
+ "feature-gm-module": "GM 兼容音源",
307
+ "feature-gm-module-description":
308
+ "快速加载 128 种虚拟乐器,同时使用 WebAudio API 和 AudioWorklet 构建的专用音频模块让您可以在浏览器中播放任何的音符。",
309
+ "piano-roll": "钢琴卷",
310
+ tempo: "速度",
311
+ "tempo-track": "速度轨",
312
+ "feature-time-signature": "支持除 4/4 以外的拍号和 300+ BPM",
313
+ "feature-time-signature-description":
314
+ "您可以使用可视化编辑器自由更改曲目中的速度和拍号。",
315
+ "feature-pwa": "支持 PWA",
316
+ "feature-pwa-description":
317
+ "想要隐藏浏览器的地址栏? 您可以将其安装在桌面上并将其用作应用程序使用。",
318
+ "feature-export-wav": "导出为 WAV 文件",
319
+ "feature-midi-io": "支持 MIDI 键盘",
320
+ "feature-midi-io-description":
321
+ "支持 Web MIDI API 的浏览器允许您连接 MIDI 键盘来录制您的演奏,并使用硬件合成器播放音频。",
322
+ "feature-export-audio": "快速音频导出功能",
323
+ "feature-export-audio-description":
324
+ "您可以将创作的曲目导出为 WAV 文件并在智能手机上播放,将其用作视频的背景音乐,或将其导入 DAW。",
325
+ "time-signature": "拍号",
326
+ "add-time-signature": "添加拍号",
327
+ "remove-time-signature": "删除拍号",
328
+ support: "支持",
329
+ "sponsor-intro":
330
+ "signal 是我个人在周末构建的应用程序。如果您喜欢在浏览器中运行的轻量级合成软件,请支持我。",
331
+ "support-github-desctiption": "在 GitHub 上提交 BUG 和 PR",
332
+ "become-sponsor": "成为赞助商",
333
+ "open-github-sponsors": "前往 GitHub Sponsors",
334
+ "follow-twitter": "关注 Twitter 以获取更新",
335
+ file: "文件",
336
+ "new-song": "新建",
337
+ "open-song": "打开",
338
+ "save-song": "保存",
339
+ "save-as": "另存为",
340
+ "song-created": "曲目已创建",
341
+ "song-saved": "曲目已保存",
342
+ "untitled-song": "未命名",
343
+ name: "名称",
344
+ rename: "重命名",
345
+ track: "音轨",
346
+ "add-track": "添加音轨",
347
+ "delete-track": "删除音轨",
348
+ "duplicate-track": "复制音轨",
349
+ channel: "频道",
350
+ categories: "类别",
351
+ instruments: "乐器",
352
+ "rhythm-track": "节奏轨",
353
+ "conductor-track": "指挥轨",
354
+ "track-name": "音轨名",
355
+ cancel: "取消",
356
+ ok: "确定",
357
+ triplet: "三连音",
358
+ dotted: "附点",
359
+ "confirm-new": "正在编辑曲目,您确定要新建一个吗?",
360
+ "confirm-open": "正在编辑曲目,您确定要打开另一个吗?",
361
+ "confirm-close": "正在编辑曲目,您确定要关闭吗?",
362
+ "auto-scroll": "自动滚动",
363
+ help: "帮助",
364
+ close: "关闭",
365
+ "keyboard-shortcut": "快捷键",
366
+ "play-pause": "播放/暂停",
367
+ rewind: "快退",
368
+ "fast-forward": "快进",
369
+ "forward-rewind": "快进/快退",
370
+ "next-previous-track": "下一音轨/上一音轨",
371
+ "solo-mute-ghost": "独奏/静音/幽灵音",
372
+ stop: "停止",
373
+ record: "录音",
374
+ "pencil-tool": "铅笔工具",
375
+ "selection-tool": "选择工具",
376
+ "copy-selection": "拷贝选择的内容",
377
+ "cut-selection": "剪切选择的内容",
378
+ "paste-selection": "粘贴选择的内容",
379
+ "select-all": "全选",
380
+ "delete-selection": "删除选择的内容",
381
+ "move-selection": "移动选择的内容",
382
+ undo: "撤消",
383
+ redo: "恢复",
384
+ cut: "剪切",
385
+ copy: "拷贝",
386
+ paste: "粘贴",
387
+ duplicate: "复制",
388
+ delete: "删除",
389
+ "select-note": "选择音符",
390
+ "scroll-horizontally": "左右滚动",
391
+ "scroll-vertically": "上下滚动",
392
+ settings: "设置",
393
+ "midi-settings": "MIDI设置",
394
+ inputs: "输入",
395
+ outputs: "输出",
396
+ "one-octave-up": "+1 个八度",
397
+ "one-octave-down": "-1 个八度",
398
+ arrange: "编排",
399
+ "arrangement-view": "编排视图",
400
+ "open-help": "打开帮助",
401
+ "event-list": "事件列表",
402
+ "open-chat": "打开聊天框",
403
+ property: "属性",
404
+ quantize: "量化",
405
+ "snap-to-grid": "对齐网格",
406
+ "export-audio": "导出音频",
407
+ export: "导出",
408
+ "exporting-audio": "正在导出音频...",
409
+ "file-type": "文件格式",
410
+ "export-error-too-short": "这首曲目长度太短了",
411
+ "set-loop-start": "设置循环开始位置",
412
+ "set-loop-end": "设置循环结束位置",
413
+ "switch-tab": "切换标签",
414
+ transpose: "移调",
415
+ yes: "是",
416
+ no: "否",
417
+ "please-sign-up": "注册来使用云存档",
418
+ "save-changes": "是否要保存对曲目的更改?",
419
+ "sign-in": "登录",
420
+ "success-sign-in": "登录成功",
421
+ files: "文件",
422
+ "created-date": "创建日期",
423
+ "modified-date": "修改日期",
424
+ "cloud-beta-warning":
425
+ "由于云功能处于开发阶段,请及时下载保存您创作的重要曲目。",
426
+ "cloud-description":
427
+ "通过登录,您可以将您的曲目保存到云端并随时随地恢复作曲。",
428
+ "import-midi": "导入 MIDI",
429
+ "export-midi": "导出 MIDI",
430
+ /* MIDI Instrument Categories */
431
+ Piano: "钢琴",
432
+ "Chromatic Percussion": "半音打击乐",
433
+ Organ: "风琴",
434
+ Guitar: "吉他",
435
+ Bass: "贝司",
436
+ Strings: "弦乐独奏",
437
+ Ensemble: "合唱合奏",
438
+ Brass: "铜管乐器",
439
+ Reed: "哨片乐器",
440
+ Pipe: "吹管乐器",
441
+ "Synth Lead": "合成主音",
442
+ "Synth Pad": "合成柔音",
443
+ "Synth Effects": "合成特效",
444
+ Ethnic: "民族乐器",
445
+ Percussive: "打击乐",
446
+ "Sound effects": "声音特效",
447
+ /* MIDI Instruments */
448
+ "Acoustic Grand Piano": "大钢琴",
449
+ "Bright Acoustic Piano": "立式钢琴",
450
+ "Electric Grand Piano": "电钢琴",
451
+ "Honky-tonk Piano": "酒吧钢琴",
452
+ "Electric Piano 1": "电钢琴1",
453
+ "Electric Piano 2": "电钢琴2",
454
+ Harpsichord: "拨弦古钢琴",
455
+ Clavinet: "击弦古钢琴",
456
+ Celesta: "钢片琴",
457
+ Glockenspiel: "钟琴",
458
+ "Music Box": "八音盒",
459
+ Vibraphone: "电颤琴",
460
+ Marimba: "马林巴",
461
+ Xylophone: "木琴",
462
+ "Tubular Bells": "管钟",
463
+ Dulcimer: "扬琴",
464
+ "Drawbar Organ": "击杆风琴",
465
+ "Percussive Organ": "打击型风琴",
466
+ "Rock Organ": "摇滚风琴",
467
+ "Church Organ": "管风琴",
468
+ "Reed Organ": "簧风琴",
469
+ Accordion: "手风琴",
470
+ Harmonica: "口琴",
471
+ "Tango Accordion": "探戈手风琴",
472
+ "Acoustic Guitar (nylon)": "尼龙弦吉他",
473
+ "Acoustic Guitar (steel)": "钢弦吉他",
474
+ "Electric Guitar (jazz)": "爵士乐电吉他",
475
+ "Electric Guitar (clean)": "清音电吉他",
476
+ "Electric Guitar (muted)": "弱音电吉他",
477
+ "Overdriven Guitar": "驱动音效吉他",
478
+ "Distortion Guitar": "失真音效吉他",
479
+ "Guitar Harmonics": "吉他泛音",
480
+ "Acoustic Bass": "原声贝司",
481
+ "Electric Bass (finger)": "指拨电贝司",
482
+ "Electric Bass (pick)": "拨片拨电贝司",
483
+ "Fretless Bass": "无品贝司",
484
+ "Slap Bass 1": "击弦贝司1",
485
+ "Slap Bass 2": "击弦贝司2",
486
+ "Synth Bass 1": "合成贝司1",
487
+ "Synth Bass 2": "合成贝司2",
488
+ Violin: "小提琴",
489
+ Viola: "中提琴",
490
+ Cello: "大提琴",
491
+ Contrabass: "低音提琴",
492
+ "Tremolo Strings": "弦乐震音",
493
+ "Pizzicato Strings": "弦乐拨奏",
494
+ "Orchestral Harp": "竖琴",
495
+ Timpani: "定音鼓",
496
+ "String Ensemble 1": "弦乐合奏1",
497
+ "String Ensemble 2": "弦乐合奏2",
498
+ "Synth Strings 1": "合成弦乐1",
499
+ "Synth Strings 2": "合成弦乐2",
500
+ "Choir Aahs": "合唱“啊”音",
501
+ "Voice Oohs": "人声“嘟”音",
502
+ "Synth Choir": "合成人声",
503
+ "Orchestra Hit": "乐队打击乐",
504
+ Trumpet: "小号",
505
+ Trombone: "长号",
506
+ Tuba: "大号",
507
+ "Muted Trumpet": "弱音小号",
508
+ "French Horn": "圆号",
509
+ "Brass Section": "铜管组",
510
+ "Synth Brass 1": "合成铜管1",
511
+ "Synth Brass 2": "合成铜管2",
512
+ "Soprano Sax": "高音萨克斯",
513
+ "Alto Sax": "中音萨克斯",
514
+ "Tenor Sax": "次中音萨克斯",
515
+ "Baritone Sax": "上低音萨克斯",
516
+ Oboe: "双簧管",
517
+ "English Horn": "英国管",
518
+ Bassoon: "大管",
519
+ Clarinet: "单簧管",
520
+ Piccolo: "短笛",
521
+ Flute: "长笛",
522
+ Recorder: "竖笛",
523
+ "Pan Flute": "排笛",
524
+ "Blown Bottle": "吹瓶口",
525
+ Shakuhachi: "尺八",
526
+ Whistle: "哨",
527
+ Ocarina: "奥卡雷那",
528
+ "Lead 1 (square)": "合成主音1(方波)",
529
+ "Lead 2 (sawtooth)": "合成主音2(锯齿波)",
530
+ "Lead 3 (calliope)": "合成主音3(汽笛风琴)",
531
+ "Lead 4 (chiff)": "合成主音4 (吹管)",
532
+ "Lead 5 (charang)": "合成主音5(吉他)",
533
+ "Lead 6 (voice)": "合成主音6(人声)",
534
+ "Lead 7 (fifths)": "合成主音7(五度)",
535
+ "Lead 8 (bass + lead)": "合成主音8(贝斯加主音)",
536
+ "Pad 1 (new age)": "合成柔音1(新时代)",
537
+ "Pad 2 (warm)": "合成柔音(暖音)",
538
+ "Pad 3 (polysynth)": "合成柔音3(复合成)",
539
+ "Pad 4 (choir)": "合成柔音4(合唱)",
540
+ "Pad 5 (bowed)": "合成柔音5(弓弦)",
541
+ "Pad 6 (metallic)": "合成柔音6(金属)",
542
+ "Pad 7 (halo)": "合成柔音7(光晕)",
543
+ "Pad 8 (sweep)": "合成柔音8(扫弦)",
544
+ "FX 1 (rain)": "合成特效1(雨)",
545
+ "FX 2 (soundtrack)": "合成特效2(音轨)",
546
+ "FX 3 (crystal)": "合成特效3(水晶)",
547
+ "FX 4 (atmosphere)": "合成特效4(大气)",
548
+ "FX 5 (brightness)": "合成特效5(轻柔)",
549
+ "FX 6 (goblins)": "合成特效6(小妖)",
550
+ "FX 7 (echoes)": "合成特效7(回声)",
551
+ "FX 8 (sci-fi)": "合成特效8(科幻)",
552
+ Sitar: "锡塔尔",
553
+ Banjo: "班卓",
554
+ Shamisen: "三味线",
555
+ Koto: "筝",
556
+ Kalimba: "卡林巴",
557
+ Bagpipe: "风笛",
558
+ Fiddle: "古提琴",
559
+ Shanai: "唢呐",
560
+ "Tinkle Bell": "铃铛",
561
+ Agogo: "拉丁打铃",
562
+ "Steel Drums": "钢鼓",
563
+ Woodblock: "木块",
564
+ "Taiko Drum": "太鼓",
565
+ "Melodic Tom": "嗵鼓",
566
+ "Synth Drum": "合成鼓",
567
+ "Reverse Cymbal": "铜钹",
568
+ "Guitar Fret Noise": "磨弦声",
569
+ "Breath Noise": "呼吸声",
570
+ Seashore: "海浪声",
571
+ "Bird Tweet": "鸟鸣声",
572
+ "Telephone Ring": "电话声",
573
+ Helicopter: "直升机声",
574
+ Applause: "鼓掌声",
575
+ Gunshot: "枪声",
576
+ velocity: "力度控制",
577
+ "pitch-bend": "弯音控制",
578
+ volume: "音量控制",
579
+ panpot: "声像控制",
580
+ expression: "情绪控制",
581
+ "hold-pedal": "延音踏板",
582
+ pan: "声像",
583
+ event: "事件",
584
+ duration: "时长",
585
+ tick: "起始",
586
+ value: "值",
587
+ },
588
+ } as { [key: string]: { [key: string]: string } }
src/common/localize/localizedString.ts ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import localization from "./localization"
2
+
3
+ export type Language = "en" | "ja" | "zh"
4
+
5
+ export function localized(key: string): string | undefined
6
+ export function localized(key: string, defaultValue: string): string
7
+ export function localized(
8
+ key: string,
9
+ defaultValue: string,
10
+ language?: Language,
11
+ ): string
12
+ export function localized(
13
+ key: string,
14
+ defaultValue?: string,
15
+ language?: Language,
16
+ ): string | undefined {
17
+ // ja-JP or ja -> ja
18
+
19
+ const locale = language ?? getBrowserLanguage()
20
+
21
+ if (
22
+ key !== null &&
23
+ localization[locale] !== undefined &&
24
+ localization[locale][key] !== undefined
25
+ ) {
26
+ return localization[locale][key]
27
+ }
28
+ return defaultValue
29
+ }
30
+
31
+ function getBrowserLanguage() {
32
+ // Use URL parameter ?lang=ja or navigator.language
33
+ const navigatorLanguage = navigator.language.split("-")[0]
34
+ const langParam = new URL(location.href).searchParams.get("lang")
35
+ return langParam ?? navigatorLanguage
36
+ }
src/common/measure/Measure.ts ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export interface Measure {
2
+ startTick: number
3
+ measure: number
4
+ numerator: number
5
+ denominator: number
6
+ }
7
+
8
+ export const calculateMBT = (
9
+ measure: Measure,
10
+ tick: number,
11
+ ticksPerBeatBase: number,
12
+ ) => {
13
+ const ticksPerBeat = (ticksPerBeatBase * 4) / measure.denominator
14
+ const ticksPerMeasure = ticksPerBeat * measure.numerator
15
+
16
+ let aTick = tick - measure.startTick
17
+
18
+ const deltaMeasure = Math.floor(aTick / ticksPerMeasure)
19
+ aTick -= deltaMeasure * ticksPerMeasure
20
+
21
+ const beat = Math.floor(aTick / ticksPerBeat)
22
+ aTick -= beat * ticksPerBeat
23
+
24
+ return {
25
+ measure: measure.measure + deltaMeasure,
26
+ beat: beat,
27
+ tick: aTick,
28
+ }
29
+ }
src/common/measure/MeasureList.ts ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import Track, { isTimeSignatureEvent } from "../track"
2
+ import { Measure } from "./Measure"
3
+
4
+ export function getMeasureAt(tick: number, measures: Measure[]): Measure {
5
+ let lastMeasure: Measure = {
6
+ startTick: 0,
7
+ measure: 0,
8
+ denominator: 4,
9
+ numerator: 4,
10
+ }
11
+ for (const m of measures) {
12
+ if (m.startTick > tick) {
13
+ break
14
+ }
15
+ lastMeasure = m
16
+ }
17
+ return lastMeasure
18
+ }
19
+
20
+ export function getMeasuresFromConductorTrack(
21
+ conductorTrack: Track,
22
+ timebase: number,
23
+ ): Measure[] {
24
+ const events = conductorTrack.events
25
+ .filter(isTimeSignatureEvent)
26
+ .slice()
27
+ .sort((a, b) => a.tick - b.tick)
28
+
29
+ if (events.length === 0) {
30
+ return [
31
+ {
32
+ startTick: 0,
33
+ measure: 0,
34
+ denominator: 4,
35
+ numerator: 4,
36
+ },
37
+ ]
38
+ } else {
39
+ let lastMeasure = 0
40
+ return events.map((e, i) => {
41
+ let measure = 0
42
+ if (i > 0) {
43
+ const lastEvent = events[i - 1]
44
+ const ticksPerBeat = (timebase * 4) / lastEvent.denominator
45
+ const measureDelta = Math.floor(
46
+ (e.tick - lastEvent.tick) / ticksPerBeat / lastEvent.numerator,
47
+ )
48
+ measure = lastMeasure + measureDelta
49
+ lastMeasure = measure
50
+ }
51
+ return {
52
+ startTick: e.tick,
53
+ measure,
54
+ numerator: e.numerator,
55
+ denominator: e.denominator,
56
+ }
57
+ })
58
+ }
59
+ }
src/common/measure/mbt.ts ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { calculateMBT, Measure } from "./Measure"
2
+ import { getMeasureAt } from "./MeasureList"
3
+
4
+ export const getMBTString = (
5
+ measures: Measure[],
6
+ tick: number,
7
+ ticksPerBeat: number,
8
+ formatter = defaultMBTFormatter,
9
+ ): string => formatter(getMBT(measures, tick, ticksPerBeat))
10
+
11
+ interface Beat {
12
+ measure: number
13
+ beat: number
14
+ tick: number
15
+ }
16
+
17
+ const getMBT = (
18
+ measures: Measure[],
19
+ tick: number,
20
+ ticksPerBeat: number,
21
+ ): Beat => {
22
+ return calculateMBT(getMeasureAt(tick, measures), tick, ticksPerBeat)
23
+ }
24
+
25
+ const pad = (v: number, digit: number) => {
26
+ const str = v.toString(10)
27
+ return ("0".repeat(digit) + str).slice(-Math.max(digit, str.length))
28
+ }
29
+
30
+ function defaultMBTFormatter(mbt: Beat): string {
31
+ return `${pad(mbt.measure + 1, 4)}:${pad(mbt.beat + 1, 2)}:${pad(
32
+ mbt.tick,
33
+ 3,
34
+ )}`
35
+ }
src/common/midi/GM.ts ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export const getCategoryIndex = (programNumber: number) =>
2
+ Math.floor(programNumber / 8)
3
+
4
+ export const categoryEmojis = [
5
+ "🎹",
6
+ "🔔",
7
+ "🎹",
8
+ "🎸",
9
+ "🎸",
10
+ "🎻",
11
+ "🧑‍🤝‍🧑",
12
+ "🎺",
13
+ "🎷",
14
+ "🍾",
15
+ "🕹️",
16
+ "🔮",
17
+ "⚡",
18
+ "🍛",
19
+ "🥁",
20
+ "🚁",
21
+ ]
src/common/midi/MidiEvent.ts ADDED
@@ -0,0 +1,273 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ ControllerEvent,
3
+ EndOfTrackEvent,
4
+ Event,
5
+ NoteOffEvent,
6
+ NoteOnEvent,
7
+ PitchBendEvent,
8
+ PortPrefixEvent,
9
+ ProgramChangeEvent,
10
+ SequencerSpecificEvent,
11
+ SetTempoEvent,
12
+ TimeSignatureEvent,
13
+ TrackNameEvent,
14
+ } from "midifile-ts"
15
+
16
+ /* factory */
17
+
18
+ export function midiEvent<T extends string>(
19
+ deltaTime: number,
20
+ type: T,
21
+ ): Event<T> {
22
+ return {
23
+ deltaTime,
24
+ type,
25
+ }
26
+ }
27
+
28
+ export function endOfTrackMidiEvent(deltaTime: number): EndOfTrackEvent {
29
+ return {
30
+ deltaTime,
31
+ type: "meta",
32
+ subtype: "endOfTrack",
33
+ }
34
+ }
35
+
36
+ export function portPrefixMidiEvent(
37
+ deltaTime: number,
38
+ port: number,
39
+ ): PortPrefixEvent {
40
+ return {
41
+ deltaTime,
42
+ type: "meta",
43
+ subtype: "portPrefix",
44
+ port,
45
+ }
46
+ }
47
+
48
+ export function trackNameMidiEvent(
49
+ deltaTime: number,
50
+ text: string,
51
+ ): TrackNameEvent {
52
+ return {
53
+ deltaTime,
54
+ type: "meta",
55
+ subtype: "trackName",
56
+ text,
57
+ }
58
+ }
59
+
60
+ // from bpm: SetTempoMidiEvent(t, 60000000 / bpm)
61
+ export function setTempoMidiEvent(
62
+ deltaTime: number,
63
+ microsecondsPerBeat: number,
64
+ ): SetTempoEvent {
65
+ return {
66
+ deltaTime,
67
+ type: "meta",
68
+ subtype: "setTempo",
69
+ microsecondsPerBeat,
70
+ }
71
+ }
72
+
73
+ export function timeSignatureMidiEvent(
74
+ deltaTime: number,
75
+ numerator = 4,
76
+ denominator = 4,
77
+ metronome = 24,
78
+ thirtyseconds = 8,
79
+ ): TimeSignatureEvent {
80
+ return {
81
+ deltaTime,
82
+ type: "meta",
83
+ subtype: "timeSignature",
84
+ numerator,
85
+ denominator,
86
+ metronome,
87
+ thirtyseconds,
88
+ }
89
+ }
90
+
91
+ // channel events
92
+
93
+ export function noteOnMidiEvent(
94
+ deltaTime: number,
95
+ channel: number,
96
+ noteNumber: number,
97
+ velocity: number,
98
+ ): NoteOnEvent {
99
+ return {
100
+ deltaTime,
101
+ type: "channel",
102
+ subtype: "noteOn",
103
+ channel,
104
+ noteNumber,
105
+ velocity,
106
+ }
107
+ }
108
+
109
+ export function noteOffMidiEvent(
110
+ deltaTime: number,
111
+ channel: number,
112
+ noteNumber: number,
113
+ velocity: number = 0,
114
+ ): NoteOffEvent {
115
+ return {
116
+ deltaTime,
117
+ type: "channel",
118
+ subtype: "noteOff",
119
+ channel,
120
+ noteNumber,
121
+ velocity,
122
+ }
123
+ }
124
+
125
+ export function pitchBendMidiEvent(
126
+ deltaTime: number,
127
+ channel: number,
128
+ value: number,
129
+ ): PitchBendEvent {
130
+ return {
131
+ deltaTime,
132
+ type: "channel",
133
+ subtype: "pitchBend",
134
+ channel,
135
+ value,
136
+ }
137
+ }
138
+
139
+ export function programChangeMidiEvent(
140
+ deltaTime: number,
141
+ channel: number,
142
+ value: number,
143
+ ): ProgramChangeEvent {
144
+ return {
145
+ deltaTime,
146
+ type: "channel",
147
+ subtype: "programChange",
148
+ channel,
149
+ value,
150
+ }
151
+ }
152
+
153
+ // controller events
154
+
155
+ export function controllerMidiEvent(
156
+ deltaTime: number,
157
+ channel: number,
158
+ controllerType: number,
159
+ value: number,
160
+ ): ControllerEvent {
161
+ return {
162
+ deltaTime,
163
+ type: "channel",
164
+ subtype: "controller",
165
+ channel,
166
+ controllerType,
167
+ value,
168
+ }
169
+ }
170
+
171
+ export function modulationMidiEvent(
172
+ deltaTime: number,
173
+ channel: number,
174
+ value: number,
175
+ ) {
176
+ return controllerMidiEvent(deltaTime, channel, 0x01, value)
177
+ }
178
+
179
+ export function volumeMidiEvent(
180
+ deltaTime: number,
181
+ channel: number,
182
+ value: number,
183
+ ) {
184
+ return controllerMidiEvent(deltaTime, channel, 0x07, value)
185
+ }
186
+
187
+ export function panMidiEvent(
188
+ deltaTime: number,
189
+ channel: number,
190
+ value: number,
191
+ ) {
192
+ return controllerMidiEvent(deltaTime, channel, 0x0a, value)
193
+ }
194
+
195
+ export function expressionMidiEvent(
196
+ deltaTime: number,
197
+ channel: number,
198
+ value: number,
199
+ ) {
200
+ return controllerMidiEvent(deltaTime, channel, 0x0b, value)
201
+ }
202
+
203
+ export function resetAllMidiEvent(deltaTime: number, channel: number) {
204
+ return controllerMidiEvent(deltaTime, channel, 121, 0)
205
+ }
206
+
207
+ export function sequencerSpecificEvent(
208
+ deltaTime: number,
209
+ data: number[],
210
+ ): SequencerSpecificEvent {
211
+ return {
212
+ type: "meta",
213
+ subtype: "sequencerSpecific",
214
+ deltaTime,
215
+ data,
216
+ }
217
+ }
218
+
219
+ // Control Change
220
+
221
+ export function controlChangeEvents(
222
+ deltaTime: number,
223
+ channel: number,
224
+ rpnMsb: number,
225
+ rpnLsb: number,
226
+ dataMsb?: number | undefined,
227
+ dataLsb?: number | undefined,
228
+ ): ControllerEvent[] {
229
+ const rpn = [
230
+ controllerMidiEvent(deltaTime, channel, 101, rpnMsb),
231
+ controllerMidiEvent(0, channel, 100, rpnLsb),
232
+ ]
233
+
234
+ const data: ControllerEvent[] = []
235
+ if (dataMsb !== undefined) {
236
+ data.push(controllerMidiEvent(0, channel, 6, dataMsb))
237
+ }
238
+ if (dataLsb !== undefined) {
239
+ data.push(controllerMidiEvent(0, channel, 38, dataLsb))
240
+ }
241
+
242
+ return [...rpn, ...data]
243
+ }
244
+
245
+ // value: 0 - 24 (半音 / Half sound)
246
+ export function pitchbendSensitivityEvents(
247
+ deltaTime: number,
248
+ channel: number,
249
+ value = 2,
250
+ ) {
251
+ return controlChangeEvents(deltaTime, channel, 0, 0, value)
252
+ }
253
+
254
+ // value: -8192 - 8191
255
+ export function masterFineTuningEvents(
256
+ deltaTime: number,
257
+ channel: number,
258
+ value = 0,
259
+ ) {
260
+ const s = value + 0x2000
261
+ const m = Math.floor(s / 0x80)
262
+ const l = s - m * 0x80
263
+ return controlChangeEvents(deltaTime, channel, 0, 1, m, l)
264
+ }
265
+
266
+ // value: -24 - 24
267
+ export function masterCoarceTuningEvents(
268
+ deltaTime: number,
269
+ channel: number,
270
+ value = 0,
271
+ ) {
272
+ return controlChangeEvents(deltaTime, channel, 0, 2, value + 64)
273
+ }
src/common/midi/midiConversion.test.ts ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as fs from "fs"
2
+ import { AnyEvent } from "midifile-ts"
3
+ import * as path from "path"
4
+ import { serialize } from "serializr"
5
+ import { emptySong } from "../song/SongFactory"
6
+ import { NoteEvent } from "../track"
7
+ import Track from "../track/Track"
8
+ import {
9
+ noteOffMidiEvent,
10
+ noteOnMidiEvent,
11
+ setTempoMidiEvent,
12
+ timeSignatureMidiEvent,
13
+ } from "./MidiEvent"
14
+ import {
15
+ createConductorTrackIfNeeded,
16
+ songFromMidi,
17
+ songToMidi,
18
+ songToMidiEvents,
19
+ } from "./midiConversion"
20
+
21
+ // id for each event will not be serialized in midi file
22
+ // we change ids sorted by order in events array
23
+ const reassignIDs = (track: Track) => {
24
+ track.events.forEach((e, i) => {
25
+ track.events[i].id = i
26
+ })
27
+ }
28
+
29
+ describe("SongFile", () => {
30
+ it("write and read", () => {
31
+ const song = emptySong()
32
+ const note = song.tracks[1].addEvent<NoteEvent>({
33
+ type: "channel",
34
+ subtype: "note",
35
+ noteNumber: 57,
36
+ tick: 960,
37
+ velocity: 127,
38
+ duration: 240,
39
+ })
40
+ song.tracks.forEach(reassignIDs)
41
+ const bytes = songToMidi(song)
42
+ const song2 = songFromMidi(bytes)
43
+ song2.filepath = song.filepath // filepath will not be serialized
44
+ expect(serialize(song2)).toStrictEqual(serialize(song))
45
+ })
46
+ describe("songToMidiEvents", () => {
47
+ const expectEveryTrackHaveEndOfTrackEvent = (tracks: AnyEvent[][]) => {
48
+ for (const track of tracks) {
49
+ expect(
50
+ track.findIndex(
51
+ (e) => e.type === "meta" && e.subtype === "endOfTrack",
52
+ ),
53
+ ).toBe(track.length - 1)
54
+ }
55
+ }
56
+
57
+ const openFile = (fileName: string): AnyEvent[][] => {
58
+ const song = songFromMidi(
59
+ fs.readFileSync(path.join(__dirname, "../../../testdata/", fileName))
60
+ .buffer,
61
+ )
62
+ return songToMidiEvents(song)
63
+ }
64
+
65
+ describe("format 1", () => {
66
+ const rawTracks = openFile("tracks.mid")
67
+
68
+ it("every tracks have endOfTrack event", () => {
69
+ expect(rawTracks.length).toBe(18)
70
+ expectEveryTrackHaveEndOfTrackEvent(rawTracks)
71
+ })
72
+ })
73
+
74
+ describe("format 0", () => {
75
+ const rawTracks = openFile("format0.mid")
76
+
77
+ it("every tracks have endOfTrack event", () => {
78
+ expect(rawTracks.length).toBe(17)
79
+ expectEveryTrackHaveEndOfTrackEvent(rawTracks)
80
+ })
81
+ })
82
+ })
83
+
84
+ describe("signal events", () => {
85
+ it("should save the track color", () => {
86
+ const song = emptySong()
87
+ song.tracks[1].setColor({
88
+ red: 12,
89
+ green: 34,
90
+ blue: 56,
91
+ alpha: 78,
92
+ })
93
+ const bytes = songToMidi(song)
94
+ const song2 = songFromMidi(bytes)
95
+ expect(song2.tracks[1].color).toMatchObject({
96
+ red: 12,
97
+ green: 34,
98
+ blue: 56,
99
+ alpha: 78,
100
+ })
101
+ })
102
+ })
103
+ describe("createConductorTrackIfNeeded", () => {
104
+ it("should not create the conductor track", () => {
105
+ const tracks: AnyEvent[][] = [
106
+ [timeSignatureMidiEvent(0, 4, 4), setTempoMidiEvent(120, 500000)],
107
+ [noteOnMidiEvent(0, 1, 60, 100), noteOffMidiEvent(120, 1, 60, 0)],
108
+ ]
109
+ const result = createConductorTrackIfNeeded(tracks)
110
+ expect(result).toStrictEqual([
111
+ [timeSignatureMidiEvent(0, 4, 4), setTempoMidiEvent(120, 500000)],
112
+ [noteOnMidiEvent(0, 1, 60, 100), noteOffMidiEvent(120, 1, 60, 0)],
113
+ ])
114
+ })
115
+ it("should create the conductor track", () => {
116
+ const tracks: AnyEvent[][] = [
117
+ [
118
+ timeSignatureMidiEvent(0, 4, 4),
119
+ setTempoMidiEvent(120, 500000),
120
+ noteOnMidiEvent(120, 5, 60, 100),
121
+ noteOffMidiEvent(120, 5, 60, 0),
122
+ ],
123
+ [noteOnMidiEvent(0, 2, 60, 100), noteOffMidiEvent(120, 2, 60, 0)],
124
+ ]
125
+ const result = createConductorTrackIfNeeded(tracks)
126
+ expect(result).toStrictEqual([
127
+ [timeSignatureMidiEvent(0, 4, 4), setTempoMidiEvent(120, 500000)],
128
+ [noteOnMidiEvent(240, 5, 60, 100), noteOffMidiEvent(120, 5, 60, 0)],
129
+ [noteOnMidiEvent(0, 2, 60, 100), noteOffMidiEvent(120, 2, 60, 0)],
130
+ ])
131
+ })
132
+ })
133
+ })
src/common/midi/midiConversion.ts ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { partition } from "lodash"
2
+ import groupBy from "lodash/groupBy"
3
+ import {
4
+ AnyEvent,
5
+ EndOfTrackEvent,
6
+ MidiFile,
7
+ read,
8
+ StreamSource,
9
+ write as writeMidiFile,
10
+ } from "midifile-ts"
11
+ import { toJS } from "mobx"
12
+ import { isNotNull } from "../helpers/array"
13
+ import { downloadBlob } from "../helpers/Downloader"
14
+ import { addDeltaTime, toRawEvents } from "../helpers/toRawEvents"
15
+ import {
16
+ addTick,
17
+ tickedEventsToTrackEvents,
18
+ toTrackEvents,
19
+ } from "../helpers/toTrackEvents"
20
+ import Song from "../song"
21
+ import Track, { AnyEventFeature } from "../track"
22
+
23
+ const trackFromMidiEvents = (events: AnyEvent[]): Track => {
24
+ const track = new Track()
25
+
26
+ const channel = findChannel(events)
27
+ if (channel !== undefined) {
28
+ track.channel = channel
29
+ }
30
+ track.addEvents(toTrackEvents(events))
31
+
32
+ return track
33
+ }
34
+
35
+ const tracksFromFormat0Events = (events: AnyEvent[]): Track[] => {
36
+ const tickedEvents = addTick(events)
37
+ const eventsPerChannel = groupBy(tickedEvents, (e) => {
38
+ if ("channel" in e) {
39
+ return e.channel + 1
40
+ }
41
+ return 0 // conductor track
42
+ })
43
+ const tracks: Track[] = []
44
+ for (const channel of Object.keys(eventsPerChannel)) {
45
+ const events = eventsPerChannel[channel]
46
+ const ch = parseInt(channel)
47
+ while (tracks.length <= ch) {
48
+ const track = new Track()
49
+ track.channel = ch > 0 ? ch - 1 : undefined
50
+ tracks.push(track)
51
+ }
52
+ const track = tracks[ch]
53
+ const trackEvents = tickedEventsToTrackEvents(events)
54
+ track.addEvents(trackEvents)
55
+ }
56
+ return tracks
57
+ }
58
+
59
+ const findChannel = (events: AnyEvent[]) => {
60
+ const chEvent = events.find((e) => {
61
+ return e.type === "channel"
62
+ })
63
+ if (chEvent !== undefined && "channel" in chEvent) {
64
+ return chEvent.channel
65
+ }
66
+ return undefined
67
+ }
68
+
69
+ const isConductorTrack = (track: AnyEvent[]) => findChannel(track) === undefined
70
+
71
+ const isConductorEvent = (e: AnyEventFeature) =>
72
+ "subtype" in e && (e.subtype === "timeSignature" || e.subtype === "setTempo")
73
+
74
+ export const createConductorTrackIfNeeded = (
75
+ tracks: AnyEvent[][],
76
+ ): AnyEvent[][] => {
77
+ // Find conductor track
78
+ let [conductorTracks, normalTracks] = partition(tracks, isConductorTrack)
79
+
80
+ // Create a conductor track if there is no conductor track
81
+ if (conductorTracks.length === 0) {
82
+ conductorTracks.push([])
83
+ }
84
+
85
+ const [conductorTrack, ...restTracks] = [
86
+ ...conductorTracks,
87
+ ...normalTracks,
88
+ ].map(addTick)
89
+
90
+ const newTracks = restTracks.map((track) =>
91
+ track
92
+ .map((e) => {
93
+ // Collect all conductor events
94
+ if (isConductorEvent(e)) {
95
+ conductorTrack.push(e)
96
+ return null
97
+ }
98
+ return e
99
+ })
100
+ .filter(isNotNull),
101
+ )
102
+
103
+ return [conductorTrack, ...newTracks].map(addDeltaTime)
104
+ }
105
+
106
+ const getTracks = (midi: MidiFile): Track[] => {
107
+ switch (midi.header.formatType) {
108
+ case 0:
109
+ return tracksFromFormat0Events(midi.tracks[0])
110
+ case 1:
111
+ return createConductorTrackIfNeeded(midi.tracks).map(trackFromMidiEvents)
112
+ default:
113
+ throw new Error(`Unsupported midi format ${midi.header.formatType}`)
114
+ }
115
+ }
116
+
117
+ export function songFromMidi(data: StreamSource) {
118
+ const song = new Song()
119
+ const midi = read(data)
120
+
121
+ getTracks(midi).forEach((t) => song.addTrack(t))
122
+
123
+ if (midi.header.formatType === 1 && song.tracks.length > 0) {
124
+ // Use the first track name as the song title
125
+ const name = song.tracks[0].name
126
+ if (name !== undefined) {
127
+ song.name = name
128
+ }
129
+ }
130
+
131
+ song.timebase = midi.header.ticksPerBeat
132
+
133
+ return song
134
+ }
135
+
136
+ const setChannel =
137
+ (channel: number) =>
138
+ (e: AnyEvent): AnyEvent => {
139
+ if (e.type === "channel") {
140
+ return { ...e, channel }
141
+ }
142
+ return e
143
+ }
144
+
145
+ export function songToMidiEvents(song: Song): AnyEvent[][] {
146
+ const tracks = toJS(song.tracks)
147
+ return tracks.map((t) => {
148
+ const endOfTrack: EndOfTrackEvent = {
149
+ deltaTime: 0,
150
+ type: "meta",
151
+ subtype: "endOfTrack",
152
+ }
153
+ const rawEvents = [...toRawEvents(t.events), endOfTrack]
154
+ if (t.channel !== undefined) {
155
+ return rawEvents.map(setChannel(t.channel))
156
+ }
157
+ return rawEvents
158
+ })
159
+ }
160
+
161
+ export function songToMidi(song: Song) {
162
+ const rawTracks = songToMidiEvents(song)
163
+ return writeMidiFile(rawTracks, song.timebase)
164
+ }
165
+
166
+ export function downloadSongAsMidi(song: Song) {
167
+ const bytes = songToMidi(song)
168
+ const blob = new Blob([bytes], { type: "application/octet-stream" })
169
+ downloadBlob(blob, song.filepath.length > 0 ? song.filepath : "no name.mid")
170
+ }
src/common/player/EventScheduler.test.ts ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { filterEventsWithRange } from "../helpers/filterEvents"
2
+ import EventScheduler from "./EventScheduler"
3
+
4
+ describe("EventScheduler", () => {
5
+ it("readNextEvents", () => {
6
+ const events = [{ tick: 0 }, { tick: 100 }, { tick: 110 }]
7
+ const s = new EventScheduler(
8
+ (start, end) => filterEventsWithRange(events, start, end),
9
+ () => [],
10
+ 0,
11
+ 480,
12
+ 100,
13
+ )
14
+
15
+ // 先読み時間分のイベントが入っている
16
+ // There are events for read ahead time
17
+ {
18
+ const result = s.readNextEvents(120, 0)
19
+ expect(result.length).toBe(1)
20
+ expect(result[0].event).toBe(events[0])
21
+ }
22
+
23
+ // 前回から時間が経過してなければイベントはない
24
+ // There is no event if time has passed since last time
25
+ {
26
+ const result = s.readNextEvents(120, 0)
27
+ expect(result.length).toBe(0)
28
+ }
29
+
30
+ // 時間が経過すると2個目以降のイベントが返ってくる
31
+ // If time has passed, the second or later events will come back
32
+ {
33
+ const result = s.readNextEvents(120, 120)
34
+ expect(result.length).toBe(2)
35
+ expect(result[0].event).toBe(events[1])
36
+ expect(result[0].timestamp).toBe(120)
37
+ expect(result[1].event).toBe(events[2])
38
+ expect(result[1].timestamp).toBe(120)
39
+ }
40
+ })
41
+ })
src/common/player/EventScheduler.ts ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { DistributiveOmit } from "../types"
2
+
3
+ export type SchedulableEvent = {
4
+ tick: number
5
+ }
6
+
7
+ export interface EventSchedulerLoop {
8
+ begin: number
9
+ end: number
10
+ }
11
+
12
+ type WithTimestamp<E> = {
13
+ event: E
14
+ timestamp: number
15
+ }
16
+
17
+ /**
18
+ * Player でイベントを随時読み取るためのクラス
19
+ * 精確にスケジューリングするために先読みを行う
20
+ * https://www.html5rocks.com/ja/tutorials/audio/scheduling/
21
+ */
22
+ /**
23
+ * Player Classes for reading events at any time
24
+ * Perform prefetching for accurate scheduling
25
+ * https://www.html5rocks.com/ja/tutorials/audio/scheduling/
26
+ */
27
+ export default class EventScheduler<E extends SchedulableEvent> {
28
+ // 先読み時間 (ms)
29
+ // Leading time (MS)
30
+ lookAheadTime = 100
31
+
32
+ // 1/4 拍子ごとの tick 数
33
+ // 1/4 TICK number for each beat
34
+ timebase = 480
35
+
36
+ loop: EventSchedulerLoop | null = null
37
+
38
+ private _currentTick = 0
39
+ private _scheduledTick = 0
40
+ private _prevTime: number | undefined = undefined
41
+ private _getEvents: (startTick: number, endTick: number) => E[]
42
+ private _createLoopEndEvents: () => Omit<E, "tick">[]
43
+
44
+ constructor(
45
+ getEvents: (startTick: number, endTick: number) => E[],
46
+ createLoopEndEvents: () => DistributiveOmit<E, "tick">[],
47
+ tick = 0,
48
+ timebase = 480,
49
+ lookAheadTime = 100,
50
+ ) {
51
+ this._getEvents = getEvents
52
+ this._createLoopEndEvents = createLoopEndEvents
53
+ this._currentTick = tick
54
+ this._scheduledTick = tick
55
+ this.timebase = timebase
56
+ this.lookAheadTime = lookAheadTime
57
+ }
58
+
59
+ get scheduledTick() {
60
+ return this._scheduledTick
61
+ }
62
+
63
+ millisecToTick(ms: number, bpm: number) {
64
+ return (((ms / 1000) * bpm) / 60) * this.timebase
65
+ }
66
+
67
+ tickToMillisec(tick: number, bpm: number) {
68
+ return (tick / (this.timebase / 60) / bpm) * 1000
69
+ }
70
+
71
+ seek(tick: number) {
72
+ this._currentTick = this._scheduledTick = Math.max(0, tick)
73
+ }
74
+
75
+ readNextEvents(bpm: number, timestamp: number): WithTimestamp<E>[] {
76
+ const withTimestamp =
77
+ (currentTick: number) =>
78
+ (e: E): WithTimestamp<E> => {
79
+ const waitTick = e.tick - currentTick
80
+ const delayedTime =
81
+ timestamp + Math.max(0, this.tickToMillisec(waitTick, bpm))
82
+ return { event: e, timestamp: delayedTime }
83
+ }
84
+
85
+ const getEventsInRange = (
86
+ startTick: number,
87
+ endTick: number,
88
+ currentTick: number,
89
+ ) => this._getEvents(startTick, endTick).map(withTimestamp(currentTick))
90
+
91
+ if (this._prevTime === undefined) {
92
+ this._prevTime = timestamp
93
+ }
94
+ const delta = timestamp - this._prevTime
95
+ const deltaTick = Math.max(0, this.millisecToTick(delta, bpm))
96
+ const nowTick = Math.floor(this._currentTick + deltaTick)
97
+
98
+ // 先読み時間
99
+ // Leading time
100
+ const lookAheadTick = Math.floor(
101
+ this.millisecToTick(this.lookAheadTime, bpm),
102
+ )
103
+
104
+ // 前回スケジュール済みの時点から、
105
+ // From the previous scheduled point,
106
+ // 先読み時間までを処理の対象とする
107
+ // Target of processing up to read time
108
+ const startTick = this._scheduledTick
109
+ const endTick = nowTick + lookAheadTick
110
+
111
+ this._prevTime = timestamp
112
+
113
+ if (
114
+ this.loop !== null &&
115
+ startTick < this.loop.end &&
116
+ endTick >= this.loop.end
117
+ ) {
118
+ const loop = this.loop
119
+ const offset = endTick - loop.end
120
+ const endTick2 = loop.begin + offset
121
+ const currentTick = loop.begin - (loop.end - nowTick)
122
+ this._currentTick = currentTick
123
+ this._scheduledTick = endTick2
124
+
125
+ return [
126
+ ...getEventsInRange(startTick, loop.end, nowTick),
127
+ ...this._createLoopEndEvents().map((e) =>
128
+ withTimestamp(currentTick)({ ...e, tick: loop.begin } as E),
129
+ ),
130
+ ...getEventsInRange(loop.begin, endTick2, currentTick),
131
+ ]
132
+ } else {
133
+ this._currentTick = nowTick
134
+ this._scheduledTick = endTick
135
+
136
+ return getEventsInRange(startTick, endTick, nowTick)
137
+ }
138
+ }
139
+ }
src/common/player/Player.ts ADDED
@@ -0,0 +1,319 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import range from "lodash/range"
2
+ import throttle from "lodash/throttle"
3
+ import { AnyEvent, MIDIControlEvents } from "midifile-ts"
4
+ import { computed, makeObservable, observable } from "mobx"
5
+ import { SendableEvent, SynthOutput } from "../../main/services/SynthOutput"
6
+ import { SongStore } from "../../main/stores/SongStore"
7
+ import { filterEventsWithRange } from "../helpers/filterEvents"
8
+ import { Beat, createBeatsInRange } from "../helpers/mapBeats"
9
+ import {
10
+ controllerMidiEvent,
11
+ noteOffMidiEvent,
12
+ noteOnMidiEvent,
13
+ } from "../midi/MidiEvent"
14
+ import { getStatusEvents } from "../track/selector"
15
+ import { ITrackMute } from "../trackMute/ITrackMute"
16
+ import { DistributiveOmit } from "../types"
17
+ import EventScheduler from "./EventScheduler"
18
+ import { convertTrackEvents, PlayerEvent } from "./PlayerEvent"
19
+
20
+ export interface LoopSetting {
21
+ begin: number
22
+ end: number
23
+ enabled: boolean
24
+ }
25
+
26
+ const TIMER_INTERVAL = 50
27
+ const LOOK_AHEAD_TIME = 50
28
+ const METRONOME_TRACK_ID = 99999
29
+ export const DEFAULT_TEMPO = 120
30
+
31
+ export default class Player {
32
+ private _currentTempo = DEFAULT_TEMPO
33
+ private _scheduler: EventScheduler<PlayerEvent> | null = null
34
+ private _songStore: SongStore
35
+ private _output: SynthOutput
36
+ private _metronomeOutput: SynthOutput
37
+ private _trackMute: ITrackMute
38
+ private _interval: number | null = null
39
+ private _currentTick = 0
40
+ private _isPlaying = false
41
+
42
+ disableSeek: boolean = false
43
+ isMetronomeEnabled: boolean = false
44
+
45
+ loop: LoopSetting | null = null
46
+
47
+ constructor(
48
+ output: SynthOutput,
49
+ metronomeOutput: SynthOutput,
50
+ trackMute: ITrackMute,
51
+ songStore: SongStore,
52
+ ) {
53
+ makeObservable<Player, "_currentTick" | "_isPlaying">(this, {
54
+ _currentTick: observable,
55
+ _isPlaying: observable,
56
+ loop: observable,
57
+ isMetronomeEnabled: observable,
58
+ position: computed,
59
+ isPlaying: computed,
60
+ })
61
+
62
+ this._output = output
63
+ this._metronomeOutput = metronomeOutput
64
+ this._trackMute = trackMute
65
+ this._songStore = songStore
66
+ }
67
+
68
+ private get song() {
69
+ return this._songStore.song
70
+ }
71
+
72
+ private get timebase() {
73
+ return this.song.timebase
74
+ }
75
+
76
+ play() {
77
+ if (this.isPlaying) {
78
+ console.warn("called play() while playing. aborted.")
79
+ return
80
+ }
81
+ this._scheduler = new EventScheduler<PlayerEvent>(
82
+ (startTick, endTick) =>
83
+ filterEventsWithRange(this.song.allEvents, startTick, endTick).concat(
84
+ filterEventsWithRange(
85
+ createBeatsInRange(
86
+ this.song.measures,
87
+ this.song.timebase,
88
+ startTick,
89
+ endTick,
90
+ ).flatMap((b) => this.beatToEvents(b)),
91
+ startTick,
92
+ endTick,
93
+ ),
94
+ ),
95
+ () => this.allNotesOffEvents(),
96
+ this._currentTick,
97
+ this.timebase,
98
+ TIMER_INTERVAL + LOOK_AHEAD_TIME,
99
+ )
100
+ this._isPlaying = true
101
+ this._output.activate()
102
+ this._interval = window.setInterval(() => this._onTimer(), TIMER_INTERVAL)
103
+ this._output.activate()
104
+ }
105
+
106
+ set position(tick: number) {
107
+ if (!Number.isInteger(tick)) {
108
+ console.warn("Player.tick should be an integer", tick)
109
+ }
110
+ if (this.disableSeek) {
111
+ return
112
+ }
113
+ tick = Math.min(Math.max(Math.floor(tick), 0), this.song.endOfSong)
114
+ if (this._scheduler) {
115
+ this._scheduler.seek(tick)
116
+ }
117
+ this._currentTick = tick
118
+
119
+ if (this.isPlaying) {
120
+ this.allSoundsOff()
121
+ }
122
+
123
+ this.sendCurrentStateEvents()
124
+ }
125
+
126
+ get position() {
127
+ return this._currentTick
128
+ }
129
+
130
+ get isPlaying() {
131
+ return this._isPlaying
132
+ }
133
+
134
+ get numberOfChannels() {
135
+ return 0xf
136
+ }
137
+
138
+ allSoundsOffChannel(ch: number) {
139
+ this.sendEvent(
140
+ controllerMidiEvent(0, ch, MIDIControlEvents.ALL_SOUNDS_OFF, 0),
141
+ )
142
+ }
143
+
144
+ allSoundsOff() {
145
+ for (const ch of range(0, this.numberOfChannels)) {
146
+ this.allSoundsOffChannel(ch)
147
+ }
148
+ }
149
+
150
+ allSoundsOffExclude(channel: number) {
151
+ for (const ch of range(0, this.numberOfChannels)) {
152
+ if (ch !== channel) {
153
+ this.allSoundsOffChannel(ch)
154
+ }
155
+ }
156
+ }
157
+
158
+ private allNotesOffEvents(): DistributiveOmit<PlayerEvent, "tick">[] {
159
+ return range(0, this.numberOfChannels).map((ch) => ({
160
+ ...controllerMidiEvent(0, ch, MIDIControlEvents.ALL_NOTES_OFF, 0),
161
+ trackId: -1, // do not mute
162
+ }))
163
+ }
164
+
165
+ private resetControllers() {
166
+ for (const ch of range(0, this.numberOfChannels)) {
167
+ this.sendEvent(
168
+ controllerMidiEvent(0, ch, MIDIControlEvents.RESET_CONTROLLERS, 0x7f),
169
+ )
170
+ }
171
+ }
172
+
173
+ private beatToEvents(beat: Beat): PlayerEvent[] {
174
+ const velocity = beat.beat === 0 ? 100 : 70
175
+ const noteNumber = beat.beat === 0 ? 76 : 77
176
+ return [
177
+ {
178
+ ...noteOnMidiEvent(0, 9, noteNumber, velocity),
179
+ tick: beat.tick,
180
+ trackId: METRONOME_TRACK_ID,
181
+ },
182
+ ]
183
+ }
184
+
185
+ stop() {
186
+ this._scheduler = null
187
+ this.allSoundsOff()
188
+ this._isPlaying = false
189
+
190
+ if (this._interval !== null) {
191
+ clearInterval(this._interval)
192
+ this._interval = null
193
+ }
194
+ }
195
+
196
+ reset() {
197
+ this.resetControllers()
198
+ this.stop()
199
+ this._currentTick = 0
200
+ }
201
+
202
+ /*
203
+ to restore synthesizer state (e.g. pitch bend)
204
+ collect all previous state events
205
+ and send them to the synthesizer
206
+ */
207
+ sendCurrentStateEvents() {
208
+ this.song.tracks
209
+ .flatMap((t, i) => {
210
+ const statusEvents = getStatusEvents(t.events, this._currentTick)
211
+ statusEvents.forEach((e) => this.applyPlayerEvent(e))
212
+ return convertTrackEvents(statusEvents, t.channel, i)
213
+ })
214
+ .forEach((e) => this.sendEvent(e))
215
+ }
216
+
217
+ get currentTempo() {
218
+ return this._currentTempo
219
+ }
220
+
221
+ set currentTempo(value: number) {
222
+ this._currentTempo = value
223
+ }
224
+
225
+ startNote(
226
+ {
227
+ channel,
228
+ noteNumber,
229
+ velocity,
230
+ }: {
231
+ noteNumber: number
232
+ velocity: number
233
+ channel: number
234
+ },
235
+ delayTime = 0,
236
+ ) {
237
+ this._output.activate()
238
+ this.sendEvent(noteOnMidiEvent(0, channel, noteNumber, velocity), delayTime)
239
+ }
240
+
241
+ stopNote(
242
+ {
243
+ channel,
244
+ noteNumber,
245
+ }: {
246
+ noteNumber: number
247
+ channel: number
248
+ },
249
+ delayTime = 0,
250
+ ) {
251
+ this.sendEvent(noteOffMidiEvent(0, channel, noteNumber, 0), delayTime)
252
+ }
253
+
254
+ // delayTime: seconds, timestampNow: milliseconds
255
+ sendEvent(
256
+ event: SendableEvent,
257
+ delayTime: number = 0,
258
+ timestampNow: number = performance.now(),
259
+ ) {
260
+ this._output.sendEvent(event, delayTime, timestampNow)
261
+ }
262
+
263
+ private syncPosition = throttle(() => {
264
+ if (this._scheduler !== null) {
265
+ this._currentTick = this._scheduler.scheduledTick
266
+ }
267
+ }, 50)
268
+
269
+ private applyPlayerEvent(
270
+ e: DistributiveOmit<AnyEvent, "deltaTime" | "channel">,
271
+ ) {
272
+ if (e.type !== "channel" && "subtype" in e) {
273
+ switch (e.subtype) {
274
+ case "setTempo":
275
+ this._currentTempo = 60000000 / e.microsecondsPerBeat
276
+ break
277
+ default:
278
+ break
279
+ }
280
+ }
281
+ }
282
+
283
+ private _onTimer() {
284
+ if (this._scheduler === null) {
285
+ return
286
+ }
287
+
288
+ const timestamp = performance.now()
289
+
290
+ this._scheduler.loop =
291
+ this.loop !== null && this.loop.enabled ? this.loop : null
292
+ const events = this._scheduler.readNextEvents(this._currentTempo, timestamp)
293
+
294
+ events.forEach(({ event: e, timestamp: time }) => {
295
+ if (e.type === "channel") {
296
+ const delayTime = (time - timestamp) / 1000
297
+ if (e.trackId === METRONOME_TRACK_ID) {
298
+ if (this.isMetronomeEnabled) {
299
+ this._metronomeOutput.sendEvent(e, delayTime, timestamp)
300
+ }
301
+ } else if (this._trackMute.shouldPlayTrack(e.trackId)) {
302
+ // channel イベントを MIDI Output に送信
303
+ // Send Channel Event to MIDI OUTPUT
304
+ this.sendEvent(e, delayTime, timestamp)
305
+ }
306
+ } else {
307
+ // channel イベント以外を実行
308
+ // Run other than Channel Event
309
+ this.applyPlayerEvent(e)
310
+ }
311
+ })
312
+
313
+ if (this._scheduler.scheduledTick >= this.song.endOfSong) {
314
+ this.stop()
315
+ }
316
+
317
+ this.syncPosition()
318
+ }
319
+ }
src/common/player/PlayerEvent.ts ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { AnyChannelEvent, AnyEvent } from "midifile-ts"
2
+ import { deassemble as deassembleNote } from "../helpers/noteAssembler"
3
+ import Track, { TrackEvent } from "../track"
4
+ import { DistributiveOmit } from "../types"
5
+
6
+ export type PlayerEventOf<T> = DistributiveOmit<T, "deltaTime"> & {
7
+ tick: number
8
+ trackId: number
9
+ }
10
+
11
+ export type PlayerEvent = PlayerEventOf<AnyEvent>
12
+
13
+ export const convertTrackEvents = (
14
+ events: TrackEvent[],
15
+ channel: number | undefined,
16
+ trackId: number,
17
+ ) =>
18
+ events
19
+ .filter((e) => !(e.isRecording === true))
20
+ .flatMap((e) => deassembleNote(e))
21
+ .map(
22
+ (e) =>
23
+ ({ ...e, channel: channel, trackId }) as PlayerEventOf<AnyChannelEvent>,
24
+ )
25
+
26
+ export const collectAllEvents = (tracks: Track[]): PlayerEvent[] =>
27
+ tracks.flatMap((t, i) => convertTrackEvents(t.events, t.channel, i))
src/common/player/index.ts ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ export * from "./Player"
2
+ export { default } from "./Player"
src/common/quantizer/Quantizer.ts ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { SongStore } from "../../main/stores/SongStore"
2
+ import { getMeasureStart } from "../song/selector"
3
+
4
+ export default class Quantizer {
5
+ private denominator: number
6
+ private songStore: SongStore
7
+ private isEnabled: boolean = true
8
+
9
+ constructor(songStore: SongStore, denominator: number, isEnabled: boolean) {
10
+ this.songStore = songStore
11
+
12
+ // N 分音符の N
13
+ // n-remnant note n
14
+ this.denominator = denominator
15
+
16
+ this.isEnabled = isEnabled
17
+ }
18
+
19
+ private get timebase() {
20
+ return this.songStore.song.timebase
21
+ }
22
+
23
+ private calc(tick: number, fn: (tick: number) => number) {
24
+ if (!this.isEnabled) {
25
+ return Math.round(tick)
26
+ }
27
+ const measureStart = getMeasureStart(this.songStore.song, tick)
28
+ const beats =
29
+ this.denominator === 1 ? measureStart?.timeSignature.numerator ?? 4 : 4
30
+ const u = (this.timebase * beats) / this.denominator
31
+ const offset = measureStart?.tick ?? 0
32
+ return fn((tick - offset) / u) * u + offset
33
+ }
34
+
35
+ round(tick: number) {
36
+ return this.calc(tick, Math.round)
37
+ }
38
+
39
+ ceil(tick: number) {
40
+ return this.calc(tick, Math.ceil)
41
+ }
42
+
43
+ floor(tick: number) {
44
+ return this.calc(tick, Math.floor)
45
+ }
46
+
47
+ get unit() {
48
+ return (this.timebase * 4) / this.denominator
49
+ }
50
+ }
src/common/quantizer/index.ts ADDED
@@ -0,0 +1 @@
 
 
1
+ export { default } from "./Quantizer"
src/common/selection/ArrangeSelection.ts ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import Quantizer from "../quantizer"
2
+ import { ArrangePoint } from "../transform/ArrangePoint"
3
+
4
+ export interface ArrangeSelection {
5
+ fromTick: number
6
+ fromTrackIndex: number
7
+ toTick: number
8
+ toTrackIndex: number
9
+ }
10
+
11
+ export function arrangeSelectionFromPoints(
12
+ start: ArrangePoint,
13
+ end: ArrangePoint,
14
+ quantizer: Quantizer,
15
+ maxTrackIndex: number,
16
+ ): ArrangeSelection {
17
+ const startSelection = selectionForPoint(start, quantizer)
18
+ const endSelection = selectionForPoint(end, quantizer)
19
+ return clampSelection(
20
+ unionSelections(startSelection, endSelection),
21
+ maxTrackIndex,
22
+ )
23
+ }
24
+
25
+ export const selectionForPoint = (
26
+ point: ArrangePoint,
27
+ quantizer: Quantizer,
28
+ ): ArrangeSelection => {
29
+ const fromTick = quantizer.floor(point.tick)
30
+ const toTick = quantizer.ceil(point.tick)
31
+ return {
32
+ fromTick,
33
+ toTick,
34
+ fromTrackIndex: Math.floor(point.trackIndex),
35
+ toTrackIndex: Math.floor(point.trackIndex) + 1,
36
+ }
37
+ }
38
+
39
+ export const unionSelections = (
40
+ a: ArrangeSelection,
41
+ b: ArrangeSelection,
42
+ ): ArrangeSelection => {
43
+ return {
44
+ fromTick: Math.min(a.fromTick, b.fromTick),
45
+ toTick: Math.max(a.toTick, b.toTick),
46
+ fromTrackIndex: Math.min(a.fromTrackIndex, b.fromTrackIndex),
47
+ toTrackIndex: Math.max(a.toTrackIndex, b.toTrackIndex),
48
+ }
49
+ }
50
+
51
+ export const clampSelection = (
52
+ selection: ArrangeSelection,
53
+ maxTrackIndex: number,
54
+ ): ArrangeSelection => ({
55
+ fromTick: Math.max(0, selection.fromTick),
56
+ toTick: Math.max(0, selection.toTick),
57
+ fromTrackIndex: Math.min(
58
+ maxTrackIndex,
59
+ Math.max(0, selection.fromTrackIndex),
60
+ ),
61
+ toTrackIndex: Math.min(maxTrackIndex, Math.max(0, selection.toTrackIndex)),
62
+ })
63
+
64
+ export const movedSelection = (
65
+ selection: ArrangeSelection,
66
+ delta: ArrangePoint,
67
+ ): ArrangeSelection => ({
68
+ fromTick: selection.fromTick + delta.tick,
69
+ toTick: selection.toTick + delta.tick,
70
+ fromTrackIndex: selection.fromTrackIndex + delta.trackIndex,
71
+ toTrackIndex: selection.toTrackIndex + delta.trackIndex,
72
+ })
src/common/selection/ControlSelection.ts ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ export interface ControlSelection {
2
+ fromTick: number
3
+ toTick: number
4
+ }
src/common/selection/Selection.ts ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import cloneDeep from "lodash/cloneDeep"
2
+ import { MaxNoteNumber } from "../../main/Constants"
3
+ import { IRect } from "../geometry"
4
+ import { NoteCoordTransform } from "../transform"
5
+ import { clampNotePoint, NotePoint } from "../transform/NotePoint"
6
+
7
+ export interface Selection {
8
+ from: NotePoint
9
+ to: NotePoint
10
+ }
11
+
12
+ export const getSelectionBounds = (
13
+ selection: Selection,
14
+ transform: NoteCoordTransform,
15
+ ): IRect => {
16
+ const left = transform.getX(selection.from.tick)
17
+ const right = transform.getX(selection.to.tick)
18
+ const top = transform.getY(selection.from.noteNumber)
19
+ const bottom = transform.getY(selection.to.noteNumber)
20
+ return {
21
+ x: left,
22
+ y: top,
23
+ width: right - left,
24
+ height: bottom - top,
25
+ }
26
+ }
27
+
28
+ export const movedSelection = (
29
+ selection: Selection,
30
+ dt: number,
31
+ dn: number,
32
+ ): Selection => {
33
+ const s = cloneDeep(selection)
34
+
35
+ s.from.tick += dt
36
+ s.to.tick += dt
37
+ s.from.noteNumber += dn
38
+ s.to.noteNumber += dn
39
+
40
+ return s
41
+ }
42
+
43
+ // to が右下になるようにする
44
+ // to Make the lower right
45
+
46
+ export const regularizedSelection = (
47
+ fromTick: number,
48
+ fromNoteNumber: number,
49
+ toTick: number,
50
+ toNoteNumber: number,
51
+ ): Selection => ({
52
+ from: {
53
+ tick: Math.max(0, Math.min(fromTick, toTick)),
54
+ noteNumber: Math.min(
55
+ MaxNoteNumber,
56
+ Math.max(0, Math.max(fromNoteNumber, toNoteNumber)),
57
+ ),
58
+ },
59
+ to: {
60
+ tick: Math.max(fromTick, toTick),
61
+ noteNumber: Math.min(
62
+ MaxNoteNumber,
63
+ Math.max(0, Math.min(fromNoteNumber, toNoteNumber)),
64
+ ),
65
+ },
66
+ })
67
+
68
+ export const clampSelection = (selection: Selection): Selection => ({
69
+ from: clampNotePoint(selection.from),
70
+ to: clampNotePoint(selection.to),
71
+ })
src/common/selection/TempoSelection.ts ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { IRect } from "../geometry"
2
+ import { TempoCoordTransform } from "../transform"
3
+
4
+ export interface TempoSelection {
5
+ fromTick: number
6
+ toTick: number
7
+ }
8
+
9
+ export const getTempoSelectionBounds = (
10
+ selection: TempoSelection,
11
+ transform: TempoCoordTransform,
12
+ ): IRect => {
13
+ const left = transform.getX(selection.fromTick)
14
+ const right = transform.getX(selection.toTick)
15
+ return {
16
+ x: left,
17
+ y: 0,
18
+ width: right - left,
19
+ height: transform.height,
20
+ }
21
+ }
src/common/song/Song.test.ts ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as fs from "fs"
2
+ import * as path from "path"
3
+ import { deserialize, serialize } from "serializr"
4
+ import { songFromMidi } from "../midi/midiConversion"
5
+ import Song from "./Song"
6
+ import { emptySong } from "./SongFactory"
7
+
8
+ describe("Song", () => {
9
+ const song = songFromMidi(
10
+ fs.readFileSync(path.join(__dirname, "../../../testdata/tracks.mid"))
11
+ .buffer,
12
+ )
13
+
14
+ it("fromMidi", () => {
15
+ expect(song).not.toBeNull()
16
+ const { tracks } = song
17
+ expect(tracks.length).toBe(18)
18
+
19
+ expect(tracks[0].isConductorTrack).toBeTruthy()
20
+ expect(!tracks[1].isConductorTrack).toBeTruthy()
21
+ expect(tracks[1].channel).toBe(0)
22
+ expect(tracks[2].channel).toBe(0)
23
+ expect(tracks[3].channel).toBe(1)
24
+ expect(tracks[17].channel).toBe(15)
25
+
26
+ expect(tracks[0].getTempo(240)).toBe(128)
27
+ expect(tracks[2].getVolume(193)).toBe(100)
28
+ expect(tracks[2].getPan(192)).toBe(1)
29
+ expect(tracks[2].programNumber).toBe(29)
30
+ })
31
+
32
+ it("should be serializable", () => {
33
+ const song = emptySong()
34
+ song.filepath = "abc"
35
+ const x = serialize(song)
36
+ const s = deserialize(Song, x)
37
+ expect(s.filepath).toBe("abc")
38
+ expect(s.tracks.length).toBe(song.tracks.length)
39
+ })
40
+ })
src/common/song/Song.ts ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { DocumentReference } from "firebase/firestore"
2
+ import pullAt from "lodash/pullAt"
3
+ import {
4
+ action,
5
+ computed,
6
+ makeObservable,
7
+ observable,
8
+ reaction,
9
+ transaction,
10
+ } from "mobx"
11
+ import { createModelSchema, list, object, primitive } from "serializr"
12
+ import { FirestoreSong, FirestoreSongData } from "../../firebase/song"
13
+ import { TIME_BASE } from "../../main/Constants"
14
+ import { isNotUndefined } from "../helpers/array"
15
+ import { Measure } from "../measure/Measure"
16
+ import { getMeasuresFromConductorTrack } from "../measure/MeasureList"
17
+ import { collectAllEvents, PlayerEvent } from "../player/PlayerEvent"
18
+ import Track from "../track"
19
+
20
+ const END_MARGIN = 480 * 30
21
+
22
+ export default class Song {
23
+ tracks: Track[] = []
24
+ filepath: string = ""
25
+ timebase: number = TIME_BASE
26
+ name: string = ""
27
+ fileHandle: FileSystemFileHandle | null = null
28
+ firestoreReference: DocumentReference<FirestoreSong> | null = null
29
+ firestoreDataReference: DocumentReference<FirestoreSongData> | null = null
30
+ isSaved = true
31
+
32
+ constructor() {
33
+ makeObservable(this, {
34
+ addTrack: action,
35
+ removeTrack: action,
36
+ insertTrack: action,
37
+ conductorTrack: computed,
38
+ measures: computed,
39
+ endOfSong: computed,
40
+ allEvents: computed({ keepAlive: true }),
41
+ tracks: observable.shallow,
42
+ filepath: observable,
43
+ timebase: observable,
44
+ name: observable,
45
+ isSaved: observable,
46
+ })
47
+
48
+ reaction(
49
+ () => [
50
+ this.tracks.map((t) => ({ channel: t.channel, events: t.events })),
51
+ this.name,
52
+ ],
53
+ () => (this.isSaved = false),
54
+ )
55
+ }
56
+
57
+ insertTrack(t: Track, index: number) {
58
+ // 最初のトラックは Conductor Track なので channel を設定しない
59
+ if (t.channel === undefined && this.tracks.length > 0) {
60
+ t.channel = t.channel || this.tracks.length - 1
61
+ }
62
+ this.tracks.splice(index, 0, t)
63
+ }
64
+
65
+ addTrack(t: Track) {
66
+ this.insertTrack(t, this.tracks.length)
67
+ }
68
+
69
+ removeTrack(id: number) {
70
+ transaction(() => {
71
+ pullAt(this.tracks, id)
72
+ })
73
+ }
74
+
75
+ get conductorTrack(): Track | undefined {
76
+ return this.tracks.find((t) => t.isConductorTrack)
77
+ }
78
+
79
+ getTrack(id: number): Track | undefined {
80
+ return this.tracks[id]
81
+ }
82
+
83
+ get measures(): Measure[] {
84
+ const conductorTrack = this.conductorTrack
85
+ if (conductorTrack === undefined) {
86
+ return []
87
+ }
88
+ return getMeasuresFromConductorTrack(conductorTrack, this.timebase)
89
+ }
90
+
91
+ get endOfSong(): number {
92
+ const eos = Math.max(
93
+ ...this.tracks.map((t) => t.endOfTrack).filter(isNotUndefined),
94
+ )
95
+ return (eos ?? 0) + END_MARGIN
96
+ }
97
+
98
+ get allEvents(): PlayerEvent[] {
99
+ return collectAllEvents(this.tracks)
100
+ }
101
+ }
102
+
103
+ createModelSchema(Song, {
104
+ tracks: list(object(Track)),
105
+ filepath: primitive(),
106
+ timebase: primitive(),
107
+ })
src/common/song/SongFactory.ts ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { conductorTrack, emptyTrack } from "../track"
2
+ import Song from "./Song"
3
+
4
+ export function emptySong() {
5
+ const song = new Song()
6
+ song.addTrack(conductorTrack())
7
+ song.addTrack(emptyTrack(0))
8
+ // Empty songs do not need to be saved.
9
+ song.isSaved = true
10
+ return song
11
+ }
src/common/song/index.ts ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ export { default } from "./Song"
2
+ export * from "./SongFactory"