diff --git a/main.ts b/main.ts index 9b5a414..65210be 100644 --- a/main.ts +++ b/main.ts @@ -43,6 +43,9 @@ export default class NibbleAI extends Plugin { this.recorder.setDeviceId(deviceId); this.statusBar = new StatusBar(this); + this.recorder.setInputLevelCallback((level) => { + this.statusBar.updateInputLevel(level); + }); this.proofreader = new Proofreader(this); this.titleGenerator = new TitleGenerator(this); diff --git a/src/recorder/AudioRecorder.ts b/src/recorder/AudioRecorder.ts index 0750594..aee43a4 100644 --- a/src/recorder/AudioRecorder.ts +++ b/src/recorder/AudioRecorder.ts @@ -33,6 +33,12 @@ export class NativeAudioRecorder implements AudioRecorder { private recorder: MediaRecorder | null = null; private mimeType: string | undefined; private deviceId: string | null = null; + private audioContext: AudioContext | null = null; + private analyser: AnalyserNode | null = null; + private inputSource: MediaStreamAudioSourceNode | null = null; + private inputSamples: Uint8Array | null = null; + private animationFrameId: number | null = null; + private onInputLevel: ((level: number) => void) | null = null; getRecordingState(): "inactive" | "recording" | "paused" | undefined { return this.recorder?.state; @@ -46,6 +52,10 @@ export class NativeAudioRecorder implements AudioRecorder { this.deviceId = deviceId; } + setInputLevelCallback(callback: ((level: number) => void) | null): void { + this.onInputLevel = callback; + } + async startRecording(): Promise { if (!this.recorder) { try { @@ -70,6 +80,7 @@ export class NativeAudioRecorder implements AudioRecorder { }); this.recorder = recorder; + this.startInputMeter(stream); } catch (err) { new Notice("✘ Couldn't access microphone"); console.error("Error initializing recorder:", err); @@ -87,6 +98,7 @@ export class NativeAudioRecorder implements AudioRecorder { if (this.recorder.state === "recording") { this.recorder.pause(); + this.onInputLevel?.(0); } else if (this.recorder.state === "paused") { this.recorder.resume(); } @@ -97,6 +109,7 @@ export class NativeAudioRecorder implements AudioRecorder { if (!this.recorder || this.recorder.state === "inactive") { const blob = new Blob(this.chunks, { type: this.mimeType }); this.chunks.length = 0; + this.stopInputMeter(); resolve(blob); } else { this.recorder.addEventListener( @@ -113,6 +126,7 @@ export class NativeAudioRecorder implements AudioRecorder { .forEach((track) => track.stop()); this.recorder = null; } + this.stopInputMeter(); resolve(blob); }, @@ -123,4 +137,54 @@ export class NativeAudioRecorder implements AudioRecorder { } }); } + + private startInputMeter(stream: MediaStream): void { + this.stopInputMeter(); + + this.audioContext = new AudioContext(); + this.analyser = this.audioContext.createAnalyser(); + this.analyser.fftSize = 256; + this.inputSamples = new Uint8Array(this.analyser.fftSize); + + this.inputSource = this.audioContext.createMediaStreamSource(stream); + this.inputSource.connect(this.analyser); + this.tickInputMeter(); + } + + private tickInputMeter(): void { + if (!this.analyser || !this.inputSamples) return; + + if (this.recorder?.state === "recording") { + this.analyser.getByteTimeDomainData(this.inputSamples); + + let sum = 0; + for (let i = 0; i < this.inputSamples.length; i++) { + const centeredSample = (this.inputSamples[i] - 128) / 128; + sum += centeredSample * centeredSample; + } + + const rms = Math.sqrt(sum / this.inputSamples.length); + this.onInputLevel?.(Math.min(1, rms * 6)); + } else { + this.onInputLevel?.(0); + } + + this.animationFrameId = requestAnimationFrame(() => + this.tickInputMeter() + ); + } + + private stopInputMeter(): void { + if (this.animationFrameId !== null) { + cancelAnimationFrame(this.animationFrameId); + this.animationFrameId = null; + } + + void this.audioContext?.close(); + this.audioContext = null; + this.analyser = null; + this.inputSource = null; + this.inputSamples = null; + this.onInputLevel?.(0); + } } \ No newline at end of file diff --git a/src/recorder/StatusBar.ts b/src/recorder/StatusBar.ts index fde6435..62fd186 100644 --- a/src/recorder/StatusBar.ts +++ b/src/recorder/StatusBar.ts @@ -12,6 +12,7 @@ export class StatusBar { statusBarItem: HTMLElement | null = null; status: RecordingStatus = RecordingStatus.Idle; private listeners: Array<(status: RecordingStatus) => void> = []; + private meterBars: HTMLElement[] = []; constructor(plugin: Plugin) { this.plugin = plugin; @@ -33,27 +34,61 @@ export class StatusBar { this.listeners.forEach((fn) => fn(status)); } + updateInputLevel(level: number): void { + if (this.status !== RecordingStatus.Recording) return; + + const clampedLevel = Math.max(0, Math.min(1, level)); + const activeBars = Math.round(clampedLevel * this.meterBars.length); + + this.meterBars.forEach((bar, index) => { + bar.toggleClass("nibbleai-meter-bar-active", index < activeBars); + }); + } + updateStatusBarItem() { - if (this.statusBarItem) { - switch (this.status) { - case RecordingStatus.Recording: - this.statusBarItem.textContent = "NibbleAI Recording..."; - this.statusBarItem.style.color = "red"; - break; - case RecordingStatus.Paused: - this.statusBarItem.textContent = "NibbleAI Paused"; - this.statusBarItem.style.color = "yellow"; - break; - case RecordingStatus.Processing: - this.statusBarItem.textContent = "NibbleAI Processing..."; - this.statusBarItem.style.color = "gray"; - break; - case RecordingStatus.Idle: - default: - this.statusBarItem.textContent = "NibbleAI Idle"; - this.statusBarItem.style.color = "green"; - break; - } + if (!this.statusBarItem) return; + + this.statusBarItem.empty(); + this.statusBarItem.removeClass("nibbleai-status-recording"); + this.statusBarItem.removeClass("nibbleai-status-paused"); + this.statusBarItem.removeClass("nibbleai-status-processing"); + this.statusBarItem.removeClass("nibbleai-status-idle"); + this.meterBars = []; + + switch (this.status) { + case RecordingStatus.Recording: + this.statusBarItem.addClass("nibbleai-status-recording"); + this.statusBarItem.createSpan({ text: "NibbleAI " }); + this.createInputMeter(); + break; + case RecordingStatus.Paused: + this.statusBarItem.addClass("nibbleai-status-paused"); + this.statusBarItem.setText("NibbleAI Paused"); + break; + case RecordingStatus.Processing: + this.statusBarItem.addClass("nibbleai-status-processing"); + this.statusBarItem.setText("NibbleAI Processing..."); + break; + case RecordingStatus.Idle: + default: + this.statusBarItem.addClass("nibbleai-status-idle"); + this.statusBarItem.setText("NibbleAI Idle"); + break; + } + } + + private createInputMeter(): void { + if (!this.statusBarItem) return; + + const meter = this.statusBarItem.createSpan({ + cls: "nibbleai-input-meter", + attr: { "aria-label": "Recording input level" }, + }); + + for (let i = 0; i < 8; i++) { + this.meterBars.push( + meter.createSpan({ cls: "nibbleai-meter-bar" }) + ); } } diff --git a/styles.css b/styles.css index 2ad766c..2db53fa 100644 --- a/styles.css +++ b/styles.css @@ -35,6 +35,65 @@ height: 14px; } +/* ── Recording Status Meter ── */ +.nibbleai-status-recording { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--text-error); +} + +.nibbleai-status-paused { + color: var(--text-warning); +} + +.nibbleai-status-processing { + color: var(--text-muted); +} + +.nibbleai-status-idle { + color: var(--text-success); +} + +.nibbleai-input-meter { + display: inline-flex; + align-items: center; + gap: 2px; + height: 12px; +} + +.nibbleai-meter-bar { + width: 2px; + height: 4px; + border-radius: 999px; + background-color: currentColor; + opacity: 0.25; + transition: height 70ms ease-out, opacity 70ms ease-out; +} + +.nibbleai-meter-bar-active { + height: 12px; + opacity: 1; + animation: nibbleai-meter-pulse 360ms ease-in-out infinite alternate; +} + +.nibbleai-meter-bar:nth-child(2n).nibbleai-meter-bar-active { + animation-delay: 80ms; +} + +.nibbleai-meter-bar:nth-child(3n).nibbleai-meter-bar-active { + animation-delay: 160ms; +} + +@keyframes nibbleai-meter-pulse { + from { + transform: scaleY(0.55); + } + to { + transform: scaleY(1); + } +} + /* ── Model Browser Suggestion ── */ .model-suggestion { padding: 4px 0;