Spaces:
Sleeping
Sleeping
Yann
commited on
Commit
·
f23825d
1
Parent(s):
7e76b3e
test
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .gitignore +46 -0
- .gitmodules +0 -0
- package.json +99 -0
- src/@types/emotion.d.ts +5 -0
- src/@types/index.d.ts +6 -0
- src/common/geometry/Point.ts +20 -0
- src/common/geometry/Rect.test.ts +117 -0
- src/common/geometry/Rect.ts +77 -0
- src/common/geometry/Size.ts +4 -0
- src/common/geometry/index.ts +3 -0
- src/common/helpers/Downloader.ts +18 -0
- src/common/helpers/array.ts +33 -0
- src/common/helpers/bpm.ts +9 -0
- src/common/helpers/filterEvents.test.ts +33 -0
- src/common/helpers/filterEvents.ts +49 -0
- src/common/helpers/mapBeats.test.ts +64 -0
- src/common/helpers/mapBeats.ts +107 -0
- src/common/helpers/noteAssembler.test.ts +70 -0
- src/common/helpers/noteAssembler.ts +80 -0
- src/common/helpers/noteNumberString.ts +32 -0
- src/common/helpers/pojo.ts +7 -0
- src/common/helpers/songToSynthEvents.ts +56 -0
- src/common/helpers/toRawEvents.ts +37 -0
- src/common/helpers/toTrackEvents.ts +56 -0
- src/common/helpers/valueEvent.ts +41 -0
- src/common/localize/envString.ts +14 -0
- src/common/localize/localization.ts +588 -0
- src/common/localize/localizedString.ts +36 -0
- src/common/measure/Measure.ts +29 -0
- src/common/measure/MeasureList.ts +59 -0
- src/common/measure/mbt.ts +35 -0
- src/common/midi/GM.ts +21 -0
- src/common/midi/MidiEvent.ts +273 -0
- src/common/midi/midiConversion.test.ts +133 -0
- src/common/midi/midiConversion.ts +170 -0
- src/common/player/EventScheduler.test.ts +41 -0
- src/common/player/EventScheduler.ts +139 -0
- src/common/player/Player.ts +319 -0
- src/common/player/PlayerEvent.ts +27 -0
- src/common/player/index.ts +2 -0
- src/common/quantizer/Quantizer.ts +50 -0
- src/common/quantizer/index.ts +1 -0
- src/common/selection/ArrangeSelection.ts +72 -0
- src/common/selection/ControlSelection.ts +4 -0
- src/common/selection/Selection.ts +71 -0
- src/common/selection/TempoSelection.ts +21 -0
- src/common/song/Song.test.ts +40 -0
- src/common/song/Song.ts +107 -0
- src/common/song/SongFactory.ts +11 -0
- 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"
|