NewSQLデータベースのTiDB Cloudを今すぐ体験!新規サインアップキャンペーン開催中 詳細を見る
tidb_feature_1800x600-1-5

※このブログは2026年1月30日に公開された英語ブログ 「How to Build a Voice-to-Text App That Learns Your Style (Without Storing Your Words)」 の拙訳です

私は早口なのですが、既存のツールはどれも画一的で、まるで味気ないJIRAチケットのように事務的に扱ってきます。そこでこの問題を解決するため、Chrome拡張の開発に取り組み、Speak Itを作りました。Speak Itは、ユーザーの声そのものを記録することなく、ユーザーの文体や話し方のスタイルを学習する音声認識アプリです。

プライバシーを最優先するAIを活用し、本システムは音声の「フィンガープリント」をマッピングします。具体的には、文の丁寧さや長さといった要素に着目し、話した内容そのものを保存することはありません。TiDBベクトル検索技術により、データが一切収集されないことを保証することで、最も厳格な企業の法務チームでさえも満足させる、パーソナライズされたフォーマットを提供します。

このブログでは、SlackからGmailまであらゆるプラットフォームに合わせてあなたの声を変換する書き起こしツールの構築方法を詳しく解説します。また技術スタック全体に加え、実際のメッセージを一切保存せずに個人の文章スタイルを学習する「統計的フィンガープリント」のロジックについてもご紹介します。

技術スタック:TiDB、Claude、Deepgram

使用している技術とその理由:

  • Chrome拡張機能:このアプリは特定のプラットフォーム専用ではなく、あらゆるウェブサイトで動作する必要がありました。Gmail、Slack、Notion、Twitterなどあらゆる場所にマイクボタンを挿入する唯一の手段がブラウザ拡張機能でした。
  • Web Speech API + Deepgram:ChromeとEdgeは無料でWeb Speech APIをサポートしています。非対応ブラウザ (Arc、Safari、Firefox) についてはDeepgramのストリーミングAPIにフォールバックします。これにより、幅広い互換性を維持しつつ、大多数のユーザーに対してコストを抑えることができます。
  • TiDB Cloud Starter:通常の業務データ用とベクトルデータ用に、2つのデータベースを別々に運用したくありませんでした。幸いTiDBはベクトルデータと業務データを1つのデータベースで扱うことが可能です。さらにMySQL互換のため、既存の知識をそのまま使えるうえ、アイドル時はゼロまでスケールするため、未使用のリソースにコストをかけずにすみます。
  • Claude Sonnet 4:フォーマットエンジンとしてClaude Sonnet 4を採用しました。生の文字起こしデータを文脈やスタイル指示に基づき再構成します。Sonnetは制約事項を非常によく守りつつ、過剰な書き換えを行わない点が優れており、この文脈では極めて重要な特性です。
  • OpenAI Embeddings:埋め込みにはOpenAIのtext-embedding-3-smallを使用します。文章スタイルのサンプルをベクトル表現に変換し、スタイルの分類に必要な類似性マッチングを支えています。

アーキテクチャ:コンテンツを保存しないパーソナライズ

以下は、本システムにおけるデータの流れです。

[ユーザーの発声] 
      ↓
[Deepgram / Web Speech API]
      ↓
[文字起こしテキスト]
      ↓
[コンテキスト検出:Gmail? Slack? Twitter?]
      ↓
[TiDBからスタイルプロファイルの取得]
      ↓
[Claude がスタイルとコンテキストを利用して内容を変換]
      ↓
[ユーザーが提案を受け入れ、または拒否]
      ↓
[受け入れられたテキストから統計情報を抽出]
      ↓
[TiDBにあるスタイルプロファイルを更新]
      ↓
[類似性マッチングのために埋め込みを生成]

重要なアーキテクチャ上の決定は、内容ではなく統計情報を保存することでした。ここでは、スタイルプロファイルに含まれる項目を示します:

