社内文字起こしツール:作るか買うか、そして結局は作る
api社内ツール開発者

社内文字起こしツール:作るか買うか、そして結局は作る

BMMamane B. MoussaMay 26, 2026Updated July 2, 20265 min read

Summarize this article with:

作るか買うか、率直に

チームの利用量が十分に多く、ユーザー単位課金のSaaSコストが従量課金APIと数週間のエンジニアリング時間のコストを明確に上回るなら、社内文字起こしツールを自社開発する価値があります。 その損益分岐点は通常、アクティブユーザー10〜15人あたりです。それ以下であれば、OtterやFirefliesに支払うのがほぼ常に正解です。それ以上であれば、明確なメリットが得られます。データが自社システム内にとどまり、既存のSSOでアクセスを管理でき、他社製品に合わせて業務を変えるのではなく、チームが必要とするワークフローをそのまま実現できるのです。

本ガイドでは、アーキテクチャ、自社開発と購入の判断基準、本番レベルの社内ツールに必要な中核コードを解説します。

購入がまだ勝るケース

コードを書き始める前に、ユーザー単位課金で支払っているものが実際には何なのか、正直に見極めましょう。

会議ボットによる自動キャプチャは再現が最も難しい機能です。OtterとFirefliesは、誰も録画ボタンを押し忘れないよう、ZoomやMeetの通話に自動的にボットを参加させます。これを自前で実現するには、サードパーティのrecall APIとの統合、ボットのライフサイクル管理、GDPR/免責事項への対応、会議プラットフォームのAPI変更への追従が必要です。数週間の作業と継続的なメンテナンスが求められます。

SOC 2とコンプライアンス対応も現実のコストです。エンタープライズ顧客から「社内ツールはコンプライアンスに準拠しているのか」と問われたとき、その監査責任をチームが引き受ける覚悟があるかという問題に変わります。市販ツールには独自のコンプライアンス文書が付属しますが、社内ツールにはありません。

自社開発が購入に勝つ正当なケース:

  • 会議以外由来の音声ファイル(インタビュー、フィールド録音、顧客通話、ポッドキャスト、法廷証言など)を大量に扱っている。
  • 既存のネイティブ連携ではカバーされない、内部システム(案件管理、CRM、ドキュメントストア)との深い統合が必要。
  • 音声を自社インフラ内に留めることを求める厳格なデータローカリゼーション要件がある。
  • チーム規模が大きく、ユーザー単位課金のコストが無視できない額になっている。

Otter.ai Businessは年払いで1ユーザーあたり月19.99ドル(月払いなら30ドル)です。Fireflies Businessは年払いで1ユーザーあたり月19ドル(月払いなら29ドル)です。30ユーザーだと月570〜600ドルほどになります。一方、Deepgram Nova-3のような従量課金APIは、録音済み音声で1分あたり0.0077ドルです。30人のチームが各自月1時間ずつ音声を送信した場合、文字起こしAPI費用は約14ドルです。この差は歴然です。

Otter BusinessFireflies Business社内ツール(API)
10ユーザー月200ドル月190ドルAPI費用+開発工数
30ユーザー月600ドル月570ドル通常の利用量で月50〜100ドル程度
50ユーザー月1,000ドル月950ドル通常の利用量で月80〜160ドル程度
SSO/SAMLEnterpriseプランのみEnterpriseプランのみ自前で構築
会議ボットあり(標準搭載)あり(標準搭載)サードパーティAPIが必要
データローカリゼーションベンダーのインフラベンダーのインフラ自社インフラ

ユーザー単位の価格はotter.ai/pricingおよびfireflies.ai/pricingで確認済み(2026年7月)。API費用の見積りは、録音済み音声で1分あたり0.0077ドルのDeepgram Nova-3に基づき(deepgram.com/pricingで確認済み)、ホスティングとストレージは含みません。

構築目標:アップロード、処理、配信、ポリシーに沿った保持
構築目標:アップロード、処理、配信、ポリシーに沿った保持

アーキテクチャ

最小限でも実用的な社内ツールは、次の4つの要素で構成されます。

  1. アップロードエンドポイント。 ファイルはオブジェクトストレージ(S3、R2、GCS)に送られます。
  2. 文字起こしAPI連携。 ジョブを送信し、webhookまたはポーリングで完了を受け取ります。
  3. データベース。 文字起こし結果、ジョブのメタデータ、ユーザー紐付けを保存します。
  4. 検索とダッシュボード。 ユーザー、プロジェクト、内容、日付で文字起こし結果を検索します。

