diff --git a/README.md b/README.md index 4170462..1b8116a 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ High-accuracy multilingual voice input for Obsidian. Uses OpenAI GPT-4o Audio Tr - Quick controls in view: copy/clear/insert at cursor/append to end - Auto‑save drafts: periodic and on blur, automatic restore - Multilingual support: Japanese, English, Chinese, Korean interface languages +- Voice activity detection modes: Server VAD (default) with optional local auto-stop when you install fvad.wasm/fvad.js ## Requirements @@ -25,7 +26,7 @@ Note: OpenAI usage is billed by API. ## Installation (manual) 1. Download the latest assets from Releases -2. Copy `main.js`, `manifest.json`, `styles.css`, `fvad.wasm`, `fvad.js` +2. Copy `main.js`, `manifest.json`, `styles.css` (add `fvad.wasm` and `fvad.js` only if you plan to enable Local VAD) 3. Place them under `/.obsidian/plugins/voice-input/` 4. Restart Obsidian and enable the plugin @@ -54,6 +55,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 Server (default), Local (requires `fvad.wasm`/`fvad.js`), or Off ## Security & Privacy @@ -102,6 +104,7 @@ Third‑party licensing: see `THIRD_PARTY_LICENSES.md`. - ビュー内のクイック操作(コピー/クリア/カーソル位置へ挿入/末尾へ追記) - 自動保存(定期保存とフォーカス外れ時)。再オープン時に自動復元 - 多言語サポート(日本語、英語、中国語、韓国語のインターフェース) +- VADモード選択(標準はサーバーVAD。`fvad.wasm`/`fvad.js` を配置するとローカルVADで無音自動停止が可能) ## 必要条件 @@ -114,7 +117,7 @@ Third‑party licensing: see `THIRD_PARTY_LICENSES.md`. ## インストール(手動) 1. Releases から最新版を取得 -2. `main.js`、`manifest.json`、`styles.css`、`fvad.wasm`、`fvad.js` を配置 +2. `main.js`、`manifest.json`、`styles.css`(ローカルVADを使う場合のみ `fvad.wasm` と `fvad.js` も)を配置 3. `/.obsidian/plugins/voice-input/` に置く 4. Obsidianを再起動し、プラグインを有効化 @@ -143,6 +146,7 @@ Third‑party licensing: see `THIRD_PARTY_LICENSES.md`. - AI後処理: 辞書ベースの補正(有効時は全言語に適用) - 最大録音時間: スライダー(初期値5分) - **プラグイン言語**: UI表示のみを制御。Obsidianの言語設定から自動検出(ja/zh/ko/en)。 +- 音声区間検出 (VAD): サーバー(標準)、ローカル(`fvad.wasm`/`fvad.js` 必須)、オフ から選択 ### 言語設定 diff --git a/scripts/post-build.mjs b/scripts/post-build.mjs new file mode 100644 index 0000000..3b6ece9 --- /dev/null +++ b/scripts/post-build.mjs @@ -0,0 +1,177 @@ +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, '..'); + +// Get version and timestamp (Japan time) +const manifest = JSON.parse(fs.readFileSync(path.join(rootDir, 'manifest.json'), 'utf8')); +const version = manifest.version; +const jstDate = new Date(Date.now() + 9 * 60 * 60 * 1000); // JST = UTC+9 +const timestamp = jstDate.toISOString().replace(/[T:-]/g, '').replace(/\.\d{3}Z$/, '').replace(/^(\d{8})(\d{6})$/, '$1_$2'); // YYYYMMDD_HHMMSS + +const includeLocalVadAssets = process.env.INCLUDE_WASM === 'true' || process.argv.includes('--with-wasm'); + +// Build output directories +const buildRootDir = path.join(rootDir, 'build'); +const buildTimestampDir = path.join(buildRootDir, timestamp); +const buildLatestDir = path.join(buildRootDir, 'latest'); + +// Create build directories (keep history) +console.log('📁 Preparing build directories...'); +fs.mkdirSync(buildTimestampDir, { recursive: true }); +// Do NOT clean build/latest here; esbuild writes main.js into build/latest +fs.mkdirSync(buildLatestDir, { recursive: true }); + +// Function to copy files to a directory +function copyToBuildDir(targetDir, targetName) { + console.log(`\n📦 Building to ${targetName}...`); + + // main.js is now produced by esbuild under build/latest/ + const mainJsSrcPath = path.join(buildLatestDir, 'main.js'); + const filesToCopy = ['manifest.json']; + + // Copy main.js when targetDir is not the same as source (timestamped builds) + const mainJsDestPath = path.join(targetDir, 'main.js'); + if (path.resolve(targetDir) !== path.resolve(buildLatestDir)) { + if (fs.existsSync(mainJsSrcPath)) { + fs.copyFileSync(mainJsSrcPath, mainJsDestPath); + console.log(' ✅ main.js'); + } else { + console.error(' ❌ main.js not found in build/latest!'); + process.exit(1); + } + } else { + // For build/latest ensure main.js exists + if (fs.existsSync(mainJsSrcPath)) { + console.log(' ✅ main.js'); + } else { + console.error(' ❌ main.js not found in build/latest!'); + process.exit(1); + } + } + + // Copy supporting plugin files (from project root) + filesToCopy.forEach(file => { + const srcPath = path.join(rootDir, file); + const destPath = path.join(targetDir, file); + + if (fs.existsSync(srcPath)) { + fs.copyFileSync(srcPath, destPath); + console.log(` ✅ ${file}`); + } else { + console.error(` ❌ ${file} not found!`); + process.exit(1); + } + }); + + // Copy styles.css from src/styles/voice-input.css + const stylesSrcPath = path.join(rootDir, 'src', 'styles', 'voice-input.css'); + const stylesDestPath = path.join(targetDir, 'styles.css'); + if (fs.existsSync(stylesSrcPath)) { + fs.copyFileSync(stylesSrcPath, stylesDestPath); + console.log(` ✅ styles.css (from src/styles/voice-input.css)`); + } else { + console.log(` ⚠️ styles.css (optional, not found)`); + } + + // Copy fvad files to plugin root + if (includeLocalVadAssets) { + const fvadFiles = [ + { src: 'fvad.wasm', desc: 'WebAssembly binary' }, + { src: 'fvad.js', desc: 'WebAssembly loader' } + ]; + + fvadFiles.forEach(({ src, desc }) => { + const srcPath = path.join(rootDir, 'src', 'lib', 'fvad-wasm', src); + const destPath = path.join(targetDir, src); + + if (fs.existsSync(srcPath)) { + fs.copyFileSync(srcPath, destPath); + console.log(` ✅ ${src} (${desc})`); + } else { + console.warn(` ⚠️ ${src} not found (skipped)`); + } + }); + } else { + console.log(' • Skipping local VAD assets (INCLUDE_WASM not set)'); + } + + // Copy license files (best practice to ship with distribution) + const licenseFiles = ['LICENSE', 'THIRD_PARTY_LICENSES.md']; + licenseFiles.forEach((file) => { + const srcPath = path.join(rootDir, file); + const destPath = path.join(targetDir, file); + if (fs.existsSync(srcPath)) { + fs.copyFileSync(srcPath, destPath); + console.log(` ✅ ${file}`); + } else { + // Non-fatal if missing + console.log(` ⚠️ ${file} (optional, not found)`); + } + }); +} + +// Copy to timestamped directory (for history) +copyToBuildDir(buildTimestampDir, `build/${timestamp}`); + +// Ensure latest directory contains non-bundled assets as well +copyToBuildDir(buildLatestDir, 'build/latest'); + +// Verify build first +const mainJsSize = fs.statSync(path.join(buildTimestampDir, 'main.js')).size; +const mainJsSizeLatest = fs.statSync(path.join(buildLatestDir, 'main.js')).size; + +// Create or update build history file +const buildHistoryPath = path.join(buildRootDir, 'build-history.json'); +let buildsInfo = []; +if (fs.existsSync(buildHistoryPath)) { + buildsInfo = JSON.parse(fs.readFileSync(buildHistoryPath, 'utf8')); +} + +// Add current build info +buildsInfo.push({ + timestamp: timestamp, + date: new Date().toISOString(), + version: version, + mainJsSize: mainJsSize +}); + +// Keep only last 20 builds info +if (buildsInfo.length > 20) { + buildsInfo = buildsInfo.slice(-20); +} + +fs.writeFileSync(buildHistoryPath, JSON.stringify(buildsInfo, null, 2)); + +// Clean up old builds (keep last 10) +const allBuilds = fs.readdirSync(buildRootDir) + .filter(dir => dir.match(/^\d{8}_\d{6}$/)) + .sort(); + +if (allBuilds.length > 10) { + const buildsToRemove = allBuilds.slice(0, -10); + buildsToRemove.forEach(oldBuild => { + const oldBuildPath = path.join(buildRootDir, oldBuild); + console.log(`🗑️ Removing old build: ${oldBuild}`); + fs.rmSync(oldBuildPath, { recursive: true, force: true }); + }); +} + +if (mainJsSize < 10000 || mainJsSizeLatest < 10000) { + console.error('❌ main.js seems too small. Build may have failed.'); + process.exit(1); +} + +// Get current build count +const currentBuilds = fs.readdirSync(buildRootDir) + .filter(dir => dir.match(/^\d{8}_\d{6}$/)); + +console.log(`\n✨ Build complete!`); +console.log(`📁 Build output:`); +console.log(` - Timestamped: build/${timestamp}/ (${(mainJsSize / 1024).toFixed(2)} KB)`); +console.log(` - Latest: build/latest/ (${(mainJsSizeLatest / 1024).toFixed(2)} KB)`); +console.log(`\n📚 Build history: ${currentBuilds.length} builds kept`); +console.log(`\n💡 Tip: Use 'npm run deploy' to deploy to Obsidian vaults`); diff --git a/src/i18n/en.ts b/src/i18n/en.ts index cc7d80b..caddece 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -55,7 +55,9 @@ export const en: TranslationResource = { micInit: 'Status: Initializing microphone...', recording: 'Status: Recording...', stopped: 'Status: Stopped', - cancelled: 'Status: Cancelled' + cancelled: 'Status: Cancelled', + vadSpeech: 'Status: Speech detected', + vadSilence: 'Status: Silence detected' }, processing: { transcribing: 'Status: Transcribing...', @@ -98,7 +100,8 @@ export const en: TranslationResource = { enterApiKey: 'Please enter API key', serviceInitFailed: 'Service initialization failed', audioTooShort: 'Audio is too short', - noAudioDetected: 'No audio detected' + noAudioDetected: 'No audio detected', + localVadMissing: 'Local VAD module not found. Using server-side detection instead. Place fvad.wasm and fvad.js under {path}.' }, error: { clipboardFailed: 'Failed to copy to clipboard', @@ -175,7 +178,12 @@ export const en: TranslationResource = { customDictionaryDesc: 'Manage corrections used for post-processing', dictionaryDefinite: 'Definite Corrections (max {max})', dictionaryImportExport: 'Dictionary Import/Export', - dictionaryImportExportDesc: 'Import or export your correction dictionary as JSON' + dictionaryImportExportDesc: 'Import or export your correction dictionary as JSON', + vadMode: 'Voice Activity Detection (VAD)', + vadModeDesc: 'Server VAD is recommended and requires no additional modules.', + vadModeLocalMissing: 'Local VAD requires fvad.wasm and fvad.js under {path}. Install them before switching.', + vadModeLocalAvailable: 'Local VAD files found in {path}. Recording will auto-stop on silence.', + vadModeDisabledDesc: 'Disable VAD to capture audio continuously without auto-stop.' }, options: { modelMini: 'GPT-4o mini Transcribe', @@ -184,7 +192,10 @@ export const en: TranslationResource = { languageJa: 'Japanese', languageEn: 'English', languageZh: 'Chinese', - languageKo: 'Korean' + languageKo: 'Korean', + vadServer: 'Server (default)', + vadLocal: 'Local (requires fvad.wasm)', + vadDisabled: 'Off' }, tooltips: { copy: 'Copy to clipboard', diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 8d58f6a..b952279 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -55,7 +55,9 @@ export const ja: TranslationResource = { micInit: 'ステータス: マイク初期化中...', recording: 'ステータス: 録音中...', stopped: 'ステータス: 停止済み', - cancelled: 'ステータス: キャンセルされました' + cancelled: 'ステータス: キャンセルされました', + vadSpeech: 'ステータス: 発話を検出', + vadSilence: 'ステータス: 無音を検出' }, processing: { transcribing: 'ステータス: 文字起こし中...', @@ -98,7 +100,8 @@ export const ja: TranslationResource = { enterApiKey: 'APIキーを入力してください', serviceInitFailed: 'サービスの初期化に失敗しました', audioTooShort: '音声が短すぎます', - noAudioDetected: '音声が検出されませんでした' + noAudioDetected: '音声が検出されませんでした', + localVadMissing: 'ローカルVADモジュールが見つからないため、サーバーVADに切り替えます。{path} に fvad.wasm と fvad.js を配置してください。' }, error: { clipboardFailed: 'クリップボードへのコピーに失敗しました', @@ -175,7 +178,12 @@ export const ja: TranslationResource = { customDictionaryDesc: '補正辞書を管理', dictionaryDefinite: '固定補正(最大{max}個)', dictionaryImportExport: '辞書のインポート/エクスポート', - dictionaryImportExportDesc: '補正辞書をJSONファイルとしてインポート・エクスポート' + dictionaryImportExportDesc: '補正辞書をJSONファイルとしてインポート・エクスポート', + vadMode: '音声区間検出(VAD)', + vadModeDesc: 'サーバーVADは追加モジュール不要の推奨設定です。', + vadModeLocalMissing: 'ローカルVADを有効にするには {path} に fvad.wasm と fvad.js を配置してください。', + vadModeLocalAvailable: '{path} でローカルVAD用モジュールを検出しました。無音で自動停止します。', + vadModeDisabledDesc: 'VADを無効化すると無音でも録音を継続します。' }, options: { modelMini: 'GPT-4o mini Transcribe', @@ -184,7 +192,10 @@ export const ja: TranslationResource = { languageJa: '日本語', languageEn: '英語', languageZh: '中国語', - languageKo: '韓国語' + languageKo: '韓国語', + vadServer: 'サーバー(標準)', + vadLocal: 'ローカル(fvad.wasm が必要)', + vadDisabled: 'オフ' }, tooltips: { copy: 'クリップボードにコピー', diff --git a/src/i18n/ko.ts b/src/i18n/ko.ts index 8b2aea4..06d159c 100644 --- a/src/i18n/ko.ts +++ b/src/i18n/ko.ts @@ -55,7 +55,9 @@ export const ko: TranslationResource = { micInit: '상태: 마이크 초기화 중...', recording: '상태: 녹음 중...', stopped: '상태: 중지됨', - cancelled: '상태: 취소됨' + cancelled: '상태: 취소됨', + vadSpeech: '상태: 음성을 감지했습니다', + vadSilence: '상태: 무음을 감지했습니다' }, processing: { transcribing: '상태: 음성 텍스트 변환 중...', @@ -98,7 +100,8 @@ export const ko: TranslationResource = { enterApiKey: 'API 키를 입력해주세요', serviceInitFailed: '서비스 초기화에 실패했습니다', audioTooShort: '오디오가 너무 짧습니다', - noAudioDetected: '오디오가 감지되지 않았습니다' + noAudioDetected: '오디오가 감지되지 않았습니다', + localVadMissing: '로컬 VAD 모듈을 찾을 수 없어 서버 VAD로 전환합니다. {path} 에 fvad.wasm 및 fvad.js 를 배치하세요.' }, error: { clipboardFailed: '클립보드 복사에 실패했습니다', @@ -175,7 +178,12 @@ export const ko: TranslationResource = { customDictionaryDesc: '후처리에 사용되는 교정 사전 관리', dictionaryDefinite: '고정 교정 (최대 {max}개)', dictionaryImportExport: '사전 가져오기/내보내기', - dictionaryImportExportDesc: '교정 사전을 JSON 파일로 가져오기 또는 내보내기' + dictionaryImportExportDesc: '교정 사전을 JSON 파일로 가져오기 또는 내보내기', + vadMode: '음성 활동 감지(VAD)', + vadModeDesc: '서버 VAD는 추가 모듈 없이 사용할 수 있는 권장 설정입니다.', + vadModeLocalMissing: '로컬 VAD를 사용하려면 {path} 에 fvad.wasm 및 fvad.js 를 배치하세요.', + vadModeLocalAvailable: '{path} 에서 로컬 VAD 모듈을 감지했습니다. 무음 시 자동으로 녹음을 중지합니다.', + vadModeDisabledDesc: 'VAD를 끄면 무음이어도 녹음이 계속됩니다.' }, options: { modelMini: 'GPT-4o mini Transcribe', @@ -184,7 +192,10 @@ export const ko: TranslationResource = { languageJa: '일본어', languageEn: '영어', languageZh: '중국어', - languageKo: '한국어' + languageKo: '한국어', + vadServer: '서버(기본값)', + vadLocal: '로컬(fvad.wasm 필요)', + vadDisabled: '꺼짐' }, tooltips: { copy: '클립보드에 복사', diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 99f7d77..292cf06 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -55,7 +55,9 @@ export const zh: TranslationResource = { micInit: '状态:麦克风初始化中...', recording: '状态:录音中...', stopped: '状态:已停止', - cancelled: '状态:已取消' + cancelled: '状态:已取消', + vadSpeech: '状态:检测到语音', + vadSilence: '状态:检测到静音' }, processing: { transcribing: '状态:语音转文字中...', @@ -98,7 +100,8 @@ export const zh: TranslationResource = { enterApiKey: '请输入API密钥', serviceInitFailed: '服务初始化失败', audioTooShort: '音频过短', - noAudioDetected: '未检测到音频' + noAudioDetected: '未检测到音频', + localVadMissing: '未找到本地VAD模块,已切换为服务器端检测。请将 fvad.wasm 和 fvad.js 放在 {path}。' }, error: { clipboardFailed: '复制到剪贴板失败', @@ -175,7 +178,12 @@ export const zh: TranslationResource = { customDictionaryDesc: '管理用于后处理的校正词典', dictionaryDefinite: '固定校正(最多{max}个)', dictionaryImportExport: '词典导入/导出', - dictionaryImportExportDesc: '将校正词典作为JSON文件导入或导出' + dictionaryImportExportDesc: '将校正词典作为JSON文件导入或导出', + vadMode: '语音活动检测(VAD)', + vadModeDesc: '推荐使用服务器VAD,无需额外模块。', + vadModeLocalMissing: '启用本地VAD需要将 fvad.wasm 和 fvad.js 放在 {path}。', + vadModeLocalAvailable: '在 {path} 检测到本地VAD模块,将在静音时自动停止录音。', + vadModeDisabledDesc: '关闭VAD时,录音不会因静音自动停止。' }, options: { modelMini: 'GPT-4o mini Transcribe', @@ -184,7 +192,10 @@ export const zh: TranslationResource = { languageJa: '日语', languageEn: '英语', languageZh: '中文', - languageKo: '韩语' + languageKo: '韩语', + vadServer: '服务器(默认)', + vadLocal: '本地(需要 fvad.wasm)', + vadDisabled: '关闭' }, tooltips: { copy: '复制到剪贴板', diff --git a/src/interfaces/i18n.ts b/src/interfaces/i18n.ts index e2f18eb..9e82070 100644 --- a/src/interfaces/i18n.ts +++ b/src/interfaces/i18n.ts @@ -75,6 +75,8 @@ export type TranslationResource = { recording: string; stopped: string; cancelled: string; + vadSpeech: string; + vadSilence: string; }; processing: { transcribing: string; @@ -118,6 +120,7 @@ export type TranslationResource = { serviceInitFailed: string; audioTooShort: string; noAudioDetected: string; + localVadMissing: string; }; error: { clipboardFailed: string; @@ -195,6 +198,11 @@ export type TranslationResource = { dictionaryDefinite: string; dictionaryImportExport: string; dictionaryImportExportDesc: string; + vadMode: string; + vadModeDesc: string; + vadModeLocalMissing: string; + vadModeLocalAvailable: string; + vadModeDisabledDesc: string; }; options: { modelMini: string; @@ -204,6 +212,9 @@ export type TranslationResource = { languageEn: string; languageZh: string; languageKo: string; + vadServer: string; + vadLocal: string; + vadDisabled: string; }; tooltips: { copy: string; diff --git a/src/interfaces/recording.ts b/src/interfaces/recording.ts index 96a459b..81754bb 100644 --- a/src/interfaces/recording.ts +++ b/src/interfaces/recording.ts @@ -28,4 +28,5 @@ export interface RecordingState { isPushToTalkMode: boolean; lastStopReason?: StopReason; processingQueue: AudioQueueItem[]; + activeVadMode?: 'server' | 'local' | 'disabled'; } diff --git a/src/interfaces/settings.ts b/src/interfaces/settings.ts index 082e94f..4840add 100644 --- a/src/interfaces/settings.ts +++ b/src/interfaces/settings.ts @@ -6,6 +6,7 @@ export interface VoiceInputSettings { openaiApiKey: string; enableTranscriptionCorrection: boolean; // 文字起こし補正を有効化(辞書補正) transcriptionModel: 'gpt-4o-transcribe' | 'gpt-4o-mini-transcribe'; + vadMode: 'server' | 'local' | 'disabled'; // 録音設定 maxRecordingSeconds: number; // 最大録音時間(秒) // 言語設定 @@ -31,6 +32,7 @@ export const DEFAULT_SETTINGS: VoiceInputSettings = { // 言語設定 transcriptionLanguage: 'en', // 初期値(実際は起動時に環境ロケールへ移行) pluginLanguage: 'en', // 初期値、実際はObsidianの設定に従う + vadMode: 'server', customDictionary: { definiteCorrections: [] }, // デバッグ設定 debugMode: false, // 本番環境ではデフォルトでオフ diff --git a/src/plugin/VoiceInputPlugin.ts b/src/plugin/VoiceInputPlugin.ts index a58d3ad..6e54e28 100644 --- a/src/plugin/VoiceInputPlugin.ts +++ b/src/plugin/VoiceInputPlugin.ts @@ -348,6 +348,12 @@ export default class VoiceInputPlugin extends Plugin { this.logger?.info('Migrated advanced.transcriptionLanguage from auto to detected locale'); } } + + if (!hasSettingsKey(data, 'vadMode')) { + this.settings.vadMode = 'server'; + needsSave = true; + this.logger?.info('Initialized vadMode to server (default)'); + } } else { // 保存データが存在しない場合(初回起動) this.settings.pluginLanguage = this.detectPluginLanguage(); @@ -356,6 +362,7 @@ export default class VoiceInputPlugin extends Plugin { languageLinkingEnabled: true, transcriptionLanguage: this.detectPluginLanguage() }; + this.settings.vadMode = 'server'; needsSave = true; this.logger?.info(`First run - detected locale: ${this.settings.pluginLanguage}, transcriptionLanguage set to detected locale, advanced settings initialized`); } diff --git a/src/settings/VoiceInputSettingTab.ts b/src/settings/VoiceInputSettingTab.ts index e10f588..eb65a7d 100644 --- a/src/settings/VoiceInputSettingTab.ts +++ b/src/settings/VoiceInputSettingTab.ts @@ -13,7 +13,7 @@ import { getI18n, createServiceLogger } from '../services'; import type { I18nService, Locale } from '../interfaces'; import { SUPPORTED_LOCALES } from '../interfaces'; import { VIEW_TYPE_VOICE_INPUT } from '../views'; -import { Logger } from '../utils'; +import { Logger, hasLocalVadAssets, getLocalVadInstructionsPath } from '../utils'; import { patternsToString, stringToPatterns, migrateCorrectionEntries } from '../utils'; import { DeferredViewHelper } from '../utils'; @@ -234,6 +234,44 @@ export class VoiceInputSettingTab extends PluginSettingTab { await this.plugin.saveSettings(); })); + const vadInstructionsPath = getLocalVadInstructionsPath(this.app); + const initialVadMode = this.plugin.settings.vadMode ?? 'server'; + const vadModeSetting = new Setting(containerEl) + .setName(this.i18n.t('ui.settings.vadMode')) + .addDropdown(dropdown => { + dropdown + .addOption('server', this.i18n.t('ui.options.vadServer')) + .addOption('local', this.i18n.t('ui.options.vadLocal')) + .addOption('disabled', this.i18n.t('ui.options.vadDisabled')) + .setValue(initialVadMode) + .onChange(async (value) => { + const mode = value as 'server' | 'local' | 'disabled'; + this.plugin.settings.vadMode = mode; + await this.plugin.saveSettings(); + const available = await updateVadDescription(mode); + if (mode === 'local' && !available) { + new Notice(this.i18n.t('notification.warning.localVadMissing', { path: vadInstructionsPath })); + } + }); + }); + + const updateVadDescription = async (mode: 'server' | 'local' | 'disabled'): Promise => { + if (mode === 'local') { + const available = await hasLocalVadAssets(this.app); + const key = available ? 'ui.settings.vadModeLocalAvailable' : 'ui.settings.vadModeLocalMissing'; + vadModeSetting.setDesc(this.i18n.t(key, { path: vadInstructionsPath })); + return available; + } + if (mode === 'disabled') { + vadModeSetting.setDesc(this.i18n.t('ui.settings.vadModeDisabledDesc')); + return true; + } + vadModeSetting.setDesc(this.i18n.t('ui.settings.vadModeDesc')); + return true; + }; + + void updateVadDescription(initialVadMode); + // AI Post-processing (dictionary-based) new Setting(containerEl) .setName(this.i18n.t('ui.settings.aiPostProcessing')) diff --git a/src/utils/VadUtils.ts b/src/utils/VadUtils.ts new file mode 100644 index 0000000..217851a --- /dev/null +++ b/src/utils/VadUtils.ts @@ -0,0 +1,30 @@ +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}`); +} + +function getPluginAssetPath(app: App, fileName: string): string { + return normalizePath(`${getPluginBasePath(app)}/${fileName}`); +} + +export async function hasLocalVadAssets(app: App): Promise { + const adapter = app.vault.adapter; + const wasmPath = getPluginAssetPath(app, FVAD_WASM); + const jsPath = getPluginAssetPath(app, FVAD_JS); + + const [hasWasm, hasJs] = await Promise.all([ + adapter.exists(wasmPath), + adapter.exists(jsPath) + ]); + + return Boolean(hasWasm && hasJs); +} + +export function getLocalVadInstructionsPath(app: App): string { + return getPluginBasePath(app); +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 54b8ecf..3d652b5 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -2,4 +2,5 @@ export { Logger, getLogger, LogLevel } from './Logger'; export { mergeSettings, hasSettingsKey } from './settings-utils'; export { migrateCorrectionEntries, patternsToString, stringToPatterns } from './migration'; export { DeferredViewHelper } from './DeferredViewHelper'; +export { hasLocalVadAssets, getLocalVadInstructionsPath } from './VadUtils'; // export { ObsidianHttpClient } from './ObsidianHttpClient'; // 未使用: requestUrlを直接使用 diff --git a/src/views/VoiceInputViewActions.ts b/src/views/VoiceInputViewActions.ts index be70be7..66a05f0 100644 --- a/src/views/VoiceInputViewActions.ts +++ b/src/views/VoiceInputViewActions.ts @@ -8,8 +8,9 @@ import type { VoiceInputView } from './VoiceInputView'; import type VoiceInputPlugin from '../plugin'; import { getI18n, createServiceLogger } from '../services'; import type { I18nService } from '../interfaces'; -import type { StopReason, RecordingState } from '../interfaces'; -import { Logger } from '../utils'; +import type { StopReason, RecordingState, AudioRecorderOptionsWithVAD, AudioRecorderOptionsWithoutVAD } from '../interfaces'; +import { Logger, hasLocalVadAssets, getLocalVadInstructionsPath } from '../utils'; +import { DEFAULT_AUDIO_SETTINGS, DEFAULT_VAD_SETTINGS } from '../config'; /** * Handles all recording and text manipulation actions for the Voice Input View @@ -25,7 +26,8 @@ export class VoiceInputViewActions { recordingState: RecordingState = { isRecording: false, isPushToTalkMode: false, - processingQueue: [] + processingQueue: [], + activeVadMode: 'server' }; private isProcessingAudio = false; // 連打や高速操作による並行実行を防ぐための遷移ロック @@ -33,6 +35,7 @@ export class VoiceInputViewActions { private statusTimer: NodeJS.Timeout | null = null; private clearConfirmTimer: NodeJS.Timeout | null = null; private clearPressCount = 0; + private pendingVadAutoStop = false; constructor(view: VoiceInputView, plugin: VoiceInputPlugin) { this.view = view; @@ -111,6 +114,16 @@ export class VoiceInputViewActions { } } + private handleVADStatusChange(status: 'speech' | 'silence') { + if (status === 'speech') { + this.pendingVadAutoStop = false; + this.view.ui.statusEl.setText(this.i18n.t('status.recording.vadSpeech')); + } else { + this.pendingVadAutoStop = true; + this.view.ui.statusEl.setText(this.i18n.t('status.recording.vadSilence')); + } + } + /** * Toggle recording on/off */ @@ -151,8 +164,32 @@ export class VoiceInputViewActions { // Obsidian provides its own secure environment try { + const desiredVadMode = this.plugin.settings.vadMode ?? 'server'; + const wantsLocalVad = desiredVadMode === 'local'; + const wantsDisabled = desiredVadMode === 'disabled'; + let localVadAvailable = false; + + if (wantsLocalVad) { + localVadAvailable = await hasLocalVadAssets(this.app); + if (!localVadAvailable) { + const pluginPath = getLocalVadInstructionsPath(this.app); + new Notice(this.i18n.t('notification.warning.localVadMissing', { path: pluginPath })); + this.logger.warn('Local VAD assets not found; falling back to server VAD', { pluginPath }); + } + } + + const effectiveVadMode: 'server' | 'local' | 'disabled' = localVadAvailable + ? 'local' + : wantsDisabled + ? 'disabled' + : 'server'; + this.recordingState.activeVadMode = effectiveVadMode; + this.pendingVadAutoStop = false; + this.logger.info('Starting recording session', { - mode: 'continuous', + requestedVadMode: desiredVadMode, + effectiveVadMode, + localVadAvailable, maxDuration: this.plugin.settings.maxRecordingSeconds }); @@ -169,24 +206,47 @@ export class VoiceInputViewActions { this.view.ui.setButtonsEnabled(false); this.view.ui.showCancelButton(true); // Show cancel button - // Initialize audio recorder for continuous recording mode - // AudioWorklet requires serving from same origin as the page - // In Obsidian, we'll let it fallback to ScriptProcessor - this.audioRecorder = new AudioRecorder({ - useVAD: false, // 常に連続録音モード - onSpeechEnd: async (audioBlob) => { - // 連続録音モードでのonSpeechEndは最大時間到達時のみ - this.recordingState.isRecording = false; - this.updateUIAfterStop(); - await this.processRecordedAudio(audioBlob, { type: 'max-duration' }); - }, - onMicrophoneStatusChange: (status) => { - this.handleMicrophoneStatus(status); - }, - visualizerContainer: this.view.ui.visualizerContainer, - useSimpleVisualizer: false, - maxRecordingSeconds: this.plugin.settings.maxRecordingSeconds - }); + const handleSpeechEnd = async (audioBlob: Blob) => { + this.recordingState.isRecording = false; + const reasonType: StopReason['type'] = (this.recordingState.activeVadMode === 'local' && this.pendingVadAutoStop) + ? 'vad-auto' + : 'max-duration'; + this.pendingVadAutoStop = false; + this.updateUIAfterStop(); + await this.processRecordedAudio(audioBlob, { type: reasonType }); + }; + + const handleMicrophoneStatusChange = (status: 'initializing' | 'ready' | 'error') => { + this.handleMicrophoneStatus(status); + }; + + if (effectiveVadMode === 'local') { + const vadOptions: AudioRecorderOptionsWithVAD = { + useVAD: true, + app: this.app, + onSpeechEnd: handleSpeechEnd, + onMicrophoneStatusChange: handleMicrophoneStatusChange, + onVADStatusChange: (status) => this.handleVADStatusChange(status), + visualizerContainer: this.view.ui.visualizerContainer, + useSimpleVisualizer: false, + maxRecordingSeconds: this.plugin.settings.maxRecordingSeconds, + autoStopSilenceDuration: DEFAULT_AUDIO_SETTINGS.autoStopSilenceDuration, + vadMode: DEFAULT_VAD_SETTINGS.mode, + minSpeechDuration: DEFAULT_VAD_SETTINGS.minSpeechDuration, + minSilenceDuration: DEFAULT_VAD_SETTINGS.minSilenceDuration + }; + this.audioRecorder = new AudioRecorder(vadOptions); + } else { + const continuousOptions: AudioRecorderOptionsWithoutVAD = { + useVAD: false, + onSpeechEnd: handleSpeechEnd, + onMicrophoneStatusChange: handleMicrophoneStatusChange, + visualizerContainer: this.view.ui.visualizerContainer, + useSimpleVisualizer: false, + maxRecordingSeconds: this.plugin.settings.maxRecordingSeconds + }; + this.audioRecorder = new AudioRecorder(continuousOptions); + } await this.audioRecorder.initialize(); await this.audioRecorder.startRecording(); @@ -217,6 +277,7 @@ export class VoiceInputViewActions { return; } + this.pendingVadAutoStop = false; // Default to manual stop if no reason provided const stopReason = reason || { type: 'manual' as const }; this.recordingState.lastStopReason = stopReason;