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

· 約7分
森田 有貴

森田です。

前回の記事で blocknote と yjs と liveblocks を用いた共同編集の実装を紹介しました。

とても簡単に実装できるというは大きな利点なのですが、これは Nextjs ありきの実装となっており、react ではどう実装するのという話になってしまいます。

なので今回は React + Rails の構成で yjs と ActionCable を用いたコンフリクトフリーな共同編集を実装してみましょう!

info

Rails は開発環境構築が完了しており、5000 ポートで立ち上がる前提で進めます。 フロントは Vite + React で開発します。

実装

Rails

まずActionCableを使うにあたって必要なgemをインポートしておきます。

/config/application.rb
require "action_cable/engine"

続いて開発環境のみでどのオリジンからもActionCableに接続できるようにしておきます。 該当する行のコメントアウトを外すだけです。

/config/environments/development.rb
config.action_cable.disable_request_forgery_protection = true

では実際にActionCableを作成してみましょう。

ストリーム先を自由に選択できるようにしたいので、idによって変化するようにします。

/app/channels/sync_channel.rb
class SyncChannel < ApplicationCable::Channel
def subscribed
document_id = params[:id]
stream_from("document-#{document_id}")
end

def receive(message)
document_id = params[:id]
ActionCable.server.broadcast("document-#{document_id}", message)
end
end

React

続いてフロント側の実装をしていきます。

まず必要なライブラリをインポートしていきます。

npm i @rails/actioncable @types/rails_actioncable @y-rb/actioncable   

おやおや?

何やら怪しいライブラリが含まれていますね...

y-rb/acioncableとはなんぞや?と思われたそこのあなた。鋭いですねぇ

今回も前回と同様にyjsというフレームワークを用いてコンフリクトフリーな共同編集を実装します。 ところがyjsはjsと付いているようにjavascriptを前提に作られているフレームワークです。 なのでrailsで扱うには少し不都合なのです。

そこで登場するのがこのy-rb/actioncableです。 公式の説明を簡単に翻訳すると、「yjsクライアントとRailsのActionCableチャネルを使用してWebSocket接続を設定するために必要なJavaScriptとRubyの依存関係を提供してくれる」とのことです。

詳しくはこちら↓

https://github.com/y-crdt/yrb-actioncable

こんな便利なものがあるなら使わない手はないということでしっかり組み込んでフロントを実装していきましょう。

BlockNote関連の部分は前回の記事を参照ください。

/src/App.tsx
import { WebsocketProvider } from "@y-rb/actioncable";
import { createConsumer } from "@rails/actioncable";
import * as Y from "yjs";
import { useEffect, useState } from "react";
import { BlockNoteEditor } from "@blocknote/core";
import { useCreateBlockNote } from "@blocknote/react";
import { BlockNoteView } from "@blocknote/mantine";
import "@blocknote/core/fonts/inter.css";
import "@blocknote/mantine/style.css";

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

useEffect(() => {
const consumer = createConsumer("ws://localhost:5000/cable");
const yDoc = new Y.Doc();
const yProvider = new WebsocketProvider(yDoc, consumer, "SyncChannel", { id: "1" });
setDoc(yDoc);
setProvider(yProvider);

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

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

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

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

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.tsx:16
const consumer = createConsumer("ws://localhost:5000/cable");

@rails/actioncable"を用いてcosumerを設定しています。今回Railsを5000ポートで立ち上げているのため上記のようになっているので、ここはそれぞれの環境に合わせて書き換えてください。

/src/App.tsx:18
const yProvider = new WebsocketProvider(yDoc, consumer, "SyncChannel", { id: "1" });

そして@y-rb/actioncableを用いてproviderを設定しているという感じですね。ここのidを変更することによってストリーム先を変更することができるので、必要に応じて変数にしたりしてみてください。

というわけで実装はここまで。

フロントとバックエンドそれぞれを立ち上げてみると...

video1

完成!🎉

課題

とりあえず共同編集はできるようにはなったのですが、この実装ではリロードすると記入内容が消えてしまいます。本来であれば、ActionCable側が接続されたことを感知してdbに保存されている内容を参照して送り返すという処理を挟むべきなのですが、yjsを使っているため従来のやり方ではできないのです。 今色々と試している段階なので、何か良い解決策が見つかったらまた記事にしてお届けしたいと思います。

終わり

というわけでyjsとActionCableを用いた共同編集の実装を紹介しました。ちゃんと動く状態になるまで試行錯誤してかなり時間がかかった記憶があるのですが、コードで見てみると結構簡単に実装できますね。 かなり拡張性のあるものだと思うので、ぜひ色々いじってみてくださ。何か面白いことができたりしたら逆に教えて欲しいです。

ではまた。

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

ではまた!

· 約4分
山田 哲也

こんにちは!最近 Terraform にハマっている山田です。

Terraform を使っていて、レシピ集的なものを作ったら結構需要がありそうだなと思ったので、早速記事にしてみることにしました。

今回は、AWS Systems Manager のセッションマネージャーを使ってログイン可能な EC2 インスタンスを作成する Terraform のレシピを紹介したいと思います!

前提知識

セッションマネージャーとは

セッションマネージャーは AWS Systems Manager の機能の一つで、EC2 インスタンスに対して SSH などのポートを開けずにブラウザや AWS CLI からインスタンスにログインできる機能です。

SSH の場合ポート以外にもキーペアの管理やセキュリティグループの設定が必要ですが、セッションマネージャーを使うことでこれらの設定を省略することができて非常に便利です。また、よりセキュアに EC2 を利用することができるようになります。

AWS CLI では以下のようなコマンドを実行することで EC2 にログインすることができます。

aws ssm start-session --target <インスタンスID>

Terraform コード

プロバイダーの設定

terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 4.16"
}
}
}

