mirror of
https://github.com/mssoftjp/obsidian-voice-input.git
synced 2026-07-22 06:44:48 +00:00
Compare commits
18 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9cde86540e | ||
|
|
fd15231bab | ||
|
|
d8a3200619 | ||
|
|
3249ed9811 | ||
|
|
5c04d21985 | ||
|
|
b4dcb897d2 | ||
|
|
1417f67984 | ||
|
|
6b649ac25e | ||
|
|
3331cece0e | ||
|
|
c40db83bb2 | ||
|
|
dd65bcc549 | ||
|
|
5fc7b50f4c | ||
|
|
738c72694d | ||
|
|
ab368ee387 | ||
|
|
2f77ca92dc | ||
|
|
4f41ee0df4 | ||
|
|
6c81ba0491 | ||
|
|
88289ab60e |
40 changed files with 3210 additions and 1183 deletions
30
.github/workflows/ci.yml
vendored
Normal file
30
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
prepr:
|
||||
name: Pre-PR checks
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run checks
|
||||
run: npm run check:prepr
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -33,6 +33,7 @@ models/
|
|||
tmp/
|
||||
temp/
|
||||
*.tmp
|
||||
obsidian-developer-docs/
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
|
|
|
|||
94
AGENTS.md
Normal file
94
AGENTS.md
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
# Repository Guidelines
|
||||
|
||||
## Project Structure & Module Organization
|
||||
- Source: `src/` (TypeScript). Key areas: `plugin/` (entry/commands/ribbon), `views/` (`VoiceInputView` and other UI/Actions), `core/` (audio/VAD/transcription), `config/`, `utils/`, `interfaces/`, `settings/`, `security/`.
|
||||
- Assets: `src/lib/fvad-wasm/{fvad.wasm,fvad.js}` (copied during build). Keep generated binaries inside `build/<version>/`.
|
||||
- Build output: `build/latest/` and `build/YYYYMMDD_HHMMSS/` (snapshots). Release bundles include `main.js`, `manifest.json`, optional `styles.css`, and wasm/binary assets.
|
||||
- Root holds `manifest.json`, `package.json`; use `tmp/` only for short-lived review artifacts and clean it up.
|
||||
- Tests: `tests/` (Jest + jsdom). Mirrors the `src/` layout.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
- `npm ci`: clean install dependencies.
|
||||
- `npm run dev`: local development with esbuild watch.
|
||||
- `npm run build`: `tsc -noEmit -skipLibCheck` + esbuild, outputs to `build/`.
|
||||
- `npm run build-plugin`: run `npm run build` then post-build steps to populate `build/latest/` and snapshot.
|
||||
- `npm run check`: `npm run lint` then `npm run build`.
|
||||
- `npm run deploy-local`: after build, deploy to detected Obsidian vaults.
|
||||
- `npm test`: run Jest tests. Optional: `npm test -- --runTestsByPath <file>`.
|
||||
- `npm run analyze:unused`: output unused file list from the esbuild dependency graph.
|
||||
- `npm run lint` / `npm run lint:fix`: lint TypeScript sources.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
- Language: TypeScript (ES2018), 2-space indent. Prefer explicit types and interfaces. Avoid `any` and non-null `!`.
|
||||
- Do not mutate shared config constants; derive overrides via helpers (e.g., `getModelConfig`).
|
||||
- Lint: ESLint (`@typescript-eslint`). Example: `npm run lint`.
|
||||
- Modules: barrel-export per folder via `index.ts`.
|
||||
- Naming: classes `PascalCase`; functions/variables `camelCase`. Files follow existing pattern (class files `PascalCase.ts`; folders lowercase).
|
||||
- Comments only for non-obvious logic.
|
||||
|
||||
## Lint & Static Analysis
|
||||
- Pin `eslint-plugin-obsidianmd` to the CI version and ensure `eslint.config.mjs` loads it; update the plugin and config together.
|
||||
- After `npm run build-plugin`, run `npm run check:artifacts` to sanity-check `build/latest/` (required files, manifest JSON, and high-risk patterns) instead of linting minified `main.js`.
|
||||
- Keep TS strict flags: `noImplicitAny`, `noFallthroughCasesInSwitch`, `noImplicitOverride` (consider `noUncheckedIndexedAccess` when helpful).
|
||||
- Prefer a single enum in comparisons/switch; use `assertUnreachable` for exhaustiveness.
|
||||
- Track Obsidian API changes and replace deprecated APIs (e.g., `getFilename`).
|
||||
- Lint imported templates/generated code immediately and clear warnings before merging.
|
||||
- Prefer CSS files over inline styles; class names/IDs must be plugin-prefixed to avoid clashes.
|
||||
- Use `getLanguage()` for locale; avoid browser-only globals in mobile builds.
|
||||
|
||||
## Testing Guidelines
|
||||
- Framework: Jest (`jest-environment-jsdom`).
|
||||
- Location: `tests/**` mirrors `src/**`. Naming uses `*.test.ts`; keep each suite focused.
|
||||
- Execution: `npm test`. Add tests focused on audio/VAD/transcription, controller/storage paths, and settings migration logic.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
- Commits: Conventional Commits; short and imperative (e.g., `fix: ...`, `chore(css): ...`, `refactor(http): centralize requestUrl`, `style(lint): add EOF newlines`).
|
||||
- PRs: include summary/background; attach screenshots or GIFs for UI changes; link related issues; add manual-test notes (e.g., deploy path) and relevant logs for UI/build changes. Ensure `npm run lint`, `npm test`, and `npm run build-plugin` pass.
|
||||
- Do not run `git push`; the user will push at their discretion.
|
||||
|
||||
## Pre-PR Checklist
|
||||
- `npm run lint` (with `eslint-plugin-obsidianmd`).
|
||||
- `npm test` passes.
|
||||
- `npm run build-plugin`.
|
||||
- `npm run check:artifacts` (or `npm run check:prepr`).
|
||||
- Re-check `obsidian-developer-docs/en/Obsidian October plugin self-critique checklist.md`.
|
||||
- Any allowed `any` is justified in code comments or PR notes.
|
||||
- Confirm new/changed commands avoid default hotkeys and commandId/name duplication.
|
||||
|
||||
## Security & Configuration Tips
|
||||
- Never commit API keys; store them encrypted in plugin settings. Obsidian fetches only `main.js`, `manifest.json`, `styles.css`; bundle wasm/binary assets (e.g., `fvad.wasm`) with releases.
|
||||
- For network requests, use Obsidian `requestUrl` via `ObsidianHttpClient`.
|
||||
- Do not commit `build/` output. For local verification use `npm run deploy-local`.
|
||||
|
||||
## Agent Workflow & Issue Hygiene
|
||||
- Align with `.gitignore`; do not expose ignored artifacts.
|
||||
- When creating issues, specify steps, target files/modules, settings/i18n keys, acceptance criteria.
|
||||
- No branch-naming instructions needed; clarify scope with labels and tasks.
|
||||
|
||||
## Obsidian Plugin Compliance (`obsidian-developer-docs/en/Plugins`)
|
||||
- Submission/naming/distribution: remove placeholders; avoid extra "Obsidian" in the plugin name. Do not include plugin name or ID in command names/IDs. `main.js` only in release assets, not in the repo. `fundingUrl` must point to donation services (set if possible). `minAppVersion` should be minimal. Description must be ≤250 chars, end with `.`, no emoji, proper capitalization, start with an action verb. If using Node/Electron set `isDesktopOnly: true`.
|
||||
- General/style: use `this.app`, avoid global `app`. Remove unnecessary `console.log`. No default hotkeys. Do not override core styles (use custom classes + CSS variables). No inline styles in JS/HTML. Replace deprecated APIs flagged by IDE strikeouts. Split large `main.ts`. Namespace selectors with a plugin prefix; prefer CSS over JS styling; use Lucide icons via `setIcon`/`addIcon` (≤ v0.446.0) with custom SVG viewBox `0 0 100 100` when needed.
|
||||
- Security/disclosure: In README disclose payments/accounts/network/external files/ads/telemetry/closed source, etc. Keep dependencies minimal (less is safer). No client-side telemetry. Commit package manager lock file.
|
||||
- UI/settings text: enforce sentence case. Only add setting headings when multiple sections; do not include words like "settings/option". Use `Setting#setHeading` instead of `<h1>`. No default hotkeys. Choose command callback type appropriately (`callback` / `checkCallback` / `editorCallback` / `editorCheckCallback`).
|
||||
- DOM/resources: do not use `innerHTML`/`outerHTML`/`insertAdjacentHTML`; assemble with `createEl` etc. Register events/intervals with `registerEvent`/`registerInterval` and release on unload. Classnames/IDs in DOM must be plugin-prefixed.
|
||||
- Workspace/view: register custom views with `registerView`; do not hold references (use `getLeavesOfType` as needed). Avoid direct `workspace.activeLeaf`; use `getActiveViewOfType`/`activeEditor`. Do not manually detach leaves in `onunload`.
|
||||
- Vault/editor/API: resolve paths via `plugin.manifest.dir`; avoid hardcoded `.obsidian`. Use Editor API for active edits, `Vault.process` for background; use `FileManager.processFrontMatter` for frontmatter. Use `trashFile` to respect user settings when deleting. Store plugin data with `loadData`/`saveData`. Prefer Vault API over Adapter API; locate with `getFileByPath` etc., avoid full scans. Always `normalizePath`. Bundle images/icons locally; avoid remote CDNs.
|
||||
- Mobile compatibility: if `isDesktopOnly: false`, do not require Node modules at top level (guard with `Platform.isDesktopApp` and dynamic require). Avoid `process.platform`; use `Platform`. Determine `Vault.adapter` via `instanceof FileSystemAdapter` (mobile uses `CapacitorAdapter`). Use `requestUrl` instead of `fetch/axios.get`. Beware iOS <16.4 lacking regex lookbehind support. Avoid browser-only globals; status bar items are not available on mobile.
|
||||
- Performance: initialize startup code with `workspace.onLayoutReady()` when possible. Avoid repo-wide path scans. Minify `main.js` for release. Use `import { moment } from 'obsidian'`. Add DeferredViews support if needed for 1.7.2+ compatibility. Continually optimize load times.
|
||||
- Data/sync: keep user data in `saveData()` / `data.json` unless intentionally unsynced; document any external files written. Do not store secrets.
|
||||
- TypeScript/coding: use `const`/`let`; forbid `var`. Prefer `async/await` over promise chains. Avoid globals; avoid `as any` with proper typing. Before casting, check with `instanceof`.
|
||||
|
||||
## Development Workflow Guardrails
|
||||
- Before coding: skim `obsidian-developer-docs/en/Home.md` and `.../Developer policies.md` to align with current Obsidian plugin rules.
|
||||
- Keep `obsidian-developer-docs/en/Obsidian October plugin self-critique checklist.md` open during implementation; use it mid-task (CSS namespacing, API choices, path handling, lifecycle cleanup, etc.) and consult on every change.
|
||||
- Re-check the checklist before opening a PR to avoid reviewer reminders; if a rule must be bent, note the rationale in the PR description.
|
||||
- Treat this section and `obsidian-developer-docs/` as the single source of truth; update both when guidelines change so they never diverge.
|
||||
|
||||
## Communication Preference
|
||||
- Respond to the user in Japanese for all interactions; internal reasoning language is unrestricted.
|
||||
|
||||
## Attitude & Quality Bar
|
||||
- Default to reviewer mindset: proactively surface issues, edge cases, and guideline gaps before they are reported back to us.
|
||||
- Treat guideline compliance (Obsidian docs, repo rules) as part of “done”; don’t weaken lint/rules to fit code—fix code instead.
|
||||
- Favor minimal noise: concise status, clear next steps, and verify with lint/tests/builds after impactful changes.
|
||||
- Assume logs and console noise matter: prevent recurring warnings/errors rather than muting them.
|
||||
63
README.md
63
README.md
|
|
@ -1,6 +1,6 @@
|
|||
# Voice Input Plugin for Obsidian
|
||||
|
||||
High-accuracy multilingual voice input for Obsidian. Uses OpenAI GPT-4o Audio Transcriptions to transcribe speech (with tuned prompts for ja/en/zh/ko) and insert into your notes with one click or push-to-talk.
|
||||
Capture notes with high-accuracy multilingual voice input using OpenAI Speech-to-Text. Uses GPT-4o Audio Transcriptions (with tuned prompts for ja/en/zh/ko) and inserts into your notes with one click or push-to-talk.
|
||||
|
||||
## Features
|
||||
|
||||
|
|
@ -10,10 +10,10 @@ High-accuracy multilingual voice input for Obsidian. Uses OpenAI GPT-4o Audio Tr
|
|||
- Language separation: independent UI language and voice recognition language settings
|
||||
- Language linking: voice recognition follows the UI locale (ja/en/zh/ko) with optional overrides
|
||||
- AI post‑processing: optional dictionary-based cleanup (applied to all languages when enabled)
|
||||
- Quick controls in view: copy/clear/insert at cursor/append to end
|
||||
- Quick controls in view: copy/clear/insert at caret/append to end
|
||||
- Auto‑save drafts: periodic and on blur, automatic restore
|
||||
- Multilingual support: Japanese, English, Chinese, Korean interface languages
|
||||
- Voice activity detection modes: Off by default for maximum accuracy. Optional server-side chunking or local auto‑stop (requires fvad.wasm/fvad.js, installed manually)
|
||||
- Voice activity detection modes: Off by default for maximum accuracy. Optional server-side chunking or local auto‑stop (requires fvad.wasm, installed manually)
|
||||
|
||||
## Requirements
|
||||
|
||||
|
|
@ -31,11 +31,13 @@ Note: OpenAI usage is billed by API.
|
|||
|
||||
Compatibility: requires Obsidian 1.8.0 or later (per `minAppVersion`).
|
||||
|
||||
Local VAD (optional): this plugin does not ship the WebAssembly files. If you want local VAD auto‑stop, download `fvad.wasm` and `fvad.js` from the fvad‑wasm project and place them in the same plugin folder, or use the “Choose fvad.wasm / fvad.js…” button in Settings → Voice Activity Detection to copy them. Local VAD is desktop‑only.
|
||||
Release assets: the plugin only needs `main.js`, `manifest.json`, and `styles.css` from the release bundle. Optional local VAD files are user-provided and are not included in releases.
|
||||
|
||||
Local VAD (optional): this plugin does not ship the WebAssembly file. If you want local VAD auto‑stop, download `fvad.wasm` from the fvad‑wasm project and place it in the same plugin folder, or use the “Choose fvad.wasm…” button in Settings → Voice Activity Detection to copy it. The plugin reads this local file from the installed plugin folder. No external network requests are made for local VAD. Local VAD is desktop‑only.
|
||||
|
||||
## Commands
|
||||
|
||||
- Open Voice Input (`open-voice-input`): opens the Voice Input view. Assign a hotkey from Obsidian’s Settings → Hotkeys if desired.
|
||||
- Open Voice Input (`open-view`): opens the Voice Input view. Assign a hotkey from Obsidian’s Settings → Hotkeys if desired.
|
||||
|
||||
## Usage
|
||||
|
||||
|
|
@ -46,7 +48,7 @@ Local VAD (optional): this plugin does not ship the WebAssembly files. If you wa
|
|||
- Click “Start Voice Input” to toggle recording, or use push‑to‑talk: long‑press the record button (starts after a short threshold), release to stop.
|
||||
|
||||
3) Use the result
|
||||
- Copy, Insert at Cursor, or Append to End of the active note. Clear resets the area.
|
||||
- Copy, Insert at caret, or Append to end of the active note. Clear resets the area.
|
||||
|
||||
Tip: A settings gear in the view header opens the plugin’s settings.
|
||||
|
||||
|
|
@ -58,7 +60,7 @@ Tip: A settings gear in the view header opens the plugin’s settings.
|
|||
- AI Post‑processing: enable dictionary‑based cleanup (applied to all languages when enabled)
|
||||
- Maximum Recording Duration: slider (default 5 min)
|
||||
- Plugin Language: Japanese/English/Chinese/Korean (controls UI display only, auto-detected from Obsidian, adjustable)
|
||||
- Voice Activity Detection: choose Off (default, most accurate), Server (faster turnaround via upstream silence trimming), or Local (desktop auto‑stop; requires `fvad.wasm`/`fvad.js` installed manually)
|
||||
- Voice Activity Detection: choose Off (default, most accurate), Server (faster turnaround via upstream silence trimming), or Local (desktop auto‑stop; requires `fvad.wasm` installed manually)
|
||||
|
||||
### Drafts and sync
|
||||
|
||||
|
|
@ -67,13 +69,16 @@ Tip: A settings gear in the view header opens the plugin’s settings.
|
|||
|
||||
## Security & Privacy
|
||||
|
||||
- Processing in memory; audio is not written to disk by the plugin
|
||||
- Audio you record is transmitted to OpenAI for transcription over HTTPS (via Obsidian’s `requestUrl`).
|
||||
- API key is encrypted for storage
|
||||
|
||||
Note: When Electron SafeStorage is unavailable, the plugin falls back to lightweight obfuscation for the stored key; the plugin is desktop‑only by design.
|
||||
|
||||
See also OpenAI’s Privacy Policy.
|
||||
- Processing in memory: audio is not written to disk by the plugin.
|
||||
- Network use: recorded audio is sent over HTTPS to `https://api.openai.com/v1/audio/transcriptions` via Obsidian’s `requestUrl` for transcription. The settings connection test calls `https://api.openai.com/v1/models`. No telemetry, ads, or self-update requests are sent by the plugin.
|
||||
- Account and billing: an OpenAI API key is required, and OpenAI API usage may be billed by OpenAI.
|
||||
- API key storage: the API key is stored in plugin settings. When Electron SafeStorage is available, the key is encrypted with SafeStorage before saving.
|
||||
- SafeStorage fallback: if Electron SafeStorage is unavailable, the plugin stores the key with a lightweight XOR/Base64 obfuscation fallback for backward compatibility. This fallback is not equivalent to OS-backed encryption.
|
||||
- Clipboard access: the Copy button writes only the current transcription text to the system clipboard. If note creation or insertion fails, the plugin may also write that same transcription text to the clipboard as a recovery fallback. The plugin does not read from the clipboard.
|
||||
- Vault file access: drafts are saved to, loaded from, and cleared from `<vault>/.obsidian/plugins/voice-input/draft.txt` using Obsidian Vault/FileManager APIs. Insert and append actions write only to the active target note, or create a timestamped `Voice-Memo-*.md` note when no suitable note is available.
|
||||
- Local files: optional local VAD reads `fvad.wasm` from the plugin folder after you install or choose it. The release bundle does not include this WebAssembly file.
|
||||
- External links: settings may show a link to the fvad-wasm GitHub project for manual download, but the plugin does not download those files automatically.
|
||||
- Privacy policy: see [OpenAI’s Privacy Policy](https://openai.com/policies/privacy-policy/) for OpenAI API data handling.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
|
@ -100,7 +105,7 @@ Third‑party licensing: see `THIRD_PARTY_LICENSES.md`.
|
|||
- ビュー内のクイック操作(コピー/クリア/カーソル位置へ挿入/末尾へ追記)
|
||||
- 自動保存(定期保存とフォーカス外れ時)。再オープン時に自動復元
|
||||
- 多言語サポート(日本語、英語、中国語、韓国語のインターフェース)
|
||||
- VADモード選択(標準はオフ=精度重視。必要に応じてサーバーVAD(応答を速めたい場合)や `fvad.wasm` / `fvad.js` を使ったローカルVAD(デスクトップの自動停止)を利用可能)
|
||||
- VADモード選択(標準はオフ=精度重視。必要に応じてサーバーVAD(応答を速めたい場合)や `fvad.wasm` を使ったローカルVAD(デスクトップの自動停止)を利用可能)
|
||||
|
||||
## 必要条件
|
||||
|
||||
|
|
@ -114,14 +119,15 @@ Third‑party licensing: see `THIRD_PARTY_LICENSES.md`.
|
|||
|
||||
1. Releases から最新版を取得
|
||||
2. `main.js`、`manifest.json`、`styles.css` を `<Vault>/.obsidian/plugins/voice-input/` に配置
|
||||
3. `<Vault>/.obsidian/plugins/voice-input/` に置く
|
||||
4. Obsidianを再起動し、プラグインを有効化
|
||||
3. Obsidianを再起動し、プラグインを有効化
|
||||
|
||||
ローカルVAD(任意): 本プラグインは WebAssembly ファイルを同梱しません。ローカルVADの自動停止を使う場合、`fvad.wasm` と `fvad.js` を fvad‑wasm プロジェクトから取得して同じフォルダに配置するか、設定 → 音声区間検出 の「fvad.wasm / fvad.js を選択…」ボタンでコピーしてください。ローカルVADはデスクトップ専用です。
|
||||
リリースアセット: プラグインの実行に必要なファイルはリリースバンドル内の `main.js`、`manifest.json`、`styles.css` です。任意のローカルVADファイルはユーザーが用意するもので、リリースには含めません。
|
||||
|
||||
ローカルVAD(任意): 本プラグインは WebAssembly ファイルを同梱しません。ローカルVADの自動停止を使う場合、`fvad.wasm` を fvad‑wasm プロジェクトから取得して同じフォルダに配置するか、設定 → 音声区間検出 の「fvad.wasm を選択…」ボタンでコピーしてください。プラグインはインストール済みプラグインフォルダ内のこのローカルファイルを読み込みます。ローカルVADのための外部ネットワーク通信は行いません。ローカルVADはデスクトップ専用です。
|
||||
|
||||
## コマンド
|
||||
|
||||
- Open Voice Input(`open-voice-input`): 音声入力ビューを開きます。必要に応じてホットキーを割り当ててください。
|
||||
- Open Voice Input(`open-view`): 音声入力ビューを開きます。必要に応じてホットキーを割り当ててください。
|
||||
|
||||
## 使い方
|
||||
|
||||
|
|
@ -144,7 +150,7 @@ Third‑party licensing: see `THIRD_PARTY_LICENSES.md`.
|
|||
- AI後処理: 辞書ベースの補正(有効時は全言語に適用)
|
||||
- 最大録音時間: スライダー(初期値5分)
|
||||
- **プラグイン言語**: UI表示のみを制御。Obsidianの言語設定から自動検出(ja/zh/ko/en)。
|
||||
- 音声区間検出 (VAD): オフ(標準・精度重視)、サーバー(応答を速めたい場合の無音カット)、ローカル(デスクトップの自動停止。`fvad.wasm`/`fvad.js` を手動導入)から選択
|
||||
- 音声区間検出 (VAD): オフ(標準・精度重視)、サーバー(応答を速めたい場合の無音カット)、ローカル(デスクトップの自動停止。`fvad.wasm` を手動導入)から選択
|
||||
|
||||
### ドラフトと同期
|
||||
|
||||
|
|
@ -173,13 +179,16 @@ Third‑party licensing: see `THIRD_PARTY_LICENSES.md`.
|
|||
|
||||
## セキュリティ / プライバシー
|
||||
|
||||
- 処理はメモリ内で行い、音声ファイルはプラグイン側でディスク保存しません
|
||||
- 録音した音声は文字起こしのため OpenAI に送信され、HTTPS(Obsidian の `requestUrl` 経由)で通信します。
|
||||
- APIキーは保存時に暗号化
|
||||
|
||||
補足: Electron の SafeStorage が利用できない環境では保存キーを軽度に難読化して保持します(本プラグインはデスクトップ専用の設計です)。
|
||||
|
||||
OpenAIのプライバシーポリシーもご参照ください。
|
||||
- 処理はメモリ内で行い、音声ファイルはプラグイン側でディスク保存しません。
|
||||
- ネットワーク利用: 録音した音声は文字起こしのため `https://api.openai.com/v1/audio/transcriptions` に HTTPS(Obsidian の `requestUrl` 経由)で送信されます。設定画面の接続テストでは `https://api.openai.com/v1/models` に接続します。プラグインはテレメトリ、広告、自己更新のための通信を行いません。
|
||||
- アカウントと課金: OpenAI API キーが必要です。OpenAI API の利用料金は OpenAI 側で発生する場合があります。
|
||||
- APIキー保存: APIキーはプラグイン設定に保存されます。Electron SafeStorage が利用できる場合は、SafeStorage で暗号化して保存します。
|
||||
- SafeStorage フォールバック: Electron SafeStorage が利用できない場合、後方互換性のため XOR/Base64 による軽度の難読化で保存します。この方式は OS レベルの暗号化と同等ではありません。
|
||||
- クリップボードアクセス: コピーボタンは現在の文字起こし結果だけをシステムクリップボードへ書き込みます。ノート作成や挿入に失敗した場合も、復旧用 fallback として同じ文字起こし結果をクリップボードへ書き込むことがあります。プラグインはクリップボードからの読み取りは行いません。
|
||||
- Vault ファイルアクセス: 下書きは Obsidian の Vault/FileManager API を使って `<Vault>/.obsidian/plugins/voice-input/draft.txt` に保存、読み込み、削除されます。挿入と追記は対象ノートのみに書き込み、適切なノートが見つからない場合は `Voice-Memo-*.md` 形式のノートを作成します。
|
||||
- ローカルファイル: 任意のローカルVADは、ユーザーが配置または選択した `fvad.wasm` をプラグインフォルダから読み込みます。リリースバンドルにはこの WebAssembly ファイルは含めません。
|
||||
- 外部リンク: 設定画面に fvad-wasm の GitHub プロジェクトへのリンクを表示することがありますが、プラグインが自動でこれらのファイルをダウンロードすることはありません。
|
||||
- プライバシーポリシー: OpenAI API におけるデータの扱いは [OpenAI のプライバシーポリシー](https://openai.com/policies/privacy-policy/) も参照してください。
|
||||
|
||||
## トラブルシューティング
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
import esbuild from "esbuild";
|
||||
import process from "process";
|
||||
import builtins from "builtin-modules";
|
||||
import { builtinModules } from "node:module";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
const builtins = [...new Set(builtinModules.flatMap((name) => (
|
||||
name.startsWith("node:") ? [name] : [name, `node:${name}`]
|
||||
)))];
|
||||
|
||||
const banner =
|
||||
`/*
|
||||
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
|
||||
|
|
@ -43,10 +47,10 @@ const context = await esbuild.context({
|
|||
logLevel: "info",
|
||||
sourcemap: prod ? false : "inline",
|
||||
treeShaking: true,
|
||||
minifyWhitespace: prod,
|
||||
minifyIdentifiers: prod,
|
||||
minifySyntax: prod,
|
||||
legalComments: 'inline',
|
||||
// Production builds must be minified (Obsidian dev docs: Optimize plugin load time)
|
||||
minify: prod,
|
||||
// Remove all other comments; banner remains
|
||||
legalComments: 'none',
|
||||
outfile: path.join(outDir, "main.js"),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,53 +1,76 @@
|
|||
import js from '@eslint/js';
|
||||
import tseslint from 'typescript-eslint';
|
||||
import tsParser from '@typescript-eslint/parser';
|
||||
import { defineConfig } from 'eslint/config';
|
||||
import obsidianmd from 'eslint-plugin-obsidianmd';
|
||||
import * as espree from 'espree';
|
||||
import globals from 'globals';
|
||||
|
||||
export default tseslint.config(
|
||||
const manifestJsonParser = {
|
||||
meta: {
|
||||
name: 'manifest-json-parser',
|
||||
version: '1.0.0'
|
||||
},
|
||||
parseForESLint(text, options) {
|
||||
const normalizedText = text.replace(/^\uFEFF/u, '');
|
||||
const wrappedText = `(${normalizedText})`;
|
||||
const ast = espree.parse(wrappedText, {
|
||||
...options,
|
||||
ecmaVersion: 2022,
|
||||
sourceType: 'script',
|
||||
comment: true,
|
||||
loc: true,
|
||||
range: true,
|
||||
tokens: true
|
||||
});
|
||||
return { ast, services: {}, visitorKeys: espree.VisitorKeys };
|
||||
}
|
||||
};
|
||||
|
||||
const obsidianmdManifestRuleOverrides = Object.fromEntries(
|
||||
Object.keys(obsidianmd.rules).map((ruleName) => [`obsidianmd/${ruleName}`, 'off'])
|
||||
);
|
||||
|
||||
export default defineConfig([
|
||||
{
|
||||
ignores: [
|
||||
'node_modules/**',
|
||||
'build/**',
|
||||
'dist/**',
|
||||
'build/**',
|
||||
'coverage/**',
|
||||
'main.js',
|
||||
'docs/**'
|
||||
'*.config.mjs',
|
||||
'scripts/**',
|
||||
'docs/**',
|
||||
'src/lib/fvad-wasm/**'
|
||||
]
|
||||
},
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
...obsidianmd.configs.recommended,
|
||||
...obsidianmd.configs.recommendedWithLocalesEn,
|
||||
{
|
||||
files: [
|
||||
'src/**/*.ts',
|
||||
'tests/**/*.ts'
|
||||
],
|
||||
files: ['manifest.json'],
|
||||
languageOptions: {
|
||||
parser: manifestJsonParser
|
||||
},
|
||||
plugins: {
|
||||
obsidianmd
|
||||
},
|
||||
rules: {
|
||||
...obsidianmdManifestRuleOverrides,
|
||||
'obsidianmd/validate-manifest': 'error'
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['src/**/*.ts', 'tests/**/*.ts'],
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.json', './tsconfig.test.json'],
|
||||
tsconfigRootDir: import.meta.dirname ?? process.cwd()
|
||||
},
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
Option: 'readonly',
|
||||
NodeJS: 'readonly'
|
||||
},
|
||||
parserOptions: {
|
||||
ecmaVersion: 2020,
|
||||
sourceType: 'module',
|
||||
project: ['./tsconfig.json'],
|
||||
tsconfigRootDir: import.meta.dirname
|
||||
}
|
||||
},
|
||||
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', 'debug'] }],
|
||||
'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 }]
|
||||
}
|
||||
}
|
||||
);
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
{
|
||||
"id": "voice-input",
|
||||
"name": "Voice Input",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.5",
|
||||
"minAppVersion": "1.8.0",
|
||||
"description": "AI-powered high-accuracy voice input using OpenAI Speech to text API",
|
||||
"description": "Capture notes with high-accuracy multilingual voice input using OpenAI Speech-to-Text.",
|
||||
"author": "Musashino Software",
|
||||
"authorUrl": "https://github.com/mssoftjp",
|
||||
"fundingUrl": "https://buymeacoffee.com/mssoft",
|
||||
|
|
|
|||
3249
package-lock.json
generated
3249
package-lock.json
generated
File diff suppressed because it is too large
Load diff
30
package.json
30
package.json
|
|
@ -1,8 +1,11 @@
|
|||
{
|
||||
"name": "voice-input",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.5",
|
||||
"description": "Voice input plugin for Obsidian with accurate transcription",
|
||||
"main": "main.js",
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "node esbuild.config.mjs",
|
||||
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
|
||||
|
|
@ -12,8 +15,10 @@
|
|||
"deploy-local": "npm run build-plugin && node scripts/deploy-local.mjs",
|
||||
"deploy:quick": "npm run deploy-local",
|
||||
"check": "npm run lint && npm run build",
|
||||
"lint": "eslint src/**/*.ts",
|
||||
"lint:fix": "eslint src/**/*.ts --fix",
|
||||
"check:artifacts": "node scripts/check-artifacts.mjs",
|
||||
"check:prepr": "npm run lint && npm test && npm run build-plugin && npm run check:artifacts",
|
||||
"lint": "eslint src manifest.json --ext .ts --max-warnings=0",
|
||||
"lint:fix": "eslint src manifest.json --ext .ts --fix --max-warnings=0",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:coverage": "jest --coverage",
|
||||
|
|
@ -29,24 +34,25 @@
|
|||
"author": "Musashino Software",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@eslint/js": "9.39.2",
|
||||
"@eslint/json": "0.14.0",
|
||||
"@types/jest": "30.0.0",
|
||||
"@types/node": "^16.11.6",
|
||||
"@typescript-eslint/eslint-plugin": "^8.46.3",
|
||||
"@typescript-eslint/parser": "^8.46.3",
|
||||
"builtin-modules": "3.3.0",
|
||||
"esbuild": "0.17.3",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-obsidianmd": "^0.1.8",
|
||||
"eslint": "9.39.2",
|
||||
"eslint-plugin-obsidianmd": "0.3.0",
|
||||
"globals": "16.5.0",
|
||||
"jest": "30.0.5",
|
||||
"jest-environment-jsdom": "^30.0.5",
|
||||
"obsidian": "latest",
|
||||
"obsidian": "1.8.7",
|
||||
"ts-jest": "29.4.0",
|
||||
"tslib": "^2.6.2",
|
||||
"typescript": "^5.5.4",
|
||||
"typescript-eslint": "^8.46.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@echogarden/fvad-wasm": "^0.2.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"jest": "^30.0.5",
|
||||
"lodash.merge": "^4.6.2",
|
||||
"ts-jest": "^29.4.0"
|
||||
"@echogarden/fvad-wasm": "^0.2.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
85
scripts/check-artifacts.mjs
Normal file
85
scripts/check-artifacts.mjs
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const rootDir = path.join(__dirname, '..');
|
||||
|
||||
const buildLatestDir = path.join(rootDir, 'build', 'latest');
|
||||
const buildLatestMainPath = path.join(buildLatestDir, 'main.js');
|
||||
const buildLatestManifestPath = path.join(buildLatestDir, 'manifest.json');
|
||||
|
||||
function fail(message) {
|
||||
console.error(`ERROR: ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function ensureFileExists(filePath) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
fail(`Missing file: ${path.relative(rootDir, filePath)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function excerpt(text, index, context = 80) {
|
||||
const start = Math.max(0, index - context);
|
||||
const end = Math.min(text.length, index + context);
|
||||
const prefix = start > 0 ? '…' : '';
|
||||
const suffix = end < text.length ? '…' : '';
|
||||
return `${prefix}${text.slice(start, end)}${suffix}`;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(buildLatestDir)) {
|
||||
fail('Missing build/latest. Run `npm run build-plugin` first.');
|
||||
}
|
||||
|
||||
ensureFileExists(buildLatestMainPath);
|
||||
ensureFileExists(buildLatestManifestPath);
|
||||
|
||||
const mainStat = fs.statSync(buildLatestMainPath);
|
||||
if (!mainStat.isFile() || mainStat.size < 10_000) {
|
||||
fail(`build/latest/main.js looks too small (${mainStat.size} bytes).`);
|
||||
}
|
||||
|
||||
try {
|
||||
JSON.parse(fs.readFileSync(buildLatestManifestPath, 'utf8'));
|
||||
} catch (error) {
|
||||
fail(`build/latest/manifest.json is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
|
||||
const mainJs = fs.readFileSync(buildLatestMainPath, 'utf8');
|
||||
const forbidden = [
|
||||
{ name: 'innerHTML', regex: /\binnerHTML\b/u },
|
||||
{ name: 'outerHTML', regex: /\bouterHTML\b/u },
|
||||
{ name: 'insertAdjacentHTML', regex: /\binsertAdjacentHTML\b/u },
|
||||
{ name: 'eval(', regex: /\beval\s*\(/u },
|
||||
{ name: 'new Function', regex: /\bnew\s+Function\b/u },
|
||||
{ name: 'debugger', regex: /\bdebugger\b/u },
|
||||
{ name: 'console.log(', regex: /\bconsole\.log\s*\(/u }
|
||||
];
|
||||
|
||||
const failures = [];
|
||||
for (const check of forbidden) {
|
||||
const match = mainJs.match(check.regex);
|
||||
if (!match || typeof match.index !== 'number') {
|
||||
continue;
|
||||
}
|
||||
failures.push({
|
||||
name: check.name,
|
||||
index: match.index,
|
||||
snippet: excerpt(mainJs, match.index)
|
||||
});
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
for (const failureInfo of failures) {
|
||||
console.error(`ERROR: Forbidden pattern "${failureInfo.name}" found in build/latest/main.js (index ${failureInfo.index}).`);
|
||||
console.error(failureInfo.snippet);
|
||||
console.error('');
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('OK: build/latest artifacts passed sanity checks.');
|
||||
|
|
@ -142,7 +142,6 @@ export const FILE_CONSTANTS = {
|
|||
|
||||
/** WASMファイルのパス */
|
||||
WASM_PATHS: {
|
||||
JS: 'src/lib/fvad-wasm/fvad.js',
|
||||
WASM: 'src/lib/fvad-wasm/fvad.wasm'
|
||||
}
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -141,9 +141,13 @@ export const ConfigHelper = {
|
|||
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);
|
||||
if (value === 'true') {
|
||||
value = true;
|
||||
} else if (value === 'false') {
|
||||
value = false;
|
||||
} else if (!isNaN(Number(value))) {
|
||||
value = Number(value);
|
||||
}
|
||||
|
||||
// ネストされたキーの処理
|
||||
const keys = configKey.split('.');
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ export class AudioRecorder extends Disposable {
|
|||
private isRecording: boolean = false;
|
||||
private stream: MediaStream | null = null;
|
||||
private options: AudioRecorderOptions;
|
||||
private silenceTimer: NodeJS.Timeout | null = null;
|
||||
private silenceTimer: number | null = null;
|
||||
private lastSpeechTime: number = 0;
|
||||
private continuousAudioData: Float32Array[] = [];
|
||||
private audioRingBuffer: AudioRingBuffer;
|
||||
|
|
@ -50,7 +50,7 @@ export class AudioRecorder extends Disposable {
|
|||
private disposables: CompositeDisposable = new CompositeDisposable();
|
||||
private logger: Logger | null = null;
|
||||
private recordingStartTime: number = 0;
|
||||
private maxRecordingTimer: NodeJS.Timeout | null = null;
|
||||
private maxRecordingTimer: number | null = null;
|
||||
private isStarting: boolean = false;
|
||||
|
||||
constructor(options: AudioRecorderOptions) {
|
||||
|
|
@ -279,7 +279,7 @@ export class AudioRecorder extends Disposable {
|
|||
|
||||
// Set up maximum recording time limit
|
||||
const maxSeconds = this.options.maxRecordingSeconds || AUDIO_CONSTANTS.MAX_RECORDING_SECONDS;
|
||||
this.maxRecordingTimer = setTimeout(() => {
|
||||
this.maxRecordingTimer = window.setTimeout(() => {
|
||||
this.logger?.info(`Maximum recording time (${maxSeconds}s) reached, stopping recording`);
|
||||
void this.stopRecording()
|
||||
.then((audioBlob) => {
|
||||
|
|
@ -401,10 +401,10 @@ export class AudioRecorder extends Disposable {
|
|||
this.logger?.error('Failed to process audio data from analyser fallback', error);
|
||||
}
|
||||
|
||||
this.analyserFrameRequest = requestAnimationFrame(processFrame);
|
||||
this.analyserFrameRequest = window.requestAnimationFrame(processFrame);
|
||||
};
|
||||
|
||||
this.analyserFrameRequest = requestAnimationFrame(processFrame);
|
||||
this.analyserFrameRequest = window.requestAnimationFrame(processFrame);
|
||||
}
|
||||
|
||||
private stopAnalyserProcessing(): void {
|
||||
|
|
@ -489,7 +489,7 @@ export class AudioRecorder extends Disposable {
|
|||
this.lastSpeechTime = Date.now();
|
||||
|
||||
if (this.silenceTimer) {
|
||||
clearTimeout(this.silenceTimer);
|
||||
window.clearTimeout(this.silenceTimer);
|
||||
this.silenceTimer = null;
|
||||
}
|
||||
|
||||
|
|
@ -514,7 +514,7 @@ export class AudioRecorder extends Disposable {
|
|||
// Check if we should auto-stop due to silence (only in VAD mode)
|
||||
if (this.options.useVAD && this.lastSpeechTime > 0 && !this.silenceTimer) {
|
||||
const autoStopDuration = this.options.autoStopSilenceDuration ?? DEFAULT_AUDIO_SETTINGS.autoStopSilenceDuration;
|
||||
this.silenceTimer = setTimeout(() => {
|
||||
this.silenceTimer = window.setTimeout(() => {
|
||||
if (!this.isRecording) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -543,7 +543,7 @@ export class AudioRecorder extends Disposable {
|
|||
|
||||
// Clear maximum recording timer
|
||||
if (this.maxRecordingTimer) {
|
||||
clearTimeout(this.maxRecordingTimer);
|
||||
window.clearTimeout(this.maxRecordingTimer);
|
||||
this.maxRecordingTimer = null;
|
||||
}
|
||||
|
||||
|
|
@ -576,12 +576,12 @@ export class AudioRecorder extends Disposable {
|
|||
|
||||
private cleanup(): void {
|
||||
if (this.silenceTimer) {
|
||||
clearTimeout(this.silenceTimer);
|
||||
window.clearTimeout(this.silenceTimer);
|
||||
this.silenceTimer = null;
|
||||
}
|
||||
|
||||
if (this.maxRecordingTimer) {
|
||||
clearTimeout(this.maxRecordingTimer);
|
||||
window.clearTimeout(this.maxRecordingTimer);
|
||||
this.maxRecordingTimer = null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ export class AudioVisualizer extends Disposable {
|
|||
|
||||
constructor(container: HTMLElement) {
|
||||
super();
|
||||
this.canvas = document.createElement('canvas');
|
||||
this.canvas = activeDocument.createElement('canvas');
|
||||
this.canvas.className = 'voice-input-audio-visualizer-canvas';
|
||||
this.canvas.width = 200;
|
||||
this.canvas.height = 56;
|
||||
|
|
@ -46,7 +46,7 @@ export class AudioVisualizer extends Disposable {
|
|||
stop(): void {
|
||||
this.isRecording = false;
|
||||
if (this.animationId) {
|
||||
cancelAnimationFrame(this.animationId);
|
||||
window.cancelAnimationFrame(this.animationId);
|
||||
this.animationId = null;
|
||||
}
|
||||
this.clear();
|
||||
|
|
@ -65,7 +65,7 @@ export class AudioVisualizer extends Disposable {
|
|||
return;
|
||||
}
|
||||
|
||||
this.animationId = requestAnimationFrame(() => this.draw());
|
||||
this.animationId = window.requestAnimationFrame(() => this.draw());
|
||||
|
||||
// Get time domain data (waveform)
|
||||
const waveformArray = this.dataArray as Uint8Array<ArrayBuffer>;
|
||||
|
|
@ -230,20 +230,20 @@ export class SimpleAudioLevelIndicator extends Disposable {
|
|||
}
|
||||
|
||||
private createUI(): void {
|
||||
const wrapper = document.createElement('div');
|
||||
const wrapper = activeDocument.createElement('div');
|
||||
wrapper.className = 'voice-input-audio-level-indicator';
|
||||
|
||||
// Level bar
|
||||
const levelContainer = document.createElement('div');
|
||||
const levelContainer = activeDocument.createElement('div');
|
||||
levelContainer.className = 'voice-input-audio-level-container';
|
||||
|
||||
this.levelBar = document.createElement('div');
|
||||
this.levelBar = activeDocument.createElement('div');
|
||||
this.levelBar.className = 'voice-input-audio-level-bar';
|
||||
|
||||
levelContainer.appendChild(this.levelBar);
|
||||
|
||||
// VAD indicator
|
||||
this.vadIndicator = document.createElement('div');
|
||||
this.vadIndicator = activeDocument.createElement('div');
|
||||
this.vadIndicator.className = 'voice-input-audio-vad-indicator';
|
||||
|
||||
wrapper.appendChild(levelContainer);
|
||||
|
|
@ -265,7 +265,7 @@ export class SimpleAudioLevelIndicator extends Disposable {
|
|||
stop(): void {
|
||||
this.isActive = false;
|
||||
if (this.animationId) {
|
||||
cancelAnimationFrame(this.animationId);
|
||||
window.cancelAnimationFrame(this.animationId);
|
||||
this.animationId = null;
|
||||
}
|
||||
this.setLevelBarClass(0);
|
||||
|
|
@ -284,7 +284,7 @@ export class SimpleAudioLevelIndicator extends Disposable {
|
|||
return;
|
||||
}
|
||||
|
||||
this.animationId = requestAnimationFrame(() => this.update());
|
||||
this.animationId = window.requestAnimationFrame(() => this.update());
|
||||
|
||||
const frequencyArray = this.dataArray as Uint8Array<ArrayBuffer>;
|
||||
this.analyser.getByteFrequencyData(frequencyArray);
|
||||
|
|
|
|||
|
|
@ -1,111 +1,9 @@
|
|||
/**
|
||||
* AudioWorklet source code as a string
|
||||
* This allows us to create a blob URL for loading in restricted environments
|
||||
* AudioWorklet source code as a single-line string with comments stripped
|
||||
* to keep bundled output comment-free (except the esbuild banner).
|
||||
*/
|
||||
|
||||
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);
|
||||
`;
|
||||
export const AUDIO_WORKLET_SOURCE =
|
||||
`class AudioProcessorWorklet extends AudioWorkletProcessor{constructor(){super();this.bufferSize=4096;this.audioBuffer=[];this.isRecording=!1;this.port.onmessage=e=>{this.handleMessage(e.data)}}handleMessage(e){switch(e.type){case"start":this.isRecording=!0,this.audioBuffer=[];break;case"stop":this.isRecording=!1,this.audioBuffer.length>0&&this.sendAudioData();break;case"configure":e.bufferSize&&(this.bufferSize=e.bufferSize);break}}process(e){const t=e[0];if(!this.isRecording||!t||t.length===0)return!0;const s=t[0];if(!s||s.length===0)return!0;this.audioBuffer.push(new Float32Array(s));const r=this.audioBuffer.reduce((n,o)=>n+o.length,0);return r>=this.bufferSize&&this.sendAudioData(),!0}sendAudioData(){if(this.audioBuffer.length===0)return;const e=this.audioBuffer.reduce((t,s)=>t+s.length,0),t=new Float32Array(e);let s=0;for(const r of this.audioBuffer)t.set(r,s),s+=r.length;this.port.postMessage({type:"audio",data:t,timestamp:currentTime}),this.audioBuffer=[]}}registerProcessor("audio-processor-worklet",AudioProcessorWorklet);`;
|
||||
|
||||
/**
|
||||
* Create a blob URL for the AudioWorklet
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { App, FileSystemAdapter, normalizePath } from 'obsidian';
|
||||
import createFvadModule from '@echogarden/fvad-wasm';
|
||||
import { VAD_CONSTANTS, DEFAULT_VAD_SETTINGS, FILE_CONSTANTS } from '../../config';
|
||||
import { Disposable } from '../../interfaces';
|
||||
import { FvadModule, WindowWithFvad, hasFvadModule } from '../../types';
|
||||
import { FvadModule } from '../../types';
|
||||
import { createServiceLogger } from '../../services';
|
||||
import { Logger } from '../../utils';
|
||||
|
||||
|
|
@ -104,86 +105,12 @@ export class VADProcessor extends Disposable {
|
|||
* fvad モジュールを初期化
|
||||
*/
|
||||
private async initializeFvadModule(wasmBuffer: ArrayBuffer): Promise<void> {
|
||||
// スクリプトタグを使用して fvad.js を読み込む
|
||||
// これは Obsidian の制限された環境でモジュールを読み込む最も確実な方法
|
||||
this.ensureFileSystemAdapter();
|
||||
|
||||
// グローバルオブジェクトを準備(型安全に)
|
||||
const globalWindow = window as WindowWithFvad;
|
||||
|
||||
// fvad.js の内容を読み込んで評価(プラグインルートから)
|
||||
const fvadJsPath = this.getPluginAssetPath('fvad.js');
|
||||
const fvadJsContent = await this.readPluginTextAsset('fvad.js');
|
||||
|
||||
// モジュールを評価するための一時的な環境を作成
|
||||
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(() => {
|
||||
const loadModule = async (): Promise<void> => {
|
||||
if (!hasFvadModule(globalWindow)) {
|
||||
throw new Error('fvad module not found in global scope');
|
||||
}
|
||||
|
||||
const createModule = globalWindow.__fvadModule;
|
||||
if (!createModule) {
|
||||
throw new Error('fvad module factory is undefined');
|
||||
}
|
||||
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 instanceof Error ? error : new Error(String(error)));
|
||||
});
|
||||
return {};
|
||||
}
|
||||
});
|
||||
|
||||
// クリーンアップ
|
||||
document.head.removeChild(script);
|
||||
delete globalWindow.__fvadModule;
|
||||
};
|
||||
|
||||
loadModule()
|
||||
.then(() => resolve())
|
||||
.catch((error) => {
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
});
|
||||
}, 100); // モジュール読み込みを待つ
|
||||
this.fvadModule = await createFvadModule({
|
||||
wasmBinary: new Uint8Array(wasmBuffer)
|
||||
});
|
||||
this.logger.debug('fvad module initialized from bundled loader');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -244,21 +171,6 @@ export class VADProcessor extends Disposable {
|
|||
return normalizePath(`${this.getPluginAssetBasePath()}/${fileName}`);
|
||||
}
|
||||
|
||||
private async readPluginTextAsset(fileName: string): Promise<string> {
|
||||
const fullPath = this.getPluginAssetPath(fileName);
|
||||
const existingFile = this.app.vault.getFileByPath(fullPath);
|
||||
if (existingFile) {
|
||||
return this.app.vault.read(existingFile);
|
||||
}
|
||||
|
||||
const adapter = this.ensureFileSystemAdapter();
|
||||
const exists = await adapter.exists(fullPath);
|
||||
if (!exists) {
|
||||
throw new Error(`Asset not found: ${fullPath}`);
|
||||
}
|
||||
return adapter.read(fullPath);
|
||||
}
|
||||
|
||||
private async readPluginBinaryAsset(fileName: string): Promise<ArrayBuffer> {
|
||||
const fullPath = this.getPluginAssetPath(fileName);
|
||||
const existingFile = this.app.vault.getFileByPath(fullPath);
|
||||
|
|
|
|||
|
|
@ -187,7 +187,9 @@ export class ErrorHandler implements IDisposable {
|
|||
context: ErrorContext,
|
||||
severity: ErrorSeverity = ErrorSeverity.ERROR
|
||||
): void {
|
||||
if (this._isDisposed) return;
|
||||
if (this._isDisposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
// エラーログに記録
|
||||
const logEntry: ErrorLogEntry = {
|
||||
|
|
@ -388,7 +390,7 @@ export class ErrorHandler implements IDisposable {
|
|||
* 遅延処理
|
||||
*/
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
return new Promise(resolve => window.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -409,7 +411,9 @@ export class ErrorHandler implements IDisposable {
|
|||
* リソースの解放
|
||||
*/
|
||||
dispose(): void {
|
||||
if (this._isDisposed) return;
|
||||
if (this._isDisposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.unhandledRejectionHandler) {
|
||||
window.removeEventListener('unhandledrejection', this.unhandledRejectionHandler);
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ export const en: TranslationResource = {
|
|||
serviceInitFailed: 'Service initialization failed',
|
||||
audioTooShort: 'Audio is too short',
|
||||
noAudioDetected: 'No audio detected',
|
||||
localVadMissing: 'Local VAD module not found. Using server-side detection instead. Place fvad.wasm and fvad.js under {path}.'
|
||||
localVadMissing: 'Local VAD module not found. Using server-side detection instead. Place fvad.wasm under {path}.'
|
||||
},
|
||||
error: {
|
||||
clipboardFailed: 'Failed to copy to clipboard',
|
||||
|
|
@ -131,7 +131,7 @@ export const en: TranslationResource = {
|
|||
cleanup: 'Clean up',
|
||||
copy: 'Copy',
|
||||
insert: 'Insert to note',
|
||||
insertAtCursor: 'Insert at cursor',
|
||||
insertAtCursor: 'Insert at caret',
|
||||
append: 'Append to end',
|
||||
clear: 'Clear',
|
||||
cancel: 'Cancel',
|
||||
|
|
@ -150,7 +150,7 @@ export const en: TranslationResource = {
|
|||
},
|
||||
titles: {
|
||||
main: 'Voice input',
|
||||
settings: 'Voice input settings'
|
||||
settings: 'Voice input'
|
||||
},
|
||||
settings: {
|
||||
apiKey: 'OpenAI API key',
|
||||
|
|
@ -180,20 +180,20 @@ export const en: TranslationResource = {
|
|||
dictionaryImportExport: 'Dictionary import/export',
|
||||
dictionaryImportExportDesc: 'Import or export your correction dictionary as JSON',
|
||||
vadMode: 'Voice activity detection',
|
||||
vadModeDesc: 'Off (default) keeps the raw audio for maximum accuracy. Server VAD can trim silence upstream for quicker turnaround but may slightly affect segmentation accuracy. Local VAD (requires fvad.wasm/fvad.js) stops recording automatically on the desktop.',
|
||||
vadModeLocalMissing: 'Local VAD requires fvad.wasm and fvad.js under {path}. Install them before switching.',
|
||||
vadModeDesc: 'Off keeps the raw audio for maximum accuracy. Server VAD can trim silence upstream for quicker turnaround but may slightly affect segmentation accuracy. Local VAD requires fvad.wasm and stops recording automatically on the desktop.',
|
||||
vadModeLocalMissing: 'Local VAD requires fvad.wasm under {path}. Install it before switching.',
|
||||
vadModeLocalAvailable: 'Local VAD files found in {path}. Recording will auto-stop on silence.',
|
||||
vadModeDisabledDesc: 'Voice activity detection is disabled – audio is recorded continuously and sent untouched.',
|
||||
vadModeSummaryServer: 'Cuts silence on the server for faster turnaround (cloud processing).',
|
||||
vadModeSummaryLocal: 'Detects silence locally and auto-stops on desktop (requires fvad.wasm/fvad.js).',
|
||||
vadModeInstallButton: 'Choose fvad.wasm / fvad.js…',
|
||||
vadModeInstallDesc: 'Download fvad.wasm (and fvad.js) from the official WebRTC voice activity detection port, then choose the wasm file to copy it into the plugin folder (desktop only).',
|
||||
vadModeSummaryLocal: 'Detects silence locally and auto-stops on desktop (requires fvad.wasm).',
|
||||
vadModeInstallButton: 'Choose fvad.wasm…',
|
||||
vadModeInstallDesc: 'Download fvad.wasm from the official WebRTC voice activity detection port, then choose the wasm file to copy it into the plugin folder (desktop only).',
|
||||
vadModeInstallLinkLabel: 'Visit the fvad-wasm project',
|
||||
vadModeInstallInvalidName: 'Please select a file named fvad.wasm.',
|
||||
vadModeInstallInvalidType: 'The selected file does not appear to be a WebAssembly module.',
|
||||
vadModeInstallSuccess: 'The fvad.wasm file is installed. Local voice activity detection will run the next time you record.',
|
||||
vadModeInstallWriteError: 'Failed to install VAD assets: {error}',
|
||||
vadModeInstallJsMissing: 'Local voice activity detection also needs fvad.js in the same plugin folder. Copy it manually if it is not present.'
|
||||
vadModeInstallJsMissing: 'Local voice activity detection now only requires fvad.wasm. Update the plugin if this message appears.'
|
||||
},
|
||||
options: {
|
||||
modelMini: 'GPT-4o mini transcribe',
|
||||
|
|
@ -209,8 +209,8 @@ export const en: TranslationResource = {
|
|||
},
|
||||
tooltips: {
|
||||
copy: 'Copy to clipboard',
|
||||
insert: 'Insert at cursor',
|
||||
insertAtCursor: 'Insert at cursor position',
|
||||
insert: 'Insert at caret',
|
||||
insertAtCursor: 'Insert at caret position',
|
||||
append: 'Append to end of note',
|
||||
clear: 'Press twice to clear text area',
|
||||
settingsButton: 'Open settings'
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ export const ja: TranslationResource = {
|
|||
serviceInitFailed: 'サービスの初期化に失敗しました',
|
||||
audioTooShort: '音声が短すぎます',
|
||||
noAudioDetected: '音声が検出されませんでした',
|
||||
localVadMissing: 'ローカルVADモジュールが見つからないため、サーバーVADに切り替えます。{path} に fvad.wasm と fvad.js を配置してください。'
|
||||
localVadMissing: 'ローカルVADモジュールが見つからないため、サーバーVADに切り替えます。{path} に fvad.wasm を配置してください。'
|
||||
},
|
||||
error: {
|
||||
clipboardFailed: 'クリップボードへのコピーに失敗しました',
|
||||
|
|
@ -180,20 +180,20 @@ export const ja: TranslationResource = {
|
|||
dictionaryImportExport: '辞書のインポート/エクスポート',
|
||||
dictionaryImportExportDesc: '補正辞書をJSONファイルとしてインポート・エクスポート',
|
||||
vadMode: '音声区間検出(VAD)',
|
||||
vadModeDesc: 'オフ(標準)は録音をそのまま送信するため精度重視です。サーバーVADを有効にすると無音区間を先にカットし、応答が速くなる場合がありますが、切り分けによって文字起こし精度に影響する可能性があります。ローカルVAD(fvad.wasm/fvad.js が必要)はPC側で無音を検出し自動停止します。',
|
||||
vadModeLocalMissing: 'ローカルVADを有効にするには {path} に fvad.wasm と fvad.js を配置してください。',
|
||||
vadModeDesc: 'オフ(標準)は録音をそのまま送信するため精度重視です。サーバーVADを有効にすると無音区間を先にカットし、応答が速くなる場合がありますが、切り分けによって文字起こし精度に影響する可能性があります。ローカルVAD(fvad.wasm が必要)はPC側で無音を検出し自動停止します。',
|
||||
vadModeLocalMissing: 'ローカルVADを有効にするには {path} に fvad.wasm を配置してください。',
|
||||
vadModeLocalAvailable: '{path} でローカルVAD用モジュールを検出しました。無音で自動停止します。',
|
||||
vadModeDisabledDesc: 'VADを無効化すると録音は常に継続し、加工せず送信されます。',
|
||||
vadModeSummaryServer: 'サーバー側で無音をカット(応答を速めたい場合)',
|
||||
vadModeSummaryLocal: 'デスクトップで無音を検出して自動停止(fvad.wasm/fvad.js 必須)',
|
||||
vadModeInstallButton: 'fvad.wasm / fvad.js を選択…',
|
||||
vadModeInstallDesc: '公式の WebRTC VAD 移植プロジェクトから fvad.wasm(および fvad.js)をダウンロードし、ここで wasm ファイルを選択するとプラグインフォルダにコピーされます(デスクトップのみ)。',
|
||||
vadModeSummaryLocal: 'デスクトップで無音を検出して自動停止(fvad.wasm 必須)',
|
||||
vadModeInstallButton: 'fvad.wasm を選択…',
|
||||
vadModeInstallDesc: '公式の WebRTC VAD 移植プロジェクトから fvad.wasm をダウンロードし、ここで wasm ファイルを選択するとプラグインフォルダにコピーされます(デスクトップのみ)。',
|
||||
vadModeInstallLinkLabel: 'fvad-wasm プロジェクト',
|
||||
vadModeInstallInvalidName: 'fvad.wasm というファイルを選択してください。',
|
||||
vadModeInstallInvalidType: 'WebAssembly モジュールではないファイルが選択されました。',
|
||||
vadModeInstallSuccess: 'fvad.wasm を配置しました。次回の録音からローカルVADを試行します。',
|
||||
vadModeInstallWriteError: 'VAD関連ファイルの配置に失敗しました: {error}',
|
||||
vadModeInstallJsMissing: 'ローカルVADには fvad.js も同じフォルダに必要です。存在しない場合は手動で配置してください。'
|
||||
vadModeInstallJsMissing: '現在のローカルVADは fvad.wasm のみを必要とします。この表示が出る場合はプラグインを更新してください。'
|
||||
},
|
||||
options: {
|
||||
modelMini: 'GPT-4o mini Transcribe',
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ export const ko: TranslationResource = {
|
|||
serviceInitFailed: '서비스 초기화에 실패했습니다',
|
||||
audioTooShort: '오디오가 너무 짧습니다',
|
||||
noAudioDetected: '오디오가 감지되지 않았습니다',
|
||||
localVadMissing: '로컬 VAD 모듈을 찾을 수 없어 서버 VAD로 전환합니다. {path} 에 fvad.wasm 및 fvad.js 를 배치하세요.'
|
||||
localVadMissing: '로컬 VAD 모듈을 찾을 수 없어 서버 VAD로 전환합니다. {path} 에 fvad.wasm 을 배치하세요.'
|
||||
},
|
||||
error: {
|
||||
clipboardFailed: '클립보드 복사에 실패했습니다',
|
||||
|
|
@ -180,20 +180,20 @@ export const ko: TranslationResource = {
|
|||
dictionaryImportExport: '사전 가져오기/내보내기',
|
||||
dictionaryImportExportDesc: '교정 사전을 JSON 파일로 가져오기 또는 내보내기',
|
||||
vadMode: '음성 활동 감지(VAD)',
|
||||
vadModeDesc: '기본값인 “끄기”는 원본 오디오를 그대로 전송하여 최고 정확도를 유지합니다. 서버 VAD를 켜면 업로드 전에 무음을 잘라 전체 응답 속도가 빨라질 수 있지만, 분할 방식에 따라 정확도에 영향이 있을 수 있습니다. 로컬 VAD(fvad.wasm / fvad.js 필요)는 데스크톱에서 무음을 감지해 자동으로 녹음을 멈춥니다.',
|
||||
vadModeLocalMissing: '{path} 에 fvad.wasm 및 fvad.js 를 배치하면 로컬 VAD를 사용할 수 있습니다.',
|
||||
vadModeDesc: '기본값인 “끄기”는 원본 오디오를 그대로 전송하여 최고 정확도를 유지합니다. 서버 VAD를 켜면 업로드 전에 무음을 잘라 전체 응답 속도가 빨라질 수 있지만, 분할 방식에 따라 정확도에 영향이 있을 수 있습니다. 로컬 VAD(fvad.wasm 필요)는 데스크톱에서 무음을 감지해 자동으로 녹음을 멈춥니다.',
|
||||
vadModeLocalMissing: '{path} 에 fvad.wasm 을 배치하면 로컬 VAD를 사용할 수 있습니다.',
|
||||
vadModeLocalAvailable: '{path} 에서 로컬 VAD 모듈을 감지했습니다. 무음 시 자동으로 녹음을 중지합니다.',
|
||||
vadModeDisabledDesc: 'VAD 끄기: 무음이 있어도 녹음을 계속 진행합니다.',
|
||||
vadModeSummaryServer: '서버에서 무음을 잘라 응답을 빠르게 함 (클라우드)',
|
||||
vadModeSummaryLocal: '데스크톱에서 무음을 감지해 자동 중지 (fvad.wasm/fvad.js 필요)',
|
||||
vadModeInstallButton: 'fvad.wasm / fvad.js 선택…',
|
||||
vadModeInstallDesc: '공식 WebRTC VAD 포트에서 fvad.wasm(및 fvad.js)을 다운로드한 뒤 여기서 wasm 파일을 선택하면 플러그인 폴더에 복사됩니다(데스크톱 한정).',
|
||||
vadModeSummaryLocal: '데스크톱에서 무음을 감지해 자동 중지 (fvad.wasm 필요)',
|
||||
vadModeInstallButton: 'fvad.wasm 선택…',
|
||||
vadModeInstallDesc: '공식 WebRTC VAD 포트에서 fvad.wasm을 다운로드한 뒤 여기서 wasm 파일을 선택하면 플러그인 폴더에 복사됩니다(데스크톱 한정).',
|
||||
vadModeInstallLinkLabel: 'fvad-wasm 프로젝트',
|
||||
vadModeInstallInvalidName: '파일 이름이 fvad.wasm 인 것을 선택해주세요.',
|
||||
vadModeInstallInvalidType: '선택한 파일이 WebAssembly 모듈이 아닌 것 같습니다.',
|
||||
vadModeInstallSuccess: 'fvad.wasm 설치 완료. 다음 녹음부터 로컬 VAD를 시도합니다.',
|
||||
vadModeInstallWriteError: 'VAD 관련 파일 설치에 실패했습니다: {error}',
|
||||
vadModeInstallJsMissing: '로컬 VAD에는 fvad.js 도 필요합니다. 없을 경우 동일한 폴더에 직접 복사해 주세요.'
|
||||
vadModeInstallJsMissing: '현재 로컬 VAD는 fvad.wasm만 필요합니다. 이 메시지가 표시되면 플러그인을 업데이트하세요.'
|
||||
},
|
||||
options: {
|
||||
modelMini: 'GPT-4o mini Transcribe',
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ export const zh: TranslationResource = {
|
|||
serviceInitFailed: '服务初始化失败',
|
||||
audioTooShort: '音频过短',
|
||||
noAudioDetected: '未检测到音频',
|
||||
localVadMissing: '未找到本地VAD模块,已切换为服务器端检测。请将 fvad.wasm 和 fvad.js 放在 {path}。'
|
||||
localVadMissing: '未找到本地VAD模块,已切换为服务器端检测。请将 fvad.wasm 放在 {path}。'
|
||||
},
|
||||
error: {
|
||||
clipboardFailed: '复制到剪贴板失败',
|
||||
|
|
@ -180,20 +180,20 @@ export const zh: TranslationResource = {
|
|||
dictionaryImportExport: '词典导入/导出',
|
||||
dictionaryImportExportDesc: '将校正词典作为JSON文件导入或导出',
|
||||
vadMode: '语音活动检测(VAD)',
|
||||
vadModeDesc: '默认的“关闭”会保持原始音频,以获得最高的转写精度。启用服务器VAD可以在上传前剪掉静音,可能缩短整体响应时间,但也可能影响分段精度。本地VAD(需要 fvad.wasm / fvad.js)会在桌面端检测静音并自动停止录音。',
|
||||
vadModeLocalMissing: '启用本地VAD需要将 fvad.wasm 和 fvad.js 放在 {path}。',
|
||||
vadModeDesc: '默认的“关闭”会保持原始音频,以获得最高的转写精度。启用服务器VAD可以在上传前剪掉静音,可能缩短整体响应时间,但也可能影响分段精度。本地VAD(需要 fvad.wasm)会在桌面端检测静音并自动停止录音。',
|
||||
vadModeLocalMissing: '启用本地VAD需要将 fvad.wasm 放在 {path}。',
|
||||
vadModeLocalAvailable: '在 {path} 检测到本地VAD模块,将在静音时自动停止录音。',
|
||||
vadModeDisabledDesc: '关闭VAD:持续录音并完整发送音频。',
|
||||
vadModeSummaryServer: '服务器裁剪静音,加快整体响应(云端处理)',
|
||||
vadModeSummaryLocal: '在桌面本地检测静音并自动停止(需 fvad.wasm/fvad.js)',
|
||||
vadModeInstallButton: '选择 fvad.wasm / fvad.js…',
|
||||
vadModeInstallDesc: '请从官方 WebRTC VAD 移植项目下载 fvad.wasm(以及 fvad.js),在此选择 wasm 文件即可复制到插件目录(仅桌面)。',
|
||||
vadModeSummaryLocal: '在桌面本地检测静音并自动停止(需 fvad.wasm)',
|
||||
vadModeInstallButton: '选择 fvad.wasm…',
|
||||
vadModeInstallDesc: '请从官方 WebRTC VAD 移植项目下载 fvad.wasm,在此选择 wasm 文件即可复制到插件目录(仅桌面)。',
|
||||
vadModeInstallLinkLabel: 'fvad-wasm 项目',
|
||||
vadModeInstallInvalidName: '请选择名为 fvad.wasm 的文件。',
|
||||
vadModeInstallInvalidType: '所选文件不像 WebAssembly 模块。',
|
||||
vadModeInstallSuccess: '已安装 fvad.wasm。下次录音将尝试本地 VAD。',
|
||||
vadModeInstallWriteError: '安装 VAD 相关文件失败:{error}',
|
||||
vadModeInstallJsMissing: '本地 VAD 还需要 fvad.js。若不存在,请手动复制到同一插件目录。'
|
||||
vadModeInstallJsMissing: '当前本地 VAD 只需要 fvad.wasm。如果看到此消息,请更新插件。'
|
||||
},
|
||||
options: {
|
||||
modelMini: 'GPT-4o mini Transcribe',
|
||||
|
|
|
|||
|
|
@ -30,24 +30,20 @@ export class DraftManager {
|
|||
const folderPath = this.getDraftFolderPath(app);
|
||||
const abstractFile = app.vault.getAbstractFileByPath(folderPath);
|
||||
|
||||
if (abstractFile instanceof TFolder) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (abstractFile) {
|
||||
this.logger?.warn('Draft path exists but is not a folder', { folderPath });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const adapter = app.vault.adapter;
|
||||
if (await adapter.exists(folderPath)) {
|
||||
if (abstractFile instanceof TFolder) {
|
||||
return; // already exists and is a folder
|
||||
}
|
||||
if (abstractFile) {
|
||||
this.logger?.warn('Draft path exists but is not a folder', { folderPath });
|
||||
return;
|
||||
}
|
||||
await adapter.mkdir(folderPath);
|
||||
|
||||
await app.vault.createFolder(folderPath);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (!message.includes('File already exists') && !message.includes('folder already exists')) {
|
||||
const normalized = message.toLowerCase();
|
||||
if (!normalized.includes('already exists')) {
|
||||
this.logger?.error('Failed to create draft folder', error);
|
||||
throw error;
|
||||
}
|
||||
|
|
@ -95,28 +91,55 @@ export class DraftManager {
|
|||
await this.ensureDraftFolder(app);
|
||||
|
||||
let saved = false;
|
||||
|
||||
const writeWithVault = async (file: TFile): Promise<boolean> => {
|
||||
try {
|
||||
await app.vault.process(file, () => textToSave);
|
||||
return true;
|
||||
} catch (processError) {
|
||||
this.logger?.warn('Vault.process failed for draft', processError);
|
||||
}
|
||||
|
||||
try {
|
||||
await app.vault.modify(file, textToSave);
|
||||
return true;
|
||||
} catch (modifyError) {
|
||||
this.logger?.warn('Vault.modify failed for draft', modifyError);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const existingFile = app.vault.getFileByPath(draftPath);
|
||||
if (existingFile instanceof TFile) {
|
||||
try {
|
||||
await app.vault.modify(existingFile, textToSave);
|
||||
saved = true;
|
||||
} catch (error) {
|
||||
this.logger?.warn('Vault.modify failed for draft, falling back to adapter', error);
|
||||
}
|
||||
saved = await writeWithVault(existingFile);
|
||||
} else {
|
||||
try {
|
||||
await app.vault.create(draftPath, textToSave);
|
||||
saved = true;
|
||||
const created = app.vault.getFileByPath(draftPath);
|
||||
if (created instanceof TFile) {
|
||||
saved = await writeWithVault(created);
|
||||
} else {
|
||||
saved = true; // created but not immediately resolvable; treat as success
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (!message.includes('already exists')) {
|
||||
this.logger?.warn('Vault.create failed for draft, falling back to adapter', error);
|
||||
const normalized = message.toLowerCase();
|
||||
if (normalized.includes('already exists')) {
|
||||
const file = app.vault.getFileByPath(draftPath);
|
||||
if (file instanceof TFile) {
|
||||
saved = await writeWithVault(file);
|
||||
} else {
|
||||
saved = true;
|
||||
}
|
||||
} else {
|
||||
this.logger?.warn('Vault.create failed for draft', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!saved) {
|
||||
await app.vault.adapter.write(draftPath, textToSave);
|
||||
this.logger?.error('Failed to save draft via Vault API');
|
||||
return false;
|
||||
}
|
||||
|
||||
this.logger?.info('Draft saved successfully', {
|
||||
|
|
@ -146,13 +169,7 @@ export class DraftManager {
|
|||
return text;
|
||||
}
|
||||
|
||||
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;
|
||||
return null;
|
||||
} catch (error) {
|
||||
this.logger?.error('Failed to load draft', error);
|
||||
return null;
|
||||
|
|
@ -172,11 +189,6 @@ export class DraftManager {
|
|||
this.logger?.info('Draft cleared successfully via file manager');
|
||||
return;
|
||||
}
|
||||
|
||||
if (await app.vault.adapter.exists(draftPath)) {
|
||||
await app.vault.adapter.remove(draftPath);
|
||||
this.logger?.info('Draft cleared successfully (adapter fallback)');
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger?.error('Failed to clear draft', error);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -200,7 +200,7 @@ export default class VoiceInputPlugin extends Plugin {
|
|||
// Add timeout to prevent hanging
|
||||
return Promise.race([
|
||||
view.actions.stopRecording(),
|
||||
new Promise(resolve => setTimeout(resolve, 1000)) // 1 second timeout
|
||||
new Promise(resolve => window.setTimeout(resolve, 1000)) // 1 second timeout
|
||||
]).catch(error => {
|
||||
this.logger.error('Error stopping recording during unload', error);
|
||||
});
|
||||
|
|
@ -276,7 +276,7 @@ export default class VoiceInputPlugin extends Plugin {
|
|||
}
|
||||
delete migratedData.language;
|
||||
needsSave = true;
|
||||
this.logger?.info(`Migrating language (${data.language}) to transcriptionLanguage (${migratedData.transcriptionLanguage})`);
|
||||
this.logger?.info(`Migrating language (${String(data.language ?? '')}) to transcriptionLanguage (${String(migratedData.transcriptionLanguage ?? '')})`);
|
||||
}
|
||||
|
||||
// languageからpluginLanguageへの移行(古いバージョンとの互換性のため)
|
||||
|
|
@ -289,7 +289,7 @@ export default class VoiceInputPlugin extends Plugin {
|
|||
migratedData.pluginLanguage = 'en';
|
||||
}
|
||||
needsSave = true;
|
||||
this.logger?.info(`Migrating language (${data.language}) to pluginLanguage (${migratedData.pluginLanguage})`);
|
||||
this.logger?.info(`Migrating language (${String(data.language ?? '')}) to pluginLanguage (${String(migratedData.pluginLanguage ?? '')})`);
|
||||
}
|
||||
|
||||
// 不要になったlanguageフィールドの削除
|
||||
|
|
@ -303,15 +303,15 @@ export default class VoiceInputPlugin extends Plugin {
|
|||
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})`);
|
||||
this.logger?.info(`Removing recordingMode setting (was: ${String(data.recordingMode ?? '')})`);
|
||||
}
|
||||
if ('autoStopSilenceDuration' in data) {
|
||||
delete migratedData.autoStopSilenceDuration;
|
||||
this.logger?.info(`Removing autoStopSilenceDuration setting (was: ${data.autoStopSilenceDuration})`);
|
||||
this.logger?.info(`Removing autoStopSilenceDuration setting (was: ${String(data.autoStopSilenceDuration ?? '')})`);
|
||||
}
|
||||
if ('minSpeechDuration' in data) {
|
||||
delete migratedData.minSpeechDuration;
|
||||
this.logger?.info(`Removing minSpeechDuration setting (was: ${data.minSpeechDuration})`);
|
||||
this.logger?.info(`Removing minSpeechDuration setting (was: ${String(data.minSpeechDuration ?? '')})`);
|
||||
}
|
||||
needsSave = true;
|
||||
this.logger?.info('Migrated to continuous recording only mode');
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ const resolveElectronRenderer = (): ElectronRenderer | null => {
|
|||
if (windowElectron) {
|
||||
return windowElectron ?? null;
|
||||
}
|
||||
return (globalThis as ElectronGlobal).electron ?? null;
|
||||
return (window as ElectronGlobal).electron ?? null;
|
||||
};
|
||||
|
||||
export class SafeStorageService {
|
||||
|
|
@ -68,23 +68,23 @@ export class SafeStorageService {
|
|||
|
||||
// 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'}`);
|
||||
this.logger.debug(`window.require exists: ${String('require' in window)}`);
|
||||
this.logger.debug(`window.require is function: ${String(typeof (window as ElectronWindow).require === 'function')}`);
|
||||
|
||||
const electron = resolveElectronRenderer();
|
||||
|
||||
if (!electron) {
|
||||
this.logger.debug('Electron renderer not available');
|
||||
} 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.logger.debug(`Electron object exists: ${String(!!electron)}`);
|
||||
this.logger.debug(`Electron.remote exists: ${String(!!electron?.remote)}`);
|
||||
this.logger.debug(`Electron.safeStorage exists: ${String(!!electron?.safeStorage)}`);
|
||||
this.logger.debug(`Electron.remote.safeStorage exists: ${String(!!electron?.remote?.safeStorage)}`);
|
||||
|
||||
this.safeStorage = electron?.remote?.safeStorage || electron?.safeStorage || null;
|
||||
}
|
||||
|
||||
this.logger.debug(`SafeStorage initialized: ${!!this.safeStorage}`);
|
||||
this.logger.debug(`SafeStorage initialized: ${String(!!this.safeStorage)}`);
|
||||
} catch (e) {
|
||||
this.logger.error('Error during safeStorage initialization', e instanceof Error ? e : new Error(String(e)));
|
||||
}
|
||||
|
|
@ -94,11 +94,15 @@ export class SafeStorageService {
|
|||
|
||||
/** 暗号化して保存用文字列へ変換 */
|
||||
static encryptForStore(apiKey: string): string {
|
||||
if (!apiKey) return '';
|
||||
if (!apiKey) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Trim the API key before storing
|
||||
const trimmedKey = apiKey.trim();
|
||||
if (!trimmedKey) return '';
|
||||
if (!trimmedKey) {
|
||||
return '';
|
||||
}
|
||||
|
||||
this.logger.debug('Encrypting API key for storage');
|
||||
|
||||
|
|
@ -124,7 +128,9 @@ export class SafeStorageService {
|
|||
|
||||
/** 保存文字列 -> 平文 API キー */
|
||||
static decryptFromStore(stored: string): string {
|
||||
if (!stored) return '';
|
||||
if (!stored) {
|
||||
return '';
|
||||
}
|
||||
|
||||
this.logger.debug('Decrypting stored API key');
|
||||
|
||||
|
|
@ -169,6 +175,14 @@ export class SafeStorageService {
|
|||
return '';
|
||||
}
|
||||
|
||||
private static encodeBinaryString(text: string): string {
|
||||
return Buffer.from(text, 'latin1').toString('base64');
|
||||
}
|
||||
|
||||
private static decodeBinaryString(encoded: string): string {
|
||||
return Buffer.from(encoded, 'base64').toString('latin1');
|
||||
}
|
||||
|
||||
private static xorEncrypt(text: string, key: string): string {
|
||||
let result = '';
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
|
|
@ -176,12 +190,12 @@ export class SafeStorageService {
|
|||
text.charCodeAt(i) ^ key.charCodeAt(i % key.length)
|
||||
);
|
||||
}
|
||||
return btoa(result);
|
||||
return this.encodeBinaryString(result);
|
||||
}
|
||||
|
||||
private static xorDecrypt(encoded: string, key: string): string {
|
||||
try {
|
||||
const text = atob(encoded);
|
||||
const text = this.decodeBinaryString(encoded);
|
||||
let result = '';
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
result += String.fromCharCode(
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@ export class SecurityUtils {
|
|||
* Mask API key for display purposes
|
||||
*/
|
||||
static maskAPIKey(key: string): string {
|
||||
if (!key || key.length < 10) return '';
|
||||
if (!key || key.length < 10) {
|
||||
return '';
|
||||
}
|
||||
return key.substring(0, 7) + '*'.repeat(40);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -105,7 +105,9 @@ export class I18nServiceImpl implements I18nService {
|
|||
*/
|
||||
private getTranslation(key: string, locale: Locale): string | undefined {
|
||||
const resource = translations[locale];
|
||||
if (!resource) return undefined;
|
||||
if (!resource) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Navigate through the object using the key path
|
||||
const keys = key.split('.');
|
||||
|
|
|
|||
|
|
@ -63,11 +63,6 @@ export class VoiceInputSettingTab extends PluginSettingTab {
|
|||
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'))
|
||||
|
|
@ -222,7 +217,7 @@ export class VoiceInputSettingTab extends PluginSettingTab {
|
|||
button.setCta();
|
||||
|
||||
// 3秒後に元に戻す
|
||||
setTimeout(() => {
|
||||
window.setTimeout(() => {
|
||||
button.setButtonText(this.i18n.t('ui.buttons.connectionTest'));
|
||||
button.removeCta();
|
||||
button.setDisabled(false);
|
||||
|
|
@ -232,7 +227,7 @@ export class VoiceInputSettingTab extends PluginSettingTab {
|
|||
button.setButtonText(this.i18n.t('ui.buttons.testFailed'));
|
||||
|
||||
// 3秒後に元に戻す
|
||||
setTimeout(() => {
|
||||
window.setTimeout(() => {
|
||||
button.setButtonText(this.i18n.t('ui.buttons.connectionTest'));
|
||||
button.setDisabled(false);
|
||||
}, 3000);
|
||||
|
|
@ -267,7 +262,6 @@ export class VoiceInputSettingTab extends PluginSettingTab {
|
|||
|
||||
const FVAD_DOWNLOAD_URL = 'https://github.com/echogarden-project/fvad-wasm';
|
||||
const wasmFileName = 'fvad.wasm';
|
||||
const loaderFileName = 'fvad.js';
|
||||
const vadInstructionsPath = getLocalVadInstructionsPath(this.app);
|
||||
const initialVadMode = this.plugin.settings.vadMode ?? 'disabled';
|
||||
const vadModeSetting = new Setting(containerEl)
|
||||
|
|
@ -304,21 +298,19 @@ export class VoiceInputSettingTab extends PluginSettingTab {
|
|||
helperNote = helperContainer.createDiv({ cls: 'setting-item-description' });
|
||||
const buttonElement = helperContainer.createEl('button', { text: this.i18n.t('ui.settings.vadModeInstallButton') });
|
||||
|
||||
if (buttonElement instanceof HTMLButtonElement) {
|
||||
if (buttonElement.instanceOf(HTMLButtonElement)) {
|
||||
helperButton = buttonElement;
|
||||
helperButton.classList.add('mod-cta');
|
||||
helperContainer.classList.add('voice-input-hidden');
|
||||
|
||||
helperButton.addEventListener('click', () => {
|
||||
const input = document.createElement('input');
|
||||
const input = activeDocument.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.wasm,.js,application/wasm';
|
||||
input.multiple = true;
|
||||
input.accept = '.wasm,application/wasm';
|
||||
input.onchange = () => {
|
||||
this.runAsync(async () => {
|
||||
const files = input.files ? Array.from(input.files) : [];
|
||||
const wasmFile = files.find(file => file.name === wasmFileName);
|
||||
const jsFile = files.find(file => file.name === loaderFileName);
|
||||
|
||||
if (!wasmFile) {
|
||||
new Notice(this.i18n.t('ui.settings.vadModeInstallInvalidName'));
|
||||
|
|
@ -347,20 +339,7 @@ export class VoiceInputSettingTab extends PluginSettingTab {
|
|||
|
||||
const wasmTarget = getLocalVadAssetPath(this.app, wasmFileName);
|
||||
await adapter.writeBinary(wasmTarget, toArrayBuffer(wasmBytes));
|
||||
|
||||
let loaderPresent = await adapter.exists(getLocalVadAssetPath(this.app, loaderFileName));
|
||||
if (jsFile) {
|
||||
const loaderContent = await jsFile.text();
|
||||
const loaderTarget = getLocalVadAssetPath(this.app, loaderFileName);
|
||||
await adapter.write(loaderTarget, loaderContent);
|
||||
loaderPresent = true;
|
||||
}
|
||||
|
||||
if (!loaderPresent) {
|
||||
new Notice(this.i18n.t('ui.settings.vadModeInstallJsMissing'));
|
||||
} else {
|
||||
new Notice(this.i18n.t('ui.settings.vadModeInstallSuccess'));
|
||||
}
|
||||
new Notice(this.i18n.t('ui.settings.vadModeInstallSuccess'));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
new Notice(this.i18n.t('notification.error.fileWrite'));
|
||||
|
|
@ -380,27 +359,27 @@ export class VoiceInputSettingTab extends PluginSettingTab {
|
|||
}
|
||||
|
||||
const createVadDescription = (includeMissing: boolean, includeLocal: boolean): DocumentFragment => {
|
||||
const fragment = document.createDocumentFragment();
|
||||
const fragment = activeDocument.createDocumentFragment();
|
||||
fragment.appendText(this.i18n.t('ui.settings.vadModeDesc'));
|
||||
fragment.appendChild(document.createElement('br'));
|
||||
fragment.appendChild(activeDocument.createElement('br'));
|
||||
fragment.appendText(`${this.i18n.t('ui.options.vadServer')}: ${this.i18n.t('ui.settings.vadModeSummaryServer')}`);
|
||||
fragment.appendChild(document.createElement('br'));
|
||||
fragment.appendChild(activeDocument.createElement('br'));
|
||||
fragment.appendText(`${this.i18n.t('ui.options.vadLocal')}: ${this.i18n.t('ui.settings.vadModeSummaryLocal')}`);
|
||||
|
||||
if (includeMissing) {
|
||||
fragment.appendChild(document.createElement('br'));
|
||||
fragment.appendChild(document.createElement('br'));
|
||||
fragment.appendChild(activeDocument.createElement('br'));
|
||||
fragment.appendChild(activeDocument.createElement('br'));
|
||||
fragment.appendText(this.i18n.t('ui.settings.vadModeLocalMissing', { path: vadInstructionsPath }));
|
||||
fragment.appendChild(document.createElement('br'));
|
||||
const link = document.createElement('a');
|
||||
fragment.appendChild(activeDocument.createElement('br'));
|
||||
const link = activeDocument.createElement('a');
|
||||
link.href = FVAD_DOWNLOAD_URL;
|
||||
link.textContent = this.i18n.t('ui.settings.vadModeInstallLinkLabel');
|
||||
link.target = '_blank';
|
||||
link.rel = 'noopener noreferrer';
|
||||
fragment.appendChild(link);
|
||||
} else if (includeLocal) {
|
||||
fragment.appendChild(document.createElement('br'));
|
||||
fragment.appendChild(document.createElement('br'));
|
||||
fragment.appendChild(activeDocument.createElement('br'));
|
||||
fragment.appendChild(activeDocument.createElement('br'));
|
||||
fragment.appendText(this.i18n.t('ui.settings.vadModeLocalAvailable', { path: vadInstructionsPath }));
|
||||
}
|
||||
|
||||
|
|
@ -493,14 +472,10 @@ export class VoiceInputSettingTab extends PluginSettingTab {
|
|||
// Create table container for dictionary tables
|
||||
const tableContainer = containerEl.createDiv('voice-input-dictionary-table-container');
|
||||
|
||||
// Definite Corrections Section
|
||||
// Definite Corrections Section (no heading to comply with Obsidian guidelines)
|
||||
new Setting(tableContainer)
|
||||
.setHeading()
|
||||
.setName(this.i18n.t('ui.settings.dictionaryDefinite', { max: DICTIONARY_CONSTANTS.MAX_DEFINITE_CORRECTIONS }));
|
||||
tableContainer.createEl('div', {
|
||||
cls: 'setting-item-description',
|
||||
text: this.i18n.t('ui.help.dictionaryFromComma')
|
||||
});
|
||||
.setName(this.i18n.t('ui.settings.dictionaryDefinite', { max: DICTIONARY_CONSTANTS.MAX_DEFINITE_CORRECTIONS }))
|
||||
.setDesc(this.i18n.t('ui.help.dictionaryFromComma'));
|
||||
this.createCorrectionTable(
|
||||
tableContainer,
|
||||
this.plugin.settings.customDictionary.definiteCorrections,
|
||||
|
|
@ -752,17 +727,17 @@ export class VoiceInputSettingTab extends PluginSettingTab {
|
|||
const blob = new Blob([jsonStr], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const a = document.createElement('a');
|
||||
const a = activeDocument.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);
|
||||
activeDocument.body.appendChild(a);
|
||||
a.click();
|
||||
|
||||
// Clean up
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(a);
|
||||
window.setTimeout(() => {
|
||||
activeDocument.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}, 100);
|
||||
|
||||
|
|
@ -778,14 +753,16 @@ export class VoiceInputSettingTab extends PluginSettingTab {
|
|||
* Import dictionary from JSON
|
||||
*/
|
||||
private importDictionary() {
|
||||
const input = document.createElement('input');
|
||||
const input = activeDocument.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.json';
|
||||
|
||||
input.onchange = (e) => {
|
||||
this.runAsync(async () => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (!file) return;
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const text = await file.text();
|
||||
|
|
|
|||
|
|
@ -388,11 +388,11 @@
|
|||
|
||||
/* Utility classes for JavaScript style replacements */
|
||||
.voice-input-hidden {
|
||||
display: none !important;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.voice-input-flex {
|
||||
display: flex !important;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* Audio Visualizer Styles */
|
||||
|
|
@ -450,6 +450,6 @@
|
|||
|
||||
/* Form element styles */
|
||||
.voice-input-textarea-wide {
|
||||
width: 100% !important;
|
||||
min-height: 100px !important;
|
||||
width: 100%;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
|
|
|||
19
src/types/fvad-wasm.d.ts
vendored
Normal file
19
src/types/fvad-wasm.d.ts
vendored
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
declare module '@echogarden/fvad-wasm' {
|
||||
interface FvadWasmModule {
|
||||
_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 };
|
||||
}
|
||||
|
||||
type FvadWasmModuleFactory = (options?: {
|
||||
wasmBinary?: Uint8Array;
|
||||
}) => Promise<FvadWasmModule>;
|
||||
|
||||
const createFvadModule: FvadWasmModuleFactory;
|
||||
export default createFvadModule;
|
||||
}
|
||||
|
|
@ -37,11 +37,18 @@ export class DeferredViewHelper {
|
|||
* @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;
|
||||
const view = await this.safeGetView<View & { getViewType?: () => string }>(leaf);
|
||||
|
||||
if (!view) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const viewType = typeof view.getViewType === 'function' ? view.getViewType() : undefined;
|
||||
|
||||
// Do not rely on constructor.name because it is minified in production builds.
|
||||
// NOTE: Keep the view type string duplicated here to avoid importing from the view module
|
||||
// which can introduce circular dependencies in tests.
|
||||
return viewType === 'voice-input-view' ? (view as VoiceInputView) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -62,10 +62,11 @@ export class Logger {
|
|||
});
|
||||
}
|
||||
|
||||
const prefix = `${mainLogger.config.prefix ?? ''} [${moduleName}]`;
|
||||
const moduleLogger = new Logger({
|
||||
debugMode: mainLogger.config.debugMode,
|
||||
logLevel: mainLogger.config.logLevel,
|
||||
prefix: `${mainLogger.config.prefix} [${moduleName}]`
|
||||
prefix
|
||||
});
|
||||
mainLogger.moduleLoggers.set(moduleName, moduleLogger);
|
||||
return moduleLogger;
|
||||
|
|
@ -104,7 +105,8 @@ export class Logger {
|
|||
*/
|
||||
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}`;
|
||||
const prefix = this.config.prefix ?? '';
|
||||
return `${timestamp} ${LogLevel[level].padEnd(5)} ${prefix} ${message}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -246,7 +248,8 @@ export class Logger {
|
|||
*/
|
||||
time(label: string): void {
|
||||
if (this.shouldLog(LogLevel.DEBUG)) {
|
||||
const timerKey = `${this.config.prefix} ${label}`;
|
||||
const prefix = this.config.prefix ?? '';
|
||||
const timerKey = `${prefix} ${label}`;
|
||||
const now = typeof performance !== 'undefined' && typeof performance.now === 'function'
|
||||
? performance.now()
|
||||
: Date.now();
|
||||
|
|
@ -259,9 +262,12 @@ export class Logger {
|
|||
*/
|
||||
timeEnd(label: string): void {
|
||||
if (this.shouldLog(LogLevel.DEBUG)) {
|
||||
const timerKey = `${this.config.prefix} ${label}`;
|
||||
const prefix = this.config.prefix ?? '';
|
||||
const timerKey = `${prefix} ${label}`;
|
||||
const start = this.performanceTimers.get(timerKey);
|
||||
if (start === undefined) return;
|
||||
if (start === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = typeof performance !== 'undefined' && typeof performance.now === 'function'
|
||||
? performance.now()
|
||||
|
|
@ -276,10 +282,11 @@ export class Logger {
|
|||
* Create a scoped logger for a specific operation
|
||||
*/
|
||||
scope(scopeName: string): Logger {
|
||||
const prefix = this.config.prefix ?? '';
|
||||
return new Logger({
|
||||
debugMode: this.config.debugMode,
|
||||
logLevel: this.config.logLevel,
|
||||
prefix: `${this.config.prefix} [${scopeName}]`
|
||||
prefix: `${prefix} [${scopeName}]`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ export class ObsidianHttpClient {
|
|||
for (const [name, value] of entries) {
|
||||
parts.push(textEncoder.encode(`--${boundary}\r\n`));
|
||||
|
||||
if (value != null && typeof value === 'object' && 'arrayBuffer' in value) {
|
||||
if (value !== null && value !== undefined && typeof value === 'object' && 'arrayBuffer' in value) {
|
||||
const blobValue = value as File | Blob;
|
||||
const filename = blobValue instanceof File ? blobValue.name : 'blob';
|
||||
parts.push(textEncoder.encode(
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { App, normalizePath } from 'obsidian';
|
|||
import { FILE_CONSTANTS } from '../config';
|
||||
|
||||
const FVAD_WASM = 'fvad.wasm';
|
||||
const FVAD_JS = 'fvad.js';
|
||||
|
||||
function getPluginBasePath(app: App): string {
|
||||
return normalizePath(`${app.vault.configDir}/plugins/${FILE_CONSTANTS.PLUGIN_ID}`);
|
||||
|
|
@ -20,15 +19,8 @@ export async function hasLocalVadAssets(app: App): Promise<boolean> {
|
|||
getPluginAssetPath(app, FVAD_WASM),
|
||||
normalizePath(`${baseDir}/node_modules/@echogarden/fvad-wasm/${FVAD_WASM}`)
|
||||
];
|
||||
const jsCandidates = [
|
||||
getPluginAssetPath(app, FVAD_JS),
|
||||
normalizePath(`${baseDir}/node_modules/@echogarden/fvad-wasm/${FVAD_JS}`)
|
||||
];
|
||||
|
||||
const hasWasm = await existsInCandidates(adapter, wasmCandidates);
|
||||
const hasJs = await existsInCandidates(adapter, jsCandidates);
|
||||
|
||||
return hasWasm && hasJs;
|
||||
return existsInCandidates(adapter, wasmCandidates);
|
||||
}
|
||||
|
||||
export function getLocalVadInstructionsPath(app: App): string {
|
||||
|
|
|
|||
|
|
@ -19,8 +19,8 @@ export class VoiceInputView extends ItemView {
|
|||
ui: VoiceInputViewUI;
|
||||
actions: VoiceInputViewActions;
|
||||
private blurHandler: ((e: FocusEvent) => void) | null = null;
|
||||
private autoSaveTimeout: NodeJS.Timeout | null = null;
|
||||
private periodicSaveInterval: NodeJS.Timeout | null = null;
|
||||
private autoSaveTimeout: number | null = null;
|
||||
private periodicSaveInterval: number | null = null;
|
||||
private static readonly AUTO_SAVE_DELAY = 2000; // 2 seconds
|
||||
private static readonly PERIODIC_SAVE_INTERVAL = 5000; // 5 seconds
|
||||
|
||||
|
|
@ -52,7 +52,7 @@ export class VoiceInputView extends ItemView {
|
|||
|
||||
private async handleOpen(): Promise<void> {
|
||||
const containerElement = this.containerEl.children[1];
|
||||
if (containerElement instanceof HTMLElement) {
|
||||
if (containerElement.instanceOf(HTMLElement)) {
|
||||
const container = containerElement;
|
||||
container.empty();
|
||||
container.addClass('voice-input-view');
|
||||
|
|
@ -99,13 +99,13 @@ export class VoiceInputView extends ItemView {
|
|||
private async handleClose(): Promise<void> {
|
||||
// Clear any pending auto-save timeout
|
||||
if (this.autoSaveTimeout) {
|
||||
clearTimeout(this.autoSaveTimeout);
|
||||
window.clearTimeout(this.autoSaveTimeout);
|
||||
this.autoSaveTimeout = null;
|
||||
}
|
||||
|
||||
// Clear periodic save interval
|
||||
if (this.periodicSaveInterval) {
|
||||
clearInterval(this.periodicSaveInterval);
|
||||
window.clearInterval(this.periodicSaveInterval);
|
||||
this.periodicSaveInterval = null;
|
||||
}
|
||||
|
||||
|
|
@ -149,7 +149,7 @@ export class VoiceInputView extends ItemView {
|
|||
refreshUI() {
|
||||
// Re-create the UI with new language
|
||||
const containerElement = this.containerEl.children[1];
|
||||
if (containerElement instanceof HTMLElement) {
|
||||
if (containerElement.instanceOf(HTMLElement)) {
|
||||
const container = containerElement;
|
||||
container.empty();
|
||||
container.addClass('voice-input-view');
|
||||
|
|
@ -192,7 +192,9 @@ export class VoiceInputView extends ItemView {
|
|||
* Set up auto-save when text area loses focus
|
||||
*/
|
||||
private setupAutoSaveOnBlur() {
|
||||
if (!this.ui?.textArea) return;
|
||||
if (!this.ui?.textArea) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove existing handler if any
|
||||
if (this.blurHandler) {
|
||||
|
|
@ -232,11 +234,11 @@ export class VoiceInputView extends ItemView {
|
|||
private setupPeriodicSave() {
|
||||
// Clear existing interval if any
|
||||
if (this.periodicSaveInterval) {
|
||||
clearInterval(this.periodicSaveInterval);
|
||||
window.clearInterval(this.periodicSaveInterval);
|
||||
}
|
||||
|
||||
// Set up periodic save every 5 seconds
|
||||
this.periodicSaveInterval = setInterval(() => {
|
||||
this.periodicSaveInterval = window.setInterval(() => {
|
||||
if (this.ui?.textArea) {
|
||||
const content = this.ui.textArea.value;
|
||||
if (content.trim()) {
|
||||
|
|
@ -256,11 +258,11 @@ export class VoiceInputView extends ItemView {
|
|||
onTextChanged() {
|
||||
// Clear existing timeout
|
||||
if (this.autoSaveTimeout) {
|
||||
clearTimeout(this.autoSaveTimeout);
|
||||
window.clearTimeout(this.autoSaveTimeout);
|
||||
}
|
||||
|
||||
// Set new timeout for auto-save (for responsiveness, keeps existing behavior)
|
||||
this.autoSaveTimeout = setTimeout(() => {
|
||||
this.autoSaveTimeout = window.setTimeout(() => {
|
||||
if (this.ui?.textArea) {
|
||||
const content = this.ui.textArea.value;
|
||||
if (content.trim()) {
|
||||
|
|
|
|||
|
|
@ -32,8 +32,8 @@ export class VoiceInputViewActions {
|
|||
private isProcessingAudio = false;
|
||||
// 連打や高速操作による並行実行を防ぐための遷移ロック
|
||||
private isTransitioning = false;
|
||||
private statusTimer: NodeJS.Timeout | null = null;
|
||||
private clearConfirmTimer: NodeJS.Timeout | null = null;
|
||||
private statusTimer: number | null = null;
|
||||
private clearConfirmTimer: number | null = null;
|
||||
private clearPressCount = 0;
|
||||
private pendingVadAutoStop = false;
|
||||
|
||||
|
|
@ -65,7 +65,9 @@ export class VoiceInputViewActions {
|
|||
* Apply transcription settings to the service
|
||||
*/
|
||||
private applyTranscriptionSettings() {
|
||||
if (!this.transcriptionService) return;
|
||||
if (!this.transcriptionService) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.transcriptionService.setModel(this.plugin.settings.transcriptionModel);
|
||||
// Apply transcription correction and custom dictionary settings
|
||||
|
|
@ -128,7 +130,9 @@ export class VoiceInputViewActions {
|
|||
* Toggle recording on/off
|
||||
*/
|
||||
async toggleRecording() {
|
||||
if (this.isTransitioning) return; // 二重操作防止
|
||||
if (this.isTransitioning) {
|
||||
return; // 二重操作防止
|
||||
}
|
||||
this.isTransitioning = true;
|
||||
if (this.audioRecorder && this.audioRecorder.isActive()) {
|
||||
try {
|
||||
|
|
@ -353,11 +357,13 @@ export class VoiceInputViewActions {
|
|||
* Set status text and clear it after a timeout
|
||||
*/
|
||||
setStatusWithTimeout(text: string, timeout = 3000): void {
|
||||
if (!this.view.ui.statusEl) return;
|
||||
if (!this.view.ui.statusEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear existing timer
|
||||
if (this.statusTimer) {
|
||||
clearTimeout(this.statusTimer);
|
||||
window.clearTimeout(this.statusTimer);
|
||||
this.statusTimer = null;
|
||||
}
|
||||
|
||||
|
|
@ -367,7 +373,7 @@ export class VoiceInputViewActions {
|
|||
this.view.ui.statusEl.removeClass('error');
|
||||
|
||||
// Set timer to clear status
|
||||
this.statusTimer = setTimeout(() => {
|
||||
this.statusTimer = window.setTimeout(() => {
|
||||
if (this.view.ui.statusEl && !this.recordingState.isRecording && !this.isProcessingAudio) {
|
||||
this.view.ui.statusEl.setText(this.i18n.t('status.idle'));
|
||||
}
|
||||
|
|
@ -453,7 +459,7 @@ export class VoiceInputViewActions {
|
|||
// Check if new items were added while processing
|
||||
if (this.recordingState.processingQueue.length > 0) {
|
||||
// Use setTimeout to avoid potential stack overflow
|
||||
setTimeout(() => {
|
||||
window.setTimeout(() => {
|
||||
void this.processQueue().catch((error) => {
|
||||
this.logger.error('Failed to continue processing audio queue', error);
|
||||
});
|
||||
|
|
@ -465,10 +471,14 @@ export class VoiceInputViewActions {
|
|||
* Update processing status display
|
||||
*/
|
||||
private updateProcessingStatus(): void {
|
||||
if (!this.view.ui.statusEl) return;
|
||||
if (!this.view.ui.statusEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't update if a status message with timer is active
|
||||
if (this.statusTimer) return;
|
||||
if (this.statusTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const queueLength = this.recordingState.processingQueue.length;
|
||||
|
||||
|
|
@ -599,14 +609,14 @@ export class VoiceInputViewActions {
|
|||
this.setStatusWithTimeout(this.i18n.t('status.warning.clearConfirm'));
|
||||
|
||||
// Set timer to reset press count
|
||||
this.clearConfirmTimer = setTimeout(() => {
|
||||
this.clearConfirmTimer = window.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);
|
||||
window.clearTimeout(this.clearConfirmTimer);
|
||||
this.clearConfirmTimer = null;
|
||||
}
|
||||
this.clearPressCount = 0;
|
||||
|
|
@ -682,7 +692,9 @@ export class VoiceInputViewActions {
|
|||
* Cancel recording without processing
|
||||
*/
|
||||
async cancelRecording() {
|
||||
if (this.isTransitioning) return; // 二重キャンセル防止
|
||||
if (this.isTransitioning) {
|
||||
return; // 二重キャンセル防止
|
||||
}
|
||||
this.isTransitioning = true;
|
||||
if (!this.audioRecorder || !this.audioRecorder.isActive()) {
|
||||
this.isTransitioning = false;
|
||||
|
|
@ -879,13 +891,13 @@ export class VoiceInputViewActions {
|
|||
|
||||
// Clear status timer
|
||||
if (this.statusTimer) {
|
||||
clearTimeout(this.statusTimer);
|
||||
window.clearTimeout(this.statusTimer);
|
||||
this.statusTimer = null;
|
||||
}
|
||||
|
||||
// Clear clear confirm timer
|
||||
if (this.clearConfirmTimer) {
|
||||
clearTimeout(this.clearConfirmTimer);
|
||||
window.clearTimeout(this.clearConfirmTimer);
|
||||
this.clearConfirmTimer = null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -194,13 +194,13 @@ export class VoiceInputViewUI {
|
|||
this.setupRecordButtonHandlers();
|
||||
|
||||
// Legacy buttons (hidden)
|
||||
this.formatButton = document.createElement('button');
|
||||
this.formatButton = activeDocument.createElement('button');
|
||||
this.formatButton.classList.add('voice-input-hidden');
|
||||
|
||||
this.contextCorrectionButton = document.createElement('button');
|
||||
this.contextCorrectionButton = activeDocument.createElement('button');
|
||||
this.contextCorrectionButton.classList.add('voice-input-hidden');
|
||||
|
||||
this.cleanupButton = document.createElement('button');
|
||||
this.cleanupButton = activeDocument.createElement('button');
|
||||
this.cleanupButton.classList.add('voice-input-hidden');
|
||||
}
|
||||
|
||||
|
|
@ -255,7 +255,7 @@ export class VoiceInputViewUI {
|
|||
* Setup record button event handlers for both click and push-to-talk
|
||||
*/
|
||||
private setupRecordButtonHandlers() {
|
||||
let longPressTimer: NodeJS.Timeout | null = null;
|
||||
let longPressTimer: number | null = null;
|
||||
let isPushToTalk = false;
|
||||
// let pressStartTime = 0;
|
||||
|
||||
|
|
@ -275,7 +275,7 @@ export class VoiceInputViewUI {
|
|||
// pressStartTime = Date.now();
|
||||
|
||||
// Start a timer to begin recording after threshold
|
||||
longPressTimer = setTimeout(() => {
|
||||
longPressTimer = window.setTimeout(() => {
|
||||
isPushToTalk = true;
|
||||
longPressTimer = null;
|
||||
|
||||
|
|
@ -295,7 +295,7 @@ export class VoiceInputViewUI {
|
|||
|
||||
// Cancel timer if still running (short press)
|
||||
if (longPressTimer) {
|
||||
clearTimeout(longPressTimer);
|
||||
window.clearTimeout(longPressTimer);
|
||||
longPressTimer = null;
|
||||
return;
|
||||
}
|
||||
|
|
@ -307,7 +307,9 @@ export class VoiceInputViewUI {
|
|||
console.error('Failed to stop recording after push-to-talk', error);
|
||||
});
|
||||
// Keep isPushToTalk true to prevent click handler
|
||||
setTimeout(() => { isPushToTalk = false; }, UI_CONSTANTS.PUSH_TO_TALK_RESET_DELAY);
|
||||
window.setTimeout(() => {
|
||||
isPushToTalk = false;
|
||||
}, UI_CONSTANTS.PUSH_TO_TALK_RESET_DELAY);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -406,7 +408,9 @@ export class VoiceInputViewUI {
|
|||
* Setup text change listener for auto-save
|
||||
*/
|
||||
private setupTextChangeListener() {
|
||||
if (!this.textArea) return;
|
||||
if (!this.textArea) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create the text change handler
|
||||
this.textChangeHandler = () => {
|
||||
|
|
|
|||
|
|
@ -17,6 +17,11 @@ export class App {
|
|||
vault = new Vault();
|
||||
}
|
||||
|
||||
export const Platform = {
|
||||
isMobileApp: false,
|
||||
isDesktopApp: true,
|
||||
};
|
||||
|
||||
export class Plugin {
|
||||
app = new App();
|
||||
|
||||
|
|
|
|||
11
tests/unit/security/safe-storage-service.test.ts
Normal file
11
tests/unit/security/safe-storage-service.test.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { SafeStorageService } from '../../../src/security/SafeStorageService';
|
||||
|
||||
describe('SafeStorageService', () => {
|
||||
test('round-trips API keys through the fallback store format without browser base64 APIs', () => {
|
||||
const apiKey = 'sk-proj-test-key-abcdefghijklmnopqrstuvwxyz1234567890';
|
||||
|
||||
const stored = SafeStorageService.encryptForStore(apiKey);
|
||||
expect(stored).toMatch(/^XOR_V1::/u);
|
||||
expect(SafeStorageService.decryptFromStore(stored)).toBe(apiKey);
|
||||
});
|
||||
});
|
||||
|
|
@ -11,6 +11,7 @@
|
|||
"importHelpers": true,
|
||||
"isolatedModules": true,
|
||||
"strictNullChecks": true,
|
||||
"types": ["node"],
|
||||
"lib": ["DOM", "ES2018", "DOM.Iterable"]
|
||||
},
|
||||
"include": ["**/*.ts", "src/types/**/*.d.ts"],
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
{
|
||||
"0.8.0": "1.8.0",
|
||||
"0.9.0": "1.8.0",
|
||||
"0.9.1": "1.8.0"
|
||||
"0.9.1": "1.8.0",
|
||||
"0.9.2": "1.8.0",
|
||||
"0.9.3": "1.8.0",
|
||||
"0.9.4": "1.8.0",
|
||||
"0.9.5": "1.8.0"
|
||||
}
|
||||
Loading…
Reference in a new issue