feat: show recording input meter

This commit is contained in:
2026-08-10 19:53:55 -04:00
parent 8caa86753b
commit d1f760cd2c
4 changed files with 181 additions and 20 deletions
+64
View File
@@ -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<void> {
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);
}
}
+55 -20
View File
@@ -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" })
);
}
}