provider "aws" {
region = "ap-northeast-1"
}

IAM ロールとインスタンスプロファイルの定義

ここでは IAM ロールとインスタンスプロファイルを定義しています。

ポイントは AmazonSSMManagedInstanceCore ポリシーをアタッチすることで、これによってセッションマネージャーを利用することができるようになります。

resource "aws_iam_role" "ec2_role" {
name = "sample-role"
assume_role_policy = jsonencode({
Version = "2012-10-17",
Statement = [
{
Effect = "Allow",
Principal = {
Service = "ec2.amazonaws.com"
},
Action = "sts:AssumeRole"
}
]
})
}

resource "aws_iam_role_policy_attachment" "ec2_role_policy_attach" {
role = aws_iam_role.ec2_role.name
policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}

resource "aws_iam_instance_profile" "ec2_instance_profile" {
name = "sample-profile"
role = aws_iam_role.ec2_role.name
}

EC2 インスタンスの定義

iam_instance_profile に先ほどのインスタンスプロファイルを割り当てることで、セッションマネージャーを使ってログイン可能なインスタンスを作成することができます。

resource "aws_instance" "example" {
ami = "ami-0f36779931e4e31ce"
instance_type = "t4g.medium"
iam_instance_profile = aws_iam_instance_profile.ec2_instance_profile.name

tags = {
Name = "ec2-ssm-sample"
}
}

アウトプットの設定

アウトプットでインスタンス ID を出力しておくと、terraform apply 後にインスタンス ID がコンソールに表示されるので AWS CLI でログインする際に便利です。

output "instance_id" {
value = aws_instance.example.id
}

これでコードの作成は完了です!

terraform の実行とログイン

最後に terraform apply を実行して EC2 インスタンスを作成したら、以下のコマンドでログインできるか確認してみてください。

aws ssm start-session --target <インスタンスID>

今回は内容は以上となります。次回もお楽しみに!

· 約12分
森田 有貴

今年の4月にリーディングマークに入社しもうすぐ3ヶ月が経とうとしています。初めての社会人は刺激的で、学びが多い日々を過ごしています。 今回はこの3ヶ月を振り返り、感じたことや学んだことを共有し、すこしお話的な記事をお届けします。

携わったプロジェクト

組織心理研究所サイト

組織心理研究所

4月初旬ごろまで我が社が誇る組織心理研究所のサイトの実装をさせていただきました。 実は内定者インターンの時期から携わっていたプロジェクトで、3月ごろから開発自体には携わっていました。

記事を閲覧できるページなどもあるのですが、基盤的な部分はすでに櫻田さんが実装されていて、私が担当したのはfigmaに作成されたデザインのフロントへの実装でした。

実際に開発するなかで、細かいアニメーション、パララックス、レスポンシブ対応、などなどかなり多くの課題に直面しました。 というのも今までは便利な機能を持ったウェブアプリの開発することに重点を置いていたことが多く、デザイン的によく見えるものを作ることにあまり重きを置いてきませんでした。

デザイナーさんとできるだけコミュニケーションを取り、組織心理研究所の方々の思いやデザイナーさん方のこだわりがふんだんに詰まったサイトをリリースまでやり切ることができたことは、エンジニアとして何よりもの喜びですし、今後の開発にも活きるのではないかなと思っています。

ちなみにこのプロジェクトの一員として5月に月次アワードをいただくことができました🎉

新規事業

