mirror of
https://github.com/mssoftjp/obsidian-voice-input.git
synced 2026-07-22 06:44:48 +00:00
chore(public): initial import of plugin source and config
This commit is contained in:
parent
0e4e7122b9
commit
0a78540cbc
71 changed files with 16295 additions and 0 deletions
43
.eslintrc.json
Normal file
43
.eslintrc.json
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
{
|
||||
"root": true,
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"plugins": [
|
||||
"@typescript-eslint"
|
||||
],
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended"
|
||||
],
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 2020,
|
||||
"sourceType": "module"
|
||||
},
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es6": true,
|
||||
"node": true
|
||||
},
|
||||
"rules": {
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
"@typescript-eslint/explicit-function-return-type": "off",
|
||||
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }],
|
||||
"@typescript-eslint/no-non-null-assertion": "warn",
|
||||
"no-console": ["warn", { "allow": ["warn", "error", "log"] }],
|
||||
"semi": ["error", "always"],
|
||||
"quotes": ["error", "single", { "avoidEscape": true }],
|
||||
"comma-dangle": ["error", "never"],
|
||||
"no-trailing-spaces": "error",
|
||||
"indent": ["error", 4, { "SwitchCase": 1 }],
|
||||
"linebreak-style": ["error", "unix"],
|
||||
"eol-last": ["error", "always"],
|
||||
"no-multiple-empty-lines": ["error", { "max": 1, "maxEOF": 0 }]
|
||||
},
|
||||
"ignorePatterns": [
|
||||
"node_modules/",
|
||||
"build/",
|
||||
"dist/",
|
||||
"main.js",
|
||||
"*.js",
|
||||
"docs/"
|
||||
]
|
||||
}
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -96,6 +96,7 @@ AGENTS.md
|
|||
agents.md
|
||||
.kiro
|
||||
.windsurf
|
||||
|
||||
# Personal, not for public release
|
||||
scripts/
|
||||
memo/
|
||||
|
|
|
|||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2025 Musashino Software
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
172
README.md
Normal file
172
README.md
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
# Voice Input Plugin for Obsidian
|
||||
|
||||
High-accuracy voice input for Obsidian. Uses OpenAI GPT-4o Audio Transcriptions to transcribe speech (optimized for Japanese) and insert into your notes with one click or push-to-talk.
|
||||
|
||||
## Features
|
||||
|
||||
- One‑click recording: start/stop from a microphone ribbon icon
|
||||
- Push‑to‑talk: long‑press to record, release to stop
|
||||
- Model selection: GPT‑4o Transcribe or GPT‑4o mini Transcribe
|
||||
- AI post‑processing: optional dictionary-based cleanup (JA)
|
||||
- Quick controls in view: copy/clear/insert at cursor/append to end
|
||||
- Auto‑save drafts: periodic and on blur, automatic restore
|
||||
|
||||
## Requirements
|
||||
|
||||
- OpenAI API key (Audio Transcriptions)
|
||||
- Internet connectivity
|
||||
- Obsidian desktop (Windows/macOS). This plugin is desktop‑only.
|
||||
|
||||
Note: OpenAI usage is billed by API.
|
||||
|
||||
## Installation (manual)
|
||||
|
||||
1. Download the latest assets from Releases
|
||||
2. Copy `main.js`, `manifest.json`, `styles.css`, `fvad.wasm`, `fvad.js`
|
||||
3. Place them under `<vault>/.obsidian/plugins/voice-input/`
|
||||
4. Restart Obsidian and enable the plugin
|
||||
|
||||
## Commands
|
||||
|
||||
- Open Voice Input (`open-voice-input`): opens the Voice Input view. Assign a hotkey from Obsidian’s Settings → Hotkeys if desired.
|
||||
|
||||
## Usage
|
||||
|
||||
1) Open the view
|
||||
- Click the microphone ribbon icon, or run “Open Voice Input”.
|
||||
|
||||
2) Record
|
||||
- Click “Start Voice Input” to toggle recording, or use push‑to‑talk: long‑press the record button (starts after a short threshold), release to stop.
|
||||
|
||||
3) Use the result
|
||||
- Copy, Insert at Cursor, or Append to End of the active note. Clear resets the area.
|
||||
|
||||
Tip: A settings gear in the view header opens the plugin’s settings.
|
||||
|
||||
## Settings
|
||||
|
||||
- OpenAI API Key: stored locally (encrypted at rest)
|
||||
- Transcription Model: `gpt-4o-transcribe` or `gpt-4o-mini-transcribe`
|
||||
- AI Post‑processing: enable dictionary‑based cleanup (Japanese)
|
||||
- Maximum Recording Duration: slider (default 5 min)
|
||||
- Plugin Language: English/Japanese (auto‑detected from Obsidian, adjustable)
|
||||
|
||||
## Security & Privacy
|
||||
|
||||
- Processing in memory; audio is not written to disk by the plugin
|
||||
- HTTPS for all network requests (via Obsidian’s `requestUrl`)
|
||||
- API key is encrypted for storage
|
||||
|
||||
See also OpenAI’s Privacy Policy.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Microphone not recognized: allow mic access and restart Obsidian
|
||||
- Transcription fails: verify API key, connectivity, and OpenAI credit
|
||||
- Quiet audio: move closer to the mic and reduce background noise
|
||||
|
||||
## Development
|
||||
|
||||
- Install deps: `npm ci`
|
||||
- Build: `npm run build-plugin` (outputs `build/latest/`)
|
||||
- Deploy locally: `npm run deploy-local` (copies to detected vaults)
|
||||
- Analyze unused code (build-time): `npm run analyze:unused`
|
||||
|
||||
Third‑party licensing: see `THIRD_PARTY_LICENSES.md`.
|
||||
|
||||
—
|
||||
|
||||
# Voice Input Plugin for Obsidian(日本語)
|
||||
|
||||
高精度な音声認識でメモを作成できるプラグインです。OpenAI GPT‑4oの音声文字起こしを使用し、日本語に最適化した文字起こしを行います。
|
||||
|
||||
## 機能
|
||||
|
||||
- ワンクリック録音(リボンのマイクアイコン)
|
||||
- プッシュトゥトーク(長押しで録音開始、離して停止)
|
||||
- モデル選択(GPT‑4o Transcribe / GPT‑4o mini Transcribe)
|
||||
- AI後処理(辞書ベースの補正、JA向け)
|
||||
- ビュー内のクイック操作(コピー/クリア/カーソル位置へ挿入/末尾へ追記)
|
||||
- 自動保存(定期保存とフォーカス外れ時)。再オープン時に自動復元
|
||||
|
||||
## 必要条件
|
||||
|
||||
- OpenAI APIキー(Audio Transcriptions)
|
||||
- インターネット接続
|
||||
- Obsidianデスクトップ版(Windows/macOS)。本プラグインはデスクトップ専用です。
|
||||
|
||||
※ OpenAI APIの利用には課金が発生します。
|
||||
|
||||
## インストール(手動)
|
||||
|
||||
1. Releases から最新版を取得
|
||||
2. `main.js`、`manifest.json`、`styles.css`、`fvad.wasm`、`fvad.js` を配置
|
||||
3. `<Vault>/.obsidian/plugins/voice-input/` に置く
|
||||
4. Obsidianを再起動し、プラグインを有効化
|
||||
|
||||
## コマンド
|
||||
|
||||
- Open Voice Input(`open-voice-input`): 音声入力ビューを開きます。必要に応じてホットキーを割り当ててください。
|
||||
|
||||
## 使い方
|
||||
|
||||
1) ビューを開く
|
||||
- リボンのマイクアイコンをクリック、またはコマンドパレットから “Open Voice Input”。
|
||||
|
||||
2) 録音
|
||||
- 「音声入力開始」でトグル録音、またはプッシュトゥトーク(ボタン長押しで短い閾値後に録音開始、指を離すと停止)。
|
||||
|
||||
3) 結果の利用
|
||||
- コピー、カーソル位置へ挿入、末尾へ追記。クリアでテキストエリアを初期化。
|
||||
|
||||
ヒント: ビューのヘッダにある歯車ボタンから設定を開けます。
|
||||
|
||||
## 設定
|
||||
|
||||
- OpenAI APIキー: ローカルに暗号化して保存
|
||||
- 文字起こしモデル: `gpt-4o-transcribe` または `gpt-4o-mini-transcribe`
|
||||
- AI後処理: 辞書ベースの補正(日本語向け)
|
||||
- 最大録音時間: スライダー(初期値5分)
|
||||
- プラグイン言語: 英語/日本語(Obsidian設定から自動検出、変更可)
|
||||
|
||||
## セキュリティ / プライバシー
|
||||
|
||||
- 処理はメモリ内で行い、音声ファイルはプラグイン側でディスク保存しません
|
||||
- 通信はHTTPS(Obsidianの `requestUrl` 経由)
|
||||
- APIキーは保存時に暗号化
|
||||
|
||||
OpenAIのプライバシーポリシーもご参照ください。
|
||||
|
||||
## トラブルシューティング
|
||||
|
||||
- マイクが認識されない: マイク許可の付与、Obsidianの再起動
|
||||
- 文字起こしに失敗: APIキー、通信、OpenAIクレジット残高を確認
|
||||
- 音量が小さい/雑音が多い: マイクに近づく、静かな環境で録音
|
||||
|
||||
## 開発
|
||||
|
||||
- 依存インストール: `npm ci`
|
||||
- ビルド: `npm run build-plugin`
|
||||
- ローカル配布: `npm run deploy-local`
|
||||
|
||||
サードパーティライセンスは `THIRD_PARTY_LICENSES.md` を参照してください。
|
||||
- 静かな環境で録音してください
|
||||
- マイクとの距離を調整してください(30cm程度推奨)
|
||||
- 他のアプリケーションがマイクを使用していないか確認
|
||||
|
||||
## ライセンス
|
||||
|
||||
MIT License - 詳細は[LICENSE](LICENSE)ファイルをご確認ください。
|
||||
|
||||
詳細なライセンス情報については [THIRD_PARTY_LICENSES.md](THIRD_PARTY_LICENSES.md) をご覧ください。
|
||||
|
||||
## 作者
|
||||
|
||||
**Musashino Software**
|
||||
|
||||
- GitHub: [@mssoftjp](https://github.com/mssoftjp)
|
||||
- 支援: [Buy Me a Coffee](https://buymeacoffee.com/mssoft)
|
||||
|
||||
## 貢献
|
||||
|
||||
バグ報告や機能要望は[Issues](https://github.com/mssoftjp/obsidian-voice-input-private/issues)でお願いします。
|
||||
83
THIRD_PARTY_LICENSES.md
Normal file
83
THIRD_PARTY_LICENSES.md
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
# Third-Party Licenses
|
||||
|
||||
This project uses the following third-party libraries. Licenses and attributions are provided below.
|
||||
|
||||
## Runtime Dependencies
|
||||
|
||||
### @echogarden/fvad-wasm
|
||||
- License: MIT
|
||||
- Version: 0.2.0
|
||||
- Purpose: WebAssembly-based Voice Activity Detection (WebRTC VAD)
|
||||
- Repository: https://github.com/echogarden-project/fvad-wasm
|
||||
|
||||
This package provides a WebAssembly build of the WebRTC Voice Activity Detection (VAD). The original WebRTC VAD implementation is licensed under the BSD 3-Clause license and is attributed below.
|
||||
|
||||
## Development Dependencies
|
||||
|
||||
The following tools are used during development and are typically MIT or Apache-2.0 licensed:
|
||||
|
||||
- TypeScript (Apache-2.0): TypeScript compiler and types
|
||||
- ESBuild (MIT): JavaScript bundler and minifier
|
||||
- ESLint (MIT): Linting for JavaScript/TypeScript
|
||||
- Jest (MIT): Testing framework
|
||||
- Obsidian API (MIT): Obsidian plugin development API
|
||||
|
||||
## WebRTC VAD Attribution (BSD 3-Clause)
|
||||
|
||||
```
|
||||
Copyright (c) 2011, The WebRTC project authors. All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are
|
||||
met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
|
||||
* Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in
|
||||
the documentation and/or other materials provided with the
|
||||
distribution.
|
||||
|
||||
* Neither the name of Google nor the names of its contributors may
|
||||
be used to endorse or promote products derived from this software
|
||||
without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
||||
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
||||
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
```
|
||||
|
||||
## License Texts
|
||||
|
||||
### MIT License
|
||||
|
||||
```
|
||||
MIT License
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
```
|
||||
58
esbuild.config.mjs
Normal file
58
esbuild.config.mjs
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import esbuild from "esbuild";
|
||||
import process from "process";
|
||||
import builtins from "builtin-modules";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
const banner =
|
||||
`/*
|
||||
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
|
||||
if you want to view the source, please visit the github repository of this plugin
|
||||
*/
|
||||
`;
|
||||
|
||||
const prod = (process.argv[2] === "production");
|
||||
|
||||
// Ensure build/latest exists as the output directory
|
||||
const outDir = path.join(process.cwd(), "build", "latest");
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
|
||||
const context = await esbuild.context({
|
||||
banner: {
|
||||
js: banner,
|
||||
},
|
||||
entryPoints: ["src/main.ts"],
|
||||
bundle: true,
|
||||
external: [
|
||||
"obsidian",
|
||||
"electron",
|
||||
"@codemirror/autocomplete",
|
||||
"@codemirror/collab",
|
||||
"@codemirror/commands",
|
||||
"@codemirror/language",
|
||||
"@codemirror/lint",
|
||||
"@codemirror/search",
|
||||
"@codemirror/state",
|
||||
"@codemirror/view",
|
||||
"@lezer/common",
|
||||
"@lezer/highlight",
|
||||
"@lezer/lr",
|
||||
...builtins],
|
||||
format: "cjs",
|
||||
target: "es2018",
|
||||
logLevel: "info",
|
||||
sourcemap: prod ? false : "inline",
|
||||
treeShaking: true,
|
||||
minifyWhitespace: prod,
|
||||
minifyIdentifiers: prod,
|
||||
minifySyntax: prod,
|
||||
legalComments: 'inline',
|
||||
outfile: path.join(outDir, "main.js"),
|
||||
});
|
||||
|
||||
if (prod) {
|
||||
await context.rebuild();
|
||||
process.exit(0);
|
||||
} else {
|
||||
await context.watch();
|
||||
}
|
||||
28
jest.config.js
Normal file
28
jest.config.js
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'jsdom',
|
||||
roots: ['<rootDir>/src', '<rootDir>/tests'],
|
||||
testMatch: ['**/__tests__/**/*.ts', '**/?(*.)+(spec|test).ts'],
|
||||
transform: {
|
||||
'^.+\\.ts$': ['ts-jest', {
|
||||
tsconfig: {
|
||||
esModuleInterop: true,
|
||||
allowSyntheticDefaultImports: true,
|
||||
},
|
||||
}],
|
||||
},
|
||||
setupFilesAfterEnv: ['<rootDir>/tests/setup.ts'],
|
||||
moduleFileExtensions: ['ts', 'js'],
|
||||
collectCoverageFrom: [
|
||||
'src/**/*.ts',
|
||||
'!src/**/*.d.ts',
|
||||
'!src/plugin/index.ts',
|
||||
'!src/**/index.ts',
|
||||
],
|
||||
coverageDirectory: 'coverage',
|
||||
coverageReporters: ['text', 'lcov', 'html'],
|
||||
testTimeout: 10000,
|
||||
moduleNameMapper: {
|
||||
'^obsidian$': '<rootDir>/tests/mocks/obsidian.ts',
|
||||
},
|
||||
};
|
||||
11
manifest.json
Normal file
11
manifest.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"id": "voice-input",
|
||||
"name": "Voice Input",
|
||||
"version": "0.8.0",
|
||||
"minAppVersion": "17.0.0",
|
||||
"description": "Voice input with real-time transcription using GPT-4o.",
|
||||
"author": "Musashino Software",
|
||||
"authorUrl": "https://github.com/mssoftjp",
|
||||
"fundingUrl": "https://buymeacoffee.com/mssoft",
|
||||
"isDesktopOnly": true
|
||||
}
|
||||
6582
package-lock.json
generated
Normal file
6582
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
51
package.json
Normal file
51
package.json
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
{
|
||||
"name": "voice-input",
|
||||
"version": "0.8.0",
|
||||
"description": "Voice input plugin for Obsidian with accurate transcription",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"dev": "node esbuild.config.mjs",
|
||||
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
|
||||
"build-plugin": "npm run build && node scripts/post-build.mjs",
|
||||
"build-timestamp": "./scripts/build-timestamp.sh",
|
||||
"deploy": "./scripts/build-timestamp.sh",
|
||||
"analyze:unused": "node scripts/analyze-unused.mjs",
|
||||
"deploy-local": "npm run build-plugin && node scripts/deploy-local.mjs",
|
||||
"deploy-icloud": "npm run build-plugin && node scripts/deploy-icloud.mjs",
|
||||
"deploy:quick": "npm run deploy-local",
|
||||
"check": "npm run lint && npm run build",
|
||||
"lint": "eslint src/**/*.ts",
|
||||
"lint:fix": "eslint src/**/*.ts --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:coverage": "jest --coverage",
|
||||
"version": "node version-bump.mjs && git add manifest.json versions.json"
|
||||
},
|
||||
"keywords": [
|
||||
"obsidian",
|
||||
"plugin",
|
||||
"voice",
|
||||
"transcription",
|
||||
"gpt-4o"
|
||||
],
|
||||
"author": "Musashino Software",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/node": "^16.11.6",
|
||||
"@typescript-eslint/eslint-plugin": "^5.29.0",
|
||||
"@typescript-eslint/parser": "^5.29.0",
|
||||
"builtin-modules": "3.3.0",
|
||||
"esbuild": "0.17.3",
|
||||
"eslint": "^8.57.1",
|
||||
"jest-environment-jsdom": "^30.0.5",
|
||||
"obsidian": "latest",
|
||||
"tslib": "2.4.0",
|
||||
"typescript": "4.7.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@echogarden/fvad-wasm": "^0.2.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"jest": "^30.0.5",
|
||||
"ts-jest": "^29.4.0"
|
||||
}
|
||||
}
|
||||
212
src/config/constants.ts
Normal file
212
src/config/constants.ts
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
/**
|
||||
* 技術的な定数の定義
|
||||
* これらの値は内部的に使用され、通常ユーザーが変更する必要はありません
|
||||
*/
|
||||
|
||||
/**
|
||||
* オーディオ関連の定数
|
||||
*/
|
||||
export const AUDIO_CONSTANTS = {
|
||||
/** WebRTC VADが要求するサンプルレート (Hz) */
|
||||
SAMPLE_RATE: 16000,
|
||||
|
||||
/** オーディオ処理のバッファサイズ */
|
||||
BUFFER_SIZE: 4096,
|
||||
|
||||
/** 最大録音時間 (秒) */
|
||||
MAX_RECORDING_SECONDS: 300,
|
||||
|
||||
/** デフォルトのリングバッファ最大時間 (秒) */
|
||||
DEFAULT_RING_BUFFER_SECONDS: 300,
|
||||
|
||||
/** オーディオコンテキストのレイテンシーヒント */
|
||||
LATENCY_HINT: 'interactive' as const,
|
||||
|
||||
/** デフォルトのゲイン値 */
|
||||
DEFAULT_GAIN: 1.0,
|
||||
|
||||
/** オーディオフィルター設定 */
|
||||
FILTERS: {
|
||||
/** ハイパスフィルターの周波数 (Hz) - 低周波ノイズ除去 */
|
||||
HIGH_PASS_FREQ: 80,
|
||||
/** ローパスフィルターの周波数 (Hz) - 高周波ノイズ除去 */
|
||||
LOW_PASS_FREQ: 7600
|
||||
},
|
||||
|
||||
/** マイク入力検出の閾値 */
|
||||
MIC_DETECTION_THRESHOLD: 0.0001,
|
||||
|
||||
/** 録音フォーマット */
|
||||
WEBM_CODEC: 'audio/webm;codecs=opus',
|
||||
|
||||
/** MediaRecorderのデータ収集間隔 (ms) */
|
||||
RECORDER_TIMESLICE: 100,
|
||||
|
||||
/** バッファ使用率の警告閾値 (%) */
|
||||
BUFFER_WARNING_THRESHOLD: 80,
|
||||
|
||||
/** バッファ統計ログの出力確率 */
|
||||
BUFFER_STATS_LOG_PROBABILITY: 0.01
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* VAD (Voice Activity Detection) 関連の定数
|
||||
*/
|
||||
export const VAD_CONSTANTS = {
|
||||
/** VADフレームサイズ (16kHzで30ms) */
|
||||
FRAME_SIZE: 480,
|
||||
|
||||
/** VADが要求するサンプルレート (Hz) */
|
||||
SAMPLE_RATE: 16000,
|
||||
|
||||
/** 入力オーディオのサンプルレート (Hz) */
|
||||
INPUT_SAMPLE_RATE: 48000,
|
||||
|
||||
/** ノイズレベルの閾値 */
|
||||
NOISE_THRESHOLDS: {
|
||||
/** 騒音環境の閾値 */
|
||||
NOISY: 0.1,
|
||||
/** 通常環境の閾値 */
|
||||
NORMAL: 0.05
|
||||
},
|
||||
|
||||
/** VADモード自動調整の間隔 (フレーム数) */
|
||||
ADJUSTMENT_INTERVAL: 100,
|
||||
|
||||
/** ノイズレベルの指数移動平均係数 */
|
||||
NOISE_LEVEL_EMA: {
|
||||
/** 前の値の重み */
|
||||
PREVIOUS: 0.95,
|
||||
/** 現在の値の重み */
|
||||
CURRENT: 0.05
|
||||
},
|
||||
|
||||
/** VAD結果の蓄積閾値 (フレーム数) */
|
||||
VAD_ACCUMULATION_THRESHOLD: 10
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 可視化関連の定数
|
||||
*/
|
||||
export const VISUALIZATION_CONSTANTS = {
|
||||
/** キャンバスのサイズ */
|
||||
CANVAS: {
|
||||
WIDTH: 200,
|
||||
HEIGHT: 60
|
||||
},
|
||||
|
||||
/** 波形履歴の最大長 */
|
||||
MAX_HISTORY_LENGTH: 300,
|
||||
|
||||
/** FFTサイズ (詳細表示用) */
|
||||
FFT_SIZE: 2048,
|
||||
|
||||
/** FFTサイズ (シンプル表示用) */
|
||||
SIMPLE_FFT_SIZE: 256,
|
||||
|
||||
/** 関連する周波数ビンの数 */
|
||||
RELEVANT_BINS: 50,
|
||||
|
||||
/** オーディオレベルのスケーリング係数 */
|
||||
LEVEL_SCALING: 128,
|
||||
|
||||
/** VAD閾値のデフォルト値 */
|
||||
DEFAULT_VAD_THRESHOLD: 0.02
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* API関連の定数
|
||||
*/
|
||||
export const API_CONSTANTS = {
|
||||
/** APIエンドポイント */
|
||||
ENDPOINTS: {
|
||||
/** 音声文字起こしAPI */
|
||||
TRANSCRIPTION: 'https://api.openai.com/v1/audio/transcriptions'
|
||||
},
|
||||
|
||||
/** APIパラメータ */
|
||||
PARAMETERS: {
|
||||
/** 文字起こしの温度パラメータ (決定論的) */
|
||||
TRANSCRIPTION_TEMPERATURE: 0.0,
|
||||
/** 最大トークン数 */
|
||||
MAX_TOKENS: 2000
|
||||
}
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* ファイルシステム関連の定数
|
||||
*/
|
||||
export const FILE_CONSTANTS = {
|
||||
/** プラグインID */
|
||||
PLUGIN_ID: 'voice-input',
|
||||
|
||||
/** WASMファイルのパス */
|
||||
WASM_PATHS: {
|
||||
JS: 'src/lib/fvad-wasm/fvad.js',
|
||||
WASM: 'src/lib/fvad-wasm/fvad.wasm'
|
||||
}
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 辞書関連の定数
|
||||
*/
|
||||
export const DICTIONARY_CONSTANTS = {
|
||||
/** 固定補正の最大個数 */
|
||||
MAX_DEFINITE_CORRECTIONS: 50,
|
||||
|
||||
/** 文脈補正の最大個数 */
|
||||
MAX_CONTEXTUAL_CORRECTIONS: 150,
|
||||
|
||||
/** 補正カテゴリ(IMEの品詞分類を参考) */
|
||||
CATEGORIES: [
|
||||
'noun', // 名詞
|
||||
'person', // 人名
|
||||
'place', // 地名
|
||||
'org', // 組織名
|
||||
'proper', // その他固有名詞
|
||||
'technical', // 専門用語
|
||||
'spoken', // 話し言葉
|
||||
'symbol' // 記号・単位
|
||||
] as const,
|
||||
|
||||
/** デフォルトカテゴリ */
|
||||
DEFAULT_CATEGORY: 'noun'
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* UI関連の定数
|
||||
*/
|
||||
export const UI_CONSTANTS = {
|
||||
/** プッシュ・トゥ・トークの閾値 (ms) */
|
||||
PUSH_TO_TALK_THRESHOLD: 300,
|
||||
|
||||
/** プッシュトゥトークのフラグリセット遅延 (ms) */
|
||||
PUSH_TO_TALK_RESET_DELAY: 100,
|
||||
|
||||
/** フィードバック表示の遅延時間 (ms) */
|
||||
FEEDBACK_DELAY: 3000,
|
||||
|
||||
/** レコードボタンのスタイル */
|
||||
RECORD_BUTTON: {
|
||||
PADDING: '32px 20px',
|
||||
FONT_SIZE: '20px',
|
||||
MIN_HEIGHT: '80px'
|
||||
},
|
||||
|
||||
/** テキストエリアの最小高さ */
|
||||
TEXTAREA_MIN_HEIGHT: '200px',
|
||||
|
||||
/** アニメーション */
|
||||
ANIMATION: {
|
||||
/** パルスアニメーションの周期 */
|
||||
PULSE_DURATION: '1.5s'
|
||||
}
|
||||
} as const;
|
||||
|
||||
/** すべての定数の型 */
|
||||
export type AudioConstants = typeof AUDIO_CONSTANTS;
|
||||
export type VADConstants = typeof VAD_CONSTANTS;
|
||||
export type VisualizationConstants = typeof VISUALIZATION_CONSTANTS;
|
||||
export type APIConstants = typeof API_CONSTANTS;
|
||||
export type FileConstants = typeof FILE_CONSTANTS;
|
||||
export type UIConstants = typeof UI_CONSTANTS;
|
||||
107
src/config/costs.ts
Normal file
107
src/config/costs.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
/**
|
||||
* APIモデルのコスト情報
|
||||
* 価格は1分あたりのUSDで表記
|
||||
* 最新の価格はOpenAIの公式サイトで確認してください。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 音声文字起こしモデルのコスト ($/分)
|
||||
*/
|
||||
export const TRANSCRIPTION_MODEL_COSTS = {
|
||||
/** GPT-4o Transcribe - 高精度版 */
|
||||
'gpt-4o-transcribe': 0.06,
|
||||
|
||||
/** GPT-4o Mini Transcribe - コスト効率版 */
|
||||
'gpt-4o-mini-transcribe': 0.03,
|
||||
|
||||
/** Whisper Large (参考) */
|
||||
'whisper-1': 0.006
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* コスト計算のヘルパー関数
|
||||
*/
|
||||
export const CostCalculator = {
|
||||
/**
|
||||
* 音声文字起こしのコストを計算
|
||||
* @param durationSeconds 音声の長さ(秒)
|
||||
* @param model 使用するモデル
|
||||
* @returns コスト(USD)
|
||||
*/
|
||||
calculateTranscriptionCost(
|
||||
durationSeconds: number,
|
||||
model: keyof typeof TRANSCRIPTION_MODEL_COSTS
|
||||
): number {
|
||||
const durationMinutes = durationSeconds / 60;
|
||||
const costPerMinute = TRANSCRIPTION_MODEL_COSTS[model];
|
||||
return durationMinutes * costPerMinute;
|
||||
},
|
||||
|
||||
/**
|
||||
* テキストからトークン数を概算
|
||||
* 日本語の場合: 1文字 ≈ 0.5トークン
|
||||
* 英語の場合: 1単語 ≈ 1.3トークン
|
||||
* @param text テキスト
|
||||
* @param language 言語
|
||||
* @returns 概算トークン数
|
||||
*/
|
||||
estimateTokens(text: string, language = 'ja'): number {
|
||||
if (language === 'ja') {
|
||||
// 日本語: 文字数の約半分
|
||||
return Math.ceil(text.length * 0.5);
|
||||
} else {
|
||||
// 英語: 単語数の約1.3倍
|
||||
const words = text.split(/\s+/).length;
|
||||
return Math.ceil(words * 1.3);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 月間コストを予測(文字起こしのみ)
|
||||
* @param dailyMinutes 1日あたりの使用分数
|
||||
* @param transcriptionModel 文字起こしモデル
|
||||
* @returns 月間予測コスト(USD)
|
||||
*/
|
||||
estimateMonthlyCost(
|
||||
dailyMinutes: number,
|
||||
transcriptionModel: keyof typeof TRANSCRIPTION_MODEL_COSTS
|
||||
): number {
|
||||
// 文字起こしコスト(30日分)
|
||||
return dailyMinutes * 30 * TRANSCRIPTION_MODEL_COSTS[transcriptionModel];
|
||||
}
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* コスト情報の表示用フォーマット
|
||||
*/
|
||||
export const COST_DISPLAY = {
|
||||
/**
|
||||
* 金額をフォーマット
|
||||
* @param amount 金額(USD)
|
||||
* @param decimals 小数点以下の桁数
|
||||
* @returns フォーマットされた文字列
|
||||
*/
|
||||
formatCost(amount: number, decimals = 2): string {
|
||||
return `$${amount.toFixed(decimals)}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* コストの比較を表示
|
||||
* @param costA コストA
|
||||
* @param costB コストB
|
||||
* @returns 比較結果の文字列
|
||||
*/
|
||||
compareCost(costA: number, costB: number): string {
|
||||
const ratio = costA / costB;
|
||||
if (ratio > 1) {
|
||||
return `${ratio.toFixed(1)}倍高い`;
|
||||
} else {
|
||||
return `${(1 / ratio).toFixed(1)}倍安い`;
|
||||
}
|
||||
}
|
||||
} as const;
|
||||
|
||||
/** コスト関連の型定義 */
|
||||
export type TranscriptionModelCosts = typeof TRANSCRIPTION_MODEL_COSTS;
|
||||
export type CostCalculatorType = typeof CostCalculator;
|
||||
export type CostDisplayType = typeof COST_DISPLAY;
|
||||
109
src/config/defaults.ts
Normal file
109
src/config/defaults.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
/**
|
||||
* ユーザー設定のデフォルト値
|
||||
* これらの値はユーザーが設定画面から変更可能です
|
||||
*/
|
||||
|
||||
/**
|
||||
* オーディオ設定のデフォルト値
|
||||
*/
|
||||
export const DEFAULT_AUDIO_SETTINGS = {
|
||||
/** 無音検出による自動停止までの時間 (ms) */
|
||||
autoStopSilenceDuration: 2000,
|
||||
|
||||
/** オーディオゲイン */
|
||||
gain: 1.0,
|
||||
|
||||
/** エコーキャンセレーション */
|
||||
echoCancellation: true,
|
||||
|
||||
/** ノイズ抑制 */
|
||||
noiseSuppression: true,
|
||||
|
||||
/** 自動ゲインコントロール */
|
||||
autoGainControl: true,
|
||||
|
||||
/** チャンネル数 (モノラル) */
|
||||
channelCount: 1
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* VAD設定のデフォルト値
|
||||
*/
|
||||
export const DEFAULT_VAD_SETTINGS = {
|
||||
/** VADモード (0-3: 0=低感度, 3=高感度) */
|
||||
mode: 3,
|
||||
|
||||
/** 最小音声継続時間 (ms) */
|
||||
minSpeechDuration: 100,
|
||||
|
||||
/** 最小無音継続時間 (ms) */
|
||||
minSilenceDuration: 1000,
|
||||
|
||||
/** 音声前後のパディング (ms) */
|
||||
speechPadding: 200,
|
||||
|
||||
/** 環境に応じた自動調整 */
|
||||
autoAdjust: true
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* UI設定のデフォルト値
|
||||
*/
|
||||
export const DEFAULT_UI_SETTINGS = {
|
||||
/** プッシュ・トゥ・トーク機能の有効化 */
|
||||
pushToTalkEnabled: true,
|
||||
|
||||
/** 音声レベルの可視化表示 */
|
||||
showVisualization: true,
|
||||
|
||||
/** タイムスタンプのフォーマット */
|
||||
timestampFormat: 'YYYY-MM-DD-HH-mm-ss',
|
||||
|
||||
/** 録音ボタンのサイズ */
|
||||
recordButtonSize: 'large' as 'small' | 'medium' | 'large'
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 文字起こし設定のデフォルト値
|
||||
*/
|
||||
export const DEFAULT_TRANSCRIPTION_SETTINGS = {
|
||||
/** デフォルトの文字起こしモデル */
|
||||
model: 'gpt-4o-transcribe' as const,
|
||||
|
||||
/** 言語設定 */
|
||||
language: 'ja',
|
||||
|
||||
/** 文字起こし補正の有効化 */
|
||||
enableTranscriptionCorrection: true
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 統合されたデフォルト設定
|
||||
*/
|
||||
export const DEFAULT_USER_SETTINGS = {
|
||||
/** 基本設定 */
|
||||
openaiApiKey: '',
|
||||
language: DEFAULT_TRANSCRIPTION_SETTINGS.language,
|
||||
enableTranscriptionCorrection: DEFAULT_TRANSCRIPTION_SETTINGS.enableTranscriptionCorrection,
|
||||
|
||||
/** 音声設定 */
|
||||
autoStopSilenceDuration: DEFAULT_AUDIO_SETTINGS.autoStopSilenceDuration,
|
||||
minSpeechDuration: DEFAULT_VAD_SETTINGS.minSpeechDuration,
|
||||
|
||||
/** モデル設定 */
|
||||
transcriptionModel: DEFAULT_TRANSCRIPTION_SETTINGS.model,
|
||||
|
||||
/** 詳細設定 (オプション) */
|
||||
advanced: {
|
||||
audio: DEFAULT_AUDIO_SETTINGS,
|
||||
vad: DEFAULT_VAD_SETTINGS,
|
||||
ui: DEFAULT_UI_SETTINGS
|
||||
}
|
||||
} as const;
|
||||
|
||||
/** デフォルト設定の型 */
|
||||
export type DefaultAudioSettings = typeof DEFAULT_AUDIO_SETTINGS;
|
||||
export type DefaultVADSettings = typeof DEFAULT_VAD_SETTINGS;
|
||||
export type DefaultUISettings = typeof DEFAULT_UI_SETTINGS;
|
||||
export type DefaultTranscriptionSettings = typeof DEFAULT_TRANSCRIPTION_SETTINGS;
|
||||
export type DefaultUserSettings = typeof DEFAULT_USER_SETTINGS;
|
||||
158
src/config/index.ts
Normal file
158
src/config/index.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
/**
|
||||
* 設定ファイルの統合エクスポート
|
||||
* すべての設定定数、デフォルト値、コスト情報をまとめて提供
|
||||
*/
|
||||
|
||||
// 定数のエクスポート
|
||||
export * from './constants';
|
||||
export {
|
||||
AUDIO_CONSTANTS,
|
||||
VAD_CONSTANTS,
|
||||
VISUALIZATION_CONSTANTS,
|
||||
API_CONSTANTS,
|
||||
FILE_CONSTANTS,
|
||||
UI_CONSTANTS
|
||||
} from './constants';
|
||||
|
||||
// デフォルト値のエクスポート
|
||||
export * from './defaults';
|
||||
export {
|
||||
DEFAULT_AUDIO_SETTINGS,
|
||||
DEFAULT_VAD_SETTINGS,
|
||||
DEFAULT_TRANSCRIPTION_SETTINGS,
|
||||
DEFAULT_USER_SETTINGS
|
||||
} from './defaults';
|
||||
|
||||
// Message constants have been migrated to i18n system
|
||||
// See src/i18n/ for translations
|
||||
|
||||
// コスト情報のエクスポート
|
||||
export * from './costs';
|
||||
export {
|
||||
TRANSCRIPTION_MODEL_COSTS,
|
||||
CostCalculator,
|
||||
COST_DISPLAY
|
||||
} from './costs';
|
||||
|
||||
/**
|
||||
* 設定ヘルパー関数
|
||||
* @deprecated 現在未使用 - 将来の拡張用に保持
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const ConfigHelper = {
|
||||
/**
|
||||
* ユーザー設定とデフォルト設定をマージ
|
||||
* @param userSettings ユーザーの設定
|
||||
* @param defaults デフォルト設定
|
||||
* @returns マージされた設定
|
||||
*/
|
||||
mergeWithDefaults<T extends Record<string, unknown>>(
|
||||
userSettings: Partial<T>,
|
||||
defaults: T
|
||||
): T {
|
||||
return {
|
||||
...defaults,
|
||||
...userSettings,
|
||||
// ネストされたオブジェクトも再帰的にマージ
|
||||
...Object.keys(defaults).reduce((acc, key) => {
|
||||
if (
|
||||
typeof defaults[key] === 'object' &&
|
||||
defaults[key] !== null &&
|
||||
!Array.isArray(defaults[key]) &&
|
||||
userSettings[key]
|
||||
) {
|
||||
(acc as Record<string, unknown>)[key] = this.mergeWithDefaults(userSettings[key], defaults[key]);
|
||||
}
|
||||
return acc;
|
||||
}, {} as Partial<T>)
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* 設定値の検証
|
||||
* @param value 検証する値
|
||||
* @param min 最小値
|
||||
* @param max 最大値
|
||||
* @returns 検証済みの値
|
||||
*/
|
||||
validateNumber(value: number, min: number, max: number): number {
|
||||
if (isNaN(value)) {
|
||||
throw new Error('Value must be a number');
|
||||
}
|
||||
return Math.max(min, Math.min(max, value));
|
||||
},
|
||||
|
||||
/**
|
||||
* 設定値が有効な範囲内かチェック
|
||||
* @param settings 設定オブジェクト
|
||||
* @returns エラーメッセージの配列(エラーがない場合は空配列)
|
||||
*/
|
||||
validateSettings(settings: Record<string, unknown>): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
// 音声設定の検証
|
||||
if (settings.autoStopSilenceDuration !== undefined) {
|
||||
const value = settings.autoStopSilenceDuration as number;
|
||||
if (typeof value === 'number' && (value < 100 || value > 10000)) {
|
||||
errors.push('Auto stop duration must be between 100ms and 10000ms');
|
||||
}
|
||||
}
|
||||
|
||||
// VAD設定の検証
|
||||
if (settings.minSpeechDuration !== undefined) {
|
||||
const value = settings.minSpeechDuration as number;
|
||||
if (typeof value === 'number' && (value < 50 || value > 5000)) {
|
||||
errors.push('Minimum speech duration must be between 50ms and 5000ms');
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
},
|
||||
|
||||
/**
|
||||
* 環境変数から設定を読み込み
|
||||
* @param envPrefix 環境変数のプレフィックス
|
||||
* @returns 環境変数から読み込んだ設定
|
||||
*/
|
||||
loadFromEnv(envPrefix = 'VOICE_TRANSCRIPTION_'): Record<string, unknown> {
|
||||
const config: Record<string, unknown> = {};
|
||||
|
||||
// ブラウザ環境では環境変数は使用できないため、空オブジェクトを返す
|
||||
if (typeof process === 'undefined' || !process.env) {
|
||||
return config;
|
||||
}
|
||||
|
||||
Object.keys(process.env).forEach(key => {
|
||||
if (key.startsWith(envPrefix)) {
|
||||
const configKey = key
|
||||
.substring(envPrefix.length)
|
||||
.toLowerCase()
|
||||
.replace(/_/g, '.');
|
||||
|
||||
let value: string | number | boolean = process.env[key] || '';
|
||||
|
||||
// 型変換の試行
|
||||
if (value === 'true') value = true;
|
||||
else if (value === 'false') value = false;
|
||||
else if (!isNaN(Number(value))) value = Number(value);
|
||||
|
||||
// ネストされたキーの処理
|
||||
const keys = configKey.split('.');
|
||||
let current: Record<string, unknown> = config;
|
||||
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
if (!current[keys[i]]) {
|
||||
current[keys[i]] = {};
|
||||
}
|
||||
current = current[keys[i]] as Record<string, unknown>;
|
||||
}
|
||||
|
||||
current[keys[keys.length - 1]] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return config;
|
||||
}
|
||||
} as const;
|
||||
|
||||
// ConfigHelperは現在未使用のため、必要になったらエクスポートを追加
|
||||
588
src/core/audio/AudioRecorder.ts
Normal file
588
src/core/audio/AudioRecorder.ts
Normal file
|
|
@ -0,0 +1,588 @@
|
|||
import { VADProcessor, VADSegment } from './VADProcessor';
|
||||
import { AudioVisualizer, SimpleAudioLevelIndicator } from './AudioVisualizer';
|
||||
import { AudioRecorderOptions } from '../../interfaces';
|
||||
import { TranscriptionError, TranscriptionErrorType } from '../../errors';
|
||||
import { AudioRingBuffer } from './AudioRingBuffer';
|
||||
import { createAudioWorkletBlobURL, revokeAudioWorkletBlobURL } from './AudioWorkletSource';
|
||||
import { AUDIO_CONSTANTS, DEFAULT_AUDIO_SETTINGS, DEFAULT_VAD_SETTINGS, VAD_CONSTANTS } from '../../config';
|
||||
import { Disposable, CompositeDisposable } from '../../interfaces';
|
||||
import { Logger } from '../../utils';
|
||||
import { createServiceLogger } from '../../services';
|
||||
import { WindowWithWebkitAudio, hasWebkitAudioContext } from '../../types';
|
||||
|
||||
export class AudioRecorder extends Disposable {
|
||||
private mediaRecorder: MediaRecorder | null = null;
|
||||
private audioContext: AudioContext | null = null;
|
||||
private analyserNode: AnalyserNode | null = null;
|
||||
private sourceNode: MediaStreamAudioSourceNode | null = null;
|
||||
private gainNode: GainNode | null = null; // 音声増幅用
|
||||
private highPassFilter: BiquadFilterNode | null = null; // 80Hz HPF
|
||||
private lowPassFilter: BiquadFilterNode | null = null; // 7.6kHz LPF
|
||||
private workletNode: AudioWorkletNode | null = null;
|
||||
private vadProcessor: VADProcessor | null = null;
|
||||
private visualizer: AudioVisualizer | SimpleAudioLevelIndicator | null = null;
|
||||
private chunks: Blob[] = [];
|
||||
private isRecording: boolean = false;
|
||||
private stream: MediaStream | null = null;
|
||||
private options: AudioRecorderOptions;
|
||||
private silenceTimer: NodeJS.Timeout | null = null;
|
||||
private lastSpeechTime: number = 0;
|
||||
private continuousAudioData: Float32Array[] = [];
|
||||
private audioRingBuffer: AudioRingBuffer;
|
||||
private sampleRate: number = AUDIO_CONSTANTS.SAMPLE_RATE;
|
||||
private workletReady: boolean = false;
|
||||
private workletBlobURL: string | null = null;
|
||||
private microphoneReady: boolean = false;
|
||||
private audioDataReceived: boolean = false;
|
||||
private disposables: CompositeDisposable = new CompositeDisposable();
|
||||
private logger: Logger | null = null;
|
||||
private recordingStartTime: number = 0;
|
||||
private maxRecordingTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(options: AudioRecorderOptions) {
|
||||
super();
|
||||
this.options = options;
|
||||
|
||||
// Loggerの遅延初期化(ServiceLocatorから取得)
|
||||
try {
|
||||
this.logger = createServiceLogger('AudioRecorder');
|
||||
} catch (error) {
|
||||
// ServiceLocatorがまだ初期化されていない場合は後で初期化
|
||||
this.logger = null;
|
||||
}
|
||||
|
||||
// 型ガードでVADの使用を判定
|
||||
if (options.useVAD) {
|
||||
// TypeScriptの型推論により、ここではoptions.appが必須であることが保証される
|
||||
this.vadProcessor = new VADProcessor(options.app, {
|
||||
vadMode: options.vadMode || DEFAULT_VAD_SETTINGS.mode,
|
||||
minSpeechDuration: options.minSpeechDuration || DEFAULT_VAD_SETTINGS.minSpeechDuration,
|
||||
minSilenceDuration: options.minSilenceDuration || DEFAULT_VAD_SETTINGS.minSilenceDuration,
|
||||
speechPadding: DEFAULT_VAD_SETTINGS.speechPadding
|
||||
});
|
||||
}
|
||||
// VADを使用しない場合は、vadProcessorはnullのまま
|
||||
|
||||
// Initialize ring buffer with max recording duration
|
||||
const maxSeconds = this.options.maxRecordingSeconds || AUDIO_CONSTANTS.MAX_RECORDING_SECONDS;
|
||||
this.audioRingBuffer = new AudioRingBuffer(maxSeconds, this.sampleRate);
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
this.throwIfDisposed();
|
||||
|
||||
if (this.options.useVAD && this.vadProcessor) {
|
||||
await this.vadProcessor.initialize();
|
||||
}
|
||||
|
||||
// Create audio context with configured sample rate and low latency hint
|
||||
const WindowWithWebkit = window as WindowWithWebkitAudio;
|
||||
const AudioContextConstructor = window.AudioContext || WindowWithWebkit.webkitAudioContext;
|
||||
|
||||
if (!AudioContextConstructor) {
|
||||
throw new TranscriptionError(
|
||||
TranscriptionErrorType.AUDIO_INITIALIZATION_FAILED,
|
||||
'AudioContext is not supported in this browser'
|
||||
);
|
||||
}
|
||||
|
||||
this.audioContext = new AudioContextConstructor({
|
||||
sampleRate: AUDIO_CONSTANTS.SAMPLE_RATE,
|
||||
latencyHint: AUDIO_CONSTANTS.LATENCY_HINT // Low latency mode for faster initialization
|
||||
});
|
||||
this.sampleRate = this.audioContext.sampleRate;
|
||||
|
||||
// Try to use AudioWorklet with blob URL
|
||||
if (this.audioContext.audioWorklet) {
|
||||
try {
|
||||
// Create blob URL for AudioWorklet
|
||||
this.workletBlobURL = createAudioWorkletBlobURL();
|
||||
await this.audioContext.audioWorklet.addModule(this.workletBlobURL);
|
||||
this.workletReady = true;
|
||||
this.logger?.info('AudioWorklet initialized successfully using blob URL');
|
||||
} catch (error) {
|
||||
this.logger?.info('AudioWorklet not available, using ScriptProcessor fallback');
|
||||
this.workletReady = false;
|
||||
// Clean up blob URL if it was created
|
||||
if (this.workletBlobURL) {
|
||||
revokeAudioWorkletBlobURL(this.workletBlobURL);
|
||||
this.workletBlobURL = null;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// AudioWorklet not supported
|
||||
this.workletReady = false;
|
||||
}
|
||||
|
||||
// Create visualizer if container provided
|
||||
if (this.options.visualizerContainer) {
|
||||
if (this.options.useSimpleVisualizer) {
|
||||
this.visualizer = new SimpleAudioLevelIndicator(this.options.visualizerContainer);
|
||||
} else {
|
||||
this.visualizer = new AudioVisualizer(this.options.visualizerContainer);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
async startRecording(): Promise<void> {
|
||||
this.throwIfDisposed();
|
||||
if (this.isRecording) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Log recording session start
|
||||
this.logger?.info('Starting recording session', {
|
||||
sampleRate: AUDIO_CONSTANTS.SAMPLE_RATE,
|
||||
channelCount: DEFAULT_AUDIO_SETTINGS.channelCount,
|
||||
maxDuration: this.options.maxRecordingSeconds || AUDIO_CONSTANTS.MAX_RECORDING_SECONDS,
|
||||
vadEnabled: this.options.useVAD !== false,
|
||||
vadMode: this.options.useVAD !== false ? (this.options.vadMode || DEFAULT_VAD_SETTINGS.mode) : DEFAULT_VAD_SETTINGS.mode
|
||||
});
|
||||
|
||||
// Notify initialization started
|
||||
if (this.options.onMicrophoneStatusChange) {
|
||||
this.options.onMicrophoneStatusChange('initializing');
|
||||
}
|
||||
|
||||
// Get microphone access with optimized settings for transcription
|
||||
this.stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
sampleRate: AUDIO_CONSTANTS.SAMPLE_RATE, // Optimal for speech recognition
|
||||
channelCount: DEFAULT_AUDIO_SETTINGS.channelCount, // Mono audio
|
||||
echoCancellation: DEFAULT_AUDIO_SETTINGS.echoCancellation,
|
||||
noiseSuppression: DEFAULT_AUDIO_SETTINGS.noiseSuppression,
|
||||
autoGainControl: DEFAULT_AUDIO_SETTINGS.autoGainControl
|
||||
}
|
||||
});
|
||||
|
||||
// Monitor stream tracks for mute state
|
||||
const audioTrack = this.stream.getAudioTracks()[0];
|
||||
if (audioTrack) {
|
||||
// Check if track is initially muted (Windows often starts muted)
|
||||
if (audioTrack.muted) {
|
||||
this.logger?.warn('Microphone track is muted, waiting for unmute...');
|
||||
}
|
||||
|
||||
// Listen for mute state changes
|
||||
audioTrack.addEventListener('mute', () => {
|
||||
this.logger?.debug('Microphone muted');
|
||||
this.microphoneReady = false;
|
||||
});
|
||||
|
||||
audioTrack.addEventListener('unmute', () => {
|
||||
this.logger?.debug('Microphone unmuted');
|
||||
if (!this.microphoneReady && this.audioDataReceived) {
|
||||
this.microphoneReady = true;
|
||||
if (this.options.onMicrophoneStatusChange) {
|
||||
this.options.onMicrophoneStatusChange('ready');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Setup audio processing chain with filters
|
||||
this.sourceNode = this.audioContext!.createMediaStreamSource(this.stream);
|
||||
this.gainNode = this.audioContext!.createGain();
|
||||
this.gainNode.gain.value = DEFAULT_AUDIO_SETTINGS.gain; // デフォルトゲイン
|
||||
|
||||
// Create BiquadFilters for band-limiting
|
||||
this.highPassFilter = this.audioContext!.createBiquadFilter();
|
||||
this.highPassFilter.type = 'highpass';
|
||||
this.highPassFilter.frequency.value = AUDIO_CONSTANTS.FILTERS.HIGH_PASS_FREQ; // HPF - 低域ノイズ除去
|
||||
|
||||
this.lowPassFilter = this.audioContext!.createBiquadFilter();
|
||||
this.lowPassFilter.type = 'lowpass';
|
||||
this.lowPassFilter.frequency.value = AUDIO_CONSTANTS.FILTERS.LOW_PASS_FREQ; // LPF - 音声帯域に制限
|
||||
|
||||
this.analyserNode = this.audioContext!.createAnalyser();
|
||||
|
||||
// Connect: source -> gain -> HPF -> LPF -> analyser
|
||||
this.sourceNode.connect(this.gainNode);
|
||||
this.gainNode.connect(this.highPassFilter);
|
||||
this.highPassFilter.connect(this.lowPassFilter);
|
||||
this.lowPassFilter.connect(this.analyserNode);
|
||||
|
||||
// Connect visualizer
|
||||
if (this.visualizer) {
|
||||
this.visualizer.connect(this.analyserNode);
|
||||
this.visualizer.start();
|
||||
}
|
||||
|
||||
// Setup MediaRecorder
|
||||
this.mediaRecorder = new MediaRecorder(this.stream, {
|
||||
mimeType: AUDIO_CONSTANTS.WEBM_CODEC
|
||||
});
|
||||
|
||||
this.chunks = [];
|
||||
this.continuousAudioData = [];
|
||||
this.isRecording = true;
|
||||
this.recordingStartTime = Date.now();
|
||||
|
||||
// Set up maximum recording time limit
|
||||
const maxSeconds = this.options.maxRecordingSeconds || AUDIO_CONSTANTS.MAX_RECORDING_SECONDS;
|
||||
this.maxRecordingTimer = setTimeout(async () => {
|
||||
this.logger?.info(`Maximum recording time (${maxSeconds}s) reached, stopping recording`);
|
||||
const audioBlob = await this.stopRecording();
|
||||
if (audioBlob && this.options.onSpeechEnd) {
|
||||
this.options.onSpeechEnd(audioBlob);
|
||||
}
|
||||
}, maxSeconds * 1000);
|
||||
|
||||
this.mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
this.chunks.push(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
this.mediaRecorder.start(AUDIO_CONSTANTS.RECORDER_TIMESLICE); // Collect data at configured interval
|
||||
|
||||
// Start continuous audio processing for VAD
|
||||
this.startContinuousProcessing();
|
||||
|
||||
this.logger?.debug('Recording started successfully', {
|
||||
mimeType: AUDIO_CONSTANTS.WEBM_CODEC,
|
||||
timeslice: AUDIO_CONSTANTS.RECORDER_TIMESLICE
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
this.logger?.error('Failed to start recording', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async startContinuousProcessing(): Promise<void> {
|
||||
if (!this.audioContext || !this.sourceNode) return;
|
||||
|
||||
if (this.workletReady) {
|
||||
// Use AudioWorklet (preferred)
|
||||
try {
|
||||
this.workletNode = new AudioWorkletNode(this.audioContext, 'audio-processor-worklet', {
|
||||
numberOfInputs: 1,
|
||||
numberOfOutputs: 1,
|
||||
channelCount: 1
|
||||
});
|
||||
|
||||
// Configure worklet
|
||||
this.workletNode.port.postMessage({
|
||||
type: 'configure',
|
||||
bufferSize: AUDIO_CONSTANTS.BUFFER_SIZE
|
||||
});
|
||||
|
||||
// Start recording
|
||||
this.workletNode.port.postMessage({ type: 'start' });
|
||||
|
||||
// Handle audio data from worklet
|
||||
this.workletNode.port.onmessage = async (event) => {
|
||||
if (event.data.type === 'audio' && this.isRecording) {
|
||||
await this.processAudioData(event.data.data);
|
||||
}
|
||||
};
|
||||
|
||||
// Connect nodes: lowPassFilter (end of chain) -> worklet
|
||||
this.lowPassFilter!.connect(this.workletNode);
|
||||
this.workletNode.connect(this.audioContext.destination);
|
||||
} catch (error) {
|
||||
this.logger?.warn('Failed to use AudioWorklet, falling back to ScriptProcessor', error);
|
||||
this.workletReady = false;
|
||||
this.startLegacyProcessing();
|
||||
}
|
||||
} else {
|
||||
// Fallback to ScriptProcessor
|
||||
this.startLegacyProcessing();
|
||||
}
|
||||
}
|
||||
|
||||
private startLegacyProcessing(): void {
|
||||
if (!this.audioContext || !this.sourceNode) return;
|
||||
|
||||
const bufferSize = AUDIO_CONSTANTS.BUFFER_SIZE;
|
||||
const scriptProcessor = this.audioContext.createScriptProcessor(bufferSize, 1, 1);
|
||||
|
||||
this.lowPassFilter!.connect(scriptProcessor);
|
||||
scriptProcessor.connect(this.audioContext.destination);
|
||||
|
||||
scriptProcessor.onaudioprocess = async (event) => {
|
||||
if (!this.isRecording) return;
|
||||
|
||||
const inputData = event.inputBuffer.getChannelData(0);
|
||||
const audioData = new Float32Array(inputData);
|
||||
await this.processAudioData(audioData);
|
||||
};
|
||||
}
|
||||
|
||||
private async processAudioData(audioData: Float32Array): Promise<void> {
|
||||
// Early return if not recording to prevent unnecessary processing
|
||||
if (!this.isRecording) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Write to ring buffer to prevent unlimited memory growth
|
||||
this.audioRingBuffer.write(audioData);
|
||||
|
||||
// Check for actual audio data to confirm microphone is working
|
||||
const audioLevel = Math.max(...audioData.map(Math.abs));
|
||||
|
||||
// If we haven't marked microphone as ready yet, check for non-zero audio data
|
||||
if (!this.microphoneReady && audioLevel > AUDIO_CONSTANTS.MIC_DETECTION_THRESHOLD) {
|
||||
this.audioDataReceived = true;
|
||||
const audioTrack = this.stream?.getAudioTracks()[0];
|
||||
|
||||
// Check if track is not muted and we have actual audio data
|
||||
if (audioTrack && !audioTrack.muted) {
|
||||
this.microphoneReady = true;
|
||||
this.logger?.info('Microphone is now ready - audio data detected');
|
||||
if (this.options.onMicrophoneStatusChange) {
|
||||
this.options.onMicrophoneStatusChange('ready');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (this.options.useVAD) {
|
||||
// VADモード: 音声区間を自動検出
|
||||
this.continuousAudioData.push(audioData);
|
||||
|
||||
// Accumulate audio data for VAD processing
|
||||
if (this.continuousAudioData.length >= VAD_CONSTANTS.VAD_ACCUMULATION_THRESHOLD) { // Process when enough frames accumulated
|
||||
const combinedData = this.combineAudioData(this.continuousAudioData);
|
||||
this.continuousAudioData = [];
|
||||
|
||||
|
||||
// Process with VAD
|
||||
const segments = this.vadProcessor ? await this.vadProcessor.detectSpeechSegments(combinedData) : [];
|
||||
|
||||
if (segments.length > 0) {
|
||||
this.handleSpeechDetected();
|
||||
} else {
|
||||
this.handleSilenceDetected();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 連続録音モード: 常に録音中として扱う
|
||||
this.lastSpeechTime = Date.now();
|
||||
}
|
||||
|
||||
// Log buffer usage periodically
|
||||
if (Math.random() < AUDIO_CONSTANTS.BUFFER_STATS_LOG_PROBABILITY) { // Log periodically
|
||||
const stats = this.audioRingBuffer.getStats();
|
||||
if (stats.percentage > AUDIO_CONSTANTS.BUFFER_WARNING_THRESHOLD) {
|
||||
this.logger?.warn(`Audio buffer usage high: ${stats.percentage.toFixed(1)}%`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private combineAudioData(arrays: Float32Array[]): Float32Array {
|
||||
const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0);
|
||||
const result = new Float32Array(totalLength);
|
||||
let offset = 0;
|
||||
|
||||
for (const arr of arrays) {
|
||||
result.set(arr, offset);
|
||||
offset += arr.length;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private handleSpeechDetected(): void {
|
||||
this.lastSpeechTime = Date.now();
|
||||
|
||||
if (this.silenceTimer) {
|
||||
clearTimeout(this.silenceTimer);
|
||||
this.silenceTimer = null;
|
||||
}
|
||||
|
||||
if (this.visualizer) {
|
||||
this.visualizer.setVADStatus('speech');
|
||||
}
|
||||
|
||||
if (this.options.useVAD) {
|
||||
this.options.onVADStatusChange('speech');
|
||||
}
|
||||
}
|
||||
|
||||
private handleSilenceDetected(): void {
|
||||
if (this.visualizer) {
|
||||
this.visualizer.setVADStatus('silence');
|
||||
}
|
||||
|
||||
if (this.options.useVAD) {
|
||||
this.options.onVADStatusChange('silence');
|
||||
}
|
||||
|
||||
// Check if we should auto-stop due to silence (only in VAD mode)
|
||||
if (this.options.useVAD && this.lastSpeechTime > 0 && !this.silenceTimer) {
|
||||
this.silenceTimer = setTimeout(async () => {
|
||||
if (this.isRecording) {
|
||||
const audioBlob = await this.stopRecording();
|
||||
if (audioBlob && this.options.onSpeechEnd) {
|
||||
this.options.onSpeechEnd(audioBlob);
|
||||
}
|
||||
}
|
||||
}, this.options.useVAD ? this.options.autoStopSilenceDuration! : 0);
|
||||
}
|
||||
}
|
||||
|
||||
async stopRecording(): Promise<Blob | null> {
|
||||
this.throwIfDisposed();
|
||||
if (!this.isRecording || !this.mediaRecorder) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Clear maximum recording timer
|
||||
if (this.maxRecordingTimer) {
|
||||
clearTimeout(this.maxRecordingTimer);
|
||||
this.maxRecordingTimer = null;
|
||||
}
|
||||
|
||||
// Log recording session end
|
||||
if (this.recordingStartTime) {
|
||||
const duration = (Date.now() - this.recordingStartTime) / 1000;
|
||||
this.logger?.info('Recording session ended', {
|
||||
duration: duration.toFixed(1),
|
||||
chunksCount: this.chunks.length,
|
||||
totalAudioData: this.continuousAudioData.length
|
||||
});
|
||||
}
|
||||
|
||||
this.isRecording = false;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
this.mediaRecorder!.onstop = () => {
|
||||
const audioBlob = new Blob(this.chunks, { type: 'audio/webm' });
|
||||
|
||||
this.cleanup();
|
||||
|
||||
// Do not call onSpeechEnd here - it will be called by specific stop handlers
|
||||
|
||||
resolve(audioBlob);
|
||||
};
|
||||
|
||||
this.mediaRecorder!.stop();
|
||||
});
|
||||
}
|
||||
|
||||
private cleanup(): void {
|
||||
if (this.silenceTimer) {
|
||||
clearTimeout(this.silenceTimer);
|
||||
this.silenceTimer = null;
|
||||
}
|
||||
|
||||
if (this.maxRecordingTimer) {
|
||||
clearTimeout(this.maxRecordingTimer);
|
||||
this.maxRecordingTimer = null;
|
||||
}
|
||||
|
||||
if (this.workletNode) {
|
||||
// Clear event handlers first
|
||||
if (this.workletNode.port) {
|
||||
this.workletNode.port.onmessage = null;
|
||||
}
|
||||
this.workletNode.port.postMessage({ type: 'stop' });
|
||||
this.workletNode.disconnect();
|
||||
this.workletNode = null;
|
||||
}
|
||||
|
||||
if (this.visualizer) {
|
||||
this.visualizer.stop();
|
||||
}
|
||||
|
||||
if (this.stream) {
|
||||
this.stream.getTracks().forEach(track => track.stop());
|
||||
this.stream = null;
|
||||
}
|
||||
|
||||
if (this.sourceNode) {
|
||||
this.sourceNode.disconnect();
|
||||
this.sourceNode = null;
|
||||
}
|
||||
|
||||
if (this.gainNode) {
|
||||
this.gainNode.disconnect();
|
||||
this.gainNode = null;
|
||||
}
|
||||
|
||||
if (this.highPassFilter) {
|
||||
this.highPassFilter.disconnect();
|
||||
this.highPassFilter = null;
|
||||
}
|
||||
|
||||
if (this.lowPassFilter) {
|
||||
this.lowPassFilter.disconnect();
|
||||
this.lowPassFilter = null;
|
||||
}
|
||||
|
||||
if (this.analyserNode) {
|
||||
this.analyserNode.disconnect();
|
||||
this.analyserNode = null;
|
||||
}
|
||||
|
||||
this.chunks = [];
|
||||
this.continuousAudioData = [];
|
||||
this.lastSpeechTime = 0;
|
||||
this.audioRingBuffer.clear();
|
||||
this.microphoneReady = false;
|
||||
this.audioDataReceived = false;
|
||||
}
|
||||
|
||||
isActive(): boolean {
|
||||
return this.isRecording;
|
||||
}
|
||||
|
||||
isMicrophoneReady(): boolean {
|
||||
return this.microphoneReady;
|
||||
}
|
||||
|
||||
setAudioGain(gain: number): void {
|
||||
if (this.gainNode) {
|
||||
this.gainNode.gain.value = gain;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disposableパターンの実装
|
||||
*/
|
||||
protected onDispose(): void {
|
||||
this.cleanup();
|
||||
|
||||
if (this.visualizer) {
|
||||
this.visualizer.destroy();
|
||||
this.visualizer = null;
|
||||
}
|
||||
|
||||
if (this.audioContext) {
|
||||
this.audioContext.close();
|
||||
this.audioContext = null;
|
||||
}
|
||||
|
||||
// Clean up blob URL
|
||||
if (this.workletBlobURL) {
|
||||
revokeAudioWorkletBlobURL(this.workletBlobURL);
|
||||
this.workletBlobURL = null;
|
||||
}
|
||||
|
||||
if (this.vadProcessor) {
|
||||
this.vadProcessor.destroy();
|
||||
this.vadProcessor = null;
|
||||
}
|
||||
|
||||
// Dispose all registered disposables
|
||||
this.disposables.dispose();
|
||||
}
|
||||
|
||||
/**
|
||||
* 互換性のためのdestroyメソッド
|
||||
* @deprecated dispose()を使用してください
|
||||
*/
|
||||
destroy(): void {
|
||||
this.dispose();
|
||||
}
|
||||
|
||||
setAutoStopDuration(duration: number): void {
|
||||
if (this.options.useVAD) {
|
||||
this.options.autoStopSilenceDuration = duration;
|
||||
}
|
||||
}
|
||||
}
|
||||
114
src/core/audio/AudioRingBuffer.ts
Normal file
114
src/core/audio/AudioRingBuffer.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import { AUDIO_CONSTANTS } from '../../config';
|
||||
|
||||
/**
|
||||
* Ring buffer implementation for efficient audio data management
|
||||
* Prevents memory leaks by limiting buffer size
|
||||
*/
|
||||
export class AudioRingBuffer {
|
||||
private buffer: Float32Array;
|
||||
private writePos: number = 0;
|
||||
private readPos: number = 0;
|
||||
private maxSamples: number;
|
||||
private filled: boolean = false;
|
||||
|
||||
constructor(maxDurationSeconds: number = AUDIO_CONSTANTS.DEFAULT_RING_BUFFER_SECONDS, sampleRate: number = AUDIO_CONSTANTS.SAMPLE_RATE) {
|
||||
// Limit to maximum duration to prevent excessive memory usage
|
||||
const maxAllowedSeconds = AUDIO_CONSTANTS.MAX_RECORDING_SECONDS;
|
||||
const maxAllowedSamples = maxAllowedSeconds * sampleRate;
|
||||
this.maxSamples = Math.min(maxDurationSeconds * sampleRate, maxAllowedSamples);
|
||||
this.buffer = new Float32Array(this.maxSamples);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write audio data to the ring buffer
|
||||
*/
|
||||
write(data: Float32Array): void {
|
||||
const dataLength = data.length;
|
||||
|
||||
// If data is larger than buffer, only keep the latest samples
|
||||
if (dataLength >= this.maxSamples) {
|
||||
this.buffer.set(data.slice(dataLength - this.maxSamples));
|
||||
this.writePos = 0;
|
||||
this.readPos = 0;
|
||||
this.filled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate available space
|
||||
const remainingSpace = this.maxSamples - this.writePos;
|
||||
|
||||
if (dataLength <= remainingSpace) {
|
||||
// Data fits in remaining space
|
||||
this.buffer.set(data, this.writePos);
|
||||
this.writePos += dataLength;
|
||||
} else {
|
||||
// Data wraps around
|
||||
this.buffer.set(data.slice(0, remainingSpace), this.writePos);
|
||||
this.buffer.set(data.slice(remainingSpace), 0);
|
||||
this.writePos = dataLength - remainingSpace;
|
||||
this.filled = true;
|
||||
}
|
||||
|
||||
// Update read position if we've wrapped around
|
||||
if (this.filled && this.writePos >= this.readPos) {
|
||||
this.readPos = this.writePos;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read all available data from the buffer
|
||||
*/
|
||||
read(): Float32Array {
|
||||
const availableSamples = this.getAvailableSamples();
|
||||
if (availableSamples === 0) {
|
||||
return new Float32Array(0);
|
||||
}
|
||||
|
||||
const result = new Float32Array(availableSamples);
|
||||
|
||||
if (this.filled) {
|
||||
// Buffer has wrapped, read from readPos to end, then from start to writePos
|
||||
const endSamples = this.maxSamples - this.readPos;
|
||||
result.set(this.buffer.slice(this.readPos, this.maxSamples), 0);
|
||||
result.set(this.buffer.slice(0, this.writePos), endSamples);
|
||||
} else {
|
||||
// Buffer hasn't wrapped, simple read
|
||||
result.set(this.buffer.slice(0, this.writePos), 0);
|
||||
}
|
||||
|
||||
// Reset after reading
|
||||
this.clear();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of available samples
|
||||
*/
|
||||
getAvailableSamples(): number {
|
||||
if (!this.filled) {
|
||||
return this.writePos;
|
||||
}
|
||||
return this.maxSamples;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the buffer
|
||||
*/
|
||||
clear(): void {
|
||||
this.writePos = 0;
|
||||
this.readPos = 0;
|
||||
this.filled = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get buffer statistics
|
||||
*/
|
||||
getStats(): { used: number; total: number; percentage: number } {
|
||||
const used = this.getAvailableSamples();
|
||||
const total = this.maxSamples;
|
||||
const percentage = (used / total) * 100;
|
||||
|
||||
return { used, total, percentage };
|
||||
}
|
||||
}
|
||||
308
src/core/audio/AudioVisualizer.ts
Normal file
308
src/core/audio/AudioVisualizer.ts
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
import { VISUALIZATION_CONSTANTS } from '../../config';
|
||||
import { Disposable } from '../../interfaces';
|
||||
|
||||
export class AudioVisualizer extends Disposable {
|
||||
private canvas: HTMLCanvasElement;
|
||||
private ctx: CanvasRenderingContext2D;
|
||||
private analyser: AnalyserNode | null = null;
|
||||
private dataArray: Uint8Array | null = null;
|
||||
private isRecording: boolean = false;
|
||||
private animationId: number | null = null;
|
||||
private vadStatus: 'silence' | 'speech' = 'silence';
|
||||
private vadThreshold: number = VISUALIZATION_CONSTANTS.DEFAULT_VAD_THRESHOLD;
|
||||
private currentLevel: number = 0;
|
||||
private waveformHistory: number[] = []; // 時系列波形データ
|
||||
private maxHistoryLength: number = VISUALIZATION_CONSTANTS.MAX_HISTORY_LENGTH;
|
||||
|
||||
constructor(container: HTMLElement) {
|
||||
super();
|
||||
this.canvas = document.createElement('canvas');
|
||||
this.canvas.className = 'audio-visualizer-canvas';
|
||||
this.canvas.width = 200;
|
||||
this.canvas.height = 56;
|
||||
container.appendChild(this.canvas);
|
||||
|
||||
const context = this.canvas.getContext('2d');
|
||||
if (!context) {
|
||||
throw new Error('Failed to get canvas context');
|
||||
}
|
||||
this.ctx = context;
|
||||
|
||||
this.drawEmptyGraph();
|
||||
}
|
||||
|
||||
connect(analyser: AnalyserNode): void {
|
||||
this.analyser = analyser;
|
||||
this.analyser.fftSize = 2048;
|
||||
const bufferLength = this.analyser.fftSize;
|
||||
this.dataArray = new Uint8Array(bufferLength);
|
||||
}
|
||||
|
||||
start(): void {
|
||||
this.isRecording = true;
|
||||
this.draw();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.isRecording = false;
|
||||
if (this.animationId) {
|
||||
cancelAnimationFrame(this.animationId);
|
||||
this.animationId = null;
|
||||
}
|
||||
this.clear();
|
||||
}
|
||||
|
||||
setVADStatus(status: 'silence' | 'speech'): void {
|
||||
this.vadStatus = status;
|
||||
}
|
||||
|
||||
setVADThreshold(threshold: number): void {
|
||||
this.vadThreshold = threshold;
|
||||
}
|
||||
|
||||
private draw(): void {
|
||||
if (!this.isRecording || !this.analyser || !this.dataArray) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.animationId = requestAnimationFrame(() => this.draw());
|
||||
|
||||
// Get time domain data (waveform)
|
||||
this.analyser.getByteTimeDomainData(this.dataArray);
|
||||
|
||||
// Calculate RMS (Root Mean Square) for current audio level
|
||||
let sum = 0;
|
||||
for (let i = 0; i < this.dataArray.length; i++) {
|
||||
const normalized = (this.dataArray[i] - 128) / 128;
|
||||
sum += normalized * normalized;
|
||||
}
|
||||
const rms = Math.sqrt(sum / this.dataArray.length);
|
||||
this.currentLevel = rms;
|
||||
|
||||
// Apply scaling factor to make waveform more visible
|
||||
// RMS values are typically 0.0-0.3, so scale up by 3-5x
|
||||
const scalingFactor = 4.0;
|
||||
const scaledRms = Math.min(rms * scalingFactor, 1.0); // Cap at 1.0
|
||||
|
||||
// Add to history
|
||||
this.waveformHistory.push(scaledRms);
|
||||
if (this.waveformHistory.length > this.maxHistoryLength) {
|
||||
this.waveformHistory.shift();
|
||||
}
|
||||
|
||||
// Clear canvas
|
||||
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
|
||||
// Draw waveform
|
||||
this.drawWaveform();
|
||||
|
||||
// Draw center line
|
||||
this.drawCenterLine();
|
||||
}
|
||||
|
||||
private drawWaveform(): void {
|
||||
if (this.waveformHistory.length < 2) return;
|
||||
|
||||
const width = this.canvas.width;
|
||||
const height = this.canvas.height;
|
||||
const centerY = height / 2;
|
||||
const maxAmplitude = height / 2 - 2;
|
||||
|
||||
// Determine if current level is above threshold
|
||||
const isAboveThreshold = this.currentLevel > this.vadThreshold;
|
||||
|
||||
// Set stroke style
|
||||
this.ctx.strokeStyle = isAboveThreshold ? '#40c057' : '#868e96';
|
||||
this.ctx.lineWidth = 2;
|
||||
|
||||
// Draw positive waveform
|
||||
this.drawWaveformPath(width, centerY, maxAmplitude, false);
|
||||
this.ctx.stroke();
|
||||
|
||||
// Draw negative waveform (mirror)
|
||||
this.drawWaveformPath(width, centerY, maxAmplitude, true);
|
||||
this.ctx.stroke();
|
||||
|
||||
// Fill area if above threshold
|
||||
if (isAboveThreshold) {
|
||||
this.ctx.fillStyle = 'rgba(64, 192, 87, 0.1)';
|
||||
this.drawWaveformPath(width, centerY, maxAmplitude, false);
|
||||
// Complete the path
|
||||
for (let i = this.waveformHistory.length - 1; i >= 0; i--) {
|
||||
const x = (i / (this.maxHistoryLength - 1)) * width;
|
||||
const amplitude = this.waveformHistory[i] * maxAmplitude;
|
||||
const y = centerY + amplitude;
|
||||
this.ctx.lineTo(x, y);
|
||||
}
|
||||
this.ctx.closePath();
|
||||
this.ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
private drawCenterLine(): void {
|
||||
const centerY = this.canvas.height / 2;
|
||||
|
||||
this.ctx.strokeStyle = '#dee2e6';
|
||||
this.ctx.lineWidth = 1;
|
||||
this.ctx.setLineDash([5, 5]);
|
||||
|
||||
this.ctx.beginPath();
|
||||
this.ctx.moveTo(0, centerY);
|
||||
this.ctx.lineTo(this.canvas.width, centerY);
|
||||
this.ctx.stroke();
|
||||
|
||||
this.ctx.setLineDash([]);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Draw waveform path
|
||||
*/
|
||||
private drawWaveformPath(width: number, centerY: number, maxAmplitude: number, isMirrored: boolean): void {
|
||||
this.ctx.beginPath();
|
||||
for (let i = 0; i < this.waveformHistory.length; i++) {
|
||||
const x = (i / (this.maxHistoryLength - 1)) * width;
|
||||
const amplitude = this.waveformHistory[i] * maxAmplitude;
|
||||
const y = centerY + (isMirrored ? amplitude : -amplitude);
|
||||
|
||||
if (i === 0) {
|
||||
this.ctx.moveTo(x, y);
|
||||
} else {
|
||||
this.ctx.lineTo(x, y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private clear(): void {
|
||||
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
this.waveformHistory = []; // 波形履歴もクリア
|
||||
this.drawEmptyGraph();
|
||||
}
|
||||
|
||||
private drawEmptyGraph(): void {
|
||||
// Clear canvas
|
||||
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
|
||||
// Draw center line
|
||||
this.drawCenterLine();
|
||||
|
||||
// Draw flat waveform line
|
||||
const centerY = this.canvas.height / 2;
|
||||
this.ctx.strokeStyle = '#868e96';
|
||||
this.ctx.lineWidth = 1;
|
||||
this.ctx.beginPath();
|
||||
this.ctx.moveTo(0, centerY);
|
||||
this.ctx.lineTo(this.canvas.width, centerY);
|
||||
this.ctx.stroke();
|
||||
}
|
||||
|
||||
/**
|
||||
* Disposableパターンの実装
|
||||
*/
|
||||
protected onDispose(): void {
|
||||
this.stop();
|
||||
if (this.canvas.parentNode) {
|
||||
this.canvas.parentNode.removeChild(this.canvas);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 互換性のためのdestroyメソッド
|
||||
* @deprecated dispose()を使用してください
|
||||
*/
|
||||
destroy(): void {
|
||||
this.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
export class SimpleAudioLevelIndicator {
|
||||
private container: HTMLElement;
|
||||
private levelBar: HTMLElement;
|
||||
private vadIndicator: HTMLElement;
|
||||
private analyser: AnalyserNode | null = null;
|
||||
private dataArray: Uint8Array | null = null;
|
||||
private animationId: number | null = null;
|
||||
private isActive: boolean = false;
|
||||
|
||||
constructor(container: HTMLElement) {
|
||||
this.container = container;
|
||||
this.createUI();
|
||||
}
|
||||
|
||||
private createUI(): void {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'audio-level-indicator';
|
||||
|
||||
// Level bar
|
||||
const levelContainer = document.createElement('div');
|
||||
levelContainer.className = 'audio-level-container';
|
||||
|
||||
this.levelBar = document.createElement('div');
|
||||
this.levelBar.className = 'audio-level-bar';
|
||||
|
||||
levelContainer.appendChild(this.levelBar);
|
||||
|
||||
// VAD indicator
|
||||
this.vadIndicator = document.createElement('div');
|
||||
this.vadIndicator.className = 'audio-vad-indicator';
|
||||
|
||||
wrapper.appendChild(levelContainer);
|
||||
wrapper.appendChild(this.vadIndicator);
|
||||
this.container.appendChild(wrapper);
|
||||
}
|
||||
|
||||
connect(analyser: AnalyserNode): void {
|
||||
this.analyser = analyser;
|
||||
this.analyser.fftSize = 256;
|
||||
this.dataArray = new Uint8Array(this.analyser.frequencyBinCount);
|
||||
}
|
||||
|
||||
start(): void {
|
||||
this.isActive = true;
|
||||
this.update();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.isActive = false;
|
||||
if (this.animationId) {
|
||||
cancelAnimationFrame(this.animationId);
|
||||
this.animationId = null;
|
||||
}
|
||||
this.levelBar.style.setProperty('--audio-level-width', '0%');
|
||||
}
|
||||
|
||||
setVADStatus(status: 'silence' | 'speech'): void {
|
||||
if (status === 'speech') {
|
||||
this.vadIndicator.classList.add('speech');
|
||||
} else {
|
||||
this.vadIndicator.classList.remove('speech');
|
||||
}
|
||||
}
|
||||
|
||||
private update(): void {
|
||||
if (!this.isActive || !this.analyser || !this.dataArray) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.animationId = requestAnimationFrame(() => this.update());
|
||||
|
||||
this.analyser.getByteFrequencyData(this.dataArray);
|
||||
|
||||
// Calculate average level with emphasis on lower frequencies (voice range)
|
||||
let sum = 0;
|
||||
const relevantBins = Math.min(this.dataArray.length / 4, 50); // Focus on voice frequencies
|
||||
for (let i = 0; i < relevantBins; i++) {
|
||||
sum += this.dataArray[i];
|
||||
}
|
||||
const average = sum / relevantBins;
|
||||
// Apply scaling for better visibility
|
||||
const percentage = Math.min((average / 128) * 100, 100);
|
||||
|
||||
this.levelBar.style.setProperty('--audio-level-width', `${percentage}%`);
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.stop();
|
||||
this.container.empty();
|
||||
}
|
||||
}
|
||||
123
src/core/audio/AudioWorkletSource.ts
Normal file
123
src/core/audio/AudioWorkletSource.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
/**
|
||||
* AudioWorklet source code as a string
|
||||
* This allows us to create a blob URL for loading in restricted environments
|
||||
*/
|
||||
|
||||
export const AUDIO_WORKLET_SOURCE = `
|
||||
// AudioWorkletProcessor for real-time audio processing
|
||||
class AudioProcessorWorklet extends AudioWorkletProcessor {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.bufferSize = 4096;
|
||||
this.audioBuffer = [];
|
||||
this.isRecording = false;
|
||||
|
||||
// Listen for messages from main thread
|
||||
this.port.onmessage = (event) => {
|
||||
this.handleMessage(event.data);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle messages from main thread
|
||||
*/
|
||||
handleMessage(data) {
|
||||
switch (data.type) {
|
||||
case 'start':
|
||||
this.isRecording = true;
|
||||
this.audioBuffer = [];
|
||||
break;
|
||||
|
||||
case 'stop':
|
||||
this.isRecording = false;
|
||||
// Send any remaining buffered audio
|
||||
if (this.audioBuffer.length > 0) {
|
||||
this.sendAudioData();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'configure':
|
||||
if (data.bufferSize) {
|
||||
this.bufferSize = data.bufferSize;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process audio - called for each render quantum (128 samples)
|
||||
*/
|
||||
process(inputs, outputs, parameters) {
|
||||
const input = inputs[0];
|
||||
|
||||
if (!this.isRecording || !input || input.length === 0) {
|
||||
return true; // Keep processor alive
|
||||
}
|
||||
|
||||
// Get the first channel
|
||||
const channelData = input[0];
|
||||
if (!channelData || channelData.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Buffer the audio data
|
||||
this.audioBuffer.push(new Float32Array(channelData));
|
||||
|
||||
// Check if we've accumulated enough data
|
||||
const totalSamples = this.audioBuffer.reduce((sum, buffer) => sum + buffer.length, 0);
|
||||
if (totalSamples >= this.bufferSize) {
|
||||
this.sendAudioData();
|
||||
}
|
||||
|
||||
return true; // Keep processor alive
|
||||
}
|
||||
|
||||
/**
|
||||
* Send accumulated audio data to main thread
|
||||
*/
|
||||
sendAudioData() {
|
||||
if (this.audioBuffer.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Combine all buffers into one
|
||||
const totalLength = this.audioBuffer.reduce((sum, buffer) => sum + buffer.length, 0);
|
||||
const combinedBuffer = new Float32Array(totalLength);
|
||||
|
||||
let offset = 0;
|
||||
for (const buffer of this.audioBuffer) {
|
||||
combinedBuffer.set(buffer, offset);
|
||||
offset += buffer.length;
|
||||
}
|
||||
|
||||
// Send to main thread
|
||||
this.port.postMessage({
|
||||
type: 'audio',
|
||||
data: combinedBuffer,
|
||||
timestamp: currentTime
|
||||
});
|
||||
|
||||
// Clear the buffer
|
||||
this.audioBuffer = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Register the processor
|
||||
registerProcessor('audio-processor-worklet', AudioProcessorWorklet);
|
||||
`;
|
||||
|
||||
/**
|
||||
* Create a blob URL for the AudioWorklet
|
||||
*/
|
||||
export function createAudioWorkletBlobURL(): string {
|
||||
const blob = new Blob([AUDIO_WORKLET_SOURCE], { type: 'application/javascript' });
|
||||
return URL.createObjectURL(blob);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up blob URL when no longer needed
|
||||
*/
|
||||
export function revokeAudioWorkletBlobURL(url: string): void {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
521
src/core/audio/VADProcessor.ts
Normal file
521
src/core/audio/VADProcessor.ts
Normal file
|
|
@ -0,0 +1,521 @@
|
|||
import { App, FileSystemAdapter, normalizePath } from 'obsidian';
|
||||
import { SpeechSegment } from '../../interfaces';
|
||||
import { VAD_CONSTANTS, DEFAULT_VAD_SETTINGS, FILE_CONSTANTS } from '../../config';
|
||||
import { Disposable } from '../../interfaces';
|
||||
import { FvadModule, FvadModuleFactory, WindowWithFvad, hasFvadModule } from '../../types';
|
||||
import { createServiceLogger } from '../../services';
|
||||
import { Logger } from '../../utils';
|
||||
|
||||
export interface VADSegment {
|
||||
start: number;
|
||||
end: number;
|
||||
audioData: Float32Array;
|
||||
}
|
||||
|
||||
export interface VADProcessorOptions {
|
||||
vadMode?: 0 | 1 | 2 | 3;
|
||||
minSpeechDuration?: number;
|
||||
minSilenceDuration?: number;
|
||||
speechPadding?: number;
|
||||
}
|
||||
|
||||
// WebRTC VAD の型定義は別ファイルに移動
|
||||
|
||||
/**
|
||||
* WebRTC VAD プロセッサー
|
||||
* Google WebRTC プロジェクトの VAD アルゴリズムを使用した高精度な音声検出
|
||||
*
|
||||
* CLAUDE.md の哲学に従った実装:
|
||||
* - 根本的な解決: 実証済みの WebRTC VAD を使用
|
||||
* - 汎用的な設計: 将来の拡張を考慮した interface
|
||||
* - 型安全性: TypeScript の型システムを活用
|
||||
* - エラーハンドリング: 適切なエラー処理とリソース管理
|
||||
*/
|
||||
export class VADProcessor extends Disposable {
|
||||
private fvadModule: FvadModule | null = null;
|
||||
private vadInstance: number | null = null;
|
||||
private bufferPtr: number | null = null;
|
||||
private readonly frameSize: number = VAD_CONSTANTS.FRAME_SIZE; // 30ms at 16kHz (WebRTC VAD requirement)
|
||||
private readonly sampleRate: number = VAD_CONSTANTS.SAMPLE_RATE; // WebRTC VAD requires 16kHz
|
||||
private logger: Logger;
|
||||
|
||||
// 設定可能なパラメータ
|
||||
private vadMode: 0 | 1 | 2 | 3;
|
||||
private minSpeechDuration: number; // ms
|
||||
private minSilenceDuration: number; // ms
|
||||
private speechPadding: number; // ms
|
||||
|
||||
// 自動調整用パラメータ
|
||||
private autoAdjust: boolean = DEFAULT_VAD_SETTINGS.autoAdjust;
|
||||
private noiseLevel: number = 0;
|
||||
private recentFalsePositives: number = 0;
|
||||
private recentFalseNegatives: number = 0;
|
||||
private adjustmentCounter: number = 0;
|
||||
|
||||
constructor(
|
||||
private app: App,
|
||||
options: VADProcessorOptions = {}
|
||||
) {
|
||||
super();
|
||||
this.logger = createServiceLogger('VADProcessor');
|
||||
this.vadMode = options.vadMode ?? DEFAULT_VAD_SETTINGS.mode;
|
||||
this.minSpeechDuration = options.minSpeechDuration ?? DEFAULT_VAD_SETTINGS.minSpeechDuration;
|
||||
this.minSilenceDuration = options.minSilenceDuration ?? DEFAULT_VAD_SETTINGS.minSilenceDuration;
|
||||
this.speechPadding = options.speechPadding ?? DEFAULT_VAD_SETTINGS.speechPadding;
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
this.throwIfDisposed();
|
||||
|
||||
// App インスタンスの検証
|
||||
if (!this.app) {
|
||||
throw new Error('App instance is required for WebRTC VAD');
|
||||
}
|
||||
|
||||
try {
|
||||
this.logger.info('Initializing WebRTC VAD processor', {
|
||||
vadMode: this.vadMode,
|
||||
minSpeechDuration: this.minSpeechDuration,
|
||||
minSilenceDuration: this.minSilenceDuration,
|
||||
frameSize: this.frameSize,
|
||||
sampleRate: this.sampleRate
|
||||
});
|
||||
|
||||
// WASM ファイルを読み込む
|
||||
const wasmBuffer = await this.loadWasmFile();
|
||||
this.logger.debug('WASM file loaded', { size: wasmBuffer.byteLength });
|
||||
|
||||
// fvad モジュールを初期化
|
||||
await this.initializeFvadModule(wasmBuffer);
|
||||
|
||||
// VAD インスタンスを作成して設定
|
||||
await this.createAndConfigureVAD();
|
||||
|
||||
this.logger.info('WebRTC VAD processor initialized successfully');
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('WebRTC VAD initialization error', error);
|
||||
// リソースをクリーンアップ
|
||||
this.destroy();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* fvad モジュールを初期化
|
||||
*/
|
||||
private async initializeFvadModule(wasmBuffer: ArrayBuffer): Promise<void> {
|
||||
// スクリプトタグを使用して fvad.js を読み込む
|
||||
// これは Obsidian の制限された環境でモジュールを読み込む最も確実な方法
|
||||
const adapter = this.app.vault.adapter;
|
||||
if (!(adapter instanceof FileSystemAdapter)) {
|
||||
throw new Error('WebRTC VAD requires FileSystemAdapter (desktop version)');
|
||||
}
|
||||
|
||||
// プラグインID(manifest.json から)
|
||||
const pluginId = FILE_CONSTANTS.PLUGIN_ID;
|
||||
const basePath = normalizePath(`${this.app.vault.configDir}/plugins/${pluginId}`);
|
||||
|
||||
// グローバルオブジェクトを準備(型安全に)
|
||||
const globalWindow = window as WindowWithFvad;
|
||||
|
||||
// fvad.js の内容を読み込んで評価(プラグインルートから)
|
||||
const fvadJsPath = normalizePath(`${basePath}/fvad.js`);
|
||||
const fvadJsContent = await adapter.read(fvadJsPath);
|
||||
|
||||
// モジュールを評価するための一時的な環境を作成
|
||||
return new Promise((resolve, reject) => {
|
||||
// スクリプトタグを作成
|
||||
const script = document.createElement('script');
|
||||
script.type = 'module';
|
||||
script.textContent = `
|
||||
// import.meta.url のポリフィル
|
||||
const importMeta = { url: 'file:///${fvadJsPath}' };
|
||||
|
||||
// fvad モジュールを定義
|
||||
${fvadJsContent}
|
||||
|
||||
// グローバルに公開
|
||||
window.__fvadModule = fvad;
|
||||
`;
|
||||
|
||||
// エラーハンドリング
|
||||
script.onerror = (error) => {
|
||||
this.logger.error('WebRTC VAD script loading error', error);
|
||||
reject(new Error('Failed to load fvad.js'));
|
||||
};
|
||||
|
||||
// スクリプトを実行
|
||||
document.head.appendChild(script);
|
||||
|
||||
// モジュールが読み込まれるのを待つ
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
if (!hasFvadModule(globalWindow)) {
|
||||
throw new Error('fvad module not found in global scope');
|
||||
}
|
||||
|
||||
const createModule = globalWindow.__fvadModule;
|
||||
this.logger.debug('fvad module loaded from global scope');
|
||||
|
||||
// WebAssembly モジュールを初期化
|
||||
this.fvadModule = await createModule!({
|
||||
wasmBinary: new Uint8Array(wasmBuffer),
|
||||
instantiateWasm: (
|
||||
imports: WebAssembly.Imports,
|
||||
successCallback: (instance: WebAssembly.Instance) => void
|
||||
) => {
|
||||
WebAssembly.instantiate(new Uint8Array(wasmBuffer), imports)
|
||||
.then((result) => {
|
||||
successCallback(result.instance);
|
||||
})
|
||||
.catch((error) => {
|
||||
this.logger.error('WebRTC VAD WASM instantiation error', error);
|
||||
reject(error);
|
||||
});
|
||||
return {};
|
||||
}
|
||||
});
|
||||
|
||||
// クリーンアップ
|
||||
document.head.removeChild(script);
|
||||
delete globalWindow.__fvadModule;
|
||||
|
||||
resolve();
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
}, 100); // モジュール読み込みを待つ
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* VAD インスタンスを作成して設定
|
||||
*/
|
||||
private async createAndConfigureVAD(): Promise<void> {
|
||||
if (!this.fvadModule) {
|
||||
throw new Error('fvad module not initialized');
|
||||
}
|
||||
|
||||
// VAD インスタンスを作成
|
||||
this.vadInstance = this.fvadModule._fvad_new();
|
||||
if (!this.vadInstance) {
|
||||
throw new Error('Failed to create VAD instance');
|
||||
}
|
||||
this.logger.debug('VAD instance created');
|
||||
|
||||
// サンプルレートを設定(16kHz 固定)
|
||||
const sampleRateResult = this.fvadModule._fvad_set_sample_rate(this.vadInstance, this.sampleRate);
|
||||
if (sampleRateResult !== 0) {
|
||||
throw new Error('Failed to set sample rate');
|
||||
}
|
||||
|
||||
// VAD モードを設定
|
||||
const modeResult = this.fvadModule._fvad_set_mode(this.vadInstance, this.vadMode);
|
||||
if (modeResult !== 0) {
|
||||
throw new Error(`Failed to set VAD mode: ${this.vadMode}`);
|
||||
}
|
||||
|
||||
// 処理用バッファを事前確保
|
||||
this.bufferPtr = this.fvadModule._malloc(this.frameSize * 2); // 2 bytes per sample (Int16)
|
||||
if (!this.bufferPtr) {
|
||||
throw new Error('Failed to allocate buffer');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WASM ファイルを読み込む
|
||||
*/
|
||||
private async loadWasmFile(): Promise<ArrayBuffer> {
|
||||
const adapter = this.app.vault.adapter;
|
||||
|
||||
// FileSystemAdapter が必要
|
||||
if (!(adapter instanceof FileSystemAdapter)) {
|
||||
throw new Error('WebRTC VAD requires FileSystemAdapter (desktop version)');
|
||||
}
|
||||
|
||||
// プラグインフォルダからの相対パス(プラグインルートにあるfvad.wasm)
|
||||
const pluginId = FILE_CONSTANTS.PLUGIN_ID;
|
||||
const wasmPath = normalizePath(`${this.app.vault.configDir}/plugins/${pluginId}/fvad.wasm`);
|
||||
|
||||
// ファイルの存在確認
|
||||
const exists = await adapter.exists(wasmPath);
|
||||
if (!exists) {
|
||||
throw new Error(`WASM file not found: ${wasmPath}`);
|
||||
}
|
||||
|
||||
// バイナリとして読み込む
|
||||
const wasmBuffer = await adapter.readBinary(wasmPath);
|
||||
return wasmBuffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* ノイズレベルを分析して VAD モードを自動調整
|
||||
*/
|
||||
private analyzeAndAdjustVAD(audioData: Float32Array): void {
|
||||
if (!this.autoAdjust) return;
|
||||
|
||||
// RMS (Root Mean Square) でノイズレベルを計算
|
||||
let sum = 0;
|
||||
for (let i = 0; i < audioData.length; i++) {
|
||||
sum += audioData[i] * audioData[i];
|
||||
}
|
||||
const rms = Math.sqrt(sum / audioData.length);
|
||||
|
||||
// 指数移動平均でノイズレベルを更新
|
||||
this.noiseLevel = this.noiseLevel * VAD_CONSTANTS.NOISE_LEVEL_EMA.PREVIOUS + rms * VAD_CONSTANTS.NOISE_LEVEL_EMA.CURRENT;
|
||||
|
||||
// 調整カウンターをインクリメント
|
||||
this.adjustmentCounter++;
|
||||
if (this.adjustmentCounter >= VAD_CONSTANTS.ADJUSTMENT_INTERVAL) {
|
||||
this.adjustmentCounter = 0;
|
||||
|
||||
// ノイズレベルに基づいて VAD モードを調整
|
||||
if (this.noiseLevel > VAD_CONSTANTS.NOISE_THRESHOLDS.NOISY) {
|
||||
// 騒音が多い環境 → 低感度
|
||||
this.vadMode = 0;
|
||||
} else if (this.noiseLevel > VAD_CONSTANTS.NOISE_THRESHOLDS.NORMAL) {
|
||||
// 普通の環境 → 標準感度
|
||||
this.vadMode = 2;
|
||||
} else {
|
||||
// 静かな環境 → 高感度
|
||||
this.vadMode = 3;
|
||||
}
|
||||
|
||||
// WebRTC VAD のモードを更新
|
||||
if (this.fvadModule && this.vadInstance) {
|
||||
this.fvadModule._fvad_set_mode(this.vadInstance, this.vadMode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 音声セグメントを検出
|
||||
*/
|
||||
async detectSpeechSegments(audioData: Float32Array): Promise<VADSegment[]> {
|
||||
this.throwIfDisposed();
|
||||
if (!this.fvadModule || !this.vadInstance || !this.bufferPtr) {
|
||||
throw new Error('VAD not initialized');
|
||||
}
|
||||
|
||||
// 自動調整を実行
|
||||
this.analyzeAndAdjustVAD(audioData);
|
||||
|
||||
// 48kHz → 16kHz にリサンプリング(必要な場合)
|
||||
const resampledAudio = this.resampleAudio(audioData, VAD_CONSTANTS.INPUT_SAMPLE_RATE, this.sampleRate);
|
||||
this.logger.trace('Audio resampled', {
|
||||
inputSampleRate: VAD_CONSTANTS.INPUT_SAMPLE_RATE,
|
||||
outputSampleRate: this.sampleRate,
|
||||
inputLength: audioData.length,
|
||||
outputLength: resampledAudio.length
|
||||
});
|
||||
|
||||
// Float32 → Int16 に変換
|
||||
const int16Audio = this.float32ToInt16(resampledAudio);
|
||||
|
||||
// VAD でフレームごとに処理
|
||||
const frameDuration = (this.frameSize / this.sampleRate) * 1000; // ms
|
||||
const frameCount = Math.floor(int16Audio.length / this.frameSize);
|
||||
|
||||
// 音声区間の検出結果を格納
|
||||
const vadResults: boolean[] = new Array(frameCount);
|
||||
|
||||
// フレームごとに VAD を実行
|
||||
for (let i = 0; i < frameCount; i++) {
|
||||
const frameStart = i * this.frameSize;
|
||||
const frame = int16Audio.slice(frameStart, frameStart + this.frameSize);
|
||||
|
||||
// バッファにコピー
|
||||
const bufferView = new Int16Array(this.fvadModule.HEAP16.buffer, this.bufferPtr, this.frameSize);
|
||||
bufferView.set(frame);
|
||||
|
||||
// VAD 判定(1 = 音声, 0 = 無音)
|
||||
const isSpeech = this.fvadModule._fvad_process(this.vadInstance, this.bufferPtr, this.frameSize) === 1;
|
||||
vadResults[i] = isSpeech;
|
||||
}
|
||||
|
||||
|
||||
// 後処理: セグメントの統合とフィルタリング
|
||||
const processedSegments = this.postProcessSegments(vadResults, frameDuration, resampledAudio);
|
||||
|
||||
this.logger.debug('VAD processing completed', {
|
||||
inputDuration: (audioData.length / this.sampleRate * 1000).toFixed(1),
|
||||
totalFrames: vadResults.length,
|
||||
speechFrames: vadResults.filter(v => v).length,
|
||||
segments: processedSegments.length
|
||||
});
|
||||
|
||||
return processedSegments;
|
||||
}
|
||||
|
||||
/**
|
||||
* セグメントの後処理
|
||||
* - 短い無音で分離されたセグメントを結合
|
||||
* - パディングを追加
|
||||
* - 最小セグメント長でフィルタ
|
||||
*/
|
||||
private postProcessSegments(
|
||||
vadResults: boolean[],
|
||||
frameDuration: number,
|
||||
audioData: Float32Array
|
||||
): VADSegment[] {
|
||||
const segments: VADSegment[] = [];
|
||||
let currentSegment: { start: number; end: number } | null = null;
|
||||
|
||||
const minSilenceFrames = Math.ceil(this.minSilenceDuration / frameDuration);
|
||||
const minSpeechFrames = Math.ceil(this.minSpeechDuration / frameDuration);
|
||||
const paddingFrames = Math.ceil(this.speechPadding / frameDuration);
|
||||
|
||||
let speechFrames = 0;
|
||||
let silenceFrames = 0;
|
||||
|
||||
for (let i = 0; i < vadResults.length; i++) {
|
||||
if (vadResults[i]) {
|
||||
// 音声フレーム
|
||||
speechFrames++;
|
||||
silenceFrames = 0;
|
||||
|
||||
if (!currentSegment && speechFrames >= minSpeechFrames) {
|
||||
// 新しいセグメントの開始
|
||||
const startFrame = Math.max(0, i - speechFrames + 1 - paddingFrames);
|
||||
currentSegment = {
|
||||
start: startFrame * frameDuration,
|
||||
end: 0
|
||||
};
|
||||
}
|
||||
|
||||
if (currentSegment) {
|
||||
// セグメントの終了時間を更新
|
||||
currentSegment.end = Math.min(
|
||||
(i + 1 + paddingFrames) * frameDuration,
|
||||
(audioData.length / this.sampleRate) * 1000
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// 無音フレーム
|
||||
silenceFrames++;
|
||||
speechFrames = 0;
|
||||
|
||||
if (currentSegment && silenceFrames >= minSilenceFrames) {
|
||||
// セグメントの終了
|
||||
segments.push(this.createSegment(currentSegment, audioData));
|
||||
currentSegment = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 最後のセグメントを処理
|
||||
if (currentSegment) {
|
||||
segments.push(this.createSegment(currentSegment, audioData));
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a segment from current segment data
|
||||
*/
|
||||
private createSegment(currentSegment: { start: number; end: number }, audioData: Float32Array): VADSegment {
|
||||
const startSample = Math.floor((currentSegment.start / 1000) * this.sampleRate);
|
||||
const endSample = Math.floor((currentSegment.end / 1000) * this.sampleRate);
|
||||
|
||||
return {
|
||||
start: currentSegment.start,
|
||||
end: currentSegment.end,
|
||||
audioData: audioData.slice(startSample, endSample)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Float32 配列を Int16 配列に変換
|
||||
*/
|
||||
private float32ToInt16(float32Array: Float32Array): Int16Array {
|
||||
const int16Array = new Int16Array(float32Array.length);
|
||||
for (let i = 0; i < float32Array.length; i++) {
|
||||
// クリッピングを含む変換
|
||||
const s = Math.max(-1, Math.min(1, float32Array[i]));
|
||||
int16Array[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
|
||||
}
|
||||
return int16Array;
|
||||
}
|
||||
|
||||
/**
|
||||
* オーディオデータをリサンプリング
|
||||
*/
|
||||
private resampleAudio(audioData: Float32Array, fromRate: number, toRate: number): Float32Array {
|
||||
if (fromRate === toRate) {
|
||||
return audioData;
|
||||
}
|
||||
|
||||
const ratio = fromRate / toRate;
|
||||
const newLength = Math.floor(audioData.length / ratio);
|
||||
const resampled = new Float32Array(newLength);
|
||||
|
||||
// 線形補間によるリサンプリング
|
||||
for (let i = 0; i < newLength; i++) {
|
||||
const srcIndex = i * ratio;
|
||||
const srcIndexInt = Math.floor(srcIndex);
|
||||
const srcIndexFrac = srcIndex - srcIndexInt;
|
||||
|
||||
if (srcIndexInt + 1 < audioData.length) {
|
||||
// 線形補間
|
||||
resampled[i] = audioData[srcIndexInt] * (1 - srcIndexFrac) +
|
||||
audioData[srcIndexInt + 1] * srcIndexFrac;
|
||||
} else {
|
||||
resampled[i] = audioData[srcIndexInt];
|
||||
}
|
||||
}
|
||||
|
||||
return resampled;
|
||||
}
|
||||
|
||||
/**
|
||||
* VAD モードを設定
|
||||
*/
|
||||
setMode(mode: 0 | 1 | 2 | 3): void {
|
||||
this.vadMode = mode;
|
||||
|
||||
if (this.fvadModule && this.vadInstance) {
|
||||
const result = this.fvadModule._fvad_set_mode(this.vadInstance, mode);
|
||||
if (result !== 0) {
|
||||
this.logger.error(`Failed to set VAD mode (mode: ${mode}, result: ${result})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 無音継続時間を設定
|
||||
*/
|
||||
setSilenceDuration(duration: number): void {
|
||||
this.minSilenceDuration = duration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disposableパターンの実装
|
||||
*/
|
||||
protected onDispose(): void {
|
||||
if (this.fvadModule) {
|
||||
// バッファを解放
|
||||
if (this.bufferPtr !== null) {
|
||||
this.fvadModule._free(this.bufferPtr);
|
||||
this.bufferPtr = null;
|
||||
}
|
||||
|
||||
// VAD インスタンスを解放
|
||||
if (this.vadInstance !== null) {
|
||||
this.fvadModule._fvad_free(this.vadInstance);
|
||||
this.vadInstance = null;
|
||||
}
|
||||
|
||||
this.fvadModule = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 互換性のためのdestroyメソッド
|
||||
* @deprecated dispose()を使用してください
|
||||
*/
|
||||
destroy(): void {
|
||||
this.dispose();
|
||||
}
|
||||
}
|
||||
10
src/core/index.ts
Normal file
10
src/core/index.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
// Audio components
|
||||
export { AudioRecorder } from './audio/AudioRecorder';
|
||||
export { AudioRingBuffer } from './audio/AudioRingBuffer';
|
||||
export { AudioVisualizer } from './audio/AudioVisualizer';
|
||||
// export { AUDIO_WORKLET_SOURCE } from './audio/AudioWorkletSource'; // 未使用: 将来のAudioWorklet実装用
|
||||
export { VADProcessor } from './audio/VADProcessor';
|
||||
|
||||
// Transcription components
|
||||
export { TranscriptionService } from './transcription/TranscriptionService';
|
||||
export { DictionaryCorrector } from './transcription/DictionaryCorrector';
|
||||
141
src/core/transcription/DictionaryCorrector.ts
Normal file
141
src/core/transcription/DictionaryCorrector.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
|
||||
import { CorrectionRule, DictionaryCorrectorOptions, ITextCorrector, SimpleCorrectionDictionary, CorrectionEntry } from '../../interfaces';
|
||||
import { TranscriptionError, TranscriptionErrorType } from '../../errors';
|
||||
import { API_CONSTANTS, DICTIONARY_CONSTANTS } from '../../config';
|
||||
import { requestUrl } from 'obsidian';
|
||||
|
||||
/**
|
||||
* 辞書修正設定
|
||||
*
|
||||
* CLAUDE.mdの哲学に従った設計:
|
||||
* - シンプルな固定置換のみをサポート
|
||||
*/
|
||||
export interface DictionarySettings {
|
||||
enabled: boolean;
|
||||
customRules: CorrectionRule[];
|
||||
correctionDictionary?: SimpleCorrectionDictionary;
|
||||
}
|
||||
|
||||
export class DictionaryCorrector implements ITextCorrector {
|
||||
private settings: DictionarySettings;
|
||||
private correctionDictionary: SimpleCorrectionDictionary;
|
||||
|
||||
// Common Japanese transcription errors (empty by default - users can add their own)
|
||||
private defaultRules: CorrectionRule[] = [];
|
||||
|
||||
constructor(settings?: Partial<DictionarySettings>) {
|
||||
this.settings = {
|
||||
enabled: true,
|
||||
customRules: [],
|
||||
...settings
|
||||
};
|
||||
// Initialize correction dictionary
|
||||
this.correctionDictionary = settings?.correctionDictionary || {
|
||||
definiteCorrections: []
|
||||
};
|
||||
}
|
||||
|
||||
async correct(text: string): Promise<string> {
|
||||
if (!this.settings.enabled) {
|
||||
return text;
|
||||
}
|
||||
|
||||
// 空文字または短すぎるテキストは処理しない
|
||||
if (!text || text.trim().length < 2) {
|
||||
return text;
|
||||
}
|
||||
|
||||
// Apply rule-based corrections
|
||||
const correctedText = this.applyRules(text);
|
||||
|
||||
return correctedText;
|
||||
}
|
||||
|
||||
private applyRules(text: string): string {
|
||||
let result = text;
|
||||
|
||||
// Apply default rules
|
||||
for (const rule of this.defaultRules) {
|
||||
result = this.applyRule(result, rule);
|
||||
}
|
||||
|
||||
// Apply custom rules
|
||||
for (const rule of this.settings.customRules) {
|
||||
result = this.applyRule(result, rule);
|
||||
}
|
||||
|
||||
// Apply dictionary corrections
|
||||
for (const correction of this.correctionDictionary.definiteCorrections) {
|
||||
// Handle multiple patterns per correction
|
||||
for (const pattern of correction.from) {
|
||||
if (result.includes(pattern)) {
|
||||
result = result.replace(
|
||||
new RegExp(this.escapeRegExp(pattern), 'g'),
|
||||
correction.to
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private escapeRegExp(string: string): string {
|
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
private applyRule(text: string, rule: CorrectionRule): string {
|
||||
if (typeof rule.pattern === 'string') {
|
||||
const flags = rule.caseSensitive ? 'g' : 'gi';
|
||||
const regex = new RegExp(rule.pattern, flags);
|
||||
return text.replace(regex, rule.replacement);
|
||||
} else {
|
||||
return text.replace(rule.pattern, rule.replacement);
|
||||
}
|
||||
}
|
||||
|
||||
updateSettings(settings: Partial<DictionarySettings>): void {
|
||||
this.settings = { ...this.settings, ...settings };
|
||||
if (settings.correctionDictionary) {
|
||||
this.correctionDictionary = {
|
||||
...this.correctionDictionary,
|
||||
...settings.correctionDictionary
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update correction dictionary
|
||||
*/
|
||||
updateCorrectionDictionary(dictionary: Partial<SimpleCorrectionDictionary>): void {
|
||||
this.correctionDictionary = {
|
||||
...this.correctionDictionary,
|
||||
...dictionary
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Add custom correction entry
|
||||
*/
|
||||
addCorrectionEntry(entry: CorrectionEntry, contextual: boolean = false): void {
|
||||
// Contextual corrections are not yet implemented
|
||||
// For now, all corrections are added as definite corrections
|
||||
this.correctionDictionary.definiteCorrections.push(entry);
|
||||
}
|
||||
|
||||
addCustomRule(rule: CorrectionRule): void {
|
||||
this.settings.customRules.push(rule);
|
||||
}
|
||||
|
||||
removeCustomRule(index: number): void {
|
||||
this.settings.customRules.splice(index, 1);
|
||||
}
|
||||
|
||||
getSettings(): DictionarySettings {
|
||||
return { ...this.settings };
|
||||
}
|
||||
|
||||
getDefaultRules(): CorrectionRule[] {
|
||||
return [...this.defaultRules];
|
||||
}
|
||||
}
|
||||
308
src/core/transcription/TranscriptionService.ts
Normal file
308
src/core/transcription/TranscriptionService.ts
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
import { DictionaryCorrector } from './DictionaryCorrector';
|
||||
import { TranscriptionResult, ITranscriptionProvider, SimpleCorrectionDictionary } from '../../interfaces';
|
||||
import { TranscriptionError, TranscriptionErrorType } from '../../errors';
|
||||
import { SecurityUtils } from '../../security';
|
||||
import { API_CONSTANTS, DEFAULT_TRANSCRIPTION_SETTINGS, TRANSCRIPTION_MODEL_COSTS } from '../../config';
|
||||
import { ObsidianHttpClient } from '../../utils/ObsidianHttpClient';
|
||||
import { createServiceLogger } from '../../services';
|
||||
import { Logger } from '../../utils';
|
||||
|
||||
export class TranscriptionService implements ITranscriptionProvider {
|
||||
private apiKey: string;
|
||||
private corrector: DictionaryCorrector;
|
||||
private logger: Logger;
|
||||
// 注: gpt-4o-mini-transcribe と gpt-4o-transcribe の両方が日本語音声認識で高精度
|
||||
// コスト重視なら mini、わずかな精度向上が必要なら通常版を選択
|
||||
private model: 'gpt-4o-transcribe' | 'gpt-4o-mini-transcribe' = DEFAULT_TRANSCRIPTION_SETTINGS.model;
|
||||
private enableTranscriptionCorrection: boolean = DEFAULT_TRANSCRIPTION_SETTINGS.enableTranscriptionCorrection;
|
||||
private language: string = 'ja';
|
||||
|
||||
constructor(apiKey: string, dictionary?: SimpleCorrectionDictionary) {
|
||||
this.apiKey = apiKey;
|
||||
this.logger = createServiceLogger('TranscriptionService');
|
||||
this.corrector = new DictionaryCorrector({
|
||||
correctionDictionary: dictionary
|
||||
});
|
||||
}
|
||||
|
||||
async transcribe(audioBlob: Blob, language: string = 'ja'): Promise<TranscriptionResult> {
|
||||
return this.transcribeAudio(audioBlob, language);
|
||||
}
|
||||
|
||||
async transcribeAudio(audioBlob: Blob, language: string = 'ja'): Promise<TranscriptionResult> {
|
||||
const startTime = Date.now();
|
||||
const perfStartTime = performance.now();
|
||||
|
||||
this.logger.info('Starting transcription', {
|
||||
audioBlobSize: audioBlob.size,
|
||||
audioType: audioBlob.type,
|
||||
language,
|
||||
model: this.model,
|
||||
enableTranscriptionCorrection: this.enableTranscriptionCorrection
|
||||
});
|
||||
|
||||
try {
|
||||
// Convert audio blob to proper format for API
|
||||
const formData = new FormData();
|
||||
formData.append('file', audioBlob, 'audio.webm');
|
||||
formData.append('model', this.model);
|
||||
formData.append('response_format', 'json');
|
||||
formData.append('temperature', String(API_CONSTANTS.PARAMETERS.TRANSCRIPTION_TEMPERATURE)); // Deterministic output
|
||||
|
||||
// Language setting
|
||||
if (language !== 'auto') {
|
||||
formData.append('language', language);
|
||||
}
|
||||
|
||||
// Build prompt for transcription
|
||||
const prompt = this.buildTranscriptionPrompt();
|
||||
if (prompt) {
|
||||
formData.append('prompt', prompt);
|
||||
}
|
||||
|
||||
|
||||
// IMPORTANT: OpenAI Audio API implementation
|
||||
// この部分を変更する場合は、必ずOpenAI APIの仕様を確認すること
|
||||
// 参照: https://platform.openai.com/docs/api-reference/audio/createTranscription
|
||||
//
|
||||
// 要件:
|
||||
// - multipart/form-data形式
|
||||
// - fileフィールドは音声ファイル(Blob)
|
||||
// - modelフィールドは必須
|
||||
// - boundary設定が必要
|
||||
|
||||
// Send multipart request via centralized HTTP client
|
||||
const { status, json: responseData } = await ObsidianHttpClient.postFormData(
|
||||
API_CONSTANTS.ENDPOINTS.TRANSCRIPTION,
|
||||
formData,
|
||||
{ 'Authorization': `Bearer ${this.apiKey}` }
|
||||
);
|
||||
|
||||
if (status >= 400) {
|
||||
const errorData = responseData as Record<string, unknown> | undefined;
|
||||
const errorMessage = (errorData as any)?.error?.message || `HTTP ${status}`;
|
||||
|
||||
// Determine error type based on status and message
|
||||
let errorType = TranscriptionErrorType.TRANSCRIPTION_FAILED;
|
||||
if (status === 401) {
|
||||
errorType = TranscriptionErrorType.INVALID_API_KEY;
|
||||
} else if (status === 429) {
|
||||
errorType = TranscriptionErrorType.API_QUOTA_EXCEEDED;
|
||||
} else if (status >= 500) {
|
||||
errorType = TranscriptionErrorType.NETWORK_ERROR;
|
||||
}
|
||||
|
||||
throw new TranscriptionError(errorType, errorMessage);
|
||||
}
|
||||
|
||||
|
||||
// Extract text from response
|
||||
let originalText = '';
|
||||
if (responseData.text) {
|
||||
originalText = responseData.text;
|
||||
} else if (typeof responseData === 'string') {
|
||||
originalText = responseData;
|
||||
}
|
||||
|
||||
// Clean up GPT-4o specific artifacts
|
||||
originalText = this.cleanGPT4oResponse(originalText);
|
||||
|
||||
|
||||
// プロンプトエラーの検出(音声が無音の場合にプロンプトが返される問題)
|
||||
if (originalText.includes('この指示文は出力に含めないでください') ||
|
||||
originalText.includes('話者の発言内容だけを正確に記録してください') ||
|
||||
originalText === '(話者の発言のみ)' ||
|
||||
originalText.trim() === '話者の発言のみ') {
|
||||
// 音声がない場合は空文字を返す
|
||||
originalText = '';
|
||||
}
|
||||
|
||||
// 空文字の場合は後処理をスキップして早期リターン
|
||||
if (!originalText || originalText.trim() === '') {
|
||||
const duration = Date.now() - startTime;
|
||||
const elapsedTime = performance.now() - perfStartTime;
|
||||
this.logger.info('Transcription completed (empty result)', {
|
||||
elapsedTime: `${elapsedTime.toFixed(2)}ms`,
|
||||
duration
|
||||
});
|
||||
return {
|
||||
text: '',
|
||||
originalText: '',
|
||||
duration,
|
||||
model: this.model,
|
||||
language: responseData.language || language
|
||||
};
|
||||
}
|
||||
|
||||
// Apply corrections if enabled (only for Japanese)
|
||||
const correctedText = this.enableTranscriptionCorrection && language === 'ja'
|
||||
? await this.corrector.correct(originalText)
|
||||
: originalText;
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
const elapsedTime = performance.now() - perfStartTime;
|
||||
|
||||
this.logger.info('Transcription completed', {
|
||||
originalLength: originalText.length,
|
||||
correctedLength: correctedText.length,
|
||||
duration,
|
||||
elapsedTime: `${elapsedTime.toFixed(2)}ms`,
|
||||
wasCorrected: correctedText !== originalText,
|
||||
model: this.model,
|
||||
inputSize: audioBlob.size,
|
||||
outputLength: correctedText.length
|
||||
});
|
||||
|
||||
return {
|
||||
text: correctedText,
|
||||
originalText,
|
||||
duration,
|
||||
model: this.model,
|
||||
language: responseData.language || language
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error('Transcription error', error);
|
||||
|
||||
if (error instanceof TranscriptionError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw TranscriptionError.fromError(error, TranscriptionErrorType.TRANSCRIPTION_FAILED);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build prompt for GPT-4o transcription
|
||||
*/
|
||||
private buildTranscriptionPrompt(): string {
|
||||
return `以下の音声内容のみを文字に起こしてください。この指示文は出力に含めないでください。
|
||||
話者の発言内容だけを正確に記録してください。
|
||||
|
||||
出力形式:
|
||||
<TRANSCRIPT>
|
||||
(話者の発言のみ)
|
||||
</TRANSCRIPT>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean GPT-4o specific response artifacts
|
||||
*/
|
||||
private cleanGPT4oResponse(text: string): string {
|
||||
// First attempt: Extract content from complete TRANSCRIPT tags
|
||||
let transcriptMatch = text.match(/<TRANSCRIPT>\s*([\s\S]*?)\s*<\/TRANSCRIPT>/);
|
||||
if (transcriptMatch) {
|
||||
text = transcriptMatch[1];
|
||||
} else {
|
||||
// Second attempt: Handle incomplete TRANSCRIPT tags (missing closing tag)
|
||||
const openingMatch = text.match(/<TRANSCRIPT>\s*([\s\S]*)/);
|
||||
if (openingMatch) {
|
||||
text = openingMatch[1];
|
||||
}
|
||||
}
|
||||
|
||||
// Remove TRANSCRIPT opening tag if still present (for cases where it's not properly extracted)
|
||||
text = text.replace(/<\/?TRANSCRIPT[^>]*>/g, '');
|
||||
|
||||
// Remove specific meta instruction patterns (both at line start and anywhere in text)
|
||||
const metaPatterns = [
|
||||
/^以下の音声内容.*?$/gm,
|
||||
/^この指示文.*?$/gm,
|
||||
/^話者の発言内容だけを正確に記録してください.*?$/gm,
|
||||
/^話者の発言.*?$/gm,
|
||||
/^出力形式.*?$/gm,
|
||||
/(話者の発言のみ)/g, // Remove this specific phrase anywhere in the text
|
||||
];
|
||||
|
||||
// Apply all cleaning patterns
|
||||
for (const pattern of metaPatterns) {
|
||||
text = text.replace(pattern, '');
|
||||
}
|
||||
|
||||
// Clean up extra whitespace and empty lines
|
||||
text = text.trim();
|
||||
text = text.replace(/\n{3,}/g, '\n\n');
|
||||
text = text.replace(/^\s*\n/gm, ''); // Remove lines with only whitespace
|
||||
text = text.trim();
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate transcription cost
|
||||
*/
|
||||
estimateCost(audioLengthSeconds: number): number {
|
||||
const audioLengthMinutes = audioLengthSeconds / 60;
|
||||
|
||||
// Get cost per minute from configuration
|
||||
const costPerMinute = TRANSCRIPTION_MODEL_COSTS[this.model];
|
||||
|
||||
return audioLengthMinutes * costPerMinute;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert audio blob to WAV format if needed
|
||||
*/
|
||||
private async convertToWav(audioBlob: Blob): Promise<Blob> {
|
||||
// If already WAV, return as-is
|
||||
if (audioBlob.type === 'audio/wav') {
|
||||
return audioBlob;
|
||||
}
|
||||
|
||||
// For now, we'll use webm directly as GPT-4o supports it
|
||||
// In the future, we might want to implement proper conversion
|
||||
return audioBlob;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update settings
|
||||
*/
|
||||
updateCorrectorSettings(settings: { enabled: boolean }) {
|
||||
this.enableTranscriptionCorrection = settings.enabled;
|
||||
this.corrector.updateSettings({
|
||||
enabled: settings.enabled
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set transcription correction enabled/disabled
|
||||
*/
|
||||
setTranscriptionCorrection(enabled: boolean) {
|
||||
this.enableTranscriptionCorrection = enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update API key
|
||||
*/
|
||||
updateApiKey(apiKey: string) {
|
||||
this.apiKey = apiKey;
|
||||
// API key is no longer needed for the simplified corrector
|
||||
// Preserve existing dictionary settings when updating API key
|
||||
const currentDict = this.corrector.getSettings().correctionDictionary;
|
||||
this.corrector = new DictionaryCorrector({
|
||||
correctionDictionary: currentDict
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set transcription model
|
||||
*/
|
||||
setModel(model: 'gpt-4o-transcribe' | 'gpt-4o-mini-transcribe') {
|
||||
this.model = model;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Update custom correction dictionary
|
||||
*/
|
||||
setCustomDictionary(dictionary: SimpleCorrectionDictionary) {
|
||||
this.corrector.updateCorrectionDictionary(dictionary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get corrector instance for dictionary cleanup
|
||||
*/
|
||||
getCorrector(): DictionaryCorrector {
|
||||
return this.corrector;
|
||||
}
|
||||
|
||||
}
|
||||
469
src/errors/ErrorHandler.ts
Normal file
469
src/errors/ErrorHandler.ts
Normal file
|
|
@ -0,0 +1,469 @@
|
|||
/**
|
||||
* エラーハンドリングの体系化
|
||||
*
|
||||
* CLAUDE.mdの哲学に従った実装:
|
||||
* - 根本的な解決: エラー処理の一元化
|
||||
* - 汎用的な設計: あらゆるエラーパターンに対応
|
||||
* - 型安全性: TypeScriptの型システムを活用
|
||||
* - エラーリカバリー: 適切な復旧処理
|
||||
*/
|
||||
|
||||
import { Notice } from 'obsidian';
|
||||
import { TranscriptionError, TranscriptionErrorType } from './TranscriptionError';
|
||||
import type { IDisposable } from '../interfaces';
|
||||
import { getI18n } from '../services';
|
||||
import type { I18nService } from '../interfaces';
|
||||
|
||||
/**
|
||||
* エラーの重要度レベル
|
||||
*/
|
||||
export enum ErrorSeverity {
|
||||
/** 致命的 - プラグインの動作を停止すべき */
|
||||
FATAL = 'fatal',
|
||||
/** エラー - 機能は動作しないが、プラグインは継続可能 */
|
||||
ERROR = 'error',
|
||||
/** 警告 - 機能は動作するが、問題がある */
|
||||
WARNING = 'warning',
|
||||
/** 情報 - ユーザーに通知すべき情報 */
|
||||
INFO = 'info'
|
||||
}
|
||||
|
||||
/**
|
||||
* エラーコンテキスト情報
|
||||
*/
|
||||
export interface ErrorContext {
|
||||
/** エラーが発生したコンポーネント */
|
||||
component: string;
|
||||
/** エラー発生時の操作 */
|
||||
operation: string;
|
||||
/** 追加のメタデータ */
|
||||
metadata?: Record<string, unknown>;
|
||||
/** エラー発生時刻 */
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* エラーハンドラーのオプション
|
||||
*/
|
||||
export interface ErrorHandlerOptions {
|
||||
/** 開発モードかどうか */
|
||||
isDevelopment: boolean;
|
||||
/** エラーログの最大保持数 */
|
||||
maxErrorLogs: number;
|
||||
/** リトライの最大回数 */
|
||||
maxRetries: number;
|
||||
/** リトライの基本待機時間 (ms) */
|
||||
retryBaseDelay: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* エラーログエントリ
|
||||
*/
|
||||
interface ErrorLogEntry {
|
||||
error: Error;
|
||||
context: ErrorContext;
|
||||
severity: ErrorSeverity;
|
||||
timestamp: Date;
|
||||
handled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* リトライ可能な操作の定義
|
||||
*/
|
||||
export interface RetryableOperation<T> {
|
||||
/** 実行する操作 */
|
||||
operation: () => Promise<T>;
|
||||
/** リトライ可能かどうかを判定する関数 */
|
||||
isRetryable?: (error: Error) => boolean;
|
||||
/** リトライ前のクリーンアップ処理 */
|
||||
onRetry?: (attempt: number, error: Error) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* グローバルエラーハンドラー
|
||||
*/
|
||||
export class ErrorHandler implements IDisposable {
|
||||
private static instance: ErrorHandler;
|
||||
private errorLogs: ErrorLogEntry[] = [];
|
||||
private options: ErrorHandlerOptions;
|
||||
private _isDisposed = false;
|
||||
private unhandledRejectionHandler?: (event: PromiseRejectionEvent) => void;
|
||||
private errorHandler?: (event: ErrorEvent) => void;
|
||||
private i18n?: I18nService;
|
||||
|
||||
private constructor(options: Partial<ErrorHandlerOptions> = {}) {
|
||||
this.options = {
|
||||
isDevelopment: false,
|
||||
maxErrorLogs: 100,
|
||||
maxRetries: 3,
|
||||
retryBaseDelay: 1000,
|
||||
...options
|
||||
};
|
||||
|
||||
// i18n service might not be available yet during initialization
|
||||
try {
|
||||
this.i18n = getI18n();
|
||||
} catch (error) {
|
||||
// i18n service will be available later
|
||||
}
|
||||
|
||||
this.setupGlobalHandlers();
|
||||
}
|
||||
|
||||
/**
|
||||
* シングルトンインスタンスを取得
|
||||
*/
|
||||
static getInstance(options?: Partial<ErrorHandlerOptions>): ErrorHandler {
|
||||
if (!ErrorHandler.instance) {
|
||||
ErrorHandler.instance = new ErrorHandler(options);
|
||||
}
|
||||
return ErrorHandler.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* グローバルエラーハンドラーの設定
|
||||
*/
|
||||
private setupGlobalHandlers(): void {
|
||||
// Unhandled promise rejections
|
||||
this.unhandledRejectionHandler = (event: PromiseRejectionEvent) => {
|
||||
// Filter out errors that are not from our plugin
|
||||
const error = event.reason;
|
||||
const errorString = error?.toString?.() || String(error);
|
||||
|
||||
// Ignore errors from other plugins/sources
|
||||
if (errorString.includes('inputEl') ||
|
||||
errorString.includes('Cannot read properties of undefined') ||
|
||||
!errorString.includes('voice-input')) {
|
||||
// Log in development mode only
|
||||
if (this.options.isDevelopment) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.debug('[VoiceInput] Ignored external error:', error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.handleError(
|
||||
new Error(`Unhandled Promise Rejection: ${event.reason}`),
|
||||
{
|
||||
component: 'Global',
|
||||
operation: 'UnhandledRejection',
|
||||
metadata: { reason: event.reason },
|
||||
timestamp: new Date()
|
||||
},
|
||||
ErrorSeverity.ERROR
|
||||
);
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
// Global error events
|
||||
this.errorHandler = (event: ErrorEvent) => {
|
||||
this.handleError(
|
||||
event.error || new Error(event.message),
|
||||
{
|
||||
component: 'Global',
|
||||
operation: 'UncaughtError',
|
||||
metadata: {
|
||||
filename: event.filename,
|
||||
lineno: event.lineno,
|
||||
colno: event.colno
|
||||
},
|
||||
timestamp: new Date()
|
||||
},
|
||||
ErrorSeverity.ERROR
|
||||
);
|
||||
};
|
||||
|
||||
window.addEventListener('unhandledrejection', this.unhandledRejectionHandler);
|
||||
window.addEventListener('error', this.errorHandler);
|
||||
}
|
||||
|
||||
/**
|
||||
* エラーを処理する
|
||||
*/
|
||||
handleError(
|
||||
error: Error,
|
||||
context: ErrorContext,
|
||||
severity: ErrorSeverity = ErrorSeverity.ERROR
|
||||
): void {
|
||||
if (this._isDisposed) return;
|
||||
|
||||
// エラーログに記録
|
||||
const logEntry: ErrorLogEntry = {
|
||||
error,
|
||||
context,
|
||||
severity,
|
||||
timestamp: new Date(),
|
||||
handled: true
|
||||
};
|
||||
|
||||
this.addErrorLog(logEntry);
|
||||
|
||||
// コンソールにログ出力
|
||||
this.logError(error, context, severity);
|
||||
|
||||
// ユーザーへの通知
|
||||
if (severity !== ErrorSeverity.INFO) {
|
||||
this.notifyUser(error, severity);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* リトライ可能な操作を実行
|
||||
*/
|
||||
async executeWithRetry<T>(
|
||||
retryableOp: RetryableOperation<T>,
|
||||
context: ErrorContext
|
||||
): Promise<T> {
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 0; attempt < this.options.maxRetries; attempt++) {
|
||||
try {
|
||||
return await retryableOp.operation();
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
|
||||
// リトライ可能かチェック
|
||||
const isRetryable = retryableOp.isRetryable?.(lastError) ?? true;
|
||||
if (!isRetryable || attempt === this.options.maxRetries - 1) {
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
// リトライ前のクリーンアップ
|
||||
if (retryableOp.onRetry) {
|
||||
await retryableOp.onRetry(attempt + 1, lastError);
|
||||
}
|
||||
|
||||
// 指数バックオフで待機
|
||||
const delay = this.options.retryBaseDelay * Math.pow(2, attempt);
|
||||
await this.delay(delay);
|
||||
|
||||
// リトライをログに記録
|
||||
this.logRetry(attempt + 1, lastError, context);
|
||||
}
|
||||
}
|
||||
|
||||
let contextSummary: string;
|
||||
try {
|
||||
contextSummary = JSON.stringify(context);
|
||||
} catch (e) {
|
||||
contextSummary = '[Unserializable context]';
|
||||
}
|
||||
throw lastError || new Error(`Retry operation failed after ${this.options.maxRetries} attempts in context: ${contextSummary}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* エラーログに追加
|
||||
*/
|
||||
private addErrorLog(entry: ErrorLogEntry): void {
|
||||
this.errorLogs.push(entry);
|
||||
|
||||
// 最大数を超えたら古いものから削除
|
||||
if (this.errorLogs.length > this.options.maxErrorLogs) {
|
||||
this.errorLogs = this.errorLogs.slice(-this.options.maxErrorLogs);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* エラーをコンソールにログ出力
|
||||
*/
|
||||
private logError(error: Error, context: ErrorContext, severity: ErrorSeverity): void {
|
||||
const logData = {
|
||||
severity,
|
||||
component: context.component,
|
||||
operation: context.operation,
|
||||
message: error.message,
|
||||
metadata: context.metadata,
|
||||
timestamp: context.timestamp.toISOString()
|
||||
};
|
||||
|
||||
if (this.options.isDevelopment) {
|
||||
// 開発モードでは詳細情報を出力
|
||||
// eslint-disable-next-line no-console
|
||||
console.group(`[${severity.toUpperCase()}] ${context.component}.${context.operation}`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.table(logData);
|
||||
// eslint-disable-next-line no-console
|
||||
console.trace();
|
||||
// eslint-disable-next-line no-console
|
||||
console.groupEnd();
|
||||
} else {
|
||||
// 本番モードでは簡潔に
|
||||
switch (severity) {
|
||||
case ErrorSeverity.FATAL:
|
||||
case ErrorSeverity.ERROR:
|
||||
console.error(`[${context.component}] ${error.message}`, logData);
|
||||
break;
|
||||
case ErrorSeverity.WARNING:
|
||||
console.warn(`[${context.component}] ${error.message}`, logData);
|
||||
break;
|
||||
case ErrorSeverity.INFO:
|
||||
// eslint-disable-next-line no-console
|
||||
console.info(`[${context.component}] ${error.message}`, logData);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* リトライをログに記録
|
||||
*/
|
||||
private logRetry(attempt: number, error: Error, context: ErrorContext): void {
|
||||
if (this.options.isDevelopment) {
|
||||
console.warn(
|
||||
`[RETRY] Attempt ${attempt}/${this.options.maxRetries} for ${context.component}.${context.operation}`,
|
||||
{ error: error.message }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ユーザーに通知
|
||||
*/
|
||||
private notifyUser(error: Error, severity: ErrorSeverity): void {
|
||||
let message: string;
|
||||
|
||||
if (error instanceof TranscriptionError) {
|
||||
message = error.getLocalizedMessage();
|
||||
} else {
|
||||
// 一般的なエラーメッセージ
|
||||
message = this.getGenericErrorMessage(severity);
|
||||
}
|
||||
|
||||
new Notice(message, severity === ErrorSeverity.FATAL ? 0 : 5000);
|
||||
}
|
||||
|
||||
/**
|
||||
* 一般的なエラーメッセージを取得
|
||||
*/
|
||||
private getGenericErrorMessage(severity: ErrorSeverity): string {
|
||||
// Try to get i18n service if not available yet
|
||||
if (!this.i18n) {
|
||||
try {
|
||||
this.i18n = getI18n();
|
||||
} catch (error) {
|
||||
// Fallback to English messages
|
||||
switch (severity) {
|
||||
case ErrorSeverity.FATAL:
|
||||
return 'A fatal error occurred';
|
||||
case ErrorSeverity.ERROR:
|
||||
return 'An error occurred';
|
||||
case ErrorSeverity.WARNING:
|
||||
return 'Warning';
|
||||
default:
|
||||
return 'Unknown error';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch (severity) {
|
||||
case ErrorSeverity.FATAL:
|
||||
return this.i18n.t('error.general.fatal');
|
||||
case ErrorSeverity.ERROR:
|
||||
return this.i18n.t('error.general.error');
|
||||
case ErrorSeverity.WARNING:
|
||||
return this.i18n.t('error.general.warning');
|
||||
default:
|
||||
return this.i18n.t('error.general.default');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 遅延処理
|
||||
*/
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* エラーログを取得
|
||||
*/
|
||||
getErrorLogs(): ReadonlyArray<ErrorLogEntry> {
|
||||
return [...this.errorLogs];
|
||||
}
|
||||
|
||||
/**
|
||||
* エラーログをクリア
|
||||
*/
|
||||
clearErrorLogs(): void {
|
||||
this.errorLogs = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* リソースの解放
|
||||
*/
|
||||
dispose(): void {
|
||||
if (this._isDisposed) return;
|
||||
|
||||
if (this.unhandledRejectionHandler) {
|
||||
window.removeEventListener('unhandledrejection', this.unhandledRejectionHandler);
|
||||
}
|
||||
|
||||
if (this.errorHandler) {
|
||||
window.removeEventListener('error', this.errorHandler);
|
||||
}
|
||||
|
||||
this.errorLogs = [];
|
||||
this._isDisposed = true;
|
||||
}
|
||||
|
||||
get isDisposed(): boolean {
|
||||
return this._isDisposed;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* エラーハンドリングのヘルパー関数
|
||||
*/
|
||||
export const ErrorHandlerUtils = {
|
||||
/**
|
||||
* TranscriptionErrorに変換
|
||||
*/
|
||||
toTranscriptionError(
|
||||
error: unknown,
|
||||
defaultType: TranscriptionErrorType = TranscriptionErrorType.TRANSCRIPTION_FAILED
|
||||
): TranscriptionError {
|
||||
if (error instanceof TranscriptionError) {
|
||||
return error;
|
||||
}
|
||||
|
||||
return TranscriptionError.fromError(error, defaultType);
|
||||
},
|
||||
|
||||
/**
|
||||
* エラーが特定のタイプかチェック
|
||||
*/
|
||||
isErrorType(error: unknown, type: TranscriptionErrorType): boolean {
|
||||
return error instanceof TranscriptionError && error.type === type;
|
||||
},
|
||||
|
||||
/**
|
||||
* ネットワークエラーかチェック
|
||||
*/
|
||||
isNetworkError(error: unknown): boolean {
|
||||
if (error instanceof TranscriptionError) {
|
||||
return error.type === TranscriptionErrorType.NETWORK_ERROR;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return error.message.toLowerCase().includes('network') ||
|
||||
error.message.toLowerCase().includes('fetch');
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
|
||||
/**
|
||||
* APIエラーかチェック
|
||||
*/
|
||||
isApiError(error: unknown): boolean {
|
||||
if (error instanceof TranscriptionError) {
|
||||
return [
|
||||
TranscriptionErrorType.API_ERROR,
|
||||
TranscriptionErrorType.API_QUOTA_EXCEEDED,
|
||||
TranscriptionErrorType.INVALID_API_KEY
|
||||
].includes(error.type);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
};
|
||||
62
src/errors/TranscriptionError.ts
Normal file
62
src/errors/TranscriptionError.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { getI18n } from '../services';
|
||||
import type { I18nService } from '../interfaces';
|
||||
|
||||
export enum TranscriptionErrorType {
|
||||
PERMISSION_DENIED = 'PERMISSION_DENIED',
|
||||
NETWORK_ERROR = 'NETWORK_ERROR',
|
||||
API_QUOTA_EXCEEDED = 'API_QUOTA_EXCEEDED',
|
||||
INVALID_API_KEY = 'INVALID_API_KEY',
|
||||
AUDIO_DEVICE_ERROR = 'AUDIO_DEVICE_ERROR',
|
||||
AUDIO_INITIALIZATION_FAILED = 'AUDIO_INITIALIZATION_FAILED',
|
||||
VAD_INITIALIZATION_ERROR = 'VAD_INITIALIZATION_ERROR',
|
||||
TRANSCRIPTION_FAILED = 'TRANSCRIPTION_FAILED',
|
||||
AUDIO_PROCESSING_ERROR = 'AUDIO_PROCESSING_ERROR',
|
||||
SERVICE_NOT_INITIALIZED = 'SERVICE_NOT_INITIALIZED',
|
||||
API_ERROR = 'API_ERROR'
|
||||
}
|
||||
|
||||
export class TranscriptionError extends Error {
|
||||
constructor(
|
||||
public type: TranscriptionErrorType,
|
||||
message: string,
|
||||
public recoverable: boolean = false,
|
||||
public originalError?: Error
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'TranscriptionError';
|
||||
}
|
||||
|
||||
static fromError(error: unknown, type: TranscriptionErrorType): TranscriptionError {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error occurred';
|
||||
const originalError = error instanceof Error ? error : undefined;
|
||||
return new TranscriptionError(type, message, false, originalError);
|
||||
}
|
||||
|
||||
getLocalizedMessage(): string {
|
||||
let i18n: I18nService | null = null;
|
||||
|
||||
try {
|
||||
i18n = getI18n();
|
||||
} catch (error) {
|
||||
// Fallback to original message if i18n is not available
|
||||
return this.message;
|
||||
}
|
||||
|
||||
const errorTypeToI18nKey: Record<TranscriptionErrorType, string> = {
|
||||
[TranscriptionErrorType.PERMISSION_DENIED]: 'error.audio.micPermission',
|
||||
[TranscriptionErrorType.NETWORK_ERROR]: 'error.general.network',
|
||||
[TranscriptionErrorType.API_QUOTA_EXCEEDED]: 'error.api.quotaExceeded',
|
||||
[TranscriptionErrorType.INVALID_API_KEY]: 'error.api.unauthorized',
|
||||
[TranscriptionErrorType.AUDIO_DEVICE_ERROR]: 'error.audio.micNotFound',
|
||||
[TranscriptionErrorType.AUDIO_INITIALIZATION_FAILED]: 'error.audio.audioContextFailed',
|
||||
[TranscriptionErrorType.VAD_INITIALIZATION_ERROR]: 'error.file.wasmLoadFailed',
|
||||
[TranscriptionErrorType.TRANSCRIPTION_FAILED]: 'error.transcription.failed',
|
||||
[TranscriptionErrorType.AUDIO_PROCESSING_ERROR]: 'error.audio.audioContextFailed',
|
||||
[TranscriptionErrorType.SERVICE_NOT_INITIALIZED]: 'error.transcription.serviceInitFailed',
|
||||
[TranscriptionErrorType.API_ERROR]: 'error.api.connectionFailed'
|
||||
};
|
||||
|
||||
const i18nKey = errorTypeToI18nKey[this.type];
|
||||
return i18nKey ? i18n.t(i18nKey) : this.message;
|
||||
}
|
||||
}
|
||||
3
src/errors/index.ts
Normal file
3
src/errors/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { ErrorHandler } from './ErrorHandler';
|
||||
// export { ErrorSeverity, ErrorHandlerUtils } from './ErrorHandler'; // 未使用: 将来のエラー処理拡張用
|
||||
export { TranscriptionError, TranscriptionErrorType } from './TranscriptionError';
|
||||
200
src/i18n/en.ts
Normal file
200
src/i18n/en.ts
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
/**
|
||||
* English translation resource
|
||||
*/
|
||||
import { TranslationResource } from '../interfaces';
|
||||
|
||||
export const en: TranslationResource = {
|
||||
error: {
|
||||
api: {
|
||||
noKey: 'OpenAI API key is not set',
|
||||
invalidKey: 'Invalid API key format',
|
||||
invalidKeyDetail: 'Invalid API key format. Please enter an API key starting with sk-',
|
||||
connectionFailed: 'Failed to connect to API',
|
||||
quotaExceeded: 'API quota exceeded',
|
||||
rateLimited: 'API rate limit reached. Please wait and try again.',
|
||||
unauthorized: 'Invalid API key. Please check your settings.'
|
||||
},
|
||||
audio: {
|
||||
micPermission: 'Microphone access permission required',
|
||||
micNotFound: 'Microphone not found',
|
||||
micInitFailed: 'Failed to initialize microphone',
|
||||
recordingFailed: 'Failed to start recording',
|
||||
audioContextFailed: 'Failed to create audio context'
|
||||
},
|
||||
transcription: {
|
||||
failed: 'Transcription failed',
|
||||
emptyResult: 'Transcription result is empty',
|
||||
serviceInitFailed: 'Failed to initialize service'
|
||||
},
|
||||
file: {
|
||||
createFailed: 'Failed to create file',
|
||||
notFound: 'File not found',
|
||||
wasmLoadFailed: 'Failed to load WebAssembly module'
|
||||
},
|
||||
general: {
|
||||
unknown: 'An unknown error occurred',
|
||||
network: 'Network error occurred',
|
||||
timeout: 'Request timed out',
|
||||
fatal: 'A fatal error occurred in the plugin. Please restart.',
|
||||
default: 'A problem occurred during processing.'
|
||||
}
|
||||
},
|
||||
status: {
|
||||
idle: 'Status: Idle',
|
||||
memoCleared: 'Status: Memo cleared',
|
||||
clipboardCopied: 'Status: Copied to clipboard',
|
||||
noteInserted: 'Status: Inserted into note',
|
||||
noteAppended: 'Status: Appended to end of note',
|
||||
cleanupInProgress: 'Status: Processing...',
|
||||
cleanupCompleted: 'Status: Processing completed',
|
||||
draftRestored: 'Status: Previous draft restored',
|
||||
recording: {
|
||||
preparing: 'Status: Preparing to record...',
|
||||
micInit: 'Status: Initializing microphone...',
|
||||
recording: 'Status: Recording...',
|
||||
stopped: 'Status: Stopped',
|
||||
cancelled: 'Status: Cancelled'
|
||||
},
|
||||
processing: {
|
||||
transcribing: 'Status: Transcribing...',
|
||||
correcting: 'Status: Correcting...',
|
||||
completed: 'Status: Completed'
|
||||
},
|
||||
transcription: {
|
||||
vadAutoStopped: 'Status: Auto-stopped due to silence',
|
||||
maxDurationReached: 'Status: Maximum duration reached',
|
||||
audioTooShort: 'Status: Audio is too short',
|
||||
noAudioDetected: 'Status: No audio detected'
|
||||
},
|
||||
warning: {
|
||||
noTextToClear: 'Status: No text to clear',
|
||||
noTextToCopy: 'Status: No text to copy',
|
||||
noTextToCleanup: 'Status: No text to process',
|
||||
noTextToInsert: 'Status: No text to insert',
|
||||
clearConfirm: 'Status: Press again to clear'
|
||||
},
|
||||
error: 'Status: Error'
|
||||
},
|
||||
notification: {
|
||||
success: {
|
||||
copied: 'Copied to clipboard',
|
||||
inserted: 'Text inserted into note',
|
||||
cleared: 'Memo cleared',
|
||||
cleanupDone: 'Processing completed',
|
||||
newNoteCreated: 'New note created',
|
||||
dictionaryExported: 'Dictionary exported successfully',
|
||||
dictionaryImported: 'Dictionary imported successfully',
|
||||
apiKeyValid: 'Success: API key validation successful'
|
||||
},
|
||||
warning: {
|
||||
noTextToCopy: 'No text to copy',
|
||||
noTextToInsert: 'No text to insert',
|
||||
noTextToClear: 'No text to clear',
|
||||
noTextToCleanup: 'No text to process',
|
||||
noEditorFound: 'Editor not found. Copied to clipboard.',
|
||||
enterApiKey: 'Please enter API key',
|
||||
serviceInitFailed: 'Service initialization failed',
|
||||
audioTooShort: 'Audio is too short',
|
||||
noAudioDetected: 'No audio detected'
|
||||
},
|
||||
error: {
|
||||
clipboardFailed: 'Failed to copy to clipboard',
|
||||
noteCreateFailed: 'Failed to create note. Copied to clipboard.',
|
||||
apiKeyInvalid: 'Error: API key validation failed',
|
||||
testError: 'Error occurred during test',
|
||||
cleanupFailed: 'Processing failed: {error}',
|
||||
dictionaryParseFailed: 'Failed to parse dictionary: {error}',
|
||||
dictionaryImportFailed: 'Import failed: ',
|
||||
noDictionaryData: 'No dictionary data to export',
|
||||
dictionaryExportFailed: 'Failed to export dictionary'
|
||||
}
|
||||
},
|
||||
ui: {
|
||||
buttons: {
|
||||
recordStart: 'Start Voice Input',
|
||||
recordStop: 'Stop Voice Input',
|
||||
recordPushToTalk: 'Keep speaking...',
|
||||
recordStopPushToTalk: 'Release to stop',
|
||||
recordPreparing: 'Preparing mic...',
|
||||
cleanup: 'Clean Up',
|
||||
copy: 'Copy',
|
||||
insert: 'Insert to Note',
|
||||
insertAtCursor: 'Insert at Cursor',
|
||||
append: 'Append to End',
|
||||
clear: 'Clear',
|
||||
cancel: 'Cancel',
|
||||
connectionTest: 'Test Connection',
|
||||
testing: 'Testing...',
|
||||
testSuccess: 'Success',
|
||||
testFailed: 'Failed',
|
||||
openSettings: 'Open Settings',
|
||||
reset: 'Reset to default',
|
||||
preview: 'Preview JSON',
|
||||
export: 'Export',
|
||||
import: 'Import'
|
||||
},
|
||||
placeholders: {
|
||||
textarea: 'Voice transcription will appear here...',
|
||||
apiKey: 'sk-...',
|
||||
language: 'en'
|
||||
},
|
||||
titles: {
|
||||
main: 'Voice Input',
|
||||
settings: 'Voice Input Settings',
|
||||
visualizer: 'Audio Level'
|
||||
},
|
||||
settings: {
|
||||
apiKey: 'OpenAI API Key',
|
||||
apiKeyDesc: 'Your OpenAI API key for transcription',
|
||||
aiPostProcessing: 'Dictionary Correction',
|
||||
aiPostProcessingDesc: 'Apply dictionary-based corrections to transcription results',
|
||||
transcriptionCorrection: 'Transcription Correction',
|
||||
transcriptionCorrectionDesc: 'Apply dictionary corrections for more accurate text',
|
||||
transcriptionModel: 'Transcription Model',
|
||||
transcriptionModelDesc: 'Select the model for voice recognition',
|
||||
maxRecordingDuration: 'Max Recording Duration',
|
||||
maxRecordingDurationDesc: 'Maximum recording time in seconds (30s - 10min)',
|
||||
pluginLanguage: 'Plugin Language',
|
||||
pluginLanguageDesc: 'Set language for UI, voice processing, and correction dictionary',
|
||||
customDictionary: 'Custom Dictionary',
|
||||
customDictionaryDesc: 'Manage corrections used for post-processing',
|
||||
dictionaryDefinite: 'Definite Corrections (max 50)',
|
||||
dictionaryContextual: 'Contextual Corrections (max 150)',
|
||||
dictionaryImportExport: 'Dictionary Import/Export',
|
||||
dictionaryImportExportDesc: 'Import or export your correction dictionary as JSON'
|
||||
},
|
||||
options: {
|
||||
modelMini: 'GPT-4o mini Transcribe',
|
||||
modelFull: 'GPT-4o Transcribe'
|
||||
},
|
||||
tooltips: {
|
||||
copy: 'Copy to clipboard',
|
||||
insert: 'Insert at cursor',
|
||||
insertAtCursor: 'Insert at cursor position',
|
||||
append: 'Append to end of note',
|
||||
clear: 'Press twice to clear text area',
|
||||
settingsButton: 'Open settings'
|
||||
},
|
||||
units: {
|
||||
seconds: 's',
|
||||
minutes: 'min'
|
||||
},
|
||||
labels: {
|
||||
from: 'From',
|
||||
fromMultiple: 'From (comma-separated)',
|
||||
to: 'To',
|
||||
category: 'Category',
|
||||
context: 'Context Keywords'
|
||||
},
|
||||
categories: {
|
||||
noun: 'Noun',
|
||||
person: 'Person Name',
|
||||
place: 'Place Name',
|
||||
org: 'Organization',
|
||||
proper: 'Other Proper Noun',
|
||||
technical: 'Technical Term',
|
||||
spoken: 'Spoken Form',
|
||||
symbol: 'Symbol/Unit'
|
||||
}
|
||||
}
|
||||
};
|
||||
17
src/i18n/index.ts
Normal file
17
src/i18n/index.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
/**
|
||||
* 翻訳リソースのエクスポート
|
||||
*/
|
||||
import { ja } from './ja';
|
||||
import { en } from './en';
|
||||
import { zh } from './zh';
|
||||
import { ko } from './ko';
|
||||
import { Locale, TranslationResource } from '../interfaces';
|
||||
|
||||
export const translations: Record<Locale, TranslationResource> = {
|
||||
ja,
|
||||
en,
|
||||
zh,
|
||||
ko
|
||||
};
|
||||
|
||||
export { ja, en, zh, ko };
|
||||
200
src/i18n/ja.ts
Normal file
200
src/i18n/ja.ts
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
/**
|
||||
* 日本語翻訳リソース
|
||||
*/
|
||||
import { TranslationResource } from '../interfaces';
|
||||
|
||||
export const ja: TranslationResource = {
|
||||
error: {
|
||||
api: {
|
||||
noKey: 'OpenAI APIキーが設定されていません',
|
||||
invalidKey: '無効なAPIキー形式です',
|
||||
invalidKeyDetail: '無効なAPIキー形式です。sk-で始まるAPIキーを入力してください。',
|
||||
connectionFailed: 'APIへの接続に失敗しました',
|
||||
quotaExceeded: 'APIの利用上限に達しました',
|
||||
rateLimited: 'APIのレート制限に達しました。しばらく待ってから再試行してください。',
|
||||
unauthorized: 'APIキーが無効です。設定を確認してください。'
|
||||
},
|
||||
audio: {
|
||||
micPermission: 'マイクへのアクセス許可が必要です',
|
||||
micNotFound: 'マイクが見つかりません',
|
||||
micInitFailed: 'マイクの初期化に失敗しました',
|
||||
recordingFailed: '録音の開始に失敗しました',
|
||||
audioContextFailed: 'オーディオコンテキストの作成に失敗しました'
|
||||
},
|
||||
transcription: {
|
||||
failed: '文字起こしに失敗しました',
|
||||
emptyResult: '文字起こし結果が空です',
|
||||
serviceInitFailed: 'サービスの初期化に失敗しました'
|
||||
},
|
||||
file: {
|
||||
createFailed: 'ファイルの作成に失敗しました',
|
||||
notFound: 'ファイルが見つかりません',
|
||||
wasmLoadFailed: 'WebAssemblyモジュールの読み込みに失敗しました'
|
||||
},
|
||||
general: {
|
||||
unknown: '不明なエラーが発生しました',
|
||||
network: 'ネットワークエラーが発生しました',
|
||||
timeout: 'タイムアウトしました',
|
||||
fatal: 'プラグインで致命的なエラーが発生しました。再起動してください。',
|
||||
default: '処理中に問題が発生しました。'
|
||||
}
|
||||
},
|
||||
status: {
|
||||
idle: 'ステータス: 待機中',
|
||||
memoCleared: 'ステータス: メモをクリアしました',
|
||||
clipboardCopied: 'ステータス: クリップボードにコピーしました',
|
||||
noteInserted: 'ステータス: ノートに挿入しました',
|
||||
noteAppended: 'ステータス: ノートの末尾に挿入しました',
|
||||
cleanupInProgress: 'ステータス: 処理中...',
|
||||
cleanupCompleted: 'ステータス: 処理完了',
|
||||
draftRestored: 'ステータス: 前回の下書きを復元しました',
|
||||
recording: {
|
||||
preparing: 'ステータス: 録音準備中...',
|
||||
micInit: 'ステータス: マイク初期化中...',
|
||||
recording: 'ステータス: 録音中...',
|
||||
stopped: 'ステータス: 停止済み',
|
||||
cancelled: 'ステータス: キャンセルされました'
|
||||
},
|
||||
processing: {
|
||||
transcribing: 'ステータス: 文字起こし中...',
|
||||
correcting: 'ステータス: 清書中...',
|
||||
completed: 'ステータス: 完了'
|
||||
},
|
||||
transcription: {
|
||||
vadAutoStopped: 'ステータス: 無音検出により自動停止しました',
|
||||
maxDurationReached: 'ステータス: 最大録音時間に達しました',
|
||||
audioTooShort: 'ステータス: 音声が短すぎます',
|
||||
noAudioDetected: 'ステータス: 音声が検出されませんでした'
|
||||
},
|
||||
warning: {
|
||||
noTextToClear: 'ステータス: クリアするテキストがありません',
|
||||
noTextToCopy: 'ステータス: コピーするテキストがありません',
|
||||
noTextToCleanup: 'ステータス: 処理するテキストがありません',
|
||||
noTextToInsert: 'ステータス: 挿入するテキストがありません',
|
||||
clearConfirm: 'ステータス: もう一度押してクリア'
|
||||
},
|
||||
error: 'ステータス: エラー'
|
||||
},
|
||||
notification: {
|
||||
success: {
|
||||
copied: 'クリップボードにコピーしました',
|
||||
inserted: 'テキストをノートに挿入しました',
|
||||
cleared: 'メモをクリアしました',
|
||||
cleanupDone: '処理が完了しました',
|
||||
newNoteCreated: '新しいノートを作成しました',
|
||||
dictionaryExported: '辞書を正常にエクスポートしました',
|
||||
dictionaryImported: '辞書を正常にインポートしました',
|
||||
apiKeyValid: '成功: APIキーの検証に成功しました'
|
||||
},
|
||||
warning: {
|
||||
noTextToCopy: 'コピーするテキストがありません',
|
||||
noTextToInsert: '挿入するテキストがありません',
|
||||
noTextToClear: 'クリアするテキストがありません',
|
||||
noTextToCleanup: '処理するテキストがありません',
|
||||
noEditorFound: 'エディタが見つかりません。クリップボードにコピーしました。',
|
||||
enterApiKey: 'APIキーを入力してください',
|
||||
serviceInitFailed: 'サービスの初期化に失敗しました',
|
||||
audioTooShort: '音声が短すぎます',
|
||||
noAudioDetected: '音声が検出されませんでした'
|
||||
},
|
||||
error: {
|
||||
clipboardFailed: 'クリップボードへのコピーに失敗しました',
|
||||
noteCreateFailed: 'ノートの作成に失敗しました。クリップボードにコピーしました。',
|
||||
apiKeyInvalid: 'エラー: APIキーの検証に失敗しました',
|
||||
testError: 'テスト中にエラーが発生しました',
|
||||
cleanupFailed: '処理に失敗しました: {error}',
|
||||
dictionaryParseFailed: '辞書の読み込みに失敗しました: {error}',
|
||||
dictionaryImportFailed: 'インポートに失敗しました: ',
|
||||
noDictionaryData: 'エクスポートする辞書データがありません',
|
||||
dictionaryExportFailed: '辞書のエクスポートに失敗しました'
|
||||
}
|
||||
},
|
||||
ui: {
|
||||
buttons: {
|
||||
recordStart: '音声入力開始',
|
||||
recordStop: '音声入力停止',
|
||||
recordPushToTalk: '話し続けてください...',
|
||||
recordStopPushToTalk: '離すと停止',
|
||||
recordPreparing: 'マイク準備中...',
|
||||
cleanup: '清書',
|
||||
copy: 'コピー',
|
||||
insert: 'ノートに挿入',
|
||||
insertAtCursor: 'カーソル位置に挿入',
|
||||
append: '末尾に挿入',
|
||||
clear: 'クリア',
|
||||
cancel: 'キャンセル',
|
||||
connectionTest: '接続テスト',
|
||||
testing: 'テスト中...',
|
||||
testSuccess: '成功',
|
||||
testFailed: '失敗',
|
||||
openSettings: '設定を開く',
|
||||
reset: 'デフォルトに戻す',
|
||||
preview: 'JSONを表示',
|
||||
export: 'エクスポート',
|
||||
import: 'インポート'
|
||||
},
|
||||
placeholders: {
|
||||
textarea: 'ここに音声の文字起こしが表示されます...',
|
||||
apiKey: 'sk-...',
|
||||
language: 'ja'
|
||||
},
|
||||
titles: {
|
||||
main: 'Voice Input',
|
||||
settings: 'Voice Input 設定',
|
||||
visualizer: '音声レベル'
|
||||
},
|
||||
settings: {
|
||||
apiKey: 'OpenAI API キー',
|
||||
apiKeyDesc: '文字起こし用のOpenAI APIキー',
|
||||
aiPostProcessing: '辞書補正',
|
||||
aiPostProcessingDesc: '文字起こし結果を辞書で補正',
|
||||
transcriptionCorrection: '文字起こし補正',
|
||||
transcriptionCorrectionDesc: '辞書補正を適用して、より正確なテキストを生成',
|
||||
transcriptionModel: '文字起こしモデル',
|
||||
transcriptionModelDesc: '音声認識に使用するモデルを選択',
|
||||
maxRecordingDuration: '最大録音時間',
|
||||
maxRecordingDurationDesc: '最大録音時間(秒)(30秒〜10分)',
|
||||
pluginLanguage: 'プラグイン言語',
|
||||
pluginLanguageDesc: 'UI表示、音声認識処理、補正辞書の言語を設定',
|
||||
customDictionary: 'カスタム辞書',
|
||||
customDictionaryDesc: '補正辞書を管理',
|
||||
dictionaryDefinite: '固定補正(最大50個)',
|
||||
dictionaryContextual: '文脈補正(最大150個)',
|
||||
dictionaryImportExport: '辞書のインポート/エクスポート',
|
||||
dictionaryImportExportDesc: '補正辞書をJSONファイルとしてインポート・エクスポート'
|
||||
},
|
||||
options: {
|
||||
modelMini: 'GPT-4o mini Transcribe',
|
||||
modelFull: 'GPT-4o Transcribe'
|
||||
},
|
||||
tooltips: {
|
||||
copy: 'クリップボードにコピー',
|
||||
insert: 'カーソル位置に挿入',
|
||||
insertAtCursor: 'カーソル位置に挿入',
|
||||
append: 'ノートの末尾に挿入',
|
||||
clear: '2回押してテキストエリアをクリア',
|
||||
settingsButton: '設定画面を開く'
|
||||
},
|
||||
units: {
|
||||
seconds: '秒',
|
||||
minutes: '分'
|
||||
},
|
||||
labels: {
|
||||
from: '入力語',
|
||||
fromMultiple: '入力語(カンマ区切り)',
|
||||
to: '修正語',
|
||||
category: 'カテゴリ',
|
||||
context: '文脈キーワード'
|
||||
},
|
||||
categories: {
|
||||
noun: '名詞',
|
||||
person: '人名',
|
||||
place: '地名',
|
||||
org: '組織名',
|
||||
proper: 'その他固有名詞',
|
||||
technical: '専門用語',
|
||||
spoken: '話し言葉',
|
||||
symbol: '記号・単位'
|
||||
}
|
||||
}
|
||||
};
|
||||
200
src/i18n/ko.ts
Normal file
200
src/i18n/ko.ts
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
/**
|
||||
* Korean translation resource
|
||||
*/
|
||||
import { TranslationResource } from '../interfaces';
|
||||
|
||||
export const ko: TranslationResource = {
|
||||
error: {
|
||||
api: {
|
||||
noKey: 'OpenAI API 키가 설정되지 않았습니다',
|
||||
invalidKey: '유효하지 않은 API 키 형식입니다',
|
||||
invalidKeyDetail: '유효하지 않은 API 키 형식입니다. sk-로 시작하는 API 키를 입력해주세요.',
|
||||
connectionFailed: 'API 연결에 실패했습니다',
|
||||
quotaExceeded: 'API 사용량 한도에 도달했습니다',
|
||||
rateLimited: 'API 요청 제한에 도달했습니다. 잠시 후 다시 시도해주세요.',
|
||||
unauthorized: 'API 키가 유효하지 않습니다. 설정을 확인해주세요.'
|
||||
},
|
||||
audio: {
|
||||
micPermission: '마이크 액세스 권한이 필요합니다',
|
||||
micNotFound: '마이크를 찾을 수 없습니다',
|
||||
micInitFailed: '마이크 초기화에 실패했습니다',
|
||||
recordingFailed: '녹음 시작에 실패했습니다',
|
||||
audioContextFailed: '오디오 컨텍스트 생성에 실패했습니다'
|
||||
},
|
||||
transcription: {
|
||||
failed: '음성 텍스트 변환에 실패했습니다',
|
||||
emptyResult: '음성 텍스트 변환 결과가 비어있습니다',
|
||||
serviceInitFailed: '서비스 초기화에 실패했습니다'
|
||||
},
|
||||
file: {
|
||||
createFailed: '파일 생성에 실패했습니다',
|
||||
notFound: '파일을 찾을 수 없습니다',
|
||||
wasmLoadFailed: 'WebAssembly 모듈 로드에 실패했습니다'
|
||||
},
|
||||
general: {
|
||||
unknown: '알 수 없는 오류가 발생했습니다',
|
||||
network: '네트워크 오류가 발생했습니다',
|
||||
timeout: '요청 시간이 초과되었습니다',
|
||||
fatal: '플러그인에서 치명적인 오류가 발생했습니다. 재시작해주세요.',
|
||||
default: '처리 중 문제가 발생했습니다.'
|
||||
}
|
||||
},
|
||||
status: {
|
||||
idle: '상태: 대기 중',
|
||||
memoCleared: '상태: 메모를 지웠습니다',
|
||||
clipboardCopied: '상태: 클립보드에 복사했습니다',
|
||||
noteInserted: '상태: 노트에 삽입했습니다',
|
||||
noteAppended: '상태: 노트 끝에 추가했습니다',
|
||||
cleanupInProgress: '상태: 처리 중...',
|
||||
cleanupCompleted: '상태: 처리 완료',
|
||||
draftRestored: '상태: 이전 초안을 복원했습니다',
|
||||
recording: {
|
||||
preparing: '상태: 녹음 준비 중...',
|
||||
micInit: '상태: 마이크 초기화 중...',
|
||||
recording: '상태: 녹음 중...',
|
||||
stopped: '상태: 중지됨',
|
||||
cancelled: '상태: 취소됨'
|
||||
},
|
||||
processing: {
|
||||
transcribing: '상태: 음성 텍스트 변환 중...',
|
||||
correcting: '상태: 텍스트 정리 중...',
|
||||
completed: '상태: 완료'
|
||||
},
|
||||
transcription: {
|
||||
vadAutoStopped: '상태: 무음 감지로 자동 중지됨',
|
||||
maxDurationReached: '상태: 최대 녹음 시간에 도달했습니다',
|
||||
audioTooShort: '상태: 오디오가 너무 짧습니다',
|
||||
noAudioDetected: '상태: 오디오가 감지되지 않았습니다'
|
||||
},
|
||||
warning: {
|
||||
noTextToClear: '상태: 지울 텍스트가 없습니다',
|
||||
noTextToCopy: '상태: 복사할 텍스트가 없습니다',
|
||||
noTextToCleanup: '상태: 처리할 텍스트가 없습니다',
|
||||
noTextToInsert: '상태: 삽입할 텍스트가 없습니다',
|
||||
clearConfirm: '상태: 다시 눌러서 지우기'
|
||||
},
|
||||
error: '상태: 오류'
|
||||
},
|
||||
notification: {
|
||||
success: {
|
||||
copied: '클립보드에 복사했습니다',
|
||||
inserted: '텍스트를 노트에 삽입했습니다',
|
||||
cleared: '메모를 지웠습니다',
|
||||
cleanupDone: '처리가 완료되었습니다',
|
||||
newNoteCreated: '새 노트를 생성했습니다',
|
||||
dictionaryExported: '사전을 성공적으로 내보냈습니다',
|
||||
dictionaryImported: '사전을 성공적으로 가져왔습니다',
|
||||
apiKeyValid: '성공: API 키 검증 성공'
|
||||
},
|
||||
warning: {
|
||||
noTextToCopy: '복사할 텍스트가 없습니다',
|
||||
noTextToInsert: '삽입할 텍스트가 없습니다',
|
||||
noTextToClear: '지울 텍스트가 없습니다',
|
||||
noTextToCleanup: '처리할 텍스트가 없습니다',
|
||||
noEditorFound: '에디터를 찾을 수 없습니다. 클립보드에 복사했습니다.',
|
||||
enterApiKey: 'API 키를 입력해주세요',
|
||||
serviceInitFailed: '서비스 초기화에 실패했습니다',
|
||||
audioTooShort: '오디오가 너무 짧습니다',
|
||||
noAudioDetected: '오디오가 감지되지 않았습니다'
|
||||
},
|
||||
error: {
|
||||
clipboardFailed: '클립보드 복사에 실패했습니다',
|
||||
noteCreateFailed: '노트 생성에 실패했습니다. 클립보드에 복사했습니다.',
|
||||
apiKeyInvalid: '오류: API 키 검증 실패',
|
||||
testError: '테스트 중 오류가 발생했습니다',
|
||||
cleanupFailed: '처리에 실패했습니다: {error}',
|
||||
dictionaryParseFailed: '사전 파싱에 실패했습니다: {error}',
|
||||
dictionaryImportFailed: '가져오기에 실패했습니다: ',
|
||||
noDictionaryData: '내보낼 사전 데이터가 없습니다',
|
||||
dictionaryExportFailed: '사전 내보내기에 실패했습니다'
|
||||
}
|
||||
},
|
||||
ui: {
|
||||
buttons: {
|
||||
recordStart: '음성 입력 시작',
|
||||
recordStop: '음성 입력 중지',
|
||||
recordPushToTalk: '계속 말해주세요...',
|
||||
recordStopPushToTalk: '놓으면 중지',
|
||||
recordPreparing: '마이크 준비 중...',
|
||||
cleanup: '텍스트 정리',
|
||||
copy: '복사',
|
||||
insert: '노트에 삽입',
|
||||
insertAtCursor: '커서 위치에 삽입',
|
||||
append: '끝에 추가',
|
||||
clear: '지우기',
|
||||
cancel: '취소',
|
||||
connectionTest: '연결 테스트',
|
||||
testing: '테스트 중...',
|
||||
testSuccess: '성공',
|
||||
testFailed: '실패',
|
||||
openSettings: '설정 열기',
|
||||
reset: '기본값으로 재설정',
|
||||
preview: 'JSON 미리보기',
|
||||
export: '내보내기',
|
||||
import: '가져오기'
|
||||
},
|
||||
placeholders: {
|
||||
textarea: '음성 텍스트 변환 결과가 여기에 표시됩니다...',
|
||||
apiKey: 'sk-...',
|
||||
language: 'ko'
|
||||
},
|
||||
titles: {
|
||||
main: 'Voice Input',
|
||||
settings: 'Voice Input 설정',
|
||||
visualizer: '오디오 레벨'
|
||||
},
|
||||
settings: {
|
||||
apiKey: 'OpenAI API 키',
|
||||
apiKeyDesc: '음성 텍스트 변환용 OpenAI API 키',
|
||||
aiPostProcessing: '사전 교정',
|
||||
aiPostProcessingDesc: '음성 텍스트 변환 결과를 사전으로 교정',
|
||||
transcriptionCorrection: '음성 텍스트 변환 교정',
|
||||
transcriptionCorrectionDesc: '사전 교정을 적용하여 더 정확한 텍스트 생성',
|
||||
transcriptionModel: '음성 텍스트 변환 모델',
|
||||
transcriptionModelDesc: '음성 인식에 사용할 모델 선택',
|
||||
maxRecordingDuration: '최대 녹음 시간',
|
||||
maxRecordingDurationDesc: '최대 녹음 시간(초) (30초~10분)',
|
||||
pluginLanguage: '플러그인 언어',
|
||||
pluginLanguageDesc: 'UI 표시, 음성 처리 및 교정 사전의 언어 설정',
|
||||
customDictionary: '사용자 정의 사전',
|
||||
customDictionaryDesc: '후처리에 사용되는 교정 사전 관리',
|
||||
dictionaryDefinite: '고정 교정 (최대 50개)',
|
||||
dictionaryContextual: '문맥 교정 (최대 150개)',
|
||||
dictionaryImportExport: '사전 가져오기/내보내기',
|
||||
dictionaryImportExportDesc: '교정 사전을 JSON 파일로 가져오기 또는 내보내기'
|
||||
},
|
||||
options: {
|
||||
modelMini: 'GPT-4o mini Transcribe',
|
||||
modelFull: 'GPT-4o Transcribe'
|
||||
},
|
||||
tooltips: {
|
||||
copy: '클립보드에 복사',
|
||||
insert: '커서 위치에 삽입',
|
||||
insertAtCursor: '커서 위치에 삽입',
|
||||
append: '노트 끝에 추가',
|
||||
clear: '두 번 눌러서 텍스트 영역 지우기',
|
||||
settingsButton: '설정 화면 열기'
|
||||
},
|
||||
units: {
|
||||
seconds: '초',
|
||||
minutes: '분'
|
||||
},
|
||||
labels: {
|
||||
from: '입력어',
|
||||
fromMultiple: '입력어(쉼표구분)',
|
||||
to: '교정어',
|
||||
category: '카테고리',
|
||||
context: '문맥 키워드'
|
||||
},
|
||||
categories: {
|
||||
noun: '명사',
|
||||
person: '인명',
|
||||
place: '지명',
|
||||
org: '기관명',
|
||||
proper: '기타 고유명사',
|
||||
technical: '전문용어',
|
||||
spoken: '구어체',
|
||||
symbol: '기호/단위'
|
||||
}
|
||||
}
|
||||
};
|
||||
200
src/i18n/zh.ts
Normal file
200
src/i18n/zh.ts
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
/**
|
||||
* Chinese translation resource
|
||||
*/
|
||||
import { TranslationResource } from '../interfaces';
|
||||
|
||||
export const zh: TranslationResource = {
|
||||
error: {
|
||||
api: {
|
||||
noKey: 'OpenAI API密钥未设置',
|
||||
invalidKey: 'API密钥格式无效',
|
||||
invalidKeyDetail: 'API密钥格式无效。请输入以sk-开头的API密钥。',
|
||||
connectionFailed: '连接API失败',
|
||||
quotaExceeded: 'API配额已达上限',
|
||||
rateLimited: 'API请求频率受限。请稍后重试。',
|
||||
unauthorized: 'API密钥无效。请检查设置。'
|
||||
},
|
||||
audio: {
|
||||
micPermission: '需要麦克风访问权限',
|
||||
micNotFound: '未找到麦克风',
|
||||
micInitFailed: '麦克风初始化失败',
|
||||
recordingFailed: '录音启动失败',
|
||||
audioContextFailed: '音频上下文创建失败'
|
||||
},
|
||||
transcription: {
|
||||
failed: '语音转文字失败',
|
||||
emptyResult: '语音转文字结果为空',
|
||||
serviceInitFailed: '服务初始化失败'
|
||||
},
|
||||
file: {
|
||||
createFailed: '文件创建失败',
|
||||
notFound: '文件未找到',
|
||||
wasmLoadFailed: 'WebAssembly模块加载失败'
|
||||
},
|
||||
general: {
|
||||
unknown: '发生未知错误',
|
||||
network: '发生网络错误',
|
||||
timeout: '请求超时',
|
||||
fatal: '插件发生致命错误。请重启。',
|
||||
default: '处理过程中发生问题。'
|
||||
}
|
||||
},
|
||||
status: {
|
||||
idle: '状态:待机中',
|
||||
memoCleared: '状态:备忘录已清除',
|
||||
clipboardCopied: '状态:已复制到剪贴板',
|
||||
noteInserted: '状态:已插入到笔记',
|
||||
noteAppended: '状态:已添加到笔记末尾',
|
||||
cleanupInProgress: '状态:处理中...',
|
||||
cleanupCompleted: '状态:处理完成',
|
||||
draftRestored: '状态:已恢复上次草稿',
|
||||
recording: {
|
||||
preparing: '状态:录音准备中...',
|
||||
micInit: '状态:麦克风初始化中...',
|
||||
recording: '状态:录音中...',
|
||||
stopped: '状态:已停止',
|
||||
cancelled: '状态:已取消'
|
||||
},
|
||||
processing: {
|
||||
transcribing: '状态:语音转文字中...',
|
||||
correcting: '状态:文本处理中...',
|
||||
completed: '状态:完成'
|
||||
},
|
||||
transcription: {
|
||||
vadAutoStopped: '状态:检测到静音自动停止',
|
||||
maxDurationReached: '状态:已达最大录音时间',
|
||||
audioTooShort: '状态:音频过短',
|
||||
noAudioDetected: '状态:未检测到音频'
|
||||
},
|
||||
warning: {
|
||||
noTextToClear: '状态:没有要清除的文本',
|
||||
noTextToCopy: '状态:没有要复制的文本',
|
||||
noTextToCleanup: '状态:没有要处理的文本',
|
||||
noTextToInsert: '状态:没有要插入的文本',
|
||||
clearConfirm: '状态:再次按下以清除'
|
||||
},
|
||||
error: '状态:错误'
|
||||
},
|
||||
notification: {
|
||||
success: {
|
||||
copied: '已复制到剪贴板',
|
||||
inserted: '文本已插入到笔记',
|
||||
cleared: '备忘录已清除',
|
||||
cleanupDone: '处理完成',
|
||||
newNoteCreated: '新笔记已创建',
|
||||
dictionaryExported: '词典导出成功',
|
||||
dictionaryImported: '词典导入成功',
|
||||
apiKeyValid: '成功:API密钥验证成功'
|
||||
},
|
||||
warning: {
|
||||
noTextToCopy: '没有要复制的文本',
|
||||
noTextToInsert: '没有要插入的文本',
|
||||
noTextToClear: '没有要清除的文本',
|
||||
noTextToCleanup: '没有要处理的文本',
|
||||
noEditorFound: '未找到编辑器。已复制到剪贴板。',
|
||||
enterApiKey: '请输入API密钥',
|
||||
serviceInitFailed: '服务初始化失败',
|
||||
audioTooShort: '音频过短',
|
||||
noAudioDetected: '未检测到音频'
|
||||
},
|
||||
error: {
|
||||
clipboardFailed: '复制到剪贴板失败',
|
||||
noteCreateFailed: '笔记创建失败。已复制到剪贴板。',
|
||||
apiKeyInvalid: '错误:API密钥验证失败',
|
||||
testError: '测试过程中发生错误',
|
||||
cleanupFailed: '处理失败:{error}',
|
||||
dictionaryParseFailed: '词典解析失败:{error}',
|
||||
dictionaryImportFailed: '导入失败:',
|
||||
noDictionaryData: '没有要导出的词典数据',
|
||||
dictionaryExportFailed: '词典导出失败'
|
||||
}
|
||||
},
|
||||
ui: {
|
||||
buttons: {
|
||||
recordStart: '开始语音输入',
|
||||
recordStop: '停止语音输入',
|
||||
recordPushToTalk: '请继续说话...',
|
||||
recordStopPushToTalk: '松开停止',
|
||||
recordPreparing: '麦克风准备中...',
|
||||
cleanup: '文本整理',
|
||||
copy: '复制',
|
||||
insert: '插入到笔记',
|
||||
insertAtCursor: '在光标处插入',
|
||||
append: '添加到末尾',
|
||||
clear: '清除',
|
||||
cancel: '取消',
|
||||
connectionTest: '连接测试',
|
||||
testing: '测试中...',
|
||||
testSuccess: '成功',
|
||||
testFailed: '失败',
|
||||
openSettings: '打开设置',
|
||||
reset: '重置为默认值',
|
||||
preview: '预览JSON',
|
||||
export: '导出',
|
||||
import: '导入'
|
||||
},
|
||||
placeholders: {
|
||||
textarea: '语音转文字结果将显示在这里...',
|
||||
apiKey: 'sk-...',
|
||||
language: 'zh'
|
||||
},
|
||||
titles: {
|
||||
main: 'Voice Input',
|
||||
settings: 'Voice Input 设置',
|
||||
visualizer: '音频级别'
|
||||
},
|
||||
settings: {
|
||||
apiKey: 'OpenAI API 密钥',
|
||||
apiKeyDesc: '用于语音转文字的OpenAI API密钥',
|
||||
aiPostProcessing: '词典校正',
|
||||
aiPostProcessingDesc: '使用词典对语音转文字结果进行校正',
|
||||
transcriptionCorrection: '语音转文字校正',
|
||||
transcriptionCorrectionDesc: '应用词典校正以生成更准确的文本',
|
||||
transcriptionModel: '语音转文字模型',
|
||||
transcriptionModelDesc: '选择用于语音识别的模型',
|
||||
maxRecordingDuration: '最大录音时长',
|
||||
maxRecordingDurationDesc: '最大录音时间(秒)(30秒~10分钟)',
|
||||
pluginLanguage: '插件语言',
|
||||
pluginLanguageDesc: '设置UI显示、语音处理和校正词典的语言',
|
||||
customDictionary: '自定义词典',
|
||||
customDictionaryDesc: '管理用于后处理的校正词典',
|
||||
dictionaryDefinite: '固定校正(最多50个)',
|
||||
dictionaryContextual: '语境校正(最多150个)',
|
||||
dictionaryImportExport: '词典导入/导出',
|
||||
dictionaryImportExportDesc: '将校正词典作为JSON文件导入或导出'
|
||||
},
|
||||
options: {
|
||||
modelMini: 'GPT-4o mini Transcribe',
|
||||
modelFull: 'GPT-4o Transcribe'
|
||||
},
|
||||
tooltips: {
|
||||
copy: '复制到剪贴板',
|
||||
insert: '在光标处插入',
|
||||
insertAtCursor: '在光标处插入',
|
||||
append: '添加到笔记末尾',
|
||||
clear: '按两次清除文本区域',
|
||||
settingsButton: '打开设置'
|
||||
},
|
||||
units: {
|
||||
seconds: '秒',
|
||||
minutes: '分钟'
|
||||
},
|
||||
labels: {
|
||||
from: '输入词',
|
||||
fromMultiple: '输入词(逗号分隔)',
|
||||
to: '校正词',
|
||||
category: '类别',
|
||||
context: '语境关键词'
|
||||
},
|
||||
categories: {
|
||||
noun: '名词',
|
||||
person: '人名',
|
||||
place: '地名',
|
||||
org: '机构名',
|
||||
proper: '其他专有名词',
|
||||
technical: '专业术语',
|
||||
spoken: '口语',
|
||||
symbol: '符号/单位'
|
||||
}
|
||||
}
|
||||
};
|
||||
95
src/interfaces/audio.ts
Normal file
95
src/interfaces/audio.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import { App } from 'obsidian';
|
||||
|
||||
/**
|
||||
* 共通のオプション
|
||||
*
|
||||
* CLAUDE.mdの哲学に従った設計:
|
||||
* - 必須とオプションの明確な分離
|
||||
* - onSpeechEndはコア機能のため必須
|
||||
*/
|
||||
interface BaseAudioRecorderOptions {
|
||||
/** 音声録音が終了した際のコールバック(必須) */
|
||||
onSpeechEnd: (audioBlob: Blob) => void;
|
||||
|
||||
/** マイクの状態変化を通知するコールバック */
|
||||
onMicrophoneStatusChange?: (status: 'initializing' | 'ready' | 'error') => void;
|
||||
|
||||
/** ビジュアライザーを表示するコンテナ要素 */
|
||||
visualizerContainer?: HTMLElement;
|
||||
|
||||
/** シンプルなレベルインジケーターを使用するか */
|
||||
useSimpleVisualizer?: boolean;
|
||||
|
||||
/** 最大録音時間(秒) */
|
||||
maxRecordingSeconds?: number;
|
||||
}
|
||||
|
||||
// VADを使用しない場合のオプション
|
||||
export interface AudioRecorderOptionsWithoutVAD extends BaseAudioRecorderOptions {
|
||||
useVAD: false;
|
||||
}
|
||||
|
||||
/**
|
||||
* VADを使用する場合のオプション
|
||||
*
|
||||
* CLAUDE.mdの哲学に従った設計:
|
||||
* - VAD使用時に必要なプロパティを必須化
|
||||
* - 型システムで安全性を保証
|
||||
*/
|
||||
export interface AudioRecorderOptionsWithVAD extends BaseAudioRecorderOptions {
|
||||
useVAD: true;
|
||||
|
||||
/** Obsidian Appインスタンス(VAD使用時は必須) */
|
||||
app: App;
|
||||
|
||||
/** VADの状態変化を通知するコールバック(VAD使用時は必須) */
|
||||
onVADStatusChange: (status: 'speech' | 'silence') => void;
|
||||
|
||||
/** 無音継続時の自動停止時間 (ms) - デフォルト: 2000 */
|
||||
autoStopSilenceDuration?: number;
|
||||
|
||||
/** VADモード (0-3: 0=低感度, 3=高感度) - デフォルト: 3 */
|
||||
vadMode?: 0 | 1 | 2 | 3;
|
||||
|
||||
/** 最小音声継続時間 (ms) - デフォルト: 100 */
|
||||
minSpeechDuration?: number;
|
||||
|
||||
/** エネルギー闾値 - デフォルト: 0.02 */
|
||||
energyThreshold?: number;
|
||||
|
||||
/** 最小無音継続時間 (ms) - デフォルト: 1000 */
|
||||
minSilenceDuration?: number;
|
||||
}
|
||||
|
||||
// ユニオン型で両方のケースを表現
|
||||
export type AudioRecorderOptions = AudioRecorderOptionsWithoutVAD | AudioRecorderOptionsWithVAD;
|
||||
|
||||
export interface IAudioProcessor {
|
||||
initialize(): Promise<void>;
|
||||
process(data: Float32Array): Promise<ProcessedAudio>;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
export interface ProcessedAudio {
|
||||
data: Float32Array;
|
||||
isSpeech: boolean;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface IAudioVisualizer {
|
||||
initialize(audioContext: AudioContext, source: MediaStreamAudioSourceNode): void;
|
||||
updateVADStatus(isActive: boolean): void;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
export interface IVADProcessor {
|
||||
initialize(sampleRate: number): Promise<void>;
|
||||
process(audioData: Float32Array): Promise<boolean>;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
export interface SpeechSegment {
|
||||
audioData: Float32Array;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
}
|
||||
224
src/interfaces/disposable.ts
Normal file
224
src/interfaces/disposable.ts
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
/**
|
||||
* Disposableパターンのインターフェース
|
||||
*
|
||||
* CLAUDE.md の哲学に従った実装:
|
||||
* - 根本的な解決: リソース管理の体系化
|
||||
* - 汎用的な設計: あらゆるリソースに適用可能
|
||||
* - 型安全性: TypeScriptの型システムを活用
|
||||
* - エラーハンドリング: 適切なクリーンアップ保証
|
||||
*/
|
||||
|
||||
import { Logger } from '../utils';
|
||||
|
||||
/**
|
||||
* リソースの解放を保証するインターフェース
|
||||
*/
|
||||
export interface IDisposable {
|
||||
/**
|
||||
* リソースを解放する
|
||||
* - 複数回呼ばれても安全であること(冪等性)
|
||||
* - エラーを投げないこと
|
||||
*/
|
||||
dispose(): void;
|
||||
|
||||
/**
|
||||
* リソースが既に解放されているかどうか
|
||||
*/
|
||||
readonly isDisposed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 非同期でリソースを解放するインターフェース
|
||||
*/
|
||||
export interface IAsyncDisposable {
|
||||
/**
|
||||
* リソースを非同期で解放する
|
||||
* - 複数回呼ばれても安全であること(冪等性)
|
||||
* - エラーを投げないこと
|
||||
*/
|
||||
disposeAsync(): Promise<void>;
|
||||
|
||||
/**
|
||||
* リソースが既に解放されているかどうか
|
||||
*/
|
||||
readonly isDisposed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disposableパターンの基本実装
|
||||
*/
|
||||
export abstract class Disposable implements IDisposable {
|
||||
private _isDisposed = false;
|
||||
|
||||
/**
|
||||
* リソースが既に解放されているかどうか
|
||||
*/
|
||||
get isDisposed(): boolean {
|
||||
return this._isDisposed;
|
||||
}
|
||||
|
||||
/**
|
||||
* リソースを解放する
|
||||
*/
|
||||
dispose(): void {
|
||||
if (this._isDisposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.onDispose();
|
||||
} finally {
|
||||
this._isDisposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 派生クラスでオーバーライドして、実際のリソース解放処理を実装する
|
||||
*/
|
||||
protected abstract onDispose(): void;
|
||||
|
||||
/**
|
||||
* リソースが解放されていることを確認する
|
||||
* @throws Error リソースが既に解放されている場合
|
||||
*/
|
||||
protected throwIfDisposed(): void {
|
||||
if (this._isDisposed) {
|
||||
throw new Error(`${this.constructor.name} has been disposed`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 非同期Disposableパターンの基本実装
|
||||
*/
|
||||
export abstract class AsyncDisposable implements IAsyncDisposable {
|
||||
private _isDisposed = false;
|
||||
|
||||
/**
|
||||
* リソースが既に解放されているかどうか
|
||||
*/
|
||||
get isDisposed(): boolean {
|
||||
return this._isDisposed;
|
||||
}
|
||||
|
||||
/**
|
||||
* リソースを非同期で解放する
|
||||
*/
|
||||
async disposeAsync(): Promise<void> {
|
||||
if (this._isDisposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.onDisposeAsync();
|
||||
} finally {
|
||||
this._isDisposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 派生クラスでオーバーライドして、実際のリソース解放処理を実装する
|
||||
*/
|
||||
protected abstract onDisposeAsync(): Promise<void>;
|
||||
|
||||
/**
|
||||
* リソースが解放されていることを確認する
|
||||
* @throws Error リソースが既に解放されている場合
|
||||
*/
|
||||
protected throwIfDisposed(): void {
|
||||
if (this._isDisposed) {
|
||||
throw new Error(`${this.constructor.name} has been disposed`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 複数のDisposableを管理するクラス
|
||||
*/
|
||||
export class CompositeDisposable implements IDisposable {
|
||||
private readonly disposables: IDisposable[] = [];
|
||||
private _isDisposed = false;
|
||||
private static logger = Logger.getLogger('CompositeDisposable');
|
||||
|
||||
/**
|
||||
* リソースが既に解放されているかどうか
|
||||
*/
|
||||
get isDisposed(): boolean {
|
||||
return this._isDisposed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disposableを追加する
|
||||
*/
|
||||
add(disposable: IDisposable): void {
|
||||
if (this._isDisposed) {
|
||||
disposable.dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
this.disposables.push(disposable);
|
||||
}
|
||||
|
||||
/**
|
||||
* 全てのリソースを解放する
|
||||
*/
|
||||
dispose(): void {
|
||||
if (this._isDisposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 逆順で解放(依存関係を考慮)
|
||||
for (let i = this.disposables.length - 1; i >= 0; i--) {
|
||||
try {
|
||||
this.disposables[i].dispose();
|
||||
} catch (error) {
|
||||
// エラーをログに記録するが、処理は継続
|
||||
CompositeDisposable.logger.error('Error disposing resource', error);
|
||||
}
|
||||
}
|
||||
|
||||
this.disposables.length = 0;
|
||||
this._isDisposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disposableのユーティリティ関数
|
||||
*/
|
||||
export const DisposableUtils = {
|
||||
/**
|
||||
* 関数をDisposableとしてラップする
|
||||
*/
|
||||
fromFunction(fn: () => void): IDisposable {
|
||||
return new FunctionDisposable(fn);
|
||||
},
|
||||
|
||||
/**
|
||||
* 複数のDisposableを安全に解放する
|
||||
*/
|
||||
disposeAll(...disposables: (IDisposable | undefined | null)[]): void {
|
||||
for (const disposable of disposables) {
|
||||
if (disposable && !disposable.isDisposed) {
|
||||
try {
|
||||
disposable.dispose();
|
||||
} catch (error) {
|
||||
// Use logger for error reporting - will be controlled by debugMode in production
|
||||
Logger.getInstance().error('Error disposing resource:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 関数をラップするDisposable
|
||||
*/
|
||||
class FunctionDisposable extends Disposable {
|
||||
constructor(private readonly fn: () => void) {
|
||||
super();
|
||||
}
|
||||
|
||||
protected onDispose(): void {
|
||||
this.fn();
|
||||
}
|
||||
}
|
||||
268
src/interfaces/i18n.ts
Normal file
268
src/interfaces/i18n.ts
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
/**
|
||||
* 国際化(i18n)関連のインターフェース定義
|
||||
*
|
||||
* CLAUDE.md の哲学に従った実装:
|
||||
* - 型安全性: すべての翻訳キーを型で定義
|
||||
* - 拡張性: 新しい言語の追加が容易
|
||||
* - 一貫性: プラグイン全体で統一されたi18n使用
|
||||
*/
|
||||
|
||||
/**
|
||||
* サポートする言語コード
|
||||
*/
|
||||
export const SUPPORTED_LOCALES = ['ja', 'en', 'zh', 'ko'] as const;
|
||||
export type Locale = typeof SUPPORTED_LOCALES[number];
|
||||
|
||||
/**
|
||||
* デフォルト言語
|
||||
*/
|
||||
export const DEFAULT_LOCALE: Locale = 'ja';
|
||||
|
||||
/**
|
||||
* 翻訳リソースの型定義
|
||||
* ネストされたオブジェクト構造をサポート
|
||||
*/
|
||||
export type TranslationResource = {
|
||||
error: {
|
||||
api: {
|
||||
noKey: string;
|
||||
invalidKey: string;
|
||||
invalidKeyDetail: string;
|
||||
connectionFailed: string;
|
||||
quotaExceeded: string;
|
||||
rateLimited: string;
|
||||
unauthorized: string;
|
||||
};
|
||||
audio: {
|
||||
micPermission: string;
|
||||
micNotFound: string;
|
||||
micInitFailed: string;
|
||||
recordingFailed: string;
|
||||
audioContextFailed: string;
|
||||
};
|
||||
transcription: {
|
||||
failed: string;
|
||||
emptyResult: string;
|
||||
serviceInitFailed: string;
|
||||
};
|
||||
file: {
|
||||
createFailed: string;
|
||||
notFound: string;
|
||||
wasmLoadFailed: string;
|
||||
};
|
||||
general: {
|
||||
unknown: string;
|
||||
network: string;
|
||||
timeout: string;
|
||||
fatal: string;
|
||||
default: string;
|
||||
};
|
||||
};
|
||||
status: {
|
||||
idle: string;
|
||||
memoCleared: string;
|
||||
clipboardCopied: string;
|
||||
noteInserted: string;
|
||||
noteAppended: string;
|
||||
cleanupInProgress: string;
|
||||
cleanupCompleted: string;
|
||||
draftRestored: string;
|
||||
recording: {
|
||||
preparing: string;
|
||||
micInit: string;
|
||||
recording: string;
|
||||
stopped: string;
|
||||
cancelled: string;
|
||||
};
|
||||
processing: {
|
||||
transcribing: string;
|
||||
correcting: string;
|
||||
completed: string;
|
||||
};
|
||||
transcription: {
|
||||
vadAutoStopped: string;
|
||||
maxDurationReached: string;
|
||||
audioTooShort: string;
|
||||
noAudioDetected: string;
|
||||
};
|
||||
warning: {
|
||||
noTextToClear: string;
|
||||
noTextToCopy: string;
|
||||
noTextToCleanup: string;
|
||||
noTextToInsert: string;
|
||||
clearConfirm: string;
|
||||
};
|
||||
error: string;
|
||||
};
|
||||
notification: {
|
||||
success: {
|
||||
copied: string;
|
||||
inserted: string;
|
||||
cleared: string;
|
||||
cleanupDone: string;
|
||||
newNoteCreated: string;
|
||||
apiKeyValid: string;
|
||||
dictionaryExported: string;
|
||||
dictionaryImported: string;
|
||||
};
|
||||
warning: {
|
||||
noTextToCopy: string;
|
||||
noTextToInsert: string;
|
||||
noTextToClear: string;
|
||||
noTextToCleanup: string;
|
||||
noEditorFound: string;
|
||||
enterApiKey: string;
|
||||
serviceInitFailed: string;
|
||||
audioTooShort: string;
|
||||
noAudioDetected: string;
|
||||
};
|
||||
error: {
|
||||
clipboardFailed: string;
|
||||
noteCreateFailed: string;
|
||||
apiKeyInvalid: string;
|
||||
testError: string;
|
||||
cleanupFailed: string;
|
||||
dictionaryParseFailed: string;
|
||||
dictionaryImportFailed: string;
|
||||
noDictionaryData: string;
|
||||
dictionaryExportFailed: string;
|
||||
};
|
||||
};
|
||||
ui: {
|
||||
buttons: {
|
||||
recordStart: string;
|
||||
recordStop: string;
|
||||
recordPushToTalk: string;
|
||||
recordStopPushToTalk: string;
|
||||
recordPreparing: string;
|
||||
cleanup: string;
|
||||
copy: string;
|
||||
insert: string;
|
||||
insertAtCursor: string;
|
||||
append: string;
|
||||
clear: string;
|
||||
cancel: string;
|
||||
connectionTest: string;
|
||||
testing: string;
|
||||
testSuccess: string;
|
||||
testFailed: string;
|
||||
openSettings: string;
|
||||
reset: string;
|
||||
preview: string;
|
||||
export: string;
|
||||
import: string;
|
||||
};
|
||||
placeholders: {
|
||||
textarea: string;
|
||||
apiKey: string;
|
||||
language: string;
|
||||
};
|
||||
titles: {
|
||||
main: string;
|
||||
settings: string;
|
||||
visualizer: string;
|
||||
};
|
||||
settings: {
|
||||
apiKey: string;
|
||||
apiKeyDesc: string;
|
||||
aiPostProcessing: string;
|
||||
aiPostProcessingDesc: string;
|
||||
transcriptionCorrection: string;
|
||||
transcriptionCorrectionDesc: string;
|
||||
transcriptionModel: string;
|
||||
transcriptionModelDesc: string;
|
||||
maxRecordingDuration: string;
|
||||
maxRecordingDurationDesc: string;
|
||||
pluginLanguage: string;
|
||||
pluginLanguageDesc: string;
|
||||
customDictionary: string;
|
||||
customDictionaryDesc: string;
|
||||
dictionaryDefinite: string;
|
||||
dictionaryContextual: string;
|
||||
dictionaryImportExport: string;
|
||||
dictionaryImportExportDesc: string;
|
||||
};
|
||||
options: {
|
||||
modelMini: string;
|
||||
modelFull: string;
|
||||
};
|
||||
tooltips: {
|
||||
copy: string;
|
||||
insert: string;
|
||||
insertAtCursor: string;
|
||||
append: string;
|
||||
clear: string;
|
||||
settingsButton: string;
|
||||
};
|
||||
units: {
|
||||
seconds: string;
|
||||
minutes: string;
|
||||
};
|
||||
labels: {
|
||||
from: string;
|
||||
fromMultiple: string;
|
||||
to: string;
|
||||
category: string;
|
||||
context: string;
|
||||
};
|
||||
categories: {
|
||||
noun: string;
|
||||
person: string;
|
||||
place: string;
|
||||
org: string;
|
||||
proper: string;
|
||||
technical: string;
|
||||
spoken: string;
|
||||
symbol: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 翻訳キーのパス型
|
||||
* ドット記法でネストされたキーにアクセス
|
||||
*/
|
||||
export type TranslationKey = string;
|
||||
|
||||
/**
|
||||
* i18nサービスのインターフェース
|
||||
*/
|
||||
export interface I18nService {
|
||||
/**
|
||||
* 現在の言語を取得
|
||||
*/
|
||||
getCurrentLocale(): Locale;
|
||||
|
||||
/**
|
||||
* 言語を設定
|
||||
* @param locale 設定する言語コード
|
||||
*/
|
||||
setLocale(locale: Locale): void;
|
||||
|
||||
/**
|
||||
* 翻訳を取得
|
||||
* @param key 翻訳キー(ドット記法)
|
||||
* @param params パラメータ置換用のオブジェクト
|
||||
*/
|
||||
t(key: TranslationKey, params?: Record<string, string | number>): string;
|
||||
|
||||
/**
|
||||
* 利用可能な言語のリストを取得
|
||||
*/
|
||||
getAvailableLocales(): readonly Locale[];
|
||||
|
||||
/**
|
||||
* 言語変更時のコールバックを登録
|
||||
*/
|
||||
onLocaleChange(callback: (locale: Locale) => void): void;
|
||||
|
||||
/**
|
||||
* 言語変更時のコールバックを解除
|
||||
*/
|
||||
offLocaleChange(callback: (locale: Locale) => void): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 翻訳関数の型
|
||||
*/
|
||||
export type TranslateFunction = (key: TranslationKey, params?: Record<string, string | number>) => string;
|
||||
18
src/interfaces/index.ts
Normal file
18
src/interfaces/index.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
// Settings
|
||||
export type { VoiceInputSettings } from './settings';
|
||||
export { DEFAULT_SETTINGS } from './settings';
|
||||
|
||||
// Audio interfaces
|
||||
export * from './audio';
|
||||
|
||||
// Recording interfaces
|
||||
export * from './recording';
|
||||
|
||||
// Transcription interfaces
|
||||
export * from './transcription';
|
||||
|
||||
// Disposable interface
|
||||
export * from './disposable';
|
||||
|
||||
// i18n interfaces
|
||||
export * from './i18n';
|
||||
31
src/interfaces/recording.ts
Normal file
31
src/interfaces/recording.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
/**
|
||||
* Recording stop reason types
|
||||
*/
|
||||
export type StopReasonType = 'manual' | 'vad-auto' | 'max-duration' | 'error';
|
||||
|
||||
/**
|
||||
* Recording stop reason interface
|
||||
*/
|
||||
export interface StopReason {
|
||||
type: StopReasonType;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Audio processing queue item
|
||||
*/
|
||||
export interface AudioQueueItem {
|
||||
audioBlob: Blob;
|
||||
timestamp: number;
|
||||
stopReason: StopReason;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recording state interface
|
||||
*/
|
||||
export interface RecordingState {
|
||||
isRecording: boolean;
|
||||
isPushToTalkMode: boolean;
|
||||
lastStopReason?: StopReason;
|
||||
processingQueue: AudioQueueItem[];
|
||||
}
|
||||
31
src/interfaces/settings.ts
Normal file
31
src/interfaces/settings.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { Locale } from './i18n';
|
||||
import { SimpleCorrectionDictionary } from './transcription';
|
||||
import { LogLevel } from '../utils';
|
||||
|
||||
export interface VoiceInputSettings {
|
||||
openaiApiKey: string;
|
||||
enableTranscriptionCorrection: boolean; // 文字起こし補正を有効化(辞書補正)
|
||||
transcriptionModel: 'gpt-4o-transcribe' | 'gpt-4o-mini-transcribe';
|
||||
// 録音設定
|
||||
maxRecordingSeconds: number; // 最大録音時間(秒)
|
||||
// プラグイン言語設定
|
||||
pluginLanguage: Locale; // プラグイン全体の言語(UI、音声認識、補正辞書)
|
||||
customDictionary: SimpleCorrectionDictionary;
|
||||
// デバッグ設定
|
||||
debugMode: boolean; // デバッグモード
|
||||
logLevel: LogLevel; // ログレベル
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: VoiceInputSettings = {
|
||||
openaiApiKey: '',
|
||||
enableTranscriptionCorrection: true, // デフォルトで補正を有効化
|
||||
transcriptionModel: 'gpt-4o-transcribe',
|
||||
// 録音設定
|
||||
maxRecordingSeconds: 300, // 5分(300秒)
|
||||
// プラグイン言語はObsidianの設定に従う(loadSettingsで設定)
|
||||
pluginLanguage: 'en', // 初期値、実際はObsidianの設定に従う
|
||||
customDictionary: { definiteCorrections: [] },
|
||||
// デバッグ設定
|
||||
debugMode: false, // 本番環境ではデフォルトでオフ
|
||||
logLevel: LogLevel.INFO // 通常レベル
|
||||
};
|
||||
69
src/interfaces/transcription.ts
Normal file
69
src/interfaces/transcription.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
/**
|
||||
* 文字起こし結果
|
||||
*
|
||||
* CLAUDE.mdの哲学に従った設計:
|
||||
* - 重要なメトリクスを必須化
|
||||
* - デバッグとトラッキングに必要な情報を保証
|
||||
*/
|
||||
export interface TranscriptionResult {
|
||||
/** 最終的な文字起こしテキスト */
|
||||
text: string;
|
||||
|
||||
/** 使用されたモデル */
|
||||
model: string;
|
||||
|
||||
/** 検出された言語 */
|
||||
language: string;
|
||||
|
||||
/** 処理時間 (ms) */
|
||||
duration: number;
|
||||
|
||||
/** オリジナルの文字起こしテキスト(修正前) */
|
||||
originalText?: string;
|
||||
|
||||
/** 信頼度スコア (0-1) */
|
||||
confidence?: number;
|
||||
}
|
||||
|
||||
export interface ITranscriptionProvider {
|
||||
transcribe(audio: Blob, language: string): Promise<TranscriptionResult>;
|
||||
}
|
||||
|
||||
export interface ITextCorrector {
|
||||
correct(text: string): Promise<string>;
|
||||
}
|
||||
|
||||
export interface CorrectionRule {
|
||||
pattern: RegExp;
|
||||
replacement: string;
|
||||
description?: string;
|
||||
caseSensitive?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 辞書修正オプション
|
||||
*
|
||||
* CLAUDE.mdの哲学に従った設計:
|
||||
* - シンプルな固定置換のみをサポート
|
||||
*/
|
||||
export interface DictionaryCorrectorOptions {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
// Simple correction dictionary for speech recognition errors
|
||||
export interface CorrectionEntry {
|
||||
from: string[]; // 誤認識パターン(複数対応)
|
||||
to: string; // 正しい表記
|
||||
}
|
||||
|
||||
// Legacy type for backward compatibility
|
||||
export type CorrectionEntryLegacy = {
|
||||
from: string;
|
||||
to: string;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
export interface SimpleCorrectionDictionary {
|
||||
// 確実に修正すべきパターン
|
||||
definiteCorrections: CorrectionEntry[];
|
||||
}
|
||||
19
src/lib/fvad-wasm/fvad.js
Normal file
19
src/lib/fvad-wasm/fvad.js
Normal file
File diff suppressed because one or more lines are too long
BIN
src/lib/fvad-wasm/fvad.wasm
Normal file
BIN
src/lib/fvad-wasm/fvad.wasm
Normal file
Binary file not shown.
2
src/main.ts
Normal file
2
src/main.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
// Main entry point for Voice Memo Plugin
|
||||
export { default } from './plugin';
|
||||
113
src/managers/DraftManager.ts
Normal file
113
src/managers/DraftManager.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import type { App } from 'obsidian';
|
||||
import { normalizePath } from 'obsidian';
|
||||
import { getLogger } from '../utils';
|
||||
import type { Logger } from '../utils';
|
||||
import { FILE_CONSTANTS } from '../config';
|
||||
|
||||
/**
|
||||
* Manages draft saving and restoration for Voice Input
|
||||
* Ensures consistent behavior across different save scenarios
|
||||
*/
|
||||
export class DraftManager {
|
||||
private static readonly MAX_DRAFT_SIZE = 1024 * 1024; // 1MB
|
||||
private static logger: Logger | null = null;
|
||||
private static lastSaveTime = 0;
|
||||
private static readonly MIN_SAVE_INTERVAL = 1000; // 1秒の最小間隔
|
||||
|
||||
/**
|
||||
* Get the draft file path using the vault's config directory
|
||||
*/
|
||||
private static getDraftPath(app: App): string {
|
||||
const path = `${app.vault.configDir}/plugins/${FILE_CONSTANTS.PLUGIN_ID}/draft.txt`;
|
||||
return normalizePath(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize logger (called once when plugin loads)
|
||||
*/
|
||||
static initLogger(): void {
|
||||
this.logger = getLogger('DraftManager');
|
||||
}
|
||||
|
||||
/**
|
||||
* Save draft text to disk
|
||||
* @param app - Obsidian app instance
|
||||
* @param text - Text to save
|
||||
* @param source - Where the save was triggered from (for logging)
|
||||
* @returns true if saved successfully
|
||||
*/
|
||||
static async saveDraft(app: App, text: string, source: string): Promise<boolean> {
|
||||
// Skip if text is empty
|
||||
if (!text?.trim()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Prevent too frequent saves (except for critical saves)
|
||||
const now = Date.now();
|
||||
const criticalSources = ['plugin-unload', 'view-close'];
|
||||
if (!criticalSources.includes(source)) {
|
||||
if (now - this.lastSaveTime < this.MIN_SAVE_INTERVAL) {
|
||||
this.logger?.debug('Draft save skipped (too frequent)', { source, timeSinceLastSave: now - this.lastSaveTime });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
this.lastSaveTime = now;
|
||||
|
||||
try {
|
||||
// Truncate if too large
|
||||
const textToSave = text.length > this.MAX_DRAFT_SIZE
|
||||
? text.substring(0, this.MAX_DRAFT_SIZE)
|
||||
: text;
|
||||
|
||||
const draftPath = this.getDraftPath(app);
|
||||
await app.vault.adapter.write(draftPath, textToSave);
|
||||
|
||||
this.logger?.info('Draft saved successfully', {
|
||||
source,
|
||||
textLength: textToSave.length,
|
||||
truncated: text.length > this.MAX_DRAFT_SIZE
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.logger?.error('Failed to save draft', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load draft text from disk
|
||||
* @param app - Obsidian app instance
|
||||
* @returns Draft text or null if not found/error
|
||||
*/
|
||||
static async loadDraft(app: App): Promise<string | null> {
|
||||
try {
|
||||
const draftPath = this.getDraftPath(app);
|
||||
if (!await app.vault.adapter.exists(draftPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const text = await app.vault.adapter.read(draftPath);
|
||||
this.logger?.info('Draft loaded successfully', { textLength: text?.length });
|
||||
return text;
|
||||
} catch (error) {
|
||||
this.logger?.error('Failed to load draft', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear draft file
|
||||
* @param app - Obsidian app instance
|
||||
*/
|
||||
static async clearDraft(app: App): Promise<void> {
|
||||
try {
|
||||
const draftPath = this.getDraftPath(app);
|
||||
if (await app.vault.adapter.exists(draftPath)) {
|
||||
await app.vault.adapter.remove(draftPath);
|
||||
this.logger?.info('Draft cleared successfully');
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger?.error('Failed to clear draft', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
147
src/managers/ViewManager.ts
Normal file
147
src/managers/ViewManager.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
import { App, MarkdownView, WorkspaceLeaf } from 'obsidian';
|
||||
import { VIEW_TYPE_VOICE_INPUT } from '../views';
|
||||
import { Disposable } from '../interfaces';
|
||||
import { Logger } from '../utils';
|
||||
import { createServiceLogger } from '../services';
|
||||
import { DeferredViewHelper } from '../utils';
|
||||
|
||||
/**
|
||||
* ビューの管理を専門に行うマネージャークラス
|
||||
* プラグインからビュー管理の責任を分離
|
||||
*/
|
||||
export class ViewManager extends Disposable {
|
||||
private app: App;
|
||||
private lastActiveMarkdownView: MarkdownView | null = null;
|
||||
private logger: Logger;
|
||||
|
||||
constructor(app: App) {
|
||||
super();
|
||||
this.app = app;
|
||||
|
||||
// ServiceLocatorからLoggerを取得
|
||||
try {
|
||||
this.logger = createServiceLogger('ViewManager');
|
||||
} catch (error) {
|
||||
// フォールバック: ServiceLocatorが初期化されていない場合
|
||||
// 基本のLoggerインスタンスを作成
|
||||
this.logger = Logger.getLogger('ViewManager');
|
||||
this.logger.warn('ServiceLocator not initialized, using fallback logger');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 最後にアクティブだったマークダウンビューを記録
|
||||
*/
|
||||
recordActiveMarkdownView(): void {
|
||||
const currentActiveView = this.app.workspace.getActiveViewOfType(MarkdownView);
|
||||
if (currentActiveView) {
|
||||
this.lastActiveMarkdownView = currentActiveView;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 最後にアクティブだったマークダウンビューを取得
|
||||
*/
|
||||
getLastActiveMarkdownView(): MarkdownView | null {
|
||||
// ビューがまだ有効かチェック
|
||||
if (this.lastActiveMarkdownView && this.lastActiveMarkdownView.leaf) {
|
||||
const leaves = this.app.workspace.getLeavesOfType('markdown');
|
||||
if (leaves.some(leaf => leaf === this.lastActiveMarkdownView?.leaf)) {
|
||||
return this.lastActiveMarkdownView;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Voice Inputビューをアクティブ化
|
||||
*/
|
||||
async activateVoiceInputView(): Promise<void> {
|
||||
const { workspace } = this.app;
|
||||
|
||||
// 現在のマークダウンビューを記録
|
||||
this.recordActiveMarkdownView();
|
||||
|
||||
let leaf: WorkspaceLeaf | null = null;
|
||||
const leaves = workspace.getLeavesOfType(VIEW_TYPE_VOICE_INPUT);
|
||||
|
||||
if (leaves.length > 0) {
|
||||
// 既存のビューを使用(最初のもののみ)
|
||||
leaf = leaves[0];
|
||||
// 複数のビューがある場合は警告(通常は発生しないはず)
|
||||
if (leaves.length > 1) {
|
||||
this.logger.warn('Multiple Voice Input views detected, using the first one', {
|
||||
viewCount: leaves.length
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// 新しいビューを作成
|
||||
const rightLeaf = workspace.getRightLeaf(false);
|
||||
if (rightLeaf) {
|
||||
leaf = rightLeaf;
|
||||
await leaf.setViewState({ type: VIEW_TYPE_VOICE_INPUT, active: true });
|
||||
}
|
||||
}
|
||||
|
||||
// ビューを表示(DeferredViews対応)
|
||||
if (leaf) {
|
||||
await DeferredViewHelper.safeRevealLeaf(leaf, workspace);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 適切なマークダウンビューを見つける
|
||||
*/
|
||||
findTargetMarkdownView(): MarkdownView | null {
|
||||
// 1. 最後にアクティブだったビューを試す
|
||||
const lastActive = this.getLastActiveMarkdownView();
|
||||
if (lastActive) {
|
||||
return lastActive;
|
||||
}
|
||||
|
||||
// 2. 任意のマークダウンビューを探す
|
||||
const leaves = this.app.workspace.getLeavesOfType('markdown');
|
||||
for (const leaf of leaves) {
|
||||
const view = leaf.view;
|
||||
if (view instanceof MarkdownView) {
|
||||
return view;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. No suitable existing markdown view found
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* すべてのVoice Inputビューをクリーンアップ
|
||||
* Note: According to Obsidian plugin guidelines, we don't detach leaves in onunload
|
||||
* as this is an antipattern. We only clean up references and let Obsidian handle view lifecycle.
|
||||
*/
|
||||
cleanupVoiceInputViews(): void {
|
||||
// Following Obsidian plugin guidelines - we don't detach leaves in cleanup
|
||||
// Individual views will handle their own cleanup in their onClose methods
|
||||
// This method is kept for consistency but doesn't perform detachment
|
||||
}
|
||||
|
||||
/**
|
||||
* Disposableパターンの実装
|
||||
*/
|
||||
protected onDispose(): void {
|
||||
// Clean up references to existing views without detaching them
|
||||
// (following Obsidian plugin guidelines - don't detach leaves in onunload)
|
||||
this.cleanupVoiceInputViews();
|
||||
|
||||
// 参照をクリア
|
||||
this.lastActiveMarkdownView = null;
|
||||
|
||||
this.logger?.info('ViewManager disposed');
|
||||
}
|
||||
|
||||
/**
|
||||
* 互換性のためのdestroyメソッド
|
||||
* @deprecated dispose()を使用してください
|
||||
*/
|
||||
destroy(): void {
|
||||
this.dispose();
|
||||
}
|
||||
}
|
||||
2
src/managers/index.ts
Normal file
2
src/managers/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { ViewManager } from './ViewManager';
|
||||
export { DraftManager } from './DraftManager';
|
||||
380
src/plugin/VoiceInputPlugin.ts
Normal file
380
src/plugin/VoiceInputPlugin.ts
Normal file
|
|
@ -0,0 +1,380 @@
|
|||
import {
|
||||
Plugin
|
||||
} from 'obsidian';
|
||||
import { VoiceInputSettings, DEFAULT_SETTINGS } from '../interfaces';
|
||||
import { VoiceInputView, VIEW_TYPE_VOICE_INPUT } from '../views';
|
||||
import { VoiceInputSettingTab } from '../settings';
|
||||
import { ViewManager, DraftManager } from '../managers';
|
||||
import { ErrorHandler } from '../errors';
|
||||
import { Logger, getLogger, mergeSettings, hasSettingsKey, migrateCorrectionEntries, DeferredViewHelper } from '../utils';
|
||||
import { serviceLocator, ServiceKeys, getI18nService } from '../services';
|
||||
import { getObsidianLocale } from '../types';
|
||||
import { SafeStorageService } from '../security';
|
||||
|
||||
export default class VoiceInputPlugin extends Plugin {
|
||||
settings: VoiceInputSettings;
|
||||
private viewManager: ViewManager;
|
||||
private errorHandler: ErrorHandler;
|
||||
private logger: Logger;
|
||||
|
||||
async onload() {
|
||||
// 初期化順序の依存関係:
|
||||
// 1. ServiceLocatorに基本サービスを登録
|
||||
// 2. ErrorHandlerとLoggerの初期化
|
||||
// 3. Settingsの読み込み
|
||||
// 4. ViewManagerの初期化
|
||||
// 5. ViewとCommandの登録
|
||||
|
||||
// Step 1: Register basic services
|
||||
// In Obsidian, we determine development mode based on the presence of a .git folder
|
||||
const isDevelopment = await this.app.vault.adapter.exists('.git');
|
||||
serviceLocator.register(ServiceKeys.IS_DEVELOPMENT, isDevelopment);
|
||||
|
||||
// Step 2: Load settings first to get debug mode configuration
|
||||
await this.loadSettings();
|
||||
|
||||
// Step 3: Initialize error handling and logging with debug mode from settings
|
||||
this.errorHandler = ErrorHandler.getInstance({ isDevelopment });
|
||||
serviceLocator.register(ServiceKeys.ERROR_HANDLER, this.errorHandler);
|
||||
|
||||
this.logger = Logger.getInstance({
|
||||
debugMode: this.settings.debugMode || isDevelopment,
|
||||
logLevel: this.settings.logLevel
|
||||
});
|
||||
serviceLocator.register(ServiceKeys.LOGGER_FACTORY, (component: string) => getLogger(component));
|
||||
|
||||
this.logger.info('Voice Input Plugin loading...', {
|
||||
debugMode: this.settings.debugMode,
|
||||
isDevelopment,
|
||||
logLevel: this.settings.logLevel
|
||||
});
|
||||
|
||||
// Initialize DraftManager logger
|
||||
DraftManager.initLogger();
|
||||
this.logger.debug('DraftManager logger initialized');
|
||||
|
||||
try {
|
||||
// Step 4: Initialize i18n with user's preferred language
|
||||
const i18nService = getI18nService();
|
||||
i18nService.setLocale(this.settings.pluginLanguage);
|
||||
serviceLocator.register(ServiceKeys.I18N_SERVICE, i18nService);
|
||||
this.logger.debug('I18n service initialized', { locale: this.settings.pluginLanguage });
|
||||
|
||||
// Step 5: Initialize ViewManager (requires app instance only)
|
||||
this.viewManager = new ViewManager(this.app);
|
||||
this.logger.debug('ViewManager initialized');
|
||||
|
||||
// Initialize UI when workspace layout is ready
|
||||
this.app.workspace.onLayoutReady(() => {
|
||||
this.initializeUI();
|
||||
});
|
||||
|
||||
this.logger.info('Voice Input Plugin loaded successfully');
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to load Voice Input Plugin', error);
|
||||
this.errorHandler.handleError(
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
{
|
||||
component: 'VoiceInputPlugin',
|
||||
operation: 'onload',
|
||||
timestamp: new Date()
|
||||
}
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize UI components when workspace layout is ready
|
||||
*/
|
||||
private initializeUI(): void {
|
||||
try {
|
||||
this.logger.debug('Initializing UI components...');
|
||||
|
||||
// Register the view
|
||||
this.registerView(
|
||||
VIEW_TYPE_VOICE_INPUT,
|
||||
(leaf) => new VoiceInputView(leaf, this)
|
||||
);
|
||||
this.logger.debug('VoiceInputView registered');
|
||||
|
||||
// Add ribbon icon
|
||||
this.addRibbonIcon('microphone', 'Voice Input', () => {
|
||||
this.logger.debug('Ribbon icon clicked');
|
||||
this.viewManager.activateVoiceInputView();
|
||||
});
|
||||
this.logger.debug('Ribbon icon added');
|
||||
|
||||
// Add command
|
||||
this.addCommand({
|
||||
id: 'open-voice-input',
|
||||
name: 'Open Voice Input',
|
||||
callback: () => {
|
||||
this.logger.debug('Command executed: open-voice-input');
|
||||
this.viewManager.activateVoiceInputView();
|
||||
}
|
||||
});
|
||||
this.logger.debug('Commands registered');
|
||||
|
||||
// Add settings tab
|
||||
this.addSettingTab(new VoiceInputSettingTab(this.app, this));
|
||||
this.logger.debug('Settings tab added');
|
||||
|
||||
this.logger.info('UI initialized successfully');
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to initialize UI', error);
|
||||
this.errorHandler.handleError(
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
{
|
||||
component: 'VoiceInputPlugin',
|
||||
operation: 'initializeUI',
|
||||
timestamp: new Date()
|
||||
}
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async onunload() {
|
||||
// Logger might not be initialized if onload failed
|
||||
if (this.logger) {
|
||||
this.logger.info('Voice Input Plugin unloading...');
|
||||
}
|
||||
|
||||
try {
|
||||
// Save draft text from any open Voice Input views
|
||||
if (this.viewManager) {
|
||||
const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_VOICE_INPUT);
|
||||
for (const leaf of leaves) {
|
||||
const view = await DeferredViewHelper.safeGetVoiceInputView(leaf);
|
||||
if (view?.ui?.textArea?.value) {
|
||||
await DraftManager.saveDraft(this.app, view.ui.textArea.value, 'plugin-unload');
|
||||
}
|
||||
}
|
||||
|
||||
// Stop any active recordings before cleanup
|
||||
const stopPromises = leaves.map(async leaf => {
|
||||
const view = await DeferredViewHelper.safeGetVoiceInputView(leaf);
|
||||
if (view?.actions?.audioRecorder?.isActive()) {
|
||||
this.logger?.info('Stopping active recording before unload');
|
||||
// Add timeout to prevent hanging
|
||||
return Promise.race([
|
||||
view.actions.stopRecording(),
|
||||
new Promise(resolve => setTimeout(resolve, 1000)) // 1 second timeout
|
||||
]).catch(error => {
|
||||
this.logger.error('Error stopping recording during unload', error);
|
||||
});
|
||||
}
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
// Wait for all recordings to stop
|
||||
await Promise.all(stopPromises);
|
||||
|
||||
this.viewManager.cleanupVoiceInputViews();
|
||||
this.viewManager.dispose();
|
||||
this.logger?.debug('ViewManager cleaned up');
|
||||
}
|
||||
if (this.errorHandler) {
|
||||
this.errorHandler.dispose();
|
||||
this.logger?.debug('ErrorHandler cleaned up');
|
||||
}
|
||||
|
||||
// ServiceLocatorをクリア
|
||||
serviceLocator.clear();
|
||||
this.logger?.debug('ServiceLocator cleared');
|
||||
|
||||
this.logger?.info('Voice Input Plugin unloaded successfully');
|
||||
} catch (error) {
|
||||
if (this.logger) {
|
||||
this.logger.error('Error during plugin unload', error);
|
||||
} else {
|
||||
// Fallback: Only use console if it exists (for extreme edge cases)
|
||||
// This should rarely happen as logger is initialized early
|
||||
if (typeof console !== 'undefined' && console.error) {
|
||||
console.error('Error during plugin unload', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async loadSettings() {
|
||||
const data = await this.loadData();
|
||||
|
||||
// まずデフォルト設定から開始
|
||||
this.settings = { ...DEFAULT_SETTINGS };
|
||||
|
||||
// 保存が必要かどうかを追跡
|
||||
let needsSave = false;
|
||||
|
||||
if (data) {
|
||||
// 設定のマイグレーション処理
|
||||
const migratedData = { ...data };
|
||||
|
||||
// interfaceLanguageからpluginLanguageへの移行
|
||||
if ('interfaceLanguage' in data && !('pluginLanguage' in data)) {
|
||||
migratedData.pluginLanguage = data.interfaceLanguage;
|
||||
delete migratedData.interfaceLanguage;
|
||||
needsSave = true;
|
||||
this.logger?.info('Migrating interfaceLanguage to pluginLanguage');
|
||||
}
|
||||
|
||||
// languageからpluginLanguageへの移行
|
||||
if ('language' in data && !('pluginLanguage' in data)) {
|
||||
// 言語コードを正規化(ja → ja、en → en、その他 → en)
|
||||
const langCode = data.language as string;
|
||||
migratedData.pluginLanguage = (langCode === 'ja' || langCode === 'en') ? langCode : 'en';
|
||||
delete migratedData.language;
|
||||
needsSave = true;
|
||||
this.logger?.info(`Migrating language (${data.language}) to pluginLanguage (${migratedData.pluginLanguage})`);
|
||||
} else if ('language' in data) {
|
||||
// pluginLanguageが既に存在する場合は、languageフィールドを削除
|
||||
delete migratedData.language;
|
||||
needsSave = true;
|
||||
this.logger?.info('Removing redundant language field');
|
||||
}
|
||||
|
||||
// VAD関連設定の削除(連続録音のみサポートするため)
|
||||
if ('recordingMode' in data || 'autoStopSilenceDuration' in data || 'minSpeechDuration' in data) {
|
||||
if ('recordingMode' in data) {
|
||||
delete migratedData.recordingMode;
|
||||
this.logger?.info(`Removing recordingMode setting (was: ${data.recordingMode})`);
|
||||
}
|
||||
if ('autoStopSilenceDuration' in data) {
|
||||
delete migratedData.autoStopSilenceDuration;
|
||||
this.logger?.info(`Removing autoStopSilenceDuration setting (was: ${data.autoStopSilenceDuration})`);
|
||||
}
|
||||
if ('minSpeechDuration' in data) {
|
||||
delete migratedData.minSpeechDuration;
|
||||
this.logger?.info(`Removing minSpeechDuration setting (was: ${data.minSpeechDuration})`);
|
||||
}
|
||||
needsSave = true;
|
||||
this.logger?.info('Migrated to continuous recording only mode');
|
||||
}
|
||||
|
||||
// 保存されたデータをデフォルト設定に上書き(undefinedの値は除外)
|
||||
mergeSettings(this.settings, migratedData);
|
||||
|
||||
// APIキーの復号化
|
||||
if (this.settings.openaiApiKey) {
|
||||
try {
|
||||
this.settings.openaiApiKey = SafeStorageService.decryptFromStore(this.settings.openaiApiKey);
|
||||
} catch (error) {
|
||||
this.logger?.error('Failed to decrypt API key:', error);
|
||||
// 復号化に失敗した場合は、暗号化されていない可能性があるのでそのまま使用
|
||||
}
|
||||
}
|
||||
|
||||
// customDictionaryが存在しない場合はデフォルトを使用
|
||||
if (!this.settings.customDictionary) {
|
||||
this.settings.customDictionary = DEFAULT_SETTINGS.customDictionary;
|
||||
needsSave = true;
|
||||
this.logger?.info('Initialized customDictionary with default values');
|
||||
}
|
||||
|
||||
// CorrectionEntry の旧形式から新形式への移行
|
||||
if (this.settings.customDictionary.definiteCorrections &&
|
||||
this.settings.customDictionary.definiteCorrections.length > 0) {
|
||||
const originalEntries = this.settings.customDictionary.definiteCorrections;
|
||||
const migratedEntries = migrateCorrectionEntries(originalEntries);
|
||||
|
||||
// 変更があった場合のみ保存
|
||||
if (JSON.stringify(originalEntries) !== JSON.stringify(migratedEntries)) {
|
||||
this.settings.customDictionary.definiteCorrections = migratedEntries;
|
||||
needsSave = true;
|
||||
this.logger?.info('Migrated CorrectionEntry format from single pattern to multiple patterns');
|
||||
}
|
||||
}
|
||||
|
||||
// 重要な設定が欠落している場合の検出
|
||||
// enableTranscriptionCorrectionが保存データに存在しない場合
|
||||
if (!hasSettingsKey(data, 'enableTranscriptionCorrection')) {
|
||||
needsSave = true;
|
||||
this.logger?.info('enableTranscriptionCorrection not found, using default: true');
|
||||
}
|
||||
|
||||
// pluginLanguageが設定されていない場合
|
||||
if (!hasSettingsKey(data, 'pluginLanguage') &&
|
||||
!hasSettingsKey(data, 'interfaceLanguage')) {
|
||||
const obsidianLocale = this.getObsidianLocale();
|
||||
this.settings.pluginLanguage = obsidianLocale.startsWith('ja') ? 'ja' : 'en';
|
||||
needsSave = true;
|
||||
this.logger?.info(`Auto-detected language: ${this.settings.pluginLanguage} (from Obsidian: ${obsidianLocale})`);
|
||||
}
|
||||
} else {
|
||||
// 保存データが存在しない場合(初回起動)
|
||||
const obsidianLocale = this.getObsidianLocale();
|
||||
this.settings.pluginLanguage = obsidianLocale.startsWith('ja') ? 'ja' : 'en';
|
||||
needsSave = true;
|
||||
this.logger?.info(`First run - auto-detected language: ${this.settings.pluginLanguage}`);
|
||||
}
|
||||
|
||||
// 必要に応じて設定を保存
|
||||
if (needsSave) {
|
||||
await this.saveSettings();
|
||||
this.logger?.info('Settings saved after migration/initialization');
|
||||
}
|
||||
|
||||
// i18nの言語設定は、サービスが登録された後にonload内で行う
|
||||
|
||||
this.logger?.info('Settings loaded:', {
|
||||
pluginLanguage: this.settings.pluginLanguage,
|
||||
enableTranscriptionCorrection: this.settings.enableTranscriptionCorrection,
|
||||
hasApiKey: !!this.settings.openaiApiKey
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Obsidianの言語設定を取得
|
||||
*/
|
||||
private getObsidianLocale(): string {
|
||||
return getObsidianLocale(this.app);
|
||||
}
|
||||
|
||||
async saveSettings() {
|
||||
// APIキーを暗号化して保存
|
||||
const dataToSave = {
|
||||
...this.settings,
|
||||
openaiApiKey: this.settings.openaiApiKey ? SafeStorageService.encryptForStore(this.settings.openaiApiKey) : ''
|
||||
};
|
||||
await this.saveData(dataToSave);
|
||||
|
||||
// Update logger configuration based on new settings
|
||||
Logger.getInstance().updateConfig({
|
||||
debugMode: this.settings.debugMode,
|
||||
logLevel: this.settings.logLevel
|
||||
});
|
||||
|
||||
// Update all open voice input views
|
||||
const updatePromises = this.app.workspace.getLeavesOfType(VIEW_TYPE_VOICE_INPUT).map(async leaf => {
|
||||
const view = await DeferredViewHelper.safeGetVoiceInputView(leaf);
|
||||
if (view) {
|
||||
// actions.updateTranscriptionServiceを呼び出す
|
||||
if (view.actions?.updateTranscriptionService) {
|
||||
view.actions.updateTranscriptionService();
|
||||
}
|
||||
// UIコンポーネントも更新して同期を保つ
|
||||
if (view.ui) {
|
||||
view.ui.updateSettingsUI();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for all updates to complete
|
||||
await Promise.all(updatePromises);
|
||||
}
|
||||
|
||||
/**
|
||||
* ViewManagerのゲッター(他のコンポーネントからアクセス可能)
|
||||
*/
|
||||
getViewManager(): ViewManager {
|
||||
return this.viewManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get logger instance
|
||||
*/
|
||||
getLogger(): Logger | null {
|
||||
return this.logger;
|
||||
}
|
||||
}
|
||||
1
src/plugin/index.ts
Normal file
1
src/plugin/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from './VoiceInputPlugin';
|
||||
184
src/security/SafeStorageService.ts
Normal file
184
src/security/SafeStorageService.ts
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
import { Platform } from 'obsidian';
|
||||
import { getLogger, Logger } from '../utils';
|
||||
|
||||
const PREFIX = 'SAFE_V1::';
|
||||
const LEGACY_XOR = 'XOR_V1::';
|
||||
const LEGACY_PLAIN = 'PLAIN::';
|
||||
const FIXED_KEY = 'voice-input-obsidian-2025';
|
||||
|
||||
interface ElectronSafeStorage {
|
||||
isEncryptionAvailable(): boolean;
|
||||
encryptString(plainText: string): Buffer;
|
||||
decryptString(encrypted: Buffer): string;
|
||||
}
|
||||
|
||||
interface ElectronRemote {
|
||||
safeStorage?: ElectronSafeStorage;
|
||||
}
|
||||
|
||||
interface ElectronRenderer {
|
||||
safeStorage?: ElectronSafeStorage;
|
||||
remote?: ElectronRemote;
|
||||
}
|
||||
|
||||
interface ElectronWindow extends Window {
|
||||
require?: (moduleName: string) => ElectronRenderer;
|
||||
electron?: ElectronRenderer;
|
||||
}
|
||||
|
||||
interface ElectronGlobal {
|
||||
electron?: ElectronRenderer;
|
||||
}
|
||||
|
||||
const isElectronWindow = (win: Window): win is ElectronWindow => {
|
||||
return 'require' in win && typeof (win as ElectronWindow).require === 'function';
|
||||
};
|
||||
|
||||
export class SafeStorageService {
|
||||
private static safeStorage: ElectronSafeStorage | null = null;
|
||||
private static logger: Logger = getLogger('SafeStorageService');
|
||||
|
||||
/** safeStorageの遅延初期化 */
|
||||
private static getSafeStorage() {
|
||||
if (!this.safeStorage) {
|
||||
try {
|
||||
// モバイル環境チェック
|
||||
if (Platform.isMobileApp) {
|
||||
this.logger.debug('Running on mobile, safeStorage not available');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Obsidianのelectron環境からsafeStorageを取得
|
||||
this.logger.debug('Checking for Electron environment');
|
||||
this.logger.debug(`window.require exists: ${'require' in window}`);
|
||||
this.logger.debug(`window.require is function: ${typeof (window as ElectronWindow).require === 'function'}`);
|
||||
|
||||
const electron = isElectronWindow(window) ? window.require?.('electron') : null;
|
||||
|
||||
if (!electron) {
|
||||
// Alternative access method
|
||||
const globalElectron = (window as ElectronWindow).electron || (global as ElectronGlobal).electron;
|
||||
if (globalElectron) {
|
||||
this.logger.debug('Found electron via global access');
|
||||
this.safeStorage = globalElectron.remote?.safeStorage || globalElectron.safeStorage || null;
|
||||
}
|
||||
} else {
|
||||
this.logger.debug(`Electron object exists: ${!!electron}`);
|
||||
this.logger.debug(`Electron.remote exists: ${!!electron?.remote}`);
|
||||
this.logger.debug(`Electron.safeStorage exists: ${!!electron?.safeStorage}`);
|
||||
this.logger.debug(`Electron.remote.safeStorage exists: ${!!electron?.remote?.safeStorage}`);
|
||||
|
||||
this.safeStorage = electron?.remote?.safeStorage || electron?.safeStorage || null;
|
||||
}
|
||||
|
||||
this.logger.debug(`SafeStorage initialized: ${!!this.safeStorage}`);
|
||||
} catch (e) {
|
||||
this.logger.error('Error during safeStorage initialization', e instanceof Error ? e : new Error(String(e)));
|
||||
}
|
||||
}
|
||||
return this.safeStorage;
|
||||
}
|
||||
|
||||
/** 暗号化して保存用文字列へ変換 */
|
||||
static encryptForStore(apiKey: string): string {
|
||||
if (!apiKey) return '';
|
||||
|
||||
// Trim the API key before storing
|
||||
const trimmedKey = apiKey.trim();
|
||||
if (!trimmedKey) return '';
|
||||
|
||||
this.logger.debug('Encrypting API key for storage');
|
||||
|
||||
const safeStorage = this.getSafeStorage();
|
||||
if (safeStorage?.isEncryptionAvailable?.()) {
|
||||
try {
|
||||
const buf = safeStorage.encryptString(trimmedKey);
|
||||
const encrypted = PREFIX + buf.toString('base64');
|
||||
this.logger.debug('API key encrypted using SafeStorage');
|
||||
return encrypted;
|
||||
} catch (e) {
|
||||
this.logger.warn('SafeStorage encryption failed, using fallback', { error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
} else {
|
||||
this.logger.debug('SafeStorage not available, using XOR fallback');
|
||||
}
|
||||
// フォールバック
|
||||
const encrypted = this.xorEncrypt(trimmedKey, FIXED_KEY);
|
||||
const result = LEGACY_XOR + encrypted;
|
||||
this.logger.debug('API key encrypted using XOR fallback');
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 保存文字列 -> 平文 API キー */
|
||||
static decryptFromStore(stored: string): string {
|
||||
if (!stored) return '';
|
||||
|
||||
this.logger.debug('Decrypting stored API key');
|
||||
|
||||
// 新方式
|
||||
if (stored.startsWith(PREFIX)) {
|
||||
const safeStorage = this.getSafeStorage();
|
||||
if (safeStorage?.decryptString) {
|
||||
const b64 = stored.slice(PREFIX.length);
|
||||
try {
|
||||
const decrypted = safeStorage.decryptString(Buffer.from(b64, 'base64'));
|
||||
this.logger.debug('API key decrypted using SafeStorage');
|
||||
return decrypted;
|
||||
} catch (e) {
|
||||
this.logger.error('SafeStorage decryption failed', e instanceof Error ? e : new Error(String(e)));
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (stored.startsWith(LEGACY_XOR)) {
|
||||
const encrypted = stored.substring(LEGACY_XOR.length);
|
||||
try {
|
||||
const decrypted = this.xorDecrypt(encrypted, FIXED_KEY);
|
||||
this.logger.debug('API key decrypted using XOR fallback');
|
||||
return decrypted;
|
||||
} catch (e) {
|
||||
this.logger.error('XOR decryption failed', e instanceof Error ? e : new Error(String(e)));
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// 平文
|
||||
if (stored.startsWith(LEGACY_PLAIN)) {
|
||||
return stored.replace(LEGACY_PLAIN, '');
|
||||
}
|
||||
|
||||
// 平文のAPIキー(sk-で始まる)をそのまま返す(後方互換性)
|
||||
if (stored.startsWith('sk-') && stored.length > 40) {
|
||||
return stored;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
private static xorEncrypt(text: string, key: string): string {
|
||||
let result = '';
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
result += String.fromCharCode(
|
||||
text.charCodeAt(i) ^ key.charCodeAt(i % key.length)
|
||||
);
|
||||
}
|
||||
return btoa(result);
|
||||
}
|
||||
|
||||
private static xorDecrypt(encoded: string, key: string): string {
|
||||
try {
|
||||
const text = atob(encoded);
|
||||
let result = '';
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
result += String.fromCharCode(
|
||||
text.charCodeAt(i) ^ key.charCodeAt(i % key.length)
|
||||
);
|
||||
}
|
||||
return result;
|
||||
} catch (e) {
|
||||
this.logger.error('XOR decryption failed', e instanceof Error ? e : new Error(String(e)));
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
87
src/security/SecurityUtils.ts
Normal file
87
src/security/SecurityUtils.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { ObsidianHttpClient } from '../utils/ObsidianHttpClient';
|
||||
|
||||
export class SecurityUtils {
|
||||
/**
|
||||
* Mask API key for display purposes
|
||||
*/
|
||||
static maskAPIKey(key: string): string {
|
||||
if (!key || key.length < 10) return '';
|
||||
return key.substring(0, 7) + '*'.repeat(40);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate OpenAI API key format
|
||||
*/
|
||||
static validateOpenAIAPIKey(key: string): boolean {
|
||||
// OpenAI API keys start with 'sk-' followed by alphanumeric characters
|
||||
// More flexible pattern to accommodate format changes
|
||||
return /^sk-[a-zA-Z0-9\-_]{20,}$/.test(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize user input to prevent injection attacks
|
||||
*/
|
||||
static sanitizeInput(input: string): string {
|
||||
// Remove potentially dangerous characters
|
||||
return input
|
||||
.replace(/[<>]/g, '')
|
||||
.replace(/javascript:/gi, '')
|
||||
.replace(/on\w+\s*=/gi, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate URL format
|
||||
*/
|
||||
static isValidURL(url: string): boolean {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
return urlObj.protocol === 'https:' || urlObj.protocol === 'http:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove sensitive information from error messages
|
||||
*/
|
||||
static sanitizeErrorMessage(message: string): string {
|
||||
// Remove API keys, tokens, and URLs that might contain sensitive data
|
||||
return message
|
||||
.replace(/sk-[a-zA-Z0-9]{48}/g, '[API_KEY_REMOVED]')
|
||||
.replace(/Bearer\s+[a-zA-Z0-9\-._~+/]+=*/g, '[TOKEN_REMOVED]')
|
||||
.replace(/https?:\/\/[^\s]+/g, '[URL_REMOVED]');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if running in secure context (HTTPS or localhost)
|
||||
*/
|
||||
static isSecureContext(): boolean {
|
||||
return window.isSecureContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test OpenAI API key by making a simple API call
|
||||
*/
|
||||
static async testOpenAIAPIKey(apiKey: string): Promise<{ valid: boolean; error?: string }> {
|
||||
try {
|
||||
const { status } = await ObsidianHttpClient.request({
|
||||
url: 'https://api.openai.com/v1/models',
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': `Bearer ${apiKey}` }
|
||||
});
|
||||
|
||||
if (status >= 200 && status < 300) {
|
||||
return { valid: true };
|
||||
} else if (status === 401) {
|
||||
return { valid: false, error: 'APIキーが無効です' };
|
||||
} else if (status === 429) {
|
||||
return { valid: false, error: 'レート制限に達しています' };
|
||||
} else {
|
||||
return { valid: false, error: `エラー: HTTP ${status}` };
|
||||
}
|
||||
} catch (_error) {
|
||||
return { valid: false, error: 'ネットワークエラー' };
|
||||
}
|
||||
}
|
||||
}
|
||||
2
src/security/index.ts
Normal file
2
src/security/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { SafeStorageService } from './SafeStorageService';
|
||||
export { SecurityUtils } from './SecurityUtils';
|
||||
166
src/services/I18nService.ts
Normal file
166
src/services/I18nService.ts
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
/**
|
||||
* 国際化(i18n)サービスの実装
|
||||
*
|
||||
* CLAUDE.md の哲学に従った実装:
|
||||
* - 型安全な翻訳キーアクセス
|
||||
* - パフォーマンスを考慮したキャッシング
|
||||
* - 拡張可能な設計
|
||||
*/
|
||||
import {
|
||||
I18nService,
|
||||
Locale,
|
||||
TranslationKey,
|
||||
DEFAULT_LOCALE,
|
||||
SUPPORTED_LOCALES
|
||||
} from '../interfaces';
|
||||
import { translations } from '../i18n';
|
||||
import { createServiceLogger } from './ServiceLocator';
|
||||
import { Logger } from '../utils';
|
||||
|
||||
export class I18nServiceImpl implements I18nService {
|
||||
private currentLocale: Locale = DEFAULT_LOCALE;
|
||||
private localeChangeCallbacks: Set<(locale: Locale) => void> = new Set();
|
||||
private logger: Logger;
|
||||
private translationCache: Map<string, string> = new Map();
|
||||
|
||||
constructor(initialLocale?: Locale) {
|
||||
this.logger = createServiceLogger('I18nService');
|
||||
if (initialLocale && SUPPORTED_LOCALES.includes(initialLocale)) {
|
||||
this.currentLocale = initialLocale;
|
||||
}
|
||||
}
|
||||
|
||||
getCurrentLocale(): Locale {
|
||||
return this.currentLocale;
|
||||
}
|
||||
|
||||
setLocale(locale: Locale): void {
|
||||
if (!SUPPORTED_LOCALES.includes(locale)) {
|
||||
this.logger.warn('Unsupported locale, falling back to default', {
|
||||
requestedLocale: locale,
|
||||
fallbackLocale: DEFAULT_LOCALE,
|
||||
supportedLocales: SUPPORTED_LOCALES
|
||||
});
|
||||
locale = DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
if (this.currentLocale !== locale) {
|
||||
this.currentLocale = locale;
|
||||
this.translationCache.clear(); // Clear cache on locale change
|
||||
this.notifyLocaleChange(locale);
|
||||
}
|
||||
}
|
||||
|
||||
t(key: TranslationKey, params?: Record<string, string | number>): string {
|
||||
// Check cache first
|
||||
const cacheKey = `${this.currentLocale}:${key}:${JSON.stringify(params || {})}`;
|
||||
if (this.translationCache.has(cacheKey)) {
|
||||
const cached = this.translationCache.get(cacheKey);
|
||||
return cached !== undefined ? cached : key;
|
||||
}
|
||||
|
||||
// Get translation
|
||||
let translation = this.getTranslation(key, this.currentLocale);
|
||||
|
||||
// Fallback to English if not found
|
||||
if (!translation && this.currentLocale !== 'en') {
|
||||
translation = this.getTranslation(key, 'en');
|
||||
}
|
||||
|
||||
// Fallback to key if still not found
|
||||
if (!translation) {
|
||||
this.logger.warn('Translation not found for key', {
|
||||
key,
|
||||
currentLocale: this.currentLocale,
|
||||
hasEnglishFallback: this.currentLocale !== 'en'
|
||||
});
|
||||
translation = key;
|
||||
}
|
||||
|
||||
// Apply parameter substitution
|
||||
if (params) {
|
||||
translation = this.substituteParams(translation, params);
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
this.translationCache.set(cacheKey, translation);
|
||||
|
||||
return translation;
|
||||
}
|
||||
|
||||
getAvailableLocales(): readonly Locale[] {
|
||||
return SUPPORTED_LOCALES;
|
||||
}
|
||||
|
||||
onLocaleChange(callback: (locale: Locale) => void): void {
|
||||
this.localeChangeCallbacks.add(callback);
|
||||
}
|
||||
|
||||
offLocaleChange(callback: (locale: Locale) => void): void {
|
||||
this.localeChangeCallbacks.delete(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get translation from resource by key path
|
||||
*/
|
||||
private getTranslation(key: string, locale: Locale): string | undefined {
|
||||
const resource = translations[locale];
|
||||
if (!resource) return undefined;
|
||||
|
||||
// Navigate through the object using the key path
|
||||
const keys = key.split('.');
|
||||
let current: unknown = resource;
|
||||
|
||||
for (const k of keys) {
|
||||
if (current && typeof current === 'object' && current !== null && k in current) {
|
||||
current = (current as Record<string, unknown>)[k];
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return typeof current === 'string' ? current : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Substitute parameters in translation string
|
||||
* Supports {paramName} syntax
|
||||
*/
|
||||
private substituteParams(translation: string, params: Record<string, string | number>): string {
|
||||
return translation.replace(/{(\w+)}/g, (match, paramName) => {
|
||||
return params[paramName]?.toString() || match;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify all registered callbacks about locale change
|
||||
*/
|
||||
private notifyLocaleChange(locale: Locale): void {
|
||||
this.localeChangeCallbacks.forEach(callback => {
|
||||
try {
|
||||
callback(locale);
|
||||
} catch (error) {
|
||||
this.logger.error('Error in locale change callback', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a singleton instance
|
||||
*/
|
||||
let i18nServiceInstance: I18nServiceImpl | null = null;
|
||||
|
||||
export function getI18nService(): I18nServiceImpl {
|
||||
if (!i18nServiceInstance) {
|
||||
i18nServiceInstance = new I18nServiceImpl();
|
||||
}
|
||||
return i18nServiceInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function for translations
|
||||
*/
|
||||
export function t(key: TranslationKey, params?: Record<string, string | number>): string {
|
||||
return getI18nService().t(key, params);
|
||||
}
|
||||
164
src/services/ServiceLocator.ts
Normal file
164
src/services/ServiceLocator.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
/**
|
||||
* サービスロケーターパターン
|
||||
*
|
||||
* CLAUDE.mdの哲学に従った実装:
|
||||
* - 根本的な解決: 初期化順序の依存関係を管理
|
||||
* - 汎用的な設計: あらゆるサービスに対応
|
||||
* - 型安全性: TypeScriptの型システムを活用
|
||||
* - 単一責任: サービスの登録と取得のみを担当
|
||||
*/
|
||||
|
||||
import { ErrorHandler } from '../errors';
|
||||
import { Logger } from '../utils';
|
||||
import { I18nService } from '../interfaces';
|
||||
|
||||
/**
|
||||
* サービスのキー
|
||||
*/
|
||||
export const ServiceKeys = {
|
||||
ERROR_HANDLER: 'ErrorHandler',
|
||||
LOGGER_FACTORY: 'LoggerFactory',
|
||||
IS_DEVELOPMENT: 'IsDevelopment',
|
||||
I18N_SERVICE: 'I18nService'
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* サービスタイプのマッピング
|
||||
*/
|
||||
interface ServiceTypeMap {
|
||||
'ErrorHandler': ErrorHandler;
|
||||
'LoggerFactory': (component: string) => Logger;
|
||||
'IsDevelopment': boolean;
|
||||
'I18nService': I18nService;
|
||||
}
|
||||
|
||||
/**
|
||||
* サービスロケーター
|
||||
*
|
||||
* 初期化順序の問題を解決するためのシングルトン
|
||||
*/
|
||||
export class ServiceLocator {
|
||||
private static instance: ServiceLocator;
|
||||
private services: Map<string, unknown> = new Map();
|
||||
private serviceInitializers: Map<string, () => unknown> = new Map();
|
||||
|
||||
private constructor() {
|
||||
// Private constructor for singleton pattern
|
||||
}
|
||||
|
||||
/**
|
||||
* シングルトンインスタンスを取得
|
||||
*/
|
||||
static getInstance(): ServiceLocator {
|
||||
if (!ServiceLocator.instance) {
|
||||
ServiceLocator.instance = new ServiceLocator();
|
||||
}
|
||||
return ServiceLocator.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* サービスを登録
|
||||
*/
|
||||
register<K extends keyof ServiceTypeMap>(
|
||||
key: K,
|
||||
service: ServiceTypeMap[K]
|
||||
): void {
|
||||
this.services.set(key as string, service);
|
||||
}
|
||||
|
||||
/**
|
||||
* 遅延初期化サービスを登録
|
||||
*/
|
||||
registerLazy<K extends keyof ServiceTypeMap>(
|
||||
key: K,
|
||||
initializer: () => ServiceTypeMap[K]
|
||||
): void {
|
||||
this.serviceInitializers.set(key as string, initializer);
|
||||
}
|
||||
|
||||
/**
|
||||
* サービスを取得
|
||||
*/
|
||||
get<K extends keyof ServiceTypeMap>(
|
||||
key: K
|
||||
): ServiceTypeMap[K] {
|
||||
const keyStr = key as string;
|
||||
|
||||
// すでに初期化されている場合
|
||||
if (this.services.has(keyStr)) {
|
||||
return this.services.get(keyStr) as ServiceTypeMap[K];
|
||||
}
|
||||
|
||||
// 遅延初期化
|
||||
if (this.serviceInitializers.has(keyStr)) {
|
||||
const initializer = this.serviceInitializers.get(keyStr);
|
||||
if (initializer) {
|
||||
const service = initializer();
|
||||
this.services.set(keyStr, service);
|
||||
this.serviceInitializers.delete(keyStr);
|
||||
return service as ServiceTypeMap[K];
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Service not found: ${keyStr}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* サービスが登録されているかチェック
|
||||
*/
|
||||
has<K extends keyof ServiceTypeMap>(key: K): boolean {
|
||||
const keyStr = key as string;
|
||||
return this.services.has(keyStr) || this.serviceInitializers.has(keyStr);
|
||||
}
|
||||
|
||||
/**
|
||||
* すべてのサービスをクリア
|
||||
*/
|
||||
clear(): void {
|
||||
this.services.clear();
|
||||
this.serviceInitializers.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 特定のサービスを削除
|
||||
*/
|
||||
remove<K extends keyof ServiceTypeMap>(key: K): void {
|
||||
const keyStr = key as string;
|
||||
this.services.delete(keyStr);
|
||||
this.serviceInitializers.delete(keyStr);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* グローバルサービスロケーター
|
||||
*/
|
||||
export const serviceLocator = ServiceLocator.getInstance();
|
||||
|
||||
/**
|
||||
* ヘルパー関数: ErrorHandlerを取得
|
||||
*/
|
||||
export function getErrorHandler(): ErrorHandler {
|
||||
return serviceLocator.get(ServiceKeys.ERROR_HANDLER);
|
||||
}
|
||||
|
||||
/**
|
||||
* ヘルパー関数: Loggerを作成
|
||||
*/
|
||||
export function createServiceLogger(component: string): Logger {
|
||||
const loggerFactory = serviceLocator.get(ServiceKeys.LOGGER_FACTORY);
|
||||
return loggerFactory(component);
|
||||
}
|
||||
|
||||
/**
|
||||
* ヘルパー関数: 開発モードかチェック
|
||||
*/
|
||||
export function isDevelopmentMode(): boolean {
|
||||
return serviceLocator.get(ServiceKeys.IS_DEVELOPMENT);
|
||||
}
|
||||
|
||||
/**
|
||||
* ヘルパー関数: I18nServiceを取得
|
||||
*/
|
||||
export function getI18n(): I18nService {
|
||||
return serviceLocator.get(ServiceKeys.I18N_SERVICE);
|
||||
}
|
||||
3
src/services/index.ts
Normal file
3
src/services/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { serviceLocator, ServiceKeys, getI18n, createServiceLogger } from './ServiceLocator';
|
||||
export { getI18nService } from './I18nService';
|
||||
// export { I18nServiceImpl } from './I18nService'; // 未使用: 実装クラスは内部使用のみ
|
||||
519
src/settings/VoiceInputSettingTab.ts
Normal file
519
src/settings/VoiceInputSettingTab.ts
Normal file
|
|
@ -0,0 +1,519 @@
|
|||
import {
|
||||
App,
|
||||
PluginSettingTab,
|
||||
Setting,
|
||||
Notice
|
||||
} from 'obsidian';
|
||||
import VoiceInputPlugin from '../plugin';
|
||||
import { DEFAULT_SETTINGS } from '../interfaces';
|
||||
import { CorrectionEntry } from '../interfaces';
|
||||
import { SecurityUtils } from '../security';
|
||||
import { getI18n, createServiceLogger } from '../services';
|
||||
import type { I18nService, Locale } from '../interfaces';
|
||||
import { SUPPORTED_LOCALES } from '../interfaces';
|
||||
import { VIEW_TYPE_VOICE_INPUT } from '../views';
|
||||
import { Logger } from '../utils';
|
||||
import { patternsToString, stringToPatterns, migrateCorrectionEntries } from '../utils';
|
||||
import { DeferredViewHelper } from '../utils';
|
||||
|
||||
export class VoiceInputSettingTab extends PluginSettingTab {
|
||||
plugin: VoiceInputPlugin;
|
||||
private i18n: I18nService;
|
||||
private logger: Logger;
|
||||
private blurHandler: (() => void) | null = null;
|
||||
private apiKeyInput: HTMLInputElement | null = null;
|
||||
|
||||
constructor(app: App, plugin: VoiceInputPlugin) {
|
||||
super(app, plugin);
|
||||
this.plugin = plugin;
|
||||
this.i18n = getI18n();
|
||||
this.logger = createServiceLogger('VoiceInputSettingTab');
|
||||
}
|
||||
|
||||
display(): void {
|
||||
const { containerEl } = this;
|
||||
containerEl.empty();
|
||||
|
||||
// Use Obsidian's setHeading API instead of createEl('h2')
|
||||
new Setting(containerEl)
|
||||
.setHeading()
|
||||
.setName(this.i18n.t('ui.titles.settings'));
|
||||
|
||||
// Plugin Language Setting
|
||||
new Setting(containerEl)
|
||||
.setName(this.i18n.t('ui.settings.pluginLanguage'))
|
||||
.setDesc(this.i18n.t('ui.settings.pluginLanguageDesc'))
|
||||
.addDropdown(dropdown => dropdown
|
||||
.addOptions(Object.fromEntries(
|
||||
SUPPORTED_LOCALES.map(locale => [
|
||||
locale,
|
||||
locale === 'ja' ? 'Japanese' :
|
||||
locale === 'en' ? 'English' :
|
||||
locale === 'zh' ? 'Chinese' :
|
||||
locale === 'ko' ? 'Korean' : locale
|
||||
])
|
||||
))
|
||||
.setValue(this.plugin.settings.pluginLanguage)
|
||||
.onChange(async (value: Locale) => {
|
||||
this.plugin.settings.pluginLanguage = value;
|
||||
await this.plugin.saveSettings();
|
||||
this.i18n.setLocale(value);
|
||||
|
||||
// Refresh all open Voice Input views to show new language
|
||||
const refreshPromises = this.app.workspace.getLeavesOfType(VIEW_TYPE_VOICE_INPUT).map(async leaf => {
|
||||
const view = await DeferredViewHelper.safeGetVoiceInputView(leaf);
|
||||
if (view) {
|
||||
view.refreshUI();
|
||||
}
|
||||
});
|
||||
await Promise.all(refreshPromises);
|
||||
|
||||
// Refresh the settings tab to show new language
|
||||
this.display();
|
||||
}));
|
||||
|
||||
// OpenAI API Key
|
||||
const apiKeySetting = new Setting(containerEl)
|
||||
.setName(this.i18n.t('ui.settings.apiKey'))
|
||||
.setDesc(this.i18n.t('ui.settings.apiKeyDesc'));
|
||||
|
||||
apiKeySetting.addText(text => {
|
||||
// Retrieve stored API key (now stored in plain text internally)
|
||||
const decryptedApiKey = this.plugin.settings.openaiApiKey;
|
||||
|
||||
const textComponent = text
|
||||
.setPlaceholder(this.i18n.t('ui.placeholders.apiKey'))
|
||||
.setValue(decryptedApiKey ? SecurityUtils.maskAPIKey(decryptedApiKey) : '')
|
||||
.onChange(async (value) => {
|
||||
// マスクされた値は無視
|
||||
if (value && !value.includes('*')) {
|
||||
if (!SecurityUtils.validateOpenAIAPIKey(value)) {
|
||||
apiKeySetting.descEl.addClass('setting-error');
|
||||
apiKeySetting.setDesc(this.i18n.t('error.api.invalidKeyDetail'));
|
||||
} else {
|
||||
apiKeySetting.descEl.removeClass('setting-error');
|
||||
apiKeySetting.setDesc(this.i18n.t('ui.settings.apiKeyDesc'));
|
||||
// Store API key (will be encrypted on save)
|
||||
this.plugin.settings.openaiApiKey = value;
|
||||
await this.plugin.saveSettings();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Store input element reference
|
||||
this.apiKeyInput = textComponent.inputEl;
|
||||
|
||||
// Create blur handler to mask the API key display in the input field when focus is lost,
|
||||
// while preserving the actual stored value. This avoids exposing the key in the UI but does not modify the stored key.
|
||||
this.blurHandler = () => {
|
||||
const decryptedApiKey = this.plugin.settings.openaiApiKey;
|
||||
if (decryptedApiKey) {
|
||||
textComponent.setValue(SecurityUtils.maskAPIKey(decryptedApiKey));
|
||||
}
|
||||
};
|
||||
|
||||
// Add blur event listener only - no focus handler for security
|
||||
this.apiKeyInput.addEventListener('blur', this.blurHandler);
|
||||
|
||||
return textComponent;
|
||||
})
|
||||
.addButton(button => button
|
||||
.setButtonText(this.i18n.t('ui.buttons.connectionTest'))
|
||||
.onClick(async () => {
|
||||
const decryptedApiKey = this.plugin.settings.openaiApiKey;
|
||||
if (!decryptedApiKey) {
|
||||
new Notice(this.i18n.t('notification.warning.enterApiKey'));
|
||||
return;
|
||||
}
|
||||
|
||||
button.setButtonText(this.i18n.t('ui.buttons.testing'));
|
||||
button.setDisabled(true);
|
||||
|
||||
try {
|
||||
const result = await SecurityUtils.testOpenAIAPIKey(decryptedApiKey);
|
||||
|
||||
if (result.valid) {
|
||||
new Notice(this.i18n.t('notification.success.apiKeyValid'));
|
||||
button.setButtonText(this.i18n.t('ui.buttons.testSuccess'));
|
||||
button.setCta();
|
||||
|
||||
// 3秒後に元に戻す
|
||||
setTimeout(() => {
|
||||
button.setButtonText(this.i18n.t('ui.buttons.connectionTest'));
|
||||
button.removeCta();
|
||||
button.setDisabled(false);
|
||||
}, 3000);
|
||||
} else {
|
||||
new Notice(`${result.error || this.i18n.t('notification.error.apiKeyInvalid')}`);
|
||||
button.setButtonText(this.i18n.t('ui.buttons.testFailed'));
|
||||
|
||||
// 3秒後に元に戻す
|
||||
setTimeout(() => {
|
||||
button.setButtonText(this.i18n.t('ui.buttons.connectionTest'));
|
||||
button.setDisabled(false);
|
||||
}, 3000);
|
||||
}
|
||||
} catch (error) {
|
||||
new Notice(this.i18n.t('notification.error.testError'));
|
||||
button.setButtonText(this.i18n.t('ui.buttons.connectionTest'));
|
||||
button.setDisabled(false);
|
||||
}
|
||||
}));
|
||||
|
||||
// Transcription Model
|
||||
new Setting(containerEl)
|
||||
.setName(this.i18n.t('ui.settings.transcriptionModel'))
|
||||
.setDesc(this.i18n.t('ui.settings.transcriptionModelDesc'))
|
||||
.addDropdown(dropdown => dropdown
|
||||
.addOption('gpt-4o-transcribe', this.i18n.t('ui.options.modelFull'))
|
||||
.addOption('gpt-4o-mini-transcribe', this.i18n.t('ui.options.modelMini'))
|
||||
.setValue(this.plugin.settings.transcriptionModel)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.transcriptionModel = value as 'gpt-4o-transcribe' | 'gpt-4o-mini-transcribe';
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
|
||||
// AI Post-processing Toggle and Model Selection
|
||||
new Setting(containerEl)
|
||||
.setName(this.i18n.t('ui.settings.aiPostProcessing'))
|
||||
.setDesc(this.i18n.t('ui.settings.aiPostProcessingDesc'))
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.plugin.settings.enableTranscriptionCorrection)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.enableTranscriptionCorrection = value;
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
|
||||
// Maximum Recording Duration
|
||||
const maxRecordingSetting = new Setting(containerEl)
|
||||
.setName(this.i18n.t('ui.settings.maxRecordingDuration'))
|
||||
.setDesc(this.i18n.t('ui.settings.maxRecordingDurationDesc'));
|
||||
|
||||
// Add text display for current value
|
||||
const durationText = maxRecordingSetting.controlEl.createEl('span', {
|
||||
cls: 'setting-duration-text',
|
||||
text: this.formatDuration(this.plugin.settings.maxRecordingSeconds)
|
||||
});
|
||||
|
||||
maxRecordingSetting
|
||||
.addSlider(slider => slider
|
||||
.setLimits(30, 600, 30) // 30秒〜10分、30秒刻み
|
||||
.setValue(this.plugin.settings.maxRecordingSeconds)
|
||||
.setDynamicTooltip()
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.maxRecordingSeconds = value;
|
||||
durationText.setText(this.formatDuration(value));
|
||||
await this.plugin.saveSettings();
|
||||
}))
|
||||
.addExtraButton(button => button
|
||||
.setIcon('reset')
|
||||
.setTooltip(this.i18n.t('ui.buttons.reset'))
|
||||
.onClick(async () => {
|
||||
this.plugin.settings.maxRecordingSeconds = DEFAULT_SETTINGS.maxRecordingSeconds;
|
||||
await this.plugin.saveSettings();
|
||||
this.display(); // Refresh UI
|
||||
}));
|
||||
|
||||
// Dictionary (Unified table editor) - Only show for Japanese
|
||||
if (this.plugin.settings.pluginLanguage === 'ja') {
|
||||
new Setting(containerEl)
|
||||
.setName(this.i18n.t('ui.settings.customDictionary'))
|
||||
.setDesc(this.i18n.t('ui.settings.customDictionaryDesc'));
|
||||
|
||||
// Create table container for dictionary tables
|
||||
const tableContainer = containerEl.createDiv('dictionary-table-container');
|
||||
|
||||
// Definite Corrections Section
|
||||
tableContainer.createEl('h4', { text: this.i18n.t('ui.settings.dictionaryDefinite') });
|
||||
this.createCorrectionTable(
|
||||
tableContainer,
|
||||
this.plugin.settings.customDictionary.definiteCorrections,
|
||||
false
|
||||
);
|
||||
|
||||
// Import/Export buttons
|
||||
new Setting(containerEl)
|
||||
.setName(this.i18n.t('ui.settings.dictionaryImportExport'))
|
||||
.setDesc(this.i18n.t('ui.settings.dictionaryImportExportDesc'))
|
||||
.addButton(button => button
|
||||
.setButtonText(this.i18n.t('ui.buttons.export'))
|
||||
.onClick(() => this.exportDictionary()))
|
||||
.addButton(button => button
|
||||
.setButtonText(this.i18n.t('ui.buttons.import'))
|
||||
.onClick(() => this.importDictionary()));
|
||||
}
|
||||
|
||||
// Debug Settings Section - Removed as per ai-transcriber pattern
|
||||
// containerEl.createEl('h3', { text: this.i18n.t('ui.titles.debugSettings') || 'Debug Settings' });
|
||||
|
||||
// Debug Mode Toggle - Removed as per ai-transcriber pattern (controlled via data.json only)
|
||||
// new Setting(containerEl)
|
||||
// .setName(this.i18n.t('ui.settings.debugMode') || 'Debug Mode')
|
||||
// .setDesc(this.i18n.t('ui.settings.debugModeDesc') || 'Enable detailed logging for development and troubleshooting')
|
||||
// .addToggle(toggle => toggle
|
||||
// .setValue(this.plugin.settings.debugMode)
|
||||
// .onChange(async (value) => {
|
||||
// this.plugin.settings.debugMode = value;
|
||||
// await this.plugin.saveSettings();
|
||||
// this.logger.info('Debug mode changed', { debugMode: value });
|
||||
// }));
|
||||
|
||||
// Log Level Dropdown (only shown when debug mode is enabled) - Removed as per ai-transcriber pattern
|
||||
// if (this.plugin.settings.debugMode) {
|
||||
// new Setting(containerEl)
|
||||
// .setName(this.i18n.t('ui.settings.logLevel') || 'Log Level')
|
||||
// .setDesc(this.i18n.t('ui.settings.logLevelDesc') || 'Set the minimum log level to display')
|
||||
// .addDropdown(dropdown => dropdown
|
||||
// .addOption('0', 'ERROR')
|
||||
// .addOption('1', 'WARN')
|
||||
// .addOption('2', 'INFO')
|
||||
// .addOption('3', 'DEBUG')
|
||||
// .addOption('4', 'TRACE')
|
||||
// .setValue(String(this.plugin.settings.logLevel))
|
||||
// .onChange(async (value) => {
|
||||
// this.plugin.settings.logLevel = parseInt(value) as LogLevel;
|
||||
// await this.plugin.saveSettings();
|
||||
// this.logger.info('Log level changed', { logLevel: this.plugin.settings.logLevel });
|
||||
// }));
|
||||
// }
|
||||
|
||||
}
|
||||
|
||||
hide() {
|
||||
// Clean up event listeners
|
||||
if (this.apiKeyInput && this.blurHandler) {
|
||||
this.apiKeyInput.removeEventListener('blur', this.blurHandler);
|
||||
this.apiKeyInput = null;
|
||||
this.blurHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format duration in seconds to human-readable string
|
||||
*/
|
||||
private formatDuration(seconds: number): string {
|
||||
if (seconds < 60) {
|
||||
return `${seconds}${this.i18n.t('ui.units.seconds')}`;
|
||||
}
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
if (remainingSeconds === 0) {
|
||||
return `${minutes}${this.i18n.t('ui.units.minutes')}`;
|
||||
}
|
||||
return `${minutes}${this.i18n.t('ui.units.minutes')} ${remainingSeconds}${this.i18n.t('ui.units.seconds')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format duration in milliseconds to human-readable string
|
||||
*/
|
||||
private formatDurationMs(milliseconds: number): string {
|
||||
const seconds = milliseconds / 1000;
|
||||
if (seconds < 1) {
|
||||
return `${(Math.round(seconds * 10) / 10).toFixed(1)}${this.i18n.t('ui.units.seconds')}`;
|
||||
}
|
||||
return `${seconds.toFixed(1)}${this.i18n.t('ui.units.seconds')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create correction table for dictionary entries
|
||||
*/
|
||||
private createCorrectionTable(
|
||||
container: HTMLElement,
|
||||
entries: CorrectionEntry[],
|
||||
isContextual: boolean,
|
||||
isReadOnly = false
|
||||
): HTMLTableElement {
|
||||
const table = container.createEl('table', { cls: 'dictionary-table' });
|
||||
|
||||
// Create header
|
||||
const thead = table.createEl('thead');
|
||||
const headerRow = thead.createEl('tr');
|
||||
headerRow.createEl('th', { text: this.i18n.t('ui.labels.from') });
|
||||
headerRow.createEl('th', { text: this.i18n.t('ui.labels.to') });
|
||||
if (isContextual) {
|
||||
headerRow.createEl('th', { text: this.i18n.t('ui.labels.context') });
|
||||
}
|
||||
headerRow.createEl('th', { text: '', cls: 'action-column' });
|
||||
|
||||
// Create body
|
||||
const tbody = table.createEl('tbody');
|
||||
this.renderTableRows(tbody, entries, isContextual, isReadOnly);
|
||||
|
||||
// Add new entry button (only for editable tables)
|
||||
if (!isReadOnly) {
|
||||
const addButton = container.createEl('button', {
|
||||
text: '+',
|
||||
cls: 'dictionary-add-button'
|
||||
});
|
||||
addButton.onclick = () => {
|
||||
const newEntry = { from: [''], to: '' };
|
||||
entries.push(newEntry);
|
||||
this.renderTableRows(tbody, entries, isContextual, isReadOnly);
|
||||
this.saveDictionary();
|
||||
};
|
||||
}
|
||||
|
||||
return table;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render table rows
|
||||
*/
|
||||
private renderTableRows(tbody: HTMLElement, entries: CorrectionEntry[], isContextual: boolean, isReadOnly = false) {
|
||||
tbody.empty();
|
||||
|
||||
entries.forEach((entry, index) => {
|
||||
const row = tbody.createEl('tr');
|
||||
|
||||
// From field (multiple patterns, comma-separated)
|
||||
const fromCell = row.createEl('td');
|
||||
if (isReadOnly) {
|
||||
fromCell.textContent = patternsToString(entry.from);
|
||||
} else {
|
||||
const fromInput = fromCell.createEl('input', {
|
||||
type: 'text',
|
||||
value: patternsToString(entry.from),
|
||||
placeholder: this.i18n.t('ui.labels.fromMultiple') || 'パターン1, パターン2, ...'
|
||||
});
|
||||
fromInput.onchange = () => {
|
||||
entry.from = stringToPatterns(fromInput.value);
|
||||
this.saveDictionary();
|
||||
};
|
||||
}
|
||||
|
||||
// To field
|
||||
const toCell = row.createEl('td');
|
||||
if (isReadOnly) {
|
||||
toCell.textContent = entry.to;
|
||||
} else {
|
||||
const toInput = toCell.createEl('input', {
|
||||
type: 'text',
|
||||
value: entry.to,
|
||||
placeholder: this.i18n.t('ui.labels.to')
|
||||
});
|
||||
toInput.onchange = () => {
|
||||
entry.to = toInput.value;
|
||||
this.saveDictionary();
|
||||
};
|
||||
}
|
||||
|
||||
// Delete button
|
||||
const actionCell = row.createEl('td', { cls: 'action-column' });
|
||||
if (!isReadOnly) {
|
||||
const deleteButton = actionCell.createEl('button', {
|
||||
text: '×',
|
||||
cls: 'dictionary-delete-button'
|
||||
});
|
||||
deleteButton.onclick = () => {
|
||||
entries.splice(index, 1);
|
||||
this.renderTableRows(tbody, entries, isContextual, isReadOnly);
|
||||
this.saveDictionary();
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save dictionary changes
|
||||
*/
|
||||
private async saveDictionary() {
|
||||
await this.plugin.saveSettings();
|
||||
|
||||
// Update all open Voice Input views
|
||||
const updatePromises = this.app.workspace.getLeavesOfType(VIEW_TYPE_VOICE_INPUT).map(async leaf => {
|
||||
const view = await DeferredViewHelper.safeGetVoiceInputView(leaf);
|
||||
if (view?.actions?.updateTranscriptionService) {
|
||||
view.actions.updateTranscriptionService();
|
||||
}
|
||||
});
|
||||
await Promise.all(updatePromises);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export dictionary as JSON
|
||||
*/
|
||||
private exportDictionary() {
|
||||
try {
|
||||
// Check if dictionary has data
|
||||
const hasData = this.plugin.settings.customDictionary.definiteCorrections.length > 0;
|
||||
|
||||
if (!hasData) {
|
||||
new Notice(this.i18n.t('notification.error.noDictionaryData'));
|
||||
return;
|
||||
}
|
||||
|
||||
const data = {
|
||||
version: '1.0',
|
||||
definiteCorrections: this.plugin.settings.customDictionary.definiteCorrections,
|
||||
exportedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
const jsonStr = JSON.stringify(data, null, 2);
|
||||
const blob = new Blob([jsonStr], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `voice-input-dictionary-${new Date().toISOString().split('T')[0]}.json`;
|
||||
|
||||
// Add link to document temporarily
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
|
||||
// Clean up
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}, 100);
|
||||
|
||||
// Note: We cannot detect if the user cancelled the save dialog
|
||||
// So we don't show a success message here
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to export dictionary', error);
|
||||
new Notice(this.i18n.t('notification.error.dictionaryExportFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import dictionary from JSON
|
||||
*/
|
||||
private importDictionary() {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.json';
|
||||
|
||||
input.onchange = async (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
const text = await file.text();
|
||||
const data = JSON.parse(text);
|
||||
|
||||
// Validate structure
|
||||
if (!data.definiteCorrections || !Array.isArray(data.definiteCorrections)) {
|
||||
throw new Error('Invalid dictionary format: missing definiteCorrections');
|
||||
}
|
||||
|
||||
// Map old categories to new ones (no longer needed, but kept for reference)
|
||||
|
||||
// Migrate to new format
|
||||
const migratedEntries = migrateCorrectionEntries(data.definiteCorrections);
|
||||
|
||||
// Update settings with migrated data
|
||||
this.plugin.settings.customDictionary = {
|
||||
definiteCorrections: migratedEntries
|
||||
};
|
||||
|
||||
await this.plugin.saveSettings();
|
||||
this.display(); // Refresh UI
|
||||
|
||||
new Notice(this.i18n.t('notification.success.dictionaryImported'));
|
||||
} catch (error) {
|
||||
new Notice(this.i18n.t('notification.error.dictionaryImportFailed') + error.message);
|
||||
}
|
||||
};
|
||||
|
||||
input.click();
|
||||
}
|
||||
}
|
||||
1
src/settings/index.ts
Normal file
1
src/settings/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { VoiceInputSettingTab } from './VoiceInputSettingTab';
|
||||
450
src/styles/voice-input.css
Normal file
450
src/styles/voice-input.css
Normal file
|
|
@ -0,0 +1,450 @@
|
|||
.voice-input-view {
|
||||
padding: 16px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.voice-input-title {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* Title bar with settings button */
|
||||
.voice-input-title-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.voice-input-settings-button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
color: var(--text-muted);
|
||||
transition: background-color 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.voice-input-settings-button:hover {
|
||||
background: var(--background-modifier-hover);
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.voice-input-visualizer {
|
||||
margin-bottom: 8px;
|
||||
height: 60px;
|
||||
background: var(--background-secondary);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* ステータス表示エリア */
|
||||
.voice-input-status-container {
|
||||
margin-bottom: 16px;
|
||||
text-align: center;
|
||||
padding: 8px;
|
||||
min-height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.voice-input-status-text {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.voice-input-status-text.processing {
|
||||
color: var(--interactive-accent);
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
.voice-input-status-text.error {
|
||||
color: var(--text-error);
|
||||
}
|
||||
|
||||
.voice-input-text-container {
|
||||
flex: 1;
|
||||
margin-bottom: 16px;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.voice-input-textarea {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 4px;
|
||||
background: var(--background-primary-alt);
|
||||
color: var(--text-normal);
|
||||
font-family: var(--font-text);
|
||||
font-size: 14px;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.voice-input-record-container {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.voice-input-record-button {
|
||||
width: 100%;
|
||||
padding: 32px 20px;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
background: var(--interactive-accent);
|
||||
color: var(--text-on-accent);
|
||||
min-height: 80px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
/* Full-width cancel button - matches height of 2 button rows */
|
||||
.voice-input-cancel-button-full {
|
||||
width: 100%;
|
||||
/* Matching 2 button rows with exact calculation:
|
||||
Button height = padding(24*2) + line-height(~20px) = 68px
|
||||
2 rows + gap = 68px + 12px + 68px = 148px
|
||||
Cancel button = padding needed for 148px total height */
|
||||
padding: 54px 12px;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
background: #f9e79f; /* Soft yellow */
|
||||
color: var(--text-normal);
|
||||
border: 1px solid #f4d03f;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
margin-bottom: 12px;
|
||||
display: none; /* Default hidden, controlled by CSS classes */
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.voice-input-cancel-button-full:hover {
|
||||
background: #f4d03f; /* Slightly darker yellow on hover */
|
||||
}
|
||||
|
||||
|
||||
.voice-input-record-button:hover {
|
||||
background: var(--interactive-accent-hover);
|
||||
}
|
||||
|
||||
.voice-input-record-button.recording {
|
||||
background: var(--text-error);
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { opacity: 1; }
|
||||
50% { opacity: 0.8; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
.voice-input-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.voice-input-button-group {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.voice-input-action-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.voice-input-secondary-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.voice-input-button-large {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.voice-input-button {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
padding: 24px 12px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: var(--interactive-normal);
|
||||
color: var(--text-normal);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: background-color 0.2s;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Removed hover effects from regular buttons - only recording and primary buttons should have hover effects */
|
||||
|
||||
.voice-input-button-primary {
|
||||
background: var(--interactive-accent);
|
||||
color: var(--text-on-accent);
|
||||
}
|
||||
|
||||
.voice-input-button-primary:hover {
|
||||
background: var(--interactive-accent-hover);
|
||||
}
|
||||
|
||||
.voice-input-button.recording {
|
||||
background: var(--text-error);
|
||||
color: var(--text-on-accent);
|
||||
}
|
||||
|
||||
.voice-input-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.voice-input-button-medium {
|
||||
padding: 24px 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.voice-input-button-icon {
|
||||
display: inline-block;
|
||||
font-size: 16px;
|
||||
margin-right: 6px;
|
||||
vertical-align: -2px;
|
||||
}
|
||||
|
||||
.voice-input-button-text {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
|
||||
.setting-error {
|
||||
color: var(--text-error);
|
||||
}
|
||||
|
||||
.setting-duration-text {
|
||||
display: inline-block;
|
||||
min-width: 3em;
|
||||
text-align: right;
|
||||
margin-left: 8px;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.setting-item-description {
|
||||
padding: 8px 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* Settings panel in right pane */
|
||||
.voice-input-settings-panel {
|
||||
margin-bottom: 16px;
|
||||
border-bottom: 1px solid var(--background-modifier-border);
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.voice-input-inline-setting {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.voice-input-inline-setting:last-child {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
/* Dictionary Table Styles */
|
||||
.dictionary-table-container {
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
.dictionary-table-container h4 {
|
||||
margin: 1em 0 0.5em;
|
||||
font-size: 0.9em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dictionary-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 1em;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.dictionary-table th,
|
||||
.dictionary-table td {
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
padding: 6px 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.dictionary-table th {
|
||||
background-color: var(--background-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dictionary-table input[type="text"],
|
||||
.dictionary-table input[type="number"],
|
||||
.dictionary-table select {
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.dictionary-table input[type="text"]:focus,
|
||||
.dictionary-table input[type="number"]:focus,
|
||||
.dictionary-table select:focus {
|
||||
outline: 1px solid var(--interactive-accent);
|
||||
background: var(--background-primary);
|
||||
}
|
||||
|
||||
.dictionary-table input[type="number"] {
|
||||
max-width: 60px;
|
||||
}
|
||||
|
||||
.dictionary-table .action-column {
|
||||
width: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dictionary-delete-button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-error);
|
||||
cursor: pointer;
|
||||
font-size: 1.2em;
|
||||
padding: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.dictionary-delete-button:hover {
|
||||
background: var(--background-modifier-error);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.dictionary-add-button {
|
||||
background: var(--interactive-accent);
|
||||
color: var(--text-on-accent);
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
padding: 4px 12px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.dictionary-add-button:hover {
|
||||
background: var(--interactive-accent-hover);
|
||||
}
|
||||
|
||||
/* Dictionary Tabs */
|
||||
.dictionary-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 1em;
|
||||
border-bottom: 2px solid var(--background-modifier-border);
|
||||
}
|
||||
|
||||
.dictionary-tab {
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 8px 16px;
|
||||
font-size: 0.9em;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
margin-bottom: -2px;
|
||||
}
|
||||
|
||||
.dictionary-tab:hover {
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.dictionary-tab.active {
|
||||
color: var(--text-normal);
|
||||
border-bottom-color: var(--interactive-accent);
|
||||
}
|
||||
|
||||
/* Read-only table styles */
|
||||
.dictionary-table tr.read-only td {
|
||||
background: var(--background-secondary-alt);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Utility classes for JavaScript style replacements */
|
||||
.voice-input-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.voice-input-flex {
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
/* Audio Visualizer Styles */
|
||||
.audio-visualizer-canvas {
|
||||
width: calc(100% - 4px);
|
||||
height: 56px;
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
display: block;
|
||||
margin: 2px;
|
||||
}
|
||||
|
||||
.audio-level-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
height: 60px;
|
||||
background: transparent;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.audio-level-container {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
background: var(--background-modifier-border);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.audio-level-bar {
|
||||
height: 100%;
|
||||
width: var(--audio-level-width, 0%);
|
||||
background: linear-gradient(90deg, var(--interactive-accent), var(--interactive-accent-hover));
|
||||
transition: width 0.05s ease-out;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.audio-vad-indicator {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.audio-vad-indicator.speech {
|
||||
background: var(--color-green);
|
||||
}
|
||||
|
||||
/* Form element styles */
|
||||
.voice-input-textarea-wide {
|
||||
width: 100% !important;
|
||||
min-height: 100px !important;
|
||||
}
|
||||
4
src/types/index.ts
Normal file
4
src/types/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export { getObsidianLocale, hasInternalSettingAPI, hasWebkitAudioContext } from './obsidian-internal';
|
||||
export type { AppInternal, VaultInternal, WindowWithWebkitAudio } from './obsidian-internal';
|
||||
export type { FvadModule, FvadModuleFactory, WindowWithFvad } from './webrtc-vad';
|
||||
export { hasFvadModule } from './webrtc-vad';
|
||||
62
src/types/obsidian-internal.ts
Normal file
62
src/types/obsidian-internal.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
/**
|
||||
* Type definitions for Obsidian internal APIs
|
||||
* These extend the official Obsidian API types to include internal/undocumented APIs
|
||||
*/
|
||||
|
||||
import { App, Vault, moment } from 'obsidian';
|
||||
|
||||
/**
|
||||
* Extended App interface with internal properties
|
||||
*/
|
||||
export interface AppInternal extends App {
|
||||
setting?: {
|
||||
open(): void;
|
||||
openTabById(id: string): void;
|
||||
};
|
||||
locale?: string;
|
||||
vault: VaultInternal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extended Vault interface with internal config
|
||||
*/
|
||||
export interface VaultInternal extends Vault {
|
||||
config?: {
|
||||
locale?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* WebKit Audio Context for cross-browser compatibility
|
||||
*/
|
||||
export interface WindowWithWebkitAudio extends Window {
|
||||
webkitAudioContext?: new (options?: AudioContextOptions) => AudioContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if app has internal setting API
|
||||
*/
|
||||
export function hasInternalSettingAPI(app: App): app is AppInternal {
|
||||
return 'setting' in app && typeof (app as AppInternal).setting?.open === 'function';
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if window has webkit audio context
|
||||
*/
|
||||
export function hasWebkitAudioContext(window: Window): window is WindowWithWebkitAudio {
|
||||
return 'webkitAudioContext' in window;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely get Obsidian locale with fallbacks
|
||||
*/
|
||||
export function getObsidianLocale(app: App): string {
|
||||
const appInternal = app as AppInternal;
|
||||
|
||||
// Try to get locale from app configuration first
|
||||
const locale = appInternal.vault?.config?.locale ||
|
||||
appInternal.locale ||
|
||||
moment.locale() || // Use Obsidian's provided moment instance
|
||||
'en';
|
||||
return locale.toLowerCase();
|
||||
}
|
||||
42
src/types/webrtc-vad.ts
Normal file
42
src/types/webrtc-vad.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
/**
|
||||
* Type definitions for WebRTC VAD WebAssembly module
|
||||
*/
|
||||
|
||||
/**
|
||||
* WebRTC VAD module interface
|
||||
*/
|
||||
export interface FvadModule {
|
||||
_fvad_new(): number;
|
||||
_fvad_free(handle: number): void;
|
||||
_fvad_set_mode(handle: number, mode: number): number;
|
||||
_fvad_set_sample_rate(handle: number, sampleRate: number): number;
|
||||
_fvad_process(handle: number, audioPtr: number, frameSize: number): number;
|
||||
_malloc(size: number): number;
|
||||
_free(ptr: number): void;
|
||||
HEAP16: { buffer: ArrayBuffer };
|
||||
}
|
||||
|
||||
/**
|
||||
* WebAssembly module factory function
|
||||
*/
|
||||
export type FvadModuleFactory = (options?: {
|
||||
wasmBinary?: Uint8Array;
|
||||
instantiateWasm?: (
|
||||
imports: WebAssembly.Imports,
|
||||
successCallback: (instance: WebAssembly.Instance) => void
|
||||
) => void;
|
||||
}) => Promise<FvadModule>;
|
||||
|
||||
/**
|
||||
* Global window interface with fvad module
|
||||
*/
|
||||
export interface WindowWithFvad extends Window {
|
||||
__fvadModule?: FvadModuleFactory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if window has fvad module
|
||||
*/
|
||||
export function hasFvadModule(window: Window): window is WindowWithFvad {
|
||||
return '__fvadModule' in window && typeof (window as WindowWithFvad).__fvadModule === 'function';
|
||||
}
|
||||
81
src/utils/DeferredViewHelper.ts
Normal file
81
src/utils/DeferredViewHelper.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { WorkspaceLeaf, View } from 'obsidian';
|
||||
import type { VoiceInputView } from '../views';
|
||||
import { Logger } from './Logger';
|
||||
|
||||
/**
|
||||
* Helper utility for safely working with DeferredViews in Obsidian 1.7.2+
|
||||
* Provides backward compatibility with older Obsidian versions
|
||||
*/
|
||||
export class DeferredViewHelper {
|
||||
/**
|
||||
* Safely get a view from a leaf, handling deferred views if present
|
||||
* @param leaf The workspace leaf to get the view from
|
||||
* @returns The view if available, null if deferred and couldn't be loaded
|
||||
*/
|
||||
static async safeGetView<T extends View>(leaf: WorkspaceLeaf): Promise<T | null> {
|
||||
try {
|
||||
// Check if the leaf has the isDeferred property (Obsidian 1.7.2+)
|
||||
if ('isDeferred' in leaf && typeof leaf.isDeferred === 'boolean') {
|
||||
if (leaf.isDeferred && 'loadIfDeferred' in leaf && typeof leaf.loadIfDeferred === 'function') {
|
||||
// Load the deferred view
|
||||
await leaf.loadIfDeferred();
|
||||
}
|
||||
}
|
||||
|
||||
// Return the view if it exists and is of the expected type
|
||||
return leaf.view as T | null;
|
||||
} catch (error) {
|
||||
// Use logger for warning - will be controlled by debugMode in production
|
||||
Logger.getInstance().warn('DeferredViewHelper: Failed to load deferred view', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely get a VoiceInputView from a leaf
|
||||
* @param leaf The workspace leaf to get the view from
|
||||
* @returns The VoiceInputView if available, null otherwise
|
||||
*/
|
||||
static async safeGetVoiceInputView(leaf: WorkspaceLeaf): Promise<VoiceInputView | null> {
|
||||
const view = await this.safeGetView<View>(leaf);
|
||||
// Narrow by constructor name to the concrete VoiceInputView
|
||||
return (view && view.constructor?.name === 'VoiceInputView')
|
||||
? (view as unknown as VoiceInputView)
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a leaf is deferred (Obsidian 1.7.2+ only)
|
||||
* @param leaf The workspace leaf to check
|
||||
* @returns true if deferred, false otherwise (including older Obsidian versions)
|
||||
*/
|
||||
static isDeferred(leaf: WorkspaceLeaf): boolean {
|
||||
return 'isDeferred' in leaf && typeof leaf.isDeferred === 'boolean' && leaf.isDeferred;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely reveal a leaf and ensure it's loaded
|
||||
* @param leaf The workspace leaf to reveal
|
||||
* @param workspace The workspace instance
|
||||
*/
|
||||
static async safeRevealLeaf(leaf: WorkspaceLeaf, workspace: { revealLeaf: (leaf: WorkspaceLeaf) => unknown }): Promise<void> {
|
||||
try {
|
||||
// Use revealLeaf and await it if it returns a promise (Obsidian 1.7.2+)
|
||||
const result = workspace.revealLeaf(leaf);
|
||||
// Handle both sync and async revealLeaf
|
||||
if (result && typeof (result as Promise<unknown>).then === 'function') {
|
||||
await (result as Promise<unknown>);
|
||||
}
|
||||
} catch (error) {
|
||||
// Use logger for warning - will be controlled by debugMode in production
|
||||
Logger.getInstance().warn('DeferredViewHelper: Failed to reveal leaf', error);
|
||||
// Fallback: try calling revealLeaf without awaiting (older versions)
|
||||
try {
|
||||
workspace.revealLeaf(leaf);
|
||||
} catch (fallbackError) {
|
||||
// If even the fallback fails, just log and continue
|
||||
Logger.getInstance().warn('DeferredViewHelper: Fallback reveal also failed', fallbackError);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
245
src/utils/Logger.ts
Normal file
245
src/utils/Logger.ts
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
/**
|
||||
* Centralized logging utility for debug output
|
||||
* Provides consistent formatting and conditional output based on debugMode
|
||||
*/
|
||||
|
||||
export enum LogLevel {
|
||||
ERROR = 0,
|
||||
WARN = 1,
|
||||
INFO = 2,
|
||||
DEBUG = 3,
|
||||
TRACE = 4
|
||||
}
|
||||
|
||||
export interface LoggerConfig {
|
||||
debugMode: boolean;
|
||||
logLevel?: LogLevel;
|
||||
prefix?: string;
|
||||
}
|
||||
|
||||
export class Logger {
|
||||
private static instance: Logger | null = null;
|
||||
private config: LoggerConfig = {
|
||||
debugMode: false,
|
||||
logLevel: LogLevel.INFO,
|
||||
prefix: '[Voice Transcription]'
|
||||
};
|
||||
private moduleLoggers: Map<string, Logger> = new Map();
|
||||
private static readonly MAX_MODULE_LOGGERS = 100; // Prevent unbounded growth
|
||||
|
||||
private constructor(config?: Partial<LoggerConfig>) {
|
||||
if (config) {
|
||||
this.updateConfig(config);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create the singleton logger instance
|
||||
*/
|
||||
static getInstance(config?: Partial<LoggerConfig>): Logger {
|
||||
if (!Logger.instance) {
|
||||
Logger.instance = new Logger(config);
|
||||
} else if (config) {
|
||||
Logger.instance.updateConfig(config);
|
||||
}
|
||||
return Logger.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a logger for a specific module/component
|
||||
*/
|
||||
static getLogger(moduleName: string): Logger {
|
||||
const mainLogger = Logger.getInstance();
|
||||
const existing = mainLogger.moduleLoggers.get(moduleName);
|
||||
if (!existing) {
|
||||
// Check size limit to prevent memory leaks
|
||||
if (mainLogger.moduleLoggers.size >= Logger.MAX_MODULE_LOGGERS) {
|
||||
// Clear oldest entries (first 10) when limit is reached
|
||||
const entries = Array.from(mainLogger.moduleLoggers.entries());
|
||||
entries.slice(0, 10).forEach(([key]) => {
|
||||
mainLogger.moduleLoggers.delete(key);
|
||||
});
|
||||
}
|
||||
|
||||
const moduleLogger = new Logger({
|
||||
debugMode: mainLogger.config.debugMode,
|
||||
logLevel: mainLogger.config.logLevel,
|
||||
prefix: `${mainLogger.config.prefix} [${moduleName}]`
|
||||
});
|
||||
mainLogger.moduleLoggers.set(moduleName, moduleLogger);
|
||||
return moduleLogger;
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update logger configuration
|
||||
*/
|
||||
updateConfig(config: Partial<LoggerConfig>): void {
|
||||
this.config = { ...this.config, ...config };
|
||||
// Update all module loggers, preserving their prefixes
|
||||
this.moduleLoggers.forEach(logger => {
|
||||
logger.updateConfig({
|
||||
debugMode: this.config.debugMode,
|
||||
logLevel: this.config.logLevel
|
||||
// Note: prefix is intentionally not updated to preserve module-specific prefixes
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if logging is enabled for a given level
|
||||
*/
|
||||
private shouldLog(level: LogLevel): boolean {
|
||||
if (!this.config.debugMode) {
|
||||
// In production mode, only show errors and warnings
|
||||
return level <= LogLevel.WARN;
|
||||
}
|
||||
return level <= (this.config.logLevel ?? LogLevel.INFO);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the log message with timestamp and prefix
|
||||
*/
|
||||
private formatMessage(level: LogLevel, message: string): string {
|
||||
const timestamp = new Date().toISOString().split('T')[1].slice(0, 12);
|
||||
return `${timestamp} ${LogLevel[level].padEnd(5)} ${this.config.prefix} ${message}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an error message
|
||||
*/
|
||||
error(message: string, error?: unknown): void {
|
||||
if (this.shouldLog(LogLevel.ERROR)) {
|
||||
const formattedMsg = this.formatMessage(LogLevel.ERROR, message);
|
||||
if (error) {
|
||||
console.error(formattedMsg, error);
|
||||
// Always show stack trace for errors, regardless of debug mode
|
||||
if (error instanceof Error && error.stack) {
|
||||
console.error('Stack trace:', error.stack);
|
||||
}
|
||||
} else {
|
||||
console.error(formattedMsg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a warning message
|
||||
*/
|
||||
warn(message: string, data?: unknown): void {
|
||||
if (this.shouldLog(LogLevel.WARN)) {
|
||||
const formattedMsg = this.formatMessage(LogLevel.WARN, message);
|
||||
if (data !== undefined) {
|
||||
console.warn(formattedMsg, data);
|
||||
} else {
|
||||
console.warn(formattedMsg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an info message
|
||||
*/
|
||||
info(message: string, data?: unknown): void {
|
||||
if (this.shouldLog(LogLevel.INFO)) {
|
||||
const formattedMsg = this.formatMessage(LogLevel.INFO, message);
|
||||
if (data !== undefined) {
|
||||
console.log(formattedMsg, data);
|
||||
} else {
|
||||
console.log(formattedMsg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a debug message
|
||||
*/
|
||||
debug(message: string, data?: unknown): void {
|
||||
if (this.shouldLog(LogLevel.DEBUG)) {
|
||||
const formattedMsg = this.formatMessage(LogLevel.DEBUG, message);
|
||||
if (data !== undefined) {
|
||||
console.log(formattedMsg, data);
|
||||
} else {
|
||||
console.log(formattedMsg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a trace message (most verbose)
|
||||
*/
|
||||
trace(message: string, data?: unknown): void {
|
||||
if (this.shouldLog(LogLevel.TRACE)) {
|
||||
const formattedMsg = this.formatMessage(LogLevel.TRACE, message);
|
||||
if (data !== undefined) {
|
||||
console.log(formattedMsg, data);
|
||||
} else {
|
||||
console.log(formattedMsg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log method entry (for tracing execution flow)
|
||||
*/
|
||||
enter(methodName: string, params?: unknown): void {
|
||||
if (this.shouldLog(LogLevel.TRACE)) {
|
||||
const message = `→ ${methodName}`;
|
||||
if (params !== undefined) {
|
||||
this.trace(message, params);
|
||||
} else {
|
||||
this.trace(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log method exit (for tracing execution flow)
|
||||
*/
|
||||
exit(methodName: string, result?: unknown): void {
|
||||
if (this.shouldLog(LogLevel.TRACE)) {
|
||||
const message = `← ${methodName}`;
|
||||
if (result !== undefined) {
|
||||
this.trace(message, result);
|
||||
} else {
|
||||
this.trace(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log performance timing
|
||||
*/
|
||||
time(label: string): void {
|
||||
if (this.shouldLog(LogLevel.DEBUG)) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.time(`${this.config.prefix} ${label}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* End performance timing
|
||||
*/
|
||||
timeEnd(label: string): void {
|
||||
if (this.shouldLog(LogLevel.DEBUG)) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.timeEnd(`${this.config.prefix} ${label}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a scoped logger for a specific operation
|
||||
*/
|
||||
scope(scopeName: string): Logger {
|
||||
return new Logger({
|
||||
debugMode: this.config.debugMode,
|
||||
logLevel: this.config.logLevel,
|
||||
prefix: `${this.config.prefix} [${scopeName}]`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Export convenience functions
|
||||
export const logger = Logger.getInstance();
|
||||
export const getLogger = Logger.getLogger;
|
||||
118
src/utils/ObsidianHttpClient.ts
Normal file
118
src/utils/ObsidianHttpClient.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import { requestUrl } from 'obsidian';
|
||||
|
||||
/**
|
||||
* Obsidian環境でのHTTPクライアント
|
||||
*
|
||||
* CLAUDE.mdの哲学に従った設計:
|
||||
* - CORS制限を回避するObsidian APIを使用
|
||||
* - 共通のエラーハンドリング
|
||||
* - 型安全性の確保
|
||||
*/
|
||||
export class ObsidianHttpClient {
|
||||
/**
|
||||
* Low-level request helper returning status and parsed body
|
||||
*/
|
||||
static async request(opts: {
|
||||
url: string;
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: ArrayBuffer | string;
|
||||
}): Promise<{ status: number; json: unknown; text: string }> {
|
||||
const { url, method = 'GET', headers = {}, body } = opts;
|
||||
const res = await requestUrl({ url, method, headers, body, throw: false });
|
||||
return { status: res.status, json: res.json, text: res.text };
|
||||
}
|
||||
|
||||
/**
|
||||
* GET JSON helper
|
||||
*/
|
||||
static async getJson(url: string, headers: Record<string, string> = {}) {
|
||||
return this.request({ url, method: 'GET', headers });
|
||||
}
|
||||
/**
|
||||
* JSONデータをPOSTリクエスト
|
||||
*/
|
||||
static async postJson(url: string, data: unknown, headers: Record<string, string> = {}) {
|
||||
const res = await requestUrl({
|
||||
url,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...headers
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
throw: false
|
||||
});
|
||||
return { status: res.status, json: res.json };
|
||||
}
|
||||
|
||||
/**
|
||||
* FormDataをmultipart/form-dataとしてPOST
|
||||
*/
|
||||
static async postFormData(url: string, formData: FormData, headers: Record<string, string> = {}) {
|
||||
const boundary = `----ObsidianFormBoundary${Math.random().toString(16).slice(2)}`;
|
||||
const body = await this.formDataToArrayBuffer(formData, boundary);
|
||||
|
||||
const response = await requestUrl({
|
||||
url,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
||||
...headers
|
||||
},
|
||||
body,
|
||||
throw: false
|
||||
});
|
||||
|
||||
return { status: response.status, json: response.json };
|
||||
}
|
||||
|
||||
/**
|
||||
* FormDataをArrayBufferに変換
|
||||
*/
|
||||
private static async formDataToArrayBuffer(formData: FormData, boundary: string): Promise<ArrayBuffer> {
|
||||
const textEncoder = new TextEncoder();
|
||||
const parts: Uint8Array[] = [];
|
||||
|
||||
// FormDataのエントリーを配列に変換(TypeScript互換性のため)
|
||||
const entries: Array<[string, FormDataEntryValue]> = [];
|
||||
// FormData.prototype.entries() の代替実装
|
||||
formData.forEach((value, key) => {
|
||||
entries.push([key, value]);
|
||||
});
|
||||
|
||||
for (const [name, value] of entries) {
|
||||
parts.push(textEncoder.encode(`--${boundary}\r\n`));
|
||||
|
||||
if (value != null && typeof value === 'object' && 'arrayBuffer' in value) {
|
||||
const blobValue = value as File | Blob;
|
||||
const filename = blobValue instanceof File ? blobValue.name : 'blob';
|
||||
parts.push(textEncoder.encode(
|
||||
`Content-Disposition: form-data; name="${name}"; filename="${filename}"\r\n` +
|
||||
`Content-Type: ${blobValue.type || 'application/octet-stream'}\r\n\r\n`
|
||||
));
|
||||
parts.push(new Uint8Array(await blobValue.arrayBuffer()));
|
||||
} else {
|
||||
parts.push(textEncoder.encode(
|
||||
`Content-Disposition: form-data; name="${name}"\r\n\r\n` +
|
||||
String(value)
|
||||
));
|
||||
}
|
||||
parts.push(textEncoder.encode('\r\n'));
|
||||
}
|
||||
|
||||
parts.push(textEncoder.encode(`--${boundary}--\r\n`));
|
||||
|
||||
// すべてのパートを結合
|
||||
const totalLength = parts.reduce((sum, part) => sum + part.length, 0);
|
||||
const result = new Uint8Array(totalLength);
|
||||
let offset = 0;
|
||||
|
||||
for (const part of parts) {
|
||||
result.set(part, offset);
|
||||
offset += part.length;
|
||||
}
|
||||
|
||||
return result.buffer;
|
||||
}
|
||||
}
|
||||
5
src/utils/index.ts
Normal file
5
src/utils/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export { Logger, getLogger, LogLevel } from './Logger';
|
||||
export { mergeSettings, hasSettingsKey } from './settings-utils';
|
||||
export { migrateCorrectionEntries, patternsToString, stringToPatterns } from './migration';
|
||||
export { DeferredViewHelper } from './DeferredViewHelper';
|
||||
// export { ObsidianHttpClient } from './ObsidianHttpClient'; // 未使用: requestUrlを直接使用
|
||||
38
src/utils/migration.ts
Normal file
38
src/utils/migration.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { CorrectionEntry, CorrectionEntryLegacy } from '../interfaces';
|
||||
|
||||
/**
|
||||
* データマイグレーション関数
|
||||
* 旧形式の補正エントリを新形式に変換する
|
||||
*/
|
||||
export function migrateCorrectionEntries(
|
||||
entries: (CorrectionEntry | CorrectionEntryLegacy)[]
|
||||
): CorrectionEntry[] {
|
||||
return entries.map(entry => {
|
||||
if (Array.isArray(entry.from)) {
|
||||
// 既に新形式の場合
|
||||
return entry as CorrectionEntry;
|
||||
} else {
|
||||
// 旧形式から変換
|
||||
const legacyEntry = entry as CorrectionEntryLegacy;
|
||||
return {
|
||||
from: [legacyEntry.from],
|
||||
to: legacyEntry.to
|
||||
// categoryは削除、priorityも削除
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* UI表示用:パターン配列を文字列に変換
|
||||
*/
|
||||
export function patternsToString(patterns: string[]): string {
|
||||
return patterns.join(', ');
|
||||
}
|
||||
|
||||
/**
|
||||
* UI入力用:文字列をパターン配列に変換
|
||||
*/
|
||||
export function stringToPatterns(input: string): string[] {
|
||||
return input ? input.split(',').map(s => s.trim()).filter(s => s) : [];
|
||||
}
|
||||
38
src/utils/settings-utils.ts
Normal file
38
src/utils/settings-utils.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
/**
|
||||
* Type-safe utilities for settings management
|
||||
*/
|
||||
|
||||
import { VoiceInputSettings } from '../interfaces';
|
||||
|
||||
/**
|
||||
* Type-safe function to merge settings objects
|
||||
*/
|
||||
export function mergeSettings(
|
||||
target: VoiceInputSettings,
|
||||
source: Partial<VoiceInputSettings>
|
||||
): void {
|
||||
(Object.keys(source) as Array<keyof VoiceInputSettings>).forEach(key => {
|
||||
const value = source[key];
|
||||
if (value !== undefined) {
|
||||
// Use index signature for type-safe assignment
|
||||
(target as Record<keyof VoiceInputSettings, unknown>)[key] = value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a key exists in settings (including legacy keys)
|
||||
*/
|
||||
export function hasSettingsKey(
|
||||
obj: unknown,
|
||||
key: keyof VoiceInputSettings | string
|
||||
): obj is Record<string, unknown> {
|
||||
return typeof obj === 'object' && obj !== null && key in obj;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for partial settings objects
|
||||
*/
|
||||
export function isPartialSettings(obj: unknown): obj is Partial<VoiceInputSettings> {
|
||||
return typeof obj === 'object' && obj !== null;
|
||||
}
|
||||
249
src/views/VoiceInputView.ts
Normal file
249
src/views/VoiceInputView.ts
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
import {
|
||||
ItemView,
|
||||
WorkspaceLeaf
|
||||
} from 'obsidian';
|
||||
import type VoiceInputPlugin from '../plugin';
|
||||
import { VoiceInputViewUI } from './VoiceInputViewUI';
|
||||
import { VoiceInputViewActions } from './VoiceInputViewActions';
|
||||
import { DraftManager } from '../managers';
|
||||
import { getI18n } from '../services';
|
||||
|
||||
export const VIEW_TYPE_VOICE_INPUT = 'voice-input-view';
|
||||
|
||||
/**
|
||||
* Main Voice Input View - coordinates UI and actions
|
||||
*/
|
||||
export class VoiceInputView extends ItemView {
|
||||
plugin: VoiceInputPlugin;
|
||||
ui: VoiceInputViewUI;
|
||||
actions: VoiceInputViewActions;
|
||||
private blurHandler: ((e: FocusEvent) => void) | null = null;
|
||||
private autoSaveTimeout: NodeJS.Timeout | null = null;
|
||||
private periodicSaveInterval: NodeJS.Timeout | null = null;
|
||||
private static readonly AUTO_SAVE_DELAY = 2000; // 2 seconds
|
||||
private static readonly PERIODIC_SAVE_INTERVAL = 5000; // 5 seconds
|
||||
|
||||
constructor(leaf: WorkspaceLeaf, plugin: VoiceInputPlugin) {
|
||||
super(leaf);
|
||||
this.plugin = plugin;
|
||||
// Note: this.app is available from ItemView base class
|
||||
}
|
||||
|
||||
getViewType() {
|
||||
return VIEW_TYPE_VOICE_INPUT;
|
||||
}
|
||||
|
||||
getDisplayText() {
|
||||
return 'Voice Input';
|
||||
}
|
||||
|
||||
getIcon() {
|
||||
return 'microphone';
|
||||
}
|
||||
|
||||
async onOpen() {
|
||||
const containerElement = this.containerEl.children[1];
|
||||
if (containerElement instanceof HTMLElement) {
|
||||
const container = containerElement;
|
||||
container.empty();
|
||||
container.addClass('voice-input-view');
|
||||
|
||||
// Capture the last active markdown view is handled by the plugin
|
||||
|
||||
// Initialize components
|
||||
this.ui = new VoiceInputViewUI(this, this.plugin, container);
|
||||
this.actions = new VoiceInputViewActions(this, this.plugin);
|
||||
|
||||
// Initialize services
|
||||
await this.actions.initializeServices();
|
||||
|
||||
// Create UI
|
||||
this.ui.createUI();
|
||||
|
||||
// Restore saved draft if exists
|
||||
await this.restoreDraft();
|
||||
|
||||
// Set up focus management and auto-save
|
||||
this.setupFocusManagement();
|
||||
this.setupAutoSaveOnBlur();
|
||||
this.setupPeriodicSave();
|
||||
}
|
||||
}
|
||||
|
||||
async onClose() {
|
||||
// Clear any pending auto-save timeout
|
||||
if (this.autoSaveTimeout) {
|
||||
clearTimeout(this.autoSaveTimeout);
|
||||
this.autoSaveTimeout = null;
|
||||
}
|
||||
|
||||
// Clear periodic save interval
|
||||
if (this.periodicSaveInterval) {
|
||||
clearInterval(this.periodicSaveInterval);
|
||||
this.periodicSaveInterval = null;
|
||||
}
|
||||
|
||||
// Save any text content
|
||||
if (this.ui?.textArea?.value) {
|
||||
await DraftManager.saveDraft(this.app, this.ui.textArea.value, 'view-close');
|
||||
} else {
|
||||
// Clear saved draft if textarea is empty
|
||||
await DraftManager.clearDraft(this.app);
|
||||
}
|
||||
|
||||
// Remove focus event listeners
|
||||
this.cleanupFocusManagement();
|
||||
|
||||
// Remove blur handler
|
||||
if (this.blurHandler && this.ui?.textArea) {
|
||||
this.ui.textArea.removeEventListener('blur', this.blurHandler);
|
||||
this.blurHandler = null;
|
||||
}
|
||||
|
||||
// Stop recording if active
|
||||
if (this.actions.audioRecorder) {
|
||||
await this.actions.stopRecording();
|
||||
}
|
||||
|
||||
// Clean up
|
||||
this.actions.destroy();
|
||||
this.ui.destroy();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update transcription service when settings change
|
||||
*/
|
||||
updateTranscriptionService() {
|
||||
this.actions.updateTranscriptionService();
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh UI when language changes
|
||||
*/
|
||||
refreshUI() {
|
||||
// Re-create the UI with new language
|
||||
const containerElement = this.containerEl.children[1];
|
||||
if (containerElement instanceof HTMLElement) {
|
||||
const container = containerElement;
|
||||
container.empty();
|
||||
container.addClass('voice-input-view');
|
||||
|
||||
// Preserve current text content
|
||||
const currentText = this.ui?.textArea?.value || '';
|
||||
|
||||
// Re-create UI components
|
||||
this.ui = new VoiceInputViewUI(this, this.plugin, container);
|
||||
this.ui.createUI();
|
||||
|
||||
// Restore text content
|
||||
if (currentText) {
|
||||
this.ui.textArea.value = currentText;
|
||||
}
|
||||
|
||||
// Re-setup focus management
|
||||
this.setupFocusManagement();
|
||||
this.setupAutoSaveOnBlur();
|
||||
this.setupPeriodicSave();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up focus management to handle recording state
|
||||
*/
|
||||
private setupFocusManagement() {
|
||||
// Removed click outside detection to allow recording to continue
|
||||
// when users interact with other parts of Obsidian
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up focus management event listeners
|
||||
*/
|
||||
private cleanupFocusManagement() {
|
||||
// No cleanup needed as click outside detection has been removed
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up auto-save when text area loses focus
|
||||
*/
|
||||
private setupAutoSaveOnBlur() {
|
||||
if (!this.ui?.textArea) return;
|
||||
|
||||
// Remove existing handler if any
|
||||
if (this.blurHandler) {
|
||||
this.ui.textArea.removeEventListener('blur', this.blurHandler);
|
||||
}
|
||||
|
||||
// Create and store the handler
|
||||
this.blurHandler = async () => {
|
||||
if (this.ui?.textArea?.value) {
|
||||
await DraftManager.saveDraft(this.app, this.ui.textArea.value, 'focus-lost');
|
||||
}
|
||||
};
|
||||
|
||||
// Save draft when text area loses focus
|
||||
this.ui.textArea.addEventListener('blur', this.blurHandler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore saved draft text if exists
|
||||
*/
|
||||
private async restoreDraft() {
|
||||
const draftText = await DraftManager.loadDraft(this.app);
|
||||
if (draftText && this.ui?.textArea) {
|
||||
this.ui.textArea.value = draftText;
|
||||
// Show notification that draft was restored
|
||||
if (this.actions) {
|
||||
const i18n = getI18n();
|
||||
this.actions.setStatusWithTimeout(i18n.t('status.draftRestored'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up periodic auto-save that saves textarea content regardless of state
|
||||
* This simple approach handles all cases including cleared text
|
||||
*/
|
||||
private setupPeriodicSave() {
|
||||
// Clear existing interval if any
|
||||
if (this.periodicSaveInterval) {
|
||||
clearInterval(this.periodicSaveInterval);
|
||||
}
|
||||
|
||||
// Set up periodic save every 5 seconds
|
||||
this.periodicSaveInterval = setInterval(async () => {
|
||||
if (this.ui?.textArea) {
|
||||
const content = this.ui.textArea.value;
|
||||
if (content.trim()) {
|
||||
// Save the content
|
||||
await DraftManager.saveDraft(this.app, content, 'periodic-save');
|
||||
} else {
|
||||
// Clear the draft when content is empty (handles cleared text)
|
||||
await DraftManager.clearDraft(this.app);
|
||||
}
|
||||
}
|
||||
}, VoiceInputView.PERIODIC_SAVE_INTERVAL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle text changes with debounced auto-save
|
||||
*/
|
||||
onTextChanged() {
|
||||
// Clear existing timeout
|
||||
if (this.autoSaveTimeout) {
|
||||
clearTimeout(this.autoSaveTimeout);
|
||||
}
|
||||
|
||||
// Set new timeout for auto-save (for responsiveness, keeps existing behavior)
|
||||
this.autoSaveTimeout = setTimeout(async () => {
|
||||
if (this.ui?.textArea) {
|
||||
const content = this.ui.textArea.value;
|
||||
if (content.trim()) {
|
||||
await DraftManager.saveDraft(this.app, content, 'auto-save');
|
||||
} else {
|
||||
// Clear saved draft if textarea is empty
|
||||
await DraftManager.clearDraft(this.app);
|
||||
}
|
||||
}
|
||||
}, VoiceInputView.AUTO_SAVE_DELAY);
|
||||
}
|
||||
}
|
||||
832
src/views/VoiceInputViewActions.ts
Normal file
832
src/views/VoiceInputViewActions.ts
Normal file
|
|
@ -0,0 +1,832 @@
|
|||
import { Notice, MarkdownView, normalizePath } from 'obsidian';
|
||||
import { AudioRecorder } from '../core';
|
||||
import { TranscriptionService } from '../core';
|
||||
import { TranscriptionError, TranscriptionErrorType } from '../errors';
|
||||
import { SecurityUtils } from '../security';
|
||||
import { DraftManager } from '../managers';
|
||||
import type { VoiceInputView } from './VoiceInputView';
|
||||
import type VoiceInputPlugin from '../plugin';
|
||||
import { getI18n, createServiceLogger } from '../services';
|
||||
import type { I18nService } from '../interfaces';
|
||||
import type { StopReason, RecordingState } from '../interfaces';
|
||||
import { Logger } from '../utils';
|
||||
|
||||
/**
|
||||
* Handles all recording and text manipulation actions for the Voice Input View
|
||||
*/
|
||||
export class VoiceInputViewActions {
|
||||
private view: VoiceInputView;
|
||||
private plugin: VoiceInputPlugin;
|
||||
private i18n: I18nService;
|
||||
private logger: Logger;
|
||||
|
||||
audioRecorder: AudioRecorder | null = null;
|
||||
transcriptionService: TranscriptionService | null = null;
|
||||
recordingState: RecordingState = {
|
||||
isRecording: false,
|
||||
isPushToTalkMode: false,
|
||||
processingQueue: []
|
||||
};
|
||||
private isProcessingAudio = false;
|
||||
private statusTimer: NodeJS.Timeout | null = null;
|
||||
private clearConfirmTimer: NodeJS.Timeout | null = null;
|
||||
private clearPressCount = 0;
|
||||
|
||||
constructor(view: VoiceInputView, plugin: VoiceInputPlugin) {
|
||||
this.view = view;
|
||||
this.plugin = plugin;
|
||||
this.i18n = getI18n();
|
||||
this.logger = createServiceLogger('VoiceInputViewActions');
|
||||
}
|
||||
|
||||
get app() {
|
||||
return this.view.app;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize services
|
||||
*/
|
||||
async initializeServices() {
|
||||
// Initialize transcription service
|
||||
const decryptedApiKey = this.plugin.settings.openaiApiKey;
|
||||
this.transcriptionService = new TranscriptionService(
|
||||
decryptedApiKey,
|
||||
this.plugin.settings.customDictionary
|
||||
);
|
||||
this.applyTranscriptionSettings();
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply transcription settings to the service
|
||||
*/
|
||||
private applyTranscriptionSettings() {
|
||||
if (!this.transcriptionService) return;
|
||||
|
||||
this.transcriptionService.setModel(this.plugin.settings.transcriptionModel);
|
||||
// Apply transcription correction and custom dictionary settings
|
||||
this.transcriptionService.setTranscriptionCorrection(this.plugin.settings.enableTranscriptionCorrection);
|
||||
this.transcriptionService.setCustomDictionary(this.plugin.settings.customDictionary);
|
||||
|
||||
// 後方互換性のため、詳細設定も更新
|
||||
this.transcriptionService.updateCorrectorSettings({
|
||||
enabled: this.plugin.settings.enableTranscriptionCorrection
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update transcription service settings
|
||||
*/
|
||||
async updateTranscriptionService() {
|
||||
if (this.transcriptionService) {
|
||||
const decryptedApiKey = this.plugin.settings.openaiApiKey;
|
||||
this.transcriptionService.updateApiKey(decryptedApiKey);
|
||||
this.transcriptionService.setCustomDictionary(this.plugin.settings.customDictionary);
|
||||
this.applyTranscriptionSettings();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle microphone status changes
|
||||
*/
|
||||
private handleMicrophoneStatus(status: 'initializing' | 'ready' | 'error') {
|
||||
if (status === 'initializing') {
|
||||
this.view.ui.statusEl.setText(this.i18n.t('status.recording.micInit'));
|
||||
this.view.ui.recordButton.setText(this.i18n.t('ui.buttons.recordPreparing'));
|
||||
} else if (status === 'ready') {
|
||||
this.view.ui.statusEl.setText(this.i18n.t('status.recording.recording'));
|
||||
// Show different text for push-to-talk mode
|
||||
if (this.recordingState.isPushToTalkMode) {
|
||||
this.view.ui.recordButton.setText(this.i18n.t('ui.buttons.recordStopPushToTalk'));
|
||||
} else {
|
||||
this.view.ui.recordButton.setText(this.i18n.t('ui.buttons.recordStop'));
|
||||
}
|
||||
this.view.ui.recordButton.addClass('recording');
|
||||
} else if (status === 'error') {
|
||||
this.view.ui.statusEl.setText(this.i18n.t('status.error'));
|
||||
this.view.ui.recordButton.setText(this.i18n.t('ui.buttons.recordStart'));
|
||||
this.view.ui.recordButton.removeClass('recording');
|
||||
new Notice(this.i18n.t('error.audio.micInitFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle recording on/off
|
||||
*/
|
||||
async toggleRecording() {
|
||||
if (this.audioRecorder && this.audioRecorder.isActive()) {
|
||||
await this.stopRecording();
|
||||
} else {
|
||||
await this.startRecording();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start recording
|
||||
*/
|
||||
async startRecording() {
|
||||
// Validate API key
|
||||
if (!this.plugin.settings.openaiApiKey) {
|
||||
new Notice(this.i18n.t('error.api.noKey'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!SecurityUtils.validateOpenAIAPIKey(this.plugin.settings.openaiApiKey)) {
|
||||
new Notice(this.i18n.t('error.api.invalidKey'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip secure context check in Obsidian
|
||||
// Obsidian provides its own secure environment
|
||||
|
||||
try {
|
||||
this.logger.info('Starting recording session', {
|
||||
mode: 'continuous',
|
||||
maxDuration: this.plugin.settings.maxRecordingSeconds
|
||||
});
|
||||
|
||||
// Dispose existing audio recorder if any
|
||||
if (this.audioRecorder) {
|
||||
this.audioRecorder.dispose();
|
||||
this.audioRecorder = null;
|
||||
}
|
||||
|
||||
// Update UI
|
||||
this.view.ui.recordButton.setText(this.i18n.t('ui.buttons.recordStop'));
|
||||
this.view.ui.recordButton.addClass('recording');
|
||||
this.view.ui.statusEl.setText(this.i18n.t('status.recording.preparing'));
|
||||
this.view.ui.setButtonsEnabled(false);
|
||||
this.view.ui.showCancelButton(true); // Show cancel button
|
||||
|
||||
// Initialize audio recorder for continuous recording mode
|
||||
// AudioWorklet requires serving from same origin as the page
|
||||
// In Obsidian, we'll let it fallback to ScriptProcessor
|
||||
this.audioRecorder = new AudioRecorder({
|
||||
useVAD: false, // 常に連続録音モード
|
||||
onSpeechEnd: async (audioBlob) => {
|
||||
// 連続録音モードでのonSpeechEndは最大時間到達時のみ
|
||||
this.recordingState.isRecording = false;
|
||||
this.updateUIAfterStop();
|
||||
await this.processRecordedAudio(audioBlob, { type: 'max-duration' });
|
||||
},
|
||||
onMicrophoneStatusChange: (status) => {
|
||||
this.handleMicrophoneStatus(status);
|
||||
},
|
||||
visualizerContainer: this.view.ui.visualizerContainer,
|
||||
useSimpleVisualizer: false,
|
||||
maxRecordingSeconds: this.plugin.settings.maxRecordingSeconds
|
||||
});
|
||||
|
||||
await this.audioRecorder.initialize();
|
||||
await this.audioRecorder.startRecording();
|
||||
this.recordingState.isRecording = true;
|
||||
|
||||
// Enable other buttons
|
||||
this.view.ui.setButtonsEnabled(true);
|
||||
|
||||
} catch (error) {
|
||||
const transcriptionError = error instanceof TranscriptionError
|
||||
? error
|
||||
: TranscriptionError.fromError(error, TranscriptionErrorType.AUDIO_DEVICE_ERROR);
|
||||
|
||||
this.logger.error('Error starting recording', transcriptionError);
|
||||
this.view.ui.recordButton.setText(this.i18n.t('ui.buttons.recordStart'));
|
||||
this.view.ui.recordButton.removeClass('recording');
|
||||
this.view.ui.statusEl.setText(this.i18n.t('status.error'));
|
||||
this.view.ui.setButtonsEnabled(true);
|
||||
new Notice(transcriptionError.getLocalizedMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop recording with reason
|
||||
*/
|
||||
async stopRecording(reason?: StopReason) {
|
||||
if (!this.audioRecorder) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Default to manual stop if no reason provided
|
||||
const stopReason = reason || { type: 'manual' as const };
|
||||
this.recordingState.lastStopReason = stopReason;
|
||||
|
||||
try {
|
||||
this.logger.info('Stopping recording session', {
|
||||
reason: stopReason.type,
|
||||
hasAudioRecorder: !!this.audioRecorder
|
||||
});
|
||||
|
||||
// Get the audio blob before destroying the recorder
|
||||
const audioBlob = await this.audioRecorder.stopRecording();
|
||||
|
||||
// Clean up audio recorder (check if still exists)
|
||||
if (this.audioRecorder) {
|
||||
this.audioRecorder.destroy();
|
||||
this.audioRecorder = null;
|
||||
}
|
||||
this.recordingState.isRecording = false;
|
||||
|
||||
// Update UI once
|
||||
this.updateUIAfterStop();
|
||||
|
||||
// Process audio for manual stop
|
||||
if (audioBlob && audioBlob.size > 0) {
|
||||
await this.processRecordedAudio(audioBlob, stopReason);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
const transcriptionError = error instanceof TranscriptionError
|
||||
? error
|
||||
: TranscriptionError.fromError(error, TranscriptionErrorType.AUDIO_DEVICE_ERROR);
|
||||
|
||||
this.logger.error('Error stopping recording', transcriptionError);
|
||||
this.updateUIAfterError();
|
||||
new Notice(transcriptionError.getLocalizedMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update UI after recording stops
|
||||
*/
|
||||
private updateUIAfterStop(): void {
|
||||
if (this.view.ui.recordButton) {
|
||||
this.view.ui.recordButton.setText(this.i18n.t('ui.buttons.recordStart'));
|
||||
this.view.ui.recordButton.removeClass('recording');
|
||||
}
|
||||
if (this.view.ui.statusEl) {
|
||||
this.view.ui.statusEl.setText(this.i18n.t('status.idle'));
|
||||
this.view.ui.statusEl.removeClass('processing');
|
||||
this.view.ui.statusEl.removeClass('error');
|
||||
}
|
||||
this.view.ui.showCancelButton(false); // Hide cancel button
|
||||
}
|
||||
|
||||
/**
|
||||
* Update UI after error
|
||||
*/
|
||||
private updateUIAfterError(): void {
|
||||
if (this.view.ui.recordButton) {
|
||||
this.view.ui.recordButton.setText(this.i18n.t('ui.buttons.recordStart'));
|
||||
this.view.ui.recordButton.removeClass('recording');
|
||||
}
|
||||
if (this.view.ui.statusEl) {
|
||||
this.view.ui.statusEl.setText(this.i18n.t('status.error'));
|
||||
this.view.ui.statusEl.removeClass('processing');
|
||||
this.view.ui.statusEl.addClass('error');
|
||||
}
|
||||
this.view.ui.showCancelButton(false); // Hide cancel button
|
||||
}
|
||||
|
||||
/**
|
||||
* Set status text and clear it after a timeout
|
||||
*/
|
||||
setStatusWithTimeout(text: string, timeout = 3000): void {
|
||||
if (!this.view.ui.statusEl) return;
|
||||
|
||||
// Clear existing timer
|
||||
if (this.statusTimer) {
|
||||
clearTimeout(this.statusTimer);
|
||||
this.statusTimer = null;
|
||||
}
|
||||
|
||||
// Set new status
|
||||
this.view.ui.statusEl.setText(text);
|
||||
this.view.ui.statusEl.removeClass('processing');
|
||||
this.view.ui.statusEl.removeClass('error');
|
||||
|
||||
// Set timer to clear status
|
||||
this.statusTimer = setTimeout(() => {
|
||||
if (this.view.ui.statusEl && !this.recordingState.isRecording && !this.isProcessingAudio) {
|
||||
this.view.ui.statusEl.setText(this.i18n.t('status.idle'));
|
||||
}
|
||||
this.statusTimer = null;
|
||||
}, timeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process recorded audio with proper notifications
|
||||
*/
|
||||
private async processRecordedAudio(audioBlob: Blob, stopReason: StopReason): Promise<void> {
|
||||
// Show appropriate status based on stop reason
|
||||
if (this.view.ui.statusEl) {
|
||||
switch (stopReason.type) {
|
||||
case 'vad-auto':
|
||||
this.setStatusWithTimeout(this.i18n.t('status.transcription.vadAutoStopped'));
|
||||
break;
|
||||
case 'max-duration':
|
||||
this.setStatusWithTimeout(this.i18n.t('status.transcription.maxDurationReached'));
|
||||
break;
|
||||
// No status update for manual stop
|
||||
}
|
||||
}
|
||||
|
||||
// Add to queue
|
||||
this.recordingState.processingQueue.push({
|
||||
audioBlob,
|
||||
timestamp: Date.now(),
|
||||
stopReason
|
||||
});
|
||||
|
||||
// Always update status when queue changes (unless a timed status is active)
|
||||
if (!this.statusTimer) {
|
||||
this.updateProcessingStatus();
|
||||
}
|
||||
|
||||
// Process queue if not already processing
|
||||
if (!this.isProcessingAudio) {
|
||||
this.processQueue();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process items from the queue
|
||||
*/
|
||||
private async processQueue(): Promise<void> {
|
||||
if (this.isProcessingAudio || this.recordingState.processingQueue.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isProcessingAudio = true;
|
||||
|
||||
while (this.recordingState.processingQueue.length > 0) {
|
||||
// Get the first item without removing it yet
|
||||
const item = this.recordingState.processingQueue[0];
|
||||
if (!item) {
|
||||
this.recordingState.processingQueue.shift();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Update status before processing (queue still contains current item)
|
||||
this.updateProcessingStatus();
|
||||
|
||||
try {
|
||||
await this.handleSpeechEnd(item.audioBlob);
|
||||
} catch (error) {
|
||||
this.logger.error('Error processing audio', error);
|
||||
} finally {
|
||||
// Remove the processed item after completion
|
||||
this.recordingState.processingQueue.shift();
|
||||
}
|
||||
}
|
||||
|
||||
this.isProcessingAudio = false;
|
||||
// Don't update status here if a status timer is active
|
||||
// This prevents overwriting status messages set by setStatusWithTimeout
|
||||
if (!this.statusTimer) {
|
||||
this.updateProcessingStatus();
|
||||
}
|
||||
|
||||
// Check if new items were added while processing
|
||||
if (this.recordingState.processingQueue.length > 0) {
|
||||
// Use setTimeout to avoid potential stack overflow
|
||||
setTimeout(() => this.processQueue(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update processing status display
|
||||
*/
|
||||
private updateProcessingStatus(): void {
|
||||
if (!this.view.ui.statusEl) return;
|
||||
|
||||
// Don't update if a status message with timer is active
|
||||
if (this.statusTimer) return;
|
||||
|
||||
const queueLength = this.recordingState.processingQueue.length;
|
||||
|
||||
if (this.isProcessingAudio) {
|
||||
let statusText = this.i18n.t('status.processing.transcribing');
|
||||
// When processing, the first item is being processed, others are waiting
|
||||
const waitingCount = queueLength > 0 ? queueLength - 1 : 0;
|
||||
if (waitingCount > 0) {
|
||||
statusText += ` (${waitingCount} 待機中)`;
|
||||
}
|
||||
this.view.ui.statusEl.setText(statusText);
|
||||
this.view.ui.statusEl.addClass('processing');
|
||||
this.view.ui.statusEl.removeClass('error');
|
||||
} else if (!this.recordingState.isRecording) {
|
||||
this.view.ui.statusEl.setText(this.i18n.t('status.idle'));
|
||||
this.view.ui.statusEl.removeClass('processing');
|
||||
this.view.ui.statusEl.removeClass('error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle speech end event from audio recorder
|
||||
* @param audioBlob - The recorded audio blob
|
||||
*/
|
||||
async handleSpeechEnd(audioBlob: Blob): Promise<void> {
|
||||
|
||||
// Validate that the view and UI components are still valid
|
||||
if (!this.view || !this.view.ui || !this.view.ui.textArea) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the plugin is still active
|
||||
if (!this.plugin || !this.app) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip extremely small audio blobs (likely empty or corrupt)
|
||||
// WebM header alone is ~200-300 bytes, so anything under 500 bytes is suspicious
|
||||
const MIN_VALID_AUDIO_SIZE = 500; // bytes
|
||||
// Minimum valid audio size for continuous recording
|
||||
const minSpeechBytes = MIN_VALID_AUDIO_SIZE;
|
||||
|
||||
if (audioBlob.size < minSpeechBytes) {
|
||||
this.setStatusWithTimeout(this.i18n.t('status.transcription.audioTooShort'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure transcription service is initialized
|
||||
if (!this.transcriptionService) {
|
||||
await this.initializeServices();
|
||||
|
||||
// Re-validate after async initialization
|
||||
if (!this.transcriptionService) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// No longer block UI during processing - allow parallel recording
|
||||
// Update status is now handled by updateProcessingStatus()
|
||||
|
||||
// Transcribe audio
|
||||
if (!this.transcriptionService) {
|
||||
throw new Error('Transcription service not available');
|
||||
}
|
||||
const result = await this.transcriptionService.transcribeAudio(audioBlob, this.plugin.settings.pluginLanguage);
|
||||
|
||||
// Check if result is empty
|
||||
if (!result.text || result.text.trim() === '') {
|
||||
this.setStatusWithTimeout(this.i18n.t('status.transcription.noAudioDetected'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate UI elements still exist before updating them
|
||||
if (this.view.ui.textArea) {
|
||||
// Append to text area
|
||||
if (this.view.ui.textArea.value) {
|
||||
this.view.ui.textArea.value += '\n\n';
|
||||
}
|
||||
this.view.ui.textArea.value += result.text;
|
||||
|
||||
// Auto scroll to bottom
|
||||
this.view.ui.textArea.scrollTop = this.view.ui.textArea.scrollHeight;
|
||||
|
||||
// Auto-save draft after successful transcription
|
||||
this.saveDraft();
|
||||
|
||||
// Show success status briefly
|
||||
this.setStatusWithTimeout(this.i18n.t('status.processing.completed'));
|
||||
} else {
|
||||
this.logger.error('TextArea not found during transcription insertion');
|
||||
}
|
||||
|
||||
// Queue status will be updated after timeout by updateProcessingStatus()
|
||||
|
||||
} catch (error) {
|
||||
const transcriptionError = error instanceof TranscriptionError
|
||||
? error
|
||||
: TranscriptionError.fromError(error, TranscriptionErrorType.TRANSCRIPTION_FAILED);
|
||||
|
||||
this.logger.error('Transcription error', transcriptionError);
|
||||
new Notice(transcriptionError.getLocalizedMessage());
|
||||
if (this.view.ui.statusEl) {
|
||||
this.view.ui.statusEl.setText(this.i18n.t('status.error'));
|
||||
this.view.ui.statusEl.addClass('error');
|
||||
this.view.ui.statusEl.removeClass('processing');
|
||||
}
|
||||
} finally {
|
||||
// Processing state is now managed by processQueue
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear text from textarea
|
||||
*/
|
||||
clearText() {
|
||||
const text = this.view.ui.textArea.value.trim();
|
||||
if (!text) {
|
||||
this.setStatusWithTimeout(this.i18n.t('status.warning.noTextToClear'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Double-press confirmation (3 seconds timeout)
|
||||
this.clearPressCount++;
|
||||
|
||||
if (this.clearPressCount === 1) {
|
||||
// First press - show confirmation message
|
||||
this.setStatusWithTimeout(this.i18n.t('status.warning.clearConfirm'));
|
||||
|
||||
// Set timer to reset press count
|
||||
this.clearConfirmTimer = setTimeout(() => {
|
||||
this.clearPressCount = 0;
|
||||
this.clearConfirmTimer = null;
|
||||
}, 3000);
|
||||
} else if (this.clearPressCount === 2) {
|
||||
// Second press within timeout - clear the text
|
||||
if (this.clearConfirmTimer) {
|
||||
clearTimeout(this.clearConfirmTimer);
|
||||
this.clearConfirmTimer = null;
|
||||
}
|
||||
this.clearPressCount = 0;
|
||||
|
||||
// Clear the text
|
||||
this.view.ui.textArea.value = '';
|
||||
this.setStatusWithTimeout(this.i18n.t('status.memoCleared'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy text to clipboard
|
||||
*/
|
||||
async copyToClipboard() {
|
||||
const text = this.view.ui.textArea.value.trim();
|
||||
if (!text) {
|
||||
this.setStatusWithTimeout(this.i18n.t('status.warning.noTextToCopy'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
this.setStatusWithTimeout(this.i18n.t('status.clipboardCopied'));
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
this.logger.error('Clipboard copy error', error);
|
||||
new Notice(this.i18n.t('notification.error.clipboardFailed') + ': ' + errorMessage);
|
||||
this.view.ui.statusEl.setText(this.i18n.t('status.error'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up text using GPT
|
||||
*/
|
||||
async cleanupText() {
|
||||
if (!this.view.ui.textArea.value.trim()) {
|
||||
this.setStatusWithTimeout(this.i18n.t('status.warning.noTextToCleanup'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure transcription service is initialized
|
||||
if (!this.transcriptionService) {
|
||||
await this.initializeServices();
|
||||
}
|
||||
|
||||
if (!this.transcriptionService) {
|
||||
new Notice(this.i18n.t('notification.warning.serviceInitFailed'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.view.ui.statusEl.setText(this.i18n.t('status.cleanupInProgress'));
|
||||
this.view.ui.setButtonsEnabled(false);
|
||||
|
||||
// Use the dictionary corrector for cleanup
|
||||
const corrector = this.transcriptionService.getCorrector();
|
||||
const cleanedText = await corrector.correct(this.view.ui.textArea.value);
|
||||
this.view.ui.textArea.value = cleanedText;
|
||||
|
||||
this.setStatusWithTimeout(this.i18n.t('status.cleanupCompleted'));
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
this.logger.error('Cleanup error', error);
|
||||
new Notice(this.i18n.t('notification.error.cleanupFailed') + errorMessage);
|
||||
this.view.ui.statusEl.setText(this.i18n.t('status.error'));
|
||||
} finally {
|
||||
this.view.ui.setButtonsEnabled(true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel recording without processing
|
||||
*/
|
||||
async cancelRecording() {
|
||||
if (!this.audioRecorder || !this.audioRecorder.isActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Stop recording and discard audio
|
||||
await this.audioRecorder.stopRecording();
|
||||
|
||||
// Clean up audio recorder
|
||||
if (this.audioRecorder) {
|
||||
this.audioRecorder.destroy();
|
||||
this.audioRecorder = null;
|
||||
}
|
||||
|
||||
this.recordingState.isRecording = false;
|
||||
|
||||
// Update UI
|
||||
this.updateUIAfterStop();
|
||||
this.view.ui.setButtonsEnabled(true);
|
||||
this.setStatusWithTimeout(this.i18n.t('status.recording.cancelled'));
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('Error cancelling recording', error);
|
||||
this.updateUIAfterError();
|
||||
this.view.ui.setButtonsEnabled(true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert text to active markdown note
|
||||
*/
|
||||
async insertToNote() {
|
||||
const text = this.view.ui.textArea.value.trim();
|
||||
if (!text) {
|
||||
this.setStatusWithTimeout(this.i18n.t('status.warning.noTextToInsert'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Use ViewManager to find a suitable markdown view
|
||||
const viewManager = this.plugin.getViewManager();
|
||||
let targetView: MarkdownView | null = viewManager.findTargetMarkdownView();
|
||||
|
||||
// Method 4: Create a new note as fallback
|
||||
if (!targetView) {
|
||||
try {
|
||||
// Generate timestamp for filename
|
||||
const timestamp = new Date().toISOString()
|
||||
.replace(/T/, '-')
|
||||
.replace(/:/g, '-')
|
||||
.replace(/\..+/, '');
|
||||
|
||||
const fileName = `Voice-Memo-${timestamp}.md`;
|
||||
const normalizedFileName = normalizePath(fileName);
|
||||
const newFile = await this.app.vault.create(normalizedFileName, '');
|
||||
|
||||
// Open the new file in a new tab for better UX
|
||||
const leaf = this.app.workspace.getLeaf('tab');
|
||||
await leaf.openFile(newFile);
|
||||
if (leaf.view instanceof MarkdownView) {
|
||||
targetView = leaf.view;
|
||||
} else {
|
||||
throw new Error('Failed to open markdown view for new file');
|
||||
}
|
||||
|
||||
new Notice(this.i18n.t('notification.success.newNoteCreated'));
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
new Notice(this.i18n.t('notification.error.noteCreateFailed') + ': ' + errorMessage);
|
||||
await navigator.clipboard.writeText(text);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Insert the text
|
||||
if (targetView && targetView.editor) {
|
||||
const editor = targetView.editor;
|
||||
|
||||
// Make the target view active
|
||||
this.app.workspace.setActiveLeaf(targetView.leaf, { focus: true });
|
||||
|
||||
// Get cursor position
|
||||
const cursor = editor.getCursor();
|
||||
|
||||
// Insert at cursor position
|
||||
editor.replaceRange(text, cursor);
|
||||
|
||||
// Move cursor to end of inserted text
|
||||
const newCursor = {
|
||||
line: cursor.line,
|
||||
ch: cursor.ch + text.length
|
||||
};
|
||||
editor.setCursor(newCursor);
|
||||
|
||||
// Clear text area after insertion
|
||||
this.view.ui.textArea.value = '';
|
||||
this.setStatusWithTimeout(this.i18n.t('status.noteInserted'));
|
||||
|
||||
// ViewManagerがターゲットビューを記録(必要に応じて)
|
||||
} else {
|
||||
new Notice(this.i18n.t('notification.warning.noEditorFound'));
|
||||
await navigator.clipboard.writeText(text);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Append text to the end of active markdown note
|
||||
*/
|
||||
async appendToNote() {
|
||||
const text = this.view.ui.textArea.value.trim();
|
||||
if (!text) {
|
||||
this.setStatusWithTimeout(this.i18n.t('status.warning.noTextToInsert'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Use ViewManager to find a suitable markdown view
|
||||
const viewManager = this.plugin.getViewManager();
|
||||
let targetView: MarkdownView | null = viewManager.findTargetMarkdownView();
|
||||
|
||||
// Create a new note as fallback (same logic as insertToNote)
|
||||
if (!targetView) {
|
||||
try {
|
||||
// Generate timestamp for filename
|
||||
const timestamp = new Date().toISOString()
|
||||
.replace(/T/, '-')
|
||||
.replace(/:/g, '-')
|
||||
.replace(/\..+/, '');
|
||||
|
||||
const fileName = `Voice-Memo-${timestamp}.md`;
|
||||
const normalizedFileName = normalizePath(fileName);
|
||||
const newFile = await this.app.vault.create(normalizedFileName, '');
|
||||
|
||||
// Open the new file in a new tab for better UX
|
||||
const leaf = this.app.workspace.getLeaf('tab');
|
||||
await leaf.openFile(newFile);
|
||||
if (leaf.view instanceof MarkdownView) {
|
||||
targetView = leaf.view;
|
||||
} else {
|
||||
throw new Error('Failed to open markdown view for new file');
|
||||
}
|
||||
|
||||
new Notice(this.i18n.t('notification.success.newNoteCreated'));
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
new Notice(this.i18n.t('notification.error.noteCreateFailed') + ': ' + errorMessage);
|
||||
await navigator.clipboard.writeText(text);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Insert the text at the end of the document
|
||||
if (targetView && targetView.editor) {
|
||||
const editor = targetView.editor;
|
||||
|
||||
// Make the target view active
|
||||
this.app.workspace.setActiveLeaf(targetView.leaf, { focus: true });
|
||||
|
||||
// Get the last line of the document
|
||||
const lastLine = editor.lastLine();
|
||||
const lastLineLength = editor.getLine(lastLine).length;
|
||||
|
||||
// Insert at end with proper spacing
|
||||
const insertPosition = { line: lastLine, ch: lastLineLength };
|
||||
const textToInsert = '\n\n' + text;
|
||||
editor.replaceRange(textToInsert, insertPosition);
|
||||
|
||||
// Move cursor to end of inserted text
|
||||
const newCursor = {
|
||||
line: lastLine + 2,
|
||||
ch: text.length
|
||||
};
|
||||
editor.setCursor(newCursor);
|
||||
|
||||
// Clear text area after insertion
|
||||
this.view.ui.textArea.value = '';
|
||||
this.setStatusWithTimeout(this.i18n.t('status.noteAppended'));
|
||||
|
||||
} else {
|
||||
new Notice(this.i18n.t('notification.warning.noEditorFound'));
|
||||
await navigator.clipboard.writeText(text);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up resources
|
||||
*/
|
||||
destroy() {
|
||||
// Clear processing queue
|
||||
this.recordingState.processingQueue = [];
|
||||
this.isProcessingAudio = false;
|
||||
|
||||
// Clear status timer
|
||||
if (this.statusTimer) {
|
||||
clearTimeout(this.statusTimer);
|
||||
this.statusTimer = null;
|
||||
}
|
||||
|
||||
// Clear clear confirm timer
|
||||
if (this.clearConfirmTimer) {
|
||||
clearTimeout(this.clearConfirmTimer);
|
||||
this.clearConfirmTimer = null;
|
||||
}
|
||||
|
||||
// Stop and clean up audio recorder
|
||||
if (this.audioRecorder) {
|
||||
// First stop recording if active
|
||||
this.audioRecorder.stopRecording().catch(error => {
|
||||
this.logger.error('Error stopping recording during destroy', error);
|
||||
});
|
||||
|
||||
// Then destroy the recorder
|
||||
this.audioRecorder.destroy();
|
||||
this.audioRecorder = null;
|
||||
}
|
||||
|
||||
// Clear any references to prevent memory leaks
|
||||
this.transcriptionService = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save current text as draft
|
||||
*/
|
||||
private async saveDraft() {
|
||||
if (this.view.ui?.textArea?.value) {
|
||||
await DraftManager.saveDraft(this.app, this.view.ui.textArea.value, 'auto-save-transcription');
|
||||
}
|
||||
}
|
||||
}
|
||||
444
src/views/VoiceInputViewUI.ts
Normal file
444
src/views/VoiceInputViewUI.ts
Normal file
|
|
@ -0,0 +1,444 @@
|
|||
import { UI_CONSTANTS } from '../config';
|
||||
import { Notice, Setting, ToggleComponent, DropdownComponent, setIcon } from 'obsidian';
|
||||
import type { VoiceInputView } from './VoiceInputView';
|
||||
import type VoiceInputPlugin from '../plugin';
|
||||
import { getI18n } from '../services';
|
||||
import type { I18nService } from '../interfaces';
|
||||
import { hasInternalSettingAPI } from '../types';
|
||||
|
||||
/**
|
||||
* Handles UI creation and management for the Voice Input View
|
||||
*/
|
||||
export class VoiceInputViewUI {
|
||||
private view: VoiceInputView;
|
||||
private i18n: I18nService;
|
||||
|
||||
// UI elements
|
||||
container: HTMLElement;
|
||||
textArea: HTMLTextAreaElement;
|
||||
recordButton: HTMLButtonElement;
|
||||
cancelButton: HTMLButtonElement;
|
||||
formatButton: HTMLButtonElement;
|
||||
contextCorrectionButton: HTMLButtonElement;
|
||||
cleanupButton: HTMLButtonElement;
|
||||
clipboardButton: HTMLButtonElement;
|
||||
insertButton: HTMLButtonElement;
|
||||
insertAtCursorButton: HTMLButtonElement;
|
||||
appendButton: HTMLButtonElement;
|
||||
clearButton: HTMLButtonElement;
|
||||
statusEl: HTMLElement;
|
||||
visualizerContainer: HTMLElement;
|
||||
settingsContainer: HTMLElement;
|
||||
firstRowContainer: HTMLElement;
|
||||
secondRowContainer: HTMLElement;
|
||||
|
||||
// Settings controls
|
||||
correctionToggle: ToggleComponent;
|
||||
transcriptionModelDropdown: DropdownComponent;
|
||||
|
||||
// Event handlers
|
||||
private clickHandler: ((e: MouseEvent) => void) | null = null;
|
||||
private mouseDownHandler: ((e: MouseEvent) => void) | null = null;
|
||||
private mouseUpHandler: (() => void) | null = null;
|
||||
private mouseLeaveHandler: (() => void) | null = null;
|
||||
private touchStartHandler: ((e: TouchEvent) => void) | null = null;
|
||||
private touchEndHandler: ((e: TouchEvent) => void) | null = null;
|
||||
private textChangeHandler: ((e: Event) => void) | null = null;
|
||||
|
||||
constructor(view: VoiceInputView, plugin: VoiceInputPlugin, container: HTMLElement) {
|
||||
this.view = view;
|
||||
this.container = container;
|
||||
this.i18n = getI18n();
|
||||
}
|
||||
|
||||
get plugin(): VoiceInputPlugin {
|
||||
return this.view.plugin;
|
||||
}
|
||||
|
||||
get app() {
|
||||
return this.view.app;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create all UI elements
|
||||
*/
|
||||
createUI() {
|
||||
// Title bar with settings button
|
||||
const titleBar = this.container.createDiv('voice-input-title-bar');
|
||||
const titleEl = titleBar.createEl('h3', { text: this.i18n.t('ui.titles.main') });
|
||||
titleEl.addClass('voice-input-title');
|
||||
|
||||
// Settings gear button
|
||||
const settingsButton = titleBar.createEl('button', {
|
||||
cls: 'voice-input-settings-button'
|
||||
});
|
||||
setIcon(settingsButton, 'settings');
|
||||
settingsButton.setAttribute('aria-label', this.i18n.t('ui.tooltips.settingsButton'));
|
||||
settingsButton.addEventListener('click', () => {
|
||||
// Open settings tab using type-safe approach
|
||||
if (hasInternalSettingAPI(this.app) && this.app.setting) {
|
||||
this.app.setting.open();
|
||||
this.app.setting.openTabById(this.plugin.manifest.id);
|
||||
} else {
|
||||
new Notice('Settings panel is not available');
|
||||
}
|
||||
});
|
||||
|
||||
// Settings panel
|
||||
this.settingsContainer = this.container.createDiv('voice-input-settings-panel');
|
||||
this.createSettingsPanel();
|
||||
|
||||
// Visualizer container
|
||||
this.visualizerContainer = this.container.createDiv('voice-input-visualizer');
|
||||
|
||||
// Status display container (between visualizer and text area)
|
||||
const statusContainer = this.container.createDiv('voice-input-status-container');
|
||||
this.statusEl = statusContainer.createEl('div', {
|
||||
text: this.i18n.t('status.idle'),
|
||||
cls: 'voice-input-status-text'
|
||||
});
|
||||
|
||||
// Text area
|
||||
const textContainer = this.container.createDiv('voice-input-text-container');
|
||||
this.textArea = textContainer.createEl('textarea', {
|
||||
placeholder: this.i18n.t('ui.placeholders.textarea'),
|
||||
cls: 'voice-input-textarea'
|
||||
});
|
||||
|
||||
// Setup auto-save on text changes
|
||||
this.setupTextChangeListener();
|
||||
|
||||
// First row buttons (コピー、クリア)
|
||||
this.firstRowContainer = this.container.createDiv('voice-input-button-group voice-input-action-buttons');
|
||||
|
||||
// Copy button
|
||||
this.clipboardButton = this.firstRowContainer.createEl('button', {
|
||||
cls: 'voice-input-button'
|
||||
});
|
||||
this.clipboardButton.createEl('span', {
|
||||
cls: 'voice-input-button-text',
|
||||
text: this.i18n.t('ui.buttons.copy')
|
||||
});
|
||||
this.clipboardButton.setAttribute('aria-label', this.i18n.t('ui.tooltips.copy'));
|
||||
this.clipboardButton.addEventListener('click', () => this.view.actions.copyToClipboard());
|
||||
|
||||
// Clear button
|
||||
this.clearButton = this.firstRowContainer.createEl('button', {
|
||||
cls: 'voice-input-button'
|
||||
});
|
||||
this.clearButton.createEl('span', {
|
||||
cls: 'voice-input-button-text',
|
||||
text: this.i18n.t('ui.buttons.clear')
|
||||
});
|
||||
this.clearButton.setAttribute('aria-label', this.i18n.t('ui.tooltips.clear'));
|
||||
this.clearButton.addEventListener('click', () => {
|
||||
this.view.actions.clearText();
|
||||
});
|
||||
|
||||
// Second row buttons (カーソル位置に挿入、末尾に挿入)
|
||||
this.secondRowContainer = this.container.createDiv('voice-input-button-group voice-input-action-buttons');
|
||||
|
||||
// Insert at cursor button
|
||||
this.insertAtCursorButton = this.secondRowContainer.createEl('button', {
|
||||
cls: 'voice-input-button'
|
||||
});
|
||||
this.insertAtCursorButton.createEl('span', {
|
||||
cls: 'voice-input-button-text',
|
||||
text: this.i18n.t('ui.buttons.insertAtCursor')
|
||||
});
|
||||
this.insertAtCursorButton.setAttribute('aria-label', this.i18n.t('ui.tooltips.insertAtCursor'));
|
||||
this.insertAtCursorButton.addEventListener('click', () => this.view.actions.insertToNote());
|
||||
|
||||
// Append button
|
||||
this.appendButton = this.secondRowContainer.createEl('button', {
|
||||
cls: 'voice-input-button'
|
||||
});
|
||||
this.appendButton.createEl('span', {
|
||||
cls: 'voice-input-button-text',
|
||||
text: this.i18n.t('ui.buttons.append')
|
||||
});
|
||||
this.appendButton.setAttribute('aria-label', this.i18n.t('ui.tooltips.append'));
|
||||
this.appendButton.addEventListener('click', () => this.view.actions.appendToNote());
|
||||
|
||||
// Keep the old insertButton for backward compatibility (set to hidden)
|
||||
/**
|
||||
* @deprecated Use insertAtCursorButton instead. This is kept for backward compatibility and refers to the same element.
|
||||
*/
|
||||
this.insertButton = this.insertAtCursorButton;
|
||||
|
||||
// Cancel button (hidden initially, replaces entire first row when recording)
|
||||
this.cancelButton = this.container.createEl('button', {
|
||||
text: this.i18n.t('ui.buttons.cancel'),
|
||||
cls: 'voice-input-cancel-button-full voice-input-hidden'
|
||||
});
|
||||
this.cancelButton.addEventListener('click', () => {
|
||||
this.view.actions.cancelRecording();
|
||||
});
|
||||
|
||||
// Second row - Record button container
|
||||
const recordButtonContainer = this.container.createDiv('voice-input-record-container');
|
||||
|
||||
// Record button
|
||||
this.recordButton = recordButtonContainer.createEl('button', {
|
||||
text: this.i18n.t('ui.buttons.recordStart'),
|
||||
cls: 'voice-input-record-button'
|
||||
});
|
||||
|
||||
// Add both click and push-to-talk functionality
|
||||
this.setupRecordButtonHandlers();
|
||||
|
||||
// Legacy buttons (hidden)
|
||||
this.formatButton = document.createElement('button');
|
||||
this.formatButton.classList.add('voice-input-hidden');
|
||||
|
||||
this.contextCorrectionButton = document.createElement('button');
|
||||
this.contextCorrectionButton.classList.add('voice-input-hidden');
|
||||
|
||||
this.cleanupButton = document.createElement('button');
|
||||
this.cleanupButton.classList.add('voice-input-hidden');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create settings panel with quick controls
|
||||
*/
|
||||
private createSettingsPanel() {
|
||||
// Transcription model dropdown
|
||||
new Setting(this.settingsContainer)
|
||||
.setName(this.i18n.t('ui.settings.transcriptionModel'))
|
||||
.setClass('voice-input-inline-setting')
|
||||
.addDropdown(dropdown => {
|
||||
this.transcriptionModelDropdown = dropdown;
|
||||
dropdown
|
||||
.addOption('gpt-4o-transcribe', this.i18n.t('ui.options.modelFull'))
|
||||
.addOption('gpt-4o-mini-transcribe', this.i18n.t('ui.options.modelMini'))
|
||||
.setValue(this.plugin.settings.transcriptionModel)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.transcriptionModel = value as 'gpt-4o-transcribe' | 'gpt-4o-mini-transcribe';
|
||||
await this.plugin.saveSettings();
|
||||
this.view.actions.updateTranscriptionService();
|
||||
});
|
||||
});
|
||||
|
||||
// AI Post-processing section
|
||||
new Setting(this.settingsContainer)
|
||||
.setName(this.i18n.t('ui.settings.aiPostProcessing'))
|
||||
.setClass('voice-input-inline-setting')
|
||||
.addToggle(toggle => {
|
||||
this.correctionToggle = toggle;
|
||||
toggle
|
||||
.setValue(this.plugin.settings.enableTranscriptionCorrection)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.enableTranscriptionCorrection = value;
|
||||
await this.plugin.saveSettings();
|
||||
this.view.actions.updateTranscriptionService();
|
||||
// Enable/disable the model dropdown
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup record button event handlers for both click and push-to-talk
|
||||
*/
|
||||
private setupRecordButtonHandlers() {
|
||||
let longPressTimer: NodeJS.Timeout | null = null;
|
||||
let isPushToTalk = false;
|
||||
// let pressStartTime = 0;
|
||||
|
||||
// Create event handlers
|
||||
this.clickHandler = (_e) => {
|
||||
// If this was part of a push-to-talk gesture, ignore it
|
||||
if (isPushToTalk || longPressTimer) {
|
||||
// Don't reset isPushToTalk here - let the timeout handle it
|
||||
return;
|
||||
}
|
||||
|
||||
this.view.actions.toggleRecording();
|
||||
};
|
||||
|
||||
// Push-to-talk functionality with delayed start
|
||||
const startLongPress = () => {
|
||||
// pressStartTime = Date.now();
|
||||
|
||||
// Start a timer to begin recording after threshold
|
||||
longPressTimer = setTimeout(() => {
|
||||
isPushToTalk = true;
|
||||
longPressTimer = null;
|
||||
|
||||
if (!this.view.actions.audioRecorder || !this.view.actions.audioRecorder.isActive()) {
|
||||
this.recordButton.setText(this.i18n.t('ui.buttons.recordPushToTalk'));
|
||||
this.view.actions.recordingState.isPushToTalkMode = true;
|
||||
this.view.actions.startRecording().catch((error) => {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
new Notice(this.i18n.t('error.audio.recordingFailed') + ': ' + errorMessage);
|
||||
});
|
||||
}
|
||||
}, UI_CONSTANTS.PUSH_TO_TALK_THRESHOLD);
|
||||
};
|
||||
|
||||
const endLongPress = () => {
|
||||
// const pressDuration = Date.now() - pressStartTime;
|
||||
|
||||
// Cancel timer if still running (short press)
|
||||
if (longPressTimer) {
|
||||
clearTimeout(longPressTimer);
|
||||
longPressTimer = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// If push-to-talk was active, stop recording
|
||||
if (isPushToTalk && this.view.actions.audioRecorder && this.view.actions.audioRecorder.isActive()) {
|
||||
this.view.actions.recordingState.isPushToTalkMode = false;
|
||||
this.view.actions.stopRecording();
|
||||
// Keep isPushToTalk true to prevent click handler
|
||||
setTimeout(() => { isPushToTalk = false; }, UI_CONSTANTS.PUSH_TO_TALK_RESET_DELAY);
|
||||
}
|
||||
};
|
||||
|
||||
// Create mouse/touch event handlers
|
||||
this.mouseDownHandler = (e) => {
|
||||
e.preventDefault();
|
||||
startLongPress();
|
||||
};
|
||||
|
||||
this.mouseUpHandler = () => {
|
||||
endLongPress();
|
||||
};
|
||||
|
||||
this.mouseLeaveHandler = () => {
|
||||
endLongPress();
|
||||
};
|
||||
|
||||
this.touchStartHandler = (e) => {
|
||||
e.preventDefault();
|
||||
startLongPress();
|
||||
};
|
||||
|
||||
this.touchEndHandler = (e) => {
|
||||
e.preventDefault();
|
||||
endLongPress();
|
||||
};
|
||||
|
||||
// Add event listeners
|
||||
this.recordButton.addEventListener('click', this.clickHandler);
|
||||
this.recordButton.addEventListener('mousedown', this.mouseDownHandler);
|
||||
this.recordButton.addEventListener('mouseup', this.mouseUpHandler);
|
||||
this.recordButton.addEventListener('mouseleave', this.mouseLeaveHandler);
|
||||
this.recordButton.addEventListener('touchstart', this.touchStartHandler);
|
||||
this.recordButton.addEventListener('touchend', this.touchEndHandler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable/disable action buttons
|
||||
*/
|
||||
setButtonsEnabled(enabled: boolean) {
|
||||
this.cleanupButton.disabled = !enabled;
|
||||
this.clipboardButton.disabled = !enabled;
|
||||
this.insertButton.disabled = !enabled;
|
||||
this.insertAtCursorButton.disabled = !enabled;
|
||||
this.appendButton.disabled = !enabled;
|
||||
this.clearButton.disabled = !enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show/hide cancel button during recording
|
||||
*/
|
||||
showCancelButton(show: boolean) {
|
||||
if (show) {
|
||||
// Hide button rows
|
||||
this.firstRowContainer.classList.add('voice-input-hidden');
|
||||
this.firstRowContainer.classList.remove('voice-input-flex');
|
||||
this.secondRowContainer.classList.add('voice-input-hidden');
|
||||
this.secondRowContainer.classList.remove('voice-input-flex');
|
||||
// Show cancel button with flex for centering
|
||||
this.cancelButton.classList.remove('voice-input-hidden');
|
||||
this.cancelButton.classList.add('voice-input-flex');
|
||||
} else {
|
||||
// Show button rows
|
||||
this.firstRowContainer.classList.remove('voice-input-hidden');
|
||||
this.firstRowContainer.classList.add('voice-input-flex');
|
||||
this.secondRowContainer.classList.remove('voice-input-hidden');
|
||||
this.secondRowContainer.classList.add('voice-input-flex');
|
||||
// Hide cancel button
|
||||
this.cancelButton.classList.add('voice-input-hidden');
|
||||
this.cancelButton.classList.remove('voice-input-flex');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update settings UI components to reflect current settings
|
||||
*/
|
||||
updateSettingsUI() {
|
||||
// Update correction toggle
|
||||
if (this.correctionToggle) {
|
||||
this.correctionToggle.setValue(this.plugin.settings.enableTranscriptionCorrection);
|
||||
}
|
||||
|
||||
// Update transcription model dropdown
|
||||
if (this.transcriptionModelDropdown) {
|
||||
this.transcriptionModelDropdown.setValue(this.plugin.settings.transcriptionModel);
|
||||
}
|
||||
|
||||
// Update post-processing model dropdown
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup text change listener for auto-save
|
||||
*/
|
||||
private setupTextChangeListener() {
|
||||
if (!this.textArea) return;
|
||||
|
||||
// Create the text change handler
|
||||
this.textChangeHandler = () => {
|
||||
// Trigger auto-save through the view
|
||||
this.view.onTextChanged();
|
||||
};
|
||||
|
||||
// Listen to multiple events that indicate text changes
|
||||
this.textArea.addEventListener('input', this.textChangeHandler);
|
||||
this.textArea.addEventListener('paste', this.textChangeHandler);
|
||||
this.textArea.addEventListener('cut', this.textChangeHandler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up resources
|
||||
*/
|
||||
destroy() {
|
||||
// Remove text change listener
|
||||
if (this.textArea && this.textChangeHandler) {
|
||||
this.textArea.removeEventListener('input', this.textChangeHandler);
|
||||
this.textArea.removeEventListener('paste', this.textChangeHandler);
|
||||
this.textArea.removeEventListener('cut', this.textChangeHandler);
|
||||
}
|
||||
|
||||
// Remove event listeners
|
||||
if (this.recordButton) {
|
||||
if (this.clickHandler) {
|
||||
this.recordButton.removeEventListener('click', this.clickHandler);
|
||||
}
|
||||
if (this.mouseDownHandler) {
|
||||
this.recordButton.removeEventListener('mousedown', this.mouseDownHandler);
|
||||
}
|
||||
if (this.mouseUpHandler) {
|
||||
this.recordButton.removeEventListener('mouseup', this.mouseUpHandler);
|
||||
}
|
||||
if (this.mouseLeaveHandler) {
|
||||
this.recordButton.removeEventListener('mouseleave', this.mouseLeaveHandler);
|
||||
}
|
||||
if (this.touchStartHandler) {
|
||||
this.recordButton.removeEventListener('touchstart', this.touchStartHandler);
|
||||
}
|
||||
if (this.touchEndHandler) {
|
||||
this.recordButton.removeEventListener('touchend', this.touchEndHandler);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear references
|
||||
this.clickHandler = null;
|
||||
this.mouseDownHandler = null;
|
||||
this.mouseUpHandler = null;
|
||||
this.mouseLeaveHandler = null;
|
||||
this.touchStartHandler = null;
|
||||
this.touchEndHandler = null;
|
||||
this.textChangeHandler = null;
|
||||
}
|
||||
}
|
||||
3
src/views/index.ts
Normal file
3
src/views/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { VoiceInputView, VIEW_TYPE_VOICE_INPUT } from './VoiceInputView';
|
||||
export { VoiceInputViewUI } from './VoiceInputViewUI';
|
||||
export { VoiceInputViewActions } from './VoiceInputViewActions';
|
||||
23
tsconfig.json
Normal file
23
tsconfig.json
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"inlineSourceMap": true,
|
||||
"inlineSources": true,
|
||||
"module": "ESNext",
|
||||
"target": "ES6",
|
||||
"allowJs": true,
|
||||
"noImplicitAny": true,
|
||||
"moduleResolution": "node",
|
||||
"importHelpers": true,
|
||||
"isolatedModules": true,
|
||||
"strictNullChecks": true,
|
||||
"lib": ["DOM", "ES5", "ES6", "ES7", "ES2015.Iterable", "DOM.Iterable"]
|
||||
},
|
||||
"include": ["**/*.ts", "src/types/**/*.d.ts"],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"docs/external/**",
|
||||
"build/**",
|
||||
"tests/**"
|
||||
]
|
||||
}
|
||||
18
tsconfig.test.json
Normal file
18
tsconfig.test.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "Node",
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"types": ["jest", "node"]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*",
|
||||
"tests/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"main.js"
|
||||
]
|
||||
}
|
||||
14
version-bump.mjs
Normal file
14
version-bump.mjs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { readFileSync, writeFileSync } from "fs";
|
||||
|
||||
const targetVersion = process.env.npm_package_version;
|
||||
|
||||
// read minAppVersion from manifest.json and bump version to target version
|
||||
let manifest = JSON.parse(readFileSync("manifest.json", "utf8"));
|
||||
const { minAppVersion } = manifest;
|
||||
manifest.version = targetVersion;
|
||||
writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t"));
|
||||
|
||||
// update versions.json with target version and minAppVersion from manifest.json
|
||||
let versions = JSON.parse(readFileSync("versions.json", "utf8"));
|
||||
versions[targetVersion] = minAppVersion;
|
||||
writeFileSync("versions.json", JSON.stringify(versions, null, "\t"));
|
||||
3
versions.json
Normal file
3
versions.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"0.8.0": "17.0.0"
|
||||
}
|
||||
Loading…
Reference in a new issue