348 lines
8.8 KiB
TypeScript
348 lines
8.8 KiB
TypeScript
import { Notice, Plugin, TFile } from "obsidian";
|
|
import { Timer } from "src/recorder/Timer";
|
|
import { Controls } from "src/recorder/Controls";
|
|
import { AudioHandler } from "src/transcription/AudioHandler";
|
|
import { NibbleAISettingsTab } from "src/NibbleAISettingsTab";
|
|
import { SettingsManager, PluginSettings } from "src/SettingsManager";
|
|
import { NativeAudioRecorder } from "src/recorder/AudioRecorder";
|
|
import { RecordingStatus, StatusBar } from "src/recorder/StatusBar";
|
|
import { getExtensionFromMimeType } from "src/transcription/utils";
|
|
import { Proofreader } from "src/proofreading/Proofreader";
|
|
import { TitleGenerator } from "src/title/TitleGenerator";
|
|
import { PropertyFiller } from "src/property/PropertyFiller";
|
|
|
|
export default class NibbleAI extends Plugin {
|
|
settings: PluginSettings;
|
|
settingsManager: SettingsManager;
|
|
timer: Timer;
|
|
recorder: NativeAudioRecorder;
|
|
audioHandler: AudioHandler;
|
|
controls: Controls | null = null;
|
|
statusBar: StatusBar;
|
|
proofreader: Proofreader;
|
|
titleGenerator: TitleGenerator;
|
|
propertyFiller: PropertyFiller;
|
|
|
|
async onload() {
|
|
this.settingsManager = new SettingsManager(this);
|
|
this.settings = await this.settingsManager.loadSettings();
|
|
|
|
this.addRibbonIcon("bot", "Open NibbleAI recording controls", () => {
|
|
this.openControls();
|
|
});
|
|
|
|
this.addSettingTab(new NibbleAISettingsTab(this.app, this));
|
|
|
|
this.timer = new Timer();
|
|
this.audioHandler = new AudioHandler(this);
|
|
this.recorder = new NativeAudioRecorder();
|
|
const deviceId =
|
|
this.settings.audioDeviceId === "default"
|
|
? null
|
|
: this.settings.audioDeviceId;
|
|
this.recorder.setDeviceId(deviceId);
|
|
|
|
this.statusBar = new StatusBar(this);
|
|
|
|
this.proofreader = new Proofreader(this);
|
|
this.titleGenerator = new TitleGenerator(this);
|
|
this.propertyFiller = new PropertyFiller(this);
|
|
|
|
// Timer reacts to recording state changes
|
|
this.statusBar.onChange((status) => {
|
|
switch (status) {
|
|
case RecordingStatus.Recording:
|
|
this.timer.start();
|
|
break;
|
|
case RecordingStatus.Paused:
|
|
this.timer.pause();
|
|
break;
|
|
case RecordingStatus.Processing:
|
|
case RecordingStatus.Idle:
|
|
this.timer.reset();
|
|
break;
|
|
}
|
|
});
|
|
|
|
this.addCommands();
|
|
this.registerEvents();
|
|
this.registerUriHandler();
|
|
}
|
|
|
|
onunload() {
|
|
if (this.controls) {
|
|
this.controls.close();
|
|
}
|
|
this.statusBar.remove();
|
|
}
|
|
|
|
// ── Recording state transitions ──
|
|
|
|
async startRecording() {
|
|
if (
|
|
this.statusBar.status === RecordingStatus.Recording ||
|
|
this.statusBar.status === RecordingStatus.Paused
|
|
) {
|
|
new Notice("Already recording");
|
|
return;
|
|
}
|
|
try {
|
|
await this.recorder.startRecording();
|
|
this.statusBar.updateStatus(RecordingStatus.Recording);
|
|
new Notice("Recording...");
|
|
} catch (err) {
|
|
this.statusBar.updateStatus(RecordingStatus.Idle);
|
|
new Notice("✘ Could not start recording");
|
|
}
|
|
}
|
|
|
|
async stopRecording() {
|
|
if (
|
|
this.statusBar.status !== RecordingStatus.Recording &&
|
|
this.statusBar.status !== RecordingStatus.Paused
|
|
) {
|
|
return;
|
|
}
|
|
this.statusBar.updateStatus(RecordingStatus.Processing);
|
|
const audioBlob = await this.recorder.stopRecording();
|
|
const extension = getExtensionFromMimeType(this.recorder.getMimeType());
|
|
const fileName = `${new Date()
|
|
.toISOString()
|
|
.replace(/[:.]/g, "-")}.${extension}`;
|
|
await this.audioHandler.sendAudioData(audioBlob, fileName);
|
|
this.statusBar.updateStatus(RecordingStatus.Idle);
|
|
}
|
|
|
|
async pauseRecording() {
|
|
if (this.statusBar.status === RecordingStatus.Recording) {
|
|
await this.recorder.pauseRecording();
|
|
this.statusBar.updateStatus(RecordingStatus.Paused);
|
|
new Notice("Recording paused");
|
|
} else if (this.statusBar.status === RecordingStatus.Paused) {
|
|
await this.recorder.pauseRecording();
|
|
this.statusBar.updateStatus(RecordingStatus.Recording);
|
|
new Notice("Recording resumed");
|
|
}
|
|
}
|
|
|
|
async cancelRecording() {
|
|
if (
|
|
this.statusBar.status !== RecordingStatus.Recording &&
|
|
this.statusBar.status !== RecordingStatus.Paused
|
|
) {
|
|
return;
|
|
}
|
|
await this.recorder.stopRecording();
|
|
this.statusBar.updateStatus(RecordingStatus.Idle);
|
|
new Notice("Recording cancelled");
|
|
}
|
|
|
|
openControls() {
|
|
if (!this.controls) {
|
|
this.controls = new Controls(this);
|
|
}
|
|
this.controls.open();
|
|
}
|
|
|
|
// ── Commands ──
|
|
|
|
addCommands() {
|
|
// Transcription / Recording
|
|
this.addCommand({
|
|
id: "start-stop-recording",
|
|
name: "Start/stop recording",
|
|
icon: "mic",
|
|
callback: async () => {
|
|
if (
|
|
this.statusBar.status !== RecordingStatus.Recording &&
|
|
this.statusBar.status !== RecordingStatus.Paused
|
|
) {
|
|
await this.startRecording();
|
|
} else {
|
|
await this.stopRecording();
|
|
}
|
|
},
|
|
hotkeys: [{ modifiers: ["Alt"], key: "Q" }],
|
|
});
|
|
|
|
this.addCommand({
|
|
id: "upload-audio-file",
|
|
name: "Upload audio file",
|
|
icon: "upload",
|
|
callback: () => {
|
|
const fileInput = document.createElement("input");
|
|
fileInput.type = "file";
|
|
fileInput.accept =
|
|
"audio/*,video/*,.mp4,.m4a,.wav,.webm,.ogg,.mp3";
|
|
fileInput.onchange = async (event) => {
|
|
const files = (event.target as HTMLInputElement).files;
|
|
if (files && files.length > 0) {
|
|
const file = files[0];
|
|
const audioBlob = file.slice(0, file.size, file.type);
|
|
await this.audioHandler.sendAudioData(
|
|
audioBlob,
|
|
file.name
|
|
);
|
|
}
|
|
};
|
|
fileInput.click();
|
|
},
|
|
});
|
|
|
|
this.addCommand({
|
|
id: "pause-resume-recording",
|
|
name: "Pause/resume recording",
|
|
icon: "pause",
|
|
callback: () => this.pauseRecording(),
|
|
});
|
|
|
|
this.addCommand({
|
|
id: "open-recording-controls",
|
|
name: "Open recording controls",
|
|
icon: "popup-open",
|
|
callback: () => this.openControls(),
|
|
});
|
|
|
|
// Proofreading
|
|
this.addCommand({
|
|
id: "fix-punctuation-grammar",
|
|
name: "Proofread with AI",
|
|
icon: "wand",
|
|
editorCallback: (editor) => {
|
|
const selection = editor.getSelection();
|
|
if (!selection || selection.trim().length === 0) {
|
|
new Notice("No text selected.");
|
|
return;
|
|
}
|
|
this.proofreader.fixSelection(editor);
|
|
},
|
|
});
|
|
|
|
// Title Generation
|
|
this.addCommand({
|
|
id: "generate-title",
|
|
name: "Generate title for current note",
|
|
icon: "heading",
|
|
callback: async () => {
|
|
await this.titleGenerator.generateTitle();
|
|
},
|
|
});
|
|
|
|
// Property Filler
|
|
this.addCommand({
|
|
id: "fill-property",
|
|
name: "Fill property with AI",
|
|
icon: "pencil",
|
|
callback: async () => {
|
|
await this.propertyFiller.fillProperty();
|
|
},
|
|
});
|
|
|
|
// Dynamic preset property commands from settings
|
|
const propertyNames = this.settings.propertyPresets
|
|
? this.settings.propertyPresets.split(",").map(s => s.trim()).filter(s => s.length > 0)
|
|
: [];
|
|
|
|
const propertyIcons: Record<string, string> = {
|
|
summary: "list",
|
|
tags: "tags",
|
|
};
|
|
|
|
for (const prop of propertyNames) {
|
|
const safeId = prop.toLowerCase().replace(/[^a-z0-9]/g, "-");
|
|
this.addCommand({
|
|
id: `fill-property-${safeId}`,
|
|
name: `Fill "${prop}" property`,
|
|
icon: propertyIcons[prop] || "pencil",
|
|
callback: async () => {
|
|
await this.propertyFiller.fillSpecificProperty(prop);
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
// ── Events ──
|
|
|
|
registerEvents() {
|
|
// Right-click audio files to transcribe
|
|
this.registerEvent(
|
|
this.app.workspace.on("file-menu", (menu, file) => {
|
|
if (!(file instanceof TFile)) return;
|
|
|
|
const audioExtensions = [
|
|
".mp3",
|
|
".mp4",
|
|
".mpeg",
|
|
".mpga",
|
|
".m4a",
|
|
".wav",
|
|
".webm",
|
|
".ogg",
|
|
];
|
|
if (!audioExtensions.some((ext) => file.path.endsWith(ext)))
|
|
return;
|
|
|
|
menu.addItem((item) => {
|
|
item.setTitle("Transcribe audio file 🔊")
|
|
.setIcon("document")
|
|
.onClick(async () => {
|
|
const audioBlob = new Blob([
|
|
await file.vault.readBinary(file),
|
|
]);
|
|
await this.audioHandler.sendAudioData(
|
|
audioBlob,
|
|
file.name
|
|
);
|
|
});
|
|
});
|
|
})
|
|
);
|
|
|
|
// Right-click selected text to fix punctuation
|
|
this.registerEvent(
|
|
this.app.workspace.on("editor-menu", (menu, editor) => {
|
|
const selection = editor.getSelection();
|
|
if (!selection || selection.trim().length === 0) return;
|
|
if (!this.settings.proofreadingContextMenu) return;
|
|
|
|
menu.addItem((item) => {
|
|
item
|
|
.setTitle("Proofread with AI")
|
|
.setIcon("wand")
|
|
.onClick(async () => {
|
|
await this.proofreader.fixSelection(editor);
|
|
});
|
|
});
|
|
})
|
|
);
|
|
}
|
|
|
|
// ── URI Handler ──
|
|
|
|
registerUriHandler() {
|
|
this.registerObsidianProtocolHandler("nibbleai", async (params) => {
|
|
const command = params.command;
|
|
if (!command) {
|
|
this.openControls();
|
|
return;
|
|
}
|
|
|
|
switch (command) {
|
|
case "start":
|
|
await this.startRecording();
|
|
break;
|
|
case "stop":
|
|
await this.stopRecording();
|
|
break;
|
|
case "pause":
|
|
await this.pauseRecording();
|
|
break;
|
|
case "cancel":
|
|
await this.cancelRecording();
|
|
break;
|
|
default:
|
|
new Notice(`✘ Unknown nibbleai command: ${command}`);
|
|
}
|
|
});
|
|
}
|
|
} |