組織心理研究所サイトの実装が終了した後、新規事業のリードエンジニアを担当させていただくことになりました。 元々、事業を立ち上げる0 -> 1の段階の開発を経験したいと言う要望を出しており、その要望が通った結果だったので、ものすごくやる気に満ち溢れていました。 新規事業とはいっても、私が参画した際はまだ決まっていることが少なく、基本的には機能検証のためのMVPの開発をしつつ本開発に向けた検証をしていくことになりました。

MVPなので製品としてリリースする際に使う使わないに関係なく、とにかく使えそうなものを片っ端から実装していくという感じで、ビジネスサイドで提案されたものをなるべく早く使える形にするというスピード感を持った開発をしていました。 決まっていないことが少ないことが逆に自由度の高い開発に繋がったので、そこは個人的にはいい環境でした。

約1ヶ月半の開発期間を経て、無事にMVPの第1弾をリリースすることができました。

ちなみにこのMVPの開発で6月にまたしても月次アワードをいただくことができました🎉

苦難と学び

今となって振り返ってみるとMVPの開発は想像を絶する大変さでした。自由度が高い環境だったことは良かったのですが、技術的や精神的に困難に直面することが数え切れないほど多くありました。何度心折れたことか。

人を頼るということ

一番苦しかったことは何よりも開発メンバーが私1人であったことです。何かわからないことがあっても他チームに気軽聞きにいけない、自分が開発しなければ新規事業自体が頓挫してしまう。そんなことを考えながら開発をしていました。

ある日このことを上長さんに相談したところ「自分が他のチームの人から相談を受けたらどう思う?」と問いかけられその瞬間に考えが全く変わりました。自分が頼られたら嬉しいなと思ったからです。

この日を境に他のチームの方にも良く相談を持ちかけるようになりました。実際相談に乗っていただくことによって解決できた問題もあったので、私が勝手にプレッシャーを感じて背負い込んでいただけでした。

無駄を減らすということ

もう一つ技術選定についても学びがありました。何か一つの機能を実装する際にどのライブラリやSSOを使うべきか、どれがパフォーマンス的に最適なのか、コスト的にはどうなのかなどもっとよく考えるべきだったなということがいくつかありました。 全てを実装して実際に使ってみるというのも一つの手ではあるのですが、時間は有限です。結局かなり手戻りが発生してしまったのでこの辺りは今後に活かしていきたいですね。

反省反省。

実装すること自体の技術的困難も数多くあったのですが、それはまた今度お話ししましょう。

これからエンジニアになる方へ

所詮3ヶ月しかエンジニアしてない私がそんなに体逸れたことも言えないのですが、今後新卒でエンジニアになった方が苦難に直面した際に役に立って欲しいアドバイスを少しだけ書きます。

信頼できる人を作る

仕事の同期や仕事とは関係ない友達など誰でもいいので信頼できる人を確保しておくことは重要だなと思います。というのも仕事の愚痴や悩みなどを心理的安全に話せるからです。 普段のイライラやモヤモヤは言葉にして吐き出すとものすごいスッキリします。本音で話すことができれば自分では思いつかなかった解決策が出てくるかもしれないですしね。

人の半分はバナナと同じ

人のDNAの半分はバナナと同じらしいですよ。人に何を言われようと強い言葉を使われようとも結局半分バナナに言われていることと思えば少し気が楽になる気がします。 自分がミスをした時も「どうせ自分半分バナナだし」と考えられれば前向きになれるような気もしますよね。 気軽に物事を捉えることで躓いた時にその場に止まらずに前進し続けられるのではないでしょうか。 自分が些細なことでも結構落ち込んでしまう性格なので最近はそう思うことでなんとか頑張っています。

大体なんとかなる

これは社会人として出なくても何にでも言えることなのですが、どんなに大変な困難に直面したとしても大体なんとかなります。なんとかなると言うよりは大変な時期はずっと続くものではないです。 想像を絶するほどの困難に直面し、時間が永遠に感じられるようなことが多々あるとはあ思うのですが、結局いつか終わります。しかも案外すぐ終わることが多いような気がします。 なので何かあってもそんなに重く考えず気楽にいきましょう。

おわり

入社してからの3ヶ月をサクッと振り返ってみました。書き出してみると書きたいことが山ほどあり、かなりいろいろあった3ヶ月だったんだなと改めて思いました。 今後また楽しいこともあれば嫌なことも経験すると思うので、楽しみながら成長していければなと思います。

では。

· 約5分
森田 有貴

唐突ですが皆様Slackはお使いでしょうか?Slack便利ですよね。私は業務のコミュニケーションは基本Slackで行っています。

