🚧 デモシステム
🎤 VoiceMatch
見た目より”声の相性”で繋がる、新感覚マッチングアプリ
🎧
マッチング待機中
または、シチュエーション体験
📚 実装ガイド
声だけマッチングアプリの構築手順
WebRTC + 音声認識AIで実現する、次世代マッチングサービス
1
開発環境とアーキテクチャ設計
リアルタイム音声通信を実現する技術選定
- 音声通信: WebRTC(ブラウザ間のP2P通信)
- シグナリングサーバー: Node.js + Socket.io、または Go
- バックエンド: Python(FastAPI)、Node.js(Express)
- データベース: PostgreSQL + Redis(マッチングキュー用)
- フロントエンド: React + TypeScript、Vue.js
- 音声処理: Web Audio API、MediaStream API
🛠️ 推奨技術スタック
WebRTC
Socket.io
Node.js 18+
React + TypeScript
PostgreSQL
Redis
Docker
📦 環境構築コマンド
# Node.js プロジェクト初期化 npm init -y # 必要なパッケージのインストール npm install express socket.io npm install simple-peer wrtc npm install redis ioredis npm install jsonwebtoken bcrypt # フロントエンド(React) npx create-react-app voice-match-client --template typescript cd voice-match-client npm install socket.io-client simple-peer
2
WebRTC音声通話の実装
ブラウザ間でリアルタイム音声通信を確立
- マイク許可: getUserMedia APIでマイクアクセス
- WebRTC接続: SimplePeerまたは生WebRTCで実装
- シグナリング: Socket.ioでSDP/ICE候補を交換
- TURN/STUNサーバー: NATトラバーサル対応
- 音声品質: エコーキャンセル、ノイズ抑制
- 接続管理: 切断・再接続のハンドリング
🎙️ WebRTC音声通話の実装(フロントエンド)
import Peer from 'simple-peer';
import io from 'socket.io-client';
class VoiceCallManager {
socket: any;
peer: any;
localStream: MediaStream | null = null;
constructor() {
this.socket = io('wss://your-server.com');
}
async startCall(roomId: string, initiator: boolean) {
// マイクへのアクセス取得
this.localStream = await navigator.mediaDevices
.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true
},
video: false
});
// WebRTC接続の確立
this.peer = new Peer({
initiator: initiator,
stream: this.localStream,
trickle: false,
config: {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{
urls: 'turn:your-turn-server.com:3478',
username: 'user',
credential: 'pass'
}
]
}
});
// シグナルデータの送信
this.peer.on('signal', (data: any) => {
this.socket.emit('signal', {
roomId,
signalData: data
});
});
// 相手の音声を受信
this.peer.on('stream', (remoteStream: MediaStream) => {
const audio = new Audio();
audio.srcObject = remoteStream;
audio.play();
});
// シグナルデータの受信
this.socket.on('signal', (data: any) => {
this.peer.signal(data.signalData);
});
// エラーハンドリング
this.peer.on('error', (err: Error) => {
console.error('WebRTC Error:', err);
});
}
endCall() {
if (this.peer) this.peer.destroy();
if (this.localStream) {
this.localStream.getTracks().forEach(track => track.stop());
}
this.socket.disconnect();
}
}
⚠️ TURN サーバーは必須: 企業ネットワークやモバイル回線ではSTUNだけでは接続できません。Twilioや自前構築のTURNサーバーが必要です。
3
マッチングアルゴリズムの実装
最適な相手をリアルタイムで見つけるシステム
- 待機キュー: Redis Listで待機ユーザーを管理
- マッチング条件: 年齢、性別、地域、興味などでフィルタリング
- 優先度スコア: アクティブ度、プロフィール充実度で優先順位付け
- 同時マッチング防止: Redis Lockで排他制御
- タイムアウト処理: 30秒以内にマッチングしない場合は条件緩和
- 再マッチング制御: 同じ相手と連続でマッチしない仕組み
🎯 マッチングアルゴリズム(バックエンド)
import Redis from 'ioredis';
class MatchingEngine {
redis: Redis;
constructor() {
this.redis = new Redis({
host: 'localhost',
port: 6379
});
}
async addToQueue(userId: string, preferences: any) {
const queueKey = this.getQueueKey(preferences);
const userData = JSON.stringify({
userId,
timestamp: Date.now(),
preferences
});
// キューに追加
await this.redis.lpush(queueKey, userData);
// すぐにマッチング試行
await this.tryMatch(userId, preferences);
}
getQueueKey(preferences: any): string {
// 年齢層、性別でキューを分ける
const ageRange = Math.floor(preferences.age / 10) * 10;
return `queue:${preferences.gender}:${ageRange}`;
}
async tryMatch(userId: string, preferences: any) {
const queueKey = this.getQueueKey(preferences);
// ロックを取得(同時マッチング防止)
const lockKey = `lock:${userId}`;
const lockAcquired = await this.redis.set(
lockKey,
'1',
'EX',
10,
'NX'
);
if (!lockAcquired) return null;
try {
// キューから候補を取得
const candidates = await this.redis.lrange(
queueKey,
0,
10
);
for (const candidate of candidates) {
const data = JSON.parse(candidate);
// 自分自身をスキップ
if (data.userId === userId) continue;
// 過去にマッチした相手をスキップ
const matchedBefore = await this.redis.sismember(
`matched:${userId}`,
data.userId
);
if (matchedBefore) continue;
// マッチング成立
await this.createMatch(userId, data.userId);
// キューから削除
await this.redis.lrem(queueKey, 1, candidate);
return data.userId;
}
return null;
} finally {
await this.redis.del(lockKey);
}
}
async createMatch(userId1: string, userId2: string) {
const roomId = `room_${Date.now()}_${userId1}_${userId2}`;
// マッチング履歴を記録
await this.redis.sadd(`matched:${userId1}`, userId2);
await this.redis.sadd(`matched:${userId2}`, userId1);
// 通話ルーム作成
await this.redis.setex(
`room:${roomId}`,
3600,
JSON.stringify({
users: [userId1, userId2],
createdAt: Date.now()
})
);
return roomId;
}
}
💡 マッチング精度向上: 機械学習で「会話が盛り上がりやすいペア」を学習させると、マッチング成功率が大幅に向上します。
4
AI音声分析とセキュリティ機能
不適切な発言を検知し、安全な環境を提供
- 音声テキスト化: Google Speech-to-Text、Azure Speech
- 不適切発言検知: OpenAI Moderationで有害コンテンツ検出
- 感情分析: 声のトーン、スピードから感情を分析
- 沈黙検知: 長時間の沈黙を検知してヘルプ表示
- 通報機能: ワンタップで通報、自動で録音保存
- ブロック機能: 特定ユーザーとのマッチングを防止
🛡️ 音声モニタリングシステム
import { SpeechClient } from '@google-cloud/speech';
import OpenAI from 'openai';
class VoiceSafetyMonitor {
speechClient: SpeechClient;
openai: OpenAI;
constructor() {
this.speechClient = new SpeechClient();
this.openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY
});
}
async monitorCall(audioStream: Buffer, userId: string) {
// 音声をテキストに変換
const [response] = await this.speechClient.recognize({
audio: { content: audioStream.toString('base64') },
config: {
encoding: 'LINEAR16',
sampleRateHertz: 16000,
languageCode: 'ja-JP',
enableAutomaticPunctuation: true
}
});
const transcript = response.results
?.map(result => result.alternatives?.[0].transcript)
.join(' ') || '';
if (!transcript) return { safe: true };
// 不適切コンテンツをチェック
const moderation = await this.openai.moderations.create({
input: transcript
});
const result = moderation.results[0];
if (result.flagged) {
// 警告ログを記録
await this.logWarning(userId, transcript, result.categories);
// 深刻な場合は通話を自動終了
if (result.categories.sexual || result.categories.violence) {
return {
safe: false,
action: 'terminate',
reason: '不適切な発言が検出されました'
};
}
return {
safe: false,
action: 'warning',
reason: '不適切な可能性がある発言が検出されました'
};
}
return { safe: true };
}
async logWarning(userId: string, transcript: string, categories: any) {
// データベースに警告ログを保存
await db.warnings.create({
userId,
transcript,
categories,
timestamp: new Date()
});
}
async detectSilence(roomId: string, duration: number) {
if (duration > 30) {
// 30秒以上沈黙したら会話のお題を提案
return {
suggestion: true,
topics: [
'最近ハマってることは?',
'好きな音楽のジャンルは?',
'休日の過ごし方は?'
]
};
}
return { suggestion: false };
}
}
⚠️ プライバシー配慮: 音声モニタリングは必ずユーザーに明示し、同意を得る必要があります。また、録音データは暗号化して保存し、一定期間後に自動削除する仕組みが必須です。
5
ユーザー認証とプロフィール管理
安全で快適なユーザー体験を提供
- 電話番号認証: SMS認証で本人確認(Twilio、Firebase Auth)
- 年齢確認: 18歳未満の利用を防止
- プロフィール設定: 年齢、性別、興味、声の特徴タグ
- 声紋認証: 将来的には声でログイン可能に
- 通知設定: マッチング通知、メッセージ通知
- プライバシー設定: 誰とマッチするか、ブロックリスト管理
🔐 ユーザー認証システム
import twilio from 'twilio';
import jwt from 'jsonwebtoken';
class AuthService {
twilioClient: any;
constructor() {
this.twilioClient = twilio(
process.env.TWILIO_ACCOUNT_SID,
process.env.TWILIO_AUTH_TOKEN
);
}
async sendVerificationCode(phoneNumber: string) {
const code = Math.floor(100000 + Math.random() * 900000);
// Redisに6桁コードを5分間保存
await redis.setex(
`verify:${phoneNumber}`,
300,
code.toString()
);
// SMS送信
await this.twilioClient.messages.create({
body: `VoiceMatchの認証コード: ${code}`,
from: process.env.TWILIO_PHONE_NUMBER,
to: phoneNumber
});
return { success: true };
}
async verifyCode(phoneNumber: string, code: string) {
const storedCode = await redis.get(`verify:${phoneNumber}`);
if (storedCode !== code) {
return { success: false, error: '認証コードが違います' };
}
// ユーザーを作成または取得
let user = await db.users.findOne({ phoneNumber });
if (!user) {
user = await db.users.create({
phoneNumber,
createdAt: new Date(),
verified: true
});
}
// JWTトークン発行
const token = jwt.sign(
{ userId: user.id },
process.env.JWT_SECRET,
{ expiresIn: '30d' }
);
await redis.del(`verify:${phoneNumber}`);
return {
success: true,
token,
user: {
id: user.id,
phoneNumber: user.phoneNumber
}
};
}
async updateProfile(userId: string, data: any) {
// 年齢確認
if (data.birthdate) {
const age = this.calculateAge(data.birthdate);
if (age < 18) {
return {
success: false,
error: '18歳以上の方のみご利用いただけます'
};
}
}
await db.users.update(
{ id: userId },
{
...data,
profileComplete: true,
updatedAt: new Date()
}
);
return { success: true };
}
calculateAge(birthdate: string): number {
const today = new Date();
const birth = new Date(birthdate);
let age = today.getFullYear() - birth.getFullYear();
const monthDiff = today.getMonth() - birth.getMonth();
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birth.getDate())) {
age--;
}
return age;
}
}
🔒 認証サービス推奨
Twilio(SMS認証)
Firebase Auth
Auth0
JWT
OAuth 2.0
6
マッチング後のチャット機能
通話後にテキストや写真でやりとり
- リアルタイムチャット: Socket.ioでメッセージ送受信
- 画像・動画送信: S3にアップロード、URLを共有
- 既読機能: メッセージ既読/未読状態の管理
- 通知: プッシュ通知(Firebase Cloud Messaging)
- メッセージ削除: 送信取り消し、会話の削除
- スパム対策: 短時間に大量メッセージを送信できない制限
💬 チャット機能の実装
// Socket.ioサーバー側
io.on('connection', (socket) => {
socket.on('join-chat', async (data) => {
const { userId, matchId } = data;
// マッチが有効か確認
const match = await db.matches.findOne({
id: matchId,
users: { $in: [userId] }
});
if (!match) {
socket.emit('error', '無効なマッチです');
return;
}
socket.join(`match:${matchId}`);
// 過去のメッセージを取得
const messages = await db.messages.find({
matchId
}).sort({ createdAt: 1 }).limit(50);
socket.emit('message-history', messages);
});
socket.on('send-message', async (data) => {
const { userId, matchId, content, type } = data;
// レート制限チェック
const rateLimitKey = `ratelimit:${userId}`;
const messageCount = await redis.incr(rateLimitKey);
if (messageCount === 1) {
await redis.expire(rateLimitKey, 60);
}
if (messageCount > 20) {
socket.emit('error', 'メッセージ送信制限に達しました');
return;
}
// メッセージを保存
const message = await db.messages.create({
matchId,
senderId: userId,
content,
type,
createdAt: new Date()
});
// 相手にメッセージを送信
io.to(`match:${matchId}`).emit('new-message', message);
// プッシュ通知を送信
const match = await db.matches.findOne({ id: matchId });
const recipientId = match.users.find(id => id !== userId);
await sendPushNotification(recipientId, {
title: '新しいメッセージ',
body: content.substring(0, 50),
data: { matchId }
});
});
socket.on('mark-read', async (data) => {
const { userId, matchId } = data;
await db.messages.updateMany(
{
matchId,
senderId: { $ne: userId },
read: false
},
{ read: true, readAt: new Date() }
);
io.to(`match:${matchId}`).emit('messages-read', {
userId,
matchId
});
});
});
💡 ユーザー体験向上: 「○○さんが入力中...」のタイピングインジケーターや、音声メッセージ送信機能を追加すると、より豊かなコミュニケーションが可能になります。
7
課金・サブスクリプション機能
収益化のための決済システム導入
- 無料プラン: 1日3回まで通話可能
- プレミアムプラン: 月額980円で無制限通話、優先マッチング
- 決済連携: Stripe、Apple Pay、Google Pay
- サブスク管理: 自動更新、解約処理
- アイテム課金: スーパーライク(優先表示)、ブースト機能
- 返金対応: トラブル時の返金フロー
💳 Stripe決済の実装
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
class PaymentService {
async createSubscription(userId: string, priceId: string) {
const user = await db.users.findOne({ id: userId });
// Stripeカスタマーを作成(初回のみ)
if (!user.stripeCustomerId) {
const customer = await stripe.customers.create({
email: user.email,
metadata: { userId: user.id }
});
await db.users.update(
{ id: userId },
{ stripeCustomerId: customer.id }
);
user.stripeCustomerId = customer.id;
}
// サブスクリプション作成
const subscription = await stripe.subscriptions.create({
customer: user.stripeCustomerId,
items: [{ price: priceId }],
payment_behavior: 'default_incomplete',
expand: ['latest_invoice.payment_intent']
});
// データベースに記録
await db.subscriptions.create({
userId,
stripeSubscriptionId: subscription.id,
status: subscription.status,
priceId,
currentPeriodStart: new Date(subscription.current_period_start * 1000),
currentPeriodEnd: new Date(subscription.current_period_end * 1000)
});
return {
subscriptionId: subscription.id,
clientSecret: subscription.latest_invoice.payment_intent.client_secret
};
}
async handleWebhook(event: any) {
switch (event.type) {
case 'invoice.payment_succeeded':
await this.handlePaymentSuccess(event.data.object);
break;
case 'invoice.payment_failed':
await this.handlePaymentFailed(event.data.object);
break;
case 'customer.subscription.deleted':
await this.handleSubscriptionCanceled(event.data.object);
break;
}
}
async handlePaymentSuccess(invoice: any) {
const subscription = await db.subscriptions.findOne({
stripeSubscriptionId: invoice.subscription
});
await db.subscriptions.update(
{ id: subscription.id },
{
status: 'active',
currentPeriodEnd: new Date(invoice.period_end * 1000)
}
);
await db.users.update(
{ id: subscription.userId },
{ isPremium: true }
);
}
async handlePaymentFailed(invoice: any) {
const subscription = await db.subscriptions.findOne({
stripeSubscriptionId: invoice.subscription
});
// ユーザーに支払い失敗を通知
await sendEmail(subscription.userId, {
subject: '決済エラー',
body: 'お支払いに問題が発生しました。'
});
}
async cancelSubscription(userId: string) {
const subscription = await db.subscriptions.findOne({
userId,
status: 'active'
});
if (!subscription) {
throw new Error('有効なサブスクリプションがありません');
}
await stripe.subscriptions.update(
subscription.stripeSubscriptionId,
{ cancel_at_period_end: true }
);
await db.subscriptions.update(
{ id: subscription.id },
{ cancelAtPeriodEnd: true }
);
return { success: true };
}
}
⚠️ 法令遵守: 特定商取引法に基づく表記、利用規約、プライバシーポリシーが必須です。また、Apple App Store、Google Playのアプリ内課金ルールに従う必要があります。
8
本番環境デプロイと運用
スケーラブルで安定したサービス運用
- インフラ: AWS、GCP、Azure(推奨: AWS)
- コンテナ化: Docker + Kubernetes でスケーラブルに
- CDN: CloudFront、Cloudflareで静的ファイル配信
- モニタリング: Datadog、New Relicでパフォーマンス監視
- ログ管理: CloudWatch Logs、ELK Stack
- 自動スケーリング: 負荷に応じてサーバー台数を自動調整
🚀 Dockerfileの例
# Node.js アプリケーション FROM node:18-alpine WORKDIR /app COPY package*.json ./ RUN npm ci --only=production COPY . . # 非rootユーザーで実行 RUN addgroup -g 1001 -S nodejs RUN adduser -S nodejs -u 1001 USER nodejs EXPOSE 3000 CMD ["node", "server.js"]
☸️ Kubernetes デプロイ設定
apiVersion: apps/v1
kind: Deployment
metadata:
name: voicematch-api
spec:
replicas: 3
selector:
matchLabels:
app: voicematch-api
template:
metadata:
labels:
app: voicematch-api
spec:
containers:
- name: api
image: voicematch/api:latest
ports:
- containerPort: 3000
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: db-secret
key: url
- name: REDIS_URL
valueFrom:
secretKeyRef:
name: redis-secret
key: url
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "500m"
livenessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 30
periodSeconds: 10
---
apiVersion: v1
kind: Service
metadata:
name: voicematch-api
spec:
selector:
app: voicematch-api
ports:
- protocol: TCP
port: 80
targetPort: 3000
type: LoadBalancer
🛠️ 推奨インフラ構成
AWS ECS / EKS
RDS PostgreSQL
ElastiCache Redis
S3
CloudFront
Route 53
CloudWatch
💡 デプロイ戦略: 最初はHerokuやRailwayで小規模スタート。ユーザーが1000人を超えたらAWSに移行するのが現実的です。
💰 開発・運用コスト見積もり
初期開発費用(MVP)
150〜200万円
サーバー費用(AWS)
月10,000〜50,000円
TURNサーバー(Twilio)
月5,000〜30,000円
SMS認証(Twilio)
1通10円×ユーザー数
音声認識API(Google)
月3,000〜15,000円
決済手数料(Stripe)
売上の3.6%
月額運用費(目安)
3〜10万円
本格版開発費用
300〜500万円
💡 収益シミュレーション:
月額980円 × 1,000人(課金率10%) = 月間売上98万円
月額980円 × 3,000人(課金率10%) = 月間売上294万円
→ 初期投資を6ヶ月〜1年で回収可能
月額980円 × 1,000人(課金率10%) = 月間売上98万円
月額980円 × 3,000人(課金率10%) = 月間売上294万円
→ 初期投資を6ヶ月〜1年で回収可能
✅ 成功させるための重要ポイント
1. 安全性を最優先に
• 本人確認必須、18歳未満は利用不可
• 不適切発言の自動検知と即時対応
• 通報機能を目立つ位置に配置
• 運営チームが24時間以内に対応する体制
2. 差別化ポイントを明確に
• 「声だけ」という唯一無二のコンセプト
• 見た目で判断されない、声の相性重視
• 5分間限定で緊張感と特別感を演出
• AI音声分析で最適な相手をマッチング
3. スモールスタートで検証
• まずは友達50人に使ってもらう
• フィードバックを元に改善を繰り返す
• マッチング成功率30%以上を目指す
• 課金機能は後回し、まず満足度向上
4. マーケティングが生命線
• TikTok、Instagram Reelsで「声マッチ体験動画」をバズらせる
• インフルエンサーに体験してもらい口コミ拡散
• 「声フェチ」「ASMR好き」コミュニティにアプローチ
• 「声だけ婚活」など新しいカテゴリを作る
5. 継続率を高める工夫
• 初回通話後のアンケートで相性度を表示
• 「あなたと相性が良い声のタイプ」を教える
• マッチング後のチャットをスムーズに
• デイリーログインボーナス(無料通話チケット)
• 本人確認必須、18歳未満は利用不可
• 不適切発言の自動検知と即時対応
• 通報機能を目立つ位置に配置
• 運営チームが24時間以内に対応する体制
2. 差別化ポイントを明確に
• 「声だけ」という唯一無二のコンセプト
• 見た目で判断されない、声の相性重視
• 5分間限定で緊張感と特別感を演出
• AI音声分析で最適な相手をマッチング
3. スモールスタートで検証
• まずは友達50人に使ってもらう
• フィードバックを元に改善を繰り返す
• マッチング成功率30%以上を目指す
• 課金機能は後回し、まず満足度向上
4. マーケティングが生命線
• TikTok、Instagram Reelsで「声マッチ体験動画」をバズらせる
• インフルエンサーに体験してもらい口コミ拡散
• 「声フェチ」「ASMR好き」コミュニティにアプローチ
• 「声だけ婚活」など新しいカテゴリを作る
5. 継続率を高める工夫
• 初回通話後のアンケートで相性度を表示
• 「あなたと相性が良い声のタイプ」を教える
• マッチング後のチャットをスムーズに
• デイリーログインボーナス(無料通話チケット)
❌ よくある失敗パターン
失敗1: 技術に凝りすぎて遅延
→ 解決策: MVPは既存サービス(Agora、Twilio Video)を使って3ヶ月でリリース
失敗2: 安全対策が不十分
→ 解決策: 通報・ブロック機能は必ず実装。運営体制も同時に整備
失敗3: マッチングが成立しない
→ 解決策: 初期はbotや運営スタッフが対応し、体験を保証する
失敗4: 音声品質が悪い
→ 解決策: 最初から品質テストに時間をかける。接続失敗時の再接続機能も必須
失敗5: 差別化できず埋もれる
→ 解決策: 「声だけ5分間」というコンセプトを徹底的に守り、他との違いを明確に
→ 解決策: MVPは既存サービス(Agora、Twilio Video)を使って3ヶ月でリリース
失敗2: 安全対策が不十分
→ 解決策: 通報・ブロック機能は必ず実装。運営体制も同時に整備
失敗3: マッチングが成立しない
→ 解決策: 初期はbotや運営スタッフが対応し、体験を保証する
失敗4: 音声品質が悪い
→ 解決策: 最初から品質テストに時間をかける。接続失敗時の再接続機能も必須
失敗5: 差別化できず埋もれる
→ 解決策: 「声だけ5分間」というコンセプトを徹底的に守り、他との違いを明確に
