Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: audio recording #4439

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
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
Empty file modified scripts/build.sh
100644 → 100755
Empty file.
2 changes: 1 addition & 1 deletion web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,4 +85,4 @@
"@bufbuild/buf"
]
}
}
}
110 changes: 110 additions & 0 deletions web/src/components/MemoEditor/ActionButton/RecordAudioButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { Button } from "@usememos/mui";
import { MicIcon, StopCircleIcon } from "lucide-react";
import { useCallback, useContext, useState } from "react";
import toast from "react-hot-toast";
import { useResourceStore } from "@/store/v1";
import { Resource } from "@/types/proto/api/v1/resource_service";
import { useTranslate } from "@/utils/i18n";
import { MemoEditorContext } from "../types";

const RecordAudioButton = () => {
const t = useTranslate();
const context = useContext(MemoEditorContext);
const resourceStore = useResourceStore();
const [isRecording, setIsRecording] = useState(false);
const [mediaRecorder, setMediaRecorder] = useState<MediaRecorder | null>(null);

// 检测浏览器支持的音频格式
const getSupportedMimeType = () => {
const types = ["audio/webm", "audio/mp4", "audio/aac", "audio/wav", "audio/ogg"];

for (const type of types) {
if (MediaRecorder.isTypeSupported(type)) {
return type;
}
}
return null;
};

const startRecording = useCallback(async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });

const mimeType = getSupportedMimeType();
if (!mimeType) {
throw new Error("No supported audio format found");
}

const recorder = new MediaRecorder(stream, {
mimeType: mimeType,
});

const chunks: BlobPart[] = [];

recorder.ondataavailable = (e) => chunks.push(e.data);
recorder.onstop = async () => {
const blob = new Blob(chunks, { type: mimeType });
const buffer = new Uint8Array(await blob.arrayBuffer());

// 根据不同的 mimeType 选择合适的文件扩展名
const getFileExtension = (mimeType: string) => {
switch (mimeType) {
case "audio/webm":
return "webm";
case "audio/mp4":
return "m4a";
case "audio/aac":
return "aac";
case "audio/wav":
return "wav";
case "audio/ogg":
return "ogg";
default:
return "webm";
}
};

try {
const resource = await resourceStore.createResource({
resource: Resource.fromPartial({
filename: `recording-${new Date().getTime()}.${getFileExtension(mimeType)}`,
type: mimeType,
size: buffer.length,
content: buffer,
}),
});
context.setResourceList([...context.resourceList, resource]);
} catch (error: any) {
console.error(error);
toast.error(error.details);
}

stream.getTracks().forEach((track) => track.stop());
};

// 每秒记录一次数据
recorder.start(1000);
setMediaRecorder(recorder);
setIsRecording(true);
} catch (error) {
console.error(error);
toast.error(t("message.microphone-not-available"));
}
}, [context, resourceStore, t]);

const stopRecording = useCallback(() => {
if (mediaRecorder) {
mediaRecorder.stop();
setMediaRecorder(null);
setIsRecording(false);
}
}, [mediaRecorder]);

return (
<Button className="relative" size="sm" variant="plain" onClick={isRecording ? stopRecording : startRecording}>
{isRecording ? <StopCircleIcon className="w-5 h-5 mx-auto text-red-500" /> : <MicIcon className="w-5 h-5 mx-auto" />}
</Button>
);
};

export default RecordAudioButton;
2 changes: 2 additions & 0 deletions web/src/components/MemoEditor/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import VisibilityIcon from "../VisibilityIcon";
import AddMemoRelationPopover from "./ActionButton/AddMemoRelationPopover";
import LocationSelector from "./ActionButton/LocationSelector";
import MarkdownMenu from "./ActionButton/MarkdownMenu";
import RecordAudioButton from "./ActionButton/RecordAudioButton";
import TagSelector from "./ActionButton/TagSelector";
import UploadResourceButton from "./ActionButton/UploadResourceButton";
import Editor, { EditorRefActions } from "./Editor";
Expand Down Expand Up @@ -465,6 +466,7 @@ const MemoEditor = observer((props: Props) => {
<TagSelector editorRef={editorRef} />
<MarkdownMenu editorRef={editorRef} />
<UploadResourceButton />
<RecordAudioButton />
<AddMemoRelationPopover editorRef={editorRef} />
{workspaceMemoRelatedSetting.enableLocation && (
<LocationSelector
Expand Down
2 changes: 1 addition & 1 deletion web/src/components/MemoResource.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const MemoResource: React.FC<Props> = (props: Props) => {
return (
<div className={`w-auto flex flex-row justify-start items-center text-gray-500 dark:text-gray-400 hover:opacity-80 ${className}`}>
{resource.type.startsWith("audio") ? (
<audio src={resourceUrl} controls></audio>
<audio src={resourceUrl} controls className="max-w-full" controlsList="nodownload" />
) : (
<>
<ResourceIcon className="!w-4 !h-4 mr-1" resource={resource} />
Expand Down
5 changes: 3 additions & 2 deletions web/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,8 @@
"remove-completed-task-list-items-successfully": "The removal was successful",
"failed-to-embed-memo": "Failed to embed memo",
"description-is-required": "Description is required",
"fill-all-required-fields": "Please fill all required fields"
"fill-all-required-fields": "Please fill all required fields",
"microphone-not-available": "Cannot access microphone"
},
"reference": {
"add-references": "Add references",
Expand Down Expand Up @@ -323,7 +324,7 @@
},
"disable-password-login": "Disable password login",
"disable-password-login-final-warning": "Please type \"CONFIRM\" if you know what you are doing.",
"disable-password-login-warning": "This will disable password login for all users. It is not possible to log in without reverting this setting in the database if your configured identity providers fail. Youll also have to be extra carefull when removing an identity provider",
"disable-password-login-warning": "This will disable password login for all users. It is not possible to log in without reverting this setting in the database if your configured identity providers fail. You'll also have to be extra carefull when removing an identity provider",
"disable-public-memos": "Disable public memos",
"disable-markdown-shortcuts-in-editor": "Disable Markdown shortcuts in editor",
"display-with-updated-time": "Display with updated time",
Expand Down
3 changes: 2 additions & 1 deletion web/src/locales/zh-Hans.json
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,8 @@
"succeed-copy-link": "复制链接到剪贴板成功。",
"update-succeed": "更新成功",
"user-not-found": "未找到该用户",
"remove-completed-task-list-items-successfully": "移除成功!"
"remove-completed-task-list-items-successfully": "移除成功!",
"microphone-not-available": "无法访问麦克风"
},
"reference": {
"add-references": "添加引用",
Expand Down