このSlackで定期的にリマインドを送ってくれる機能があったら便利だと思いませんか?例えば朝の業務前に昨日やったことを送ってくれたりだとか、今日の会議の一覧を送ってくれたりなどなど...

というわけでNextjsのApi RoutesとVercelのCron Jobsを用いて簡単に実装できたので、ご紹介いたします。

warn

今回はすでにNextjsをVercelにデプロイしていることを前提とします。


Slack Api Appの準備

まずSlackのApiを利用してリマインドを送ってくれるアプリを作成します。

こちらにアクセス -> Your Apps - Slack API

Create New Appをクリック image2

From scratchをクリック image3

ここでリマインドを送ってくれるbotの名前とそのbotを使うワークスペースを設定できます。 botの名前は後からでも変更です。 image7

続いてOAuth & Permissionsからbotの権限を設定しましょう。 今回の場合はchat:writeim:writeを設定しておけば十分です。 image5

App HomeからDefault usernameを設定しておきます。 こちらを設定しないとワークスペースにインストールできないみたいです。 image8

もろもろの設定が完了したらInstallAppからワークスペースにアプリをインストールしましょう。 image6

許可するをクリック image9

これでSlackの方の設定は完了です!

OAuth & Permissionsからトークンを取得しておきましょう。

image12


Nextjs Api RoutesからSlack Apiを叩いてメッセージを送る

まず自分のslackのuser idを取得します。

左下のアイコンからプロフィールを表示 image15

三点リーダーボタンから「メンバーIDをコピー」をクリック image16 これで取得完了です。

環境変数を設定しておきましょう。

/sample-app
touch .env.local

先ほどSlack Apiで取得したトークンとuser idを貼り付けます。

/sample-app/.env.local
SLACK_BOT_TOKEN=
SLACK_CHANNEL_ID=

続いて定期実行するapiにSlackのApiを叩いてメッセージを送る機能を作成します。

/src/app/api/cron/route.ts
import { NextResponse } from "next/server";