フィールドデータ型
avg_sentence_lengthfloat14.2
formality_scorefloat (0-1)0.35
uses_contractionsbooleantrue
greetingsJSON 配列[“Hey”, “Hi there”]
signoffsJSON 配列[“Thanks”, “Cheers”]
top_phrasesJSON 配列[“sounds good”, “let me know”]

ここにある内容はどれも、メッセージの本質ではありません。これはあなたが何を書き記すかではなく、あなたのメッセージのフィンガープリントを捉えたものです。

企業の顧客は、内部通信を保存するようなツールには一切手を出しません。この制約が、あらゆる設計判断の指針となりました。

Gmail、Slack、Xにおけるリアルタイムな文脈検知の実装

プラットフォームによって作法は異なります。例えばLinkedInはXに比べてずっとフォーマルな傾向にあります。またSlackのメッセージはメールのような堅い文章であっては違和感が生じます。そこで私が最初に行ったのは、ユーザーがどこで入力するかを把握することでした。

コンテキスト検出

この拡張機能は、現在のURLを既知のパターンと照合し、さらにプラットフォーム固有のDOMセレクタを検索することで、アクティブなテキストフィールドを特定します:

const CONTEXT_PATTERNS = {
  email: {
    urls: [/mail\.google\.com/, /outlook\.live\.com/, /outlook\.office\.com/],
    selectors: [
      '[aria-label="Message Body"]',
      '[role="textbox"][aria-multiline="true"]',
      'div[contenteditable="true"][g_editable="true"]',
    ],
  },
  slack: {
    urls: [/\.slack\.com/],
    selectors: [
      '[data-qa="message_input"]',
      '.ql-editor',
      '[contenteditable="true"][data-message-input]',
    ],
  },
  twitter: {
    urls: [/twitter\.com/, /x\.com/],
    selectors: [
      '[data-testid="tweetTextarea_0"]',
      '[role="textbox"][data-testid]',
    ],
  },
  // ... 20+ contexts total
};

この検出処理は、あらゆるフォーマット処理が行われる前に実行されます。検出された文脈によって、Claudeがどのようにテキストを書式設定するか、およびプラットフォームごとに最適化されたどのような指示をClaudeに与えるかが決定されます。

例えば、X (旧Twitter) のフォーマットでは、簡潔さを保ち、フォーマルな挨拶は削除されます。一方、メール向けのフォーマットでは、署名部分を保持し、段落分けを追加します。Slackはこれらの中間的な位置付けとなります。

TiDBにおけるプライバシー重視のスタイルプロファイル用のスキーマの設計

スタイルプロファイルはTiDBに存在します。テーブル構造は以下の通りです:

