mirror of
https://github.com/mssoftjp/obsidian-voice-input.git
synced 2026-07-22 06:44:48 +00:00
feat(vad): default to server mode with optional local assets
This commit is contained in:
parent
575f475b05
commit
6aeee71f45
14 changed files with 417 additions and 41 deletions
|
|
@ -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 `<vault>/.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. `<Vault>/.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` 必須)、オフ から選択
|
||||
|
||||
### 言語設定
|
||||
|
||||
|
|
|
|||
177
scripts/post-build.mjs
Normal file
177
scripts/post-build.mjs
Normal file
|
|
@ -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`);
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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: 'クリップボードにコピー',
|
||||
|
|
|
|||
|
|
@ -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: '클립보드에 복사',
|
||||
|
|
|
|||
|
|
@ -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: '复制到剪贴板',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -28,4 +28,5 @@ export interface RecordingState {
|
|||
isPushToTalkMode: boolean;
|
||||
lastStopReason?: StopReason;
|
||||
processingQueue: AudioQueueItem[];
|
||||
activeVadMode?: 'server' | 'local' | 'disabled';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, // 本番環境ではデフォルトでオフ
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<boolean> => {
|
||||
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'))
|
||||
|
|
|
|||
30
src/utils/VadUtils.ts
Normal file
30
src/utils/VadUtils.ts
Normal file
|
|
@ -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<boolean> {
|
||||
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);
|
||||
}
|
||||
|
|
@ -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を直接使用
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue