This commit is contained in:
Michael Gorini 2026-05-05 15:02:27 +02:00
parent 7226641990
commit 75c5406c49
24 changed files with 5201 additions and 379 deletions

53
eslint.config.mjs Normal file
View file

@ -0,0 +1,53 @@
import tsparser from "@typescript-eslint/parser";
import { defineConfig } from "eslint/config";
import obsidianmd from "eslint-plugin-obsidianmd";
const obsidianJsonConfig = obsidianmd.configs.recommended.find((config) => config.language === "json/json");
export default defineConfig([
{
ignores: ["dist/**", ".test-dist/**", "node_modules/**"],
},
...obsidianmd.configs.recommended,
{
files: ["manifest.json"],
plugins: obsidianJsonConfig?.plugins,
language: "json/json",
rules: {
"no-irregular-whitespace": "off",
"obsidianmd/validate-manifest": "error",
},
},
{
files: ["src/**/*.ts"],
languageOptions: {
parser: tsparser,
parserOptions: {
project: "./tsconfig.json",
},
},
rules: {
"obsidianmd/ui/sentence-case": [
"warn",
{
brands: [
"Resonance",
"Obsidian",
"Web Audio",
"Ollama",
"OpenAI",
"Gemini",
"Anthropic",
"whisper.cpp",
"whisper-cli",
"/path/to/whisper-cli",
"gemma3",
"http://localhost:11434",
],
acronyms: ["API", "CLI", "WAV", "OS", "URL", "HTTP"],
enforceCamelCaseLower: true,
},
],
},
},
]);

4545
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -8,13 +8,17 @@
"dev": "node ./node_modules/vite/bin/vite.js build --watch",
"build": "node ./node_modules/vite/bin/vite.js build && node scripts/postbuild.mjs",
"clean": "node -e \"import('fs').then(fs=>{for(const dir of ['dist','.test-dist']){try{fs.rmSync(dir,{recursive:true,force:true});}catch{}}})\"",
"lint": "eslint manifest.json src/**/*.ts",
"typecheck": "node ./node_modules/typescript/bin/tsc --noEmit -p tsconfig.json",
"test": "node ./node_modules/typescript/bin/tsc -p tsconfig.tests.json && node -e \"import('fs').then(fs=>fs.writeFileSync('.test-dist/package.json', JSON.stringify({type:'commonjs'})))\" && node --test .test-dist/tests/**/*.test.js"
},
"devDependencies": {
"@types/node": "^22.18.0",
"@typescript-eslint/parser": "^8.59.2",
"eslint": "^9.39.4",
"eslint-plugin-obsidianmd": "^0.2.9",
"obsidian": "^1.8.7",
"typescript": "^5.9.2",
"vite": "^5.4.19"
}
}
}

View file