シンプルに保つためのスタック選定:

  • フロントエンド:Next.jsまたは既存の社内ツールフレームワーク(迅速なプロトタイピングにはRetoolも使えます)。
  • バックエンド:Node.js、Python、GoのRESTサービス。
  • データベース:全文検索を内蔵するPostgres。
  • ストレージ:音声ファイルにはS3またはR2。
  • 認証:まずGoogle OAuth、IT部門から要望が出たらSAML/OIDC。

データモデル

中核となる3つのテーブルで基本的なユースケースをカバーします。

CREATE TABLE users (
  id UUID PRIMARY KEY,
  email TEXT UNIQUE NOT NULL,
  role TEXT NOT NULL DEFAULT 'member',
  department TEXT,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE transcription_jobs (
  id UUID PRIMARY KEY,
  user_id UUID REFERENCES users(id),
  audio_filename TEXT NOT NULL,
  audio_s3_key TEXT NOT NULL,
  external_job_id TEXT UNIQUE,
  status TEXT NOT NULL,
  language TEXT,
  duration_seconds INT,
  project TEXT,
  tags TEXT[],
  created_at TIMESTAMPTZ DEFAULT NOW(),
  completed_at TIMESTAMPTZ
);

CREATE TABLE transcripts (
  job_id UUID PRIMARY KEY REFERENCES transcription_jobs(id),
  full_text TEXT NOT NULL,
  metadata JSONB,
  summary TEXT,
  search_vector tsvector GENERATED ALWAYS AS (
    to_tsvector('english', full_text)
  ) STORED
);

CREATE INDEX idx_search ON transcripts USING gin(search_vector);
CREATE INDEX idx_jobs_user ON transcription_jobs(user_id);
CREATE INDEX idx_jobs_project ON transcription_jobs(project);

tsvectorカラムにより、別サービスを追加することなく、Postgresの全文検索で文字起こしコーパス全体を横断検索できます。非常に大規模なコーパス(数十万件の文字起こし)の場合は、MeilisearchやElasticsearchも検討する価値があります。

user_idは最初からjobsテーブルに含めてください。半年後には「誰が何をアップロードしたか」を知りたくなります。後から追加するのは苦痛です。

アップロードフロー

フロントエンドがファイルとメタデータ(プロジェクト、言語、タグ)を収集してmultipart POSTを送信します。バックエンドはS3へアップロードした後、文字起こしAPIにジョブを投入します。

import express from 'express';
import multer from 'multer';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';

const app = express();
const upload = multer({ storage: multer.memoryStorage() });
const s3 = new S3Client({ region: process.env.AWS_REGION });

app.post('/api/transcribe', requireAuth, upload.single('audio'), async (req, res) => {
  const { project, tags, language } = req.body;
  const user = req.user;

  const s3Key = `audio/${user.id}/${Date.now()}-${req.file.originalname}`;
  await s3.send(new PutObjectCommand({
    Bucket: process.env.S3_BUCKET,
    Key: s3Key,
    Body: req.file.buffer,
    ContentType: req.file.mimetype,
  }));

  const audioUrl = `https://${process.env.S3_BUCKET}.s3.amazonaws.com/${s3Key}`;

  // Replace with whichever transcription API you choose.
  // This example uses a URL-based submission (no re-upload needed).
  const apiRes = await fetch('https://api.example-transcription.com/v1/transcribe', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${process.env.TRANSCRIPTION_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      source: 'url',
      input_url: audioUrl,
      language: language || 'auto',
    }),
  });

  const { job_id: externalJobId } = await apiRes.json();

  const result = await db.query(
    `INSERT INTO transcription_jobs
     (user_id, audio_filename, audio_s3_key, external_job_id, status, language, project, tags)
     VALUES ($1, $2, $3, $4, 'queued', $5, $6, $7) RETURNING id`,
    [user.id, req.file.originalname, s3Key, externalJobId,
     language || 'auto', project, tags ? JSON.parse(tags) : []]
  );

  res.status(201).json({ job_id: result.rows[0].id });
});