export async function GET(request: Request) {
const slackToken = process.env.SLACK_BOT_TOKEN;

const response = await fetch("https://slack.com/api/chat.postMessage", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${slackToken}`,
},
body: JSON.stringify({
channel: process.env.SLACK_CHANNEL_ID,
text: "hoge",
}),
});

if (response.ok) {
return NextResponse.json({ message: "Success" });
} else {
return NextResponse.json({ message: "Failed" });
}
}

これで/api/cronを叩くとメッセージが飛んでくるようになります image13

結構簡単ですね


VercelのCron Jobsの設定

最後に定期実行の設定をします。 まずvercel.jsonを作成します。

/sample-app
touch vercel.json

作成したvercel.jsonに定期実行するapiのサブディレクトリと実行タイミングを設定します。 今回は毎日UTSで0時(JST9時)に実行されるように設定しました。

/sample-app/verce.json
{
"crons": [
{
"path": "/api/cron",
"schedule": "0 0 * * *"
}
]
}

こちらを設定してデプロイするとVercelのSettingsのCron Jobsに設定が反映されると思います。 image17

あとは次の日の朝9時まで待つだけです。


おわり

というわけでNextjsをVercelでSlackから定期的にリマインドを送ってくれる機能を作成しました。

ちなみに私は「次のMTGの3日前になったら前回のMTGの内容をリマインドしてくれる」という機能を作成する際に使いました。

使い方次第でもっと便利な機能が作れそうな気がしますね。

おしまい。

· 約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

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

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

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

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


おしまい。

· 約7分
矢代 祥一

こんにちは。株式会社リーディングマークエンジニアの矢代と申します。

RailsのFactoryBotっていいですよね!私は1年半ほどしかRailsの実務経験はありませんが、DjangoのFactory使ってみたり、Djangoの開発をしていると、ああ、Railsの引いてくれていたレールに脳死で乗ってしまっていた部分もあったんだなあと日々、開発者として未熟な自分を痛感する日々であります。

Leadingmarkでの勤務が1年ほどに達してDjangoでもしっかりとテストを書くようになってきまして、今回Fakerの使い方で面白い点があったので記事を書きました。

動作環境

  • MacBook Pro 13-inch, M1, 2020
  • Python 3.12
  • Django 4.2
  • factory-boy 3.3.0
  • Faker 25.2.0

今回例で使う3つのモデルとその関係

ER図

まずは書いてみた

DjangoにもRailsみたいなFactoryある!となって最初に書いたFactoryはこちら

from factory.django import DjangoModelFactory
from faker import Faker
fake = Faker('ja-JP')

class ClientFactory(DjangoModelFactory):
class Meta:
model = 'myapp.Client'

code = str(fake.random_int(min=0, max=9999)).zfill(4)
name = fake.romanized_name()
address = fake.address()

この方法のFakerの落とし穴

実際に呼び出してみるとわかる。特に着目していただきたいのが、ユニークにしたい部分があるのですが、2回作成しようとするとエラーになります

root@29ac6390e9d2:/code# python project/manage.py shell
Python 3.12.3 (main, Apr 10 2024, 11:26:46) [GCC 12.2.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
(InteractiveConsole)
>>> from myapp.factory import ClientFactory
>>> client_1 = ClientFactory()
>>> client_2 = ClientFactory()
Traceback (most recent call last):

~~~

django.db.utils.IntegrityError: (1062, "Duplicate entry '7878' for key 'myapp_client.PRIMARY'")

なぜ主キーの重複が起きるのか

これはPython特有?なのかわかりませんが、クラス直下にカラム名を記述して値を代入すると、みた感じ作成はできる(client_1 = ClientFactory()は通っているため)のですが、読み込み時に値が決定する方式らしく、読み込み時に「ABC」になったら「ABC」のまま2回目も作成しようとするのです。

一意にしたい顧客コードなのにFactoryの時点でこれはダメですね…

解決策

技術ブログなどを漁ってみたところ、LazyAttribute使えよBro!と書いてあったと思うので使ってみます。

class ClientFactory(DjangoModelFactory):
class Meta:
model = 'myapp.Client'

code = factory.LazyAttribute(lambda o: f'{fake.random_int(min=0, max=9999):04}')
name = factory.LazyAttribute(lambda o: f'{fake.name()}')
address = factory.LazyAttribute(lambda o: f'{fake.address()}')

ちゃんと作成できた

root@29ac6390e9d2:/code# python project/manage.py shell
Python 3.12.3 (main, Apr 10 2024, 11:26:46) [GCC 12.2.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
(InteractiveConsole)
>>> from myapp.factory import ClientFactory
>>> client_1 = ClientFactory()
>>> client_2 = ClientFactory()
>>> client_1.number
'6994'
>>> client_2.number
'1672'
info

後学のために公式ドキュメントを探し当てたので置いておきます https://factoryboy.readthedocs.io/en/stable/reference.html#lazyattribute

そういえば、0埋めのzfillってこういう書き方(:04)もできたなあと思い出した

この方法の所感

  • 正直、何個も関数に囲まれるのはちょっと…
  • 超細かいですがLazyAttribute始まる地点が違くてちょっと読みづらい…?
  • え、lambda使わないといけないんすか…

代替案として、Pythonのdict(辞書)にあるsetdefaultが便利でした

ここでRailsとは違った自由度の高いソリューションが見つかった(ソリューションって言ってみたかっただけ) 表題にあるとおりの関数でFactoryを作ってみると、なんと、Fakerの呼び出しにLazyAttributeがなくても、都度呼び出して値をセットしてくれるのです

from factory.django import DjangoModelFactory
import factory
from faker import Faker
fake = Faker('ja-JP')

class ClientFactory(DjangoModelFactory):
class Meta:
model = 'myapp.Client'

@classmethod
def _create(cls, model_class, *args, **kwargs):
kwargs.setdefault('code', f'{fake.random_int(min=0, max=9999):04}')
kwargs.setdefault('name', f'{fake.name()}')
kwargs.setdefault('address', f'{fake.address()}')

return super()._create(model_class, *args, **kwargs)

実行結果(ほぼ同じにはなりますが)

root@29ac6390e9d2:/code# python project/manage.py shell
Python 3.12.3 (main, Apr 10 2024, 11:26:46) [GCC 12.2.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
(InteractiveConsole)
>>> from myapp.factory import ClientFactory
>>> client_1 = ClientFactory.create()
>>> client_2 = ClientFactory.create()
>>> client_1.number
'9091'
>>> client_2.number
'3501'

setdefaultは、辞書の中にそのキーがなければ、第2引数のものを使うというメソッドで、都度呼び出しをしてくれます。 この方法なら、クラス直下宣言していたカラムたちを持ってきても特に違和感がない。というかぶっちゃけこの書き方は割と好きなだけかもしれません。 これでこのFactoryクラスは、RailsのFactoryBotに大きく近づいたのです

最後に

まだまだDjangoは修行中。書くのはノイズかな〜と思ったりした事項もあったので、その点は勉強会などでLTにしようと思います。 しばらくはDjangoのテスト関連の記事を書くと思うので、よろしければ、いいねやシェアで応援していただけると嬉しいです。 最後まで読んでいただきありがとうございました。