Flutter

タイムシフトカメラアプリを個人開発した話 ─ うまくいったこと、いかなかったこと、方向転換

映像を15〜120秒遅延させて映し出すカメラアプリ Backbeat Cam を個人で開発しました。

ダンスや武道の練習で「少し前の自分のフォームを見ながら練習する」ためのアプリです。この記事では開発中の方向転換と、実際につまずいた内容を書きます。


バージョンの変遷

v1(2026年1月): Android のみ、無料、シンプルな遅延カメラ
        ↓ 収益モデルが必要
v2(2026年2月): フリーミアム導入
        ↓ 差別化のためのハードウェア拡張
v3(2026年3月): iPad対応、前面/背面カメラ切り替え

iOS の音声制約に気づくまで時間を取られた

これが開発で一番時間を使った問題です。

Android では動いた

v1 は Android のみで開発していました。技術的な仕組みはこうです。

カメラ映像 + マイク音声
        ↓ エンコード(H.264 + AAC)
   RingBuffer(最大60秒分保持)
        ↓ 15秒前の位置から読み出し
   デコード → TextureView + AudioTrack

Android では 映像も音声も同じ15秒遅延で再生できました。練習中に流れている音楽も15秒遅れで聞こえるので、映像と音声がぴったり合った状態で自分のフォームを確認できます。

iOS で同じことをしようとしたら詰まった

iOS 対応を始めて、同じように「映像 + 音声を遅延再生する」実装に着手しました。

そこで発覚したのが iOS の AVAudioSession 制約です。

マイク録音(入力)とスピーカー再生(出力)を同時に使えない

iOS はセッションカテゴリによって入出力の組み合わせが制限されています。マイクで録音しながらスピーカーで音を出すと、フィードバックループが発生して音がハウリングします。

これを知らずに実装を進めていたので、「音が変なのはコードの問題だ」と思って試行錯誤し続けました。実際には仕様上の制約だったので、コードをどう書き換えても解決しません。

エコーキャンセラーも使えなかった

一般的なビデオ通話アプリなどは「エコーキャンセラー(AEC)」でこの問題を回避しています。スピーカーから出た音をマイクが拾う前にキャンセルする技術です。

しかし、このアプリでは使えませんでした。

項目
標準 AEC の対応遅延200〜400ms
このアプリの遅延15,000ms(15秒)〜
約30〜75倍

AEC は「話者の声がスピーカーからすぐ返ってくるエコー」を消すための技術です。0.5秒程度の遅延にしか対応しておらず、15秒遅延には根本的に使えません。

他にも9つのアプローチを調査しました(ノッチフィルタ、周波数シフト、帯域フィルタなど)が、いずれも「完全に解決はできない」か「音質を大きく落とす」かのどちらかでした。

設計を変えることにした

試行錯誤の末、根本的な方向転換をしました。

変更前: 遅延映像 + 遅延音声をリアルタイム再生

変更後: 遅延映像のみ再生 + マイクは別途録音 → クリップ保存時に合成

【変更後のアーキテクチャ】

遅延映像再生(映像のみ)

マイク録音(リアルタイム、別バッファ)
        ↓ クリップ保存
   映像(遅延バッファから) + 音声(マイク録音)を合成してMP4保存

遅延再生中に耳で聞こえているのは「今この瞬間の音(リアルタイム)」です。スピーカーからは何も出しません。ハウリングの原因が消えます。

そしてクリップ保存ボタンを押したとき、遅延バッファの映像とマイクで録音していた環境音を合成してMP4を作ります。「練習中に流れていた音楽が、保存した動画にも入っている」状態になります。

この設計変更が結果的に差別化になりました。 競合アプリはすべて無音動画しか保存できません。音声付きクリップは Backbeat Cam だけの機能です。


Android の前面カメラ保存が予想より複雑だった

v3 で前面カメラ切り替えを実装したとき、表示はミラーできたが保存ファイルもミラーしたいという問題が出ました。

iOS は1行で解決します。

connection.isVideoMirrored = true  // 表示も保存も両方ミラーリング

Android は MediaMuxer が回転(0/90/180/270度)しか表現できず、水平反転をメタデータで表現する手段がありません

解決策は OpenGL ES でシェーダーを書いて、MediaCodec に渡す前に映像を反転することでした。

// フラグメントシェーダー: U座標を反転して水平ミラー
vec2 mirroredCoord = vec2(1.0 - texCoord.x, texCoord.y);
gl_FragColor = texture2D(uTexture, mirroredCoord);

同じ「前面カメラでミラー保存する」機能でも、iOS は1行・Android は OpenGL のレイヤーを挟む実装になります。プラットフォームの差は機能の見た目からは予測できないことがあります。


AudioTrack の二重再生バグ

ギャラリー画面から戻ってくると、古い音声が新しい音声と重なって流れるバグがありました。

原因は AudioTrack の停止処理の順番でした。

// ❌ これだとバッファが残る
audioTrack.stop()
audioTrack.release()

// ✅ 正しい順番
audioTrack.pause()   // 一時停止
audioTrack.flush()   // バッファをクリア
audioTrack.stop()
audioTrack.release()

Android SDK のドキュメントにはこの順番が明示されていません。stop() を呼んでも内部バッファに残った音声データが次のセッション開始時に再生される、というはまりどころでした。


App Store リジェクト(ガイドライン 5.1.1)

5.1.1 はプライバシーに関する項目です。マイクを常時録音しているにもかかわらず、その説明とユーザーのコントロール手段が不十分と判断されました。

対応した内容:

  • オンボーディングに「マイク録音の目的」を明示するスクリーンを追加
  • 録音のオン/オフトグルをわかりやすい位置に設置
  • プライバシーポリシーに「録音データは端末内にのみ保存され、外部送信しない」と明記

このリジェクトをきっかけに、以降のリリース前に確認するチェックリストを整備しました。


気づいたこと

「なぜ動かないのか」を先に確認する。

iOS の音声制約を知らずに「コードが悪いはず」と何週間も試行錯誤したのが一番もったいなかったです。ハードウェアや OS レベルの制約は、どれだけコードを直しても解決しません。まず「これは仕様上できるのか?」を確認してから実装に入る順番が重要です。

制約が差別化になることがある。

iOS の制約があったおかげで「遅延再生中はリアルタイムに聞こえる音をクリップに録る」という設計になりました。結果的にこれが「練習中の音楽がそのまま動画に残る」という競合にない機能につながっています。ネガティブな制約が設計の起点になることがあります。