Webhookハンドラー

Webhookは、ユーザー体験とサーバー負荷の両面でポーリングより優れています。文字起こしAPIが完了すると、署名付きでエンドポイントにPOSTします。ペイロードを信用する前に、その署名を検証します。

ヘッダー名と署名形式はAPIごとに異なります。ハンドラーを書く前に、採用するベンダーのドキュメントで必ず確認してください。多くのAPIで使える汎用パターンは次のとおりです。

app.post('/api/webhooks/transcription',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    // Verify HMAC signature, header name varies by vendor.
    // Common patterns: X-Webhook-Signature, X-Signature-256, etc.
    const sigHeader = req.header('X-Webhook-Signature') || '';
    const sigValue = sigHeader.replace('sha256=', '');
    const expected = crypto
      .createHmac('sha256', process.env.WEBHOOK_SECRET)
      .update(req.body)
      .digest('hex');

    if (!crypto.timingSafeEqual(Buffer.from(sigValue), Buffer.from(expected))) {
      return res.status(401).send('Invalid signature');
    }

    const event = JSON.parse(req.body.toString());

    if (event.event === 'job.completed') {
      const { job_id: externalJobId, result_url, duration } = event.data;

      // Fetch the actual transcript from the result URL.
      const transcriptRes = await fetch(result_url, {
        headers: { 'Authorization': `Bearer ${process.env.TRANSCRIPTION_API_KEY}` },
      });
      const transcriptData = await transcriptRes.json();

      await db.query(
        `UPDATE transcription_jobs
         SET status = 'completed', duration_seconds = $1, completed_at = NOW()
         WHERE external_job_id = $2`,
        [duration, externalJobId]
      );

      await db.query(
        `INSERT INTO transcripts (job_id, full_text, metadata, summary)
         SELECT id, $1, $2, $3 FROM transcription_jobs WHERE external_job_id = $4
         ON CONFLICT (job_id) DO UPDATE
           SET full_text = EXCLUDED.full_text,
               metadata = EXCLUDED.metadata,
               summary = EXCLUDED.summary`,
        [
          transcriptData.text,
          JSON.stringify(transcriptData.metadata || {}),
          transcriptData.summary || null,
          externalJobId,
        ]
      );
    }

    res.sendStatus(200);
  }
);

ここで押さえるべき点は2つです。署名がない場合や不正な場合はフェイルクローズすること(ペイロードを処理しない)、そして200を素早く返すことです。時間のかかる処理はキューにオフロードしてください。Webhookとポーリングのトレードオフについて詳しくは、文字起こしにおけるwebhook vs ポーリングを参照してください。

全文検索

Postgresの全文検索でコーパス全体を検索し、一致箇所のスニペットを表示します。

app.get('/api/search', requireAuth, async (req, res) => {
  const { q, project, user_id, from, to } = req.query;

  const result = await db.query(`
    SELECT
      j.id, j.audio_filename, j.created_at, j.project,
      u.email AS user_email,
      ts_headline(
        'english', t.full_text,
        plainto_tsquery('english', $1),
        'StartSel=**, StopSel=**, MaxWords=30'
      ) AS snippet
    FROM transcripts t
    JOIN transcription_jobs j ON j.id = t.job_id
    JOIN users u ON u.id = j.user_id
    WHERE t.search_vector @@ plainto_tsquery('english', $1)
      AND ($2::text IS NULL OR j.project = $2)
      AND ($3::uuid IS NULL OR j.user_id = $3::uuid)
      AND ($4::timestamptz IS NULL OR j.created_at >= $4)
      AND ($5::timestamptz IS NULL OR j.created_at <= $5)
    ORDER BY ts_rank(t.search_vector, plainto_tsquery('english', $1)) DESC
    LIMIT 50
  `, [q, project || null, user_id || null, from || null, to || null]);

  res.json(result.rows);
});

search_vectorGINインデックスにより、数万件規模の文字起こしコーパスでも高速に動作します。スニペットは一致箇所を文脈付きで表示するため、検索が実際に使いやすくなります。

アクセス制御

ほとんどのチームは次の2つのパターンでカバーできます。

部門スコープ: 各ユーザーは自分の部門の文字起こし結果のみ閲覧できます。jobsテーブルにdepartmentカラムを追加し、すべてのクエリをリクエストユーザーの部門でフィルタリングします。シンプルで、設定ミスも起きにくい方式です。

