メインコンテンツまでスキップ

「Nextjs」タグの記事が6件件あります

全てのタグを見る

· 約5分
森田 有貴

森田です。

普段は何かしらの機能の実装の話をしてますが、今回は毛色の異なるお話をしようかと思います。

では早速こちらのコードをご覧ください。

import { useEffect, useState } from "react";

type Address = {
address1: string;
address2: string;
address3: string;
kana1: string;
kana2: string;
kana3: string;
prefcode: string;
zipcode: string;
};

export const App = () => {
const [address, setAddress] = useState<Address>();
const [loading, setLoading] = useState(true);

useEffect(() => {
const fetchData = async () => {
const response = await fetch("https://zipcloud.ibsnet.co.jp/api/search?zipcode=7830060"); //fetchでデータを取得
const data = await response.json();

setAddress(data.results[0]); //data.results[0]をaddressにセット
setLoading(false); //fetchが終わったらloadingをfalseにする
};

fetchData();
}, []);

if (loading) {
return <div>loading...</div>;
} else {
return <div></div>;
}
};

やっていることとしてページをロードした際に指定した郵便番号からどこの住所のものなのかを取得しているだけです。

fetch中はloading...と表示し、終わったら取得した内容を表示するといったページです。

では問題です。addressの型は何でしょうか。

 

 

 

 

  〜シンキングタイム〜  

 

 

 

   

正解は...

Address | undifnedです!正解できましたかね?

そうなんです、useStateで初期値を設定していないのでundefinedがくっついてしまうんです。

なのでaddressの値を表示させようとすると{address?.address1}とオプショナルプロパティをつけて書かないといけなくなってしまうのです。これはよろしくないですね。

これをtypescriptの良さを活かして解決してみましょう!

解決方法としてはaddressを定義する際にその型を(Address & { loading: false }) | { loading: true }とし、その初期値を{ loading: true }とするというものです。

つまり

const [address, setAddress] = useState<(Address & { loading: false }) | { loading: true }>({ loading: true });

とするということです。

で、fetchしたデータをaddressに代入する際にloadingの値をfalseにします。

こうすることによってaddress値はundefinedにならなくなり、さらに別でloadingの値を設定する必要がなくなるのです。

文面だけだと何言ってるか少しよくわからないですね。

最初のコードを実際に編集してみましょう。

import { useEffect, useState } from "react";

type Address = {
address1: string;
address2: string;
address3: string;
kana1: string;
kana2: string;
kana3: string;
prefcode: string;
zipcode: string;
};

export const App = () => {
const [address, setAddress] = useState<(Address & { loading: false }) | { loading: true }>({ loading: true });

useEffect(() => {
const fetchData = async () => {
const response = await fetch("https://zipcloud.ibsnet.co.jp/api/search?zipcode=7830060"); //fetchでデータを取得
const data = await response.json();
setAddress({ ...data.results[0], loading: false }); //data.results[0]とloading:falseをセット
};

fetchData();
}, []);

if (address.loading) {
return <div>loading...</div>;
} else {
return <div>{address.address1}</div>;
}
};

このようにaddress.loading === falseになっている時だけaddressの値を表示するようにすると通常通り{address.address1}と表示できるようになるというわけです!

めちゃめちゃtypescriptの良さを活かしたtypescriptだからこそできる手法ではないでしょうか。

ちなみにこちらはチームのメンバーさんが見つけてくださったうひょさんの記事を参考にしています。 https://qiita.com/uhyo/items/d74af1d8c109af43849e

実際に社内のプロダクトでもこの手法を取り入れて開発をしています。

もしよかったら参考にしてみてください!

では。

· 約10分
森田 有貴

森田です。 先日 BlockNote について紹介しましたが、今回は BlockNote と yjs と Liveblocks を用いた共同編集機能の実装をご紹介します。

共同編集と聞くと難しそうに感じますが、そんなことは全くなくとても簡単に実装できます。

早速実装といきたいところですが、少しだけ yjs と Liveblocks について説明を...

yjs とは

yjs は CRDT(Conflict-free Replicated Data Type)という技術を持ちたフレームワークです。つまりコンフリクトしないデータの型を扱うことができます。

詳しくはこちら ↓

https://docs.yjs.dev/

Liveblocks とは

Liveblocks はリアルタイムでの共同編集やカーソル共有などのコラボレーション機能を簡単に追加できるツールキットです。

公式ドキュメントがとても充実しているので詳しくはこちらを参照ください ↓

https://liveblocks.io/docs

準備

Nextjs を作成。今回は App Router + Typescript でいきます。そのほかは好みで設定してもらえればいいと思います。

