feat: show recording input meter
This commit is contained in:
@@ -43,6 +43,9 @@ export default class NibbleAI extends Plugin {
|
|||||||
this.recorder.setDeviceId(deviceId);
|
this.recorder.setDeviceId(deviceId);
|
||||||
|
|
||||||
this.statusBar = new StatusBar(this);
|
this.statusBar = new StatusBar(this);
|
||||||
|
this.recorder.setInputLevelCallback((level) => {
|
||||||
|
this.statusBar.updateInputLevel(level);
|
||||||
|
});
|
||||||
|
|
||||||
this.proofreader = new Proofreader(this);
|
this.proofreader = new Proofreader(this);
|
||||||
this.titleGenerator = new TitleGenerator(this);
|
this.titleGenerator = new TitleGenerator(this);
|
||||||
|
|||||||
@@ -33,6 +33,12 @@ export class NativeAudioRecorder implements AudioRecorder {
|
|||||||
private recorder: MediaRecorder | null = null;
|
private recorder: MediaRecorder | null = null;
|
||||||
private mimeType: string | undefined;
|
private mimeType: string | undefined;
|
||||||
private deviceId: string | null = null;
|
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 {
|
getRecordingState(): "inactive" | "recording" | "paused" | undefined {
|
||||||
return this.recorder?.state;
|
return this.recorder?.state;
|
||||||
@@ -46,6 +52,10 @@ export class NativeAudioRecorder implements AudioRecorder {
|
|||||||
this.deviceId = deviceId;
|
this.deviceId = deviceId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setInputLevelCallback(callback: ((level: number) => void) | null): void {
|
||||||
|
this.onInputLevel = callback;
|
||||||
|
}
|
||||||
|
|
||||||
async startRecording(): Promise<void> {
|
async startRecording(): Promise<void> {
|
||||||
if (!this.recorder) {
|
if (!this.recorder) {
|
||||||
try {
|
try {
|
||||||
@@ -70,6 +80,7 @@ export class NativeAudioRecorder implements AudioRecorder {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.recorder = recorder;
|
this.recorder = recorder;
|
||||||
|
this.startInputMeter(stream);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
new Notice("✘ Couldn't access microphone");
|
new Notice("✘ Couldn't access microphone");
|
||||||
console.error("Error initializing recorder:", err);
|
console.error("Error initializing recorder:", err);
|
||||||
@@ -87,6 +98,7 @@ export class NativeAudioRecorder implements AudioRecorder {
|
|||||||
|
|
||||||
if (this.recorder.state === "recording") {
|
if (this.recorder.state === "recording") {
|
||||||
this.recorder.pause();
|
this.recorder.pause();
|
||||||
|
this.onInputLevel?.(0);
|
||||||
} else if (this.recorder.state === "paused") {
|
} else if (this.recorder.state === "paused") {
|
||||||
this.recorder.resume();
|
this.recorder.resume();
|
||||||
}
|
}
|
||||||
@@ -97,6 +109,7 @@ export class NativeAudioRecorder implements AudioRecorder {
|
|||||||
if (!this.recorder || this.recorder.state === "inactive") {
|
if (!this.recorder || this.recorder.state === "inactive") {
|
||||||
const blob = new Blob(this.chunks, { type: this.mimeType });
|
const blob = new Blob(this.chunks, { type: this.mimeType });
|
||||||
this.chunks.length = 0;
|
this.chunks.length = 0;
|
||||||
|
this.stopInputMeter();
|
||||||
resolve(blob);
|
resolve(blob);
|
||||||
} else {
|
} else {
|
||||||
this.recorder.addEventListener(
|
this.recorder.addEventListener(
|
||||||
@@ -113,6 +126,7 @@ export class NativeAudioRecorder implements AudioRecorder {
|
|||||||
.forEach((track) => track.stop());
|
.forEach((track) => track.stop());
|
||||||
this.recorder = null;
|
this.recorder = null;
|
||||||
}
|
}
|
||||||
|
this.stopInputMeter();
|
||||||
|
|
||||||
resolve(blob);
|
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
@@ -12,6 +12,7 @@ export class StatusBar {
|
|||||||
statusBarItem: HTMLElement | null = null;
|
statusBarItem: HTMLElement | null = null;
|
||||||
status: RecordingStatus = RecordingStatus.Idle;
|
status: RecordingStatus = RecordingStatus.Idle;
|
||||||
private listeners: Array<(status: RecordingStatus) => void> = [];
|
private listeners: Array<(status: RecordingStatus) => void> = [];
|
||||||
|
private meterBars: HTMLElement[] = [];
|
||||||
|
|
||||||
constructor(plugin: Plugin) {
|
constructor(plugin: Plugin) {
|
||||||
this.plugin = plugin;
|
this.plugin = plugin;
|
||||||
@@ -33,27 +34,61 @@ export class StatusBar {
|
|||||||
this.listeners.forEach((fn) => fn(status));
|
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() {
|
updateStatusBarItem() {
|
||||||
if (this.statusBarItem) {
|
if (!this.statusBarItem) return;
|
||||||
switch (this.status) {
|
|
||||||
case RecordingStatus.Recording:
|
this.statusBarItem.empty();
|
||||||
this.statusBarItem.textContent = "NibbleAI Recording...";
|
this.statusBarItem.removeClass("nibbleai-status-recording");
|
||||||
this.statusBarItem.style.color = "red";
|
this.statusBarItem.removeClass("nibbleai-status-paused");
|
||||||
break;
|
this.statusBarItem.removeClass("nibbleai-status-processing");
|
||||||
case RecordingStatus.Paused:
|
this.statusBarItem.removeClass("nibbleai-status-idle");
|
||||||
this.statusBarItem.textContent = "NibbleAI Paused";
|
this.meterBars = [];
|
||||||
this.statusBarItem.style.color = "yellow";
|
|
||||||
break;
|
switch (this.status) {
|
||||||
case RecordingStatus.Processing:
|
case RecordingStatus.Recording:
|
||||||
this.statusBarItem.textContent = "NibbleAI Processing...";
|
this.statusBarItem.addClass("nibbleai-status-recording");
|
||||||
this.statusBarItem.style.color = "gray";
|
this.statusBarItem.createSpan({ text: "NibbleAI " });
|
||||||
break;
|
this.createInputMeter();
|
||||||
case RecordingStatus.Idle:
|
break;
|
||||||
default:
|
case RecordingStatus.Paused:
|
||||||
this.statusBarItem.textContent = "NibbleAI Idle";
|
this.statusBarItem.addClass("nibbleai-status-paused");
|
||||||
this.statusBarItem.style.color = "green";
|
this.statusBarItem.setText("NibbleAI Paused");
|
||||||
break;
|
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" })
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+59
@@ -35,6 +35,65 @@
|
|||||||
height: 14px;
|
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 Browser Suggestion ── */
|
||||||
.model-suggestion {
|
.model-suggestion {
|
||||||
padding: 4px 0;
|
padding: 4px 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user