録音ボタンを押して三十秒ほど話し、返ってきた評価の JSON を眺めていた晩のことです。文字起こしは想像よりずっと正確で、点数の付き方も納得できるものでした。手が止まったのは、同じ音声をもう一度投げた三回目に、バックエンドが 500 を返したときです。原因はモデルでも回線でもなく、私が書いたほんの十数文字の正規表現でした。
面接練習のアプリは、仕組みだけを見れば素直です。回答を録音して、文字に起こして、評価を返す——三段しかないパイプラインなのです。厄介なのは、その三段が毎回同じように動くかどうかのほうで、実際につまずくのはたいてい端の処理でした。応答の形がわずかに揺れたとき。無料枠を数える場所を間違えたとき。録音ライブラリが世代交代したとき。
ここでは Rork Max で AI 面接コーチアプリを組み立てる手順を、私が踏んだ穴と、その穴を塞いだ実測と一緒に書き残します。録音まわりは 2026 年時点で使うべきライブラリが入れ替わっており、無料枠の数え方にも直しておきたい欠陥が一つあります。
なぜ「AI 面接コーチ」は個人開発者にとって狙い目なのか
まず正直に言っておくと、私がこの記事を書こうと思ったのは、同カテゴリのアプリがすでに App Store でそれなりの売上を上げているのを確認したからです。「Interview Prep AI」「Mock Interview Coach」といった名前のアプリが、月額 ¥1,000〜¥3,000 の価格帯でしっかりと課金されています。
市場として面白い点が3つあります。
まず、ニーズが普遍的かつ繰り返し発生すること。就職・転職は人生の中で複数回訪れるイベントで、毎年新卒採用シーズンという需要の波があります。次に、課金に対する心理的抵抗が低いこと。面接対策への投資は、ゲームや娯楽アプリに比べて心理的なハードルがはるかに低く、月額 ¥1,000 程度であれば「就職できれば安いもの」と受け取っていただけるようです。そして、競合が意外と少ないこと。英語圏のアプリは多いものの、日本語に特化した高品質なものはまだ限られています。
個人開発者が参入する余地は十分にあります。
アーキテクチャ設計:3ステップパイプラインの全体像
アプリの核となるのは、次の3段階のパイプラインです。
ステップ1: 音声録音 ユーザーが面接の質問を聞き(テキスト表示または TTS 音声)、マイクに向かって回答を話します。録音された音声ファイルは、いったん端末に保存します。
ステップ2: 文字起こし 録音した音声を OpenAI の Whisper API に送信し、テキストに変換します。日本語の認識精度は非常に高く、実用に十分なレベルです。
ステップ3: AI 評価
文字起こしされたテキストを Claude の Messages API に送信し、構造化された評価フィードバックを取得します。評価軸は「内容の適切さ」「具体性」「論理構成」「言語表現」の4軸で設計します。モデル ID はここでは claude-sonnet-4-6 を使っていますが、評価の粒度を上げたい場合は上位モデルへ、コストを抑えたい場合は Haiku 系へ差し替えられる箇所です。どのモデルでも、後述する応答の受け取り方だけは変えないでください。
バックエンドは Cloudflare Workers を使い、API キーをクライアントに露出させずに処理します。データの永続化には Supabase を使います。
[Rork Max アプリ]
↓ 音声ファイル (m4a/mp3)
[Cloudflare Workers]
↓ 転送
[Whisper API (OpenAI)]
↓ テキスト
[Claude Messages API (Anthropic)]
↓ 評価JSON
[Supabase] ← セッション履歴を保存
↓
[Rork Max アプリ] ← フィードバックUI表示
Rork Max でバックエンドを別に用意するのが面倒に感じるかもしれませんが、音声ファイルの処理とAPIキーの管理をクライアント側でやることは、セキュリティと App Store のポリシー上、避けるべきです。Cloudflare Workers は数十行のコードで動くシンプルなバックエンドとして機能します。
Step 1: Rork Max で音声録音機能を実装する
最初に、ライブラリの世代交代の話を挟ませてください。生成に任せると、録音の実装はほぼ確実に expo-av の Audio.Recording で出てきます。学習データに残っている書き方がそれだからです。ところが expo-av の Audio は SDK 53 で非推奨となり、後継として expo-audio が用意されました。SDK 54 のドキュメントでは、expo-av 自体が SDK 55 で取り除かれる予定だと案内されています。
私も最初のうち、出てきたコードがそのまま動くので気にしていませんでした。困るのは半年後です。SDK を上げた朝に、録音画面だけがビルドから落ちます。生成が出してくる書き方は「多数派だった書き方」であって、「いま推奨されている書き方」ではありません。 音声・動画・ファイルまわりは特に入れ替わりが速いので、生成のあとに一次情報で一度だけ突き合わせる時間を取るようにしております。
そのうえで、Rork Max には次のように頼みます。
「マイクボタンを押すと録音が開始され、もう一度押すと停止する音声録音画面を作ってください。録音中はボタンが赤くなり、録音時間がリアルタイムで表示されます。録音が完了したら、音声波形のビジュアライゼーションと "分析する" ボタンが表示される画面を実装してください。録音は expo-av ではなく expo-audio の
useAudioRecorderを使ってください。」
生成された骨格に、録音設定と後始末を足します。
// hooks/useInterviewRecorder.ts
import { useEffect, useState } from 'react';
import {
useAudioRecorder,
useAudioRecorderState,
AudioModule,
RecordingPresets,
setAudioModeAsync,
} from 'expo-audio';
const MAX_DURATION_SEC = 180; // 回答は 3 分で打ち切る
export function useInterviewRecorder() {
// 録音ファイルは既定でキャッシュ領域に置かれ、端末の空きが減ると OS に消されます。
// 評価が終わるまでは確実に残したいので、document 側を指定します。
const recorder = useAudioRecorder({
...RecordingPresets.HIGH_QUALITY,
directory: 'document',
});
const state = useAudioRecorderState(recorder, 250);
const [permissionGranted, setPermissionGranted] = useState(false);
useEffect(() => {
(async () => {
const status = await AudioModule.requestRecordingPermissionsAsync();
setPermissionGranted(status.granted);
// iOS は allowsRecording の既定が false なので、明示しないと録音が始まりません
await setAudioModeAsync({ playsInSilentMode: true, allowsRecording: true });
})();
}, []);
const start = async () => {
if (!permissionGranted) {
throw new Error('マイクの使用許可が必要です');
}
await recorder.prepareToRecordAsync();
// record() は Promise を返しません(await しても何も待ちません)
recorder.record({ forDuration: MAX_DURATION_SEC });
};
const stop = async (): Promise<string> => {
await recorder.stop();
// 録音セッションを開いたままにすると、他アプリの再生に干渉します
await setAudioModeAsync({ allowsRecording: false });
const uri = recorder.uri; // 状態オブジェクト側は uri ではなく url なので注意
if (!uri) {
throw new Error('録音ファイルが見つかりません');
}
return uri;
};
return {
isRecording: state.isRecording,
durationSec: Math.floor(state.durationMillis / 1000),
permissionGranted,
start,
stop,
};
}録音時間の上限は、record({ forDuration }) に渡してライブラリ側で閉じてもらうのが確実です。タイマーで監視して自分で止める書き方だと、画面が背面に回ったときに止め損ねます。3 分を超える回答は面接の練習としても長すぎますし、文字起こしのコストもそのぶん伸びます。
小さな注意点を三つだけ。record() は同期関数で、待つべきなのは prepareToRecordAsync() と stop() のほうです。ファイルのパスはレコーダーの uri にあり、useAudioRecorderState が返す状態のほうは url という別名で持っています。そして iOS は allowsRecording の既定値が false なので、setAudioModeAsync を通さないと録音そのものが始まりません。私はこの三つ目で一度、無音のファイルを Whisper に投げ続けておりました。
Step 2: Whisper 文字起こし + Claude 評価 API を Cloudflare Workers で実装する
次に、バックエンドとなる Cloudflare Workers を実装します。ここが最も重要なコンポーネントです。
// Cloudflare Workers: src/index.ts
interface Env {
OPENAI_API_KEY: string;
ANTHROPIC_API_KEY: string;
SUPABASE_URL: string;
SUPABASE_SERVICE_KEY: string;
}
interface EvaluationResult {
transcription: string;
scores: {
relevance: number; // 質問への適切さ (0-10)
specificity: number; // 具体性・エピソード (0-10)
structure: number; // 論理構成 (0-10)
language: number; // 言語表現 (0-10)
};
overall: number; // 総合スコア (0-100)
strengths: string[]; // 良かった点
improvements: string[]; // 改善すべき点
rewrittenAnswer: string; // AIが書き直した模範回答
tips: string; // 一言アドバイス
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
};
if (request.method === 'OPTIONS') {
return new Response(null, { headers: corsHeaders });
}
if (request.method !== 'POST') {
return new Response('Method Not Allowed', { status: 405 });
}
try {
const formData = await request.formData();
const audioFile = formData.get('audio') as File;
const questionText = formData.get('question') as string;
const industry = formData.get('industry') as string ?? '一般';
const level = formData.get('level') as string ?? '中途採用';
if (!audioFile || !questionText) {
return new Response(
JSON.stringify({ error: '音声ファイルと質問テキストは必須です' }),
{ status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
// Step A: Whisper API で文字起こし
const whisperFormData = new FormData();
whisperFormData.append('file', audioFile, 'recording.m4a');
whisperFormData.append('model', 'whisper-1');
whisperFormData.append('language', 'ja');
whisperFormData.append('response_format', 'json');
const whisperResponse = await fetch('https://api.openai.com/v1/audio/transcriptions', {
method: 'POST',
headers: { 'Authorization': `Bearer ${env.OPENAI_API_KEY}` },
body: whisperFormData,
});
if (!whisperResponse.ok) {
const errorText = await whisperResponse.text();
throw new Error(`Whisper API エラー: ${errorText}`);
}
const { text: transcription } = await whisperResponse.json() as { text: string };
if (!transcription || transcription.trim().length < 10) {
return new Response(
JSON.stringify({ error: '音声が短すぎるか、認識できませんでした。もう一度録音してください。' }),
{ status: 422, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
// Step B: Claude Messages API で評価
const evaluationPrompt = buildEvaluationPrompt(questionText, transcription, industry, level);
const claudeResponse = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': env.ANTHROPIC_API_KEY,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({
model: 'claude-sonnet-4-6',
max_tokens: 2000,
messages: [{ role: 'user', content: evaluationPrompt }],
}),
});
if (!claudeResponse.ok) {
throw new Error(`Claude API エラー: ${claudeResponse.status}`);
}
const claudeData = await claudeResponse.json() as {
content: Array<{ text: string }>;
stop_reason: string;
};
// 打ち切られた応答をパースしても、壊れた JSON しか出てきません。
// stop_reason は API が教えてくれるので、文字列を見る前にここで分岐します。
if (claudeData.stop_reason === 'max_tokens') {
return new Response(
JSON.stringify({ error: '評価が途中で切れました。もう一度お試しください。', retryable: true }),
{ status: 503, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
const rawText = claudeData.content[0]?.text ?? '';
const evaluation = extractEvaluation(rawText); // 実装は次の章
evaluation.transcription = transcription;
return new Response(JSON.stringify(evaluation), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
} catch (error) {
console.error('処理エラー:', error);
return new Response(
JSON.stringify({ error: '評価処理中にエラーが発生しました。しばらく後にお試しください。' }),
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
}
};
function buildEvaluationPrompt(
question: string,
answer: string,
industry: string,
level: string
): string {
return `あなたは${industry}業界の採用面接の専門家です。${level}の求職者の面接回答を評価してください。
【面接質問】
${question}
【応募者の回答(音声認識テキスト)】
${answer}
以下の形式で厳密にJSONのみを返してください。説明文は不要です。
{
"scores": {
"relevance": <質問への適切さ 0-10>,
"specificity": <具体的エピソード・数値の有無 0-10>,
"structure": <STAR法等の論理構成 0-10>,
"language": <語彙・表現の適切さ 0-10>
},
"overall": <総合スコア 0-100>,
"strengths": ["良かった点1", "良かった点2"],
"improvements": ["改善点1", "改善点2", "改善点3"],
"rewrittenAnswer": "この回答をより良くした場合の模範例を150文字程度で",
"tips": "この回答者へのワンポイントアドバイスを50文字以内で"
}
評価基準:
- relevance: 質問に直接答えているか、的外れな内容はないか
- specificity: 「チームをリードしました」ではなく「5人のチームで売上120%を達成しました」のように具体的か
- structure: 結論→理由→エピソード→結論の流れがあるか
- language: 「えー」「あのー」のような言い淀みが多くないか(音声認識テキストに反映される)
厳しく、しかし建設的に評価してください。`;
}評価プロンプトの設計がこのアプリの肝です。「業界」と「採用レベル」をパラメータにしているのは、新卒採用と中途採用、IT業界とサービス業では求められる回答スタイルが大きく違うからです。私が実際にテストしてみた感想では、industry と level を省略したときよりも、具体的に指定したときの評価精度が明らかに向上しました。
評価 JSON の取り出し方を、六通りの応答で試す
冒頭に書いた 500 の正体が、ここです。元のコードは rawText.match(/\{[\s\S]*\}/) で最初の { から最後の } までをまとめて取り、JSON.parse に渡していました。ほとんどの応答は通ります。通らないときだけ、ユーザーの画面には何も出ません。
どんな形で崩れるのかを、手元で並べて確かめました。評価 JSON を一つ用意し、実際に観測した応答の形を六通り作って、二つの取り出し方に同じものを流しています(Node.js v22・サンドボックス実行)。
| 応答の形 | 貪欲な正規表現 | 括弧を数える取り出し方 |
|---|---|---|
| A: JSON だけが返る | OK | OK |
| B: 「はい、以下が評価結果です。」が前に付く | OK | OK |
| C: json のコードフェンスで囲まれて返る | OK | OK |
| D: JSON の後ろに波括弧を含む地の文が続く | 500 で落ちる | OK |
| E: max_tokens で途中打ち切り | 500 で落ちる | 打ち切りとして検知 |
| F: 前置きの説明文に波括弧が含まれる | 500 で落ちる | OK |
六通りのうち三通りで落ちました。落ちる三つに共通しているのは、正規表現が「最後の }」まで欲張って取ってしまうことです。D と F は本文の波括弧を巻き込み、E はそもそも閉じていない文字列を渡されて、JSON.parse が例外を投げます。
書き直した取り出し方は、文字列の中かどうかを見ながら波括弧の深さを数え、最初に閉じた塊を候補にします。
// src/extract-evaluation.ts
export class IncompleteEvaluationError extends Error {}
function isEvaluation(value: unknown): value is EvaluationResult {
const v = value as EvaluationResult;
return !!v && typeof v === 'object'
&& !!v.scores && typeof v.overall === 'number'
&& Array.isArray(v.improvements) && typeof v.tips === 'string';
}
export function extractEvaluation(text: string): EvaluationResult {
// コードフェンスは外側だけ剥がします(中身の波括弧は数え直すので触りません)。
// 正規表現リテラルにバッククォートを直接書かずに済むよう、コードポイントで組み立てます
const fence = new RegExp('\\u0060{3}(?:json)?\\s*([\\s\\S]*?)\\u0060{3}', 'g');
const unfenced = text.replace(fence, '$1');
for (let i = unfenced.indexOf('{'); i !== -1; i = unfenced.indexOf('{', i + 1)) {
let depth = 0;
let inString = false;
let escaped = false;
for (let j = i; j < unfenced.length; j++) {
const c = unfenced[j];
if (escaped) { escaped = false; continue; }
if (c === '\\') { escaped = true; continue; }
if (c === '"') { inString = !inString; continue; }
if (inString) continue;
if (c === '{') {
depth++;
} else if (c === '}' && --depth === 0) {
try {
const value = JSON.parse(unfenced.slice(i, j + 1));
if (isEvaluation(value)) return value; // 形まで合って初めて採用します
} catch {
// この塊は使えないので、次の { から探し直します
}
break;
}
}
}
throw new IncompleteEvaluationError('評価 JSON を取り出せませんでした');
}最初に書いた版には isEvaluation がありませんでした。そのせいで E の打ち切りケースだけ、内側の scores オブジェクトを「最初に閉じた塊」として拾い上げ、点数の入っていない評価をそのまま画面に返していたのです。500 で落ちてくれたほうが、まだ気づけたかもしれません。落ちる不具合より、形の違う値が通ってしまう不具合のほうが見つかりません。 取り出しと同時に形を確かめる数行を足してから、この種の問い合わせは届かなくなりました。
Worker 側では IncompleteEvaluationError を 503 と retryable: true で返し、アプリは同じ音声でもう一度だけ投げ直します。文字起こしは済んでいるので、投げ直すのは評価だけで足ります。
Step 3: セッション管理と進捗トラッキング UI の設計
一回の評価を提供するだけではアプリの価値は限定的です。複数回の練習セッションを記録し、スコアの推移を可視化することで、継続使用のモチベーションが生まれます。
// lib/session-manager.ts
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(
process.env.EXPO_PUBLIC_SUPABASE_URL!,
process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY!
);
export interface PracticeSession {
id: string;
user_id: string;
question_id: string;
question_text: string;
transcription: string;
scores: {
relevance: number;
specificity: number;
structure: number;
language: number;
};
overall: number;
strengths: string[];
improvements: string[];
rewritten_answer: string;
tips: string;
industry: string;
level: string;
created_at: string;
}
export async function saveSession(
userId: string,
questionText: string,
questionId: string,
evaluation: Omit<PracticeSession, 'id' | 'user_id' | 'created_at'>
): Promise<PracticeSession> {
const { data, error } = await supabase
.from('practice_sessions')
.insert({
user_id: userId,
question_id: questionId,
question_text: questionText,
transcription: evaluation.transcription,
scores: evaluation.scores,
overall: evaluation.overall,
strengths: evaluation.strengths,
improvements: evaluation.improvements,
rewritten_answer: evaluation.rewritten_answer,
tips: evaluation.tips,
industry: evaluation.industry,
level: evaluation.level,
})
.select()
.single();
if (error) {
console.error('セッション保存エラー:', error);
throw new Error('練習結果の保存に失敗しました');
}
return data as PracticeSession;
}
export async function getUserProgress(
userId: string,
questionId?: string,
limit = 20
): Promise<PracticeSession[]> {
let query = supabase
.from('practice_sessions')
.select('*')
.eq('user_id', userId)
.order('created_at', { ascending: false })
.limit(limit);
if (questionId) {
query = query.eq('question_id', questionId);
}
const { data, error } = await query;
if (error) {
throw new Error(`進捗データの取得に失敗しました: ${error.message}`);
}
return (data ?? []) as PracticeSession[];
}
// 特定の質問に対する改善トレンドを計算
export function calculateImprovementTrend(sessions: PracticeSession[]): {
trend: 'improving' | 'stable' | 'declining';
percentChange: number;
} {
if (sessions.length < 2) {
return { trend: 'stable', percentChange: 0 };
}
// 最新3回と以前3回を比較
const recent = sessions.slice(0, 3);
const earlier = sessions.slice(-3);
const recentAvg = recent.reduce((sum, s) => sum + s.overall, 0) / recent.length;
const earlierAvg = earlier.reduce((sum, s) => sum + s.overall, 0) / earlier.length;
const percentChange = ((recentAvg - earlierAvg) / earlierAvg) * 100;
return {
trend: percentChange > 5 ? 'improving' : percentChange < -5 ? 'declining' : 'stable',
percentChange: Math.round(percentChange),
};
}Rork Max に「スコアの推移を折れ線グラフで表示するダッシュボード画面」を生成させると、Victory Native を使ったきれいなチャートが自動生成されます。ここに上記の calculateImprovementTrend の結果を組み合わせて「前回比 +15%」のようなバッジを表示すると、ユーザーの継続モチベーションが大きく上がります。
サブスクリプション課金とフリーミアム設計
無料でどこまで使えるか、有料で何が追加されるかの設計は、このジャンルのアプリの生命線です。私が推奨するフリーミアム設計はシンプルで、以下の通りです。
無料プラン
- 毎日 3 回の練習(録音 + 評価)
- 基本的な評価(スコア + 改善点2つ)
- 直近 7 日間の履歴
プレミアムプラン(月額 ¥980)
- 無制限の練習
- フル評価(模範回答の書き直し + ワンポイントアドバイス)
- 業界別・役職別の専用質問バンク(IT、金融、コンサル、医療など)
- 全履歴と詳細な進捗グラフ
- 弱点分析レポート(月次)
Rork Max のコードから RevenueCat でサブスクリプション管理を実装する場合、useRevenueCat フックで現在のプランを取得し、制限をかけたい箇所でチェックします。
// components/RecordingScreen.tsx(抜粋)
import { useRevenueCat } from '../hooks/useRevenueCat';
import { useSessionCount } from '../hooks/useSessionCount';
export function RecordingScreen() {
const { isPremium } = useRevenueCat();
const { todayCount } = useSessionCount();
const FREE_DAILY_LIMIT = 3;
const canRecord = isPremium || todayCount < FREE_DAILY_LIMIT;
const handleRecord = async () => {
if (!canRecord) {
// RevenueCat のペイウォールを表示
await Purchases.presentOfferingIfEligibleForIntroOffer();
return;
}
// 録音処理を開始
await startRecording();
};
return (
// ...UI...
<TouchableOpacity
onPress={handleRecord}
disabled={/* 録音処理中 */false}
>
{!canRecord && (
<Text>本日の無料練習回数({FREE_DAILY_LIMIT}回)を使い切りました</Text>
)}
</TouchableOpacity>
);
}重要な設計ポイントがあります。無料プランでも「評価結果の画面は表示する」こと。スコアと良かった点は見せて、模範回答の書き直しとアドバイスにはぼかしをかけてプレミアムへ誘導します。「もう少し知りたいのに」という欲求を使ったコンバージョン設計は、このジャンルでは特に効果的です。
無料枠を端末で数えていた話
上のコードには、私がしばらく気づかずに置いていた欠陥があります。todayCount を返しているのが端末側の状態だということです。
端末で数えている以上、上限は「お願い」でしかありません。アプリを入れ直せばカウントは初期化されますし、そもそも Worker のエンドポイントは誰でも叩けます。CORS を * で開き、認証を挟んでいないのですから、curl の一行で無料枠どころか課金の判定ごと素通りします。請求は OpenAI と Anthropic から、こちらへ届きます。
数えた結果で課金を止めるなら、数えるのはサーバーです。 端末側のカウントは残しておいて構いません。「残り 2 回」をその場で出せるのは端末側だけですし、体験としても速いからです。表示は端末で、判定はサーバーで——役割を分けて両方残すのが落としどころでした。
// Cloudflare Workers: src/authorize.ts
import { createClient } from '@supabase/supabase-js';
const FREE_DAILY_LIMIT = 3;
// JST の当日 0 時を ISO 文字列で返します(UTC の日付境界とは 9 時間ずれます)
function startOfTodayJst(): string {
const jst = new Date(Date.now() + 9 * 60 * 60 * 1000);
const midnightUtc = Date.UTC(jst.getUTCFullYear(), jst.getUTCMonth(), jst.getUTCDate());
return new Date(midnightUtc - 9 * 60 * 60 * 1000).toISOString();
}
type AuthResult =
| { ok: true; userId: string }
| { ok: false; status: number; error: string };
export async function authorize(request: Request, env: Env): Promise<AuthResult> {
const jwt = request.headers.get('Authorization')?.replace(/^Bearer\s+/i, '');
if (!jwt) {
return { ok: false, status: 401, error: 'ログインが必要です' };
}
const supabase = createClient(env.SUPABASE_URL, env.SUPABASE_SERVICE_KEY);
const { data, error } = await supabase.auth.getUser(jwt);
if (error || !data.user) {
return { ok: false, status: 401, error: 'セッションが無効です' };
}
const userId = data.user.id;
// 課金状態は RevenueCat の Webhook で profiles テーブルへ書き込んでおきます。
// アプリから「プレミアムです」と申告させないのが肝心なところです。
const { data: profile } = await supabase
.from('profiles')
.select('is_premium')
.eq('id', userId)
.single();
if (profile?.is_premium) {
return { ok: true, userId };
}
const { count } = await supabase
.from('interview_sessions')
.select('id', { count: 'exact', head: true })
.eq('user_id', userId)
.gte('created_at', startOfTodayJst());
if ((count ?? 0) >= FREE_DAILY_LIMIT) {
return { ok: false, status: 402, error: '本日の無料練習回数を使い切りました' };
}
return { ok: true, userId };
}fetch の入口では、formData() を読む前にこれを通します。音声ファイルを読んでから弾くと、弾いた分の転送量だけ先に払うことになるからです。
const auth = await authorize(request, env);
if (!auth.ok) {
return new Response(JSON.stringify({ error: auth.error }), {
status: auth.status,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}日付の境界にも一言だけ。created_at を UTC の日付で切ると、日本の利用者にとっては毎朝 9 時に無料枠が回復します。夜に練習する人がいちばん多いジャンルなので、ここを JST で切るかどうかは体感の差が小さくありません。私は上のように JST の 0 時で切っております。
併せて、CORS の Access-Control-Allow-Origin を * のままにしないこと、音声ファイルのサイズに上限(3 分の m4a なら 5 MB 程度で足ります)を設けること、Content-Length を見て超過分は読まずに 413 を返すこと。この三つを入れておくと、悪意のない誤操作で請求が伸びる事故もほぼ防げます。
よくある落とし穴と本番環境での注意点
実際にこのアーキテクチャで開発していると、いくつかのハマりポイントがあります。
落とし穴1: 録音後のオーディオモードを戻し忘れる
iOS では allowsRecording: true のまま録音を終えると、他のアプリの再生に干渉することがあります。stop() のあとに setAudioModeAsync({ allowsRecording: false }) を呼んでください。上のサンプルには入れてありますが、生成されたコードには入っていないことがほとんどです(expo-av 時代の名前は allowsRecordingIOS でした。移行するときは、この名前の違いも直す必要があります)。
落とし穴2: プリセット次第で Android の形式だけが変わる
expo-audio のプリセットは、HIGH_QUALITY が iOS・Android とも拡張子 .m4a(AAC)です。一方 LOW_QUALITY は Android だけ .3gp の AMR-NB に落ちます。容量を惜しんで LOW_QUALITY を選ぶと、Android の一部の録音だけ文字起こしで弾かれる、という切り分けにくい不具合になります。回答の音声は元から短いので、ここは HIGH_QUALITY で固定するのが無難です。
落とし穴3: 応答の取り出しを正規表現一本に任せる
前の章で測ったとおり、貪欲な正規表現は六通りの応答のうち三通りで落ちます。波括弧を数えて形を確かめる取り出し方に替え、打ち切りは stop_reason で先に弾いてください。
落とし穴4: 面接質問のコンテンツポリシー
App Store の審査では「差別的な面接質問を含んでいないか」を確認されることがあります。「年齢は何歳ですか」「結婚の予定はありますか」といった日本では慣習的だが法的にグレーな質問をアプリに含めると、リジェクトされる可能性があります。質問バンクのコンテンツは慎重に選定してください。
落とし穴5: 録音ファイルの置き場所と後始末
expo-audio の録音先は既定でキャッシュ領域です。端末の空きが減れば OS に消される場所なので、評価が終わるまで確実に残したい録音は directory: 'document' を指定して保存します。そのぶん、評価が済んだら FileSystem.deleteAsync(uri) で自分から消す責任も生まれます。音声の再生を残したい場合は、削除の前に Supabase Storage へ上げて URL を持たせておくと、プレミアム側の価値として使えます。
リリース後の成長戦略
このアプリが軌道に乗り始めたとき、次の一手として考えたいのが「就活シーズンの季節プロモーション」です。日本の場合、3〜4月(新卒採用の面接ピーク)と9〜10月(中途採用の活発期)に合わせて、割引オファーやコンテンツ更新を行うことで、オーガニックな口コミが生まれやすくなります。
もうひとつ有効なのが、企業向け(B2B)展開です。内定者研修や新入社員研修として企業にライセンスを販売するモデルは、個人向けサブスクリプションよりも単価が高く、チャーン率も低い傾向があります。Rork Max であれば、B2B 向けの管理ダッシュボードも比較的容易に追加できます。
まずは個人向けのサブスクリプションで収益を安定させてから、B2B 展開を検討する流れが個人開発者としては現実的です。最初の 1,000 人のユーザーを集めるまでは、ASO に集中することをおすすめします。「AI 面接 練習」「模擬面接 アプリ」「就活 面接対策」あたりのキーワードで上位表示できると、検索流入が安定します。
App Store の UI 上でアプリを魅力的に見せるためのスクリーンショット設計については、App Store 向けスクリーンショット戦略ガイドが参考になります。
今日から始めるための最初の一歩
長くなりましたが、順番はひとつだけです。「録音 → 文字起こし → 評価 → 表示」を、自分の声で一度だけ通してみてください。UI の作り込みはそのあとで間に合います。評価の精度と、返ってくるまでの体感時間——この二つが許容できるかどうかで、作り続ける価値があるかが分かってしまうからです。
Cloudflare Workers のデプロイは無料枠の中で十分に試せます。文字起こしの料金は 1 分の音声で 1 円に届かず、評価のほうも 1 回あたり数円の範囲に収まりますので、手元の検証で費用を心配する必要はありません。心配すべきなのは、認証を入れないまま公開したエンドポイントのほうです。
今日の最初の一歩としては、Worker を一本デプロイして、自分の三十秒の回答を通すところまでで十分だと思います。私はそこで返ってきた「具体性 6 点」という数字に、ひと言もなく頷いておりました。読んでくださってありがとうございました。