chore(public): initial import of plugin source and config

This commit is contained in:
mssoftjp 2025-08-10 17:54:58 +09:00
parent 0e4e7122b9
commit 0a78540cbc
71 changed files with 16295 additions and 0 deletions

43
.eslintrc.json Normal file
View 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
View file

@ -96,6 +96,7 @@ AGENTS.md
agents.md
.kiro
.windsurf
# Personal, not for public release
scripts/
memo/

21
LICENSE Normal file
View 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
View 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
- Oneclick recording: start/stop from a microphone ribbon icon
- Pushtotalk: longpress to record, release to stop
- Model selection: GPT4o Transcribe or GPT4o mini Transcribe
- AI postprocessing: optional dictionary-based cleanup (JA)
- Quick controls in view: copy/clear/insert at cursor/append to end
- Autosave drafts: periodic and on blur, automatic restore
## Requirements
- OpenAI API key (Audio Transcriptions)
- Internet connectivity
- Obsidian desktop (Windows/macOS). This plugin is desktoponly.
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 Obsidians 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 pushtotalk: longpress 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 plugins settings.
## Settings
- OpenAI API Key: stored locally (encrypted at rest)
- Transcription Model: `gpt-4o-transcribe` or `gpt-4o-mini-transcribe`
- AI Postprocessing: enable dictionarybased cleanup (Japanese)
- Maximum Recording Duration: slider (default 5 min)
- Plugin Language: English/Japanese (autodetected from Obsidian, adjustable)
## Security & Privacy
- Processing in memory; audio is not written to disk by the plugin
- HTTPS for all network requests (via Obsidians `requestUrl`)
- API key is encrypted for storage
See also OpenAIs 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`
Thirdparty licensing: see `THIRD_PARTY_LICENSES.md`.
# Voice Input Plugin for Obsidian日本語
高精度な音声認識でメモを作成できるプラグインです。OpenAI GPT4oの音声文字起こしを使用し、日本語に最適化した文字起こしを行います。
## 機能
- ワンクリック録音(リボンのマイクアイコン)
- プッシュトゥトーク(長押しで録音開始、離して停止)
- モデル選択GPT4o Transcribe / GPT4o 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設定から自動検出、変更可
## セキュリティ / プライバシー
- 処理はメモリ内で行い、音声ファイルはプラグイン側でディスク保存しません
- 通信はHTTPSObsidianの `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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

51
package.json Normal file
View 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
View 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
View file

@ -0,0 +1,107 @@
/**
* APIモデルのコスト情報
* 1USDで表記
* 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
View 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
View 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は現在未使用のため、必要になったらエクスポートを追加

View 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;
}
}
}

View 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 };
}
}

View 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();
}
}

View 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);
}

View 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)');
}
// プラグインIDmanifest.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
View 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';

View 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];
}
}

View 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
View 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;
}
};

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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;
}

View 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
View 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
View 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';

View 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[];
}

View 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 // 通常レベル
};

View 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

File diff suppressed because one or more lines are too long

BIN
src/lib/fvad-wasm/fvad.wasm Normal file

Binary file not shown.

2
src/main.ts Normal file
View file

@ -0,0 +1,2 @@
// Main entry point for Voice Memo Plugin
export { default } from './plugin';

View 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
View 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
View file

@ -0,0 +1,2 @@
export { ViewManager } from './ViewManager';
export { DraftManager } from './DraftManager';

View 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
View file

@ -0,0 +1 @@
export { default } from './VoiceInputPlugin';

View 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 '';
}
}
}

View 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
View file

@ -0,0 +1,2 @@
export { SafeStorageService } from './SafeStorageService';
export { SecurityUtils } from './SecurityUtils';

166
src/services/I18nService.ts Normal file
View 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);
}

View 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
View file

@ -0,0 +1,3 @@
export { serviceLocator, ServiceKeys, getI18n, createServiceLogger } from './ServiceLocator';
export { getI18nService } from './I18nService';
// export { I18nServiceImpl } from './I18nService'; // 未使用: 実装クラスは内部使用のみ

View 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
View file

@ -0,0 +1 @@
export { VoiceInputSettingTab } from './VoiceInputSettingTab';

450
src/styles/voice-input.css Normal file
View 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
View 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';

View 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
View 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';
}

View 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
View 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;

View 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
View 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
View 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) : [];
}

View 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
View 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);
}
}

View 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');
}
}
}

View 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
View 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
View 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
View 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
View 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
View file

@ -0,0 +1,3 @@
{
"0.8.0": "17.0.0"
}