diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..7e8f4d3 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +* text eol=lf +*.lockb binary +*.png binary diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml new file mode 100644 index 0000000..f7927f4 --- /dev/null +++ b/.github/workflows/publish.yaml @@ -0,0 +1,35 @@ +name: publish + +on: + push: + branches: + - release + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + - uses: actions/checkout@v4 + with: + path: skdassoc.com + - uses: actions/setup-node@v4 + - uses: oven-sh/setup-bun@v1 + - run: bun test + - run: bun run build + - uses: actions/upload-pages-artifact@v3 + with: + path: pages + deploy: + needs: build + permissions: + id-token: write + pages: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-22.04 + steps: + - uses: actions/deploy-pages@v4 + id: deployment diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4f45fff --- /dev/null +++ b/.gitignore @@ -0,0 +1,178 @@ +# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore + +# Logs + +logs +_.log +npm-debug.log_ +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Caches + +.cache + +# Diagnostic reports (https://nodejs.org/api/report.html) + +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# Runtime data + +pids +_.pid +_.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover + +lib-cov + +# Coverage directory used by tools like istanbul + +coverage +*.lcov + +# nyc test coverage + +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) + +.grunt + +# Bower dependency directory (https://bower.io/) + +bower_components + +# node-waf configuration + +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) + +build/Release + +# Dependency directories + +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) + +web_modules/ + +# TypeScript cache + +*.tsbuildinfo + +# Optional npm cache directory + +.npm + +# Optional eslint cache + +.eslintcache + +# Optional stylelint cache + +.stylelintcache + +# Microbundle cache + +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history + +.node_repl_history + +# Output of 'npm pack' + +*.tgz + +# Yarn Integrity file + +.yarn-integrity + +# dotenv environment variable files + +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) + +.parcel-cache + +# Next.js build output + +.next +out + +# Nuxt.js build / generate output + +.nuxt +dist + +# Gatsby files + +# Comment in the public line in if your project uses Gatsby and not Next.js + +# https://nextjs.org/blog/next-9-1#public-directory-support + +# public + +# vuepress build output + +.vuepress/dist + +# vuepress v2.x temp and cache directory + +.temp + +# Docusaurus cache and generated files + +.docusaurus + +# Serverless directories + +.serverless/ + +# FuseBox cache + +.fusebox/ + +# DynamoDB Local files + +.dynamodb/ + +# TernJS port file + +.tern-port + +# Stores VSCode versions used for testing VSCode extensions + +.vscode-test + +# yarn v2 + +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store + +# Appendix +pages/index.js diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..feed058 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,23 @@ +{ + "printWidth": 100, + "tabWidth": 2, + "useTabs": false, + "semi": false, + "singleQuote": true, + "quoteProps": "consistent", + "jsxSingleQuote": true, + "trailingComma": "es5", + "bracketSpacing": false, + "bracketSameLine": false, + "arrowParens": "always", + "rangeStart": 0, + "rangeEnd": 99999999, + "requirePragma": false, + "insertPragma": false, + "proseWrap": "preserve", + "htmlWhitespaceSensitivity": "css", + "vueIndentScriptAndStyle": false, + "endOfLine": "lf", + "embeddedLanguageFormatting": "auto", + "singleAttributePerLine": true +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..7067751 --- /dev/null +++ b/README.md @@ -0,0 +1,23 @@ +

+ Logo +

+ +

IAM.mml

+ +

+ Deployment + +

+ +## What is this? + +Online AM/FM Synthesizer for Generating and Playing WAV from MML. + +## Build + +To build, follow these steps: + +1. Install [bun](https://bun.sh/) +2. Run `bun install` +3. Run `bun run build` +4. Host the pages directory diff --git a/bun.lockb b/bun.lockb new file mode 100644 index 0000000..ccf9682 Binary files /dev/null and b/bun.lockb differ diff --git a/package.json b/package.json new file mode 100644 index 0000000..54e9774 --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "iam-mml", + "module": "index.ts", + "type": "module", + "scripts": { + "build": "bunx prettier --write ./src && bun build ./src/index.ts --outdir ./pages --minify", + "format": "bunx prettier --write ./src", + "test": "bun test" + }, + "devDependencies": { + "@types/bun": "latest", + "prettier": "^3.2.5" + }, + "peerDependencies": { + "typescript": "^5.0.0" + } +} diff --git a/pages/build.svg b/pages/build.svg new file mode 100644 index 0000000..d0a0105 --- /dev/null +++ b/pages/build.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + diff --git a/pages/docs/jp/about/index.html b/pages/docs/jp/about/index.html new file mode 100644 index 0000000..fda03e7 --- /dev/null +++ b/pages/docs/jp/about/index.html @@ -0,0 +1,51 @@ + + + + + + IAM.mml Docs + + + + + + + + + + + + +
+

IAM.mmlとは?

+ +
+ +

概要

+ +
+ + + +
+ +

+ IAM.mmlはWebブラウザ上で動作するFM音源MMLコンパイラです。 +

+ + + +
+ +
+ IAM.mmlのアプリケーションページへ移動する +
+
+ + + diff --git a/pages/docs/jp/command/instrument/index.html b/pages/docs/jp/command/instrument/index.html new file mode 100644 index 0000000..a898ab8 --- /dev/null +++ b/pages/docs/jp/command/instrument/index.html @@ -0,0 +1,48 @@ + + + + + + コマンド/音源: IAM.mml Docs + + + + + + + + + + + + +
+

コマンド/音源

+ +
+ +

文法

+ +
instrument = "@" , identifier ;
+ +

解説

+ +

+ 音源を指定するコマンド。 +

+ + + +

+ identifierは空白・タブあるいは改行までの任意の文字列を取得する。 + 従って、音源コマンドに続くコマンドの前には、空白・タブあるいは改行を入れなければならない。 +

+
+ + + diff --git a/pages/docs/jp/command/key/index.html b/pages/docs/jp/command/key/index.html new file mode 100644 index 0000000..3860ae5 --- /dev/null +++ b/pages/docs/jp/command/key/index.html @@ -0,0 +1,48 @@ + + + + + + コマンド/調号: IAM.mml Docs + + + + + + + + + + + + +
+

コマンド/調号

+ +
+ +

文法

+ +
pitch   = "a" | "b" | "c" | "d" | "e" | "f" | "g" ;
+command = "+" | "-" | "=" ;
+key     = "k" , [ { pitch } ] , command ;
+ +

解説

+ +

+ 調号を指定するコマンド。 +

+ + +
+ + + diff --git a/pages/docs/jp/command/length/index.html b/pages/docs/jp/command/length/index.html new file mode 100644 index 0000000..76882dc --- /dev/null +++ b/pages/docs/jp/command/length/index.html @@ -0,0 +1,48 @@ + + + + + + コマンド/標準音価: IAM.mml Docs + + + + + + + + + + + + +
+

コマンド/標準音価

+ +
+ +

文法

+ +
number = natural-number ;
+length = "l" , number ;
+ +

解説

+ +

+ 音符コマンドにおいて音価が指定されなかったときの音価を指定するコマンド。 +

+ + + +

+ 標準でl4が評価されている。 +

+
+ + + diff --git a/pages/docs/jp/command/loop/index.html b/pages/docs/jp/command/loop/index.html new file mode 100644 index 0000000..e9a77cc --- /dev/null +++ b/pages/docs/jp/command/loop/index.html @@ -0,0 +1,48 @@ + + + + + + コマンド/ループ: IAM.mml Docs + + + + + + + + + + + + +
+

コマンド/ループ

+ +
+ +

文法

+ +
commands  = ? commands ? ;
+count     = natural-number ;
+delimiter = ":" ;
+loop      = "[" , commands , [ delimiter , commands ] , "]" , count ;
+ +

解説

+ +

+ []内の楽譜を指定回数繰り返すためのコマンド。 +

+ + +
+ + + diff --git a/pages/docs/jp/command/macro/index.html b/pages/docs/jp/command/macro/index.html new file mode 100644 index 0000000..b586ddf --- /dev/null +++ b/pages/docs/jp/command/macro/index.html @@ -0,0 +1,43 @@ + + + + + + コマンド/マクロ: IAM.mml Docs + + + + + + + + + + + + +
+

コマンド/マクロ

+ +
+ +

文法

+ +
macro = "!" , identifier ;
+ +

解説

+ +

+ マクロを指定するコマンド。 +

+ + +
+ + + diff --git a/pages/docs/jp/command/note/index.html b/pages/docs/jp/command/note/index.html new file mode 100644 index 0000000..509ecc0 --- /dev/null +++ b/pages/docs/jp/command/note/index.html @@ -0,0 +1,107 @@ + + + + + + コマンド/音符: IAM.mml Docs + + + + + + + + + + + + + + + +
+

コマンド/音符

+ +
+ +

文法

+ +
pitch      = "a" | "b" | "c" | "d" | "e" | "f" | "g" | "r" ;
+accidental = "+" | "-" | "=" ;
+note-value = natural-number ;
+dot        = "." ;
+note       = pitch , [ accidental ] , [ note-value ] , [ dot ] ;
+ +

解説

+ +

+ 音符を表現するコマンド。 +

+ + + +

+ 音高(周波数)\(frequency\)は次の数式によって決定する。 +

+ +

