diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b24d76c323..29fd051e0b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -27,8 +27,8 @@ on: env: VOICEVOX_ENGINE_REPO_URL: "https://github.com/VOICEVOX/voicevox_engine" - VOICEVOX_ENGINE_VERSION: 0.16.0 - VOICEVOX_RESOURCE_VERSION: 0.16.0 + VOICEVOX_ENGINE_VERSION: 0.18.0 + VOICEVOX_RESOURCE_VERSION: 0.18.0 VOICEVOX_EDITOR_VERSION: |- # releaseタグ名か、workflow_dispatchでのバージョン名か、999.999.999-developが入る ${{ github.event.release.tag_name || github.event.inputs.version || '999.999.999-develop' }} @@ -316,7 +316,7 @@ jobs: rm $name.tar - name: Upload Linux tar.gz (without nvidia) to Artifacts - if: startsWith(matrix.artifact_name, 'linux-') && !contains(matrix.artifact_name, 'nvidia') && github.event.inputs.upload_artifact + if: startsWith(matrix.artifact_name, 'linux-') && !contains(matrix.artifact_name, 'nvidia') && github.event.inputs.upload_artifact == 'true' uses: actions/upload-artifact@v3 with: name: ${{ matrix.artifact_name }}-targz @@ -346,7 +346,7 @@ jobs: 7z rn $name.zip prepackage/ VOICEVOX/ - name: Upload Windows & Mac zip (without nvidia) to Artifacts - if: (startsWith(matrix.artifact_name, 'windows-') || startsWith(matrix.artifact_name, 'macos-')) && !contains(matrix.artifact_name, 'nvidia') && github.event.inputs.upload_artifact + if: (startsWith(matrix.artifact_name, 'windows-') || startsWith(matrix.artifact_name, 'macos-')) && !contains(matrix.artifact_name, 'nvidia') && github.event.inputs.upload_artifact == 'true' uses: actions/upload-artifact@v3 with: name: ${{ matrix.artifact_name }}-zip @@ -435,7 +435,7 @@ jobs: done - name: Upload Linux AppImage split to Artifacts - if: endsWith(matrix.installer_artifact_name, '-appimage') && github.event.inputs.upload_artifact + if: endsWith(matrix.installer_artifact_name, '-appimage') && github.event.inputs.upload_artifact == 'true' uses: actions/upload-artifact@v3 with: name: ${{ matrix.installer_artifact_name }}-release @@ -453,7 +453,7 @@ jobs: target_commitish: ${{ github.sha }} - name: Upload macOS dmg to Artifacts - if: endsWith(matrix.installer_artifact_name, '-dmg') && github.event.inputs.upload_artifact + if: endsWith(matrix.installer_artifact_name, '-dmg') && github.event.inputs.upload_artifact == 'true' uses: actions/upload-artifact@v3 with: name: ${{ matrix.installer_artifact_name }}-release @@ -471,7 +471,7 @@ jobs: target_commitish: ${{ github.sha }} - name: Upload Windows NSIS Web to Artifacts - if: endsWith(matrix.installer_artifact_name, '-nsis-web') && github.event.inputs.upload_artifact + if: endsWith(matrix.installer_artifact_name, '-nsis-web') && github.event.inputs.upload_artifact == 'true' uses: actions/upload-artifact@v3 with: name: ${{ matrix.installer_artifact_name }}-release diff --git a/README.md b/README.md index 6e0c886ca7..27761ea7dc 100644 --- a/README.md +++ b/README.md @@ -158,7 +158,7 @@ npm run electron:serve npm run browser:serve ``` -また、main ブランチのビルド結果がこちらにデプロイされています +また、main ブランチのビルド結果がこちらにデプロイされています 今はローカル PC 上で音声合成エンジンを起動する必要があります。 ## ビルド @@ -167,6 +167,11 @@ npm run browser:serve npm run electron:build ``` +### Github Actions でビルド + +fork したリポジトリで Actions を ON にし、workflow_dispatch で`build.yml`を起動すればビルドできます。 +成果物は Release にアップロードされます。 + ## テスト ### 単体テスト @@ -194,7 +199,7 @@ Playwright を使用しているためテストパターンを生成すること **ブラウザ版を起動している状態で**以下のコマンドを実行してください。 ```bash -npx playwright codegen http://localhost:5173/#/talk --viewport-size=800,600 +npx playwright codegen http://localhost:5173/ --viewport-size=1024,630 ``` 詳細は [Playwright ドキュメントの Test generator](https://playwright.dev/docs/codegen-intro) を参照してください。 diff --git a/android/app/build.gradle b/android/app/build.gradle index 7088395020..c2bcc3ce28 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -75,9 +75,9 @@ dependencies { // TODO: ちゃんと公開されたらそれに置き換える implementation urlZipFile( - "voicevoxcore-android_0.15.0-preview.15", - "jp/hiroshiba/voicevoxcore/voicevoxcore-android/0.15.0-preview.15/voicevoxcore-android-0.15.0-preview.15.aar", - "https://github.com/VOICEVOX/voicevox_core/releases/download/0.15.0-preview.15/java_packages.zip" + "voicevoxcore-android_0.15.0-preview.16", + "jp/hiroshiba/voicevoxcore/voicevoxcore-android/0.15.0-preview.16/voicevoxcore-android-0.15.0-preview.16.aar", + "https://github.com/VOICEVOX/voicevox_core/releases/download/0.15.0-preview.16/java_packages.zip" ) // https://mvnrepository.com/artifact/com.google.code.gson/gson diff --git a/android/app/src/main/java/jp/hiroshiba/voicevox/CorePlugin.kt b/android/app/src/main/java/jp/hiroshiba/voicevox/CorePlugin.kt index cfe4dde3ea..34f984dac0 100644 --- a/android/app/src/main/java/jp/hiroshiba/voicevox/CorePlugin.kt +++ b/android/app/src/main/java/jp/hiroshiba/voicevox/CorePlugin.kt @@ -89,7 +89,7 @@ class CorePlugin : Plugin() { return } vvms.sortWith(compareBy { - it.name.split(".")[0].length + it.name.split(".")[0].toInt() }) voiceModels = vvms.map { VoiceModel(it.absolutePath) @@ -102,6 +102,7 @@ class CorePlugin : Plugin() { tempDir.mkdirs() Os.setenv("TMPDIR", tempDir.absolutePath, true) + Log.i("CorePlugin", "Ready") call.resolve() } catch (e: Exception) { call.reject(e.message) @@ -287,6 +288,10 @@ class CorePlugin : Plugin() { try { val words = gson.fromJson(wordsJson, Array::class.java).asList() + if (words.isEmpty()) { + call.resolve() + return + } val userDict = UserDict() words.forEach { word -> userDict.addWord(word) diff --git "a/docs/\343\202\263\343\203\274\343\203\211\343\201\256\346\255\251\343\201\215\346\226\271.md" "b/docs/\343\202\263\343\203\274\343\203\211\343\201\256\346\255\251\343\201\215\346\226\271.md" index a2400272f4..6b1bdf7d2d 100644 --- "a/docs/\343\202\263\343\203\274\343\203\211\343\201\256\346\255\251\343\201\215\346\226\271.md" +++ "b/docs/\343\202\263\343\203\274\343\203\211\343\201\256\346\255\251\343\201\215\346\226\271.md" @@ -88,7 +88,6 @@ TODO - styles ディレクトリ ・・・ CSS や SCSS などのディレクトリ。 - infrastructures ディレクトリ ・・・ UI 用のコードと UI 以外のコードを跨ぐときに一枚かませたいときのためのコードのディレクトリ。 - openapi ディレクトリ ・・・ エンジンの API を叩くためのコードのディレクトリ。OpenAPI で自動生成される。 - - router ディレクトリ ・・・ Vue Router 用のディレクトリ。 - helpers ディレクトリ ・・・ 便利な関数を置くディレクトリ。 - shared ディレクトリ ・・・ UI と Electron 両方から参照されるコードを置くディレクトリ。 - public diff --git "a/docs/\343\202\265\343\203\274\343\203\211\343\203\221\343\203\274\343\203\206\343\202\243\351\226\213\347\231\272\350\200\205\343\201\256\346\226\271\343\201\270.md" "b/docs/\343\202\265\343\203\274\343\203\211\343\203\221\343\203\274\343\203\206\343\202\243\351\226\213\347\231\272\350\200\205\343\201\256\346\226\271\343\201\270.md" new file mode 100644 index 0000000000..3be715633e --- /dev/null +++ "b/docs/\343\202\265\343\203\274\343\203\211\343\203\221\343\203\274\343\203\206\343\202\243\351\226\213\347\231\272\350\200\205\343\201\256\346\226\271\343\201\270.md" @@ -0,0 +1,82 @@ +# サードパーティ開発者の方へ + +## サードパーティが利用するときの注意 + +### VOICEVOXの基本動作について + +VOICEVOXアプリケーションは、大まかに分けるとユーザに見えるGUI(フロントエンド部分)と、音声合成を担当するエンジン(バックエンド部分)で構成されています。通常、VOICEVOXのGUIが起動されると、見えない形でエンジンが(マルチエンジンとして登録されている数だけ)順番に立ち上がる仕組みになっています。サードパーティが音声合成機能を使うためには、裏で立ち上がっているエンジンにアクセスする必要があります。 + +### VOICEVOXへのアクセスについて + +VOICEVOXのエンジンを使用するためには、APIを用いる必要があります。特別な理由がない限り、API以外でのアクセスを避けるようにしてください。(例えば、ファイルを直接書き換えるなどの動作は不具合を引き起こす可能性が高くなるほか、VOICEVOXが事前テストで動作確認できてないような望まない動作を引き起こす可能性があります。) + +基本的にVOICEVOXで音声を合成するために必要なAPIはすでに用意されていますが、もしAPIで実現できない機能/操作がある場合は、VOIEVOX の Issue ページにてAPIの実装提案を行う事をお勧めします。 + +### APIのアクセス先について + +HTTP経由でAPIにアクセスできます。 + +* VOICEVOXエンジンがデフォルトで使用するポート番号は、内部で決まっています(例:50021/tcp)。 +* 他のアプリケーションが同じポート番号を使用している場合、競合が発生するため別のポートを開きます。 +* 最終的に開いたポート番号については、情報ファイルを参照して特定してください。 +* 動的に番号が変更されるのは、VOICEVOX v0.16以降の機能です。 + +### ランタイム情報ファイル(以下情報ファイル)について + +サードパーティが必要な情報を手に入れるために、「情報ファイル」というものが生成されるようになっています。サードパーティの製作者は、まずはこのファイルを参照してアクセス先を決定してください。 + +また、VOICEVOX内でエンジンが再起動した場合に、通信ポート番号が変わる可能性があります。サードパーティ側でAPI使用中に突如通信失敗が発生した場合は、このファイルが更新されているか確認するようにしてください。 + +(なお、古いVOICEVOXではこのファイル生成機能が実装されていません。また、動的にポート番号を決定しないバージョンもあります。下位互換性を保つためには、このファイルがない場合も想定してください) + +## 情報ファイルについて + +### ファイルの場所 + +ファイルは下記の場所にあります。 + +|OS |ファイルパス | +|----------------|-----------------------------------------------------------------------------| +|Windows |C:\Users\(ユーザー名)\AppData\Roaming\voicevox\runtime-info.json | +|MacOS |/Users/(ユーザー名)/Library/Application Support/voicevox/runtime-info.json | + +なお、Windows のプロファイル設定次第では、上記ファイルの配置が変更されることがあります。確実に場所を特定するためには、環境変数 ``APPDATA`` を用いて、Roamingフォルダの位置を特定してください。 + +### ファイルの中身と意味 + +ファイル自体はJSON形式になっています。構造としては下記のような形になります。 + +```JSONC +{ + //[string] VOICEVOXのバージョン番号 + "appVersion": "xxx.yyy.zzz", + + //[number] ファイル構造バージョン(仕様変更毎にインクリメントされる) + "formatVersion": 1, + + //エンジンデータ(起動している数だけ) + "engineInfos": [ + { + //[string] エンジン通称 + "name": "engine1", + + //[string] APIエンドポイント + "url": "http://127.0.0.1:50021", + + //[UUID] エンジン識別用のUUID + "uuid": "00000000-0000-0000-0000-000000000001", + }, + { + "name": "engine2", + "url": "http://127.0.0.1:50121", + "uuid": "00000000-0000-0000-0000-000000000002", + }, + ] +} +``` + +### 生成されるタイミング + +* エンジンが起動(もしくは再起動)するタイミングでファイルが生成、更新されます。 +* 何らかの理由でファイルの書き込み権が取得できなかった場合には更新されません。(更新のタイミングで他ツールがファイルを開いていた場合など) +* この生成タイミングはファイルバージョン1の実装であり、開発の過程で変更される可能性があります。 diff --git a/package-lock.json b/package-lock.json index ab41c2678a..92b5ef962a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,7 +41,6 @@ "tree-kill": "1.2.2", "uuid": "9.0.0", "vue": "3.2.45", - "vue-router": "4.0.8", "vuedraggable": "4.1.0", "vuex": "4.0.2", "zod": "3.22.4" @@ -17893,6 +17892,7 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.0.8.tgz", "integrity": "sha512-42mWSQaH7CCBQDspQTHv63f34VEnZC20g9QNK4WJ/zW8SdIUeT6TQ2i/78fjF/pVBUPLBWrGhvB7uDnaz7O/pA==", + "optional": true, "dependencies": { "@vue/devtools-api": "^6.0.0-beta.10" }, @@ -32601,6 +32601,7 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.0.8.tgz", "integrity": "sha512-42mWSQaH7CCBQDspQTHv63f34VEnZC20g9QNK4WJ/zW8SdIUeT6TQ2i/78fjF/pVBUPLBWrGhvB7uDnaz7O/pA==", + "optional": true, "requires": { "@vue/devtools-api": "^6.0.0-beta.10" } diff --git a/package.json b/package.json index 7a2ef20828..d8c445ca3b 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,6 @@ "tree-kill": "1.2.2", "uuid": "9.0.0", "vue": "3.2.45", - "vue-router": "4.0.8", "vuedraggable": "4.1.0", "vuex": "4.0.2", "zod": "3.22.4" diff --git a/public/howtouse.md b/public/howtouse.md index d46fd4d169..3bfb590a5c 100644 --- a/public/howtouse.md +++ b/public/howtouse.md @@ -286,6 +286,25 @@ VOICEVOX では、歌声合成機能がプロトタイプ版として提供さ ソング機能は鋭意制作中です。フィードバックをお待ちしています。 +### 音域調整 + +デフォルトの設定だと、声が低いキャラクターがうまく歌えないことがあります。 +そのような場合は「音域調整」を`-12`や`-24`などにすることで、音域を低めに合わせることができます。 + +将来的にこの値は自動設定される予定です。 + +### 声量調整 + +デフォルトの設定だと、キャラクターによっては声が少しかすれてしまうことがあります。 +そのような場合は「声量調整」を`5`や`10`などにすることで、発声をより強くすることができます。 + +将来的にこの値は自動設定される予定です。 + +### ソング機能のよくある質問 + +Q. 赤くなって声が再生されない +A. なにかしらのエラー状態を示しています。現在のバージョンでは、1つのノート(音符)につき日本語1文字分のみ入力できます。またノートが重なっていてもエラーとなります。 + ## オプション 「設定」の「オプション」でいろいろな設定を変更することができます。 diff --git a/public/updateInfos.json b/public/updateInfos.json index 2f27ac43e5..cf56461d22 100644 --- a/public/updateInfos.json +++ b/public/updateInfos.json @@ -1,4 +1,72 @@ [ + { + "version": "0.18.0", + "descriptions": [ + "キャラクター「WhiteCUL」「後鬼」「No.7」のハミングを追加", + "キャラクター「ちび式じい」「櫻歌ミコ」「小夜/SAYO」「ナースロボ_タイプT」のハミングを追加", + "キャラクター「四国めたん」「ずんだもん」「波音リツ」のハミングスタイルを追加", + "ソング:ピッチ表示機能", + "ソング:矩形選択を追加", + "ソング:ノートのエラー時にツールチップ表示", + "デフォルトで全てのCPUを使うことがあるバグを修正", + "ソング:MIDIインポート時にトラックを選択可能に", + "ソング:USTファイルのインポート機能", + "ソング:コピー&ペースト" + ], + "contributors": [ + "Hiroshiba", + "romot-co", + "sabonerune", + "sevenc-nanashi", + "sigprogramming", + "siketyan", + "tarepan", + "tomoish", + "weweweok" + ] + }, + { + "version": "0.17.2", + "descriptions": [ + "プロジェクト読み込み時に声量調整などが反映されない問題を解決" + ], + "contributors": ["Hiroshiba"] + }, + { + "version": "0.17.1", + "descriptions": [ + "キャラクター「玄野武宏」「白上虎太郎」「青山龍星」「冥鳴ひまり」「九州そら」のハミングを追加", + "キャラクター「もち子さん」「剣崎雌雄」のハミングを追加", + "音域調整・声量調整機能", + "バグ修正" + ], + "contributors": ["Hiroshiba", "sigprogramming"] + }, + { + "version": "0.17.0", + "descriptions": [ + "サードパーティアプリ向けのランタイム情報ファイルを出力", + "ソング:プロジェクトファイルに保存", + "トーク:連続再生中に裏で音声合成", + "ソング:元に戻す・やり直す機能", + "ソング:ショートカットキー機能", + "開発環境の向上", + "バグ修正" + ], + "contributors": [ + "cm-ayf", + "Hiroshiba", + "nmori", + "P0ngCh4ng", + "romot-co", + "sabonerune", + "sevenc-nanashi", + "sigprogramming", + "tsym77yoshi", + "weweweok", + "y-chan" + ] + }, { "version": "0.16.1", "descriptions": ["マルチエンジン稼働時に起動しないバグを修正"], diff --git a/src/backend/common/ConfigManager.ts b/src/backend/common/ConfigManager.ts index 43f37c3112..1d3d56ea7a 100644 --- a/src/backend/common/ConfigManager.ts +++ b/src/backend/common/ConfigManager.ts @@ -123,6 +123,20 @@ const migrations: [string, (store: Record) => unknown][] = [ return config; }, ], + [ + ">=0.17", + (config) => { + // 書き出し先のディレクトリが空文字の場合書き出し先固定を無効化する + // FIXME: 勝手に書き換えるのは少し不親切なので、ダイアログで書き換えたことを案内する + const savingSetting = config.savingSetting as ConfigType["savingSetting"]; + if ( + savingSetting.fixedExportEnabled && + savingSetting.fixedExportDir === "" + ) { + savingSetting.fixedExportEnabled = false; + } + }, + ], ]; export type Metadata = { diff --git a/src/backend/electron/main.ts b/src/backend/electron/main.ts index 0f2c29717f..2c5c10e345 100644 --- a/src/backend/electron/main.ts +++ b/src/backend/electron/main.ts @@ -364,8 +364,8 @@ let filePathOnMac: string | undefined = undefined; // create window async function createWindow() { const mainWindowState = windowStateKeeper({ - defaultWidth: 800, - defaultHeight: 600, + defaultWidth: 1024, + defaultHeight: 630, }); const currentTheme = configManager.get("currentTheme"); @@ -470,7 +470,6 @@ async function loadUrl(obj: { projectFilePath?: string; }) { const fragment = - "#/talk" + `?isMultiEngineOffMode=${obj?.isMultiEngineOffMode ?? false}` + `&projectFilePath=${obj?.projectFilePath ?? ""}`; return win.loadURL(`${firstUrl}${fragment}`); diff --git a/src/backend/mobile/capacitorConfig.ts b/src/backend/mobile/capacitorConfig.ts new file mode 100644 index 0000000000..c9c2af8582 --- /dev/null +++ b/src/backend/mobile/capacitorConfig.ts @@ -0,0 +1,52 @@ +import AsyncLock from "async-lock"; +import { Preferences } from "@capacitor/preferences"; +import { defaultEngine } from "@/backend/browser/contract"; + +import { BaseConfigManager, Metadata } from "@/backend/common/ConfigManager"; +import { ConfigType, EngineId, engineSettingSchema } from "@/type/preload"; + +let configManager: MobileConfigManager | undefined; +const entryKey = `${import.meta.env.VITE_APP_NAME}-config`; + +const configManagerLock = new AsyncLock(); +const defaultEngineId = EngineId(defaultEngine.uuid); + +export async function getConfigManager() { + await configManagerLock.acquire("configManager", async () => { + if (!configManager) { + configManager = new MobileConfigManager(); + await configManager.initialize(); + } + }); + + if (!configManager) { + throw new Error("configManager is undefined"); + } + + return configManager; +} + +class MobileConfigManager extends BaseConfigManager { + protected getAppVersion() { + return import.meta.env.VITE_APP_VERSION; + } + protected async exists() { + return !!(await Preferences.get({ key: entryKey }).then((v) => v.value)); + } + protected async load(): Promise & Metadata> { + const db = await Preferences.get({ key: entryKey }); + return JSON.parse(db.value || "{}"); + } + + protected async save(data: ConfigType & Metadata) { + await Preferences.set({ key: entryKey, value: JSON.stringify(data) }); + } + + protected getDefaultConfig() { + const baseConfig = super.getDefaultConfig(); + baseConfig.engineSettings[defaultEngineId] ??= engineSettingSchema.parse( + {} + ); + return baseConfig; + } +} diff --git a/src/backend/mobile/electronMock.ts b/src/backend/mobile/electronMock.ts deleted file mode 100644 index f6fa449696..0000000000 --- a/src/backend/mobile/electronMock.ts +++ /dev/null @@ -1,255 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ -/* eslint-disable no-console */ -import { SplashScreen } from "@capacitor/splash-screen"; -import { - defaultHotkeySettings, - defaultToolbarButtonSetting, - configSchema, - EngineId, - EngineInfo, - engineSettingSchema, - Sandbox, - ThemeConf, -} from "@/type/preload"; - -declare const __availableThemes: ThemeConf[]; - -const storeName = "voicevox"; - -const engineInfos: EngineInfo[] = [ - { - executionArgs: [], - executionEnabled: false, - executionFilePath: "", - host: "core", - name: "VOICEVOX Engine", - type: "default", - uuid: EngineId("074fc39e-678b-4c13-8916-ffca8d505d1d"), - }, -]; - -const loadMock = () => { - const electronMock: Sandbox = { - async getAppInfos() { - return { - name: "VOICEVOX Web", - version: "0.0.0", - }; - }, - async getHowToUseText() { - return await fetch("/howtouse.md").then((res) => res.text()); - }, - async getPolicyText() { - return await fetch("/policy.md").then((res) => res.text()); - }, - async getOssLicenses() { - return await fetch("/licenses.json").then((res) => res.json()); - }, - async getUpdateInfos() { - return await fetch("/updateInfos.json").then((res) => res.json()); - }, - async getOssCommunityInfos() { - return await fetch("/ossCommunityInfos.md").then((res) => res.text()); - }, - async getQAndAText() { - return await fetch("/qAndA.md").then((res) => res.text()); - }, - async getContactText() { - return await fetch("/contact.md").then((res) => res.text()); - }, - async getPrivacyPolicyText() { - return await fetch("/privacyPolicy.md").then((res) => res.text()); - }, - async showAudioSaveDialog(obj) { - throw new Error(`Not implemented: showAudioSaveDialog ${obj}`); - }, - async showTextSaveDialog(obj) { - throw new Error(`Not implemented: showTextSaveDialog ${obj}`); - }, - async showVvppOpenDialog(obj) { - throw new Error(`Not implemented: showVvppOpenDialog ${obj}`); - }, - async showOpenDirectoryDialog(obj) { - throw new Error(`Not implemented: showOpenDirectoryDialog ${obj}`); - }, - async showProjectSaveDialog(obj) { - throw new Error(`Not implemented: showProjectSaveDialog ${obj}`); - }, - async showProjectLoadDialog(obj) { - throw new Error(`Not implemented: showProjectLoadDialog ${obj}`); - }, - async showMessageDialog(obj) { - throw new Error(`Not implemented: showMessageDialog ${obj}`); - }, - async showQuestionDialog(obj) { - throw new Error(`Not implemented: showQuestionDialog ${obj}`); - }, - async showImportFileDialog(obj) { - throw new Error(`Not implemented: showImportFileDialog ${obj}`); - }, - showSaveDirectoryDialog(obj) { - throw new Error(`Not implemented: showSaveDirectoryDialog ${obj}`); - }, - async writeFile(obj) { - throw new Error(`Not implemented: writeFile ${obj}`); - }, - async readFile(obj) { - throw new Error(`Not implemented: readFile ${obj}`); - }, - async isAvailableGPUMode() { - return false; - }, - async isMaximizedWindow() { - return false; - }, - async onReceivedIPCMsg(channel, listener) { - window.addEventListener("message", (event) => { - if (event.data.channel === channel) { - listener(event.data.args); - } - }); - }, - async closeWindow() { - throw new Error("Not implemented: closeWindow"); - }, - async minimizeWindow() { - throw new Error("Not implemented: minimizeWindow"); - }, - async maximizeWindow() { - throw new Error("Not implemented: maximizeWindow"); - }, - async logError(...params) { - console.error(...params); - }, - async logWarn(...params) { - console.warn(...params); - }, - async logInfo(...params) { - console.info(...params); - }, - async engineInfos() { - return engineInfos; - }, - async restartEngine(engineId) { - throw new Error(`Not implemented: restartEngine ${engineId}`); - }, - async openEngineDirectory(engineId) { - throw new Error(`Not implemented: openEngineDirectory ${engineId}`); - }, - async hotkeySettings(newData) { - if (newData != undefined) { - const hotkeySettings = await this.getSetting("hotkeySettings"); - const hotkeySetting = hotkeySettings.find( - (hotkey) => hotkey.action == newData.action - ); - if (hotkeySetting != undefined) { - hotkeySetting.combination = newData.combination; - } - await this.setSetting("hotkeySettings", hotkeySettings); - } - return this.getSetting("hotkeySettings"); - }, - async checkFileExists(file) { - return false; - }, - async changePinWindow() { - throw new Error("Not implemented: changePinWindow"); - }, - async getDefaultHotkeySettings() { - return defaultHotkeySettings; - }, - async getDefaultToolbarSetting() { - return defaultToolbarButtonSetting; - }, - setNativeTheme(source) { - // 何もしない - }, - async theme(newData) { - if (newData) { - this.setSetting("currentTheme", newData); - return; - } - return { - currentTheme: await this.getSetting("currentTheme"), - availableThemes: __availableThemes, - }; - }, - vuexReady() { - requestAnimationFrame(() => { - // 1回だけだと文字の描画が終わっていないので2回待機する - requestAnimationFrame(() => { - SplashScreen.hide(); - }); - }); - }, - getSetting(key) { - const setting = configSchema.parse( - JSON.parse(localStorage.getItem(storeName) || "{}") - ); - // 同期でも使いたいので、async functionではなく手動でPromise.resolveを返す - return Promise.resolve(setting[key]); - }, - setSetting(key, newValue) { - const setting = configSchema.parse( - JSON.parse(localStorage.getItem(storeName) || "{}") - ); - setting[key] = newValue; - localStorage.setItem(storeName, JSON.stringify(setting)); - // 同期でも使いたいので、async functionではなく手動でPromise.resolveを返す - return Promise.resolve(setting[key]); - }, - async setEngineSetting(engineId, engineSetting) { - await this.setSetting("engineSettings", { - ...this.getSetting("engineSettings"), - [engineId]: engineSetting, - }); - return; - }, - async installVvppEngine(path) { - throw new Error(`Not implemented: installVvppEngine ${path}`); - }, - async uninstallVvppEngine(engineId) { - throw new Error(`Not implemented: uninstallVvppEngine ${engineId}`); - }, - async validateEngineDir(engineDir) { - throw new Error(`Not implemented: validateEngineDir ${engineDir}`); - }, - async reloadApp() { - window.location.reload(); - }, - async getAltPortInfos() { - return {}; - }, - async openLogDirectory() { - throw new Error("Not implemented: openLogDirectory"); - }, - }; - - try { - localStorage.setItem( - storeName, - JSON.stringify( - configSchema.parse(JSON.parse(localStorage.getItem(storeName) || "{}")) - ) - ); - } catch (e) { - console.warn("Failed to load store, reset store"); - localStorage.setItem(storeName, JSON.stringify(configSchema.parse({}))); - } - - const engineSettings = JSON.parse( - localStorage.getItem("voicevox_engineSettings") || "{}" - ); - for (const engineInfo of engineInfos) { - if (!engineSettings[engineInfo.uuid]) { - // 空オブジェクトをパースさせることで、デフォルト値を取得する - engineSettings[engineInfo.uuid] = engineSettingSchema.parse({}); - } - } - electronMock.setSetting("engineSettings", engineSettings); - - // @ts-expect-error readonlyなので代入できないが、モックのため問題ない - window.electron = electronMock; -}; - -export default loadMock; diff --git a/src/backend/mobile/engine/index.ts b/src/backend/mobile/engine/index.ts index 980cdf1194..bd61386478 100644 --- a/src/backend/mobile/engine/index.ts +++ b/src/backend/mobile/engine/index.ts @@ -1,4 +1,4 @@ -import { VoicevoxCorePlugin } from "../plugin"; +import { VoicevoxCorePlugin, corePlugin } from "../plugin"; import queryProvider from "./query"; import infoProvider from "./info"; import speakerProvider from "./speaker"; @@ -11,10 +11,8 @@ export type ApiProvider = (deps: { corePlugin: VoicevoxCorePlugin; }) => Partial; -const loadApi = () => { +export const loadApi = () => { api = new DefaultApi(); - const corePlugin = window.plugins?.voicevoxCore; - if (!corePlugin) throw new Error("assert: corePlugin != null"); let isCoreInitialized = false; (async () => { await corePlugin.initialize(); @@ -47,5 +45,3 @@ const loadApi = () => { } ) as DefaultApiInterface; }; - -export default loadApi; diff --git a/src/backend/mobile/index.ts b/src/backend/mobile/index.ts deleted file mode 100644 index 8675796f09..0000000000 --- a/src/backend/mobile/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { default as loadPlugin } from "./plugin"; -export { default as loadMock } from "./electronMock"; -export { default as loadCoreBasedApi, coreBasedApi } from "./engine"; diff --git a/src/backend/mobile/plugin.ts b/src/backend/mobile/plugin.ts index 826f8ce14e..3f74654058 100644 --- a/src/backend/mobile/plugin.ts +++ b/src/backend/mobile/plugin.ts @@ -41,13 +41,4 @@ export type VoicevoxCorePlugin = { useUserDict: (obj: { wordsJson: string }) => Promise; }; -const loadPlugin = () => { - const corePlugin = registerPlugin("VoicevoxCore"); - - // @ts-expect-error 定義時だけは無視する - window.plugins = { - voicevoxCore: corePlugin, - }; -}; - -export default loadPlugin; +export const corePlugin = registerPlugin("VoicevoxCore"); diff --git a/src/backend/mobile/preload.ts b/src/backend/mobile/preload.ts new file mode 100644 index 0000000000..d65bca39b1 --- /dev/null +++ b/src/backend/mobile/preload.ts @@ -0,0 +1,8 @@ +import { loadApi } from "./engine"; +import { api } from "./sandbox"; +import { SandboxKey, Sandbox } from "@/type/preload"; + +const sandbox: Sandbox = api; +// @ts-expect-error readonlyになっているが、初期化処理はここで行うので問題ない +window[SandboxKey] = sandbox; +loadApi(); diff --git a/src/backend/mobile/sandbox.ts b/src/backend/mobile/sandbox.ts new file mode 100644 index 0000000000..5f516ce132 --- /dev/null +++ b/src/backend/mobile/sandbox.ts @@ -0,0 +1,311 @@ +import { SplashScreen } from "@capacitor/splash-screen"; +import { getConfigManager } from "./capacitorConfig"; +import { defaultEngine } from "@/backend/browser/contract"; + +import { IpcSOData } from "@/type/ipc"; +import { + defaultHotkeySettings, + defaultToolbarButtonSetting, + configSchema, + EngineId, + EngineSettingType, + EngineSettings, + HotkeySettingType, + Sandbox, + ThemeConf, +} from "@/type/preload"; +import { + ContactTextFileName, + HowToUseTextFileName, + OssCommunityInfosFileName, + OssLicensesJsonFileName, + PolicyTextFileName, + PrivacyPolicyTextFileName, + QAndATextFileName, + UpdateInfosJsonFileName, +} from "@/type/staticResources"; + +// TODO: base pathを設定できるようにするか、ビルド時埋め込みにする +const toStaticPath = (fileName: string) => `/${fileName}`; + +/** + * スマホ版のSandBox実装 + * src/type/preload.tsのSandboxを変更した場合は、interfaceに追従した変更が必要 + * スマホ版では利用しない場合は、メソッドを追加して throw new Error() する + */ +export const api: Sandbox = { + getAppInfos() { + const appInfo = { + name: import.meta.env.VITE_APP_NAME, + version: import.meta.env.VITE_APP_VERSION, + }; + return Promise.resolve(appInfo); + }, + getHowToUseText() { + return fetch(toStaticPath(HowToUseTextFileName)).then((v) => v.text()); + }, + getPolicyText() { + return fetch(toStaticPath(PolicyTextFileName)).then((v) => v.text()); + }, + getOssLicenses() { + return fetch(toStaticPath(OssLicensesJsonFileName)).then((v) => v.json()); + }, + getUpdateInfos() { + return fetch(toStaticPath(UpdateInfosJsonFileName)).then((v) => v.json()); + }, + getOssCommunityInfos() { + return fetch(toStaticPath(OssCommunityInfosFileName)).then((v) => v.text()); + }, + getQAndAText() { + return fetch(toStaticPath(QAndATextFileName)).then((v) => v.text()); + }, + getContactText() { + return fetch(toStaticPath(ContactTextFileName)).then((v) => v.text()); + }, + getPrivacyPolicyText() { + return fetch(toStaticPath(PrivacyPolicyTextFileName)).then((v) => v.text()); + }, + getAltPortInfos() { + // NOTE: ブラウザ版ではサポートされていません + return Promise.resolve({}); + }, + showAudioSaveDialog(obj: { title: string; defaultPath?: string }) { + return new Promise((resolve, reject) => { + if (obj.defaultPath == undefined) { + reject( + // storeやvue componentからdefaultPathを設定していなかったらrejectされる + new Error( + "ブラウザ版ではファイルの保存機能が一部サポートされていません。" + ) + ); + } else { + resolve(obj.defaultPath); + } + }); + }, + showTextSaveDialog(obj: { title: string; defaultPath?: string }) { + return new Promise((resolve, reject) => { + if (obj.defaultPath == undefined) { + reject( + // storeやvue componentからdefaultPathを設定していなかったらrejectされる + new Error( + "ブラウザ版ではファイルの保存機能が一部サポートされていません。" + ) + ); + } else { + resolve(obj.defaultPath); + } + }); + }, + showSaveDirectoryDialog() { + throw new Error("Not supported: showSaveDirectoryDialog"); + }, + showVvppOpenDialog(obj: { title: string; defaultPath?: string }) { + throw new Error( + `not implemented: showVvppOpenDialog, request: ${JSON.stringify(obj)}` + ); + }, + showOpenDirectoryDialog() { + throw new Error("Not supported: showOpenDirectoryDialog"); + }, + showProjectSaveDialog(obj: { title: string; defaultPath?: string }) { + return new Promise((resolve, reject) => { + if (obj.defaultPath == undefined) { + reject( + // storeやvue componentからdefaultPathを設定していなかったらrejectされる + new Error( + "スマホ版ではファイルの保存機能が一部サポートされていません。" + ) + ); + } else { + resolve(obj.defaultPath); + } + }); + }, + showProjectLoadDialog(/* obj: { title: string } */) { + throw new Error("スマホ版では現在ファイルの読み込みをサポートしていません"); + }, + showMessageDialog(obj: { + type: "none" | "info" | "error" | "question" | "warning"; + title: string; + message: string; + }) { + window.alert(`${obj.title}\n${obj.message}`); + // NOTE: どの呼び出し元も、return valueを使用していないので雑に対応している + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return Promise.resolve({} as any); + }, + showQuestionDialog(obj: { + type: "none" | "info" | "error" | "question" | "warning"; + title: string; + message: string; + buttons: string[]; + cancelId?: number; + defaultId?: number; + }) { + // FIXME + // TODO: 例えば動的にdialog要素をDOMに生成して、それを表示させるみたいのはあるかもしれない + throw new Error( + `Not implemented: showQuestionDialog, request: ${JSON.stringify(obj)}` + ); + }, + showImportFileDialog(/* obj: { title: string } */) { + throw new Error("スマホ版では現在ファイルの読み込みをサポートしていません"); + }, + writeFile(obj: { filePath: string; buffer: ArrayBuffer }) { + throw new Error( + "Not implemented: writeFile, request: " + JSON.stringify(obj) + ); + }, + readFile(/* obj: { filePath: string } */) { + throw new Error("Not implemented: readFile"); + }, + isAvailableGPUMode() { + // TODO: WebAssembly版をサポートする時に実装する + // FIXME: canvasでWebGLから調べたり、WebGPUがサポートされているかを調べたりで判断は出来そう + return Promise.resolve(false); + }, + isMaximizedWindow() { + // NOTE: UIの表示状態の制御のためだけなので固定値を返している + return Promise.resolve(true); + }, + onReceivedIPCMsg( + channel: T, + listener: (_: unknown, ...args: IpcSOData[T]["args"]) => void + ) { + window.addEventListener("message", (event) => { + if (event.data.channel == channel) { + listener(event.data.args); + } + }); + }, + closeWindow() { + throw new Error(`Not supported: closeWindow`); + }, + minimizeWindow() { + throw new Error(`Not supported: minimizeWindow`); + }, + maximizeWindow() { + throw new Error(`Not supported: maximizeWindow`); + }, + /* eslint-disable no-console */ // ログの吐き出し先は console ぐらいしかないので、ここでは特例で許可している + logError(...params: unknown[]) { + console.error(...params); + return; + }, + logWarn(...params: unknown[]) { + console.warn(...params); + return; + }, + logInfo(...params: unknown[]) { + console.info(...params); + return; + }, + openLogDirectory() { + throw new Error(`Not supported: openLogDirectory`); + }, + /* eslint-enable no-console */ + engineInfos() { + return Promise.resolve([defaultEngine]); + }, + restartEngine(/* engineId: EngineId */) { + throw new Error(`Not supported: restartEngine`); + }, + openEngineDirectory(/* engineId: EngineId */) { + throw new Error(`Not supported: openEngineDirectory`); + }, + async hotkeySettings(newData?: HotkeySettingType) { + type HotkeySettingType = ReturnType< + typeof configSchema["parse"] + >["hotkeySettings"]; + if (newData != undefined) { + const hotkeySettings = (await this.getSetting( + "hotkeySettings" + )) as HotkeySettingType; + const hotkeySetting = hotkeySettings.find( + (hotkey) => hotkey.action == newData.action + ); + if (hotkeySetting != undefined) { + hotkeySetting.combination = newData.combination; + } + await this.setSetting("hotkeySettings", hotkeySettings); + } + return this.getSetting("hotkeySettings") as Promise; + }, + checkFileExists() { + throw new Error(`Not supported: checkFileExists`); + }, + changePinWindow() { + throw new Error(`Not supported: changePinWindow`); + }, + getDefaultHotkeySettings() { + return Promise.resolve(defaultHotkeySettings); + }, + getDefaultToolbarSetting() { + return Promise.resolve(defaultToolbarButtonSetting); + }, + setNativeTheme(/* source: NativeThemeType */) { + // TODO: Impl + return; + }, + async theme(newData?: string) { + if (newData != undefined) { + await this.setSetting("currentTheme", newData); + return; + } + // NOTE: Electron版では起動時にテーマ情報が必要なので、 + // この実装とは違って起動時に読み込んだキャッシュを返すだけになっている。 + return Promise.all( + // FIXME: themeファイルのいい感じのパスの設定 + ["/themes/default.json", "/themes/dark.json"].map((url) => + fetch(url).then((res) => res.json()) + ) + ) + .then((v) => ({ + currentTheme: "Default", + availableThemes: v, + })) + .then((v) => + this.getSetting("currentTheme").then( + (currentTheme) => + ({ + ...v, + currentTheme, + } as { currentTheme: string; availableThemes: ThemeConf[] }) + ) + ); + }, + vuexReady() { + SplashScreen.hide(); + }, + async getSetting(key) { + const configManager = await getConfigManager(); + return configManager.get(key); + }, + async setSetting(key, newValue) { + const configManager = await getConfigManager(); + configManager.set(key, newValue); + return newValue; + }, + async setEngineSetting(engineId: EngineId, engineSetting: EngineSettingType) { + const engineSettings = (await this.getSetting( + "engineSettings" + )) as EngineSettings; + engineSettings[engineId] = engineSetting; + await this.setSetting("engineSettings", engineSettings); + return; + }, + installVvppEngine(/* path: string */) { + throw new Error(`Not supported: installVvppEngine`); + }, + uninstallVvppEngine(/* engineId: EngineId */) { + throw new Error(`Not supported: uninstallVvppEngine`); + }, + validateEngineDir(/* engineDir: string */) { + throw new Error(`Not supported: validateEngineDir`); + }, + async reloadApp(obj: { isMultiEngineOffMode: boolean }) { + location.search = `?isMultiEngineOffMode=${obj.isMultiEngineOffMode}`; + location.reload(); + }, +}; diff --git a/src/components/App.vue b/src/components/App.vue index 2d404164de..567e81ad8c 100644 --- a/src/components/App.vue +++ b/src/components/App.vue @@ -1,15 +1,15 @@ @@ -17,7 +17,8 @@ diff --git a/src/components/Dialog/ImportMidiDialog.vue b/src/components/Dialog/ImportMidiDialog.vue new file mode 100644 index 0000000000..f034fd3684 --- /dev/null +++ b/src/components/Dialog/ImportMidiDialog.vue @@ -0,0 +1,168 @@ + + + diff --git a/src/components/Dialog/SettingDialog.vue b/src/components/Dialog/SettingDialog.vue index 2333a86ea8..65f6119823 100644 --- a/src/components/Dialog/SettingDialog.vue +++ b/src/components/Dialog/SettingDialog.vue @@ -470,7 +470,7 @@ flat color="primary" icon="folder_open" - @click="openFileExplore" + @click="selectFixedExportDir()" > フォルダ選択 @@ -954,9 +954,11 @@ > - -
[開発時のみ機能] ピッチの表示
-
+ +
ソング:ピッチを表示
+
ONの場合、ソングエディターで、レンダリング後にピッチが表示されます。ONの場合、ソングエディターでピッチ(音の高さ)が表示されます。
@@ -1011,7 +1013,7 @@ diff --git a/src/components/MobileHeaderBar.vue b/src/components/MobileHeaderBar.vue index a1eaf1e76b..ad60c87c9d 100644 --- a/src/components/MobileHeaderBar.vue +++ b/src/components/MobileHeaderBar.vue @@ -57,14 +57,14 @@ const headerButtons = computed(() => [ { icon: "undo", onClick: () => { - store.dispatch("UNDO"); + store.dispatch("UNDO", { editor: "talk" }); }, disable: !canUndo.value, }, { icon: "redo", onClick: () => { - store.dispatch("REDO"); + store.dispatch("REDO", { editor: "talk" }); }, disable: !canRedo.value, }, diff --git a/src/components/Sing/MenuBar.vue b/src/components/Sing/MenuBar.vue index 578b08cabf..b960a64f3d 100644 --- a/src/components/Sing/MenuBar.vue +++ b/src/components/Sing/MenuBar.vue @@ -1,5 +1,9 @@ diff --git a/src/components/Sing/ScoreSequencer.vue b/src/components/Sing/ScoreSequencer.vue index 0d5e019232..946549275f 100644 --- a/src/components/Sing/ScoreSequencer.vue +++ b/src/components/Sing/ScoreSequencer.vue @@ -18,6 +18,7 @@
@@ -147,6 +149,17 @@ marginBottom: `${scrollBarWidth}px`, }" > +
+
@@ -209,6 +223,10 @@ import { onDeactivated, } from "vue"; import { v4 as uuidv4 } from "uuid"; +import ContextMenu, { + ContextMenuItemData, +} from "@/components/Menu/ContextMenu.vue"; +import { isMac } from "@/type/preload"; import { useStore } from "@/store"; import { Note } from "@/store/type"; import { @@ -239,6 +257,8 @@ import SequencerPhraseIndicator from "@/components/Sing/SequencerPhraseIndicator import CharacterPortrait from "@/components/Sing/CharacterPortrait.vue"; import SequencerPitch from "@/components/Sing/SequencerPitch.vue"; import { isOnCommandOrCtrlKeyDown } from "@/store/utility"; +import { useHotkeyManager } from "@/plugins/hotkeyPlugin"; +import { useShiftKey } from "@/composables/useModifierKey"; type PreviewMode = "ADD" | "MOVE" | "RESIZE_RIGHT" | "RESIZE_LEFT"; @@ -251,14 +271,21 @@ const isSelfEventTarget = (event: UIEvent) => { const store = useStore(); const state = store.state; + // 分解能(Ticks Per Quarter Note) const tpqn = computed(() => state.tpqn); + // テンポ const tempos = computed(() => state.tempos); + // 拍子 const timeSignatures = computed(() => state.timeSignatures); + // ノート const notes = computed(() => store.getters.SELECTED_TRACK.notes); +const isNoteSelected = computed(() => { + return state.selectedNoteIds.size > 0; +}); const unselectedNotes = computed(() => { const selectedNoteIds = state.selectedNoteIds; return notes.value.filter((value) => !selectedNoteIds.has(value.id)); @@ -267,13 +294,23 @@ const selectedNotes = computed(() => { const selectedNoteIds = state.selectedNoteIds; return notes.value.filter((value) => selectedNoteIds.has(value.id)); }); + +// 矩形選択 +const shiftKey = useShiftKey(); +const isRectSelecting = ref(false); +const rectSelectStartX = ref(0); +const rectSelectStartY = ref(0); +const rectSelectHitbox = ref(undefined); + // ズーム状態 const zoomX = computed(() => state.sequencerZoomX); const zoomY = computed(() => state.sequencerZoomY); + // スナップ const snapTicks = computed(() => { return getNoteDuration(state.sequencerSnapType, tpqn.value); }); + // シーケンサグリッド const gridCellTicks = snapTicks; // ひとまずスナップ幅=グリッドセル幅 const gridCellWidth = computed(() => { @@ -318,15 +355,18 @@ const gridWidth = computed(() => { const gridHeight = computed(() => { return gridCellHeight.value * keyInfos.length; }); + // スクロール位置 const scrollX = ref(0); const scrollY = ref(0); + // 再生ヘッドの位置 const playheadTicks = ref(0); const playheadX = computed(() => { const baseX = tickToBaseX(playheadTicks.value, tpqn.value); return Math.floor(baseX * zoomX.value); }); + // フレーズ const phraseInfos = computed(() => { return [...state.phrases.entries()].map(([key, phrase]) => { @@ -342,9 +382,11 @@ const showPitch = computed(() => { }); const scrollBarWidth = ref(12); const sequencerBody = ref(null); + // マウスカーソル位置 -let cursorX = 0; -let cursorY = 0; +const cursorX = ref(0); +const cursorY = ref(0); + // プレビュー // FIXME: 関連する値を1つのobjectにまとめる const nowPreviewing = ref(false); @@ -358,6 +400,7 @@ let dragStartGuideLineTicks = 0; let draggingNoteId = ""; // FIXME: 無効状態はstring以外の型にする let executePreviewProcess = false; let edited = false; // プレビュー終了時にstore.stateの更新を行うかどうかを表す変数 + // ダブルクリック let mouseDownNoteId: string | undefined; const clickedNoteIds: [string | undefined, string | undefined] = [ @@ -365,12 +408,13 @@ const clickedNoteIds: [string | undefined, string | undefined] = [ undefined, ]; let ignoreDoubleClick = false; + // 入力を補助する線 const showGuideLine = ref(true); const guideLineX = ref(0); const previewAdd = () => { - const cursorBaseX = (scrollX.value + cursorX) / zoomX.value; + const cursorBaseX = (scrollX.value + cursorX.value) / zoomX.value; const cursorTicks = baseXToTick(cursorBaseX, tpqn.value); const draggingNote = copiedNotesForPreview.get(draggingNoteId); if (!draggingNote) { @@ -403,8 +447,8 @@ const previewAdd = () => { }; const previewMove = () => { - const cursorBaseX = (scrollX.value + cursorX) / zoomX.value; - const cursorBaseY = (scrollY.value + cursorY) / zoomY.value; + const cursorBaseX = (scrollX.value + cursorX.value) / zoomX.value; + const cursorBaseY = (scrollY.value + cursorY.value) / zoomY.value; const cursorTicks = baseXToTick(cursorBaseX, tpqn.value); const cursorNoteNumber = baseYToNoteNumber(cursorBaseY); const draggingNote = copiedNotesForPreview.get(draggingNoteId); @@ -451,7 +495,7 @@ const previewMove = () => { }; const previewResizeRight = () => { - const cursorBaseX = (scrollX.value + cursorX) / zoomX.value; + const cursorBaseX = (scrollX.value + cursorX.value) / zoomX.value; const cursorTicks = baseXToTick(cursorBaseX, tpqn.value); const draggingNote = copiedNotesForPreview.get(draggingNoteId); if (!draggingNote) { @@ -491,7 +535,7 @@ const previewResizeRight = () => { }; const previewResizeLeft = () => { - const cursorBaseX = (scrollX.value + cursorX) / zoomX.value; + const cursorBaseX = (scrollX.value + cursorX.value) / zoomX.value; const cursorTicks = baseXToTick(cursorBaseX, tpqn.value); const draggingNote = copiedNotesForPreview.get(draggingNoteId); if (!draggingNote) { @@ -582,16 +626,16 @@ const startPreview = (event: MouseEvent, mode: PreviewMode, note?: Note) => { if (!sequencerBodyElement) { throw new Error("sequencerBodyElement is null."); } - cursorX = getXInBorderBox(event.clientX, sequencerBodyElement); - cursorY = getYInBorderBox(event.clientY, sequencerBodyElement); - if (cursorX >= sequencerBodyElement.clientWidth) { + cursorX.value = getXInBorderBox(event.clientX, sequencerBodyElement); + cursorY.value = getYInBorderBox(event.clientY, sequencerBodyElement); + if (cursorX.value >= sequencerBodyElement.clientWidth) { return; } - if (cursorY >= sequencerBodyElement.clientHeight) { + if (cursorY.value >= sequencerBodyElement.clientHeight) { return; } - const cursorBaseX = (scrollX.value + cursorX) / zoomX.value; - const cursorBaseY = (scrollY.value + cursorY) / zoomY.value; + const cursorBaseX = (scrollX.value + cursorX.value) / zoomX.value; + const cursorBaseY = (scrollY.value + cursorY.value) / zoomY.value; const cursorTicks = baseXToTick(cursorBaseX, tpqn.value); const cursorNoteNumber = baseYToNoteNumber(cursorBaseY); // NOTE: 入力を補助する線の判定の境目はスナップ幅の3/4の位置 @@ -710,8 +754,23 @@ const onMouseDown = (event: MouseEvent) => { if (!isSelfEventTarget(event)) { return; } + + // macOSの場合、Ctrl+クリックが右クリックのため、その場合はノートを追加しない + if (isMac && event.ctrlKey && event.button === 0) { + return; + } + + // TODO: メニューが表示されている場合はメニュー非表示のみ行いたい + + // 選択中のノートが無い場合、プレビューを開始しノートIDをリセット if (event.button === 0) { - startPreview(event, "ADD"); + if (event.shiftKey) { + isRectSelecting.value = true; + rectSelectStartX.value = cursorX.value; + rectSelectStartY.value = cursorY.value; + } else { + startPreview(event, "ADD"); + } mouseDownNoteId = undefined; } else { store.dispatch("DESELECT_ALL_NOTES"); @@ -723,14 +782,14 @@ const onMouseMove = (event: MouseEvent) => { if (!sequencerBodyElement) { throw new Error("sequencerBodyElement is null."); } - cursorX = getXInBorderBox(event.clientX, sequencerBodyElement); - cursorY = getYInBorderBox(event.clientY, sequencerBodyElement); + cursorX.value = getXInBorderBox(event.clientX, sequencerBodyElement); + cursorY.value = getYInBorderBox(event.clientY, sequencerBodyElement); if (nowPreviewing.value) { executePreviewProcess = true; } else { const scrollLeft = sequencerBodyElement.scrollLeft; - const cursorBaseX = (scrollLeft + cursorX) / zoomX.value; + const cursorBaseX = (scrollLeft + cursorX.value) / zoomX.value; const cursorTicks = baseXToTick(cursorBaseX, tpqn.value); // NOTE: 入力を補助する線の判定の境目はスナップ幅の3/4の位置 const guideLineTicks = @@ -753,6 +812,11 @@ const onMouseUp = (event: MouseEvent) => { ignoreDoubleClick = true; } + if (isRectSelecting.value) { + rectSelect(isOnCommandOrCtrlKeyDown(event)); + return; + } + if (!nowPreviewing.value) { return; } @@ -776,6 +840,50 @@ const onMouseUp = (event: MouseEvent) => { nowPreviewing.value = false; }; +/** + * 矩形選択。 + * @param additive 追加選択とするかどうか。 + */ +const rectSelect = (additive: boolean) => { + const rectSelectHitboxElement = rectSelectHitbox.value; + if (!rectSelectHitboxElement) { + throw new Error("rectSelectHitboxElement is null."); + } + isRectSelecting.value = false; + const left = Math.min(rectSelectStartX.value, cursorX.value); + const top = Math.min(rectSelectStartY.value, cursorY.value); + const width = Math.abs(cursorX.value - rectSelectStartX.value); + const height = Math.abs(cursorY.value - rectSelectStartY.value); + const startTicks = baseXToTick( + (scrollX.value + left) / zoomX.value, + tpqn.value + ); + const endTicks = baseXToTick( + (scrollX.value + left + width) / zoomX.value, + tpqn.value + ); + const endNoteNumber = baseYToNoteNumber((scrollY.value + top) / zoomY.value); + const startNoteNumber = baseYToNoteNumber( + (scrollY.value + top + height) / zoomY.value + ); + + const noteIdsToSelect: string[] = []; + for (const note of notes.value) { + if ( + note.position + note.duration >= startTicks && + note.position <= endTicks && + startNoteNumber <= note.noteNumber && + note.noteNumber <= endNoteNumber + ) { + noteIdsToSelect.push(note.id); + } + } + if (!additive) { + store.dispatch("DESELECT_ALL_NOTES"); + } + store.dispatch("SELECT_NOTES", { noteIds: noteIdsToSelect }); +}; + const onDoubleClick = () => { if ( ignoreDoubleClick || @@ -954,7 +1062,7 @@ const onWheel = (event: WheelEvent) => { throw new Error("sequencerBodyElement is null."); } if (isOnCommandOrCtrlKeyDown(event)) { - cursorX = getXInBorderBox(event.clientX, sequencerBodyElement); + cursorX.value = getXInBorderBox(event.clientX, sequencerBodyElement); // マウスカーソル位置を基準に水平方向のズームを行う const oldZoomX = zoomX.value; let newZoomX = zoomX.value; @@ -965,8 +1073,8 @@ const onWheel = (event: WheelEvent) => { const scrollTop = sequencerBodyElement.scrollTop; store.dispatch("SET_ZOOM_X", { zoomX: newZoomX }).then(() => { - const cursorBaseX = (scrollLeft + cursorX) / oldZoomX; - const newScrollLeft = cursorBaseX * newZoomX - cursorX; + const cursorBaseX = (scrollLeft + cursorX.value) / oldZoomX; + const newScrollLeft = cursorBaseX * newZoomX - cursorX.value; sequencerBodyElement.scrollTo(newScrollLeft, scrollTop); }); } @@ -1063,6 +1171,136 @@ onDeactivated(() => { document.removeEventListener("keydown", handleKeydown); }); + +// コンテキストメニュー +// TODO: 分割する +const { registerHotkeyWithCleanup } = useHotkeyManager(); + +registerHotkeyWithCleanup({ + editor: "song", + name: "コピー", + callback: () => { + if (nowPreviewing.value) { + return; + } + if (state.selectedNoteIds.size === 0) { + return; + } + store.dispatch("COPY_NOTES_TO_CLIPBOARD"); + }, +}); + +registerHotkeyWithCleanup({ + editor: "song", + name: "切り取り", + callback: () => { + if (nowPreviewing.value) { + return; + } + if (state.selectedNoteIds.size === 0) { + return; + } + store.dispatch("COMMAND_CUT_NOTES_TO_CLIPBOARD"); + }, +}); + +registerHotkeyWithCleanup({ + editor: "song", + name: "貼り付け", + callback: () => { + if (nowPreviewing.value) { + return; + } + store.dispatch("COMMAND_PASTE_NOTES_FROM_CLIPBOARD"); + }, +}); + +registerHotkeyWithCleanup({ + editor: "song", + name: "すべて選択", + callback: () => { + if (nowPreviewing.value) { + return; + } + store.dispatch("SELECT_ALL_NOTES"); + }, +}); + +const contextMenu = ref>(); + +const contextMenuData = ref([ + { + type: "button", + label: "コピー", + onClick: async () => { + contextMenu.value?.hide(); + await store.dispatch("COPY_NOTES_TO_CLIPBOARD"); + }, + disabled: !isNoteSelected.value, + disableWhenUiLocked: true, + }, + { + type: "button", + label: "切り取り", + onClick: async () => { + contextMenu.value?.hide(); + await store.dispatch("COMMAND_CUT_NOTES_TO_CLIPBOARD"); + }, + disabled: !isNoteSelected.value, + disableWhenUiLocked: true, + }, + { + type: "button", + label: "貼り付け", + onClick: async () => { + contextMenu.value?.hide(); + await store.dispatch("COMMAND_PASTE_NOTES_FROM_CLIPBOARD"); + }, + disableWhenUiLocked: true, + }, + { type: "separator" }, + { + type: "button", + label: "すべて選択", + onClick: async () => { + contextMenu.value?.hide(); + await store.dispatch("SELECT_ALL_NOTES"); + }, + disableWhenUiLocked: true, + }, + { + type: "button", + label: "選択解除", + onClick: async () => { + contextMenu.value?.hide(); + await store.dispatch("DESELECT_ALL_NOTES"); + }, + disabled: !isNoteSelected.value, + disableWhenUiLocked: true, + }, + { type: "separator" }, + { + type: "button", + label: "クオンタイズ", + onClick: async () => { + contextMenu.value?.hide(); + await store.dispatch("COMMAND_QUANTIZE_SELECTED_NOTES"); + }, + disabled: !isNoteSelected.value, + disableWhenUiLocked: true, + }, + { type: "separator" }, + { + type: "button", + label: "削除", + onClick: async () => { + contextMenu.value?.hide(); + await store.dispatch("COMMAND_REMOVE_SELECTED_NOTES"); + }, + disabled: !isNoteSelected.value, + disableWhenUiLocked: true, + }, +]); diff --git a/src/components/Sing/SequencerNote.vue b/src/components/Sing/SequencerNote.vue index a54e535f4e..d922c866b6 100644 --- a/src/components/Sing/SequencerNote.vue +++ b/src/components/Sing/SequencerNote.vue @@ -3,7 +3,8 @@ class="note" :class="{ selected: noteState === 'SELECTED', - overlapping: noteState === 'OVERLAPPING', + overlapping: hasOverlappingError, + 'invalid-phrase': hasPhraseError, 'below-pitch': showPitch, }" :style="{ @@ -35,6 +36,29 @@ @keydown.stop="onLyricInputKeyDown" @blur="onLyricInputBlur" /> +
@@ -47,10 +71,11 @@ import { tickToBaseX, noteNumberToBaseY, } from "@/sing/viewHelper"; -import ContextMenu from "@/components/Menu/ContextMenu.vue"; -import { MenuItemButton } from "@/components/Menu/type"; +import ContextMenu, { + ContextMenuItemData, +} from "@/components/Menu/ContextMenu.vue"; -type NoteState = "NORMAL" | "SELECTED" | "OVERLAPPING"; +type NoteState = "NORMAL" | "SELECTED"; const vFocus = { mounted(el: HTMLInputElement) { @@ -102,11 +127,24 @@ const noteState = computed((): NoteState => { if (props.isSelected) { return "SELECTED"; } - if (state.overlappingNoteIds.has(props.note.id)) { - return "OVERLAPPING"; - } return "NORMAL"; }); + +// ノートの重なりエラー +const hasOverlappingError = computed(() => { + return state.overlappingNoteIds.has(props.note.id); +}); + +// フレーズ生成エラー +const hasPhraseError = computed(() => { + // エラーがあるフレーズに自身が含まれているか + return Array.from(state.phrases.values()).some( + (phrase) => + phrase.state === "COULD_NOT_RENDER" && + phrase.notes.some((note) => note.id === props.note.id) + ); +}); + const lyric = computed({ get() { return props.note.lyric; @@ -126,13 +164,43 @@ const showPitch = computed(() => { return state.experimentalSetting.showPitchInSongEditor; }); const contextMenu = ref>(); -const contextMenuData = ref<[MenuItemButton]>([ +const contextMenuData = ref([ + { + type: "button", + label: "コピー", + onClick: async () => { + contextMenu.value?.hide(); + await store.dispatch("COPY_NOTES_TO_CLIPBOARD"); + }, + disableWhenUiLocked: true, + }, + { + type: "button", + label: "切り取り", + onClick: async () => { + contextMenu.value?.hide(); + await store.dispatch("COMMAND_CUT_NOTES_TO_CLIPBOARD"); + }, + disableWhenUiLocked: true, + }, + { type: "separator" }, + { + type: "button", + label: "クオンタイズ", + disabled: !props.isSelected, + onClick: async () => { + contextMenu.value?.hide(); + await store.dispatch("COMMAND_QUANTIZE_SELECTED_NOTES"); + }, + disableWhenUiLocked: true, + }, + { type: "separator" }, { type: "button", label: "削除", onClick: async () => { contextMenu.value?.hide(); - store.dispatch("COMMAND_REMOVE_SELECTED_NOTES"); + await store.dispatch("COMMAND_REMOVE_SELECTED_NOTES"); }, disableWhenUiLocked: true, }, @@ -219,9 +287,21 @@ const onLyricInputBlur = () => { } } - &.overlapping { + &.overlapping, + &.invalid-phrase { .note-bar { - background-color: hsl(130, 35%, 85%); + background-color: rgba(colors.$warning-rgb, 0.5); + } + + .note-lyric { + opacity: 0.6; + } + + &.selected { + .note-bar { + background-color: rgba(colors.$warning-rgb, 0.5); + border-color: colors.$warning; + } } } } @@ -274,8 +354,10 @@ const onLyricInputBlur = () => { position: absolute; bottom: 0; font-weight: 700; - width: 2rem; - border: 1px solid hsl(33, 100%, 73%); + min-width: 3rem; + max-width: 6rem; + border: 0; + outline: 2px solid colors.$primary; border-radius: 0.25rem; } diff --git a/src/components/Sing/SequencerPitch.vue b/src/components/Sing/SequencerPitch.vue index 11fac54642..a28c72bc44 100644 --- a/src/components/Sing/SequencerPitch.vue +++ b/src/components/Sing/SequencerPitch.vue @@ -73,10 +73,10 @@ const searchVoicedSections = (phonemes: FramePhoneme[]) => { }; const render = () => { - if (!canvasWidth) { + if (canvasWidth == undefined) { throw new Error("canvasWidth is undefined."); } - if (!canvasHeight) { + if (canvasHeight == undefined) { throw new Error("canvasHeight is undefined."); } if (!renderer) { @@ -104,7 +104,7 @@ const render = () => { } // ピッチラインの生成・更新を行う for (const [phraseKey, phrase] of phrases) { - if (!phrase.singer || !phrase.query || !phrase.startTime) { + if (!phrase.singer || !phrase.query || phrase.startTime == undefined) { continue; } const tempos = [toRaw(phrase.tempos[0])]; diff --git a/src/components/Sing/SingEditor.vue b/src/components/Sing/SingEditor.vue index d375b05820..ac7b35267c 100644 --- a/src/components/Sing/SingEditor.vue +++ b/src/components/Sing/SingEditor.vue @@ -90,18 +90,18 @@ onetimeWatch( }, }); - await store.dispatch("SET_VOLUME", { volume: 0.6 }); - await store.dispatch("SET_PLAYHEAD_POSITION", { position: 0 }); - await store.dispatch("SET_LEFT_LOCATOR_POSITION", { - position: 0, - }); - await store.dispatch("SET_RIGHT_LOCATOR_POSITION", { - position: 480 * 4 * 16, - }); + // CI上のe2eテストのNemoエンジンには歌手がいないためエラーになるのでワークアラウンド + // FIXME: 歌手をいると見せかけるmock APIを作り、ここのtry catchを削除する + try { + await store.dispatch("SET_SINGER", {}); + } catch (e) { + window.backend.logError(e); + } } - isCompletedInitialStartup.value = true; - await store.dispatch("SET_SINGER", {}); + await store.dispatch("SET_VOLUME", { volume: 0.6 }); + await store.dispatch("SET_PLAYHEAD_POSITION", { position: 0 }); + isCompletedInitialStartup.value = true; return "unwatch"; }, diff --git a/src/components/Sing/ToolBar.vue b/src/components/Sing/ToolBar.vue index fc3707f07a..a8a8172d64 100644 --- a/src/components/Sing/ToolBar.vue +++ b/src/components/Sing/ToolBar.vue @@ -5,13 +5,23 @@ + import { computed, watch, ref, onMounted, onUnmounted } from "vue"; import { useStore } from "@/store"; + import { getSnapTypes, isTriplet, isValidBeatType, isValidBeats, isValidBpm, - isValidVoiceKeyShift, + isValidKeyRangeAdjustment, + isValidvolumeRangeAdjustment, } from "@/sing/domain"; import CharacterMenuButton from "@/components/Sing/CharacterMenuButton/MenuButton.vue"; import { useHotkeyManager } from "@/plugins/hotkeyPlugin"; @@ -183,19 +195,25 @@ const redo = () => { const tempos = computed(() => store.state.tempos); const timeSignatures = computed(() => store.state.timeSignatures); -const keyShift = computed(() => store.getters.SELECTED_TRACK.voiceKeyShift); +const keyRangeAdjustment = computed( + () => store.getters.SELECTED_TRACK.keyRangeAdjustment +); +const volumeRangeAdjustment = computed( + () => store.getters.SELECTED_TRACK.volumeRangeAdjustment +); const bpmInputBuffer = ref(120); const beatsInputBuffer = ref(4); const beatTypeInputBuffer = ref(4); -const keyShiftInputBuffer = ref(0); +const keyRangeAdjustmentInputBuffer = ref(0); +const volumeRangeAdjustmentInputBuffer = ref(0); watch( tempos, () => { bpmInputBuffer.value = tempos.value[0].bpm; }, - { deep: true } + { deep: true, immediate: true } ); watch( @@ -204,12 +222,24 @@ watch( beatsInputBuffer.value = timeSignatures.value[0].beats; beatTypeInputBuffer.value = timeSignatures.value[0].beatType; }, - { deep: true } + { deep: true, immediate: true } ); -watch(keyShift, () => { - keyShiftInputBuffer.value = keyShift.value; -}); +watch( + keyRangeAdjustment, + () => { + keyRangeAdjustmentInputBuffer.value = keyRangeAdjustment.value; + }, + { immediate: true } +); + +watch( + volumeRangeAdjustment, + () => { + volumeRangeAdjustmentInputBuffer.value = volumeRangeAdjustment.value; + }, + { immediate: true } +); const setBpmInputBuffer = (bpmStr: string | number | null) => { const bpmValue = Number(bpmStr); @@ -235,12 +265,24 @@ const setBeatTypeInputBuffer = (beatTypeStr: string | number | null) => { beatTypeInputBuffer.value = beatTypeValue; }; -const setKeyShiftInputBuffer = (keyShiftStr: string | number | null) => { - const keyShiftValue = Number(keyShiftStr); - if (!isValidVoiceKeyShift(keyShiftValue)) { +const setKeyRangeAdjustmentInputBuffer = ( + KeyRangeAdjustmentStr: string | number | null +) => { + const KeyRangeAdjustmentValue = Number(KeyRangeAdjustmentStr); + if (!isValidKeyRangeAdjustment(KeyRangeAdjustmentValue)) { + return; + } + keyRangeAdjustmentInputBuffer.value = KeyRangeAdjustmentValue; +}; + +const setVolumeRangeAdjustmentInputBuffer = ( + volumeRangeAdjustmentStr: string | number | null +) => { + const volumeRangeAdjustmentValue = Number(volumeRangeAdjustmentStr); + if (!isValidvolumeRangeAdjustment(volumeRangeAdjustmentValue)) { return; } - keyShiftInputBuffer.value = keyShiftValue; + volumeRangeAdjustmentInputBuffer.value = volumeRangeAdjustmentValue; }; const setTempo = () => { @@ -265,9 +307,16 @@ const setTimeSignature = () => { }); }; -const setKeyShift = () => { - const voiceKeyShift = keyShiftInputBuffer.value; - store.dispatch("COMMAND_SET_VOICE_KEY_SHIFT", { voiceKeyShift }); +const setKeyRangeAdjustment = () => { + const keyRangeAdjustment = keyRangeAdjustmentInputBuffer.value; + store.dispatch("COMMAND_SET_KEY_RANGE_ADJUSTMENT", { keyRangeAdjustment }); +}; + +const setVolumeRangeAdjustment = () => { + const volumeRangeAdjustment = volumeRangeAdjustmentInputBuffer.value; + store.dispatch("COMMAND_SET_VOLUME_RANGE_ADJUSTMENT", { + volumeRangeAdjustment, + }); }; const playheadTicks = ref(0); @@ -407,12 +456,18 @@ onUnmounted(() => { flex: 1; } -.key-shift { +.key-range-adjustment { margin-left: 16px; margin-right: 4px; width: 50px; } +.volume-range-adjustment { + margin-left: 4px; + margin-right: 4px; + width: 50px; +} + .sing-tempo { margin-left: 8px; margin-right: 4px; diff --git a/src/components/Talk/AudioParameter.vue b/src/components/Talk/AudioParameter.vue index d16d75ccd4..ec2c94d26b 100644 --- a/src/components/Talk/AudioParameter.vue +++ b/src/components/Talk/AudioParameter.vue @@ -17,6 +17,16 @@ : undefined }} + + 無声化した音にイントネーションは存在しません。
テキストをクリックすることで無声化を解けます。
- + diff --git a/src/components/Talk/TalkEditor.vue b/src/components/Talk/TalkEditor.vue index fdab3ff9a4..5c3c48e603 100644 --- a/src/components/Talk/TalkEditor.vue +++ b/src/components/Talk/TalkEditor.vue @@ -228,10 +228,11 @@ const removeAudioItem = async () => { }; // view -const DEFAULT_PORTRAIT_PANE_WIDTH = 25; // % +const DEFAULT_PORTRAIT_PANE_WIDTH = 22; // % const MIN_PORTRAIT_PANE_WIDTH = 0; const MAX_PORTRAIT_PANE_WIDTH = 40; -const MIN_AUDIO_INFO_PANE_WIDTH = 160; // px +const DEFAULT_AUDIO_INFO_PANE_WIDTH = 200; // px +const MIN_AUDIO_INFO_PANE_WIDTH = 160; const MAX_AUDIO_INFO_PANE_WIDTH = 250; const MIN_AUDIO_DETAIL_PANE_HEIGHT = isDesktop.value ? 185 : 220; // px const MAX_AUDIO_DETAIL_PANE_HEIGHT = 500; @@ -394,7 +395,8 @@ watch(shouldShowSidePanes, (val, old) => { ); audioInfoPaneWidth.value = clamp( - splitterPosition.value.audioInfoPaneWidth ?? MIN_AUDIO_INFO_PANE_WIDTH, + splitterPosition.value.audioInfoPaneWidth ?? + DEFAULT_AUDIO_INFO_PANE_WIDTH, MIN_AUDIO_INFO_PANE_WIDTH, MAX_AUDIO_INFO_PANE_WIDTH ); diff --git a/src/index.html b/src/index.html index 79c0fd92ec..074aeafbe9 100644 --- a/src/index.html +++ b/src/index.html @@ -18,7 +18,7 @@ import { Buffer } from "buffer"; window.Buffer = Buffer; - + diff --git a/src/infrastructures/EngineConnector.ts b/src/infrastructures/EngineConnector.ts index 0f4e36f970..5ef201af07 100644 --- a/src/infrastructures/EngineConnector.ts +++ b/src/infrastructures/EngineConnector.ts @@ -1,4 +1,4 @@ -import { coreBasedApi } from "@/backend/mobile"; +import { coreBasedApi } from "@/backend/mobile/engine"; import { Configuration, DefaultApi, DefaultApiInterface } from "@/openapi"; export interface IEngineConnectorFactory { diff --git a/src/main.ts b/src/main.ts index bbd8128238..0cf3e49ad1 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,11 +2,8 @@ import { createApp } from "vue"; import { createGtm } from "@gtm-support/vue-gtm"; import { Quasar, Dialog, Loading, Notify } from "quasar"; import iconSet from "quasar/icon-set/material-icons"; -import { Capacitor } from "@capacitor/core"; -import router from "./router"; import { store, storeKey } from "./store"; import { ipcMessageReceiver } from "./plugins/ipcMessageReceiverPlugin"; -import * as mobile from "./backend/mobile"; import { hotkeyPlugin } from "./plugins/hotkeyPlugin"; import App from "@/components/App.vue"; import { markdownItPlugin } from "@/plugins/markdownItPlugin"; @@ -19,23 +16,13 @@ import "./styles/_index.scss"; // ため、それを防止するため自前でdataLayerをあらかじめ用意する window.dataLayer = []; -if (Capacitor.isNativePlatform()) { - // eslint-disable-next-line no-console - console.log("Running in Capacitor"); - mobile.loadMock(); - mobile.loadPlugin(); - mobile.loadCoreBasedApi(); -} - document.body.setAttribute("data-target", import.meta.env.VITE_TARGET); createApp(App) .use(store, storeKey) - .use(router) .use( createGtm({ id: import.meta.env.VITE_GTM_CONTAINER_ID ?? "GTM-DUMMY", - vueRouter: router, // NOTE: 最初はgtm.jsを読まず、プライバシーポリシーに同意後に読み込む enabled: false, }) diff --git a/src/plugins/hotkeyPlugin.ts b/src/plugins/hotkeyPlugin.ts index ddb5948f74..2efb55295c 100644 --- a/src/plugins/hotkeyPlugin.ts +++ b/src/plugins/hotkeyPlugin.ts @@ -34,6 +34,8 @@ export const useHotkeyManager = () => { type Editor = "talk" | "song"; +type BindingKey = string & { __brand: "BindingKey" }; // BindingKey専用のブランド型 + /** * ショートカットキーの処理を登録するための型。 */ @@ -50,16 +52,17 @@ export type HotkeyAction = { export type HotkeysJs = { ( - key: string, + key: BindingKey, options: { scope: string; }, callback: (e: KeyboardEvent) => void ): void; - unbind: (key: string, scope: string) => void; + unbind: (key: BindingKey, scope: string) => void; setScope: (scope: string) => void; }; +// デフォルトはテキストボックス内でショートカットキー無効なので有効にする hotkeys.filter = () => { return true; }; @@ -269,8 +272,19 @@ export class HotkeyManager { } } -const combinationToBindingKey = (combination: HotkeyCombination) => { - return combination.toLowerCase().replaceAll(" ", "+"); +/** hotkeys-js用のキーに変換する */ +const combinationToBindingKey = ( + combination: HotkeyCombination +): BindingKey => { + // MetaキーはCommandキーとして扱う + // NOTE: hotkeys-jsにはWinキーが無く、Commandキーとして扱われている + // NOTE: Metaキーは以前採用していたmousetrapがそうだった名残り + const bindingKey = combination + .toLowerCase() + .split(" ") + .map((key) => (key === "meta" ? "command" : key)) + .join("+"); + return bindingKey as BindingKey; }; export const hotkeyPlugin: Plugin = { diff --git a/src/router/index.ts b/src/router/index.ts deleted file mode 100644 index 96c36027ca..0000000000 --- a/src/router/index.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { createRouter, createWebHashHistory, RouteRecordRaw } from "vue-router"; -import SingEditor from "@/components/Sing/SingEditor.vue"; -import TalkEditor from "@/components/Talk/TalkEditor.vue"; - -const routes: Array = [ - { - path: "/talk", - component: TalkEditor, - alias: "/", - }, - { - path: "/song", - component: SingEditor, - }, -]; - -const router = createRouter({ - history: createWebHashHistory(import.meta.env.BASE_URL), - routes, -}); - -export default router; diff --git a/src/sing/domain.ts b/src/sing/domain.ts index 63952b58d7..146115834e 100644 --- a/src/sing/domain.ts +++ b/src/sing/domain.ts @@ -1,4 +1,4 @@ -import { Note, Score, Tempo, TimeSignature } from "@/store/type"; +import { Note, Phrase, Score, Tempo, TimeSignature } from "@/store/type"; const BEAT_TYPES = [2, 4, 8, 16]; const MIN_BPM = 40; @@ -279,10 +279,58 @@ export function isValidSnapType(snapType: number, tpqn: number) { return getSnapTypes(tpqn).some((value) => value === snapType); } -export function isValidVoiceKeyShift(voiceKeyShift: number) { +export function isValidKeyRangeAdjustment(keyRangeAdjustment: number) { return ( - Number.isInteger(voiceKeyShift) && - voiceKeyShift <= 24 && - voiceKeyShift >= -24 + Number.isInteger(keyRangeAdjustment) && + keyRangeAdjustment <= 28 && + keyRangeAdjustment >= -28 ); } + +export function isValidvolumeRangeAdjustment(volumeRangeAdjustment: number) { + return ( + Number.isInteger(volumeRangeAdjustment) && + volumeRangeAdjustment <= 20 && + volumeRangeAdjustment >= -20 + ); +} + +export function toSortedPhrases(phrases: Map) { + return [...phrases.entries()].sort((a, b) => { + return a[1].startTicks - b[1].startTicks; + }); +} + +/** + * 次にレンダリングするべきPhraseを探す。 + * phrasesが空の場合はエラー + * 優先順: + * - 再生位置が含まれるPhrase + * - 再生位置より後のPhrase + * - 再生位置より前のPhrase + */ +export function selectPriorPhrase( + phrases: Map, + position: number +): [string, Phrase] { + if (phrases.size === 0) { + throw new Error("Received empty phrases"); + } + // 再生位置が含まれるPhrase + for (const [phraseKey, phrase] of phrases) { + if (phrase.startTicks <= position && position <= phrase.endTicks) { + return [phraseKey, phrase]; + } + } + + const sortedPhrases = toSortedPhrases(phrases); + // 再生位置より後のPhrase + for (const [phraseKey, phrase] of sortedPhrases) { + if (phrase.startTicks > position) { + return [phraseKey, phrase]; + } + } + + // 再生位置より前のPhrase + return sortedPhrases[0]; +} diff --git a/src/sing/storeHelper.ts b/src/sing/storeHelper.ts index 1b8345ef95..bb70d5c170 100644 --- a/src/sing/storeHelper.ts +++ b/src/sing/storeHelper.ts @@ -8,8 +8,8 @@ export const DEFAULT_BEAT_TYPE = 4; export const generatePhraseHash = async (obj: { singer: Singer | undefined; - notesKeyShift: number; - voiceKeyShift: number; + keyRangeAdjustment: number; + volumeRangeAdjustment: number; tpqn: number; tempos: Tempo[]; notes: Note[]; diff --git a/src/store/command.ts b/src/store/command.ts index 6db4eced35..9d52992955 100644 --- a/src/store/command.ts +++ b/src/store/command.ts @@ -118,6 +118,8 @@ export const commandStore = createPartialStore({ action({ commit, dispatch }, { editor }: { editor: EditorType }) { commit("UNDO", { editor }); if (editor === "song") { + // TODO: 存在しないノートのみ選択解除、あるいはSELECTED_NOTE_IDS getterを作る + commit("DESELECT_ALL_NOTES"); dispatch("RENDER"); } }, @@ -134,6 +136,8 @@ export const commandStore = createPartialStore({ action({ commit, dispatch }, { editor }: { editor: EditorType }) { commit("REDO", { editor }); if (editor === "song") { + // TODO: 存在しないノートのみ選択解除、あるいはSELECTED_NOTE_IDS getterを作る + commit("DESELECT_ALL_NOTES"); dispatch("RENDER"); } }, diff --git a/src/store/project.ts b/src/store/project.ts index 2064844eef..923c6e01c6 100755 --- a/src/store/project.ts +++ b/src/store/project.ts @@ -111,8 +111,11 @@ const applySongProjectToStore = async ( await dispatch("SET_SINGER", { singer: tracks[0].singer, }); - await dispatch("SET_VOICE_KEY_SHIFT", { - voiceKeyShift: tracks[0].voiceKeyShift, + await dispatch("SET_KEY_RANGE_ADJUSTMENT", { + keyRangeAdjustment: tracks[0].keyRangeAdjustment, + }); + await dispatch("SET_VOLUME_RANGE_ADJUSTMENT", { + volumeRangeAdjustment: tracks[0].volumeRangeAdjustment, }); await dispatch("SET_SCORE", { score: { @@ -397,13 +400,9 @@ export const projectStore = createPartialStore({ } if ( - semver.satisfies( - projectAppVersion, - "<0.16.2", - semverSatisfiesOptions - ) + semver.satisfies(projectAppVersion, "<0.17", semverSatisfiesOptions) ) { - // 0.16.2 未満のプロジェクトファイルはトークの情報のみ + // 0.17 未満のプロジェクトファイルはトークの情報のみ // なので全情報(audioKeys/audioItems)をtalkに移動する projectData.talk = { audioKeys: projectData.audioKeys, @@ -412,7 +411,7 @@ export const projectStore = createPartialStore({ // ソングの情報を初期化 // generateSingingStoreInitialScoreが今後変わることがあるかもしれないので、 - // 0.16.2時点のスコア情報を直接書く + // 0.17時点のスコア情報を直接書く projectData.song = { tpqn: DEFAULT_TPQN, tempos: [ @@ -431,8 +430,7 @@ export const projectStore = createPartialStore({ tracks: [ { singer: undefined, - notesKeyShift: 0, - voiceKeyShift: 0, + keyRangeAdjustment: 0, notes: [], }, ], @@ -442,6 +440,19 @@ export const projectStore = createPartialStore({ delete projectData.audioItems; } + if ( + semver.satisfies( + projectAppVersion, + "<0.17.1", + semverSatisfiesOptions + ) + ) { + // volumeRangeAdjustmentの追加 + for (const track of projectData.song.tracks) { + track.volumeRangeAdjustment = 0; + } + } + // Validation check // トークはvalidateTalkProjectで検証する // ソングはSET_SCOREの中の`isValidScore`関数で検証される diff --git a/src/store/singing.ts b/src/store/singing.ts index 4ba8f477cf..185ec834c0 100644 --- a/src/store/singing.ts +++ b/src/store/singing.ts @@ -17,6 +17,7 @@ import { Phrase, PhraseState, transformCommandStore, + noteSchema, } from "./type"; import { sanitizeFileName } from "./utility"; import { EngineId } from "@/type/preload"; @@ -38,13 +39,16 @@ import { Transport, } from "@/sing/audioRendering"; import { + selectPriorPhrase, getMeasureDuration, + getNoteDuration, isValidNote, isValidScore, isValidSnapType, isValidTempo, isValidTimeSignature, - isValidVoiceKeyShift, + isValidKeyRangeAdjustment, + isValidvolumeRangeAdjustment, secondToTick, tickToSecond, } from "@/sing/domain"; @@ -144,8 +148,8 @@ export const generateSingingStoreInitialScore = () => { tracks: [ { singer: undefined, - notesKeyShift: 0, - voiceKeyShift: 0, + keyRangeAdjustment: 0, + volumeRangeAdjustment: 0, notes: [], }, ], @@ -165,8 +169,6 @@ export const singingStoreState: SingingStoreState = { overlappingNoteInfos: new Map(), nowPlaying: false, volume: 0, - leftLocatorPosition: 0, - rightLocatorPosition: 0, startRenderingRequested: false, stopRenderingRequested: false, nowRendering: false, @@ -223,25 +225,48 @@ export const singingStore = createPartialStore({ const styleId = singer?.styleId ?? defaultStyleId; - await dispatch("SETUP_SINGER", { singer: { engineId, styleId } }); + dispatch("SETUP_SINGER", { singer: { engineId, styleId } }); commit("SET_SINGER", { singer: { engineId, styleId } }); dispatch("RENDER"); }, }, - SET_VOICE_KEY_SHIFT: { - mutation(state, { voiceKeyShift }: { voiceKeyShift: number }) { - state.tracks[selectedTrackIndex].voiceKeyShift = voiceKeyShift; + SET_KEY_RANGE_ADJUSTMENT: { + mutation(state, { keyRangeAdjustment }: { keyRangeAdjustment: number }) { + state.tracks[selectedTrackIndex].keyRangeAdjustment = keyRangeAdjustment; }, async action( { dispatch, commit }, - { voiceKeyShift }: { voiceKeyShift: number } + { keyRangeAdjustment }: { keyRangeAdjustment: number } ) { - if (!isValidVoiceKeyShift(voiceKeyShift)) { - throw new Error("The voiceKeyShift is invalid."); + if (!isValidKeyRangeAdjustment(keyRangeAdjustment)) { + throw new Error("The keyRangeAdjustment is invalid."); } - commit("SET_VOICE_KEY_SHIFT", { voiceKeyShift }); + commit("SET_KEY_RANGE_ADJUSTMENT", { keyRangeAdjustment }); + + dispatch("RENDER"); + }, + }, + + SET_VOLUME_RANGE_ADJUSTMENT: { + mutation( + state, + { volumeRangeAdjustment }: { volumeRangeAdjustment: number } + ) { + state.tracks[selectedTrackIndex].volumeRangeAdjustment = + volumeRangeAdjustment; + }, + async action( + { dispatch, commit }, + { volumeRangeAdjustment }: { volumeRangeAdjustment: number } + ) { + if (!isValidvolumeRangeAdjustment(volumeRangeAdjustment)) { + throw new Error("The volumeRangeAdjustment is invalid."); + } + commit("SET_VOLUME_RANGE_ADJUSTMENT", { + volumeRangeAdjustment, + }); dispatch("RENDER"); }, @@ -439,6 +464,17 @@ export const singingStore = createPartialStore({ }, }, + SELECT_ALL_NOTES: { + mutation(state) { + const currentTrack = state.tracks[selectedTrackIndex]; + const allNoteIds = currentTrack.notes.map((note) => note.id); + state.selectedNoteIds = new Set(allNoteIds); + }, + async action({ commit }) { + commit("SELECT_ALL_NOTES"); + }, + }, + DESELECT_ALL_NOTES: { mutation(state) { state.editingLyricNoteId = undefined; @@ -612,24 +648,6 @@ export const singingStore = createPartialStore({ }, }, - SET_LEFT_LOCATOR_POSITION: { - mutation(state, { position }) { - state.leftLocatorPosition = position; - }, - async action({ commit }, { position }) { - commit("SET_LEFT_LOCATOR_POSITION", { position }); - }, - }, - - SET_RIGHT_LOCATOR_POSITION: { - mutation(state, { position }) { - state.rightLocatorPosition = position; - }, - async action({ commit }, { position }) { - commit("SET_RIGHT_LOCATOR_POSITION", { position }); - }, - }, - SET_PLAYBACK_STATE: { mutation(state, { nowPlaying }) { state.nowPlaying = nowPlaying; @@ -746,40 +764,41 @@ export const singingStore = createPartialStore({ async action({ state, getters, commit, dispatch }) { const searchPhrases = async ( singer: Singer | undefined, - notesKeyShift: number, - voiceKeyShift: number, + keyRangeAdjustment: number, + volumeRangeAdjustment: number, tpqn: number, tempos: Tempo[], notes: Note[] ) => { const foundPhrases = new Map(); let phraseNotes: Note[] = []; - for (let i = 0; i < notes.length; i++) { - const note = notes[i]; + for (let noteIndex = 0; noteIndex < notes.length; noteIndex++) { + const note = notes[noteIndex]; phraseNotes.push(note); + // ノートが途切れていたら別のフレーズにする + const currentNoteEnd = note.position + note.duration; + const nextNoteStart = + noteIndex + 1 < notes.length ? notes[noteIndex + 1].position : null; if ( - i === notes.length - 1 || - note.position + note.duration !== notes[i + 1].position + noteIndex === notes.length - 1 || + nextNoteStart == null || + currentNoteEnd !== nextNoteStart ) { const phraseFirstNote = phraseNotes[0]; const phraseLastNote = phraseNotes[phraseNotes.length - 1]; - const hash = await generatePhraseHash({ + const params = { singer, - notesKeyShift, - voiceKeyShift, + keyRangeAdjustment, + volumeRangeAdjustment, tpqn, tempos, notes: phraseNotes, - }); + }; + const hash = await generatePhraseHash(params); foundPhrases.set(hash, { - singer, - notesKeyShift, - voiceKeyShift, - tpqn, - tempos, - notes: phraseNotes, + ...params, startTicks: phraseFirstNote.position, endTicks: phraseLastNote.position + phraseLastNote.duration, state: "WAITING_TO_BE_RENDERED", @@ -791,18 +810,12 @@ export const singingStore = createPartialStore({ return foundPhrases; }; - const getSortedPhrasesEntries = (phrases: Map) => { - return [...phrases.entries()].sort((a, b) => { - return a[1].startTicks - b[1].startTicks; - }); - }; - const fetchQuery = async ( engineId: EngineId, notes: Note[], tempos: Tempo[], tpqn: number, - notesKeyShift: number, + keyRangeAdjustment: number, frameRate: number, restDurationSeconds: number ) => { @@ -834,8 +847,10 @@ export const singingStore = createPartialStore({ .replace("うぉ", "ウォ") .replace("は", "ハ") .replace("へ", "ヘ"); + // トランスポーズする + const key = note.noteNumber - keyRangeAdjustment; notesForRequestToEngine.push({ - key: note.noteNumber + notesKeyShift, + key, frameLength: noteFrameLength, lyric, }); @@ -877,12 +892,21 @@ export const singingStore = createPartialStore({ return frameAudioQuery.phonemes.map((value) => value.phoneme).join(" "); }; - const shiftVoiceKey = ( - voiceKeyShift: number, + const shiftGuidePitch = ( + pitchShift: number, frameAudioQuery: FrameAudioQuery ) => { frameAudioQuery.f0 = frameAudioQuery.f0.map((value) => { - return value * Math.pow(2, voiceKeyShift / 12); + return value * Math.pow(2, pitchShift / 12); + }); + }; + + const scaleGuideVolume = ( + volumeRangeAdjustment: number, + frameAudioQuery: FrameAudioQuery + ) => { + frameAudioQuery.volume = frameAudioQuery.volume.map((value) => { + return value * Math.pow(10, volumeRangeAdjustment / 20); }); }; @@ -942,8 +966,8 @@ export const singingStore = createPartialStore({ const tempos = state.tempos.map((value) => ({ ...value })); const track = getters.SELECTED_TRACK; const singer = track.singer ? { ...track.singer } : undefined; - const notesKeyShift = track.notesKeyShift; - const voiceKeyShift = track.voiceKeyShift; + const keyRangeAdjustment = track.keyRangeAdjustment; + const volumeRangeAdjustment = track.volumeRangeAdjustment; const notes = track.notes .map((value) => ({ ...value })) .filter((value) => !state.overlappingNoteIds.has(value.id)); @@ -951,8 +975,8 @@ export const singingStore = createPartialStore({ // フレーズを更新する const foundPhrases = await searchPhrases( singer, - notesKeyShift, - voiceKeyShift, + keyRangeAdjustment, + volumeRangeAdjustment, tpqn, tempos, notes @@ -1007,12 +1031,28 @@ export const singingStore = createPartialStore({ return; } + const phrasesToBeRendered = new Map( + [...state.phrases.entries()].filter(([, phrase]) => { + return ( + (phrase.state === "WAITING_TO_BE_RENDERED" || + phrase.state === "COULD_NOT_RENDER") && + phrase.singer + ); + }) + ); // 各フレーズのレンダリングを行う - const sortedPhrasesEntries = getSortedPhrasesEntries(state.phrases); - for (const [phraseKey, phrase] of sortedPhrasesEntries) { + while ( + !(startRenderingRequested() || stopRenderingRequested()) && + phrasesToBeRendered.size > 0 + ) { + const [phraseKey, phrase] = selectPriorPhrase( + phrasesToBeRendered, + playheadPosition.value + ); if (!phrase.singer) { - continue; + throw new Error("assert: phrase.singer != undefined"); } + phrasesToBeRendered.delete(phraseKey); if ( phrase.state === "WAITING_TO_BE_RENDERED" || @@ -1036,7 +1076,7 @@ export const singingStore = createPartialStore({ phrase.notes, phrase.tempos, phrase.tpqn, - phrase.notesKeyShift, + phrase.keyRangeAdjustment, frameRate, restDurationSeconds ).catch((error) => { @@ -1052,7 +1092,8 @@ export const singingStore = createPartialStore({ `Fetched frame audio query. Phonemes are "${phonemes}".` ); - shiftVoiceKey(phrase.voiceKeyShift, frameAudioQuery); + shiftGuidePitch(phrase.keyRangeAdjustment, frameAudioQuery); + scaleGuideVolume(volumeRangeAdjustment, frameAudioQuery); const startTime = calcStartTime( phrase.notes, @@ -1141,10 +1182,6 @@ export const singingStore = createPartialStore({ phraseState: "PLAYABLE", }); } - - if (startRenderingRequested() || stopRenderingRequested()) { - return; - } } }; @@ -1196,7 +1233,10 @@ export const singingStore = createPartialStore({ IMPORT_MIDI_FILE: { action: createUILockAction( - async ({ dispatch }, { filePath }: { filePath?: string }) => { + async ( + { dispatch }, + { filePath, trackIndex = 0 }: { filePath: string; trackIndex: number } + ) => { const convertPosition = ( position: number, sourceTpqn: number, @@ -1265,25 +1305,16 @@ export const singingStore = createPartialStore({ }); }; - if (!filePath) { - filePath = await window.backend.showImportFileDialog({ - title: "MIDI読み込み", - name: "MIDI", - extensions: ["mid", "midi"], - }); - if (!filePath) return; - } - + // NOTE: トラック選択のために一度ファイルを読み込んでいるので、Midiを渡すなどでもよさそう const midiData = getValueOrThrow( await window.backend.readFile({ filePath }) ); const midi = new Midi(midiData); - const midiTpqn = midi.header.ppq; const midiTempos = [...midi.header.tempos]; const midiTimeSignatures = [...midi.header.timeSignatures]; - // TODO: UIで読み込むトラックを選択できるようにする - const midiNotes = [...midi.tracks[0].notes]; // ひとまず1トラック目のみを読み込む + + const midiNotes = [...midi.tracks[trackIndex].notes]; midiTempos.sort((a, b) => a.ticks - b.ticks); midiTimeSignatures.sort((a, b) => a.ticks - b.ticks); @@ -1681,6 +1712,127 @@ export const singingStore = createPartialStore({ ), }, + IMPORT_UST_FILE: { + action: createUILockAction( + async ({ dispatch }, { filePath }: { filePath?: string }) => { + // USTファイルの読み込み + if (!filePath) { + filePath = await window.backend.showImportFileDialog({ + title: "UST読み込み", + name: "UST", + extensions: ["ust"], + }); + if (!filePath) return; + } + // ファイルの読み込み + const fileData = getValueOrThrow( + await window.backend.readFile({ filePath }) + ); + + // ファイルフォーマットに応じてエンコーディングを変える + // UTF-8とShiftJISの2種類に対応 + let ustData; + try { + ustData = new TextDecoder("utf-8").decode(fileData); + // ShiftJISの場合はShiftJISでデコードし直す + if (ustData.includes("\ufffd")) { + ustData = new TextDecoder("shift-jis").decode(fileData); + } + } catch (error) { + throw new Error("Failed to decode UST file.", { cause: error }); + } + if (!ustData || typeof ustData !== "string") { + throw new Error("Failed to decode UST file."); + } + + // 初期化 + const tpqn = DEFAULT_TPQN; + const tempos: Tempo[] = [ + { + position: 0, + bpm: DEFAULT_BPM, + }, + ]; + const timeSignatures: TimeSignature[] = [ + { + measureNumber: 1, + beats: DEFAULT_BEATS, + beatType: DEFAULT_BEAT_TYPE, + }, + ]; + const notes: Note[] = []; + + // USTファイルのセクションをパース + const parseSection = (section: string): { [key: string]: string } => { + const sectionNameMatch = section.match(/\[(.+)\]/); + if (!sectionNameMatch) { + throw new Error("UST section name not found"); + } + const params = section.split(/[\r\n]+/).reduce((acc, line) => { + const [key, value] = line.split("="); + if (key && value) { + acc[key] = value; + } + return acc; + }, {} as { [key: string]: string }); + return { + ...params, + sectionName: sectionNameMatch[1], + }; + }; + + // セクションを分割 + const sections = ustData.split(/^(?=\[)/m); + // ポジション + let position = 0; + // セクションごとに処理 + sections.forEach((section) => { + const params = parseSection(section); + // SETTINGセクション + if (params.sectionName === "#SETTING") { + const tempo = Number(params["Tempo"]); + if (tempo) tempos[0].bpm = tempo; + } + // ノートセクション + if (params.sectionName.match(/^#\d{4}/)) { + // テンポ変更があれば追加 + const tempo = Number(params["Tempo"]); + if (tempo) tempos.push({ position, bpm: tempo }); + const noteNumber = Number(params["NoteNum"]); + const duration = Number(params["Length"]); + // 歌詞の前に連続音が含まれている場合は除去 + const lyric = params["Lyric"].includes(" ") + ? params["Lyric"].split(" ")[1] + : params["Lyric"]; + // 休符であればポジションを進めるのみ + if (lyric === "R") { + position += duration; + } else { + // それ以外の場合はノートを追加 + notes.push({ + id: uuidv4(), + position, + duration, + noteNumber, + lyric, + }); + position += duration; + } + } + }); + + await dispatch("SET_SCORE", { + score: { + tpqn, + tempos, + timeSignatures, + notes, + }, + }); + } + ), + }, + SET_NOW_AUDIO_EXPORTING: { mutation(state, { nowAudioExporting }) { state.nowAudioExporting = nowAudioExporting; @@ -1960,6 +2112,115 @@ export const singingStore = createPartialStore({ }); }, }, + + COPY_NOTES_TO_CLIPBOARD: { + async action({ state, getters }) { + const currentTrack = getters.SELECTED_TRACK; + const noteIds = state.selectedNoteIds; + // ノートが選択されていない場合は何もしない + if (noteIds.size === 0) { + return; + } + // 選択されたノートのみをコピーする + const selectedNotes = currentTrack.notes + .filter((note: Note) => noteIds.has(note.id)) + .map((note: Note) => { + // idのみコピーしない + const { id, ...noteWithoutId } = note; + return noteWithoutId; + }); + // ノートをJSONにシリアライズしてクリップボードにコピーする + const serializedNotes = JSON.stringify(selectedNotes); + // クリップボードにテキストとしてコピーする + // NOTE: Electronのclipboardも使用する必要ある? + await navigator.clipboard.writeText(serializedNotes); + window.backend.logInfo("Copied to clipboard.", serializedNotes); + }, + }, + + COMMAND_CUT_NOTES_TO_CLIPBOARD: { + async action({ dispatch }) { + await dispatch("COPY_NOTES_TO_CLIPBOARD"); + await dispatch("COMMAND_REMOVE_SELECTED_NOTES"); + }, + }, + + COMMAND_PASTE_NOTES_FROM_CLIPBOARD: { + async action({ commit, state, getters, dispatch }) { + // クリップボードからテキストを読み込む + let clipboardText; + try { + clipboardText = await navigator.clipboard.readText(); + } catch (error) { + throw new Error("Failed to read the clipboard text.", { + cause: error, + }); + } + + // クリップボードのテキストをJSONとしてパースする(失敗した場合はエラーを返す) + let notes; + try { + notes = noteSchema + .omit({ id: true }) + .array() + .parse(JSON.parse(clipboardText)); + } catch (error) { + throw new Error("Failed to parse the clipboard text as JSON.", { + cause: error, + }); + } + + // パースしたJSONのノートの位置を現在の再生位置に合わせてクオンタイズして貼り付ける + const currentPlayheadPosition = getters.GET_PLAYHEAD_POSITION(); + const firstNotePosition = notes[0].position; + // TODO: クオンタイズの処理を共通化する + const snapType = state.sequencerSnapType; + const tpqn = state.tpqn; + const snapTicks = getNoteDuration(snapType, tpqn); + const notesToPaste: Note[] = notes.map((note) => { + // 新しい位置を現在の再生位置に合わせて計算する + const pasteOriginPos = + Number(note.position) - firstNotePosition + currentPlayheadPosition; + // クオンタイズ + const quantizedPastePos = + Math.round(pasteOriginPos / snapTicks) * snapTicks; + return { + id: uuidv4(), + position: quantizedPastePos, + duration: Number(note.duration), + noteNumber: Number(note.noteNumber), + lyric: String(note.lyric), + }; + }); + const pastedNoteIds = notesToPaste.map((note) => note.id); + // ノートを追加してレンダリングする + commit("COMMAND_ADD_NOTES", { notes: notesToPaste }); + dispatch("RENDER"); + // 貼り付けたノートを選択する + commit("DESELECT_ALL_NOTES"); + commit("SELECT_NOTES", { noteIds: pastedNoteIds }); + }, + }, + + COMMAND_QUANTIZE_SELECTED_NOTES: { + action({ state, commit, getters, dispatch }) { + const currentTrack = getters.SELECTED_TRACK; + const selectedNotes = currentTrack.notes.filter((note: Note) => { + return state.selectedNoteIds.has(note.id); + }); + // TODO: クオンタイズの処理を共通化する + const snapType = state.sequencerSnapType; + const tpqn = state.tpqn; + const snapTicks = getNoteDuration(snapType, tpqn); + const quantizedNotes = selectedNotes.map((note: Note) => { + const quantizedPosition = + Math.round(note.position / snapTicks) * snapTicks; + return { ...note, position: quantizedPosition }; + }); + commit("COMMAND_UPDATE_NOTES", { notes: quantizedNotes }); + dispatch("RENDER"); + }, + }, }); export const singingCommandStoreState: SingingCommandStoreState = {}; @@ -1971,24 +2232,46 @@ export const singingCommandStore = transformCommandStore( singingStore.mutations.SET_SINGER(draft, { singer }); }, async action({ dispatch, commit }, { singer }) { - await dispatch("SETUP_SINGER", { singer }); + dispatch("SETUP_SINGER", { singer }); commit("COMMAND_SET_SINGER", { singer }); dispatch("RENDER"); }, }, - COMMAND_SET_VOICE_KEY_SHIFT: { - mutation(draft, { voiceKeyShift }) { - singingStore.mutations.SET_VOICE_KEY_SHIFT(draft, { voiceKeyShift }); + COMMAND_SET_KEY_RANGE_ADJUSTMENT: { + mutation(draft, { keyRangeAdjustment }) { + singingStore.mutations.SET_KEY_RANGE_ADJUSTMENT(draft, { + keyRangeAdjustment, + }); + }, + async action( + { dispatch, commit }, + { keyRangeAdjustment }: { keyRangeAdjustment: number } + ) { + if (!isValidKeyRangeAdjustment(keyRangeAdjustment)) { + throw new Error("The keyRangeAdjustment is invalid."); + } + commit("COMMAND_SET_KEY_RANGE_ADJUSTMENT", { keyRangeAdjustment }); + + dispatch("RENDER"); + }, + }, + COMMAND_SET_VOLUME_RANGE_ADJUSTMENT: { + mutation(draft, { volumeRangeAdjustment }) { + singingStore.mutations.SET_VOLUME_RANGE_ADJUSTMENT(draft, { + volumeRangeAdjustment, + }); }, async action( { dispatch, commit }, - { voiceKeyShift }: { voiceKeyShift: number } + { volumeRangeAdjustment }: { volumeRangeAdjustment: number } ) { - if (!isValidVoiceKeyShift(voiceKeyShift)) { - throw new Error("The voiceKeyShift is invalid."); + if (!isValidvolumeRangeAdjustment(volumeRangeAdjustment)) { + throw new Error("The volumeRangeAdjustment is invalid."); } - commit("COMMAND_SET_VOICE_KEY_SHIFT", { voiceKeyShift }); + commit("COMMAND_SET_VOLUME_RANGE_ADJUSTMENT", { + volumeRangeAdjustment, + }); dispatch("RENDER"); }, diff --git a/src/store/type.ts b/src/store/type.ts index c283b26936..8543431213 100644 --- a/src/store/type.ts +++ b/src/store/type.ts @@ -751,8 +751,8 @@ export type Singer = z.infer; export const trackSchema = z.object({ singer: singerSchema.optional(), - notesKeyShift: z.number(), - voiceKeyShift: z.number(), + keyRangeAdjustment: z.number(), // 音域調整量 + volumeRangeAdjustment: z.number(), // 声量調整量 notes: z.array(noteSchema), }); export type Track = z.infer; @@ -765,8 +765,8 @@ export type PhraseState = export type Phrase = { singer?: Singer; - notesKeyShift: number; - voiceKeyShift: number; + keyRangeAdjustment: number; + volumeRangeAdjustment: number; tpqn: number; tempos: Tempo[]; notes: Note[]; @@ -794,8 +794,6 @@ export type SingingStoreState = { editingLyricNoteId?: string; nowPlaying: boolean; volume: number; - leftLocatorPosition: number; - rightLocatorPosition: number; startRenderingRequested: boolean; stopRenderingRequested: boolean; nowRendering: boolean; @@ -818,9 +816,14 @@ export type SingingStoreTypes = { action(payload: { singer?: Singer }): void; }; - SET_VOICE_KEY_SHIFT: { - mutation: { voiceKeyShift: number }; - action(payload: { voiceKeyShift: number }): void; + SET_KEY_RANGE_ADJUSTMENT: { + mutation: { keyRangeAdjustment: number }; + action(payload: { keyRangeAdjustment: number }): void; + }; + + SET_VOLUME_RANGE_ADJUSTMENT: { + mutation: { volumeRangeAdjustment: number }; + action(payload: { volumeRangeAdjustment: number }): void; }; SET_SCORE: { @@ -865,6 +868,11 @@ export type SingingStoreTypes = { action(payload: { noteIds: string[] }): void; }; + SELECT_ALL_NOTES: { + mutation: undefined; + action(): void; + }; + DESELECT_ALL_NOTES: { mutation: undefined; action(): void; @@ -920,13 +928,17 @@ export type SingingStoreTypes = { }; IMPORT_MIDI_FILE: { - action(payload: { filePath?: string }): void; + action(payload: { filePath: string; trackIndex: number }): void; }; IMPORT_MUSICXML_FILE: { action(payload: { filePath?: string }): void; }; + IMPORT_UST_FILE: { + action(payload: { filePath?: string }): void; + }; + EXPORT_WAVE_FILE: { action(payload: { filePath?: string }): SaveResultObject; }; @@ -959,16 +971,6 @@ export type SingingStoreTypes = { action(payload: { listener: (position: number) => void }): void; }; - SET_LEFT_LOCATOR_POSITION: { - mutation: { position: number }; - action(payload: { position: number }): void; - }; - - SET_RIGHT_LOCATOR_POSITION: { - mutation: { position: number }; - action(payload: { position: number }): void; - }; - SET_PLAYBACK_STATE: { mutation: { nowPlaying: boolean }; }; @@ -1021,6 +1023,22 @@ export type SingingStoreTypes = { STOP_RENDERING: { action(): void; }; + + COPY_NOTES_TO_CLIPBOARD: { + action(): void; + }; + + COMMAND_CUT_NOTES_TO_CLIPBOARD: { + action(): void; + }; + + COMMAND_PASTE_NOTES_FROM_CLIPBOARD: { + action(): void; + }; + + COMMAND_QUANTIZE_SELECTED_NOTES: { + action(): void; + }; }; export type SingingCommandStoreState = { @@ -1033,9 +1051,14 @@ export type SingingCommandStoreTypes = { action(payload: { singer: Singer }): void; }; - COMMAND_SET_VOICE_KEY_SHIFT: { - mutation: { voiceKeyShift: number }; - action(payload: { voiceKeyShift: number }): void; + COMMAND_SET_KEY_RANGE_ADJUSTMENT: { + mutation: { keyRangeAdjustment: number }; + action(payload: { keyRangeAdjustment: number }): void; + }; + + COMMAND_SET_VOLUME_RANGE_ADJUSTMENT: { + mutation: { volumeRangeAdjustment: number }; + action(payload: { volumeRangeAdjustment: number }): void; }; COMMAND_SET_TEMPO: { @@ -1506,6 +1529,7 @@ export type SettingStoreTypes = { */ export type UiStoreState = { + openedEditor: EditorType | undefined; // undefinedのときはどのエディタを開くか定まっていない uiLockCount: number; dialogLockCount: number; reloadingLock: boolean; @@ -1522,6 +1546,7 @@ export type UiStoreState = { isDictionaryManageDialogOpen: boolean; isEngineManageDialogOpen: boolean; isUpdateNotificationDialogOpen: boolean; + isImportMidiDialogOpen: boolean; isMaximized: boolean; isPinned: boolean; isFullscreen: boolean; @@ -1530,6 +1555,11 @@ export type UiStoreState = { }; export type UiStoreTypes = { + SET_OPENED_EDITOR: { + mutation: { editor: EditorType }; + action(palyoad: { editor: EditorType }): void; + }; + UI_LOCKED: { getter: boolean; }; @@ -1588,6 +1618,7 @@ export type UiStoreTypes = { isCharacterOrderDialogOpen?: boolean; isEngineManageDialogOpen?: boolean; isUpdateNotificationDialogOpen?: boolean; + isImportMidiDialogOpen?: boolean; }; action(payload: { isDefaultStyleSelectDialogOpen?: boolean; @@ -1601,6 +1632,7 @@ export type UiStoreTypes = { isCharacterOrderDialogOpen?: boolean; isEngineManageDialogOpen?: boolean; isUpdateNotificationDialogOpen?: boolean; + isImportMidiDialogOpen?: boolean; }): void; }; diff --git a/src/store/ui.ts b/src/store/ui.ts index 9215fdb87c..e8e8075dc1 100644 --- a/src/store/ui.ts +++ b/src/store/ui.ts @@ -45,6 +45,7 @@ export function withProgress( } export const uiStoreState: UiStoreState = { + openedEditor: undefined, uiLockCount: 0, dialogLockCount: 0, reloadingLock: false, @@ -61,6 +62,7 @@ export const uiStoreState: UiStoreState = { isDictionaryManageDialogOpen: false, isEngineManageDialogOpen: false, isUpdateNotificationDialogOpen: false, + isImportMidiDialogOpen: false, isMaximized: false, isPinned: false, isFullscreen: false, @@ -69,6 +71,15 @@ export const uiStoreState: UiStoreState = { }; export const uiStore = createPartialStore({ + SET_OPENED_EDITOR: { + mutation(state, { editor }) { + state.openedEditor = editor; + }, + action({ commit }, { editor }) { + commit("SET_OPENED_EDITOR", { editor }); + }, + }, + UI_LOCKED: { getter(state) { return state.uiLockCount > 0; @@ -171,6 +182,7 @@ export const uiStore = createPartialStore({ isCharacterOrderDialogOpen?: boolean; isEngineManageDialogOpen?: boolean; isUpdateNotificationDialogOpen?: boolean; + isImportMidiDialogOpen?: boolean; } ) { for (const [key, value] of Object.entries(dialogState)) { diff --git a/src/type/preload.ts b/src/type/preload.ts index 5319e6703f..3baa1bacb6 100644 --- a/src/type/preload.ts +++ b/src/type/preload.ts @@ -152,6 +152,26 @@ export const defaultHotkeySettings: HotkeySettingType[] = [ action: "選択中のアクセント句のイントネーションをリセット", combination: HotkeyCombination("R"), }, + { + action: "コピー", + combination: HotkeyCombination(!isMac ? "Ctrl C" : "Meta C"), + }, + { + action: "切り取り", + combination: HotkeyCombination(!isMac ? "Ctrl X" : "Meta X"), + }, + { + action: "貼り付け", + combination: HotkeyCombination(!isMac ? "Ctrl V" : "Meta V"), + }, + { + action: "すべて選択", + combination: HotkeyCombination(!isMac ? "Ctrl A" : "Meta A"), + }, + { + action: "選択解除", + combination: HotkeyCombination("Escape"), + }, ]; export const defaultToolbarButtonSetting: ToolbarSettingType = [ @@ -433,6 +453,11 @@ export const hotkeyActionNameSchema = z.enum([ "テキスト読み込む", "全体のイントネーションをリセット", "選択中のアクセント句のイントネーションをリセット", + "コピー", + "切り取り", + "貼り付け", + "すべて選択", + "選択解除", ]); export type HotkeyActionNameType = z.infer; diff --git "a/tests/e2e/browser/\343\202\271\343\202\257\343\203\252\343\203\274\343\203\263\343\202\267\343\203\247\343\203\203\343\203\210.spec.ts-snapshots/\343\203\241\343\202\244\343\203\263\347\224\273\351\235\242-browser-win32.png" "b/tests/e2e/browser/\343\202\271\343\202\257\343\203\252\343\203\274\343\203\263\343\202\267\343\203\247\343\203\203\343\203\210.spec.ts-snapshots/\343\203\241\343\202\244\343\203\263\347\224\273\351\235\242-browser-win32.png" index 7ed89f66aa..8c4fd69899 100644 Binary files "a/tests/e2e/browser/\343\202\271\343\202\257\343\203\252\343\203\274\343\203\263\343\202\267\343\203\247\343\203\203\343\203\210.spec.ts-snapshots/\343\203\241\343\202\244\343\203\263\347\224\273\351\235\242-browser-win32.png" and "b/tests/e2e/browser/\343\202\271\343\202\257\343\203\252\343\203\274\343\203\263\343\202\267\343\203\247\343\203\203\343\203\210.spec.ts-snapshots/\343\203\241\343\202\244\343\203\263\347\224\273\351\235\242-browser-win32.png" differ diff --git a/tests/e2e/navigators.ts b/tests/e2e/navigators.ts index 6fe1c9aeef..145f129a47 100644 --- a/tests/e2e/navigators.ts +++ b/tests/e2e/navigators.ts @@ -2,11 +2,11 @@ import { expect, Page } from "@playwright/test"; import { getNewestQuasarDialog, getQuasarMenu } from "./locators"; /** - * /#/talkに移動 + * 最初の画面に移動 */ export async function gotoHome({ page }: { page: Page }) { - const BASE_URL = "http://localhost:7357/#/talk"; - await page.setViewportSize({ width: 800, height: 600 }); + const BASE_URL = "http://localhost:7357/"; + await page.setViewportSize({ width: 1024, height: 630 }); await page.goto(BASE_URL); } diff --git a/tests/unit/lib/selectPriorPhrase.spec.ts b/tests/unit/lib/selectPriorPhrase.spec.ts new file mode 100644 index 0000000000..2a7a5b6f65 --- /dev/null +++ b/tests/unit/lib/selectPriorPhrase.spec.ts @@ -0,0 +1,67 @@ +import { it, expect } from "vitest"; +import { Phrase, PhraseState } from "@/store/type"; +import { DEFAULT_TPQN } from "@/sing/storeHelper"; +import { selectPriorPhrase } from "@/sing/domain"; +import { EngineId, StyleId } from "@/type/preload"; + +const tempos = [ + { + position: 0, + bpm: 60, + }, +]; +const createPhrase = ( + start: number, + end: number, + state: PhraseState +): Phrase => { + return { + notes: [], + startTicks: start * DEFAULT_TPQN, + endTicks: end * DEFAULT_TPQN, + keyRangeAdjustment: 0, + volumeRangeAdjustment: 0, + state, + tempos, + tpqn: DEFAULT_TPQN, + singer: { + engineId: EngineId("00000000-0000-0000-0000-000000000000"), + styleId: StyleId(0), + }, + }; +}; +const basePhrases = new Map([ + ["1", createPhrase(0, 1, "WAITING_TO_BE_RENDERED")], + ["2", createPhrase(1, 2, "WAITING_TO_BE_RENDERED")], + ["3", createPhrase(2, 3, "WAITING_TO_BE_RENDERED")], + ["4", createPhrase(3, 4, "WAITING_TO_BE_RENDERED")], + ["5", createPhrase(4, 5, "WAITING_TO_BE_RENDERED")], +]); + +it("しっかり優先順位に従って探している", () => { + const phrases = structuredClone(basePhrases); + const position = 2.5 * DEFAULT_TPQN; + for (const expectation of [ + // 再生位置が含まれるPhrase + "3", + // 再生位置より後のPhrase + "4", // 早い方 + "5", // 遅い方 + // 再生位置より前のPhrase + "1", // 早い方 + "2", // 遅い方 + ]) { + const [key] = selectPriorPhrase(phrases, position); + expect(key).toEqual(expectation); + if (key == undefined) { + // 型アサーションのためにthrowを使う + throw new Error("key is undefined"); + } + phrases.delete(key); + } + + // もう再生可能なPhraseがないのでthrow + expect(() => { + selectPriorPhrase(phrases, position); + }).toThrow("Received empty phrases"); +}); diff --git a/vite.config.ts b/vite.config.ts index 01096c8f3f..e908934820 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,6 +1,6 @@ /// import path from "path"; -import { rm, readdir, readFile } from "fs/promises"; +import { rm } from "fs/promises"; import treeKill from "tree-kill"; import electron from "vite-plugin-electron"; @@ -13,6 +13,7 @@ import { quasar } from "@quasar/vite-plugin"; const isElectron = process.env.VITE_TARGET === "electron"; const isBrowser = process.env.VITE_TARGET === "browser"; +const isMobile = process.env.VITE_TARGET === "mobile"; export default defineConfig(async (options) => { const packageName = process.env.npm_package_name; @@ -36,19 +37,6 @@ export default defineConfig(async (options) => { const sourcemap: BuildOptions["sourcemap"] = shouldEmitSourcemap ? "inline" : false; - const themes = await readdir(path.resolve(__dirname, "public/themes")).then( - (files) => - Promise.all( - files.map(async (themeFile: string) => { - return JSON.parse( - await readFile( - path.resolve(__dirname, "public/themes", themeFile), - "utf-8" - ) - ); - }) - ) - ); return { root: path.resolve(__dirname, "src"), envDir: __dirname, @@ -85,9 +73,6 @@ export default defineConfig(async (options) => { ], globals: true, }, - define: { - __availableThemes: JSON.stringify(themes), - }, plugins: [ vue(), @@ -129,7 +114,8 @@ export default defineConfig(async (options) => { }, }), ], - isBrowser && injectBrowserPreloadPlugin(), + isBrowser && injectPreloadPlugin("browser"), + isMobile && injectPreloadPlugin("mobile"), ], }; }); @@ -147,15 +133,15 @@ const cleanDistPlugin = (): Plugin => { }; }; -const injectBrowserPreloadPlugin = (): Plugin => { +const injectPreloadPlugin = (backend: string): Plugin => { return { - name: "inject-browser-preload", + name: "inject-preload", transformIndexHtml: { enforce: "pre" as const, transform: (html: string) => html.replace( - "", - `` + "", + `` ), }, };