feat(vad): default to server mode with optional local assets

This commit is contained in:
mssoftjp 2025-10-30 02:58:32 +09:00
parent 575f475b05
commit 6aeee71f45
14 changed files with 417 additions and 41 deletions

View file

@ -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
- Autosave 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 plugins settings.
- AI Postprocessing: enable dictionarybased 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 @@ Thirdparty licensing: see `THIRD_PARTY_LICENSES.md`.
- ビュー内のクイック操作(コピー/クリア/カーソル位置へ挿入/末尾へ追記)
- 自動保存(定期保存とフォーカス外れ時)。再オープン時に自動復元
- 多言語サポート(日本語、英語、中国語、韓国語のインターフェース)
- VADモード選択標準はサーバーVAD。`fvad.wasm`/`fvad.js` を配置するとローカルVADで無音自動停止が可能
## 必要条件
@ -114,7 +117,7 @@ Thirdparty 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 @@ Thirdparty 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
View 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`);

View file

@ -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',

View file

@ -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: 'クリップボードにコピー',

View file

@ -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: '클립보드에 복사',

View file

@ -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: '复制到剪贴板',

View file

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

View file

@ -28,4 +28,5 @@ export interface RecordingState {
isPushToTalkMode: boolean;
lastStopReason?: StopReason;
processingQueue: AudioQueueItem[];
activeVadMode?: 'server' | 'local' | 'disabled';
}

View file

@ -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, // 本番環境ではデフォルトでオフ

View file

@ -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`);
}

View file

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

View file

@ -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を直接使用

View file

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