+ \[index = + \left\{ + \begin{array}{cl} + 0 & (pitch\;is\;C)\\ + 2 & (pitch\;is\;D)\\ + 4 & (pitch\;is\;E)\\ + 5 & (pitch\;is\;F)\\ + 7 & (pitch\;is\;G)\\ + 9 & (pitch\;is\;A)\\ + 11 & (pitch\;is\;B) + \end{array} + \right. + + + \left\{ + \begin{array}{cl} + 1 & (sharp)\\ + -1 & (flat)\\ + 0 & (otherwise) + \end{array} + \right. + + 12 \cdot octave + \] + \[frequency = 440 \cdot 2 ^ { (index-57) / 12 }\] +

+ +

+ エンベロープを考慮しない音長\(length\)は次の数式によって決定する。 +

+ +

+ \[length = \left\lfloor 44100 \cdot \frac{60}{bpm} \cdot \frac{4}{notevalue} \cdot \left\{ + \begin{array}{cl} + 1.5 & (dotted)\\ + 1 & (otherwise) + \end{array} + \right. \right\rfloor\] +

+
+ + + diff --git a/pages/docs/jp/command/octave/index.html b/pages/docs/jp/command/octave/index.html new file mode 100644 index 0000000..d100049 --- /dev/null +++ b/pages/docs/jp/command/octave/index.html @@ -0,0 +1,56 @@ + + + + + + コマンド/オクターブ: IAM.mml Docs + + + + + + + + + + + + +
+

コマンド/オクターブ

+ +
+ +

文法

+ +
number  = natural-number ;
+command = "<" | ">" ;
+octave  = ( "o" , number ) | command ;
+ +

解説

+ +

+ オクターブを指定・移動するコマンド。 +

+ + + +

+ 標準でo4が評価されている。 +

+ +

+ オクターブは常に区間[1, 8]内の自然数である。 + つまり、o8>を評価しても、オクターブは8となる。 +

+
+ + + diff --git a/pages/docs/jp/command/tempo/index.html b/pages/docs/jp/command/tempo/index.html new file mode 100644 index 0000000..1619ee8 --- /dev/null +++ b/pages/docs/jp/command/tempo/index.html @@ -0,0 +1,60 @@ + + + + + + コマンド/テンポ: IAM.mml Docs + + + + + + + + + + + + +
+

コマンド/テンポ

+ +
+ +

文法

+ +
number  = non-negative-float ;
+command = "+" | "-" ;
+octave  = "t" , number , [ command ] ;
+ +

解説

+ +

+ テンポを指定・変更するコマンド。 +

+ + + +

+ 標準でt120が評価されている。 +

+ +

+ テンポは常に区間[1, 1000]内の実数である。 + つまり、t120t1000+を評価しても、テンポは1000となる。 +

+
+ + + diff --git a/pages/docs/jp/command/volume/index.html b/pages/docs/jp/command/volume/index.html new file mode 100644 index 0000000..0302d10 --- /dev/null +++ b/pages/docs/jp/command/volume/index.html @@ -0,0 +1,60 @@ + + + + + + コマンド/音量: IAM.mml Docs + + + + + + + + + + + + +
+

コマンド/音量

+ +
+ +

文法

+ +
number  = non-negative-float ;
+command = "+" | "-" ;
+octave  = "v" , number , [ command ] ;
+ +

解説

+ +

+ 音量を指定・変更するコマンド。 +

+ + + +

+ 標準でv0.5が評価されている。 +

+ +

+ 音量は常に区間[0, 1]内の実数である。 + つまり、v0.5v1+を評価しても、音量は1となる。 +

+
+ + + diff --git a/pages/docs/jp/ebnf/index.html b/pages/docs/jp/ebnf/index.html new file mode 100644 index 0000000..2cf7f6d --- /dev/null +++ b/pages/docs/jp/ebnf/index.html @@ -0,0 +1,55 @@ + + + + + + EBNF: IAM.mml Docs + + + + + + + + + + + + +
+

EBNF

+ +
+ +

+ 当ドキュメントでは、コマンドの構文をEBNF記法で示している。 + ただし、次の制約に従って記述している。 +

+ + + +

+ 断りなく、次の非終端記号を用いる。 +

+ +
natural-number     = { "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" } ;
+non-negative-float = natural-number , [ "." , natural-number ] ;
+space              = " " | "\t" ;
+line-break         = "\r\n" | "\r" | "\n" ;
+identifier         = ? any string until a space, a tab space or a line break ? ;
+
+ + + diff --git a/pages/docs/jp/index.css b/pages/docs/jp/index.css new file mode 100644 index 0000000..21192b6 --- /dev/null +++ b/pages/docs/jp/index.css @@ -0,0 +1,60 @@ +body { + display: flex; +} + +code { + border-radius: 10px; + font-family: + 'Noto Sans Mono CJK JP', + 'Source Han Code JP', + SFMono-Regular, + Consolas, + 'Roboto Mono', + 'Courier New', + Meiryo, + monospace; +} + +ul>li { + margin-top: 0.5rem; + margin-bottom: 0.5rem; +} + +a { + color: rgb(230, 45, 45); +} + +h1 { + margin-bottom: 0; +} + +h2 { + margin-top: 2.5rem; +} + +hr { + border: none; + border-top: 1px solid black; + margin-top: 0; + margin-bottom: 2rem; +} + +#main { + margin: 0; + width: 780px; + max-width: 780px; +} + +#sidebar-wrapper { + width: calc(50vw - 390px); + display: flex; + justify-content: flex-end; +} + +#sidebar { + padding: 0 2rem; +} + +.center { + text-align: center; +} diff --git a/pages/docs/jp/index.js b/pages/docs/jp/index.js new file mode 100644 index 0000000..ee33364 --- /dev/null +++ b/pages/docs/jp/index.js @@ -0,0 +1,53 @@ +function createMenu(path) { + const menu = document.getElementById("menu") + menu.innerHTML = ` + + ` +} diff --git a/pages/docs/jp/instrument/grammer/index.html b/pages/docs/jp/instrument/grammer/index.html new file mode 100644 index 0000000..4267c7b --- /dev/null +++ b/pages/docs/jp/instrument/grammer/index.html @@ -0,0 +1,77 @@ + + + + + + 音源/文法: IAM.mml Docs + + + + + + + + + + + + +
+

音源/文法

+ +
+ +
indent      = [ { space } ] ;
+end-of-line = { [ { space } ] , line-break } ;
+
+name        = "@" , identifier ;
+volume      = non-negative-float ;
+freqratio   = non-negative-float ;
+attack      = non-negative-float ;
+decay       = non-negative-float ;
+sustain     = non-negative-float ;
+release     = non-negative-float ;
+feedback    = non-negative-float ;
+operator    = volume , " " ,  frequency , " " , attack , " " , decay , " " , sustain , " " , release , [ " " , feedback ] ;
+instrument  = name , end-of-line , { indent , operator , end-of-line } ;
+instruments = [ { instrument } ] ;
+ + + +

+ オペレータの木構造はoperatorのインデントによって指定する。 + 次のような性質を持つ。 +

+ + + +

+ 従って、次のような「宙に浮いた」オペレータ(4行目)があってはならない。 +

+ +
@something
+1 1 0 0 1 0
+  1 1 0 0 1 0
+ 1 1 0 0 1 0
+
+ + + diff --git a/pages/docs/jp/instrument/theory/index.html b/pages/docs/jp/instrument/theory/index.html new file mode 100644 index 0000000..e3d8c16 --- /dev/null +++ b/pages/docs/jp/instrument/theory/index.html @@ -0,0 +1,158 @@ + + + + + + 音源/理論: IAM.mml Docs + + + + + + + + + + + + + + + +
+

音源/理論

+ +
+ +

音源

+ +

+ 音源は、1個以上のオペレータ、アルゴリズム、ADSRエンベロープによって決定される。 +

+ +

オペレータ

+ +

+ 波を生成する機構をオペレータと言う。 + オペレータは、それ単体では正弦波を生成する。 + オペレータには次の6種類のパラメータが与えられる。 +

+ + + +

+ 以降の数式を単純化させるために、便宜上次の数式を定義する。 + オペレータの振幅を\(v\)、ADSRエンベロープの係数を\(k(t)\)、音源への入力の音高(周波数)を\(f\)、オペレータの周波数比を\(f_r\)、時刻を\(t(\geq0)\)、FM変調に用いる別の波を\(m(t)\)、オペレータの生成する波\(w_{base}(t, m)\)は次のように表される。 +

+ +

+ \[ + w_{base}(t, m) = v \cdot k(t) \cdot \sin(2\pi f f_r t + m(t)) + \] +

+ +

アルゴリズム

+ +

+ オペレータの組合わせ方をアルゴリズムと言う。 + オペレータは\(N\)分木構造に組み合わせられ、次のように評価される。 +

+ + + +

FM変調

+ +

+ ある波の周波数を別の波で変調することをFM変調と言う。 + また、変調する側のオペレータをモジュレータ、変調される側のオペレータをキャリアと言う。 +

+ +

+ オペレータの振幅を\(v\)、オペレータの周波数比を\(f_r\)、音源への入力の音高(周波数)を\(f\)、時刻を\(t(\geq0)\)、モジュレータの生成する波を\(w_m(t)\)としたとき、キャリアの生成する波\(w_c(t)\)は次のように表される。 +

+ +

+ \[ + w_c(t) = w_{base}(t, w_m(t)) + \] +

+ +

AM変調

+ +

+ ある波の振幅を別の波で変調することをAM変調と言う。 +

+ +

+ 時刻を\(t(\geq0)\)、対象となるオペレータの数を\(N(>0)\)、それぞれオペレータの生成する波を\(w_i(t)\)としたとき、(仮想的な)オペレータの生成する波\(w(t)\)は次のように表される。 +

+ +

+ \[ + w(t) = \sum^N_{i=1} w_i(t) + \] +

+ +

ADSRエンベロープ

+ +

+ 波の時間的変化をエンベロープと言う。 + また、次の4種類のパラメータによってエンベロープを決定するものをADSRエンベロープと言う。 +

+ + + +

+ 時刻を\(t(\geq0)\)、入力が終了する時刻を\(T(\geq t)\)、オペレータの生成する波を\(w(t)\)としたとき、ADSRエンベロープの係数\(k(t)\)は次のように表される。 +

+ +

+ \[ + k(t) = + \left\{ + \begin{array}{cl} + t/a & (t < a)\\ + s + (1 - s)(1 - (t-a)/d) & (a \leq t < a + d)\\ + s & (a + d \leq t < T) \\ + s (1 - (t - T) / r) & (T \leq t < T + r) + \end{array} + \right. + \] +

+ +

フィードバック

+ +

+ オペレータが自身の出力でFM変調する機能をフィードバックと言う。 +

+ +

+ 時刻を\(t(\geq0)\)、フィードバックの回数を\(N\)、\(n\)回フィードバックを行ったオペレータの生成する波を\(w_n(t)\)、\(w_0(t)=w_{base}(t, 0)\)としたとき、生成される波\(w_N(t)\)は次のように表される。 +

+ +

+ \[ + w_N(t)=w_{base}(t, w_{N-1}(t)) + \] +

+ +
+ + + diff --git a/pages/docs/jp/learning/sample/index.html b/pages/docs/jp/learning/sample/index.html new file mode 100644 index 0000000..ab47f19 --- /dev/null +++ b/pages/docs/jp/learning/sample/index.html @@ -0,0 +1,59 @@ + + + + + + 学習/サンプルコード: IAM.mml Docs + + + + + + + + + + + + +
+

学習/サンプルコード

+ +
+ +

+ IAM.mmlを学習する前に、IAM.mmlで楽曲を再生してみましょう。 + サンプルコードとしてモーツァルト「きらきら星変奏曲」の冒頭を記述したコードを掲載します。 +

+ +
    +
  1. 下のコードをsample.mmlとしてローカルマシンに保存してください
  2. +
  3. IAM.mmlのインポートボタンを押してsample.mmlをインポートしてください
  4. +
  5. IAM.mmlの再生ボタンを押して再生してみてください
  6. +
+ +
@melody
+1 1 0 0.5 0 0
+  1 8.5 0 0 1 0 1
+
+@base
+0.75 2 0 0 1 0.2
+  0.8 2 0.2 0.1 0.9 0.1 5
+0.25 2 0 0.1 0.8 0.1
+  0.8 8 0.1 0.05 0.75 0.1 2
+
+%%
+melody @melody t120o5
+base   @base   t120o2
+
+melody [ cc  gg aa gg ff  ee   d8.e48d48c48d8.e16 c2
+base   [ c>c ec fc ec d<b >c<a fg                 c2
+
+melody : gg   ff   ee   dd gg   ff   e8.f48e48d48e8.f16 ed  ]2
+base   : >e<g >d<g >c<g bg >e<g >d<g >cc8.d16           <g2 ]2
+
+ +
+ + + diff --git a/pages/docs/jp/learning/screen/demo.png b/pages/docs/jp/learning/screen/demo.png new file mode 100644 index 0000000..41f7360 Binary files /dev/null and b/pages/docs/jp/learning/screen/demo.png differ diff --git a/pages/docs/jp/learning/screen/index.html b/pages/docs/jp/learning/screen/index.html new file mode 100644 index 0000000..ff9097e --- /dev/null +++ b/pages/docs/jp/learning/screen/index.html @@ -0,0 +1,43 @@ + + + + + + 学習/画面の見方: IAM.mml Docs + + + + + + + + + + + + +
+

学習/画面の見方

+ +
+ +
+ +
+ +
    +
  1. 再生ボタン。MMLで記述された音楽を再生します。
  2. +
  3. 一時停止ボタン。再生中の音楽を一時的に停止します。MMLに変更を加えず、続けて再生ボタンを押すと停止した時点から再生されます。
  4. +
  5. 停止ボタン。再生中・一時停止中の音楽を最初に戻します。
  6. +
  7. インポートボタン。IAM.mml専用形式のMMLをインポートし、コードエリアを上書きします。
  8. +
  9. エクスポートボタン。記述されたMMLをIAM.mml専用形式のMMLにエクスポートします。
  10. +
  11. ダウンロードボタン。MMLで記述された音楽をWAVEファイルにビルドしてダウンロードします。
  12. +
  13. 情報ボタン。IAM.mml Docsを開きます。
  14. +
  15. メッセージボックス。主にエラー文を表示するために用いられます。
  16. +
  17. 楽譜定義コードエリア。ここに楽譜を記述します。
  18. +
  19. 音源定義コードエリア。ここに音源を記述します。
  20. +
+
+ + + diff --git a/pages/docs/jp/learning/tutorial-inst/index.html b/pages/docs/jp/learning/tutorial-inst/index.html new file mode 100644 index 0000000..5c21c55 --- /dev/null +++ b/pages/docs/jp/learning/tutorial-inst/index.html @@ -0,0 +1,213 @@ + + + + + + 音源/チュートリアル: IAM.mml Docs + + + + + + + + + + + + + + + +
+

音源/チュートリアル

+ +
+ +

概要

+ +

+ IAM.mmlでは、楽譜定義と音源定義の2種類を分けて考えています。 + 本頁は音源定義のチュートリアルです。 +

+ +

+ 音源を指定しない場合、すべての音はただの正弦波として生成されます。 + これでは、音色に豊かさがありません。 + そこで、音源を定義・指定します。 +

+ +

+ 音源を定義するには、画面右側のコードエリアに音源定義を記述します。 + また、音源を指定するには、画面左側のコードエリアの楽譜において、音源指定コマンドを記述します。 +

+ +

+ 当ページでは、音源定義の簡単な解説を行います。 + 適宜理論文法を参照すると、理解が深まります。 +

+ +

正弦波を作る

+ +

+ 上述の通り、IAM.mmlでは標準の音源は正弦波を生成します。 + この標準の音源は、以下のように再定義できます。 +

+ +
@sin
+1 1 0 0 1 0
+ + + +

+ 上の音源を指定しても、標準の音源とは違いがありません。 + これでは面白くないので、少しカスタマイズしてみましょう。 +

+ +

+ IAM.mmlのコマンドには音高を自由に変えるものがありません。 + そのため、四半音上げることができません。 + この問題は、音源の周波数比に\(2^{(1/24)}\)を指定することで解決します。 +

+ +

+ 2音以上連続する楽譜を再生してみると、音の変わり目で「プツッ」という音が鳴るはずです。 + この音は「クリックノイズ」と言われる・音の波が不連続であるために発生する雑音です。 + この雑音を消す手法として、連続する音を混ぜることが挙げられます。 + これは、アタックタイムとリリースタイムを設けることで実現します。 +

+ +

+ 以上2点を反映した音源は次のようになります。 +

+ +
@new-sin
+1 1.0293 0.1 0 1 0.1
+ +

矩形波を作る

+ +

+ 正弦波だけでは、まだ音色に豊かさがありません。 + では、正弦波ではない波を生成する音源を作りましょう。 + そのためには、波を変調する必要があります。 +

+ +

+ 波の周波数を別の波で変えることをFM変調と言います。 + 周波数を変調することで、後述するAM変調よりも大きく倍音に変化を齎せます。 + しかし、カオス的な挙動をするために、意図した通りの音色を作るのが難しい変調方式でもあります。 +

+ +

+ 変調される波の周波数と変調する波の周波数の周波数の比を1:2にすることで矩形波を生成できることが知られています。 + 従って、矩形波を生成する音源は、次のように定義できます。 +

+ +
@square
+1 1 0 0 1 0
+  1 2 0 0 1 0
+ +

+ 上のように、FM変調を行うには、変調する波のインデントを深くします。 +

+ +

ベルを作る

+ +

+ FM変調は金属音を作るのに向いていると言われています。 + 例えばベルの音色は、変調される波と変調する波の周波数比が2:3や2:7のような割り切れない数である場合に発生します。 +

+ +

+ 次のように定義した音源では、ベルのような金属音が鳴ります。 + 尚、2:7としているため、1オクターブ上がっています。 + 1:3.5としても変調の内容は変わりませんが、標準の4オクターブ辺りでは音が低く、あまり金属音のように聴こえないかもしれません。 +

+ +
@bell
+1 2 0 0 1 0
+  1 7 0 0 1 0
+ +

スネアドラムを作る

+ +

+ FM音源においてスネアドラムはノイズによって表現されます。 + ノイズを作るためには、周波数を滅茶苦茶に変調する必要があります。 + ひいては、何回もFM変調を行う必要があります。 + これはフィードバック機能を用いると簡単に実現できます。 +

+ +

+ フィードバック機能を使うには、葉ノードのオペレータの第7パラメータに数値を指定します。 + 仕様上小数を指定できますが、小数点以下は切り捨てされます。 +

+ +

+ 次のように定義した音源では、スネアドラムのような音が鳴ります。 +

+ +
@snare
+1 0 0 0.3 0 0
+  50 1 0 0 1 0 10
+ +

オルガンを作る

+ +

+ 波の振幅を合成することをAM変調と言います。 +

+ +

+ オルガンの音色は複数の正弦波の合成によって表現できます。 + 次のように定義できます。 +

+ +
@organ
+0.2 1 0 0 1 0
+0.5 2 0 0 1 0
+0.2 3 0 0 1 0
+0.1 4 0 0 1 0
+ +

+ 上のように、AM変調を行うには、合成したい波のインデントを揃えます。 +

+ +

色々な音を作る

+ +

+ 次のように定義すると、弦楽器のような音になります。 +

+ +
@string
+0.75 2 0.2 0 1 0.2
+  0.8 2 0.2 0.1 0.9 0.1 5
+0.25 2 0.2 0.1 0.9 0.1
+  0.8 12 0.1 0.05 0.75 0.1
+ +

+ 次のように定義すると、タムのような音になります。 +

+ +
@tom-tom
+1 0 0 0.3 0 0
+  0.5 0 0 0.3 0 0
+    50 1 0 0 1 0 10
+  0.5 0.5 0 0.2 0 0
+ +
+ + + + diff --git a/pages/docs/jp/learning/tutorial-score/index.html b/pages/docs/jp/learning/tutorial-score/index.html new file mode 100644 index 0000000..207150f --- /dev/null +++ b/pages/docs/jp/learning/tutorial-score/index.html @@ -0,0 +1,158 @@ + + + + + + 学習/楽譜定義: IAM.mml Docs + + + + + + + + + + + + +
+

学習/楽譜定義

+ +
+ +

概要

+ +

+ IAM.mmlでは、楽譜定義と音源定義の2種類を分けて考えています。 + 本頁は楽譜定義のチュートリアルです。 +

+ +

+ IAM.mmlでは、MMLという言語体系で楽譜を記述します。 + MMLには多くの方言があり、処理系のマニュアルを読まなければ詳細はわかりません。 + しかし、基本的な理念は共通しているため、他の処理系のマニュアルを参考にするのも良いでしょう。 +

+ +

+ 当ページでは、作曲の上で最も重要である楽譜の記述方法について簡単な解説を行います。 + 適宜文法や各種コマンドを参照すると、理解が深まります。 +

+ +

ドレミファソラシド

+ +

+ 簡単な例として「ドレミ」を鳴らしてみましょう。 + 次のコードを左側のコードエリアに記述し、再生ボタンを押すと、「ドレミ」が再生されます。 +

+ +
melody cde
+ +

+ 必ず行頭から空白まではパート名を指定します。 + 上のコードでは「melody」というパート名が指定されています。 + パート名には任意の文字列を指定できます。 + パート名は各行がどのパートの続きであるかを判別するために使われます。 +

+ +

+ パート名指定以降はコマンドを列挙します。 + 上のコードでは「c」「d」「e」の3個のコマンドが指定されています。 + これらは音符コマンドです。 + 「c」ではドが、「d」ではレが、「e」ではミが鳴ります。 +

+ +

+ しかし、abcdefgと+-で表せる実質12種類の音だけでは音域が狭すぎます。 + この問題を解決するために、オクターブを移動します。 + オクターブを移動するにはオクターブコマンドを用います。 +

+ +

+ 結果、「ドレミファソラシド」は次のように記述できます。 +

+ +
melody cdef gab>c
+ +

和音

+ +

+ 音を重ねるには、パートを分けるだけです。 + しかし、楽譜は各パートごとに独立して進行します。 + そのため、期待通りに重ねるには、プログラマの責任で同期を取らなければなりません。 +

+ +

+ 可読性を高めるために、コメントで小節番号を振る等する人もいます。 + IAM.mmlでは、セミコロン「;」以降行末まではコメントとして解釈されます。 +

+ +

+ 次のコードは、CマイナースケールにおいてbVI-bVII-VIIm(b5)-Imを演奏します。 + ただし、初めにテンポコマンドオクターブコマンド音価コマンド音量コマンドで設定をしています。 +

+ +
; config
+chord1 t140o3l1v0.2 
+chord2 t140o4l1v0.2
+chord3 t140o4l1v0.2
+
+; 1
+chord1 a- b-
+chord2 c  d 
+chord3 e- f 
+
+; 3
+chord1 b >c
+chord2 d  e-
+chord3 f  g
+ +

ループ

+ +

+ 特定のフレーズを繰り返したいことがあります。 + 愚直にコピー&ペーストするのは、可読性及び変更容易性の観点から良い選択とは言えません。 + この問題を解決するために、特定の楽譜を指定回数繰り返すループコマンドが用意されています。 +

+ +

+ ループコマンドは、「:」を記すことで最後の繰り返しだけ演奏しない部分を指定することができます。 + 次のコードを鳴らしてみましょう。 + 下の順に演奏されたことがわかると思います。 +

+ +
melody l8 cd [ae:decd]4 b>c<be
+ +
    +
  1. 「ドレ」を鳴らす
  2. +
  3. ループコマンドに入る
  4. +
  5. 「ラミレミドレ」を3回繰り返して鳴らす (1-3回目)
  6. +
  7. 「ラミ」を鳴らす (4回目)
  8. +
  9. ループコマンドを出る
  10. +
  11. 「シドシミ」を鳴らす
  12. +
+ +

マクロ

+ +

+ 特定のコマンド列に名前を付けて管理したいことがあります。 + この需要はマクロによって実現されます。 + マクロを展開するためには、マクロコマンドを用います。 +

+ +

+ 次のコードでは、初期設定や楽譜をマクロ化しています。 +

+ +
!cic t140l1v0.2
+!c1-1451 c1 f1g1 c1
+!c2-1451 e1 a1b1 e1
+!c3-1451 g1>c1d1<g1
+chord1 !cic o3 !c1-1451
+chord2 !cic o4 !c2-1451
+chord3 !cic o4 !c3-1451
+
+ + + + diff --git a/pages/docs/jp/releasenote/index.html b/pages/docs/jp/releasenote/index.html new file mode 100644 index 0000000..07c2de9 --- /dev/null +++ b/pages/docs/jp/releasenote/index.html @@ -0,0 +1,45 @@ + + + + + + リリースノート: IAM.mml Docs + + + + + + + + + + + + +
+

リリースノート

+ +
+ +

v0.2.0

+

+ 2024年5月25日 +

+ + +

v0.1.0

+

+ 2024年5月13日 +

+

+ 最低限の機能を持ったIAM.mmlをリリース。 +

+
+ + + diff --git a/pages/docs/jp/score/grammer/index.html b/pages/docs/jp/score/grammer/index.html new file mode 100644 index 0000000..7a49373 --- /dev/null +++ b/pages/docs/jp/score/grammer/index.html @@ -0,0 +1,50 @@ + + + + + + 楽譜/文法: IAM.mml Docs + + + + + + + + + + + + +
+

楽譜/文法

+ +
+ +
partname   = identifier ;
+command    = ? a command ? ;
+commands   = { command , [ { space } ] } ;
+line       = [ { space } ] , partname , { space } , commands , line-break ;
+blank-line = [ { space } ] , line-break ;
+score      = { line | blank-line } ;
+ + + +

+ セミコロン「;」以降行末まではコメントとして解釈される。 +

+
+ + + diff --git a/pages/export.svg b/pages/export.svg new file mode 100644 index 0000000..d788e67 --- /dev/null +++ b/pages/export.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + diff --git a/pages/iam-mml-logo.svg b/pages/iam-mml-logo.svg new file mode 100644 index 0000000..1468289 --- /dev/null +++ b/pages/iam-mml-logo.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/pages/import.svg b/pages/import.svg new file mode 100644 index 0000000..291473a --- /dev/null +++ b/pages/import.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + diff --git a/pages/index.css b/pages/index.css new file mode 100644 index 0000000..87288c5 --- /dev/null +++ b/pages/index.css @@ -0,0 +1,270 @@ +/* ========================================================================= */ +/* general */ +/* ========================================================================= */ + +html, body { + width: 100%; + height: 100%; + margin: 0; + padding: 0; + background-color: #222; +} + +button, div, table, textarea { + box-sizing: border-box; +} + +textarea { + padding: 0.3rem; + line-height: 1.3rem; + font-family: + 'Noto Sans Mono CJK JP', + 'Source Han Code JP', + SFMono-Regular, + Consolas, + 'Roboto Mono', + 'Courier New', + Meiryo, + monospace; +} + +/* ========================================================================= */ +/* id */ +/* ========================================================================= */ + +#main { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; +} + +/* buttons */ + +#button-wrapper { + display: flex; + width: 100%; + padding: 15px; + padding-bottom: 0; +} + +#button-wrapper>button { + margin-right: 0.25rem; + min-height: 2.25rem; + max-height: 2.25rem; + padding: 0 0.625rem; +} + +#button-wrapper>button>img { + min-height: 1rem; + max-height: 1rem; +} + +#stop { + margin-right: 1rem !important; +} + +#info>img { + mix-blend-mode: multiply; +} + +.red-button { + background-color: #a00; + color: white; + border: none; + border-radius: 5px; + font-weight: bold; + cursor: pointer; +} + +.red-button:hover { + background-color: #b11; +} + +.red-button:active { + outline: 3px solid #c22; + outline-offset: -3px; +} + +.white-button { + background-color: #ccc; + color: black; + border: none; + border-radius: 5px; + font-weight: bold; + cursor: pointer; +} + +.white-button:hover { + background-color: #eee; +} + +.white-button:active { + outline: 3px solid #fff; + outline-offset: -3px; +} + +/* MML or instruments or status textarea */ + +#textarea-wrapper { + width: 100%; + flex-grow: 1; + margin: 0; + padding: 15px; + flex-grow: 1; +} + +#textarea-upper { + display: flex; + width: 100%; + height: 100%; /* calc(100% - 1.5rem); */ + margin: 0; + padding: 0; +} + +#textarea-lower { + width: 100%; + height: 1.5rem; + margin: 0; + padding: 0; +} + +#textarea-resizer { + width: 3px; + height: 100%; + background-color: #999; + cursor: ew-resize; +} + +#mml-wrapper { + display: flex; + width: 60%; + height: 100%; + margin: 0; + padding: 0; + border: 3px solid #999; + border-right: none; +} + +#inst-wrapper { + display: flex; + flex-grow: 1; + height: 100%; + margin: 0; + padding: 0; + border: 3px solid #999; + border-left: none; +} + +#status-wrapper { + display: flex; + width: 100%; + height: 100%; + margin: 0; + padding: 0; + border: 3px solid #999; + border-top: none; +} + +#mml, +#mml-numbers, +#inst, +#inst-numbers, +#status, +#log { + width: 100%; + height: 100%; + margin: 0; + padding: 0.3rem; + background-color: #222; + border: none; + color: white; + outline: none; + resize: none; +} + +#mml-numbers, +#inst-numbers { + user-select: none; + width: 3rem; + color: #999; + text-align: right; + overflow-y: hidden; + scrollbar-width: none; + -ms-overflow-style: none; +} + +/* sidebar */ + +#blinder { + position: absolute; + display: none; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); +} + +#sidebar-wrapper { + position: absolute; + display: flex; + justify-content: flex-end; + top: 0; + right: 0; + width: 40rem; + height: 100%; + pointer-events: none; +} + +#sidebar-button { + max-width: 1.5rem; + min-width: 1.5rem; + max-height: 3rem; + min-height: 3rem; + background-color: #a00; + cursor: pointer; + pointer-events: auto; +} + +#sidebar-button:hover { + background-color: #b11; +} + +#sidebar-button:active { + outline: 3px solid #c22; + outline-offset: -3px; +} + +#sidebar-button-icon { + margin-left: 25%; + margin-top: 25%; +} + +#sidebar { + display: none; + width: 100%; + height: 100%; + pointer-events: auto; +} + +#sidebar-resizer { + width: 3px; + height: 100%; + background-color: #a00; + cursor: ew-resize; +} + +.left-triangle { + background: #eee; + height: 75%; + width: 50%; + clip-path: polygon(0 50%, 100% 0, 100% 100%); +} + +.right-triangle { + background: #eee; + height: 75%; + width: 50%; + clip-path: polygon(0 0, 100% 50%, 0 100%); +} diff --git a/pages/index.html b/pages/index.html new file mode 100644 index 0000000..c774bb1 --- /dev/null +++ b/pages/index.html @@ -0,0 +1,68 @@ + + + + + IAM.mml + + + + + + + + +
+
+ + + + + + + +
+ +
+
+
+ + +
+
+
+ + +
+
+
+
+ +
+ + + + + + \ No newline at end of file diff --git a/pages/info.svg b/pages/info.svg new file mode 100644 index 0000000..f437b13 --- /dev/null +++ b/pages/info.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + diff --git a/pages/pause.svg b/pages/pause.svg new file mode 100644 index 0000000..076163c --- /dev/null +++ b/pages/pause.svg @@ -0,0 +1,13 @@ + + + + + + + + diff --git a/pages/play.svg b/pages/play.svg new file mode 100644 index 0000000..b848adf --- /dev/null +++ b/pages/play.svg @@ -0,0 +1,12 @@ + + + + + + + diff --git a/pages/stop.svg b/pages/stop.svg new file mode 100644 index 0000000..c217900 --- /dev/null +++ b/pages/stop.svg @@ -0,0 +1,12 @@ + + + + + + + diff --git a/src/App.ts b/src/App.ts new file mode 100644 index 0000000..d8a1833 --- /dev/null +++ b/src/App.ts @@ -0,0 +1,104 @@ +import {Player} from '@/Player' +import {Wave} from '@/Wave' +import {Commands} from '@/command/Commands' +import {Evaluator} from '@/evaluate/Evaluator' +import {checkCharsSame} from '@/parse/Character' +import {Characters} from '@/parse/Characters' +import {PartDefs} from '@/parse/PartDefs' +import {Parser} from '@/parse/Parser' +import type {MacroDefs} from '@/parse/MacroDefs' +import type {InstDefs} from '@/parse/InstDefs' + +export class App { + private macroDefsCache: MacroDefs | null + private instDefsCache: InstDefs | null + + private partDefsCache: PartDefs + private waveCache: Map + private player: Player | null + + public constructor() { + this.macroDefsCache = null + this.instDefsCache = null + + this.partDefsCache = new PartDefs() + this.waveCache = new Map() + this.player = null + } + + public prepare(mml: string, inst: string) { + // parse + const [partDefs, macroDefs, instDefs] = Parser.parse(mml, inst) + if (partDefs.len() === 0) { + throw new Error('[fatal error] No parts found.') + } + + // evaluate commands and create wave + // Use the cached wave when the following conditions are met: + // - score cache is found + // - wave cache is found + // - score is the same as that cache + // - macro defs cache isn't null + // - inst defs cache isn't null + // - macro defs is the same as that cache + // - inst defs is the same as that cache + const waves = [] + const waveCache = new Map() + for (const [partName, partChars] of partDefs.iter()) { + const cached = this.partDefsCache.get(partName) + if ( + cached !== null && + this.waveCache.has(partName) && + this.macroDefsCache !== null && + this.instDefsCache !== null && + checkCharsSame(cached, partChars) && + this.macroDefsCache.isSame(macroDefs) && + this.instDefsCache.isSame(instDefs) + ) { + waves.push(this.waveCache.get(partName)!) + waveCache.set(partName, this.waveCache.get(partName)!) + } else { + const chars = new Characters(partChars) + const commands = new Commands(chars) + const wave = Evaluator.eval(commands, macroDefs, instDefs) + waves.push(wave) + waveCache.set(partName, wave) + } + } + + // cache + this.partDefsCache.clear() + this.waveCache.clear() + this.partDefsCache = partDefs + this.waveCache = waveCache + + // recreate this.player + if (this.player !== null) { + this.player.close() + } + this.player = new Player(new Wave(waves)) + } + + public play() { + if (this.player === null) { + throw new Error(`[unexpected error] Tried to play null player.`) + } + this.player.play() + } + + public build() { + const waves = [] + for (const wave of this.waveCache.values()) { + waves.push(wave) + } + new Wave(waves).build() + } + + public pause() { + this.player?.pause() + } + + public stop() { + this.player?.stop() + } +} diff --git a/src/Player.ts b/src/Player.ts new file mode 100644 index 0000000..5eb44ab --- /dev/null +++ b/src/Player.ts @@ -0,0 +1,61 @@ +import {SAMPLE_RATE} from './constants' +import {Wave} from './Wave' + +export class Player { + private readonly audioContext: AudioContext + private readonly audioBuffer: AudioBuffer + private source: AudioBufferSourceNode | null + private isPaused: boolean + + public constructor(wave: Wave) { + this.audioContext = new AudioContext() + + this.audioBuffer = this.audioContext.createBuffer(1, wave.getSize(), SAMPLE_RATE) + this.audioBuffer.copyToChannel(wave.getWave(), 0) + + this.source = null + this.isPaused = false + } + + public close() { + this.source?.stop() + this.audioContext.close() + } + + public play() { + // playing -> nothing to do + // paused -> resume + if (this.source !== null) { + if (this.isPaused) { + this.audioContext.resume().then(() => (this.isPaused = false)) + } + return + } + // otherwise -> start from the beginning + this.source = this.audioContext.createBufferSource() + this.source.buffer = this.audioBuffer + this.source.connect(this.audioContext.destination) + this.source.addEventListener('ended', () => this.end()) + this.source.start() + } + + public pause() { + if (this.source !== null) { + this.audioContext.suspend().then(() => (this.isPaused = true)) + } + } + + public stop() { + if (this.source !== null) { + this.source.stop() + this.end() + } + } + + private end() { + this.source = null + if (this.isPaused) { + this.audioContext.resume().then(() => (this.isPaused = false)) + } + } +} diff --git a/src/Wave.ts b/src/Wave.ts new file mode 100644 index 0000000..bbb39a7 --- /dev/null +++ b/src/Wave.ts @@ -0,0 +1,68 @@ +import {SAMPLE_RATE} from './constants' + +function writeString(view: DataView, offset: number, str: string) { + for (let i = 0; i < str.length; ++i) { + view.setUint8(offset + i, str.charCodeAt(i)) + } +} + +function writeWaveAs16Bit(view: DataView, offset: number, wave: Float32Array) { + for (const w of wave) { + const v = Math.max(Math.min(w, 1), -1) + const p = v < 0 ? v * 0x8000 : v * 0x7fff + view.setInt16(offset, p, true) + offset += 2 + } +} + +export class Wave { + private readonly size: number + private readonly wave: Float32Array + + public constructor(waves: Float32Array[]) { + this.size = waves.reduce((r, n) => (n.length > r ? n.length : r), 0) + this.wave = new Float32Array(this.size) + for (const n of waves) { + for (let i = 0; i < n.length; ++i) { + this.wave[i] += n[i] + } + } + } + + public getSize(): number { + return this.size + } + + public getWave(): Float32Array { + return this.wave + } + + public build() { + const BYTES_COUNT_PER_SAMPLE = 2 + const buffer = new ArrayBuffer(44 + this.size * BYTES_COUNT_PER_SAMPLE) + const view = new DataView(buffer) + + writeString(view, 0, 'RIFF') + view.setUint32(4, 32 + this.size, true) + writeString(view, 8, 'WAVE') + + writeString(view, 12, 'fmt ') + view.setUint32(16, 16, true) + view.setUint16(20, 1, true) + view.setUint16(22, 1, true) + view.setUint32(24, SAMPLE_RATE, true) + view.setUint32(28, SAMPLE_RATE * BYTES_COUNT_PER_SAMPLE, true) + view.setUint16(32, BYTES_COUNT_PER_SAMPLE, true) + view.setUint16(34, BYTES_COUNT_PER_SAMPLE * 8, true) + + writeString(view, 36, 'data') + view.setUint32(40, this.size * BYTES_COUNT_PER_SAMPLE, true) + writeWaveAs16Bit(view, 44, this.wave) + + const url = window.URL.createObjectURL(new Blob([view], {type: 'audio/wav'})) + const a = document.createElement('a') + a.href = url + a.download = 'wave.wav' + a.click() + } +} diff --git a/src/command/Commands.ts b/src/command/Commands.ts new file mode 100644 index 0000000..ed82bf7 --- /dev/null +++ b/src/command/Commands.ts @@ -0,0 +1,94 @@ +import type {ICommand} from './ICommand' +import {Key} from './Key' +import {Length} from './Length' +import {Note} from './Note' +import {Octave} from './Octave' +import {Tempo} from './Tempo' +import {Volume} from './Volume' +import {Instrument} from './Instrument' +import {Macro} from './Macro' +import {Loop} from './Loop' + +import type {Characters} from '@/parse/Characters' +import type {Buffer} from '@/evaluate/Buffer' + +export class Commands { + private readonly commands: ICommand[] + + public constructor(chars: Characters, isInScope?: boolean) { + this.commands = [] + while (chars.get() !== null) { + const first = chars.get()! + const ln = first.ln + const cn = first.cn + + chars.eatSpaces() + + const key = Key.from(chars) + if (key !== null) { + this.commands.push(key) + continue + } + const length = Length.from(chars) + if (length !== null) { + this.commands.push(length) + continue + } + const note = Note.from(chars) + if (note !== null) { + this.commands.push(note) + continue + } + const octave = Octave.from(chars) + if (octave !== null) { + this.commands.push(octave) + continue + } + const tempo = Tempo.from(chars) + if (tempo !== null) { + this.commands.push(tempo) + continue + } + const volume = Volume.from(chars) + if (volume !== null) { + this.commands.push(volume) + continue + } + const instrument = Instrument.from(chars) + if (instrument !== null) { + this.commands.push(instrument) + continue + } + const macro = Macro.from(chars) + if (macro !== null) { + this.commands.push(macro) + continue + } + const loop = Loop.from(chars) + if (loop !== null) { + this.commands.push(loop) + continue + } + if (isInScope === undefined || isInScope === false) { + throw new Error(`[syntax error] Undefined token found: ${ln} line, ${cn} char.`) + } else { + break + } + } + } + + public eval(buffer: Buffer) { + for (const command of this.commands) { + command.eval(buffer) + } + } + + public isEmpty(): boolean { + return this.commands.length === 0 + } + + /** A getter for tests. */ + public get(): readonly ICommand[] { + return this.commands + } +} diff --git a/src/command/CommandsOnDemand.ts b/src/command/CommandsOnDemand.ts new file mode 100644 index 0000000..9ca47c1 --- /dev/null +++ b/src/command/CommandsOnDemand.ts @@ -0,0 +1,21 @@ +import {Commands} from './Commands' + +import {Characters} from '@/parse/Characters' +import type {Character} from '@/parse/Character' + +export class CommandsOnDemand { + private readonly chars: readonly Character[] + private commands: Commands | null + + public constructor(chars: readonly Character[]) { + this.chars = chars + this.commands = null + } + + public get(): Commands { + if (this.commands === null) { + this.commands = new Commands(new Characters(this.chars)) + } + return this.commands + } +} diff --git a/src/command/ICommand.ts b/src/command/ICommand.ts new file mode 100644 index 0000000..0912579 --- /dev/null +++ b/src/command/ICommand.ts @@ -0,0 +1,5 @@ +import type {Buffer} from '../evaluate/Buffer' + +export interface ICommand { + eval(buffer: Buffer): void +} diff --git a/src/command/Instrument.ts b/src/command/Instrument.ts new file mode 100644 index 0000000..ca43f13 --- /dev/null +++ b/src/command/Instrument.ts @@ -0,0 +1,61 @@ +import type {ICommand} from './ICommand' + +import type {Buffer} from '@/evaluate/Buffer' +import type {Characters} from '@/parse/Characters' + +export class Instrument implements ICommand { + private readonly ln: number + private readonly cn: number + private readonly name: string + + private constructor(ln: number, cn: number, name: string) { + this.ln = ln + this.cn = cn + this.name = name + } + + public static from(chars: Characters): Instrument | null { + const first = chars.get() + if (first === null) { + return null + } + + const ln = first.ln + const cn = first.cn + + // '@' + const a = chars.eatChar(['@']) + if (a === null) { + return null + } + // (IDENTIFIER) + const name = chars.eatIdentifier(ln) + if (name === null) { + throw new Error(`[syntax error] The instrument id is not found: ${ln} line, ${cn} char.`) + } + + return new Instrument(ln, cn, '@' + name) + } + + public getLn(): number { + return this.ln + } + + public getCn(): number { + return this.cn + } + + public getName(): string { + return this.name + } + + public eval(buffer: Buffer): void { + const instOnDemand = buffer.instDefs.get(this.name) + if (instOnDemand === null) { + throw new Error( + `[syntax error] The instrument "@${this.name}" is undefined: ${this.ln} line, ${this.cn} char.` + ) + } + buffer.inst = instOnDemand.get() + } +} diff --git a/src/command/Key.test.ts b/src/command/Key.test.ts new file mode 100644 index 0000000..0b5eb1f --- /dev/null +++ b/src/command/Key.test.ts @@ -0,0 +1,95 @@ +import {describe, expect, test} from 'bun:test' +import {Key} from './Key' +import {Characters} from '../parse/Characters' +import type {Pitch} from '../constants' + +describe('Key.from', () => { + test('When trying to eat out of range, it returns null and the current index.', () => { + const chars = new Characters([ + {c: 'k', ln: 1, cn: 1}, + {c: 'a', ln: 1, cn: 2}, + {c: '+', ln: 1, cn: 3}, + ]) + chars.forward(3) + expect(Key.from(chars)).toStrictEqual(null) + expect(chars.get()).toStrictEqual(null) + }) + + test('When no key is found, it returns null and the current index.', () => { + const chars = new Characters([ + {c: 'k', ln: 1, cn: 1}, + {c: 'a', ln: 1, cn: 2}, + {c: '+', ln: 1, cn: 3}, + ]) + chars.forward(1) + expect(Key.from(chars)).toStrictEqual(null) + expect(chars.get()).toStrictEqual({c: 'a', ln: 1, cn: 2}) + }) + + test('When the command is not found, it throws an error.', () => { + const chars = new Characters([ + {c: 'k', ln: 1, cn: 1}, + {c: 'a', ln: 1, cn: 2}, + ]) + expect(() => Key.from(chars)).toThrow() + }) + + test('When the command is not found on the same line, it throws an error.', () => { + const chars = new Characters([ + {c: 'k', ln: 1, cn: 1}, + {c: 'a', ln: 1, cn: 2}, + {c: '+', ln: 2, cn: 1}, + ]) + expect(() => Key.from(chars)).toThrow() + }) + + test('When it is in the middle, it throws an error.', () => { + const chars = new Characters([ + {c: 'k', ln: 1, cn: 1}, + {c: 'a', ln: 1, cn: 2}, + {c: 'b', ln: 1, cn: 3}, + {c: 'c', ln: 2, cn: 1}, + {c: '+', ln: 2, cn: 2}, + ]) + expect(() => Key.from(chars)).toThrow() + }) + + test('When "k+" is found, it returns Key and the next index.', () => { + const chars = new Characters([ + {c: 'k', ln: 1, cn: 1}, + {c: '+', ln: 1, cn: 2}, + {c: 'a', ln: 1, cn: 3}, + ]) + const key = Key.from(chars)! + expect(key.getCommand()).toStrictEqual('+') + expect(key.getPitches()).toStrictEqual([]) + expect(chars.get()).toStrictEqual({c: 'a', ln: 1, cn: 3}) + }) + + test('When "ka-" is found, it returns Key and the next index.', () => { + const chars = new Characters([ + {c: 'k', ln: 1, cn: 1}, + {c: 'a', ln: 1, cn: 2}, + {c: '-', ln: 1, cn: 3}, + {c: '-', ln: 1, cn: 4}, + ]) + const key = Key.from(chars)! + expect(key.getCommand()).toStrictEqual('-') + expect(key.getPitches()).toStrictEqual(['a']) + expect(chars.get()).toStrictEqual({c: '-', ln: 1, cn: 4}) + }) + + test('When "kabc=" is found, it returns Key and the next index.', () => { + const chars = new Characters([ + {c: 'k', ln: 1, cn: 1}, + {c: 'a', ln: 1, cn: 2}, + {c: 'b', ln: 1, cn: 3}, + {c: 'c', ln: 1, cn: 4}, + {c: '=', ln: 1, cn: 5}, + ]) + const key = Key.from(chars)! + expect(key.getCommand()).toStrictEqual('=') + expect(key.getPitches()).toStrictEqual(['a', 'b', 'c']) + expect(chars.get()).toStrictEqual(null) + }) +}) diff --git a/src/command/Key.ts b/src/command/Key.ts new file mode 100644 index 0000000..619adcd --- /dev/null +++ b/src/command/Key.ts @@ -0,0 +1,66 @@ +import type {ICommand} from './ICommand' +import {ACCIDENTALS, PITCHES, type Accidental, type Pitch} from '../constants' +import type {Buffer} from '../evaluate/Buffer' +import {Characters} from '../parse/Characters' + +export type KeyCommand = Accidental + +export class Key implements ICommand { + private readonly command: KeyCommand + private readonly pitches: readonly Pitch[] + + private constructor(command: KeyCommand, pitches: readonly Pitch[]) { + this.command = command + this.pitches = pitches + } + + public static from(chars: Characters): Key | null { + const first = chars.get() + if (first === null) { + return null + } + + // get ln and cn for me + const ln = first.ln + const cn = first.cn + + // 'k' + const k = chars.eatChar(['k']) + if (k === null) { + return null + } + // (PITCH)+ + const pitches = [] + while (true) { + const pitch = chars.eatChar(PITCHES, ln) + if (pitch === null) { + break + } + pitches.push(pitch) + } + // (ACCIDENTAL) + const command = chars.eatChar(ACCIDENTALS, ln) + if (command === null) { + throw new Error( + `[syntax error] The key command must have an accidental: ${ln} line, ${cn} char.` + ) + } + + return new Key(command as KeyCommand, pitches as readonly Pitch[]) + } + + public getCommand(): KeyCommand { + return this.command + } + public getPitches(): readonly Pitch[] { + return this.pitches + } + + public eval(buffer: Buffer): void { + const shift = this.command === '+' ? 1 : this.command === '-' ? -1 : 0 + const arr = this.pitches.length === 0 ? PITCHES : this.pitches + for (const n of arr) { + buffer.shift.set(n, shift) + } + } +} diff --git a/src/command/Length.test.ts b/src/command/Length.test.ts new file mode 100644 index 0000000..cab4708 --- /dev/null +++ b/src/command/Length.test.ts @@ -0,0 +1,62 @@ +import {describe, expect, test} from 'bun:test' +import {Length} from './Length' +import {Characters} from '../parse/Characters' + +describe('Length.from', () => { + test('When trying to eat out of range, it returns null and the current index.', () => { + const chars = new Characters([ + {c: 'l', ln: 1, cn: 1}, + {c: '4', ln: 1, cn: 2}, + ]) + chars.forward(2) + expect(Length.from(chars)).toStrictEqual(null) + expect(chars.get()).toStrictEqual(null) + }) + + test('When no Length is found, it returns null and the current index.', () => { + const chars = new Characters([ + {c: 'l', ln: 1, cn: 1}, + {c: 'b', ln: 1, cn: 2}, + {c: '4', ln: 1, cn: 3}, + ]) + chars.forward(1) + expect(Length.from(chars)).toStrictEqual(null) + expect(chars.get()).toStrictEqual({c: 'b', ln: 1, cn: 2}) + }) + + test('When the Length number is not found on the same line, it throws an error.', () => { + const chars = new Characters([ + {c: 'l', ln: 1, cn: 1}, + {c: '4', ln: 2, cn: 1}, + ]) + expect(() => Length.from(chars)).toThrow() + }) + + test('When the Length number is not found, it throws an error.', () => { + const chars = new Characters([ + {c: 'l', ln: 1, cn: 1}, + {c: 'a', ln: 1, cn: 2}, + ]) + expect(() => Length.from(chars)).toThrow() + }) + + test('When the Length number is 0, it throws an error.', () => { + const chars = new Characters([ + {c: 'l', ln: 1, cn: 1}, + {c: '0', ln: 1, cn: 2}, + ]) + expect(() => Length.from(chars)).toThrow() + }) + + test('When an Length is found, it returns Length and the next index.', () => { + const chars = new Characters([ + {c: 'l', ln: 1, cn: 1}, + {c: '1', ln: 1, cn: 2}, + {c: '6', ln: 1, cn: 3}, + {c: 'a', ln: 1, cn: 4}, + ]) + const length = Length.from(chars)! + expect(length.getNoteValue()).toStrictEqual(16) + expect(chars.get()).toStrictEqual({c: 'a', ln: 1, cn: 4}) + }) +}) diff --git a/src/command/Length.ts b/src/command/Length.ts new file mode 100644 index 0000000..41ab776 --- /dev/null +++ b/src/command/Length.ts @@ -0,0 +1,50 @@ +import type {ICommand} from './ICommand' +import type {Buffer} from '../evaluate/Buffer' +import {Characters} from '../parse/Characters' + +export class Length implements ICommand { + private readonly noteValue: number + + private constructor(noteValue: number) { + this.noteValue = noteValue + } + + public static from(chars: Characters): Length | null { + const first = chars.get() + if (first === null) { + return null + } + + // get ln and cn for me + const ln = first.ln + const cn = first.cn + + // 'l' + const k = chars.eatChar(['l']) + if (k === null) { + return null + } + // (NATURAL) + const noteValue = chars.eatNatural(ln) + if (noteValue === null) { + throw new Error(`[syntax error] The note value is not found: ${ln} line, ${cn} char.`) + } + + // check + if (noteValue <= 0) { + throw new Error( + `[syntax error] The note value must be greater than 0: ${ln} line, ${cn} char.` + ) + } + + return new Length(noteValue) + } + + public getNoteValue(): number { + return this.noteValue + } + + public eval(buffer: Buffer): void { + buffer.noteValue = this.noteValue + } +} diff --git a/src/command/Loop.ts b/src/command/Loop.ts new file mode 100644 index 0000000..33f6220 --- /dev/null +++ b/src/command/Loop.ts @@ -0,0 +1,100 @@ +import type {ICommand} from './ICommand' +import {Commands} from './Commands' + +import type {Buffer} from '@/evaluate/Buffer' +import type {Characters} from '@/parse/Characters' + +export class Loop implements ICommand { + private readonly count: number + private readonly former: Commands + private readonly latter: Commands | null + private readonly isDelimited: boolean + + private constructor( + count: number, + former: Commands, + latter: Commands | null, + isDelimited: boolean + ) { + this.count = count + this.former = former + this.latter = latter + this.isDelimited = isDelimited + } + + public static from(chars: Characters): Loop | null { + const first = chars.get() + if (first === null) { + return null + } + + // get ln and cn for me + const ln = first.ln + const cn = first.cn + + // "[" + const open = chars.eatChar(['[']) + if (open === null) { + return null + } + // commands + const former = new Commands(chars, true) + if (former.isEmpty()) { + throw new Error( + `[syntax error] The loop command must contain at least one command: ${ln} line, ${cn} char.` + ) + } + // [ ":" + const colonChar = chars.get() + const colon = chars.eatChar([':']) + const isDelimited = colon !== null + // commands ] + const latter = isDelimited ? new Commands(chars, true) : null + if (latter !== null && latter.isEmpty()) { + throw new Error( + `[syntax error] No command is found after ':': ${colonChar!.ln} line, ${colonChar!.cn} char.` + ) + } + // ']' + const closeChar = chars.get() + const close = chars.eatChar([']']) + if (closeChar === null || close === null) { + throw new Error(`[syntax error] The loop command is not closed: ${ln} line, ${cn} char.`) + } + // natural-number + const count = chars.eatNatural(closeChar.ln) + if (count === null) { + throw new Error( + `[syntax error] The number of loop iterations is not specified after the ']': ${closeChar.ln} line, ${closeChar.cn} char.` + ) + } + + return new Loop(count, former, latter, isDelimited) + } + + public getCount(): number { + return this.count + } + + public getFormer(): Commands { + return this.former + } + + public getLatter(): Commands | null { + return this.latter + } + + public getIsDelimited(): boolean { + return this.isDelimited + } + + public eval(buffer: Buffer): void { + for (let i = 0; i < this.count; ++i) { + this.former.eval(buffer) + if (this.isDelimited && i === this.count - 1) { + break + } + this.latter?.eval(buffer) + } + } +} diff --git a/src/command/Macro.ts b/src/command/Macro.ts new file mode 100644 index 0000000..9860182 --- /dev/null +++ b/src/command/Macro.ts @@ -0,0 +1,60 @@ +import type {ICommand} from './ICommand' + +import type {Buffer} from '@/evaluate/Buffer' +import type {Characters} from '@/parse/Characters' + +export class Macro implements ICommand { + private readonly ln: number + private readonly cn: number + private readonly name: string + + private constructor(ln: number, cn: number, name: string) { + this.ln = ln + this.cn = cn + this.name = name + } + + public static from(chars: Characters): Macro | null { + const first = chars.get() + if (first === null) { + return null + } + + // get ln and cn for me + const ln = first.ln + const cn = first.cn + + // 'l' + const x = chars.eatChar(['!']) + if (x === null) { + return null + } + // (IDENTIFER) + const name = chars.eatIdentifier(ln) + if (name === null) { + throw new Error(`[syntax error] The macro name is not found: ${ln} line, ${cn} char.`) + } + + return new Macro(ln, cn, name) + } + + public getLn(): number { + return this.ln + } + + public getCn(): number { + return this.cn + } + + public getName(): string { + return this.name + } + + public eval(buffer: Buffer): void { + const commandsOnDemand = buffer.macroDefs.get(this.name) + if (commandsOnDemand === null) { + throw new Error(`[syntax error] The macro is not defined: ${this.ln} line, ${this.cn} char.`) + } + commandsOnDemand.get().eval(buffer) + } +} diff --git a/src/command/Note.test.ts b/src/command/Note.test.ts new file mode 100644 index 0000000..77742ef --- /dev/null +++ b/src/command/Note.test.ts @@ -0,0 +1,135 @@ +import {describe, expect, test} from 'bun:test' +import {Note} from './Note' +import {Characters} from '../parse/Characters' + +describe('Note.from', () => { + test('When trying to eat out of range, it returns null and the current index.', () => { + const chars = new Characters([ + {c: 'a', ln: 1, cn: 1}, + {c: 'b', ln: 1, cn: 2}, + {c: 'c', ln: 1, cn: 3}, + ]) + chars.forward(3) + expect(Note.from(chars)).toStrictEqual(null) + expect(chars.get()).toStrictEqual(null) + }) + + test('When no note is found, it returns null and the current index.', () => { + const chars = new Characters([ + {c: 'a', ln: 1, cn: 1}, + {c: 'n', ln: 1, cn: 2}, + {c: 'c', ln: 1, cn: 3}, + ]) + chars.forward(1) + expect(Note.from(chars)).toStrictEqual(null) + expect(chars.get()).toStrictEqual({c: 'n', ln: 1, cn: 2}) + }) + + test('When a note whose value is 0 is found, it throws an error.', () => { + const chars = new Characters([ + {c: 'a', ln: 1, cn: 1}, + {c: '0', ln: 1, cn: 2}, + ]) + expect(() => Note.from(chars)).toThrow() + }) + + test('When "aa" is found, it eats "a".', () => { + const chars = new Characters([ + {c: 'a', ln: 1, cn: 1}, + {c: 'a', ln: 1, cn: 2}, + {c: 'a', ln: 1, cn: 3}, + ]) + chars.forward(1) + const note = Note.from(chars)! + expect(note.getPitch()).toStrictEqual('a') + expect(note.getAccidental()).toStrictEqual(null) + expect(note.getNoteValue()).toStrictEqual(null) + expect(note.isDotted()).toStrictEqual(false) + }) + + test('When "b+-" is found, it eats "b+".', () => { + const chars = new Characters([ + {c: 'b', ln: 1, cn: 1}, + {c: '+', ln: 1, cn: 2}, + {c: '-', ln: 1, cn: 3}, + ]) + const note = Note.from(chars)! + expect(note.getPitch()).toStrictEqual('b') + expect(note.getAccidental()).toStrictEqual('+') + expect(note.getNoteValue()).toStrictEqual(null) + expect(note.isDotted()).toStrictEqual(false) + }) + + test('When "c4+" is found, it eats "c4".', () => { + const chars = new Characters([ + {c: 'c', ln: 1, cn: 1}, + {c: '4', ln: 1, cn: 2}, + {c: '+', ln: 1, cn: 3}, + ]) + const note = Note.from(chars)! + expect(note.getPitch()).toStrictEqual('c') + expect(note.getAccidental()).toStrictEqual(null) + expect(note.getNoteValue()).toStrictEqual(4) + expect(note.isDotted()).toStrictEqual(false) + }) + + test('When "d-16=" is found, it eats "d-16".', () => { + const chars = new Characters([ + {c: 'd', ln: 1, cn: 1}, + {c: '-', ln: 1, cn: 2}, + {c: '1', ln: 1, cn: 3}, + {c: '6', ln: 1, cn: 4}, + {c: '=', ln: 1, cn: 5}, + ]) + const note = Note.from(chars)! + expect(note.getPitch()).toStrictEqual('d') + expect(note.getAccidental()).toStrictEqual('-') + expect(note.getNoteValue()).toStrictEqual(16) + expect(note.isDotted()).toStrictEqual(false) + }) + + test('When "e8.2" is found, it eats "e8.".', () => { + const chars = new Characters([ + {c: 'e', ln: 1, cn: 1}, + {c: '8', ln: 1, cn: 2}, + {c: '.', ln: 1, cn: 3}, + {c: '2', ln: 1, cn: 4}, + ]) + const note = Note.from(chars)! + expect(note.getPitch()).toStrictEqual('e') + expect(note.getAccidental()).toStrictEqual(null) + expect(note.getNoteValue()).toStrictEqual(8) + expect(note.isDotted()).toStrictEqual(true) + }) + + test('When "f=32.." is found, it eats "f=32.".', () => { + const chars = new Characters([ + {c: 'f', ln: 1, cn: 1}, + {c: '=', ln: 1, cn: 2}, + {c: '3', ln: 1, cn: 3}, + {c: '2', ln: 1, cn: 4}, + {c: '.', ln: 1, cn: 5}, + {c: '.', ln: 1, cn: 6}, + ]) + const note = Note.from(chars)! + expect(note.getPitch()).toStrictEqual('f') + expect(note.getAccidental()).toStrictEqual('=') + expect(note.getNoteValue()).toStrictEqual(32) + expect(note.isDotted()).toStrictEqual(true) + }) + + test('When a note spanning multiple lines is found, it returns the note found on the same line and the index of the next line character.', () => { + const chars = new Characters([ + {c: 'f', ln: 1, cn: 1}, + {c: '=', ln: 1, cn: 2}, + {c: '3', ln: 1, cn: 3}, + {c: '2', ln: 2, cn: 1}, + {c: '.', ln: 2, cn: 2}, + ]) + const note = Note.from(chars)! + expect(note.getPitch()).toStrictEqual('f') + expect(note.getAccidental()).toStrictEqual('=') + expect(note.getNoteValue()).toStrictEqual(3) + expect(note.isDotted()).toStrictEqual(false) + }) +}) diff --git a/src/command/Note.ts b/src/command/Note.ts new file mode 100644 index 0000000..88ff06a --- /dev/null +++ b/src/command/Note.ts @@ -0,0 +1,140 @@ +import type {ICommand} from './ICommand' +import { + ACCIDENTALS, + PER_SAMPLE_RATE, + PITCHES_WITH_REST, + SAMPLE_RATE, + type Accidental, + type Pitch, + type PitchWithRest, +} from '../constants' +import type {Buffer} from '../evaluate/Buffer' +import {Characters} from '../parse/Characters' + +const SEMITONE_STEP = 2 ** (1 / 12) + +function convertScaleToNumber(pitch: Pitch): number { + switch (pitch) { + case 'c': + return 0 + case 'd': + return 2 + case 'e': + return 4 + case 'f': + return 5 + case 'g': + return 7 + case 'a': + return 9 + case 'b': + return 11 + } +} + +function getFrequency(pitch: Pitch, accidental: Accidental | null, buffer: Buffer): number { + let n = convertScaleToNumber(pitch) + 12 * buffer.octave + switch (accidental) { + case '+': + n += 1 + break + case '-': + n -= 1 + break + case '=': + break + case null: + n += buffer.shift.get(pitch) ?? 0 + break + } + const d = n - 57 + return 440 * SEMITONE_STEP ** d +} + +export class Note implements ICommand { + private readonly pitch: PitchWithRest + private readonly accidental: Accidental | null + private readonly noteValue: number | null + private readonly dotted: boolean + + private constructor( + pitch: PitchWithRest, + accidental: Accidental | null, + noteValue: number | null, + dotted: boolean + ) { + this.pitch = pitch + this.accidental = accidental + this.noteValue = noteValue + this.dotted = dotted + } + + public static from(chars: Characters): Note | null { + const first = chars.get() + if (first === null) { + return null + } + + // get ln and cn for me + const ln = first.ln + const cn = first.cn + + // (PITCH_WITH_REST) + const pitch = chars.eatChar(PITCHES_WITH_REST) + if (pitch === null) { + return null + } + // (ACCIDENTAL) + const accidental = chars.eatChar(ACCIDENTALS, ln) + // (NATURAL) + const noteValue = chars.eatNatural(ln) + // ('.'|) + const dot = chars.eatChar(['.'], ln) + const dotted = dot !== null + + // check + if (noteValue !== null && noteValue <= 0) { + throw new Error(`[syntax error] Note value must be greater than 0: ${ln} line, ${cn} char.`) + } + + return new Note(pitch as PitchWithRest, accidental as Accidental, noteValue, dotted) + } + + public getPitch(): PitchWithRest { + return this.pitch + } + public getAccidental(): Accidental | null { + return this.accidental + } + public getNoteValue(): number | null { + return this.noteValue + } + public isDotted(): boolean { + return this.dotted + } + + public eval(buffer: Buffer): void { + const current = buffer.seek + const release = buffer.inst.getRelease() * SAMPLE_RATE + + const spb = 60 / buffer.bpm + const v = 4 / (this.noteValue ?? buffer.noteValue) + const d = this.dotted ? 1.5 : 1 + const length = Math.ceil(SAMPLE_RATE * spb * v * d) + buffer.seek += length + buffer.size = Math.max(buffer.seek + release, buffer.size) + + if (buffer.buffer === null) { + return + } + + if (this.pitch === 'r') { + return + } + + const frequency = getFrequency(this.pitch, this.accidental, buffer) + for (let i = current; i < current + length + release; ++i) { + buffer.buffer[i] += buffer.amplitude * buffer.inst.run(frequency, length, i - current) + } + } +} diff --git a/src/command/Octave.test.ts b/src/command/Octave.test.ts new file mode 100644 index 0000000..17b2e2b --- /dev/null +++ b/src/command/Octave.test.ts @@ -0,0 +1,82 @@ +import {describe, expect, test} from 'bun:test' +import {Octave} from './Octave' +import {Characters} from '../parse/Characters' + +describe('Octave.from', () => { + test('When trying to eat out of range, it returns null and the current index.', () => { + const chars = new Characters([ + {c: 'o', ln: 1, cn: 1}, + {c: '4', ln: 1, cn: 2}, + ]) + chars.forward(2) + expect(Octave.from(chars)).toStrictEqual(null) + expect(chars.get()).toStrictEqual(null) + }) + + test('When no octave is found, it returns null and the current index.', () => { + const chars = new Characters([ + {c: 'o', ln: 1, cn: 1}, + {c: 'b', ln: 1, cn: 2}, + {c: '4', ln: 1, cn: 3}, + ]) + chars.forward(1) + expect(Octave.from(chars)).toStrictEqual(null) + expect(chars.get()).toStrictEqual({c: 'b', ln: 1, cn: 2}) + }) + + test('When the octave number is not found on the same line, it throws an error.', () => { + const chars = new Characters([ + {c: 'o', ln: 1, cn: 1}, + {c: '4', ln: 2, cn: 1}, + ]) + expect(() => Octave.from(chars)).toThrow() + }) + + test('When the octave number is not found, it throws an error.', () => { + const chars = new Characters([ + {c: 'o', ln: 1, cn: 1}, + {c: 'a', ln: 1, cn: 2}, + ]) + expect(() => Octave.from(chars)).toThrow() + }) + + test('When "o4" is found, it returns Octave and the next index.', () => { + const chars = new Characters([ + {c: 'o', ln: 1, cn: 1}, + {c: '4', ln: 1, cn: 2}, + {c: 'a', ln: 1, cn: 3}, + ]) + const octave = Octave.from(chars)! + expect(octave.getCommand()).toStrictEqual(null) + expect(octave.getOctave()).toStrictEqual(4) + expect(chars.get()).toStrictEqual({c: 'a', ln: 1, cn: 3}) + }) + + test('When ">" is found, it returns Octave and the next index.', () => { + const chars = new Characters([ + {c: 'a', ln: 1, cn: 1}, + {c: '>', ln: 1, cn: 2}, + {c: 'o', ln: 1, cn: 3}, + {c: '4', ln: 1, cn: 4}, + ]) + chars.forward(1) + const octave = Octave.from(chars)! + expect(octave.getCommand()).toStrictEqual('>') + expect(octave.getOctave()).toStrictEqual(0) + expect(chars.get()).toStrictEqual({c: 'o', ln: 1, cn: 3}) + }) + + test('When "<" is found, it returns Octave and the next index.', () => { + const chars = new Characters([ + {c: 'a', ln: 1, cn: 1}, + {c: '<', ln: 1, cn: 2}, + {c: 'o', ln: 1, cn: 3}, + {c: '4', ln: 1, cn: 4}, + ]) + chars.forward(1) + const octave = Octave.from(chars)! + expect(octave.getCommand()).toStrictEqual('<') + expect(octave.getOctave()).toStrictEqual(0) + expect(chars.get()).toStrictEqual({c: 'o', ln: 1, cn: 3}) + }) +}) diff --git a/src/command/Octave.ts b/src/command/Octave.ts new file mode 100644 index 0000000..b7ba15f --- /dev/null +++ b/src/command/Octave.ts @@ -0,0 +1,67 @@ +import type {ICommand} from './ICommand' +import type {Buffer} from '../evaluate/Buffer' +import {Characters} from '../parse/Characters' +import {MAX_OCTAVE, MIN_OCTAVE} from '../constants' + +export type OctaveCommand = '<' | '>' | null + +export class Octave implements ICommand { + private readonly command: OctaveCommand + private readonly octave: number + + private constructor(command: OctaveCommand, octave: number) { + this.command = command + this.octave = octave + } + + public static from(chars: Characters): Octave | null { + const first = chars.get() + if (first === null) { + return null + } + + // get ln and cn for me + const ln = first.ln + const cn = first.cn + + // '<'|'>' + const command = chars.eatChar(['<', '>']) + if (command !== null) { + return new Octave(command as OctaveCommand, 0) + } + + // 'o' + const o = chars.eatChar(['o']) + if (o === null) { + return null + } + // (NATURAL) + const octave = chars.eatNatural(ln) + if (octave === null) { + throw new Error(`[syntax error] The octave number is not found: ${ln} line, ${cn} char.`) + } + + return new Octave(null, octave) + } + + public getCommand(): OctaveCommand | null { + return this.command + } + public getOctave(): number { + return this.octave + } + + public eval(buffer: Buffer): void { + switch (this.command) { + case '<': + buffer.octave = Math.max(Math.min(buffer.octave - 1, MAX_OCTAVE), MIN_OCTAVE) + break + case '>': + buffer.octave = Math.max(Math.min(buffer.octave + 1, MAX_OCTAVE), MIN_OCTAVE) + break + case null: + buffer.octave = Math.max(Math.min(this.octave, MAX_OCTAVE), MIN_OCTAVE) + break + } + } +} diff --git a/src/command/Tempo.test.ts b/src/command/Tempo.test.ts new file mode 100644 index 0000000..b21d31f --- /dev/null +++ b/src/command/Tempo.test.ts @@ -0,0 +1,103 @@ +import {describe, expect, test} from 'bun:test' +import {Tempo} from './Tempo' +import {Characters} from '../parse/Characters' + +describe('Tempo.from', () => { + test('When trying to eat out of range, it returns null and the current index.', () => { + const chars = new Characters([ + {c: 't', ln: 1, cn: 1}, + {c: '1', ln: 1, cn: 2}, + {c: '2', ln: 1, cn: 3}, + {c: '0', ln: 1, cn: 4}, + ]) + chars.forward(4) + expect(Tempo.from(chars)).toStrictEqual(null) + expect(chars.get()).toStrictEqual(null) + }) + + test('When no tempo is found, it returns null and the current index.', () => { + const chars = new Characters([ + {c: 't', ln: 1, cn: 1}, + {c: '1', ln: 1, cn: 2}, + {c: '2', ln: 1, cn: 3}, + {c: '0', ln: 1, cn: 4}, + ]) + chars.forward(1) + expect(Tempo.from(chars)).toStrictEqual(null) + expect(chars.get()).toStrictEqual({c: '1', ln: 1, cn: 2}) + }) + + test('When the tempo number is not found on the same line, it throws an error.', () => { + const chars = new Characters([ + {c: 't', ln: 1, cn: 1}, + {c: '+', ln: 1, cn: 2}, + {c: '1', ln: 2, cn: 1}, + {c: '2', ln: 2, cn: 2}, + {c: '0', ln: 2, cn: 3}, + ]) + expect(() => Tempo.from(chars)).toThrow() + }) + + test('When it is the tempo setting command and the number is smaller than 1, it throws an error.', () => { + const chars = new Characters([ + {c: 't', ln: 1, cn: 1}, + {c: '0', ln: 1, cn: 2}, + {c: '.', ln: 1, cn: 3}, + {c: '5', ln: 1, cn: 4}, + ]) + expect(() => Tempo.from(chars)).toThrow() + }) + + test('When it is the tempo setting command and the number is greater than 1000, it throws an error.', () => { + const chars = new Characters([ + {c: 't', ln: 1, cn: 1}, + {c: '1', ln: 1, cn: 2}, + {c: '0', ln: 1, cn: 3}, + {c: '0', ln: 1, cn: 4}, + {c: '0', ln: 1, cn: 5}, + {c: '.', ln: 1, cn: 6}, + {c: '5', ln: 1, cn: 7}, + ]) + expect(() => Tempo.from(chars)).toThrow() + }) + + test('When "t120" is found, it returns tempo and the next index.', () => { + const chars = new Characters([ + {c: 't', ln: 1, cn: 1}, + {c: '1', ln: 1, cn: 2}, + {c: '2', ln: 1, cn: 3}, + {c: '0', ln: 1, cn: 4}, + {c: 'a', ln: 1, cn: 5}, + ]) + const tempo = Tempo.from(chars)! + expect(tempo.getCommand()).toStrictEqual(null) + expect(tempo.getTempo()).toStrictEqual(120) + expect(chars.get()).toStrictEqual({c: 'a', ln: 1, cn: 5}) + }) + + test('When "t0.5+" is found, it returns tempo and the next index.', () => { + const chars = new Characters([ + {c: 't', ln: 1, cn: 1}, + {c: '0', ln: 1, cn: 2}, + {c: '.', ln: 1, cn: 3}, + {c: '5', ln: 1, cn: 4}, + {c: '+', ln: 1, cn: 5}, + ]) + const tempo = Tempo.from(chars)! + expect(tempo.getCommand()).toStrictEqual('+') + expect(tempo.getTempo()).toStrictEqual(0.5) + expect(chars.get()).toStrictEqual(null) + }) + + test('When "t2-" is found, it returns tempo and the next index.', () => { + const chars = new Characters([ + {c: 't', ln: 1, cn: 1}, + {c: '2', ln: 1, cn: 2}, + {c: '-', ln: 1, cn: 3}, + ]) + const tempo = Tempo.from(chars)! + expect(tempo.getCommand()).toStrictEqual('-') + expect(tempo.getTempo()).toStrictEqual(2) + expect(chars.get()).toStrictEqual(null) + }) +}) diff --git a/src/command/Tempo.ts b/src/command/Tempo.ts new file mode 100644 index 0000000..704be55 --- /dev/null +++ b/src/command/Tempo.ts @@ -0,0 +1,70 @@ +import type {ICommand} from './ICommand' +import type {Buffer} from '../evaluate/Buffer' +import {Characters} from '../parse/Characters' +import {MAX_BPM, MIN_BPM} from '../constants' + +export type TempoCommand = '+' | '-' | null + +export class Tempo implements ICommand { + private readonly command: TempoCommand + private readonly tempo: number + + private constructor(command: TempoCommand, tempo: number) { + this.command = command + this.tempo = tempo + } + + public static from(chars: Characters): Tempo | null { + const first = chars.get() + if (first === null) { + return null + } + + // get ln and cn for me + const ln = first.ln + const cn = first.cn + + // 't' + const t = chars.eatChar(['t']) + if (t === null) { + return null + } + // (NNFloat) + const tempo = chars.eatNNFloat(ln) + if (tempo === null) { + throw new Error(`[syntax error] The tempo number is not found: ${ln} line, ${cn} char.`) + } + // ('+'|'-'|) + const command = chars.eatChar(['+', '-'], ln) + + // check + if (command === null && (tempo < MIN_BPM || tempo > MAX_BPM)) { + throw new Error( + `[syntax error] The tempo number must be in [${MIN_BPM}, ${MAX_BPM}]: ${ln} line, ${cn} char.` + ) + } + + return new Tempo(command as TempoCommand, tempo) + } + + public getCommand(): TempoCommand | null { + return this.command + } + public getTempo(): number { + return this.tempo + } + + public eval(buffer: Buffer): void { + switch (this.command) { + case '+': + buffer.bpm = Math.max(Math.min(buffer.bpm + this.tempo, MAX_BPM), MIN_BPM) + break + case '-': + buffer.bpm = Math.max(Math.min(buffer.bpm - this.tempo, MAX_BPM), MIN_BPM) + break + case null: + buffer.bpm = Math.max(Math.min(this.tempo, MAX_BPM), MIN_BPM) + break + } + } +} diff --git a/src/command/Volume.test.ts b/src/command/Volume.test.ts new file mode 100644 index 0000000..6bfdba9 --- /dev/null +++ b/src/command/Volume.test.ts @@ -0,0 +1,100 @@ +import {describe, expect, test} from 'bun:test' +import {Volume} from './Volume' +import {Characters} from '../parse/Characters' + +describe('Volume.from', () => { + test('When trying to eat out of ranget returns null and the current index.', () => { + const chars = new Characters([ + {c: 'v', ln: 1, cn: 1}, + {c: '0', ln: 1, cn: 2}, + {c: '.', ln: 1, cn: 3}, + {c: '5', ln: 1, cn: 4}, + ]) + chars.forward(4) + expect(Volume.from(chars)).toStrictEqual(null) + expect(chars.get()).toStrictEqual(null) + }) + + test('When no volume is foundt returns null and the current index.', () => { + const chars = new Characters([ + {c: 'v', ln: 1, cn: 1}, + {c: '0', ln: 1, cn: 2}, + {c: '.', ln: 1, cn: 3}, + {c: '5', ln: 1, cn: 4}, + ]) + chars.forward(1) + expect(Volume.from(chars)).toStrictEqual(null) + expect(chars.get()).toStrictEqual({c: '0', ln: 1, cn: 2}) + }) + + test('When the volume number is not found on the same linet throws an error.', () => { + const chars = new Characters([ + {c: 'v', ln: 1, cn: 1}, + {c: '0', ln: 2, cn: 1}, + {c: '.', ln: 2, cn: 2}, + {c: '5', ln: 2, cn: 3}, + ]) + expect(() => Volume.from(chars)).toThrow() + }) + + test('When the volume number is greater than 1.0 throws an error. (1)', () => { + const chars = new Characters([ + {c: 'v', ln: 1, cn: 1}, + {c: '2', ln: 1, cn: 2}, + ]) + expect(() => Volume.from(chars)).toThrow() + }) + + test('When the volume number is greater than 1.0 throws an error. (2)', () => { + const chars = new Characters([ + {c: 'v', ln: 1, cn: 1}, + {c: '1', ln: 1, cn: 2}, + {c: '.', ln: 1, cn: 3}, + {c: '1', ln: 1, cn: 4}, + {c: '+', ln: 1, cn: 5}, + ]) + expect(() => Volume.from(chars)).toThrow() + }) + + test('When "v0.5" is found returns volume and the next index.', () => { + const chars = new Characters([ + {c: 'v', ln: 1, cn: 1}, + {c: '0', ln: 1, cn: 2}, + {c: '.', ln: 1, cn: 3}, + {c: '5', ln: 1, cn: 4}, + {c: 'a', ln: 1, cn: 5}, + ]) + const volume = Volume.from(chars)! + expect(volume.getCommand()).toStrictEqual(null) + expect(volume.getVolume()).toStrictEqual(0.5) + expect(chars.get()).toStrictEqual({c: 'a', ln: 1, cn: 5}) + }) + + test('When "v0.2+" is foundt returns volume and the next index.', () => { + const chars = new Characters([ + {c: 'v', ln: 1, cn: 1}, + {c: '0', ln: 1, cn: 2}, + {c: '.', ln: 1, cn: 3}, + {c: '2', ln: 1, cn: 4}, + {c: '+', ln: 1, cn: 5}, + ]) + const volume = Volume.from(chars)! + expect(volume.getCommand()).toStrictEqual('+') + expect(volume.getVolume()).toStrictEqual(0.2) + expect(chars.get()).toStrictEqual(null) + }) + + test('When "v1.0-" is foundt returns volume and the next index.', () => { + const chars = new Characters([ + {c: 'v', ln: 1, cn: 1}, + {c: '1', ln: 1, cn: 2}, + {c: '.', ln: 1, cn: 3}, + {c: '0', ln: 1, cn: 4}, + {c: '-', ln: 1, cn: 5}, + ]) + const volume = Volume.from(chars)! + expect(volume.getCommand()).toStrictEqual('-') + expect(volume.getVolume()).toStrictEqual(1.0) + expect(chars.get()).toStrictEqual(null) + }) +}) diff --git a/src/command/Volume.ts b/src/command/Volume.ts new file mode 100644 index 0000000..a6635ea --- /dev/null +++ b/src/command/Volume.ts @@ -0,0 +1,76 @@ +import type {ICommand} from './ICommand' +import type {Buffer} from '../evaluate/Buffer' +import {Characters} from '../parse/Characters' +import {MAX_AMPLITUDE, MIN_AMPLITUDE} from '../constants' + +export type VolumeCommand = '+' | '-' | null + +export class Volume implements ICommand { + private readonly command: VolumeCommand + private readonly volume: number + + private constructor(command: VolumeCommand, volume: number) { + this.command = command + this.volume = volume + } + + public static from(chars: Characters): Volume | null { + const first = chars.get() + if (first === null) { + return null + } + + // get ln and cn for me + const ln = first.ln + const cn = first.cn + + // 'v' + const t = chars.eatChar(['v']) + if (t === null) { + return null + } + // (NNFloat) + const volume = chars.eatNNFloat(ln) + if (volume === null) { + throw new Error(`[syntax error] The volume number is not found: ${ln} line, ${cn} char.`) + } + // ('+'|'-'|) + const command = chars.eatChar(['+', '-'], ln) + + // check + if (volume < MIN_AMPLITUDE || volume > MAX_AMPLITUDE) { + throw new Error( + `[syntax error] The volume number must be in [${MIN_AMPLITUDE}, ${MAX_AMPLITUDE}]: ${ln} line, ${cn} char.` + ) + } + + return new Volume(command as VolumeCommand, volume) + } + + public getCommand(): VolumeCommand | null { + return this.command + } + public getVolume(): number { + return this.volume + } + + public eval(buffer: Buffer): void { + switch (this.command) { + case '+': + buffer.amplitude = Math.max( + Math.min(buffer.amplitude + this.volume, MAX_AMPLITUDE), + MIN_AMPLITUDE + ) + break + case '-': + buffer.amplitude = Math.max( + Math.min(buffer.amplitude - this.volume, MAX_AMPLITUDE), + MIN_AMPLITUDE + ) + break + case null: + buffer.amplitude = Math.max(Math.min(this.volume, MAX_AMPLITUDE), MIN_AMPLITUDE) + break + } + } +} diff --git a/src/component/Codearea.ts b/src/component/Codearea.ts new file mode 100644 index 0000000..67f0973 --- /dev/null +++ b/src/component/Codearea.ts @@ -0,0 +1,54 @@ +import {getElementById} from './getElement' + +/** + * A class for a IAM.mml codearea. + * + * The textarea for line numbers must be readonly. + * It make the scroll of the textarea for line numbers be synchronized with that of the main textarea. + */ +export class Codearea { + private readonly ta: HTMLTextAreaElement + private readonly taNumbers: HTMLTextAreaElement + private isReaded: boolean + + public constructor(taId: string, taNumbersId: string) { + this.ta = getElementById(taId) + this.taNumbers = getElementById(taNumbersId) + this.isReaded = false + + this.taNumbers.value = '1\n' + + this.ta.addEventListener('input', () => this.update()) + this.ta.addEventListener('scroll', () => (this.taNumbers.scrollTop = this.ta.scrollTop)) + } + + private update() { + const linesCount = this.ta.value.split('\n').length + let taNumbersValue = '' + for (let i = 1; i < linesCount + 1; ++i) { + taNumbersValue += i + '\n' + } + this.taNumbers.value = taNumbersValue + this.taNumbers.scrollTop = this.ta.scrollTop + this.isReaded = false + } + + public get(): string { + this.isReaded = true + return this.ta.value + } + + public getRaw(): string { + return this.ta.value + } + + public getIsReaded(): boolean { + return this.isReaded + } + + public set(value: string) { + this.isReaded = false + this.ta.value = value + this.update() + } +} diff --git a/src/component/Resizer.ts b/src/component/Resizer.ts new file mode 100644 index 0000000..28b400c --- /dev/null +++ b/src/component/Resizer.ts @@ -0,0 +1,29 @@ +import {getElementById} from './getElement' + +/** + * A class for a resizer. + */ +export class Resizer { + private readonly divResizer: HTMLDivElement + private readonly divTarget: HTMLDivElement + private readonly isLeftToShrink: boolean + private start: number + + public constructor(divResizerId: string, divTargetId: string, isLeftToShrink: boolean) { + this.divResizer = getElementById(divResizerId) + this.divTarget = getElementById(divTargetId) + this.isLeftToShrink = isLeftToShrink + this.start = 0 + + const onMouseUp = (event: MouseEvent) => { + const k = this.isLeftToShrink ? 1 : -1 + this.divTarget.style.width = k * (event.x - this.start) + this.divTarget.clientWidth + 'px' + document.removeEventListener('mouseup', onMouseUp) + } + const onMouseDown = (event: MouseEvent) => { + this.start = event.x + document.addEventListener('mouseup', onMouseUp) + } + this.divResizer.addEventListener('mousedown', onMouseDown) + } +} diff --git a/src/component/Sidebar.ts b/src/component/Sidebar.ts new file mode 100644 index 0000000..ed51280 --- /dev/null +++ b/src/component/Sidebar.ts @@ -0,0 +1,66 @@ +import {getElementById} from './getElement' + +/** + * A class for the sidebar component. + * + * It gets the div components which have the following ids: + * - blinder + * - sidebar + * - sidebar-button + * - sidebar-button-icon + * - log + * + * It switches the icon of sidebar-button-icon by switching its classname: + * - left-triangle + * - right-triangle + */ +export class Sidebar { + private readonly divBlinder: HTMLDivElement + private readonly divSidebar: HTMLDivElement + private readonly divSidebarButtonIcon: HTMLDivElement + private readonly taLog: HTMLTextAreaElement + private isOpened: boolean + + public constructor() { + this.divBlinder = getElementById('blinder') + this.divSidebar = getElementById('sidebar') + this.divSidebarButtonIcon = getElementById('sidebar-button-icon') + this.taLog = getElementById('log') + this.isOpened = false + + this.divBlinder.addEventListener('click', () => this.close()) + + const divSidebarButton = getElementById('sidebar-button') + divSidebarButton.addEventListener('click', () => (this.isOpened ? this.close() : this.open())) + } + + private open() { + this.divBlinder.style.display = 'block' + this.divSidebar.style.display = 'flex' + this.divSidebarButtonIcon.classList.remove('left-triangle') + this.divSidebarButtonIcon.classList.add('right-triangle') + this.isOpened = true + } + + private close() { + this.divBlinder.style.display = 'none' + this.divSidebar.style.display = 'none' + this.divSidebarButtonIcon.classList.remove('right-triangle') + this.divSidebarButtonIcon.classList.add('left-triangle') + this.isOpened = false + } + + public log(message: string) { + const hhmmss = (date: Date): string => + ('0' + date.getHours()).slice(-2) + + ':' + + ('0' + date.getMinutes()).slice(-2) + + ':' + + ('0' + date.getSeconds()).slice(-2) + this.taLog.value += '(' + this.taLog.value += hhmmss(new Date()) + this.taLog.value += ') ' + this.taLog.value += message + this.open() + } +} diff --git a/src/component/getElement.ts b/src/component/getElement.ts new file mode 100644 index 0000000..3367192 --- /dev/null +++ b/src/component/getElement.ts @@ -0,0 +1,3 @@ +export function getElementById(id: string): T { + return document.getElementById(id)! as T +} diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..78a2621 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,19 @@ +export const SAMPLE_RATE = 44100 +export const PER_SAMPLE_RATE = 1 / SAMPLE_RATE + +export const MIN_BPM = 1.0 +export const MAX_BPM = 1000.0 + +export const MIN_AMPLITUDE = 0.0 +export const MAX_AMPLITUDE = 1.0 + +export const MIN_OCTAVE = 1 +export const MAX_OCTAVE = 8 + +export const PITCHES = ['a', 'b', 'c', 'd', 'e', 'f', 'g'] as const +export const PITCHES_WITH_REST = [...PITCHES, 'r'] as const +export type Pitch = (typeof PITCHES)[number] +export type PitchWithRest = (typeof PITCHES_WITH_REST)[number] + +export const ACCIDENTALS = ['+', '-', '='] as const +export type Accidental = (typeof ACCIDENTALS)[number] diff --git a/src/evaluate/Buffer.ts b/src/evaluate/Buffer.ts new file mode 100644 index 0000000..48684a9 --- /dev/null +++ b/src/evaluate/Buffer.ts @@ -0,0 +1,49 @@ +import type {Pitch} from '@/constants' +import type {MacroDefs} from '@/parse/MacroDefs' +import type {InstDefs} from '@/parse/InstDefs' +import {Inst} from '@/inst/Inst' +import {Characters} from '@/parse/Characters' +import {convertStringToCharacters} from '@/parse/Character' + +// TODO: document +export type Buffer = { + // output source + seek: number + size: number + buffer: Float32Array | null + + // + amplitude: number + octave: number + bpm: number + noteValue: number + shift: Map + inst: Inst + + // other definitions + macroDefs: MacroDefs + instDefs: InstDefs +} + +export function createBuffer( + buffer: Float32Array | null, + macroDefs: MacroDefs, + instDefs: InstDefs +): Buffer { + return { + // output source + seek: 0, + size: 0, + buffer: buffer, + // + amplitude: 0.5, + octave: 4, + bpm: 120, + noteValue: 4, + shift: new Map(), + inst: new Inst(new Characters(convertStringToCharacters('1 1 0 0 1 0', 0, 0))), + // other definitions + macroDefs: macroDefs, + instDefs: instDefs, + } +} diff --git a/src/evaluate/Evaluator.ts b/src/evaluate/Evaluator.ts new file mode 100644 index 0000000..d127d3c --- /dev/null +++ b/src/evaluate/Evaluator.ts @@ -0,0 +1,17 @@ +import {createBuffer} from './Buffer' + +import type {Commands} from '@/command/Commands' +import type {MacroDefs} from '@/parse/MacroDefs' +import type {InstDefs} from '@/parse/InstDefs' + +export class Evaluator { + private constructor() {} + + public static eval(commands: Commands, macroDefs: MacroDefs, instDefs: InstDefs): Float32Array { + const preBuffer = createBuffer(null, macroDefs, instDefs) + commands.eval(preBuffer) + const buffer = createBuffer(new Float32Array(preBuffer.size), macroDefs, instDefs) + commands.eval(buffer) + return buffer.buffer! + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..e58a78b --- /dev/null +++ b/src/index.ts @@ -0,0 +1,105 @@ +import {App} from './App' +import {Codearea} from './component/Codearea' +import {Resizer} from './component/Resizer' +import {Sidebar} from './component/Sidebar' +import {getElementById} from './component/getElement' + +document.addEventListener('DOMContentLoaded', () => { + // create an app + const app = new App() + + // components + const sidebar = new Sidebar() + const caMML = new Codearea('mml', 'mml-numbers') + const caInst = new Codearea('inst', 'inst-numbers') + const _sidebarResizer = new Resizer('sidebar-resizer', 'sidebar-wrapper', false) + const _mmlResizer = new Resizer('textarea-resizer', 'mml-wrapper', true) + + // add an event listener to the play button + const btnPlay = getElementById('play') + btnPlay.addEventListener('click', () => { + try { + if (!caMML.getIsReaded() || !caInst.getIsReaded()) { + app.prepare(caMML.get(), caInst.get()) + } + app.play() + } catch (err: unknown) { + if (err instanceof Error) { + sidebar.log(err.message + '\n') + } else { + sidebar.log(err + '\n\n') + } + } + }) + + // add an event listener to the pause button + const btnPause = getElementById('pause') + btnPause.addEventListener('click', () => app.pause()) + + // add an event listener to the stop button + const btnStop = getElementById('stop') + btnStop.addEventListener('click', () => app.stop()) + + // add an event listener to the import button + const btnImport = getElementById('import') + btnImport.addEventListener('click', () => { + const input = document.createElement('input') + input.type = 'file' + input.accept = 'text/*' + input.onchange = () => { + if (input.files === null || input.files.length === 0) { + return + } + const fileReader = new FileReader() + fileReader.onload = () => { + if (fileReader.result === null) { + sidebar.log(`[io error] Failed to read the imported file.`) + return + } + const result = fileReader.result.toString() + const index = result.indexOf('\n%%\n') + if (index < 0) { + sidebar.log(`[io error] Invalid MML is imported.`) + return + } + const inst = result.slice(0, index) + const mml = result.slice(index + 4) + caInst.set(inst) + caMML.set(mml) + } + fileReader.readAsText(input.files[0]) + } + input.click() + }) + + // add an event listener to the export button + const btnExport = getElementById('export') + btnExport.addEventListener('click', () => { + const text = caInst.getRaw() + '\n\n%%\n' + caMML.getRaw() + '\n' + const a = document.createElement('a') + a.href = 'data:text/plain;charset=utf-8,' + encodeURIComponent(text) + a.download = 'music.mml' + a.click() + }) + + // build button + const btnBuild = getElementById('build') + btnBuild.addEventListener('click', () => { + try { + if (!caMML.getIsReaded() || !caInst.getIsReaded()) { + app.prepare(caMML.get(), caInst.get()) + } + app.build() + } catch (err: unknown) { + if (err instanceof Error) { + sidebar.log(err.message + '\n') + } else { + sidebar.log(err + '\n\n') + } + } + }) + + // info button + const btnInfo = getElementById('info') + btnInfo.addEventListener('click', () => window.open('./docs/jp/about')) +}) diff --git a/src/inst/Inst.ts b/src/inst/Inst.ts new file mode 100644 index 0000000..9d52184 --- /dev/null +++ b/src/inst/Inst.ts @@ -0,0 +1,21 @@ +import {Operators} from './Operators' + +import type {Characters} from '@/parse/Characters' + +export class Inst { + private readonly carrier: Operators + private readonly release: number + + public constructor(chars: Characters) { + this.carrier = new Operators(chars, 1) + this.release = this.carrier.getRelease() + } + + public getRelease(): number { + return this.release + } + + public run(f: number, g: number, t: number): number { + return this.carrier.run(f, g, t) + } +} diff --git a/src/inst/InstOnDemand.ts b/src/inst/InstOnDemand.ts new file mode 100644 index 0000000..3d7d232 --- /dev/null +++ b/src/inst/InstOnDemand.ts @@ -0,0 +1,21 @@ +import {Inst} from './Inst' + +import {Characters} from '@/parse/Characters' +import type {Character} from '@/parse/Character' + +export class InstOnDemand { + private readonly chars: readonly Character[] + private inst: Inst | null + + public constructor(chars: readonly Character[]) { + this.chars = chars + this.inst = null + } + + public get(): Inst { + if (this.inst === null) { + this.inst = new Inst(new Characters(this.chars)) + } + return this.inst + } +} diff --git a/src/inst/Operator.ts b/src/inst/Operator.ts new file mode 100644 index 0000000..6ed099d --- /dev/null +++ b/src/inst/Operator.ts @@ -0,0 +1,107 @@ +import {Operators} from './Operators' + +import {PER_SAMPLE_RATE} from '@/constants' +import type {Characters} from '@/parse/Characters' + +export class Operator { + private readonly v: number + private readonly f: number + private readonly a: number + private readonly d: number + private readonly s: number + private readonly r: number + private readonly fbl: number + private readonly modulator: Operators | null + + public constructor(chars: Characters, indent: number) { + // get the line number for an error message + const first = chars.get() + if (first === null) { + throw new Error(`[unexpected error] Tried to an inexistent operator.`) + } + const ln = first.ln + + // check an unexpected error + if (first.cn !== indent) { + throw new Error( + `[unexpected error] The indent is expected ${indent} but found ${first.cn}: ${ln} line.` + ) + } + + // define an closure to check + const check = (s: string, n: number | null): number => { + if (n === null) { + throw new Error(`[syntax error] The operator parameter, ${s}, is not defined: ${ln} line.`) + } else { + return n + } + } + + // parse parameters + chars.eatSpaces() + this.v = check('volume', chars.eatNNFloat(ln)) + chars.eatSpaces() + this.f = check('frequency', chars.eatNNFloat(ln)) + chars.eatSpaces() + this.a = check('attack', chars.eatNNFloat(ln)) + chars.eatSpaces() + this.d = check('decay', chars.eatNNFloat(ln)) + chars.eatSpaces() + this.s = check('sustain', chars.eatNNFloat(ln)) + chars.eatSpaces() + this.r = check('release', chars.eatNNFloat(ln)) + chars.eatSpaces() + const fbl = chars.eatNNFloat(ln) + if (fbl !== null) { + this.fbl = fbl + } else { + this.fbl = 0 + } + + // set modulator null + this.modulator = null + + // check the next line + const next = chars.get() + if (next === null) { + return + } + if (next.ln === ln) { + throw new Error(`[syntax error] Unexpected token is found: ${next.ln} line, ${next.cn} char.`) + } + + // recurse + if (next.cn > indent) { + if (this.fbl > 0) { + throw new Error( + `[syntax error] The operator with feedback must be in the deepest indent: ${ln} line.` + ) + } + this.modulator = new Operators(chars, next.cn) + } + } + + public getRelease(): number { + return this.r + } + + public run(f: number, g: number, t: number): number { + const i = t * PER_SAMPLE_RATE + const j = g * PER_SAMPLE_RATE + const m = this.modulator?.run(f, g, t) ?? 0 + let result = 0 + for (let fblc = 0; fblc < this.fbl + 1; ++fblc) { + const base = this.v * Math.sin(2 * Math.PI * f * this.f * i + m + result) + if (i < this.a) { + result = (i / this.a) * base + } else if (i < j && i < this.a + this.d) { + result = (this.s + (1 - this.s) * (1 - (i - this.a) / this.d)) * base + } else if (i < j) { + result = this.s * base + } else if (i < j + this.r) { + result = this.s * (1 - (i - j) / this.r) * base + } + } + return result + } +} diff --git a/src/inst/Operators.ts b/src/inst/Operators.ts new file mode 100644 index 0000000..01dcc16 --- /dev/null +++ b/src/inst/Operators.ts @@ -0,0 +1,54 @@ +import {Operator} from './Operator' + +import type {Characters} from '@/parse/Characters' + +export class Operators { + private readonly operators: Operator[] + + public constructor(chars: Characters, indent: number) { + // get the line number for an error message + const first = chars.get() + if (first === null) { + throw new Error(`[unexpected error] No operator found.`) + } + const ln = first.ln + + // parse operators + const operators = [] + while (true) { + if ((chars.get()?.cn ?? -1) !== indent) { + break + } + operators.push(new Operator(chars, indent)) + } + + // check an error + if (operators.length === 0) { + throw new Error( + `[syntax error] No operator is found at indentation level ${indent}: ${ln} line.` + ) + } + + // finish + this.operators = operators + } + + public getRelease(): number { + let maxRelease = 0 + for (const op of this.operators) { + const release = op.getRelease() + if (release > maxRelease) { + maxRelease = release + } + } + return maxRelease + } + + public run(f: number, g: number, t: number): number { + let p = 0 + for (const op of this.operators) { + p += op.run(f, g, t) + } + return p + } +} diff --git a/src/parse/Character.ts b/src/parse/Character.ts new file mode 100644 index 0000000..647c367 --- /dev/null +++ b/src/parse/Character.ts @@ -0,0 +1,39 @@ +export type Character = { + c: string + ln: number + cn: number +} + +/** + * A function to convert a string to a Character array. + * The line and column numbers start from 1. + * + * @param line The entire string of one line + * @param ln Line number starting from 0 + * @param cn Column number starting from 0 + * @returns a converted character array. + */ +export function convertStringToCharacters(line: string, ln: number, cn: number): Character[] { + const chars = [] + for (; cn < line.length; ++cn) { + chars.push({c: line[cn], ln: ln + 1, cn: cn + 1}) + } + return chars +} + +/** + * A function to check if the contents of two given Character arrays are the same. + * + * @param a + * @param b + * @returns true if both are the same. + */ +export function checkCharsSame(a: readonly Character[], b: readonly Character[]): boolean { + if (a.length !== b.length) { + return false + } + if (JSON.stringify(a) !== JSON.stringify(b)) { + return false + } + return true +} diff --git a/src/parse/Characters.test.ts b/src/parse/Characters.test.ts new file mode 100644 index 0000000..306ede4 --- /dev/null +++ b/src/parse/Characters.test.ts @@ -0,0 +1,277 @@ +import {describe, expect, test} from 'bun:test' +import {Characters} from './Characters' + +describe('eatChar', () => { + test('When trying to eat out of range, it returns null and the current index.', () => { + const chars = new Characters([ + {c: 'a', ln: 1, cn: 1}, + {c: 'b', ln: 1, cn: 2}, + {c: 'c', ln: 1, cn: 3}, + ]) + chars.forward(3) + const matches = ['a', 'b', 'c'] + const expected = null + const expected2 = null + expect(chars.eatChar(matches)).toStrictEqual(expected) + expect(chars.get()).toStrictEqual(expected2) + }) + + test('When no match is found, it returns null and the current index.', () => { + const chars = new Characters([ + {c: 'a', ln: 1, cn: 1}, + {c: 'b', ln: 1, cn: 2}, + {c: 'c', ln: 1, cn: 3}, + ]) + chars.forward(1) + const matches = ['a', 'c', 'd'] + const expected = null + const expected2 = {c: 'b', ln: 1, cn: 2} + expect(chars.eatChar(matches)).toStrictEqual(expected) + expect(chars.get()).toStrictEqual(expected2) + }) + + test('When a match is found, it returns the matched character and the index of the next character.', () => { + const chars = new Characters([ + {c: 'a', ln: 1, cn: 1}, + {c: 'b', ln: 1, cn: 2}, + {c: 'c', ln: 1, cn: 3}, + ]) + chars.forward(1) + const matches = ['a', 'b', 'c'] + const expected = 'b' + const expected2 = {c: 'c', ln: 1, cn: 3} + expect(chars.eatChar(matches)).toStrictEqual(expected) + expect(chars.get()).toStrictEqual(expected2) + }) +}) + +describe('eatSpaces', () => { + test('When trying to eat out of range, it returns the current index.', () => { + const chars = new Characters([ + {c: 'a', ln: 1, cn: 1}, + {c: ' ', ln: 1, cn: 2}, + {c: 'c', ln: 1, cn: 3}, + ]) + chars.forward(3) + chars.eatSpaces() + const expected = null + expect(chars.get()).toStrictEqual(expected) + }) + + test('When no space or tab is found, it returns the current index.', () => { + const chars = new Characters([ + {c: 'a', ln: 1, cn: 1}, + {c: 'b', ln: 1, cn: 2}, + {c: 'c', ln: 1, cn: 3}, + ]) + chars.forward(1) + chars.eatSpaces() + const expected = {c: 'b', ln: 1, cn: 2} + expect(chars.get()).toStrictEqual(expected) + }) + + test('When spaces or tabs are found, it returns the index of the next non-space or non-tab character.', () => { + const chars = new Characters([ + {c: 'a', ln: 1, cn: 1}, + {c: ' ', ln: 1, cn: 2}, + {c: '\t', ln: 1, cn: 3}, + {c: ' ', ln: 2, cn: 1}, + {c: ' ', ln: 2, cn: 2}, + {c: 'c', ln: 2, cn: 3}, + ]) + chars.forward(1) + chars.eatSpaces() + const expected = {c: 'c', ln: 2, cn: 3} + expect(chars.get()).toStrictEqual(expected) + }) +}) + +describe('eatNatural', () => { + test('When trying to eat out of range, it returns null and the current index.', () => { + const chars = new Characters([ + {c: '0', ln: 1, cn: 1}, + {c: '1', ln: 1, cn: 2}, + {c: '2', ln: 1, cn: 3}, + ]) + chars.forward(3) + const expected = null + const expected2 = null + expect(chars.eatNatural()).toStrictEqual(expected) + expect(chars.get()).toStrictEqual(expected2) + }) + + test('When no integer is found, it returns null and the current index.', () => { + const chars = new Characters([ + {c: '0', ln: 1, cn: 1}, + {c: 'a', ln: 1, cn: 2}, + {c: '1', ln: 1, cn: 3}, + {c: '2', ln: 1, cn: 4}, + ]) + chars.forward(1) + const expected = null + const expected2 = {c: 'a', ln: 1, cn: 2} + expect(chars.eatNatural()).toStrictEqual(expected) + expect(chars.get()).toStrictEqual(expected2) + }) + + test('When an integer is found, it returns the integer and the index of the next non-digit character.', () => { + const chars = new Characters([ + {c: 'a', ln: 1, cn: 1}, + {c: '0', ln: 1, cn: 2}, + {c: '1', ln: 1, cn: 3}, + {c: '2', ln: 1, cn: 4}, + {c: '.', ln: 1, cn: 5}, + {c: '3', ln: 1, cn: 6}, + ]) + chars.forward(1) + const expected = 12 + const expected2 = {c: '.', ln: 1, cn: 5} + expect(chars.eatNatural()).toStrictEqual(expected) + expect(chars.get()).toStrictEqual(expected2) + }) + + test('When an integer spanning multiple lines is found, it returns the integer found on the same line and the index of the next non-digit character.', () => { + const chars = new Characters([ + {c: 'a', ln: 1, cn: 1}, + {c: '0', ln: 1, cn: 2}, + {c: '1', ln: 1, cn: 3}, + {c: '2', ln: 2, cn: 1}, + {c: '.', ln: 2, cn: 2}, + {c: '3', ln: 2, cn: 3}, + ]) + chars.forward(1) + const expected = 1 + const expected2 = {c: '2', ln: 2, cn: 1} + expect(chars.eatNatural()).toStrictEqual(expected) + expect(chars.get()).toStrictEqual(expected2) + }) +}) + +describe('eatNNFloat', () => { + test('When trying to eat out of range, it returns null and the current index.', () => { + const chars = new Characters([ + {c: '0', ln: 1, cn: 1}, + {c: '1', ln: 1, cn: 2}, + {c: '.', ln: 1, cn: 3}, + {c: '2', ln: 1, cn: 4}, + ]) + chars.forward(4) + const expected = null + const expected2 = null + expect(chars.eatNNFloat()).toStrictEqual(expected) + expect(chars.get()).toStrictEqual(expected2) + }) + + test('When no number is found, it returns null and the current index.', () => { + const chars = new Characters([ + {c: '0', ln: 1, cn: 1}, + {c: 'a', ln: 1, cn: 2}, + {c: '1', ln: 1, cn: 3}, + {c: '2', ln: 1, cn: 4}, + ]) + chars.forward(1) + const expected = null + const expected2 = {c: 'a', ln: 1, cn: 2} + expect(chars.eatNNFloat()).toStrictEqual(expected) + expect(chars.get()).toStrictEqual(expected2) + }) + + test('When "12" is found, it returns "12" and the index of the next non-digit character.', () => { + const chars = new Characters([ + {c: '1', ln: 1, cn: 1}, + {c: '2', ln: 1, cn: 2}, + {c: '.', ln: 2, cn: 1}, + {c: '5', ln: 2, cn: 2}, + ]) + const expected = 12 + const expected2 = {c: '.', ln: 2, cn: 1} + expect(chars.eatNNFloat()).toStrictEqual(expected) + expect(chars.get()).toStrictEqual(expected2) + }) + + test('When "012.3b" is found, it returns "12.3" and the index of the next non-digit character.', () => { + const chars = new Characters([ + {c: 'a', ln: 1, cn: 1}, + {c: '0', ln: 1, cn: 2}, + {c: '1', ln: 1, cn: 3}, + {c: '2', ln: 1, cn: 4}, + {c: '.', ln: 1, cn: 5}, + {c: '3', ln: 1, cn: 6}, + {c: 'b', ln: 1, cn: 7}, + ]) + chars.forward(1) + const expected = 12.3 + const expected2 = {c: 'b', ln: 1, cn: 7} + expect(chars.eatNNFloat()).toStrictEqual(expected) + expect(chars.get()).toStrictEqual(expected2) + }) + + test('When "01.\\n23" is found, it throws an error.', () => { + const chars = new Characters([ + {c: 'a', ln: 1, cn: 1}, + {c: '0', ln: 1, cn: 2}, + {c: '1', ln: 1, cn: 3}, + {c: '.', ln: 1, cn: 4}, + {c: '2', ln: 2, cn: 5}, + {c: '3', ln: 2, cn: 1}, + ]) + chars.forward(1) + expect(() => chars.eatNNFloat()).toThrow() + }) +}) + +describe('eatIdentifier', () => { + test('When trying to eat out of range, it returns null and the current index.', () => { + const chars = new Characters([ + {c: 'a', ln: 1, cn: 1}, + {c: '1', ln: 1, cn: 2}, + {c: ' ', ln: 1, cn: 3}, + {c: '.', ln: 1, cn: 4}, + ]) + chars.forward(4) + const expected = null + const expected2 = null + expect(chars.eatIdentifier()).toStrictEqual(expected) + expect(chars.get()).toStrictEqual(expected2) + }) + + test('When no identifier is found, it returns null and the current index.', () => { + const chars = new Characters([ + {c: 'a', ln: 1, cn: 1}, + {c: '1', ln: 1, cn: 2}, + {c: ' ', ln: 1, cn: 3}, + {c: '.', ln: 1, cn: 4}, + ]) + chars.forward(2) + const expected = null + const expected2 = {c: ' ', ln: 1, cn: 3} + expect(chars.eatIdentifier()).toStrictEqual(expected) + expect(chars.get()).toStrictEqual(expected2) + }) + + test('When "a1 ." is found, it returns "a1" and the index of the next character.', () => { + const chars = new Characters([ + {c: 'a', ln: 1, cn: 1}, + {c: '1', ln: 1, cn: 2}, + {c: ' ', ln: 1, cn: 3}, + {c: '.', ln: 1, cn: 4}, + ]) + const expected = 'a1' + const expected2 = {c: ' ', ln: 1, cn: 3} + expect(chars.eatIdentifier()).toStrictEqual(expected) + expect(chars.get()).toStrictEqual(expected2) + }) + + test('When "1.\\n23" is found, it returns "1." and the index of the next character.', () => { + const chars = new Characters([ + {c: '1', ln: 1, cn: 1}, + {c: '.', ln: 1, cn: 2}, + {c: '2', ln: 2, cn: 1}, + {c: '3', ln: 2, cn: 2}, + ]) + const expected = '1.' + const expected2 = {c: '2', ln: 2, cn: 1} + expect(chars.eatIdentifier()).toStrictEqual(expected) + expect(chars.get()).toStrictEqual(expected2) + }) +}) diff --git a/src/parse/Characters.ts b/src/parse/Characters.ts new file mode 100644 index 0000000..6e31938 --- /dev/null +++ b/src/parse/Characters.ts @@ -0,0 +1,138 @@ +import type {Character} from './Character' + +export class Characters { + private index: number + private readonly chars: readonly Character[] + + public constructor(chars: readonly Character[]) { + this.index = 0 + this.chars = chars + } + + public get(): Character | null { + if (this.index < this.chars.length) { + return this.chars[this.index] + } else { + return null + } + } + + public getAll(): readonly Character[] { + return this.chars + } + + public forward(count: number) { + this.index += count + } + + public back(count: number) { + this.index -= count + if (this.index < 0) { + this.index = 0 + } + } + + public eatSpaces(): void { + while (true) { + const space = this.eatChar([' ', '\t']) + if (space === null) { + break + } + } + } + + public eatChar(matches: readonly string[], ln?: number): string | null { + const c = this.get() + if (c !== null && matches.includes(c.c) && (ln === undefined || ln === c.ln)) { + this.forward(1) + return c.c + } else { + return null + } + } + + public eatNatural(ln?: number): number | null { + const first = this.get() + if (first === null) { + return null + } + + const fln = first.ln + if (ln !== undefined && fln !== ln) { + return null + } + + let buf = '' + while (true) { + const c = this.eatChar(['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'], fln) + if (c === null) { + break + } + buf += c + } + + if (buf.length > 0) { + return Number(buf) + } else { + return null + } + } + + public eatNNFloat(ln?: number): number | null { + const first = this.get() + if (first === null) { + return null + } + + const fln = first.ln + if (ln !== undefined && fln !== ln) { + return null + } + + const former = this.eatNatural() + if (former === null) { + return null + } + + const dot = this.eatChar(['.'], fln) + if (dot === null) { + return Number(former) + } + + const latter = this.eatNatural(fln) + if (latter === null) { + throw new Error( + `[syntax error] Invalid non-negative floating point number is found: ${first.ln} line, ${first.cn} eatChar.` + ) + } + + return Number('' + former + '.' + latter) + } + + public eatIdentifier(ln?: number): string | null { + const first = this.get() + if (first === null) { + return null + } + + const fln = first.ln + if (ln !== undefined && fln !== ln) { + return null + } + + let s = '' + while (true) { + const c = this.get() + if (c === null || c.ln !== fln || c.c === ' ' || c.c === '\t') { + break + } + s += c.c + this.forward(1) + } + if (s.length > 0) { + return s + } else { + return null + } + } +} diff --git a/src/parse/InstDefs.ts b/src/parse/InstDefs.ts new file mode 100644 index 0000000..7de193f --- /dev/null +++ b/src/parse/InstDefs.ts @@ -0,0 +1,43 @@ +import {checkCharsSame, type Character} from './Character' + +import {InstOnDemand} from '@/inst/InstOnDemand' + +export class InstDefs { + private readonly charsMap: Map + private readonly map: Map + + public constructor() { + this.charsMap = new Map() + this.map = new Map() + } + + public set(key: string, value: readonly Character[]): boolean { + if (this.map.has(key)) { + return false + } else { + this.charsMap.set(key, value) + this.map.set(key, new InstOnDemand(value)) + return true + } + } + + public get(key: string): InstOnDemand | null { + return this.map.get(key) ?? null + } + + public isSame(opponent: InstDefs): boolean { + if (this.charsMap.size !== opponent.charsMap.size) { + return false + } + for (const [key, value] of this.charsMap) { + const opponentValue = opponent.charsMap.get(key) + if (opponentValue === undefined) { + return false + } + if (!checkCharsSame(value, opponentValue)) { + return false + } + } + return true + } +} diff --git a/src/parse/MacroDefs.ts b/src/parse/MacroDefs.ts new file mode 100644 index 0000000..f0bed3a --- /dev/null +++ b/src/parse/MacroDefs.ts @@ -0,0 +1,43 @@ +import {checkCharsSame, type Character} from './Character' + +import {CommandsOnDemand} from '@/command/CommandsOnDemand' + +export class MacroDefs { + private readonly charsMap: Map + private readonly map: Map + + public constructor() { + this.charsMap = new Map() + this.map = new Map() + } + + public set(key: string, value: readonly Character[]): boolean { + if (this.map.has(key)) { + return false + } else { + this.charsMap.set(key, value) + this.map.set(key, new CommandsOnDemand(value)) + return true + } + } + + public get(key: string): CommandsOnDemand | null { + return this.map.get(key) ?? null + } + + public isSame(opponent: MacroDefs): boolean { + if (this.charsMap.size !== opponent.charsMap.size) { + return false + } + for (const [key, value] of this.charsMap) { + const opponentValue = opponent.charsMap.get(key) + if (opponentValue === undefined) { + return false + } + if (!checkCharsSame(value, opponentValue)) { + return false + } + } + return true + } +} diff --git a/src/parse/Parser.ts b/src/parse/Parser.ts new file mode 100644 index 0000000..b443e7f --- /dev/null +++ b/src/parse/Parser.ts @@ -0,0 +1,141 @@ +import {convertStringToCharacters, type Character} from './Character' +import {MacroDefs} from './MacroDefs' +import {PartDefs} from './PartDefs' +import {InstDefs} from './InstDefs' + +/** + * A class for functions that generate PartDefs, MacroDefs, and InstDefs + * from part definitions and instrument definitions. + */ +export class Parser { + private constructor() {} + + public static parse(mml: string, insts: string): [PartDefs, MacroDefs, InstDefs] { + const partDefs = new PartDefs() + const macroDefs = new MacroDefs() + const instDefs = new InstDefs() + + // part definition + + const mmlLines = mml + .replace(/\r\n|\r/g, '\n') + .replace(/;.*?\n/g, '\n') + .replace(/( |\t)+?\n/g, '\n') + .split('\n') + for (let ln = 0; ln < mmlLines.length; ++ln) { + const line = mmlLines[ln] + let cn = 0 + + // skip head spaces and tabs + for (; cn < line.length; ++cn) { + if (line[cn] !== ' ' && line[cn] !== '\t') { + break + } + } + + // is it a white spaces line? + if (cn >= line.length) { + continue + } + + // is it a macro definition line? + if (line[cn] === '!') { + let macroName = '' + for (++cn; cn < line.length; ++cn) { + if (line[cn] === ' ' || line[cn] === '\t') { + break + } + macroName += line[cn] + } + if (macroName.length === 0) { + throw new Error(`[syntax error] Macro line doesn't have name: ${ln} line.`) + } + const macroChars = convertStringToCharacters(line, ln, cn) + const setResult = macroDefs.set(macroName, macroChars) + if (!setResult) { + throw new Error( + `[syntax error] The macro '${macroName}' has been already defined: ${ln} line.` + ) + } + continue + } + + // is it a part definition line? + let partName = '' + for (; cn < line.length; ++cn) { + if (line[cn] === ' ' || line[cn] === '\t') { + break + } + partName += line[cn] + } + const partChars = convertStringToCharacters(line, ln, cn) + partDefs.set(partName, partChars) + } + + // instruments definitions + + const instsLines = insts + .replace(/\r\n|\r/g, '\n') + .replace(/;.*?\n/g, '\n') + .replace(/( |\t)+?\n/g, '\n') + .split('\n') + + let instLn = 1 + let instName = '' + let instChars: Character[] = [] + const set = () => { + if (instName.length === 0) { + return + } + if (instChars.length === 0) { + throw new Error( + `[syntax error] Every instrument must have at least one operator: ${instLn} line.` + ) + } + if (!instDefs.set(instName, instChars)) { + throw new Error( + `[syntax error] The instrument '${instName}' is already defined: ${instLn} line.` + ) + } + } + + for (let ln = 0; ln < instsLines.length; ++ln) { + const line = instsLines[ln] + let cn = 0 + + // skip a white spaces line + if (line.length === 0) { + continue + } + + // is it a instrument name definition line? + if (line[cn] === '@') { + // set + set() + // update + instLn = ln + 1 + instName = line.slice(cn) + instChars = [] + continue + } + + // skip head spaces and tabs + for (; cn < line.length; ++cn) { + if (line[cn] !== ' ' && line[cn] !== '\t') { + break + } + } + + // check a syntax error + if (instName.length === 0) { + throw new Error(`[syntax error] No instrument name has been defined: ${instLn} line.`) + } + + // push + instChars = instChars.concat(convertStringToCharacters(line, ln, cn)) + } + set() + + return [partDefs, macroDefs, instDefs] + } +} diff --git a/src/parse/PartDefs.ts b/src/parse/PartDefs.ts new file mode 100644 index 0000000..5876fef --- /dev/null +++ b/src/parse/PartDefs.ts @@ -0,0 +1,33 @@ +import type {Character} from './Character' + +export class PartDefs { + private map: Map + + public constructor() { + this.map = new Map() + } + + public set(key: string, value: readonly Character[]) { + if (this.map.has(key)) { + this.map.set(key, this.map.get(key)!.concat(value)) + } else { + this.map.set(key, value) + } + } + + public get(key: string): readonly Character[] | null { + return this.map.get(key) ?? null + } + + public iter(): IterableIterator<[string, readonly Character[]]> { + return this.map.entries() + } + + public len(): number { + return this.map.size + } + + public clear() { + this.map.clear() + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..5e69f35 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,32 @@ +{ + "compilerOptions": { + // Enable latest features + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false, + + // option + "paths": { + "@/*": ["./src/*"] + } + } +}