🚀 AI 音声メモアプリの録音 → メモ完成までをつなぐモデル & ジョブ処理まとめ
🎯 全体の役割(超概要)
録音 → サーバーに送信 → サーバーで文字起こし → Memo 完成
この「途中経過」を iOS 側で保持するためのモデルが PendingMemo。
正式 Memo が生成されるまでの 仮メモ を表す。
🟦 iOS(Swift)側:PendingMemo の仕組み
✔ 1. JobStatus(ジョブ状態を表す enum)
enum JobStatus: String, Codable {
case pending
case processing
case completed
case failed
}
✔ 2. PendingMemo(途中経過メモ)
struct PendingMemo: Identifiable, Codable {
let id: String
var status: JobStatus
var progress: Int
var result: JobResult?
var error: String?
let createdAt: Date
var updatedAt: Date
}
Memo との違い
| Memo | PendingMemo |
|---|---|
| 完成したデータ | 途中経過 |
| title / content あり | 仮テキスト |
| 保存済み | 完了したら Memo に変換 |
✔ 3. UI 表示用プレースホルダー
タイトル
var placeholderTitle: String
| status | 表示内容 |
|---|---|
| pending | 処理待ち… |
| processing | 処理中… 36% |
| completed | result.title |
| failed | エラー |
本文
var placeholderContent: String
| status | 内容 |
|---|---|
| pending | 音声を処理しています |
| processing | 文字起こし・タグ生成中… |
| completed | result.content |
| failed | エラーメッセージ |
✔ 4. completed のときだけ Memo に変換
var completedMemo: Memo? {
guard status == .completed, let result = result else { return nil }
return Memo(
id: result.memoId,
title: result.title,
content: result.content,
tags: result.tags,
audioURL: "",
embedding: nil,
relatedMemoIds: [],
createdAt: createdAt,
updatedAt: updatedAt
)
}
✔ 5. JobResult
struct JobResult: Codable {
let memoId: String
let title: String
let content: String
let tags: [String]
}
✔ 6. JobStatusResponse
struct JobStatusResponse: Codable {
let jobId: String
let status: String
let progress: Int
let result: JobResult?
let error: String?
let createdAt: Date
let updatedAt: Date
}
✔ 7. toPendingMemo()
func toPendingMemo() -> PendingMemo {
PendingMemo(
id: jobId,
status: JobStatus(rawValue: status) ?? .pending,
progress: progress,
result: result,
error: error,
createdAt: createdAt,
updatedAt: updatedAt
)
}
✔ 8. CreateJobResponse
struct CreateJobResponse: Codable {
let jobId: String
let status: String
let message: String
}
🟧 Node.js(サーバー側):JobService の仕組み
✔ Job 型
export type JobStatus = 'pending' | 'processing' | 'completed' | 'failed';
export interface Job {
id: string;
userId: string;
status: JobStatus;
progress: number;
audioURL: string;
audioFilePath: string;
result?: {
memoId: string;
title: string;
content: string;
tags: string[];
};
error?: string;
createdAt: Date;
updatedAt: Date;
}
✔ createJob(ジョブ作成)
async createJob(userId: string, audioFilePath: string, audioURL: string): Promise<string> {
const jobId = new ObjectId().toString();
const job: Job = {
id: jobId,
userId,
status: 'pending',
progress: 0,
audioURL,
audioFilePath,
createdAt: new Date(),
updatedAt: new Date(),
};
await cacheSet(generateCacheKey('job', jobId), job, JOB_TTL);
await this.collection.insertOne({ _id: new ObjectId(jobId), ...job });
this.processJob(jobId).catch(err => console.error(err));
return jobId;
}
✔ getJob(状態取得)
async getJob(jobId: string): Promise<Job | null> {
const cached = await cacheGet<Job>(generateCacheKey('job', jobId));
if (cached) return cached;
const doc = await this.collection.findOne({ _id: new ObjectId(jobId) });
if (!doc) return null;
const job: Job = {
id: doc._id.toString(),
userId: doc.userId,
status: doc.status,
progress: doc.progress,
audioURL: doc.audioURL,
audioFilePath: doc.audioFilePath,
result: doc.result,
error: doc.error,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
};
await cacheSet(generateCacheKey('job', jobId), job, JOB_TTL);
return job;
}
✔ processJob(バックグラウンド処理)
private async processJob(jobId: string): Promise<void> {
const job = await this.getJob(jobId);
if (!job) return;
try {
await this.updateJobStatus(jobId, 'processing', 10);
const content = await openAIService.transcribe(job.audioFilePath);
await this.updateJobStatus(jobId, 'processing', 40);
if (!content) throw new Error('Transcription is empty');
const [title, tags, embedding] = await Promise.all([
openAIService.generateTitle(content),
openAIService.extractTags(content),
openAIService.generateEmbedding(content),
]);
await this.updateJobStatus(jobId, 'processing', 80);
const memo = await memoService.createFromText(
job.userId,
title,
content,
tags,
job.audioURL
);
await this.updateJobStatus(jobId, 'completed', 100, {
memoId: memo.id,
title: memo.title,
content: memo.content,
tags: memo.tags,
});
} catch (error) {
await this.updateJobStatus(
jobId,
'failed',
0,
undefined,
error instanceof Error ? error.message : 'Unknown error'
);
}
}
✔ updateJobStatus
private async updateJobStatus(
jobId: string,
status: JobStatus,
progress: number,
result?: Job['result'],
error?: string
)
✔ getPendingJobs
async getPendingJobs(userId: string): Promise<Job[]> {
return this.collection
.find({
userId,
status: { $in: ['pending', 'processing'] },
})
.sort({ createdAt: -1 })
.limit(10)
.toArray();
}
✔ cleanupOldJobs
async cleanupOldJobs(): Promise<number> {
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
const result = await this.collection.deleteMany({
status: { $in: ['completed', 'failed'] },
updatedAt: { $lt: oneDayAgo },
});
return result.deletedCount;
}
🎯 全体まとめ
録音
→ サーバーへ送信
→ jobId を受け取る
→ iOS は PendingMemo を作成
→ サーバーの progress / status を監視
→ completed になれば Memo に変換