$ npx create-next-app collaborative-editor
✔ Would you like to use TypeScript? … No / Yes
✔ Would you like to use ESLint? … No / Yes
✔ Would you like to use Tailwind CSS? … No / Yes
✔ Would you like to use `src/` directory? … No / Yes
✔ Would you like to use App Router? (recommended) … No / Yes
✔ Would you like to customize the default import alias (@/*)? … No / Yes

Liveblocks 周りの設定

Liveblocks 関連のライブラリをインストールします

npm i @liveblocks/client @liveblocks/react @liveblocks/node @liveblocks/yjs

liveblocks を初期化

npx create-liveblocks-app@latest --init --framework react

何やら質問されるので、y で実行しましょう。

$ npx create-liveblocks-app@latest --init --framework react
Need to install the following packages:
create-liveblocks-app@2.20240816.0
Ok to proceed? (y) y

▀█████▀ ▄
▀██▀ ▄██▄
▀ ▄█████▄

Liveblocks


✨ liveblocks.config.ts has been generated!

これでアプリ側の設定は完了。

続いて API キーを取得しにいきましょう。

まずこちらからサインアップhttps://liveblocks.io/

API keys を選択

image1

Generate key...をクリックして

image2

キーを生成!

image3

Public key と Secret key は後々使うのでメモしておきましょう。

BlockNote の設定

先日の記事と同じように blocknote のライブラリもインポートしておきましょう

npm i @blocknote/core @blocknote/react @blocknote/mantine

実装

ではいよいよ実装に入っていきましょう。

まず先程取得した API キーを環境変数に設定しておきましょう。

/.env.local
NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY= LIVEBLOCKS_SECRET_KEY=

続いてバックエンドを作成します。

/src/app/api/liveblocks-auth/route.ts
import { Liveblocks } from "@liveblocks/node";
import { NextResponse } from "next/server";
import { nanoid } from "nanoid";

const liveblocks = new Liveblocks({
secret: process.env.LIVEBLOCKS_SECRET_KEY!,
});

export async function POST() {
const liveSession = liveblocks.prepareSession(nanoid());

liveSession.allow("*", liveSession.FULL_ACCESS);

const { body, status } = await liveSession.authorize();
return new NextResponse(body, { status });
}

続いてLiveblocks のライブラリを用いてルームの作成接続などの機能のコンポーネントを作成します。

/src/app/Components/Room.tsx
"use client";

import { LiveblocksProvider, RoomProvider, ClientSideSuspense } from "@liveblocks/react/suspense";
import { ReactNode } from "react";

type Props = {
children: ReactNode;
};

export const Room = ({ children }: Props) => {
return (
<LiveblocksProvider authEndpoint={"/api/liveblocks-auth"}>
<RoomProvider id="test-room">
<ClientSideSuspense fallback={<div>...loading</div>}>{children}</ClientSideSuspense>
</RoomProvider>
</LiveblocksProvider>
);
};

authEndpointは先程作成したバックエンドのエンドポイントを設定しましょう。

次にBlockNoteにyjsを組み込んだエディタを作成します。

/src/app/Components/Editor.tsx
"use client";
import { useEffect, useState } from "react";
import { BlockNoteEditor } from "@blocknote/core";
import { useCreateBlockNote } from "@blocknote/react";
import { BlockNoteView } from "@blocknote/mantine";
import * as Y from "yjs";
import { LiveblocksYjsProvider } from "@liveblocks/yjs";
import { useRoom } from "@liveblocks/react/suspense";
import "@blocknote/core/fonts/inter.css";
import "@blocknote/mantine/style.css";

type EditorProps = {
doc: Y.Doc;
provider: any;
};

export function Editor() {
const room = useRoom();
const [doc, setDoc] = useState<Y.Doc>();
const [provider, setProvider] = useState<any>();

useEffect(() => {
const yDoc = new Y.Doc();
const yProvider = new LiveblocksYjsProvider(room, yDoc);
setDoc(yDoc);
setProvider(yProvider);

return () => {
yDoc?.destroy();
yProvider?.destroy();
};
}, [room]);

if (!doc || !provider) {
return null;
}

return <BlockNote doc={doc} provider={provider} />;
}

function BlockNote({ doc, provider }: EditorProps) {
const editor: BlockNoteEditor = useCreateBlockNote({
collaboration: {
provider,
fragment: doc.getXmlFragment("document-store"),
user: {
name: "User",
color: "#ff0000",
},
},
});

return <BlockNoteView editor={editor} />;
}

作成したコンポーネントをページに組み込んで...

/src/app/collaborative-editing/page.tsx
import { Room } from "../Components/Room";
import { Editor } from "../Components/Editor";

const Page = () => {
return (
<Room>
<Editor />
</Room>
);
};

export default Page;

はい完成🎉

video1

ちょっと簡単すぎじゃないですかね。

おまけ

ルームの接続設定について

RoomコンポーネントのRoomProviderのidでルーム選択ができるようになっています。

/src/app/Components/Room.tsx:13

<RoomProvider id="test-room">

今回はtest-roomになっていますね。

また Liveblocks のダッシュボードから作成されたルームを管理することもできます。

image4

test-roomが作成されていますね。

またルームの id に制約をかけることもできます。

/src/app/api/liveblocks-auth/route.ts:12
liveSession.allow("room:*", liveSession.FULL_ACCESS);

このようにroom:*とするとルーム id にroom:がついていないと接続できないようになります。

今ルーム id をtest-roomにしているのでそのままでは接続できないようになっているはずです。

room:test-roomにすると再度接続できるようになるということです。

接続中の表示

ClientSideSuspensefallbackで接続中に表示されるないようを変更することができます。以下のように変更してみましょう。

/src/app/Components/Room.tsx:14
<ClientSideSuspense fallback={<div>...読み込み中</div>}>{children}</ClientSideSuspense>

video2.gif

任意のコンポーネントで指定できるので、ローディングでよくあるクルクルするやつなどを表示するのもいいかもしれないですね。

ユーザー名と色の変更

相手に表示される自分のユーザー名と色を変更できるようにしてみましょう。

以下でを設定しています。

/src/app/Components/Editor.tsx:46
user: {
name: "User",
color: "#ff0000",
},

レンダリング時に設定されてないといけないので、それも踏まえてEditorコンポーネントを編集してみましょう。

/src/app/Components/Editor.tsx
"use client";
import { useEffect, useState } from "react";
import { BlockNoteEditor } from "@blocknote/core";
import { useCreateBlockNote } from "@blocknote/react";
import { BlockNoteView } from "@blocknote/mantine";
import * as Y from "yjs";
import { LiveblocksYjsProvider } from "@liveblocks/yjs";
import { useRoom } from "@liveblocks/react/suspense";
import "@blocknote/core/fonts/inter.css";
import "@blocknote/mantine/style.css";

type EditorProps = {
doc: Y.Doc;
provider: any;
name: string;
color: string;
};

export function Editor() {
const room = useRoom();
const [doc, setDoc] = useState<Y.Doc>();
const [provider, setProvider] = useState<any>();
const [name, setName] = useState("");
const [color, setColor] = useState("");
const [showEditor, setShwoEditor] = useState(false);

useEffect(() => {
const yDoc = new Y.Doc();
const yProvider = new LiveblocksYjsProvider(room, yDoc);
setDoc(yDoc);
setProvider(yProvider);

return () => {
yDoc?.destroy();
yProvider?.destroy();
};
}, [room]);

if (!doc || !provider) {
return null;
}

if (showEditor) {
return (
<BlockNote doc={doc} provider={provider} name={name} color={color} />
);
} else {
return (
<div style={{ display: "flex", justifyItems: "center" }}>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="名前"
/>
<input
value={color}
onChange={(e) => setColor(e.target.value)}
type="color"
/>
<button onClick={() => setShwoEditor((prev) => !prev)}>接続</button>
</div>
);
}
}

function BlockNote({ doc, provider, name, color }: EditorProps) {
const editor: BlockNoteEditor = useCreateBlockNote({
collaboration: {
provider,
fragment: doc.getXmlFragment("document-store"),
user: {
name: name,
color: color,
},
},
});

return <BlockNoteView editor={editor} />;
}

名前と色を入力したらテキストを変種できるようにしたという感じですね。

video3.gif

というわけで実装できました。

おわり

今回は共同編集の実装を紹介しました。 Liveblocksはテキストの編集以外にも相手のカーソル表示やリアルタイムの通知機能など様々な機能が用意されています。 yjsについてもMap型やArray型などあり、使い方によって様々に活用できると思うので、ぜひいろいろ試していただければと思います。

今回テキストエディタはBlockNoteを使用しましたが、他のエディタでもほとんど同様に実装できるようになっています。この辺りの話はLiveblocksのドキュメントを参照してみてください。

ではまた。

· 約9分
森田 有貴

森田です。

現在開発しているプロダクトで BlockNote というリッチテキストエディタを使う機会があったのですが、これがめちゃめちゃ便利だったので今回はその紹介をしたいと思います!

BlockNote とは?

BlockNote は React で Notion のようなブロックベースのテキストエディタを実装できるライブラリです。デフォルトの状態で Notion に引けを取らない UX が設定されており、とても簡単に高クオリティなテキストエディタを実装できます。

https://www.blocknotejs.org/

実装

長々と説明するより実際に見てもらうほうが早いと思うので、早速で実装してみましょう! 今回もNextjsで行きます

サクッとプロジェクトを作成して...

$ npx create-next-app blocknote-app
✔ Would you like to use TypeScript? … No / Yes
✔ Would you like to use ESLint? … No / Yes
✔ Would you like to use Tailwind CSS? … No / Yes
✔ Would you like to use `src/` directory? … No / Yes
✔ Would you like to use App Router? (recommended) … No / Yes
✔ Would you like to customize the default import alias (@/*)? … No / Yes

必要なライブラリをインポート

npm install @blocknote/core @blocknote/react @blocknote/mantine

エディタ用のコンポーネントを作成して...

/src/app/page.tsx
"use client";

import "@blocknote/core/fonts/inter.css";
import { useCreateBlockNote } from "@blocknote/react";
import { BlockNoteView } from "@blocknote/mantine";
import "@blocknote/mantine/style.css";

export const Editor = () => {
const editor = useCreateBlockNote();
return (
<div>
<BlockNoteView editor={editor} />
</div>
);
};

ページに表示するだけ!

/src/app/page.tsx
import { Editor } from "./components/Editor";

const Page = () => {
return (
<>
<Editor />
</>
);
};

export default Page;

これだけで実装完了!

video

とっても簡単にNotionのようなテキストエディタを実装できてしまいました🎉🎉🎉

できること

blocknoteでは文字を入力できるのはもちろん、文字のサイズや色の変更、画像や動画などさまざまなコンテンツを入力表示させることができます。

image1

image2

入力欄の左の+をクリックすると入力できるものリストが表示されます。

基本的にはMarkdown記法と同様に#でHeadingにしたり、 - でリスト表示にしたりという感じで入力できます。また、+の横の点々の部分をクリックすると文字の色や背景色なども選ぶことができます。

画像や動画などはurlを入力することによって表示させることができます。

さらにBlockNoteという名前の通り、入力したコンテンツをブロックとして移動させることも簡単にできてしまいます。

video2

ものすごい機能が充実していますね😲

スタイル変更

スタイルもかなり自由に変更できるようになっています。 いくつか抜粋してご紹介します。

文字サイズ変更

初期設定では文字のサイズが大きすぎるので変更してみましょう。

.editor-custom-style .bn-editor h1 {
font-size: 18px;
}

.editor-custom-style .bn-editor h2 {
font-size: 16px;
}

.editor-custom-style .bn-editor h3 {
font-size: 14px;
}

.editor-custom-style .bn-editor {
font-size: 14px;
}

cssインポートしてクラスを指定すると...

import "./editor.css";

export const Editor = () => {
const editor = useCreateBlockNote();

return (
<div>
<BlockNoteView editor={editor} className="editor-custom-style" />
</div>
);
};

image3

いい感じのサイズに変更できました!

文字サイズ以外にも色々カスタマイズができるようになっています。

テーマ

続いてテーマを編集してましょう! 背景色を赤色にしてみます。

import { BlockNoteView, darkDefaultTheme } from "@blocknote/mantine";
import { Theme } from "@blocknote/mantine";

export const Editor = () => {
const editor = useCreateBlockNote();

const lightRedTheme = {
colors: {
editor: {
background: "red",
},
},
} satisfies Theme;

const redTheme = {
light: lightRedTheme,
dark: darkDefaultTheme,
};

return (
<div>
<BlockNoteView editor={editor} className="editor-custom-style" theme={redTheme} />
</div>
);
};

image4

むちゃくちゃ目に悪そうですね。

みてわかる通りライトテーマとダークテーマそれぞれ設定できるようになっています。 今回はライトテーマのみにカスタムテーマを当てています。

こちらもかなり自由度高くカスタマイズできるようになっています。

詳しくはこちら↓

https://www.blocknotejs.org/docs/styling-theming/themes

言語変更

デフォルトではプレイスホルダーやメニューの部分の言語が英語になっています。 日本語の設定が用意されているので変更してみましょう。

import { locales } from "@blocknote/core";

export const Editor = () => {
const editor = useCreateBlockNote({
dictionary: locales.ja,
});

return (
<div>
<BlockNoteView editor={editor} className="editor-custom-style" />
</div>
);
};

localesをインポートしてlocales.jaを設定するだけ。

image5

特に変な日本語になっている部分もないですね。

おまけ

入力した内容を取得する

textareaタグのonChange={(e) => console.log(e.target.value)}的なことをしてみましょう!

Reactのテキストエディタですからね。内容が取得できてなんぼですからね。

入力された内容はeditor.documentで取得ができます。また、inputやtextareaと同様にonChangeで入力内容の変化を感知できます。

export const Editor = () => {
const editor = useCreateBlockNote();

const onChange = () => {
console.log(editor.document);
};

return (
<div>
<BlockNoteView editor={editor} onChange={onChange} />
</div>
);
};

video3

というわけで入力が取得できました。

ただ、みてわかる通りこの実装方法ではBlockNote特有のBlock型で出力されてしまいます。

なのでマークダウンに変換して出力してみましょう!

blocksToMarkdownLossyを使ってonChangeを以下のように変更します

const onChange = async () => {
const markdown = await editor.blocksToMarkdownLossy(editor.document);
console.log(markdown);
};

video4

マークダウンで出力されるようになりました!

プログラム側から入力する

続いてプログラム側から内容を入力する方法をご紹介します。

こちらはinputやtextareaとは全く異なります。

export const Editor = () => {
const editor = useCreateBlockNote();

const markdown = `# Hello, world!`;

const loadMarkdown = async () => {
const blocks = await editor.tryParseMarkdownToBlocks(markdown);
editor.replaceBlocks(editor.document, blocks);
};

return (
<div>
<BlockNoteView editor={editor} />
<button onClick={() => loadMarkdown()}>読み込む</button>
</div>
);
};

loadMarkdown関数で入力するマークダウンの文字列をBlock型に変換し、editor.replaceBlocksで置き換えているという感じです。 

実際に動かしてみるとこんな感じ。

video5

replace以外にinsertなどさまざまな操作が可能になっています。

詳しくはこちら↓

https://www.blocknotejs.org/docs/editor-api/manipulating-blocks

おわり

今回はBlockNoteをご紹介しました。今回紹介できたのはほんの一部で他にも便利な機能が沢山用意されているので、ぜひ実際に使っていただければと思います!

公式のDiscordサーバーも活発に動いており、日々質問や不具合報告など飛び交っています。わからないことがあればこちらで聞くといいかもしれないですね。

機会があれば実際にBlockNoteを用いた機能開発も紹介したいと思っています!

ではまた。

· 約5分
森田 有貴

森田です。

先日OpenAIのWhisperを用いた文字起こしの実装を紹介しました。ですがこの文字起こしはブラウザ上で音声を録音し、それをapiに投げて文字起こししてもらうという実装でした。

しかしこれには2つの欠点があります。まず全て録音が終了するまでちゃんと文字起こしされるかわからないという点です。もう一つは録音ファイルの容量が大きくなると文字起こしに時間がかかるという点です。

今回はこの2点を解決する方法を試験的に実装できたので、そちらを紹介したいと思います。

やったこと

今回試したのは録音を特定の秒数で区切り、その都度文字起こしをかけるという方法です。 とりあえず実装してみましょう。

実装

warn

前回の記事で作成したwhipserHook.tsをそのまま使用します。

特定の秒数で区切るだけなので、whisperHook.tsにその処理を追加します。

/src/app/utils/whisperHook.ts
import { useEffect, useRef, useState } from "react";

type Hooks = {
startRecording: () => void;
stopRecording: () => void;
isAudio: boolean;
recording: boolean;
audioFile: File | null;
isLoading: boolean;
transcript: string;
};

export const useWhisperHook = (): Hooks => {
const mediaRecorder = useRef<MediaRecorder | null>(null);
const [audioFile, setAudioFile] = useState<File | null>(null);
const [isAudio, setIsAudio] = useState<boolean>(false);
const [recording, setRecording] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [transcript, setTranscript] = useState("")
const intervalRef = useRef<number | null>(null);


const handleDataAvailable = (event: BlobEvent) => {
// 音声ファイル生成
const file = new File([event.data], "audio.mp3", {
type: event.data.type,
lastModified: Date.now(),
});
setAudioFile(file);
};

const startRecording = async () => {
setAudioFile(null)
setRecording(true);
// 録音開始
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
mediaRecorder.current = new MediaRecorder(stream);
mediaRecorder.current.start();
mediaRecorder.current.addEventListener("dataavailable", handleDataAvailable);
setIsAudio(true);

intervalRef.current = window.setInterval(() => {
mediaRecorder.current?.stop();
mediaRecorder.current?.start();
}, 5000); // 5秒ごとに録音を停止して新しい録音を開始
};

const stopRecording = () => {
setRecording(false);
// 録音停止
mediaRecorder.current?.stop();
setIsAudio(false);
};

useEffect(() => {
const uploadAudio = async () => {
if (!audioFile) return;
const endPoint = "https://api.openai.com/v1/audio/transcriptions";

const formData = new FormData();
// fileを指定
formData.append("file", audioFile, "audio.mp3");
// modelを指定
formData.append("model", "whisper-1");
// languageを指定
formData.append("language", "ja");
setIsLoading(true);
const response = await fetch(endPoint, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.NEXT_PUBLIC_OPENAI_API_KEY}`,
},
body: formData,
});
const responseData = await response.json();
if (responseData.text) {
// 文字起こしされたテキスト
setTranscript(prev => prev + "\n" + responseData.text);
}
setIsLoading(false);
};
uploadAudio();
}, [audioFile]);

return {
startRecording,
stopRecording,
isAudio,
recording,
audioFile,
isLoading,
transcript
};
};

42〜45行目の部分で指定した秒数で録音を止めて開始するという処理を挟んでいます。今回は5秒で区切るようにしています。

intervalRef.current = window.setInterval(() => {
mediaRecorder.current?.stop();
mediaRecorder.current?.start();
}, 5000);

これで録音を開始してみましょう。すると...

video

定期的に区切られてリアルタイムっぽく文字起こしされてる!🎉🎉🎉

ちなみにこちらはSREチームリーダーの山田哲さんとの今日の夜ご飯何食べようねという会話を文字起こししました。

というわけで実装できました。

欠点

前回の記事でも述べたとおり、文字起こし可能な音声が録音されていない場合に変な文字列が返ってくるという不具合がかなり影響しまして、区切るタイミングを細かくするほど顕著に現れてしまいます。 また、話の途中で区切られるとうまく文章がつながらないということもあるので、精度的にも少し懸念があります。

終わり

というわけでWhisperの文字起こしをリアルタイムっぽく実装してみました。 改善の余地はたくさんありますが、ひとまず形になったので個人的には満足しています。 whisperのsdkとか出ないかなぁ。

ではまた。

· 約8分
森田 有貴

森田です。 以前 Azure Speech SDK を用いたリアルタイム文字起こしをご紹介しましたが、今回は OpenAi の Whisper を用いた文字起こしをご紹介して実際に実装までしてみたいと思います。 今回も Next js 用います。

実装

Azure の SDK を用いた文字起こしはリアルタイムでの実行が可能でしたが、Whisper では音声ファイルをアップロードし文字起こしされた文字列が返ってくるという形です。 なのでまずは録音機能から実装していきましょう。

録音機能

いつも通りサクッとプロジェクトを作成して...

$ npx create-next-app
✔ What is your project named? … whisper-sample-app
✔ Would you like to use TypeScript? … No / Yes
✔ Would you like to use ESLint? … No / Yes
✔ Would you like to use Tailwind CSS? … No / Yes
✔ Would you like to use `src/` directory? … No / Yes
✔ Would you like to use App Router? (recommended) … No / Yes
✔ Would you like to customize the default import alias (@/*)? … No / Yes

whisperHook を作成しましょう。

/src/app/utils/whisperHook.ts
import { useRef, useState } from "react";

type Hooks = {
startRecording: () => void;
stopRecording: () => void;
isAudio: boolean;
recording: boolean;
audioFile: File | null;
};

export const useWhisperHook = (): Hooks => {
const mediaRecorder = useRef<MediaRecorder | null>(null);
const [audioFile, setAudioFile] = useState<File | null>(null);
const [isAudio, setIsAudio] = useState<boolean>(false);
const [recording, setRecording] = useState(false);

const handleDataAvailable = (event: BlobEvent) => {
// 音声ファイル生成
const file = new File([event.data], "audio.mp3", {
type: event.data.type,
lastModified: Date.now(),
});
setAudioFile(file);
};

const startRecording = async () => {
setAudioFile(null)
setRecording(true);
// 録音開始
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
mediaRecorder.current = new MediaRecorder(stream);
mediaRecorder.current.start();
mediaRecorder.current.addEventListener("dataavailable", handleDataAvailable);
setIsAudio(true);
};

const stopRecording = () => {
setRecording(false);
// 録音停止
mediaRecorder.current?.stop();
setIsAudio(false);
};

return {
startRecording,
stopRecording,
isAudio,
recording,
audioFile
};
};

これでひとまず録音機能は完成です! 実際に使えるか試してみましょう。

適当なページに実装して...

/src/app/page.tsx
"use client";
import { useWhisperHook } from "./utils/whisperHook";

const Page = () => {
const { startRecording, stopRecording, recording, audioFile } = useWhisperHook();
return (
<div>
<button onClick={startRecording}>start</button>
<button onClick={stopRecording}>stop</button>
{audioFile && <audio src={URL.createObjectURL(audioFile)} controls />}
{recording && <div>recording...</div>}
</div>
);
};

export default Page;

スタートボタンとストップボタンだけの簡素な画面になればOK!

image1

スタートボタンを押すと「recording...」と表示されて録音が開始されます。

※マイク権限の許可を求められた場合は許可してあげてください。

image2

ストップボタンを押すと録音が停止されて録音した音声を聴くことができます。

image3

というわけで録音機能の実装は完了です!

文字起こし

では続いて先ほど作成したHookに文字起こしの機能を追加していきいましょう。

/src/app/utils/whisperHook.ts
import { useEffect, useRef, useState } from "react";

type Hooks = {
startRecording: () => void;
stopRecording: () => void;
isAudio: boolean;
recording: boolean;
audioFile: File | null;
isLoading: boolean;
transcript: string;
};

export const useWhisperHook = (): Hooks => {
const mediaRecorder = useRef<MediaRecorder | null>(null);
const [audioFile, setAudioFile] = useState<File | null>(null);
const [isAudio, setIsAudio] = useState<boolean>(false);
const [recording, setRecording] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [transcript, setTranscript] = useState("")

const handleDataAvailable = (event: BlobEvent) => {
// 音声ファイル生成
const file = new File([event.data], "audio.mp3", {
type: event.data.type,
lastModified: Date.now(),
});
setAudioFile(file);
};

const startRecording = async () => {
setAudioFile(null)
setRecording(true);
// 録音開始
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
mediaRecorder.current = new MediaRecorder(stream);
mediaRecorder.current.start();
mediaRecorder.current.addEventListener("dataavailable", handleDataAvailable);
setIsAudio(true);
};

const stopRecording = () => {
setRecording(false);
// 録音停止
mediaRecorder.current?.stop();
setIsAudio(false);
};

useEffect(() => {
const uploadAudio = async () => {
if (!audioFile) return;
const endPoint = "https://api.openai.com/v1/audio/transcriptions";

const formData = new FormData();
// fileを指定
formData.append("file", audioFile, "audio.mp3");
// modelを指定
formData.append("model", "whisper-1");
// languageを指定
formData.append("language", "ja");
setIsLoading(true);
const response = await fetch(endPoint, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.NEXT_PUBLIC_OPENAI_API_KEY}`,
},
body: formData,
});
const responseData = await response.json();
if (responseData.text) {
// 文字起こしされたテキスト
setTranscript(responseData.text);
}
setIsLoading(false);
};
uploadAudio();
}, [audioFile]);

return {
startRecording,
stopRecording,
isAudio,
recording,
audioFile,
isLoading,
transcript
};
};

useEffectの部分がWhisperを用いた文字起こしの機能になります。

忘れずにOpenAIのAPIキーも環境変数に設定しておきましょう。

/.env.local
NEXT_PUBLIC_OPENAI_API_KEY=

page.tsxの方も編集。

/src/app/page.tsx
"use client";
import { useWhisperHook } from "./utils/whisperHook";

const Page = () => {
const { startRecording, stopRecording, recording, audioFile, isLoading, transcript } = useWhisperHook();

return (
<div>
<button onClick={startRecording}>start</button>
<button onClick={stopRecording}>stop</button>
{audioFile && <audio src={URL.createObjectURL(audioFile)} controls />}
{recording && <div>recording...</div>}
{isLoading && <div>loading...</div>}
{transcript && <div>{transcript}</div>}
</div>
);
};

export default Page;

というわけで完成!

image4

ページ自体は先ほどと変わりませんが、録音してみると...

image5

「loading...」と表示され...

image6

文字起こしされた文章が表示されました!🎉🎉🎉🎉🎉🎉

というわけでWhisperを用いた文字起こし機能完成です!

欠点

ここまで紹介しておいて何なのですが、実はwhipserには重大な欠点があります。 それは会話が記録されていない音声ファイルを投げると変な文字列が返ってくるという点です。

実際に試していただくけるとわかると思うのですが、5秒ほどの特に会話など文字起こしする内容のない音声を録音して文字起こしをかけると「本日はご覧いただきありがとうございます。」や「ご視聴ありがとうございました。」などよくわからない文字列が返ってきてしまいます。

apiに投げる前に無音区間を削除する処理などをかませると改善するのかなぁなどと対策を考えている今日この頃です。

Whisperで文字起こしをする際には頭の片隅に覚えておいてください。

終わり

今回はNextjsでWhisperを用いた文字起こしの実装を紹介しました。

ここまで書いて思ったのですがこれNextjsでなくてReactで良かったですね。

Whisperの文字起こしの精度についてですが、他のサービスと比べてもかなり良い方だと思います。ただ複数人で話している状況ではもう一歩かなという感じもします。 先ほどあげた欠点のこともあるので、今後に期待!というところですね。

ではまた!

· 約6分
森田 有貴

最近、業務でリアルタイム文字起こし実装する機会がありまして、Nextjs と Azure の Speech SDK で実装したのでその知見を書いていこうと思います。

https://learn.microsoft.com/ja-jp/azure/ai-services/speech-service/speech-sdk

Deepgram や Open AI の Whisper、文字おこしを実装するための API や SDK は様々あります。 今回 Azure Speech SDK を選んだ理由は大きく分けて2つあり、「精度の高さ」と「話者認識」です。

Azure Speech SDK はかなり感動するレベルで高精度かつ高感度な文字起こしをしてくれます。また、公式ドキュメントには話者分離の機能についても書いてあり、これも業務上使いたい機能でした。

ちなみにDeepgramや、Whisperなどの選択肢もあったのですが、精度や話者分離の機能などの観点から見送ることになりました。

実装

APIキーの準備

まず Azure の API キーを準備しましょう。

Azure のサービスページからリソースの作成 image1

リソースの検索欄から Speech と入力 image2

こちらを選択 image3

プランを選択して作成 image4

適当に入力して image5

完了。リソースに移動して image6

キーの管理から image7

「キー1」と「場所/地域」を後で使うので保存しておきましょう。 image8

Nextjs の実装

フロントを作っていきましょう。

新しい Nextjs プロジェクトの作成

terminal
$ npx create-next-app
✔ What is your project named? … real-time-transcription
✔ Would you like to use TypeScript? … Yes
✔ Would you like to use ESLint? … Yes
✔ Would you like to use Tailwind CSS? … Yes
✔ Would you like to use `src/` directory? … Yes
✔ Would you like to use App Router? (recommended) … Yes
✔ Would you like to customize the default import alias (@/*)? … Yes
✔ What import alias would you like configured? … @/*
/real-time-transcription
$ npm i && npm run dev

image9

必要なライブラリのインストール

/real-time-transcription
$ npm i microsoft-cognitiveservices-speech-sdk

環境変数の設定

/real-time-transcription
$ vim .env.local

ここで先ほど取得した API キーを入力します。

/real-time-transcription/.env.local
NEXT_PUBLIC_AZURE_SPEECH_KEY="api key"
NEXT_PUBLIC_AZURE_SPEECH_REGION="region"

これで準備完了です。 では実際に作っていきましょう。


まず不要なものを削除していきます。

/src/app/layout.tsx
import { Inter } from "next/font/google";

const inter = Inter({ subsets: ["latin"] });

export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
</html>
);
}
/src/app/page.tsx
export default function Page() {
return <div>page</div>;
}

以下のようになれば OK image10


文字起こしコンポーネントの作成

新たに AzureSpeech コンポーネントを作成します。

/src/components/AzureSpeech.tsx
"use client";

import React, { useState, useEffect, useRef } from "react";
import * as sdk from "microsoft-cognitiveservices-speech-sdk";

const SPEECH_KEY = process.env.NEXT_PUBLIC_AZURE_SPEECH_KEY ?? "";
const SPEECH_REGION = process.env.NEXT_PUBLIC_AZURE_SPEECH_REGION ?? "";

export const AzureSpeech = () => {
const speechConfig = useRef<sdk.SpeechConfig | null>(null);
const audioConfig = useRef<sdk.AudioConfig | null>(null);
const recognizer = useRef<sdk.SpeechRecognizer | null>(null);

const [myTranscript, setMyTranscript] = useState("");
const [recognizingTranscript, setRecTranscript] = useState("");

useEffect(() => {
if (!SPEECH_KEY || !SPEECH_REGION) {
console.error("Speech key and region must be provided.");
return;
}

speechConfig.current = sdk.SpeechConfig.fromSubscription(
SPEECH_KEY,
SPEECH_REGION
);
speechConfig.current.speechRecognitionLanguage = "ja-JP";

audioConfig.current = sdk.AudioConfig.fromDefaultMicrophoneInput();
recognizer.current = new sdk.SpeechRecognizer(
speechConfig.current,
audioConfig.current
);

const processRecognizedTranscript = (
event: sdk.SpeechRecognitionEventArgs
) => {
const result = event.result;

if (result.reason === sdk.ResultReason.RecognizedSpeech) {
const transcript = result.text;
setMyTranscript(transcript);
}
};

const processRecognizingTranscript = (
event: sdk.SpeechRecognitionEventArgs
) => {
const result = event.result;
if (result.reason === sdk.ResultReason.RecognizingSpeech) {
const transcript = result.text;
setRecTranscript(transcript);
}
};

if (recognizer.current) {
recognizer.current.recognized = (
s: sdk.Recognizer,
e: sdk.SpeechRecognitionEventArgs
) => processRecognizedTranscript(e);
recognizer.current.recognizing = (
s: sdk.Recognizer,
e: sdk.SpeechRecognitionEventArgs
) => processRecognizingTranscript(e);
}
}, []);

return (
<div className="mt-8">
<div>
<div>Recognizing Transcript: {recognizingTranscript}</div>
<div>Recognized Transcript: {myTranscript}</div>
</div>
</div>
);
};

page.tsx をに AzureSpeech コンポーネントをインポートして表示します。

/src/app/page.tsx
src/app/page.tsx

import { AzureSpeech } from "./components/AzureSpeech";

export default function Page() {
return (
<div>
<AzureSpeech />
</div>
);
}

これで完成! ブラウザの設定からマイクとスピーカーを許可すると文字起こしが開始されます。 image11

Recognizing Transcript は文字起こし中の文字列が出力され、Recognized Transcript は文字起こしの内容が一区切りされたところで文を整形して出力してくれます。

ということで Azure Speech を用いた Nextjs での文字起こしの実装でした。


おまけ

冒頭で Azure Speech を選んだ理由として話者認識ができるという点を挙げました。 しかし公式ドキュメントをよく読んでみるとこんな記載が... image12

話者認識は申請をしないとできないみたいです。

というわけでワクワクドキドキで申請をしたのですが、見事に却下されました()

結局話者認識は出来ずじまいでした。

公式ドキュメントはちゃんと読みましょうね😢


おしまい。