@ -502,7 +502,9 @@ export class SessionController {
if (!this.captureAdapter?.isRunning()) return;
try {
await this.captureAdapter.stop();
} catch {}
} catch {
// Capture shutdown is best effort when the session is already failing.
}
}
private resetSnapshot() {

View file

@ -1,3 +1,4 @@
import { requestUrl, type RequestUrlParam } from "obsidian";
import { getProviderCapabilities, getSelectedSummaryModel, type SummaryProviderId } from "../../domain/providers";
import type { SummarySettings } from "../../domain/settings";
@ -7,6 +8,30 @@ export interface SummaryResult {
markdown: string;
}
interface OllamaGenerateResponse {
response?: string;
}
interface GeminiResponse {
candidates?: Array<{
content?: {
parts?: Array<{ text?: string }>;
};
}>;
}
interface OpenAIResponse {
choices?: Array<{
message?: {
content?: string;
};
}>;
}
interface AnthropicResponse {
content?: Array<{ text?: string }>;
}
function isInvalidTranscript(transcript: string): boolean {
const text = transcript.trim();
if (!text) return true;
@ -33,7 +58,7 @@ export function detectLanguageFromTranscript(transcript: string): string {
if (/[àâäéèêëïîôöùûüÿç]/.test(text)) scores.fr += 3;
const maxScore = Math.max(...Object.values(scores));
if (maxScore === 0) return "en";
return (Object.entries(scores).find(([, value]) => value === maxScore)?.[0] ?? "en") as string;
return (Object.entries(scores).find(([, value]) => value === maxScore)?.[0] ?? "en");
}
function buildSystemGuard(expectedLanguage: string, transcript: string): string {
@ -96,9 +121,10 @@ export class SummaryAdapter {
expectedLanguage: string
): Promise<string> {
const base = settings.ollamaEndpoint.trim() || "http://localhost:11434";
const response = await fetch(`${base}/api/generate`, {
const json = await requestProviderJson<OllamaGenerateResponse>("Ollama", {
url: `${base}/api/generate`,
method: "POST",
headers: { "Content-Type": "application/json" },
contentType: "application/json",
body: JSON.stringify({
model: settings.ollamaModel || getProviderCapabilities("ollama").defaultModel,
prompt: `${buildSystemGuard(expectedLanguage, transcript)}\n\n${prompt}\n\nTranscript:\n${transcript}`,
@ -106,11 +132,7 @@ export class SummaryAdapter {
options: { temperature: 0, top_p: 0.9, top_k: 40, num_predict: 2048 },
}),
});
if (!response.ok) {
throw new Error(`Ollama API error: ${response.status} ${await response.text().catch(() => "")}`);
}
const json = await response.json();
return String(json?.response ?? "").trim();
return String(json.response ?? "").trim();
}
private async summarizeWithGemini(
@ -126,16 +148,13 @@ export class SummaryAdapter {
systemInstruction: { role: "system", parts: [{ text: buildSystemGuard(expectedLanguage, transcript) }] },
contents: [{ role: "user", parts: [{ text: `${prompt}\n\nTranscript:\n${transcript}` }] }],
};
const response = await fetch(`${url}?key=${encodeURIComponent(settings.geminiApiKey)}`, {
const json = await requestProviderJson<GeminiResponse>("Gemini", {
url: `${url}?key=${encodeURIComponent(settings.geminiApiKey)}`,
method: "POST",
headers: { "Content-Type": "application/json" },
contentType: "application/json",
body: JSON.stringify(body),
});
if (!response.ok) {
throw new Error(`Gemini API error: ${response.status} ${await response.text().catch(() => "")}`);
}
const json = await response.json();
return String(json?.candidates?.[0]?.content?.parts?.map((part: { text?: string }) => part?.text ?? "").join("\n") ?? "").trim();
return String(json.candidates?.[0]?.content?.parts?.map((part) => part.text ?? "").join("\n") ?? "").trim();
}
private async summarizeWithOpenAI(
@ -145,12 +164,13 @@ export class SummaryAdapter {
expectedLanguage: string
): Promise<string> {
if (!settings.openaiApiKey.trim()) throw new Error("OpenAI API key not configured.");
const response = await fetch("https://api.openai.com/v1/chat/completions", {
const json = await requestProviderJson<OpenAIResponse>("OpenAI", {
url: "https://api.openai.com/v1/chat/completions",
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${settings.openaiApiKey}`,
},
contentType: "application/json",
body: JSON.stringify({
model: settings.openaiModel || getProviderCapabilities("openai").defaultModel,
temperature: 0,
@ -160,11 +180,7 @@ export class SummaryAdapter {
],
}),
});
if (!response.ok) {
throw new Error(`OpenAI API error: ${response.status} ${await response.text().catch(() => "")}`);
}
const json = await response.json();
return String(json?.choices?.[0]?.message?.content ?? "").trim();
return String(json.choices?.[0]?.message?.content ?? "").trim();
}
private async summarizeWithAnthropic(
@ -174,13 +190,14 @@ export class SummaryAdapter {
expectedLanguage: string
): Promise<string> {
if (!settings.anthropicApiKey.trim()) throw new Error("Anthropic API key not configured.");
const response = await fetch("https://api.anthropic.com/v1/messages", {
const json = await requestProviderJson<AnthropicResponse>("Anthropic", {
url: "https://api.anthropic.com/v1/messages",
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": settings.anthropicApiKey,
"anthropic-version": "2023-06-01",
},
contentType: "application/json",
body: JSON.stringify({
model: settings.anthropicModel || getProviderCapabilities("anthropic").defaultModel,
max_tokens: 2000,
@ -189,10 +206,17 @@ export class SummaryAdapter {
messages: [{ role: "user", content: `${prompt}\n\nTranscript:\n${transcript}` }],
}),
});
if (!response.ok) {
throw new Error(`Anthropic API error: ${response.status} ${await response.text().catch(() => "")}`);
}
const json = await response.json();
return String(json?.content?.map((part: { text?: string }) => part?.text ?? "").join("\n") ?? "").trim();
return String(json.content?.map((part) => part.text ?? "").join("\n") ?? "").trim();
}
}
async function requestProviderJson<T>(providerLabel: string, request: RequestUrlParam): Promise<T> {
const response = await requestUrl({
...request,
throw: false,
});
if (response.status >= 400) {
throw new Error(`${providerLabel} API error: ${response.status} ${response.text}`);
}
return response.json as T;
}

View file

@ -1,6 +1,21 @@
import type { TranscriptionSettings } from "../../domain/settings";
import { requireNodeModule } from "../node";
interface ProcessOutputStream {
on(event: "data", listener: (chunk: Buffer) => void): void;
}
interface ChildProcessHandle {
stdout?: ProcessOutputStream;
stderr?: ProcessOutputStream;
on(event: "error", listener: (error: Error) => void): void;
on(event: "close", listener: (code: number | null) => void): void;
}
interface ChildProcessModule {
spawn(command: string, args: string[], options: { cwd: string }): ChildProcessHandle;
}
export class WhisperTranscriptionAdapter {
constructor(private readonly settings: TranscriptionSettings) {}
@ -33,11 +48,15 @@ export class WhisperTranscriptionAdapter {
if (preparedAudioPath !== audioPath) {
try {
fs.unlinkSync(preparedAudioPath);
} catch {}
} catch {
// Temporary audio cleanup is best effort.
}
}
try {
fs.unlinkSync(outputTextPath);
} catch {}
} catch {
// whisper.cpp may not have created an output file.
}
}
}
@ -56,7 +75,9 @@ export class WhisperTranscriptionAdapter {
if (fs.existsSync(outputTextPath)) {
try {
fs.unlinkSync(outputTextPath);
} catch {}
} catch {
// Remove stale output when possible before invoking whisper.cpp.
}
}
const args = this.buildWhisperArgs(inputAudioPath, outputPrefix, relaxed);
@ -127,7 +148,7 @@ export class WhisperTranscriptionAdapter {
cwd: string,
label: string
): Promise<{ stdout: string; stderr: string }> {
const { spawn } = requireNodeModule<{ spawn: Function }>("child_process");
const { spawn } = requireNodeModule<ChildProcessModule>("child_process");
let stderr = "";
let stdout = "";
@ -140,7 +161,7 @@ export class WhisperTranscriptionAdapter {
stderr += chunk.toString();
});
child.on("error", (error: Error) => reject(error));
child.on("close", (code: number) => {
child.on("close", (code: number | null) => {
if (code === 0) {
resolve();
} else {

View file

@ -72,7 +72,7 @@ export class VaultAdapter {
if (!path) return;
const file = this.app.vault.getAbstractFileByPath(path);
if (!(file instanceof TFile)) return;
await this.app.vault.delete(file, true);
await this.app.fileManager.trashFile(file);
}
private async ensureFolderExists(folderPath: string): Promise<void> {
@ -85,7 +85,9 @@ export class VaultAdapter {
if (this.app.vault.getAbstractFileByPath(current)) continue;
try {
await this.app.vault.createFolder(current);
} catch {}
} catch {
// Another plugin or sync process may have created the folder first.
}
}
}

View file

@ -32,12 +32,42 @@ interface WebAudioContextConstructor {
new (): AudioContext;
}
const AUDIO_WORKLET_PROCESSOR_NAME = "resonance-mix-processor";
const AUDIO_WORKLET_SOURCE = `
class ResonanceMixProcessor extends AudioWorkletProcessor {
process(inputs, outputs) {
const channels = inputs[0] || [];
const length = channels[0]?.length || 0;
if (length > 0) {
const mono = new Float32Array(length);
for (let sampleIndex = 0; sampleIndex < length; sampleIndex += 1) {
let sum = 0;
for (let channelIndex = 0; channelIndex < channels.length; channelIndex += 1) {
sum += channels[channelIndex]?.[sampleIndex] || 0;
}
mono[sampleIndex] = channels.length > 0 ? sum / channels.length : 0;
}
this.port.postMessage(mono, [mono.buffer]);
}
const output = outputs[0] || [];
for (const channel of output) {
channel.fill(0);
}
return true;
}
}
registerProcessor("${AUDIO_WORKLET_PROCESSOR_NAME}", ResonanceMixProcessor);
`;
export class WebCaptureAdapter {
private context: AudioContext | null = null;
private streams: MediaStream[] = [];
private sourceNodes: MediaStreamAudioSourceNode[] = [];
private gainNodes: GainNode[] = [];
private processor: ScriptProcessorNode | null = null;
private workletNode: AudioWorkletNode | null = null;
private workletModuleUrl: string | null = null;
private sinkNode: GainNode | null = null;
private mixBus: GainNode | null = null;
private segmentBuffers: Float32Array[] = [];
@ -56,7 +86,8 @@ export class WebCaptureAdapter {
}
const AudioContextCtor = getAudioContextConstructor();
if (!AudioContextCtor || !globalThis.navigator?.mediaDevices?.getUserMedia) {
const browserNavigator = getBrowserNavigator();
if (!AudioContextCtor || !browserNavigator?.mediaDevices?.getUserMedia) {
throw new Error("Web Audio capture is not available in this runtime.");
}
@ -113,23 +144,28 @@ export class WebCaptureAdapter {
try {
this.streams = [];
this.streams.push(await globalThis.navigator.mediaDevices.getUserMedia(buildAudioConstraints(requestedDeviceId)));
this.streams.push(await browserNavigator.mediaDevices.getUserMedia(buildAudioConstraints(requestedDeviceId)));
for (const source of resolvedAdditionalSources) {
this.streams.push(await globalThis.navigator.mediaDevices.getUserMedia(buildAudioConstraints(source.resolved.deviceId)));
this.streams.push(await browserNavigator.mediaDevices.getUserMedia(buildAudioConstraints(source.resolved.deviceId)));
}
this.context = new AudioContextCtor();
await this.installAudioWorklet(this.context);
this.sampleRate = this.context.sampleRate || 48_000;
this.segmentTargetSamples = Math.max(1, Math.floor(this.sampleRate * Math.max(1, options.segmentDurationSeconds)));
this.fullRecordingWriter = new StreamingWavWriter(options.fullAudioPath, this.sampleRate, 1);
this.processor = this.context.createScriptProcessor(4096, 2, 1);
this.workletNode = new AudioWorkletNode(this.context, AUDIO_WORKLET_PROCESSOR_NAME, {
numberOfInputs: 1,
numberOfOutputs: 1,
outputChannelCount: [1],
});
this.mixBus = this.context.createGain();
this.mixBus.gain.value = 1;
this.sinkNode = this.context.createGain();
this.sinkNode.gain.value = 0;
this.processor.onaudioprocess = (event) => {
this.workletNode.port.onmessage = (event: MessageEvent<Float32Array>) => {
try {
this.handleAudioProcess(event);
this.handleAudioChunk(event.data);
} catch (error) {
const message = String((error as Error)?.message ?? error);
this.options?.onError?.(message);
@ -161,8 +197,8 @@ export class WebCaptureAdapter {
sourceLabels.push(config.label);
}
this.mixBus.connect(this.processor);
this.processor.connect(this.sinkNode);
this.mixBus.connect(this.workletNode);
this.workletNode.connect(this.sinkNode);
this.sinkNode.connect(this.context.destination);
this.running = true;
@ -182,16 +218,17 @@ export class WebCaptureAdapter {
async stop(): Promise<void> {
if (!this.running) return;
this.running = false;
this.processor && (this.processor.onaudioprocess = null);
await this.flushCurrentSegment();
if (this.workletNode) {
this.workletNode.port.onmessage = null;
}
this.flushCurrentSegmentSync();
this.fullRecordingWriter?.finalize();
await this.dispose();
}
private handleAudioProcess(event: AudioProcessingEvent): void {
private handleAudioChunk(monoSamples: Float32Array): void {
if (!this.running || !this.options) return;
const monoSamples = extractMonoSamples(event.inputBuffer);
if (monoSamples.length === 0) return;
this.fullRecordingWriter?.append(monoSamples);
@ -210,10 +247,6 @@ export class WebCaptureAdapter {
}
}
private async flushCurrentSegment(): Promise<void> {
this.flushCurrentSegmentSync();
}
private flushCurrentSegmentSync(): void {
if (!this.options || this.segmentSampleCount === 0) return;
@ -226,36 +259,64 @@ export class WebCaptureAdapter {
this.segmentSampleCount = 0;
}
private async installAudioWorklet(context: AudioContext): Promise<void> {
if (!context.audioWorklet?.addModule) {
throw new Error("AudioWorklet is not available in this runtime.");
}
const blob = new Blob([AUDIO_WORKLET_SOURCE], { type: "application/javascript" });
this.workletModuleUrl = window.URL.createObjectURL(blob);
await context.audioWorklet.addModule(this.workletModuleUrl);
}
private async dispose(): Promise<void> {
try {
this.sourceNodes.forEach((node) => node.disconnect());
} catch {}
} catch {
// Source nodes may already be disconnected during browser teardown.
}
try {
this.gainNodes.forEach((node) => node.disconnect());
} catch {}
} catch {
// Gain nodes may already be disconnected during browser teardown.
}
try {
this.mixBus?.disconnect();
} catch {}
} catch {
// The mix bus may already be disconnected after a failed start.
}
try {
this.processor?.disconnect();
} catch {}
this.workletNode?.disconnect();
this.workletNode?.port.close();
} catch {
// Worklet teardown can race with AudioContext closure.
}
try {
this.sinkNode?.disconnect();
} catch {}
} catch {
// The silent sink may already be disconnected after a failed start.
}
try {
this.streams.forEach((stream) => stream.getTracks().forEach((track) => track.stop()));
} catch {}
} catch {
// Tracks may already be stopped by the OS or browser.
}
try {
if (this.context && this.context.state !== "closed") {
await this.context.close();
}
} catch {}
} catch {
// AudioContext closure is best effort during plugin shutdown.
}
if (this.workletModuleUrl) {
window.URL.revokeObjectURL(this.workletModuleUrl);
}
this.context = null;
this.streams = [];
this.sourceNodes = [];
this.gainNodes = [];
this.processor = null;
this.workletNode = null;
this.workletModuleUrl = null;
this.sinkNode = null;
this.mixBus = null;
this.fullRecordingWriter = null;
@ -265,28 +326,13 @@ export class WebCaptureAdapter {
}
function getAudioContextConstructor(): WebAudioContextConstructor | null {
const candidate = globalThis.AudioContext ?? ((globalThis as unknown as { webkitAudioContext?: WebAudioContextConstructor }).webkitAudioContext ?? null);
if (typeof window === "undefined") return null;
const candidate = window.AudioContext ?? ((window as unknown as { webkitAudioContext?: WebAudioContextConstructor }).webkitAudioContext ?? null);
return candidate ?? null;
}
function extractMonoSamples(buffer: AudioBuffer): Float32Array {
const channels = Math.max(1, buffer.numberOfChannels);
const output = new Float32Array(buffer.length);
if (channels === 1) {
output.set(buffer.getChannelData(0));
return output;
}
for (let sampleIndex = 0; sampleIndex < buffer.length; sampleIndex += 1) {
let sum = 0;
for (let channelIndex = 0; channelIndex < channels; channelIndex += 1) {
sum += buffer.getChannelData(channelIndex)[sampleIndex] ?? 0;
}
output[sampleIndex] = sum / channels;
}
return output;
function getBrowserNavigator(): Navigator | undefined {
return typeof window === "undefined" ? undefined : window.navigator;
}
function concatFloat32Arrays(chunks: Float32Array[], totalLength: number): Float32Array {

View file

@ -18,5 +18,9 @@ export function getVaultBasePath(app: App): string {
}
export function getVaultConfigDir(app: App): string {
return ((app.vault as unknown as { configDir?: string }).configDir ?? ".obsidian").trim() || ".obsidian";
const configDir = app.vault.configDir.trim();
if (!configDir) {
throw new Error("Unable to determine vault configuration directory.");
}
return configDir;
}

View file

@ -37,7 +37,7 @@ export function setElementVisibility(element: HTMLElement, visible: boolean): vo
toggleable.show();
return;
}
element.style.removeProperty("display");
element.removeClass("rxn-hidden");
return;
}
@ -45,5 +45,5 @@ export function setElementVisibility(element: HTMLElement, visible: boolean): vo
toggleable.hide();
return;
}
element.style.setProperty("display", "none");
element.addClass("rxn-hidden");
}

View file

@ -176,13 +176,15 @@ export class SessionStore {
try {
if (!fs.statSync(dir).isDirectory()) continue;
const manifestPath = path.join(dir, "session.json");
const raw = JSON.parse(String(fs.readFileSync(manifestPath, { encoding: "utf8" })));
const raw = JSON.parse(String(fs.readFileSync(manifestPath, { encoding: "utf8" }))) as unknown;
if (!isSupportedSessionManifest(raw)) continue;
const manifest = this.normalizeManifest(raw);
if (manifest.status !== "idle" && manifest.status !== "preflight") {
manifests.push(manifest);
}
} catch {}
} catch {
// Ignore incomplete or corrupt session folders in the library view.
}
}
return manifests.sort((left, right) => right.createdAt.localeCompare(left.createdAt));
@ -194,7 +196,7 @@ export class SessionStore {
try {
const manifestPath = path.join(rootDir, "session.json");
if (!fs.existsSync(manifestPath)) return null;
const raw = JSON.parse(String(fs.readFileSync(manifestPath, { encoding: "utf8" })));
const raw = JSON.parse(String(fs.readFileSync(manifestPath, { encoding: "utf8" }))) as unknown;
if (!isSupportedSessionManifest(raw)) return null;
return this.normalizeManifest(raw);
} catch {

View file

@ -1,4 +1,4 @@
import type { App } from "obsidian";
import { requestUrl, type App } from "obsidian";
import { getProviderCapabilities } from "../../domain/providers";
import {
getSelectedProviderApiKey,
@ -57,7 +57,7 @@ export class DiagnosticsService {
const capability = getWebAudioCapability();
const permissionState = await getMicrophonePermissionState();
const deviceSnapshot = await listWebAudioInputDevices().catch(async () => ({
const deviceSnapshot = await listWebAudioInputDevices().catch(() => ({
devices: [],
permissionState,
labelsAvailable: false,
@ -261,26 +261,47 @@ export class DiagnosticsService {
if (adapter.isRunning()) {
await adapter.stop();
}
} catch {}
} catch {
// Best effort cleanup after a failed smoke test.
}
try {
fs.rmSync(tmpRoot, { recursive: true, force: true });
} catch {}
} catch {
// The temp directory is disposable; leave it behind if the OS keeps a handle open.
}
}
}
private async checkOllamaHealth(endpoint: string): Promise<SmokeTestResult> {
const base = endpoint.trim() || "http://localhost:11434";
let timeoutId: number | null = null;
try {
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), 3_000);
const response = await fetch(`${base}/api/tags`, { signal: controller.signal });
window.clearTimeout(timeoutId);
if (!response.ok) {
return { ok: false, detail: `Ollama endpoint returned ${response.status}.` };
const timeout = new Promise<SmokeTestResult>((resolve) => {
timeoutId = window.setTimeout(() => {
resolve({ ok: false, detail: "Ollama check timed out after 3 seconds." });
}, 3_000);
});
const request = requestUrl({
url: `${base}/api/tags`,
method: "GET",
throw: false,
}).then((response): SmokeTestResult => {
if (response.status >= 400) {
return { ok: false, detail: `Ollama endpoint returned ${response.status}.` };
}
return { ok: true, detail: `Ollama reachable at ${base}.` };
});
const result = await Promise.race([request, timeout]);
if (timeoutId !== null) {
window.clearTimeout(timeoutId);
}
return { ok: true, detail: `Ollama reachable at ${base}.` };
return result;
} catch (error) {
return { ok: false, detail: `Ollama check failed: ${String((error as Error)?.message ?? error)}` };
} finally {
if (timeoutId !== null) {
window.clearTimeout(timeoutId);
}
}
}
}

View file

@ -238,7 +238,9 @@ function walkCollect(root: string, depth: number, match: (path: string) => boole
if (stat.isDirectory()) {
results.push(...walkCollect(fullPath, depth - 1, match));
}
} catch {}
} catch {
// Skip unreadable directories while probing likely local installs.
}
}
return results;

View file

@ -20,7 +20,7 @@ export interface WebAudioCapability {
}
export function getWebAudioCapability(): WebAudioCapability {
const mediaDevices = globalThis.navigator?.mediaDevices;
const mediaDevices = getBrowserNavigator()?.mediaDevices;
return {
supported: Boolean(mediaDevices),
hasGetUserMedia: typeof mediaDevices?.getUserMedia === "function",
@ -29,12 +29,13 @@ export function getWebAudioCapability(): WebAudioCapability {
}
export async function getMicrophonePermissionState(): Promise<WebAudioPermissionState> {
if (!globalThis.navigator) return "unsupported";
const browserNavigator = getBrowserNavigator();
if (!browserNavigator) return "unsupported";
try {
const permissions = globalThis.navigator.permissions;
const permissions = browserNavigator.permissions;
if (!permissions?.query) return "unknown";
const status = await permissions.query({ name: "microphone" as PermissionName });
const status = await permissions.query({ name: "microphone" });
return status.state;
} catch {
return "unknown";
@ -43,7 +44,8 @@ export async function getMicrophonePermissionState(): Promise<WebAudioPermission
export async function listWebAudioInputDevices(): Promise<WebAudioDeviceSnapshot> {
const capability = getWebAudioCapability();
if (!capability.hasEnumerateDevices || !globalThis.navigator?.mediaDevices) {
const browserNavigator = getBrowserNavigator();
if (!capability.hasEnumerateDevices || !browserNavigator?.mediaDevices) {
return {
devices: [],
permissionState: capability.supported ? "unknown" : "unsupported",
@ -52,7 +54,7 @@ export async function listWebAudioInputDevices(): Promise<WebAudioDeviceSnapshot
}
const permissionState = await getMicrophonePermissionState();
const devices = await globalThis.navigator.mediaDevices.enumerateDevices();
const devices = await browserNavigator.mediaDevices.enumerateDevices();
const inputs = devices
.filter((device) => device.kind === "audioinput")
.map((device) => ({
@ -87,3 +89,7 @@ export async function resolveWebAudioInputById(selectedDeviceId?: string): Promi
const snapshot = await listWebAudioInputDevices();
return snapshot.devices.find((device) => device.deviceId === selectedDeviceId.trim());
}
function getBrowserNavigator(): Navigator | undefined {
return typeof window === "undefined" ? undefined : window.navigator;
}

View file

@ -57,35 +57,35 @@ export default class ResonanceNextPlugin extends Plugin {
setElementVisibility(this.statusBarEl, false);
this.addCommand({
id: "resonance-next-open-control-room",
id: "open-control-room",
name: uiCopy.actions.openRecorder,
callback: () => {
this.openRecorder();
},
});
this.addCommand({
id: "resonance-next-open-diagnostics",
id: "open-diagnostics",
name: uiCopy.actions.openDiagnostics,
callback: () => {
this.openDiagnostics();
},
});
this.addCommand({
id: "resonance-next-quick-toggle-session",
id: "quick-toggle-session",
name: "Start the last scenario or stop the active session",
callback: async () => {
await this.quickToggleSession();
callback: () => {
void this.quickToggleSession();
},
});
this.addCommand({
id: "resonance-next-open-library",
id: "open-library",
name: uiCopy.actions.openLibrary,
callback: () => {
this.openLibrary();
},
});
this.addCommand({
id: "resonance-next-open-setup",
id: "open-setup",
name: uiCopy.actions.openSetupGuide,
callback: () => {
this.openSetupGuide();
@ -112,7 +112,7 @@ export default class ResonanceNextPlugin extends Plugin {
});
}
async onunload() {
onunload(): void {
this.recordingModal?.close();
this.recordingModal = null;
}

View file

@ -1,4 +1,4 @@
import { App, Notice, PluginSettingTab, Setting } from "obsidian";
import { App, Notice, Platform, PluginSettingTab, Setting } from "obsidian";
import { buildDashboardSnapshot, deriveSessionListItem, summarizeSessionListItems } from "../application/dashboard";
import type { SessionController } from "../application/SessionController";
import type { DashboardSnapshot } from "../domain/dashboard";
@ -21,6 +21,7 @@ import {
} from "../infrastructure/system/webAudio";
import { formatBytes, formatDuration } from "../utils/format";
import { uiCopy } from "./copy";
import { ConfirmationModal } from "./modals/ConfirmationModal";
import { TextPreviewModal } from "./modals/TextPreviewModal";
export type SettingsSurfaceTab = "control-room" | "library" | "capture" | "transcription" | "summary" | "output";
@ -131,7 +132,11 @@ export class ResonanceNextSettingTab extends PluginSettingTab {
this.vaultAdapter = new VaultAdapter(app);
}
async display() {
display(): void {
void this.renderDisplay();
}
private async renderDisplay(): Promise<void> {
this.activeTab = preferredSettingsTab;
if (this.activeTab === "control-room") {
await this.refreshControlRoomData(true);
@ -146,7 +151,7 @@ export class ResonanceNextSettingTab extends PluginSettingTab {
this.renderHero(containerEl);
this.renderTabs(containerEl);
const body = containerEl.createEl("div", { cls: "rxn-settings-surface" });
const body = containerEl.createDiv({ cls: "rxn-settings-surface" });
if (this.activeTab === "control-room") {
await this.renderControlRoomTab(body);
return;
@ -176,28 +181,20 @@ export class ResonanceNextSettingTab extends PluginSettingTab {
}
private renderHero(container: HTMLElement) {
const hero = container.createEl("div", { cls: "rxn-card rxn-hero" });
const hero = container.createDiv({ cls: "rxn-card rxn-hero" });
const headerRow = hero.createEl("div");
headerRow.style.display = "flex";
headerRow.style.justifyContent = "space-between";
headerRow.style.alignItems = "center";
const headerRow = hero.createDiv({ cls: "rxn-hero-header-row" });
headerRow.createEl("h2", { text: uiCopy.appName, cls: "rxn-hero-brand" });
headerRow.createDiv({ text: uiCopy.appName, cls: "rxn-hero-brand" });
const sponsor = headerRow.createEl("a", {
href: "https://buymeacoffee.com/michaelgorini",
cls: "rxn-sponsor-link",
});
sponsor.target = "_blank";
sponsor.style.display = "flex";
sponsor.style.alignItems = "center";
sponsor.style.gap = "6px";
sponsor.style.fontSize = "13px";
sponsor.style.color = "var(--text-muted)";
sponsor.style.textDecoration = "none";
sponsor.createEl("span", { text: "Support Resonance" });
sponsor.createEl("span", { text: "🤍" });
sponsor.createSpan({ text: "Support Resonance" });
sponsor.createSpan({ text: "🤍" });
hero.createEl("p", {
text: uiCopy.settings.title,
@ -206,7 +203,7 @@ export class ResonanceNextSettingTab extends PluginSettingTab {
}
private renderTabs(container: HTMLElement) {
const groups = container.createEl("div", { cls: "rxn-settings-tab-groups" });
const groups = container.createDiv({ cls: "rxn-settings-tab-groups" });
this.renderTabGroup(groups, "Workspace", WORKSPACE_TABS);
this.renderTabGroup(groups, "Setup", SETUP_TABS, true);
}
@ -217,7 +214,7 @@ export class ResonanceNextSettingTab extends PluginSettingTab {
if (tab === "library") {
await this.refreshLibraryData();
}
await this.display();
await this.renderDisplay();
}
private renderTabGroup(
@ -226,11 +223,11 @@ export class ResonanceNextSettingTab extends PluginSettingTab {
tabs: SettingsTabDefinition[],
numbered = false
) {
const group = container.createEl("div", {
const group = container.createDiv({
cls: numbered ? "rxn-settings-tab-group is-setup" : "rxn-settings-tab-group is-workspace",
});
group.createEl("span", { text: title, cls: "rxn-settings-tab-group-label" });
const row = group.createEl("div", { cls: "rxn-settings-tabs" });
group.createSpan({ text: title, cls: "rxn-settings-tab-group-label" });
const row = group.createDiv({ cls: "rxn-settings-tabs" });
tabs.forEach((tab, index) => {
const button = row.createEl("button", { cls: "rxn-settings-tab" });
if (tab.key === this.activeTab) {
@ -281,25 +278,25 @@ export class ResonanceNextSettingTab extends PluginSettingTab {
const actions = intro.createDiv({ cls: "rxn-action-bar" });
this.createActionButton(actions, uiCopy.actions.refreshHealth, async () => {
await this.refreshControlRoomData(true);
await this.display();
await this.renderDisplay();
}, "rxn-btn-secondary");
this.createActionButton(actions, this.isQuickTestRunning ? "Running quick test..." : uiCopy.actions.runQuickTest, async () => {
await this.runQuickTest();
}, "rxn-btn-secondary", this.isQuickTestRunning);
const meta = intro.createDiv({ cls: "rxn-pill-row rxn-diagnostics-meta" });
meta.createEl("span", {
meta.createSpan({
text: snapshot.health.badge === "failed" ? "Blocked" : snapshot.health.badge === "warning" ? "Needs review" : "Ready",
cls: `rxn-status-pill is-${snapshot.health.badge}`,
});
meta.createEl("span", { text: `${snapshot.health.blockingCount} blocking`, cls: "rxn-pill" });
meta.createEl("span", { text: `${snapshot.health.warningCount} warnings`, cls: "rxn-pill" });
meta.createEl("span", {
meta.createSpan({ text: `${snapshot.health.blockingCount} blocking`, cls: "rxn-pill" });
meta.createSpan({ text: `${snapshot.health.warningCount} warnings`, cls: "rxn-pill" });
meta.createSpan({
text: `Quick test: ${this.isQuickTestRunning ? "running" : this.smokeMessage ? (this.smokeMessage.includes("failed") ? "failed" : "passed") : "not run"
}`,
cls: "rxn-pill",
});
meta.createEl("span", { text: `Capture: ${snapshot.health.report?.capture ?? "web-audio"}`, cls: "rxn-pill" });
meta.createSpan({ text: `Capture: ${snapshot.health.report?.capture ?? "web-audio"}`, cls: "rxn-pill" });
if (this.smokeMessage) {
const smoke = intro.createDiv({ cls: `rxn-inline-note ${this.smokeMessage.includes("failed") ? "is-failed" : "is-healthy"}` });
@ -343,7 +340,7 @@ export class ResonanceNextSettingTab extends PluginSettingTab {
new Setting(preferences)
.setName("Open setup on startup")
.setDesc("Open Capture when Obsidian starts.")
.setDesc("Open capture when Obsidian starts.")
.addToggle((toggle) =>
toggle.setValue(settings.ui.showSetupWizardOnStartup).onChange(async (value) => {
await this.options.saveSettings((current) => ({
@ -355,7 +352,7 @@ export class ResonanceNextSettingTab extends PluginSettingTab {
new Setting(preferences)
.setName("Open diagnostics on startup")
.setDesc("Open Diagnostics when Obsidian starts.")
.setDesc("Open diagnostics when Obsidian starts.")
.addToggle((toggle) =>
toggle.setValue(settings.ui.showDiagnosticsOnStartup).onChange(async (value) => {
await this.options.saveSettings((current) => ({
@ -367,7 +364,7 @@ export class ResonanceNextSettingTab extends PluginSettingTab {
preferences.createEl("p", {
cls: "rxn-muted",
text: "Session logs remain available in Library under Preview diagnostics.",
text: "Session logs remain available in library under preview diagnostics.",
});
}
@ -389,16 +386,16 @@ export class ResonanceNextSettingTab extends PluginSettingTab {
});
const meta = capture.createDiv({ cls: "rxn-pill-row rxn-diagnostics-meta" });
meta.createEl("span", {
meta.createSpan({
text: `Permission: ${this.getWebPermissionLabel(deviceSnapshot.permissionState)}`,
cls: `rxn-status-pill is-${this.getWebPermissionTone(deviceSnapshot.permissionState)}`,
});
meta.createEl("span", {
meta.createSpan({
text: deviceSnapshot.devices.length > 0 ? `${deviceSnapshot.devices.length} audio inputs` : "No audio inputs found",
cls: "rxn-pill",
});
if (!deviceSnapshot.labelsAvailable) {
meta.createEl("span", {
meta.createSpan({
text: "Labels improve after microphone access is granted",
cls: "rxn-pill",
});
@ -416,23 +413,11 @@ export class ResonanceNextSettingTab extends PluginSettingTab {
this.populateWebAudioInputs(micSelect, deviceSnapshot, settings.capture.microphone.deviceId, {
defaultLabel: "System default input",
});
micSelect.addEventListener("change", async () => {
const selected = micSelect.options[micSelect.selectedIndex];
await this.options.saveSettings((current) => ({
...current,
capture: {
...current.capture,
microphone: {
deviceId: selected?.value === "default" ? "" : selected?.value ?? "",
label: selected?.text ?? "",
},
additionalSources: current.capture.additionalSources.filter((source) => source.deviceId !== (selected?.value === "default" ? "" : selected?.value ?? "")),
},
}));
await this.display();
micSelect.addEventListener("change", () => {
void this.updateSelectedMicrophone(micSelect);
});
capture.createEl("h4", { text: "Additional sources", cls: "rxn-section-title" });
new Setting(capture).setName("Additional sources").setHeading();
capture.createEl("p", {
cls: "rxn-muted",
text:
@ -486,7 +471,7 @@ export class ResonanceNextSettingTab extends PluginSettingTab {
},
};
});
await this.display();
await this.renderDisplay();
})
);
});
@ -506,10 +491,27 @@ export class ResonanceNextSettingTab extends PluginSettingTab {
const captureActions = capture.createDiv({ cls: "rxn-action-bar" });
this.createActionButton(captureActions, uiCopy.actions.refreshDevices, async () => {
await this.display();
await this.renderDisplay();
}, "rxn-btn-secondary");
}
private async updateSelectedMicrophone(select: HTMLSelectElement): Promise<void> {
const selected = select.options[select.selectedIndex];
const selectedDeviceId = selected?.value === "default" ? "" : selected?.value ?? "";
await this.options.saveSettings((current) => ({
...current,
capture: {
...current.capture,
microphone: {
deviceId: selectedDeviceId,
label: selected?.text ?? "",
},
additionalSources: current.capture.additionalSources.filter((source) => source.deviceId !== selectedDeviceId),
},
}));
await this.renderDisplay();
}
private async renderTranscriptionTab(container: HTMLElement) {
const settings = this.options.getSettings();
const transcription = this.createGuideSection(container, {
@ -559,7 +561,7 @@ export class ResonanceNextSettingTab extends PluginSettingTab {
transcription: { ...current.transcription, whisperRepoPath: detected },
}));
new Notice(uiCopy.notices.whisperRepoDetected);
await this.display();
await this.renderDisplay();
})
);
@ -590,25 +592,25 @@ export class ResonanceNextSettingTab extends PluginSettingTab {
},
}));
new Notice(uiCopy.notices.whisperDetected);
await this.display();
await this.renderDisplay();
})
);
const selectedModelPreset = settings.transcription.modelPreset;
const selectedModelLabel = this.getWhisperModelLabel(selectedModelPreset);
const selectedModelFilename = this.getWhisperModelFilename(selectedModelPreset);
const model = container.createEl("div", { cls: "rxn-card rxn-step-section" });
const modelHeader = model.createEl("div", { cls: "rxn-step-section-header" });
modelHeader.createEl("span", { text: "Model", cls: "rxn-step-section-badge" });
modelHeader.createEl("h3", { text: "Whisper model" });
const model = container.createDiv({ cls: "rxn-card rxn-step-section" });
const modelHeader = model.createDiv({ cls: "rxn-step-section-header" });
modelHeader.createSpan({ text: "Model", cls: "rxn-step-section-badge" });
new Setting(modelHeader).setName("Whisper model").setHeading();
model.createEl("p", {
text: "Choose a model size first. Then download that model once and point Model path to the file.",
text: "Choose a model size first. Then download that model once and point model path to the file.",
cls: "rxn-muted",
});
new Setting(model)
.setName("Model size")
.setDesc("This updates the download commands below and what Detect model looks for first.")
.setDesc("This updates the download commands below and what detect model looks for first.")
.addDropdown((dropdown) => {
dropdown.addOption("base", "Base");
dropdown.addOption("small", "Small");
@ -623,7 +625,7 @@ export class ResonanceNextSettingTab extends PluginSettingTab {
modelPreset: value as PluginSettings["transcription"]["modelPreset"],
},
}));
await this.display();
await this.renderDisplay();
});
});
@ -675,7 +677,7 @@ export class ResonanceNextSettingTab extends PluginSettingTab {
text: `${selectedModelFilename} is the first file Detect model will look for.`,
});
modelBullets.createEl("li", {
text: "For higher reliability, try Medium or Large if your machine can handle them.",
text: "For higher reliability, try medium or large if your machine can handle them.",
});
new Setting(model)
@ -709,13 +711,13 @@ export class ResonanceNextSettingTab extends PluginSettingTab {
transcription: { ...currentSettings.transcription, modelPath: detected },
}));
new Notice(uiCopy.notices.modelDetected);
await this.display();
await this.renderDisplay();
})
);
new Setting(transcription)
.setName("Language / beam")
.setDesc("Leave Automatic unless you always record one language.")
.setDesc("Leave automatic unless you always record one language.")
.addDropdown((dropdown) => {
dropdown.addOption("auto", "Automatic");
dropdown.addOption("it", "Italian");
@ -804,7 +806,7 @@ export class ResonanceNextSettingTab extends PluginSettingTab {
...current,
summary: { ...current.summary, provider: value as PluginSettings["summary"]["provider"] },
}));
await this.display();
await this.renderDisplay();
});
});
@ -844,7 +846,7 @@ export class ResonanceNextSettingTab extends PluginSettingTab {
text.setValue(settings.summary[apiKeyField]).onChange(async (value) => {
await this.options.saveSettings((current) => ({
...current,
summary: { ...current.summary, [apiKeyField]: value } as PluginSettings["summary"],
summary: { ...current.summary, [apiKeyField]: value },
}));
});
text.inputEl.type = "password";
@ -853,7 +855,7 @@ export class ResonanceNextSettingTab extends PluginSettingTab {
text.setValue(settings.summary[modelField]).onChange(async (value) => {
await this.options.saveSettings((current) => ({
...current,
summary: { ...current.summary, [modelField]: value } as PluginSettings["summary"],
summary: { ...current.summary, [modelField]: value },
}));
})
);
@ -937,12 +939,12 @@ export class ResonanceNextSettingTab extends PluginSettingTab {
search.value = this.libraryQuery;
search.addEventListener("input", () => {
this.libraryQuery = search.value.trim().toLowerCase();
void this.display();
void this.renderDisplay();
});
this.createActionButton(controls, uiCopy.actions.refresh, async () => {
await this.refreshLibraryData();
await this.display();
await this.renderDisplay();
}, "rxn-btn-secondary");
this.createActionButton(controls, uiCopy.actions.openLibraryFolder, async () => {
this.openLibraryFolder();
@ -956,7 +958,7 @@ export class ResonanceNextSettingTab extends PluginSettingTab {
this.createActionButton(controls, uiCopy.actions.selectAllVisible, async () => {
this.librarySelectedIds = new Set(items.map((item) => item.sessionId));
await this.display();
await this.renderDisplay();
}, "rxn-btn-secondary", items.length === 0 || selectedItems.length === items.length);
const statsRow = library.createDiv({ cls: "rxn-library-stats" });
@ -1001,7 +1003,7 @@ export class ResonanceNextSettingTab extends PluginSettingTab {
);
this.createActionButton(bulkActions, uiCopy.actions.clearSelection, async () => {
this.librarySelectedIds.clear();
await this.display();
await this.renderDisplay();
}, "rxn-btn-secondary", bulkBusy);
}
if (items.length === 0) {
@ -1027,21 +1029,21 @@ export class ResonanceNextSettingTab extends PluginSettingTab {
});
selectToggle.checked = isSelected;
selectToggle.disabled = isBusy;
selectToggle.addEventListener("change", async () => {
selectToggle.addEventListener("change", () => {
if (selectToggle.checked) {
this.librarySelectedIds.add(item.sessionId);
} else {
this.librarySelectedIds.delete(item.sessionId);
}
await this.display();
void this.renderDisplay();
});
const titleBlock = header.createDiv({ cls: "rxn-session-title" });
titleBlock.createEl("strong", { text: item.scenarioLabel });
titleBlock.createEl("span", {
titleBlock.createSpan({
text: item.failureSummary || item.diagnosticsSummary,
cls: "rxn-muted",
});
header.createEl("span", {
header.createSpan({
text: item.status,
cls: `rxn-status-pill is-${item.healthBadge}`,
});
@ -1055,15 +1057,15 @@ export class ResonanceNextSettingTab extends PluginSettingTab {
this.renderSessionMeta(meta, "Last activity", new Date(item.lastActivityAt).toLocaleString());
const artifactRow = card.createDiv({ cls: "rxn-pill-row" });
artifactRow.createEl("span", {
artifactRow.createSpan({
text: item.artifactAvailability.hasAudio ? "Audio ready" : "No audio",
cls: `rxn-pill ${item.artifactAvailability.hasAudio ? "is-ok" : ""}`,
});
artifactRow.createEl("span", {
artifactRow.createSpan({
text: item.artifactAvailability.hasTranscript ? "Transcript ready" : "No transcript",
cls: `rxn-pill ${item.artifactAvailability.hasTranscript ? "is-ok" : ""}`,
});
artifactRow.createEl("span", {
artifactRow.createSpan({
text: item.artifactAvailability.hasSummary ? "Summary ready" : "No summary",
cls: `rxn-pill ${item.artifactAvailability.hasSummary ? "is-ok" : ""}`,
});
@ -1112,7 +1114,7 @@ export class ResonanceNextSettingTab extends PluginSettingTab {
this.openAudioSessionId === item.sessionId ? uiCopy.actions.hideAudio : uiCopy.actions.playAudio,
async () => {
this.openAudioSessionId = this.openAudioSessionId === item.sessionId ? null : item.sessionId;
await this.display();
await this.renderDisplay();
},
"rxn-btn-secondary",
isBusy || !item.artifactAvailability.hasAudio
@ -1132,7 +1134,9 @@ export class ResonanceNextSettingTab extends PluginSettingTab {
try {
const electron = requireNodeModule<{ shell?: { showItemInFolder?: (path: string) => void } }>("electron");
electron.shell?.showItemInFolder?.(item.paths.rootDir);
} catch { }
} catch {
new Notice("Unable to reveal the session folder.");
}
}, "rxn-btn-secondary", isBusy);
const dangerMenu = this.createSessionActionMenu(menus, "Delete", true);
@ -1155,13 +1159,13 @@ export class ResonanceNextSettingTab extends PluginSettingTab {
private renderSessionMeta(container: HTMLElement, label: string, value: string) {
const item = container.createDiv({ cls: "rxn-session-meta-item" });
item.createEl("span", { text: `${label}:`, cls: "rxn-session-meta-label" });
item.createEl("span", { text: value, cls: "rxn-session-meta-value" });
item.createSpan({ text: `${label}:`, cls: "rxn-session-meta-label" });
item.createSpan({ text: value, cls: "rxn-session-meta-value" });
}
private renderLibraryStat(container: HTMLElement, label: string, value: string) {
const item = container.createDiv({ cls: "rxn-library-stat" });
item.createEl("span", { text: label, cls: "rxn-library-stat-label" });
item.createSpan({ text: label, cls: "rxn-library-stat-label" });
item.createEl("strong", { text: value, cls: "rxn-library-stat-value" });
}
@ -1193,8 +1197,10 @@ export class ResonanceNextSettingTab extends PluginSettingTab {
const skippedCount = items.length - manifests.length;
const reclaimableBytes = this.store.getReclaimableBytesForSessions(manifests, "audio");
const ok = confirm(
`Delete saved audio for ${manifests.length} session${manifests.length === 1 ? "" : "s"}? Transcript and summary will be kept.${skippedCount > 0 ? ` ${skippedCount} selected session${skippedCount === 1 ? "" : "s"} will be skipped because they have no audio.` : ""} Estimated space to free: ${formatBytes(reclaimableBytes)}.`
const ok = await this.confirmDestructiveAction(
"Delete saved audio",
`Delete saved audio for ${manifests.length} session${manifests.length === 1 ? "" : "s"}? Transcript and summary will be kept.${skippedCount > 0 ? ` ${skippedCount} selected session${skippedCount === 1 ? "" : "s"} will be skipped because they have no audio.` : ""} Estimated space to free: ${formatBytes(reclaimableBytes)}.`,
uiCopy.actions.deleteAudio
);
if (!ok) return;
@ -1202,7 +1208,7 @@ export class ResonanceNextSettingTab extends PluginSettingTab {
this.openAudioSessionId = manifests.some((manifest) => manifest.sessionId === this.openAudioSessionId)
? null
: this.openAudioSessionId;
await this.display();
await this.renderDisplay();
try {
await this.store.deleteAudioArtifactsMany(manifests);
@ -1215,7 +1221,7 @@ export class ResonanceNextSettingTab extends PluginSettingTab {
await this.refreshLibraryData();
} finally {
this.libraryBulkAction = null;
await this.display();
await this.renderDisplay();
}
}
@ -1225,13 +1231,15 @@ export class ResonanceNextSettingTab extends PluginSettingTab {
const skippedCount = items.length - manifests.length;
const reclaimableBytes = this.store.getReclaimableBytesForSessions(manifests, "transcript");
const ok = confirm(
`Delete saved transcript for ${manifests.length} session${manifests.length === 1 ? "" : "s"}? Summary will be kept if it exists.${skippedCount > 0 ? ` ${skippedCount} selected session${skippedCount === 1 ? "" : "s"} will be skipped because they have no transcript.` : ""} Estimated space to free: ${formatBytes(reclaimableBytes)}.`
const ok = await this.confirmDestructiveAction(
"Delete saved transcript",
`Delete saved transcript for ${manifests.length} session${manifests.length === 1 ? "" : "s"}? Summary will be kept if it exists.${skippedCount > 0 ? ` ${skippedCount} selected session${skippedCount === 1 ? "" : "s"} will be skipped because they have no transcript.` : ""} Estimated space to free: ${formatBytes(reclaimableBytes)}.`,
uiCopy.actions.deleteTranscript
);
if (!ok) return;
this.libraryBulkAction = "transcript";
await this.display();
await this.renderDisplay();
try {
for (const manifest of manifests) {
@ -1248,7 +1256,7 @@ export class ResonanceNextSettingTab extends PluginSettingTab {
await this.refreshLibraryData();
} finally {
this.libraryBulkAction = null;
await this.display();
await this.renderDisplay();
}
}
@ -1257,8 +1265,10 @@ export class ResonanceNextSettingTab extends PluginSettingTab {
if (manifests.length === 0) return;
const reclaimableBytes = this.store.getReclaimableBytesForSessions(manifests, "session");
const ok = confirm(
`Delete ${manifests.length} session${manifests.length === 1 ? "" : "s"} and their linked notes? Estimated space to free: ${formatBytes(reclaimableBytes)}.`
const ok = await this.confirmDestructiveAction(
"Delete sessions",
`Delete ${manifests.length} session${manifests.length === 1 ? "" : "s"} and their linked notes? Estimated space to free: ${formatBytes(reclaimableBytes)}.`,
uiCopy.actions.deleteSession
);
if (!ok) return;
@ -1266,7 +1276,7 @@ export class ResonanceNextSettingTab extends PluginSettingTab {
this.openAudioSessionId = manifests.some((manifest) => manifest.sessionId === this.openAudioSessionId)
? null
: this.openAudioSessionId;
await this.display();
await this.renderDisplay();
try {
for (const manifest of manifests) {
@ -1279,7 +1289,7 @@ export class ResonanceNextSettingTab extends PluginSettingTab {
await this.refreshLibraryData();
} finally {
this.libraryBulkAction = null;
await this.display();
await this.renderDisplay();
}
}
@ -1311,19 +1321,30 @@ export class ResonanceNextSettingTab extends PluginSettingTab {
return list;
}
private async confirmDestructiveAction(title: string, message: string, confirmText: string): Promise<boolean> {
return new ConfirmationModal(this.app, {
title,
message,
confirmText,
danger: true,
}).openAndWait();
}
private openLibraryFolder() {
try {
const electron = requireNodeModule<{ shell?: { showItemInFolder?: (path: string) => void } }>("electron");
const sessionsRoot = this.store.getSessionsRootDir();
electron.shell?.showItemInFolder?.(sessionsRoot);
} catch { }
} catch {
new Notice("Unable to reveal the library folder.");
}
}
private createGuideSection(container: HTMLElement, options: GuideSectionOptions): HTMLElement {
const section = container.createEl("div", { cls: "rxn-card rxn-step-section" });
const header = section.createEl("div", { cls: "rxn-step-section-header" });
header.createEl("span", { text: options.badge, cls: "rxn-step-section-badge" });
header.createEl("h3", { text: options.title });
const section = container.createDiv({ cls: "rxn-card rxn-step-section" });
const header = section.createDiv({ cls: "rxn-step-section-header" });
header.createSpan({ text: options.badge, cls: "rxn-step-section-badge" });
new Setting(header).setName(options.title).setHeading();
section.createEl("p", { text: options.intro, cls: "rxn-muted" });
if (options.platformGuides) {
this.renderPlatformGuide(section, options.platformGuides, Boolean(options.intro.trim()));
@ -1343,7 +1364,7 @@ export class ResonanceNextSettingTab extends PluginSettingTab {
private async runLibraryRecovery(item: SessionListItem, action: "transcript" | "summary") {
this.libraryBusySessionId = item.sessionId;
this.libraryBusyAction = action;
await this.display();
await this.renderDisplay();
try {
if (action === "transcript") {
await this.options.controller.regenerateTranscript(item.paths.rootDir);
@ -1358,7 +1379,7 @@ export class ResonanceNextSettingTab extends PluginSettingTab {
} finally {
this.libraryBusySessionId = null;
this.libraryBusyAction = null;
await this.display();
await this.renderDisplay();
}
}
@ -1427,7 +1448,7 @@ export class ResonanceNextSettingTab extends PluginSettingTab {
private renderCodeBlock(container: HTMLElement, code: string, label?: string) {
const block = container.createDiv({ cls: "rxn-guide-code-block" });
if (label) {
block.createEl("div", { text: label, cls: "rxn-guide-code-label" });
block.createDiv({ text: label, cls: "rxn-guide-code-label" });
}
const shell = block.createDiv({ cls: "rxn-guide-code-shell" });
const copyButton = shell.createEl("button", {
@ -1435,19 +1456,8 @@ export class ResonanceNextSettingTab extends PluginSettingTab {
cls: "rxn-guide-code-copy rxn-btn-secondary",
attr: { type: "button" },
});
copyButton.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(code);
new Notice("Command copied.");
} catch {
const textarea = document.createElement("textarea");
textarea.value = code;
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
textarea.remove();
new Notice("Command copied.");
}
copyButton.addEventListener("click", () => {
void this.copyText(code, "Command copied.");
});
const pre = shell.createEl("pre", { cls: "rxn-guide-code" });
@ -1465,21 +1475,24 @@ export class ResonanceNextSettingTab extends PluginSettingTab {
private getDefaultGuidePlatform(platforms: GuidePlatform[]): GuidePlatform {
let preferred: GuidePlatform = "macos";
try {
const processModule = requireNodeModule<{ platform?: string }>("process");
if (processModule.platform === "win32") {
preferred = "windows";
} else if (processModule.platform === "linux") {
preferred = "linux";
}
} catch {
const agent = typeof navigator === "undefined" ? "" : navigator.userAgent.toLowerCase();
preferred = agent.includes("windows") ? "windows" : agent.includes("linux") ? "linux" : "macos";
if (Platform.isWin) {
preferred = "windows";
} else if (Platform.isLinux) {
preferred = "linux";
}
return platforms.includes(preferred) ? preferred : platforms[0] ?? "macos";
}
private async copyText(text: string, successMessage: string): Promise<void> {
try {
await navigator.clipboard.writeText(text);
new Notice(successMessage);
} catch (error) {
new Notice(`Copy failed: ${String((error as Error)?.message ?? error)}`);
}
}
private getFilteredLibraryItems(): SessionListItem[] {
return this.libraryItems.filter((item) => {
const statusMatches = this.libraryFilter === "all" ? true : item.status === this.libraryFilter;
@ -1500,7 +1513,7 @@ export class ResonanceNextSettingTab extends PluginSettingTab {
}
chip.addEventListener("click", () => {
this.libraryFilter = value;
void this.display();
void this.renderDisplay();
});
}
@ -1524,7 +1537,7 @@ export class ResonanceNextSettingTab extends PluginSettingTab {
this.isQuickTestRunning = true;
this.smokeMessage = "Quick test running...";
await this.display();
await this.renderDisplay();
try {
new Notice("Quick test started. Obsidian may ask for microphone access.");
@ -1538,7 +1551,7 @@ export class ResonanceNextSettingTab extends PluginSettingTab {
await this.refreshControlRoomData(false);
} finally {
this.isQuickTestRunning = false;
await this.display();
await this.renderDisplay();
}
}
@ -1560,7 +1573,7 @@ export class ResonanceNextSettingTab extends PluginSettingTab {
const extension = pathModule.extname(item.paths.fullAudioPath) || ".wav";
const blob = new Blob([bytes], { type: this.getAudioMimeType(item.paths.fullAudioPath) });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
const anchor = activeDocument.createEl("a");
anchor.href = url;
anchor.download = `${item.scenarioLabel}-${item.sessionId}${extension}`;
anchor.click();
@ -1599,7 +1612,7 @@ export class ResonanceNextSettingTab extends PluginSettingTab {
const addOption = (value: string, label: string) => {
if (seen.has(value)) return;
seen.add(value);
const option = document.createElement("option");
const option = activeDocument.createEl("option");
option.value = value;
option.text = label;
select.appendChild(option);

View file

@ -0,0 +1,65 @@
import { App, Modal } from "obsidian";
interface ConfirmationModalOptions {
title: string;
message: string;
confirmText: string;
cancelText?: string;
danger?: boolean;
}
export class ConfirmationModal extends Modal {
private resolved = false;
private resolveResult: ((confirmed: boolean) => void) | null = null;
constructor(app: App, private readonly options: ConfirmationModalOptions) {
super(app);
}
openAndWait(): Promise<boolean> {
return new Promise((resolve) => {
this.resolveResult = resolve;
this.open();
});
}
onOpen(): void {
this.setTitle(this.options.title);
this.contentEl.empty();
this.contentEl.addClass("rxn-modal");
this.contentEl.createEl("p", { text: this.options.message, cls: "rxn-muted" });
const actions = this.contentEl.createDiv({ cls: "rxn-action-bar" });
const cancel = actions.createEl("button", {
text: this.options.cancelText ?? "Cancel",
cls: "rxn-btn-secondary",
attr: { type: "button" },
});
cancel.addEventListener("click", () => {
this.finish(false);
});
const confirm = actions.createEl("button", {
text: this.options.confirmText,
cls: this.options.danger ? "rxn-btn-danger" : "rxn-btn-primary",
attr: { type: "button" },
});
confirm.addEventListener("click", () => {
this.finish(true);
});
}
onClose(): void {
this.contentEl.empty();
this.contentEl.removeClass("rxn-modal");
if (!this.resolved) {
this.resolved = true;
this.resolveResult?.(false);
}
}
private finish(confirmed: boolean): void {
if (this.resolved) return;
this.resolved = true;
this.resolveResult?.(confirmed);
this.close();
}
}

View file

@ -74,10 +74,10 @@ export class RecordingModal extends Modal {
headline.createEl("p", { text: this.getSubheadline(snapshot, coreConfigured), cls: "rxn-muted" });
const runtime = heroHeader.createDiv({ cls: "rxn-recording-runtime" });
runtime.createEl("span", { text: this.getStateLabel(snapshot.state), cls: `rxn-status-pill is-${this.getStateTone(snapshot.state)}` });
runtime.createSpan({ text: this.getStateLabel(snapshot.state), cls: `rxn-status-pill is-${this.getStateTone(snapshot.state)}` });
runtime.createEl("strong", { text: formatDuration(snapshot.elapsedSeconds), cls: "rxn-recording-elapsed" });
if (!canStart) {
runtime.createEl("span", { text: snapshot.scenarioLabel || scenario.label, cls: "rxn-muted" });
runtime.createSpan({ text: snapshot.scenarioLabel || scenario.label, cls: "rxn-muted" });
}
if (snapshot.lastError) {
@ -117,12 +117,12 @@ export class RecordingModal extends Modal {
private renderStartActions(snapshot: SessionRuntimeSnapshot, coreConfigured: boolean): void {
const panel = this.contentEl.createDiv({ cls: "rxn-panel" });
const meta = panel.createDiv({ cls: "rxn-pill-row" });
meta.createEl("span", { text: `Provider: ${this.options.getSettings().summary.provider}`, cls: "rxn-pill" });
meta.createSpan({ text: `Provider: ${this.options.getSettings().summary.provider}`, cls: "rxn-pill" });
if (!coreConfigured) {
const note = panel.createDiv({ cls: "rxn-inline-note is-warning" });
note.createEl("strong", { text: "Setup incomplete" });
note.createEl("p", { text: "Finish Capture, Transcription, and Summary setup before starting your first recording." });
note.createEl("p", { text: "Finish capture, transcription, and summary setup before starting your first recording." });
} else if (snapshot.state === "done") {
const note = panel.createDiv({ cls: "rxn-inline-note is-healthy" });
note.createEl("strong", { text: "Last session completed" });
@ -138,7 +138,9 @@ export class RecordingModal extends Modal {
this.render();
try {
await this.options.controller.startScenario(this.selectedScenarioKey);
} catch {}
} catch {
// The controller publishes the error through the plugin notice channel.
}
this.actionPending = null;
this.render();
},
@ -222,7 +224,7 @@ export class RecordingModal extends Modal {
private renderMetric(container: HTMLElement, label: string, value: string, detail: string): void {
const card = container.createDiv({ cls: "rxn-panel rxn-recording-metric" });
card.createEl("span", { text: label, cls: "rxn-eyebrow" });
card.createSpan({ text: label, cls: "rxn-eyebrow" });
card.createEl("strong", { text: value });
card.createEl("p", { text: detail, cls: "rxn-muted" });
}

View file

@ -6,9 +6,9 @@ export class TextPreviewModal extends Modal {
}
onOpen(): void {
this.setTitle(this.titleText);
this.contentEl.empty();
this.contentEl.addClass("rxn-modal");
this.contentEl.createEl("h2", { text: this.titleText });
const toolbar = this.contentEl.createDiv({ cls: "rxn-action-bar" });
const copy = toolbar.createEl("button", { text: "Copy all", cls: "rxn-btn-secondary" });
copy.addEventListener("click", () => {
@ -23,18 +23,8 @@ export class TextPreviewModal extends Modal {
try {
await navigator.clipboard.writeText(text);
new Notice("Copied.");
return;
} catch {}
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "true");
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
document.body.removeChild(textarea);
new Notice("Copied.");
} catch (error) {
new Notice(`Copy failed: ${String((error as Error)?.message ?? error)}`);
}
}
}

View file

@ -18,7 +18,8 @@ export function normalizeCheckboxes(markdown: string): string {
line = line.replace(
/^(\s*)[-*]\s*\[\s*([xX ])\s*\]\s*(.*)$/,
(_source, indent, checked, rest) => `${indent}- [${checked.toLowerCase() === "x" ? "x" : " "}] ${rest}`
(_source: string, indent: string, checked: string, rest: string) =>
`${indent}- [${checked.toLowerCase() === "x" ? "x" : " "}] ${rest}`
);
out.push(line);
}

View file

@ -13,6 +13,10 @@
--rxn-success: #1f9a63;
}
.rxn-hidden {
display: none !important;
}
.rxn-settings,
.rxn-modal {
font-family: var(--rxn-font);
@ -242,10 +246,18 @@
padding: 24px 26px;
}
.rxn-hero-header-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 14px;
}
.rxn-hero-brand {
max-width: 780px;
font-size: clamp(34px, 4vw, 52px);
line-height: 0.98;
font-weight: 800;
}
.rxn-hero-subtitle {
@ -255,6 +267,15 @@
line-height: 1.4;
}
.rxn-sponsor-link {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--text-muted);
text-decoration: none;
}
.rxn-panel h3 {
margin: 0 0 12px 0;
}

View file

@ -24,7 +24,7 @@ function createTestStore(t: TestContext) {
adapter: {
getBasePath: () => rootDir,
},
configDir: ".obsidian",
configDir: "custom-config",
},
} as any;
const store = new SessionStore(app, "resonance-next");

View file

@ -3,15 +3,11 @@ import assert from "node:assert/strict";
import { getWebAudioCapability, resolveWebAudioInputById } from "../src/infrastructure/system/webAudio";
test("getWebAudioCapability reports supported browser APIs", () => {
Object.defineProperty(globalThis, "navigator", {
value: {
mediaDevices: {
getUserMedia: async () => ({}) as MediaStream,
enumerateDevices: async () => [] as MediaDeviceInfo[],
},
},
configurable: true,
writable: true,
installNavigator({
mediaDevices: {
getUserMedia: async () => ({}) as MediaStream,
enumerateDevices: async () => [] as MediaDeviceInfo[],
} as unknown as MediaDevices,
});
assert.deepEqual(getWebAudioCapability(), {
@ -22,31 +18,43 @@ test("getWebAudioCapability reports supported browser APIs", () => {
});
test("resolveWebAudioInputById returns a matching audioinput device", async () => {
Object.defineProperty(globalThis, "navigator", {
value: {
mediaDevices: {
getUserMedia: async () => ({}) as MediaStream,
enumerateDevices: async () =>
[
{
kind: "audioinput",
deviceId: "default",
label: "System default input",
groupId: "default-group",
},
{
kind: "audioinput",
deviceId: "loopback-1",
label: "BlackHole 2ch",
groupId: "group-2",
},
] as MediaDeviceInfo[],
},
},
configurable: true,
writable: true,
installNavigator({
mediaDevices: {
getUserMedia: async () => ({}) as MediaStream,
enumerateDevices: async () =>
[
{
kind: "audioinput",
deviceId: "default",
label: "System default input",
groupId: "default-group",
},
{
kind: "audioinput",
deviceId: "loopback-1",
label: "BlackHole 2ch",
groupId: "group-2",
},
] as MediaDeviceInfo[],
} as unknown as MediaDevices,
});
const device = await resolveWebAudioInputById("loopback-1");
assert.equal(device?.label, "BlackHole 2ch");
});
function installNavigator(navigatorValue: Partial<Navigator>): void {
Object.defineProperty(globalThis, "window", {
value: {
navigator: navigatorValue,
},
configurable: true,
writable: true,
});
Object.defineProperty(globalThis, "navigator", {
value: navigatorValue,
configurable: true,
writable: true,
});
}

View file

@ -25,11 +25,11 @@ test("WebCaptureAdapter writes wav segments and final recording without polling"
onSegmentReady: (segment) => ready.push(segment),
});
const processor = FakeAudioContext.lastProcessor;
assert.ok(processor, "expected the fake script processor to be created");
const worklet = FakeAudioWorkletNode.lastNode;
assert.ok(worklet, "expected the fake AudioWorklet node to be created");
processor.emit([new Float32Array([0, 0.1, 0.2, 0.3, 0.4, 0.5])]);
processor.emit([new Float32Array([0.6, 0.7, 0.8, 0.9, 1])]);
worklet.emit([new Float32Array([0, 0.1, 0.2, 0.3, 0.4, 0.5])]);
worklet.emit([new Float32Array([0.6, 0.7, 0.8, 0.9, 1])]);
await adapter.stop();
@ -164,46 +164,65 @@ function installDesktopRuntime(): void {
function installFakeWebAudioEnvironment(): void {
requestedConstraints = [];
FakeAudioWorkletNode.lastNode = null;
Object.defineProperty(globalThis, "navigator", {
const navigatorValue = {
mediaDevices: {
getUserMedia: async (constraints: MediaStreamConstraints) => {
requestedConstraints.push(constraints);
return new FakeMediaStream();
},
enumerateDevices: async () =>
[
{
kind: "audioinput",
deviceId: "default",
label: "System default microphone",
groupId: "default-group",
},
{
kind: "audioinput",
deviceId: "mic-1",
label: "USB Microphone",
groupId: "group-1",
},
{
kind: "audioinput",
deviceId: "loopback-1",
label: "BlackHole 2ch",
groupId: "group-2",
},
] as MediaDeviceInfo[],
},
permissions: {
query: async () => ({ state: "prompt" }),
},
} as unknown as Navigator;
Object.defineProperty(globalThis, "window", {
value: {
mediaDevices: {
getUserMedia: async (constraints: MediaStreamConstraints) => {
requestedConstraints.push(constraints);
return new FakeMediaStream();
},
enumerateDevices: async () =>
[
{
kind: "audioinput",
deviceId: "default",
label: "System default microphone",
groupId: "default-group",
},
{
kind: "audioinput",
deviceId: "mic-1",
label: "USB Microphone",
groupId: "group-1",
},
{
kind: "audioinput",
deviceId: "loopback-1",
label: "BlackHole 2ch",
groupId: "group-2",
},
] as MediaDeviceInfo[],
},
permissions: {
query: async () => ({ state: "prompt" }),
require,
navigator: navigatorValue,
AudioContext: FakeAudioContext,
URL: {
createObjectURL: () => "blob:fake-worklet",
revokeObjectURL: () => {},
},
setTimeout,
clearTimeout,
},
configurable: true,
writable: true,
});
Object.defineProperty(globalThis, "AudioContext", {
value: FakeAudioContext,
Object.defineProperty(globalThis, "navigator", {
value: navigatorValue,
configurable: true,
writable: true,
});
Object.defineProperty(globalThis, "AudioWorkletNode", {
value: FakeAudioWorkletNode,
configurable: true,
writable: true,
});
@ -233,35 +252,48 @@ class FakeGainNode {
disconnect(): void {}
}
class FakeScriptProcessorNode {
onaudioprocess: ((event: AudioProcessingEvent) => void) | null = null;
class FakeMessagePort {
onmessage: ((event: MessageEvent<Float32Array>) => void) | null = null;
close(): void {
this.onmessage = null;
}
emit(samples: Float32Array): void {
this.onmessage?.({ data: samples } as MessageEvent<Float32Array>);
}
}
class FakeAudioWorkletNode {
static lastNode: FakeAudioWorkletNode | null = null;
readonly port = new FakeMessagePort();
constructor() {
FakeAudioWorkletNode.lastNode = this;
}
connect(): void {}
disconnect(): void {}
emit(channels: Float32Array[]): void {
const inputBuffer = new FakeAudioBuffer(channels);
this.onaudioprocess?.({ inputBuffer } as unknown as AudioProcessingEvent);
}
}
class FakeAudioBuffer {
readonly numberOfChannels: number;
readonly length: number;
constructor(private readonly channels: Float32Array[]) {
this.numberOfChannels = channels.length;
this.length = channels[0]?.length ?? 0;
}
getChannelData(channelIndex: number): Float32Array {
return this.channels[channelIndex] ?? new Float32Array(this.length);
const length = channels[0]?.length ?? 0;
const samples = new Float32Array(length);
for (let sampleIndex = 0; sampleIndex < length; sampleIndex += 1) {
let sum = 0;
for (const channel of channels) {
sum += channel[sampleIndex] ?? 0;
}
samples[sampleIndex] = channels.length > 0 ? sum / channels.length : 0;
}
this.port.emit(samples);
}
}
class FakeAudioContext {
static lastProcessor: FakeScriptProcessorNode | null = null;
readonly audioWorklet = {
addModule: async () => {},
};
readonly sampleRate = 10;
readonly destination = {} as AudioDestinationNode;
state: AudioContextState = "running";
@ -270,12 +302,6 @@ class FakeAudioContext {
return new FakeSourceNode() as unknown as MediaStreamAudioSourceNode;
}
createScriptProcessor(): ScriptProcessorNode {
const processor = new FakeScriptProcessorNode();
FakeAudioContext.lastProcessor = processor;
return processor as unknown as ScriptProcessorNode;
}
createGain(): GainNode {
return new FakeGainNode() as unknown as GainNode;
}