プロジェクトスコープ: ユーザーはプロジェクトに所属し、所属プロジェクトのすべての文字起こし結果を閲覧できます。より柔軟ですが、結合テーブルが必要です。

CREATE TABLE user_projects (
  user_id UUID REFERENCES users(id),
  project TEXT NOT NULL,
  role TEXT NOT NULL DEFAULT 'member',
  PRIMARY KEY (user_id, project)
);

以降のクエリはuser_projectsと結合して可視性を強制します。これは必ずサーバーサイドで強制してください。クライアントコードでは決して行わないこと。 フロントエンドのフラグはヒントであってゲートではありません。

20人を超えるチームでは、SSOの計画を始めてください。ほとんどのIT部門は、機密性の高い音声を扱う社内ツールに対して、いずれIdP(Okta、Azure AD、Google Workspace)との統合を要求します。最初からユーザーストアを認証方式から切り離して設計しておけば、後からSAMLアダプターを追加してもデータモデルを作り直す必要はありません。

ダッシュボードUI

実用の9割を満たすには、次の5つの画面が必要です。

  • プロジェクト、タグ、言語フィールドを備えたアップロードフォーム
  • 最近の文字起こし、ステータス、クイック操作(表示、ダウンロード、削除)を表示するジョブ一覧
  • 全文、話者ラベル、タイムスタンプ、コピー/ダウンロードボタンを備えた文字起こしビュー
  • 全文検索クエリと、プロジェクト・ユーザー・期間でのフィルタードロップダウンを備えた検索ページ
  • コストの帰属先把握やプランサイズ決定のため、ユーザー別・部門別の利用状況を表示する管理者ビュー

APIがすでにあるなら、RetoolやInternal.ioを使えばダッシュボードは午後ひとつで完成します。非技術系のチームメイトでも快適に使える洗練されたUIを目指すなら、Next.jsでの自作に1週間を見積もってください。

データ保持とストレージコスト

保持ポリシーはリリース前に決めてください。リリース後ではありません。 音声ファイルは大きく、文字起こしテキストは小さくて済みます。128kbpsの60分インタビューは約60MBです。同じインタビューのテキスト文字起こしは100KB未満です。

現実的なデフォルトは、音声を30〜90日間保持し(人が後から確認しに行ける長さでありながら、ストレージコストを抑えられる長さ)、文字起こしテキストは無期限に保持することです。法務、医療、金融のコンプライアンス要件では、より長い音声保持期間が必要になる場合があります。保持期間をワークスペースまたは部門レコード上の設定可能なフィールドとして実装すれば、チームごとに異なるポリシーを設定できます。

手動削除に頼ってはいけません。スケジュール実行されるクリーンアップジョブを書き、保持期間が切れた音声ファイルを削除しましょう。

社内ツールのコストモデルが成立するには、ストレージを安く抑える必要があります。Cloudflare R2とBackblaze B2は、純粋なストレージと下り転送量においてS3より大幅に安価です。音声の量が多いなら検討する価値があります。

後から追加する価値のある機能

SlackやNotionへの通知。 文字起こしが完了したら、要約をプロジェクトチャンネルに自動投稿します。Slackならチャンネルごとのwebhook URLだけで済み、アプリ審査も不要です。文字起こしとSlackの連携文字起こしとNotionの連携を参照してください。

監査ログ。 誰がいつどの文字起こしにアクセスしたか。規制業界では重要で、どんな組織でも役立ちます。書き込みだけでなく読み取りも記録するaccess_logテーブルを追加しましょう。

カスタム出力テンプレート。 文字起こしAPIが返す内容以上の出力(整形された議事録、案件サマリー、営業通話分析など)を得るため、文字起こしテキストに対して独自のプロンプトを実行します。大規模プロジェクト向けのバッチ処理パターンについては、大規模プロジェクト向けバッチ文字起こしを参照してください。

話者ラベルのための音声登録。 APIが話者分離(speaker diarization)に対応していれば、チームメンバーの短い音声サンプルを登録することでラベル精度を向上させられます。「Speaker 0 / Speaker 1」が自動的に実名に変わります。

