mirror of
https://github.com/blackajiro/Resonance.git
synced 2026-07-22 06:51:15 +00:00
pr fixes
This commit is contained in:
parent
7226641990
commit
75c5406c49
24 changed files with 5201 additions and 379 deletions
53
eslint.config.mjs
Normal file
53
eslint.config.mjs
Normal 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
4545
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
16
src/main.ts
16
src/main.ts
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
65
src/ui/modals/ConfirmationModal.ts
Normal file
65
src/ui/modals/ConfirmationModal.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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" });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
21
styles.css
21
styles.css
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue