Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions src/audioRecord/audioRecord.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export class AudioRecord {
data: BlobPart[] = [];
fileExtension: string;
startTime: number | null = null;
pausedAt: number | null = null;
totalPausedMs = 0;
desiredFormat: 'webm' | 'mp3';

private mimeType: SupportedMimeType = pickMimeType('audio/webm; codecs=opus');
Expand All @@ -33,7 +35,10 @@ export class AudioRecord {
}

async startRecording(deviceId?: string) {
const audioConstraints = deviceId && deviceId !== ''
this.data = [];
this.totalPausedMs = 0;
this.pausedAt = null;
const audioConstraints = deviceId && deviceId !== ''
? { deviceId: { exact: deviceId } }
: true;

Expand All @@ -43,6 +48,8 @@ export class AudioRecord {
this.mediaRecorder = this.setupMediaRecorder(stream);
this.mediaRecorder.start();
this.startTime = Date.now();
this.totalPausedMs = 0;
this.pausedAt = null;
})
.catch((err) => {
new Notice('Scribe: Failed to access the microphone');
Expand Down Expand Up @@ -70,6 +77,10 @@ export class AudioRecord {
console.error('There is no mediaRecorder, cannot resume resumeRecording');
throw new Error('There is no mediaRecorder, cannot resumeRecording');
}
if (this.pausedAt) {
this.totalPausedMs += Date.now() - this.pausedAt;
this.pausedAt = null;
}
this.mediaRecorder?.resume();
}

Expand All @@ -78,6 +89,7 @@ export class AudioRecord {
console.error('There is no mediaRecorder, cannot pauseRecording');
throw new Error('There is no mediaRecorder, cannot pauseRecording');
}
this.pausedAt = Date.now();
this.mediaRecorder?.pause();
}

Expand Down Expand Up @@ -133,11 +145,21 @@ export class AudioRecord {
}

const blob = new Blob(this.data, { type: this.mimeType });
const duration = (this.startTime && Date.now() - this.startTime) || 0;
if (this.pausedAt) {
this.totalPausedMs += Date.now() - this.pausedAt;
this.pausedAt = null;
}
const duration =
(this.startTime &&
Math.max(0, Date.now() - this.startTime - this.totalPausedMs)) ||
0;
const fixedBlob = await fixWebmDuration(blob, duration, {});

this.mediaRecorder = null;
this.startTime = null;
this.pausedAt = null;
this.totalPausedMs = 0;
this.data = [];

// If MP3 is desired, convert the WebM blob to MP3
if (this.desiredFormat === 'mp3') {
Expand Down
25 changes: 24 additions & 1 deletion src/modal/components/ModalRecordingButtons.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { TrashIcon, SaveIcon, MicVocal } from '../icons/icons';
import {
TrashIcon,
SaveIcon,
PauseIcon,
ResumeIcon,
MicVocal } from '../icons/icons';

export function ModalRecordingButtons({
active,
Expand Down Expand Up @@ -42,6 +47,24 @@ export function ModalRecordingButtons({
Reset
</button>

<button
className="scribe-btn"
onClick={handlePauseResume}
type="button"
disabled={isScribing}
>
{recordingState === "recording" && (
<>
<PauseIcon /> Pause
</>
)}
{recordingState === "paused" && (
<>
<ResumeIcon /> Resume
</>
)}
</button>

{/**
*
<button
Expand Down
38 changes: 6 additions & 32 deletions src/modal/components/ModalRecordingTimer.tsx
Original file line number Diff line number Diff line change
@@ -1,32 +1,5 @@
import { useEffect, useState } from 'react';

export function ModalRecordingTimer({
startTimeMs,
}: { startTimeMs: number | null }) {
const [duration, setDuration] = useState({
minutes: 0,
seconds: 0,
milliseconds: 0,
});

useEffect(() => {
let interval: number | undefined = undefined;

if (startTimeMs && !interval) {
interval = window.setInterval(() => {
const { minutes, seconds, milliseconds } =
calculateDuration(startTimeMs);

setDuration({ minutes, seconds, milliseconds });
}, 10);
} else {
setDuration({ minutes: 0, seconds: 0, milliseconds: 0 });
interval && window.clearInterval(interval as number);
}
return () => {
interval && window.clearInterval(interval as number);
};
}, [startTimeMs]);
export function ModalRecordingTimer({ elapsedMs }: { elapsedMs: number }) {
const duration = calculateDuration(elapsedMs);

return (
<div className="scribe-timer">
Expand All @@ -43,9 +16,10 @@ export function ModalRecordingTimer({
);
}

function calculateDuration(startTime: number) {
const currentTime = Date.now();
const durationInMs = currentTime - startTime;
function calculateDuration(durationInMs: number) {
if (!Number.isFinite(durationInMs) || durationInMs < 0) {
durationInMs = 0;
}

let remainingTime = durationInMs;
const hours = Math.floor(remainingTime / (1000 * 60 * 60));
Expand Down
38 changes: 38 additions & 0 deletions src/modal/icons/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,44 @@ export function TrashIcon() {
);
}

export function PauseIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={24}
height={24}
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
className="lucide lucide-pause"
>
<title>Pause Icon</title>
<path d="M10 4h-2v16h2V4zM16 4h-2v16h2V4z" />
</svg>
);
}

export function ResumeIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={24}
height={24}
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
className="lucide lucide-play"
>
<title>Resume / Play Icon</title>
<path d="M6 4l14 8-14 8V4z" />
</svg>
);
}

export const CircleAlert = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
Expand Down
78 changes: 62 additions & 16 deletions src/modal/scribeControlsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { createRoot, type Root } from 'react-dom/client';
import { Modal } from 'obsidian';
import type ScribePlugin from 'src';
import type { ScribeOptions } from 'src';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { ModalRecordingTimer } from './components/ModalRecordingTimer';
import { ModalRecordingButtons } from './components/ModalRecordingButtons';
import { CircleAlert } from './icons/icons';
Expand Down Expand Up @@ -49,9 +49,10 @@ const ScribeModal: React.FC<{ plugin: ScribePlugin }> = ({ plugin }) => {
const [recordingState, setRecordingState] =
useState<RecordingState>('inactive');
const [isScribing, setIsScribing] = useState(false);
const [recordingStartTimeMs, setRecordingStartTimeMs] = useState<
number | null
>(null);
const [accumulatedElapsedMs, setAccumulatedElapsedMs] = useState(0);
const [displayElapsedMs, setDisplayElapsedMs] = useState(0);
const [lastResumeTimestampMs, setLastResumeTimestampMs] =
useState<number | null>(null);
const [scribeOptions, setScribeOptions] = useState<ScribeOptions>({
isAppendToActiveFile: plugin.settings.isAppendToActiveFile,
isOnlyTranscribeActive: plugin.settings.isOnlyTranscribeActive,
Expand All @@ -67,34 +68,76 @@ const ScribeModal: React.FC<{ plugin: ScribePlugin }> = ({ plugin }) => {

const hasOpenAiApiKey = Boolean(plugin.settings.openAiApiKey);

useEffect(() => {
if (!isActive) {
setDisplayElapsedMs(0);
return;
}

if (isPaused || lastResumeTimestampMs === null) {
setDisplayElapsedMs(accumulatedElapsedMs);
return;
}

const updateElapsed = () => {
setDisplayElapsedMs(
accumulatedElapsedMs + (Date.now() - lastResumeTimestampMs),
);
};

updateElapsed();
const interval = window.setInterval(updateElapsed, 50);

return () => window.clearInterval(interval);
}, [isActive, isPaused, lastResumeTimestampMs, accumulatedElapsedMs]);

const handleStart = async () => {
const now = Date.now();
setAccumulatedElapsedMs(0);
setDisplayElapsedMs(0);
setLastResumeTimestampMs(now);
setRecordingState('recording');
await plugin.startRecording();
setRecordingStartTimeMs(Date.now());

setIsActive(true);
setIsPaused(false);
await plugin.startRecording();
};

const handlePauseResume = () => {
const updatedIsPauseState = !isPaused;
setIsPaused(updatedIsPauseState);

if (updatedIsPauseState) {
setRecordingState('paused');
} else {
if (isPaused) {
setLastResumeTimestampMs(Date.now());
setRecordingState('recording');
setIsPaused(false);
} else {
const now = Date.now();
let updatedElapsed = accumulatedElapsedMs;
if (lastResumeTimestampMs !== null) {
updatedElapsed += now - lastResumeTimestampMs;
}
setAccumulatedElapsedMs(updatedElapsed);
setDisplayElapsedMs(updatedElapsed);
setLastResumeTimestampMs(null);
setRecordingState('paused');
setIsPaused(true);
}

plugin.handlePauseResumeRecording();
};

const handleComplete = async () => {
let finalElapsed = accumulatedElapsedMs;
if (!isPaused && lastResumeTimestampMs !== null) {
finalElapsed += Date.now() - lastResumeTimestampMs;
}

setAccumulatedElapsedMs(finalElapsed);
setDisplayElapsedMs(finalElapsed);
setLastResumeTimestampMs(null);
setIsPaused(true);
setIsScribing(true);
setRecordingStartTimeMs(null);
setRecordingState('inactive');
await plugin.scribe(scribeOptions);
setAccumulatedElapsedMs(0);
setDisplayElapsedMs(0);
setIsPaused(false);
setIsActive(false);
setIsScribing(false);
Expand All @@ -105,7 +148,10 @@ const ScribeModal: React.FC<{ plugin: ScribePlugin }> = ({ plugin }) => {

setRecordingState('inactive');
setIsActive(false);
setRecordingStartTimeMs(null);
setAccumulatedElapsedMs(0);
setDisplayElapsedMs(0);
setLastResumeTimestampMs(null);
setIsPaused(true);
};

return (
Expand All @@ -124,7 +170,7 @@ const ScribeModal: React.FC<{ plugin: ScribePlugin }> = ({ plugin }) => {
)}
{hasOpenAiApiKey && (
<>
<ModalRecordingTimer startTimeMs={recordingStartTimeMs} />
<ModalRecordingTimer elapsedMs={displayElapsedMs} />

<ModalRecordingButtons
recordingState={recordingState}
Expand Down