TOTPまたはハードウェアキーによる2FA。 機密性の高い音声が社内ツールに置かれるようになったら、認証層が重要になります。本当に機密性の高いものを扱い始める前に、第二要素を追加しておきましょう。

よくある落とし穴

「シンプルな」プロジェクトだからとデータベースを省略する。 JSONファイルやスプレッドシートはごく小規模なら動きますが、50件の文字起こしで破綻します。最初からPostgresを使ってください。全文検索だけでも導入する価値があります。

最初からjobsテーブルでuser_idを追跡しない。 半年後には、コスト帰属、コンプライアンス、デバッグのために「誰が何を文字起こししたか」を知りたくなります。後から追加するとマイグレーションが必要になり、過去データに欠落が生じます。

言語情報の追跡漏れ。 多言語チームには必須です。送信時に取得し、ジョブに保存し、検索フィルターに含めましょう。文字起こし精度はAPIによって言語ごとに大きく異なります。

会議ボットによる自動キャプチャを自前で作り込む。 思った以上に難しい作業です。主なユースケースがZoomやMeetの通話録音なら、後でラッパーを作るとしても、まずOtterやFirefliesを評価してください。

文字起こし結果がwebhookペイロードに入っていると思い込む。 ほとんどの本番API(ここでモデル化したものを含む)は、全文ではなくresult_urlをwebhookで送ってきます。ハンドラーは署名検証後に、結果を取得する第2ステップを実装する必要があります。

現実的な次のステップ

最小構成(アップロード+webhookハンドラー+文字起こしビュー)を1日で立ち上げましょう。検索やダッシュボードの磨き込みの前に、チームメンバー3〜4人に使ってもらいます。最初の実際のユーザーが、次に何を作るべきかを教えてくれます。

何も構築せずにきれいな文字起こしだけが必要なら、ConvertAudioToTextがブラウザ上で直接ファイルアップロードを処理します。手軽なテストにはアカウント登録も不要です。

よくある質問

社内文字起こしツールの運用コストと、1ユーザーあたり課金のSaaS利用コストはどれくらい違いますか?

30ユーザーの場合、Otter.ai Businessは月約600ドル、Fireflies Businessは月約570ドルです。従量課金API(1分あたり0.0077ドルのDeepgram Nova-3など)を基盤とした社内ツールなら、コストは実際の使用量に応じて変わります。1人あたり月平均1時間の音声を扱う30人のチームでは、API費用は約14ドルで、そこにホスティングとストレージのコストが加わります。自社開発が有利になる損益分岐点は、通常アクティブユーザー10〜15人あたりです。

チーム向け文字起こしツールに最適なアクセス制御パターンは何ですか?

ほとんどのケースは2つのパターンでカバーできます。部門スコープ:各ユーザーは自分の部門の文字起こし結果のみ閲覧でき、jobsテーブルのdepartmentカラムとフィルタリング済みクエリで強制します。プロジェクトスコープ:ユーザーは所属する任意のプロジェクトに文字起こしを登録でき、結合テーブルで強制します。プロジェクトスコープの方が柔軟で、後の拡張も容易です。どちらもクライアント側のフラグではなく、サーバーサイドでの強制が必要です。

社内文字起こしツールの構築にSSOは必要ですか?

最初は不要ですが、計画に入れておいてください。20ユーザーを超える新しい社内ツールが別個のパスワードを必要とする場合、ほとんどのITチームは導入をブロックします。認証層は、初日からユーザーストアを認証方式から切り離して設計しましょう。Google OAuthは素早く提供でき、多くのチームをカバーします。SAML/OIDC(Okta、Azure ADなど向け)は、データモデルに触れることなく、後からアダプターとして追加できます。

ストレージコストを抑えるため、音声ファイルの保持はどう扱うべきですか?

ポリシーは初日に設定します。後回しにしないこと。音声ファイルは大きく、文字起こしテキストは小さくて済みます。現実的なデフォルトは、音声を30〜90日間保持し、文字起こしテキストは無期限に保持することです。規制業界(医療、法務)ではより長い音声保持期間が必要になる場合があるため、保持期間はワークスペースまたは部門ごとに設定可能にしておきましょう。手動削除に頼ってはいけません。

出典

Try transcription free

Convert any audio or video to clean, unwatermarked text — speaker labels, timestamps, and AI summaries included. First 10 minutes free, no account.

Related Articles