CREATE TABLE user_style_profiles (
user_id VARCHAR(255) PRIMARY KEY,
avg_sentence_length FLOAT DEFAULT 12,
formality_score FLOAT DEFAULT 0.5,
uses_contractions BOOLEAN DEFAULT TRUE,
top_phrases JSON,
greetings JSON,
signoffs JSON,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

見ての通り、message_content (メッセージ本文) のカラムは存在しません。何を書いたかではなく、どのように書くかを保存しているのです。

Formality_score (フォーマルさ) は0 (非常にカジュアル) から1 (非常にフォーマル) までの範囲で設定されています。文の長さ、句読点のパターン、語彙選択といった指標から算出されます。例えば、「やあ!ちょっと質問なんだけど、それ送れる?」と書く人は、「いつもお世話になっております。資料の件で追ってご連絡いたしました。」と書く人よりもスコアが低くなります。

プロファイルの取得は、次のようなシンプルなクエリで行われます:

async function getUserStyleProfile(userId: string): Promise<StyleProfile | null> {
  const [rows] = await connection.execute<RowDataPacket[]>(
    `SELECT avg_sentence_length, formality_score, uses_contractions,
            top_phrases, greetings, signoffs
     FROM user_style_profiles WHERE user_id = ?`,
    [userId]
  );
  
  if (rows.length === 0) return null;
  
  const row = rows[0];
  return {
    avg_sentence_length: row.avg_sentence_length || 12,
    formality_score: row.formality_score || 0.5,
    uses_contractions: row.uses_contractions !== false,
    top_phrases: row.top_phrases ? JSON.parse(row.top_phrases) : [],
    greetings: row.greetings ? JSON.parse(row.greetings) : ["Hey"],
    signoffs: row.signoffs ? JSON.parse(row.signoffs) : ["Thanks"],
  };
}

新規ユーザーには適切なデフォルト設定が適用されます。フォーマット提案を承認するか却下するかによって、プロファイルは更新されていきます。

プロンプトエンジニアリング:スタイル統計データをClaudeへの指示に変換する

スタイルプロファイルはプロンプト指示文へと変換されます。Claudeは過去の履歴を参照しているわけではなく、与えられた制約事項として解釈しています。

function buildStylePrompt(profile: StyleProfile | null, context: string): string {
  if (!profile) {
    return `Format this transcript for ${context}. Keep it natural and conversational.`;
  }

  const formality = profile.formality_score > 0.7 ? "formal" :
                    profile.formality_score < 0.3 ? "casual" : "balanced";

  const contractionNote = profile.uses_contractions
    ? "Use contractions naturally (don't, won't, can't)."
    : "Minimize contractions for a more formal tone.";

  const greetingNote = profile.greetings.length > 0
    ? `Preferred greetings: ${profile.greetings.slice(0, 3).join(", ")}`
    : "";

  const signoffNote = profile.signoffs.length > 0
    ? `Preferred sign-offs: ${profile.signoffs.slice(0, 3).join(", ")}`
    : "";

  return `Format this transcript for ${context}.

User's writing style:
- Tone: ${formality}
- Average sentence length: ~${Math.round(profile.avg_sentence_length)} words
- ${contractionNote}
${greetingNote ? `- ${greetingNote}` : ""}
${signoffNote ? `- ${signoffNote}` : ""}

Rules:
1. ONLY add punctuation and paragraph breaks
2. Remove filler words: um, uh, like, basically, you know
3. Keep EVERY other word exactly as they said it
4. Do NOT rewrite, rephrase, or "clean up" their language`;
}

文末にあるルールは極めて重要です。これらがなければ、Claudeはユーザーの言葉を「改善」してしまいます。しかし人々は自分の声を別ものに置き換えられたいのではなく、ただ「整えて」ほしいだけなのです。この両者には、明確な違いがあります。

Privacy-first AI that keeps a user's words without replace the voice.

各コンテキストには、それぞれのプラットフォーム固有の指示も追加されます:

function getContextInstructions(context: string): string {
  switch (context) {
    case "email":
      return `Email format:
- Add punctuation and paragraph breaks
- Keep their exact words
- Add sign-off if missing`;

    case "slack":
      return `Slack format:
- Keep it brief and casual
- No formal greetings needed
- Okay to use shorter sentences`;

    case "twitter":
      return `Twitter/X format:
- Add punctuation only
- Keep their exact words
- If over 280 characters, don't trim`;
    
    // ... more contexts
  }
}

スタイルプロファイルとコンテキストに応じた指示の組み合わせにより、Claudeは余計な改変をすることなく、適切に文章を整形するために必要なガイダンスを得ることができるのです。

学習ループ:加重平均を用いたスタイルの適応

ここがまだ試行錯誤を重ねている部分です。

ユーザーがフォーマットの提案を承認または拒否した際、プロファイルを更新したいと考えています。最初に考えた単純なアプローチは、新しいサンプルデータで統計値をそのまま上書きしてしまうことでした。

しかし、それは誤りでした。

数ヶ月間アプリを使用し、数百件の承認済みフォーマットがプロファイルに反映されているユーザーの場合、たった一つの新しいサンプルによって統計が劇的に変動すべきではありません。プロファイルが成熟するにつれ、新しいサンプルの影響力は小さくしていく必要があります。

その解決策が加重平均です。新しいサンプルを移動平均に対して一定の割合だけ反映するが、その割合は段階的に減少していく手法をとっています。

function updateStyleProfile(
  existingProfile: StyleProfile,
  newStats: TextStats,
  sampleCount: number
): StyleProfile {
  // Weight decreases as sample count increases
  // First sample: 100% weight. 100th sample: ~1% weight.
  const weight = 1 / (sampleCount + 1);
  
  return {
    avg_sentence_length: 
      existingProfile.avg_sentence_length * (1 - weight) + 
      newStats.avg_sentence_length * weight,
    formality_score:
      existingProfile.formality_score * (1 - weight) +
      calculateFormality(newStats) * weight,
    // ... other fields
  };
}

フレーズ、挨拶、そして結びの言葉については、単に使われたかだけではなく、出現頻度をトラッキングしています。一度しか使わない挨拶と頻繁に使う挨拶を同等に扱うべきではないからです。

また、承認されたフォーマットに対して埋め込みベクトルも生成しています:

const embeddingResponse = await openai.embeddings.create({
  model: "text-embedding-3-small",
  input: `Sentence length: ${stats.avg_sentence_length}. ` +
         `Formality: ${stats.formality_score}. ` +
         `Context: ${context}. ` +
         `Contractions: ${stats.uses_contractions}`,
});
const styleEmbedding = embeddingResponse.data[0].embedding;

ここでの考え方は、似たような文章スタイルをまとめることにあります。自分と似た書き方をするユーザーがいれば、その人のフォーマット設定は自分にとっても好ましいものである可能性が高いからです。しかし正直に言うと、この機能はまだ完全には実装されていません。埋め込みの生成までは行っていますが、これを使ってレコメンデーションのためのクエリを発行するまでには至っていないのが現状です。

これが次のイテレーションの課題です。

結論:クロスプラットフォーム対応・プライバシー第一の音声認識アプリ

現在の機能:

  • 20以上のプラットフォームで音声認識を実現 (Gmail、Slack、Notion、Twitter、LinkedIn、GitHubなど)
  • 自動文脈検出:手動切り替え不要
  • 出力フォーマットに影響するスタイルプロファイル
  • 統計情報のみ保存、内容を保存しないプライバシー最優先設計

今後の予定:

  • スタイル分類のためのベクトル類似性 (「あなたと似た書き方のユーザーは…を好みます」)
  • プロファイル更新のための洗練されたフィードバックループ
  • 英語以外の多言語サポート
  • 対応ブラウザの拡大 (Firefoxアドオン)

無料版のコードはこちらから入手可能です。

公開されたソースと導入方法:独自の文字起こしツールを構築する

このアプリ開発から得た主な知見:パーソナライズに監視は不要です。コンテンツの内容を学習することなく、パターンは学習できます。統計的フィンガープリントは、実際のコンテンツをデータベースに一切保存せずに、動作をカスタマイズするのに十分なシグナルを得られます。

プライバシーが絶対条件となる企業向けユースケースでも、このアプローチならコンテンツベース学習では閉ざされた扉を開くことができます。

類似のものを構築したい場合、TiDB Cloudを使えば十分に試すことができます。リレーショナルテーブル (ユーザープロフィール用) とベクトル検索 (スタイル類似性用) を単一データベースの組み合わせは、アーキテクチャを劇的にシンプルにしてくれました。

Start for Free

Have questions? Let us know how we can help.

Contact Us

TiDB Cloud Dedicated

TiDB Cloudのエンタープライズ版。
専用VPC上に構築された専有DBaaSでAWSとGoogle Cloudで利用可能。

TiDB Cloud Starter

TiDB Cloudのライト版。
TiDBの機能をフルマネージド環境で使用でき無料かつお客様の裁量で利用開始。