Expoで構築したオフラインファーストの多言語音声ガイドアプリ

Kuratour が Expo と expo-audio を活用し、旅行会社向けに GPS 連動の音声ガイドとホワイトラベルアプリをオフラインファーストのアーキテクチャでどう構築したかを紹介します。

日本語
コピー
The offline first, multilingual audio tour app built with Expo

この記事は Clinton Forster によるゲスト投稿です。彼はオーストラリアのフルスタック開発者で、自身のアプリ Kuratour が Adventure Tourism Awards で 2025 年イノベーション賞を受賞しました。私たちが彼に注目したのは、Kuratour を 2025 Expo App Awards に応募していたからです。

...

旅は行程に追われるものではなく、探索であるべきだ。しかし多くの旅行者にとって、最高の物語は高額なローミング料金や途切れがちな電波、あるいは融通の利かないツアーの後ろに隠れている。Kuratour はそこを変えるために作った。スマホを、場所を理解する個人ガイドに変える——世界の果てでも使えるガイドに。

Kuratour の構想

Kuratour は多言語対応の GPS ベース音声ガイドアプリで、Expo だけで作られている。リアルタイムの位置情報によるトリガー、オフラインマップ、AI が生成するルートを使い、歩きながらでも車で移動しながらでも没入感のある体験を提供する。今や Kuratour が支えているのは自社プラットフォームだけではない。オーストラリアをはじめ各地の観光事業者向けに提供する、ホワイトラベルアプリ群の基盤にもなっている。

この記事では、Expo SDK を使って「オフラインファースト」の難題をどう解決したか、そして新しい expo-audio ライブラリでモダンなオーディオエンジンをどう組み上げたかを書いていく。

Expo を土台に:スケールするモダン React Native

Kuratour は完全に Expo SDK 上に構築し、新アーキテクチャで動かしている。公式モジュールとコミュニティパッケージの両方を使い、ネイティブ並みの体験を実現している。

主要モジュール:

  • expo-location:リアルタイム GPS 追跡とジオフェンスベースのトリガー

  • expo-file-systemexpo-sqlite:オフラインデータのキャッシュとメディア保存

  • expo-audio:多言語のスムーズな再生

  • expo-video:ナレーション付きの多言語オンボーディング

  • expo-splash-screen:洗練された起動体験

  • expo-apple-authentication:iOS でのシームレスなサインイン

  • expo-application:アプリのバージョン確認、更新の促し、オフラインコンテンツが最新であることの保証

  • @rnmapbox/maps:インタラクティブでオフラインでも使えるマップ

オフラインファーストの体験を作る

Kuratour にとって「オフラインファースト」は機能ではなく、中核となる要件だ。旅行者は電波の届かない場所にいることが多いし、ローミング料金を避けるためにデータ通信を切っていることもある。そのために:

  • マップタイル、画像、音声は expo-file-system で事前にダウンロードする。

  • メタデータと訪問済みスポットは expo-sqlite でローカルに保存する。

  • アプリはネットワーク接続を検知し、オンラインとオフラインのモードを自動で切り替える。

  • expo-location が発火するリアルタイム GPS 測位により、ナレーションは適切なタイミングで始まる。

ステップ 1:SQLite で構造化データを管理する

expo-sqlite を使って、ダウンロード済みのツアー、ユーザーの進捗、ローカライズ設定を記録している。以下は DatabaseService の簡略版だ。スキーマのマイグレーションはバージョン番号の仕組みで処理する。既存のユーザー群に「Observed Directions」のような機能を後から追加するとき、これが効いてくる。

import * as SQLite from "expo-sqlite";

export class DatabaseService {
  private db: SQLite.SQLiteDatabase;
  private currentVersion = 3;

  constructor() {
    // Open (or create) the database
    this.db = SQLite.openDatabaseSync("db");
    this.initializeSchema();
  }

  private initializeSchema() {
    try {
	    // 1. create schema table if it doesn't exist
      this.db.execSync(`
      CREATE TABLE IF NOT EXISTS schema_version (
        version INTEGER PRIMARY KEY NOT NULL
      );
    `);
		
		// check db version and apply appropriate migrations
      const result = this.db.getFirstSync(
        "SELECT version FROM schema_version ORDER BY version DESC LIMIT 1"
      ) as DatabaseSchema;
      const dbVersion = result ? result.version : 0;

      if (dbVersion < this.currentVersion) {
        this.applyMigrations(dbVersion);
      }
    } catch (error) {
      console.error("Error initializing schema:", error);
    }
  }
  
  private applyMigrations(dbVersion: number) {
    try {
      if (dbVersion < 1) {
        this.db.execSync(`
          CREATE TABLE IF NOT EXISTS tours (
            id INTEGER PRIMARY KEY NOT NULL, 
            offline_data TEXT, 
            visited_locations TEXT
            );
         `);
        this.db.execSync("INSERT INTO schema_version (version) VALUES (1)");
      }

	    if (dbVersion < 2) {
	      // ... further migrations as necessary
	      
	    }
    } catch (error) {
      console.error("Error applying migrations:", error);
    }
  }

  // Save the entire tour object as a JSON string for offline retrieval
  async setOfflineData(tourID: number, offlineData: any) {
    const tourAsString = JSON.stringify(offlineData);
    await this.db.runAsync(
      "INSERT OR REPLACE INTO tours (id, offline_data) VALUES (?, ?)",
      [tourID, tourAsString]
    );
  }
  
  let databaseServiceInstance: DatabaseService | null = null;

  export function getDatabaseService(): DatabaseService {
    if (!databaseServiceInstance) {
      databaseServiceInstance = new DatabaseService();
    }
    return databaseServiceInstance;
  }
}

ステップ 2:アセットのダウンロードを組み立てる

ユーザーが「Download」をタップしたら、複数の種類のアセットをまとめて取得する必要がある。Mapbox のタイル、MP3 の音声ファイル、高解像度の画像だ。expo-file-system を使い、リモートのディレクトリ構造をローカルにミラーリングしている。

import { Directory, File, Paths } from "expo-file-system";

const downloadFile = async (key: string) => {
  const CDNUrl = getUrl(key); // Your CDN/Storage URL to the file
  const dirName = key.substring(0, key.lastIndexOf("/"));
  const destinationDir = new Directory(Paths.document, dirName); // we are allowed to store data in the documents dir without prompting user

  // Ensure the directory exists
  if (!destinationDir.exists) {
    destinationDir.create({ intermediates: true });
  }

  const file = new File(destinationDir, key.split("/").pop() || "");
  
  if (!file.exists) {
    // Download the file from the remote URL to the local filesystem
    await File.downloadFileAsync(CDNUrl, file);
  }
  return file.uri; // This local URI is what the <Audio> or <Image> components will use
};

ステップ 3:UI につなぐ

TourDownload コンポーネントでは、これらの呼び出しを一つの順序立った流れにまとめている。まずマップのルートを取得し、次にメイン画像をダウンロードし、最後にツアー内の各スポットを順に回って対応する音声とバックグラウンドトラックを取得する。すべて完了したらローカルファイルの URI を SQLite に書き戻し、アプリはネットワークから完全に切り離されて動けるようになる。

ステップ 4:カスタム Hook で賢く切り替える

データがローカルにあれば、アプリはどちらのソースを優先すべきかを知る必要がある。そこでカスタム Hook useOfflineDetector を書いた。React Navigation の useFocusEffect を利用している。これにより、ユーザーがツアーページを開くたびに、まずローカルの SQLite データベースを参照するようになる。

import { useState, useCallback } from "react";
import { useFocusEffect } from "@react-navigation/native";
import { getDatabaseService } from "../../lib/sq-lite";

export const useOfflineDetector = (tourID: number) => {
  const [isOfflineAvailable, setIsOfflineAvailable] = useState<boolean>(false);

  const fetchOfflineStatus = useCallback(async () => {
    const db = getDatabaseService();
    const offlineTour = await db.getOfflineData(tourID);
    
    // If we find data in SQLite, we flag it as offline-ready
    setIsOfflineAvailable(offlineTour !== null);
  }, [tourID]);

  // Re-run the check whenever the screen comes into focus
  useFocusEffect(
    useCallback(() => {
      fetchOfflineStatus();
    }, [fetchOfflineStatus])
  );

  return isOfflineAvailable;
};

iOS アップデート時のデータ整合性を守る

iOS でオフラインファーストのアプリを作るときによく引っかかるのが、アプリのアップグレード時に Documents ディレクトリがどう振る舞うかという点だ。SQLite データベースはアップデート後も残るが、expo-file-system で documents ディレクトリに保存したファイルは、メジャーアップデートで内部パスが変わると消えたり孤立したりすることがある。

ユーザーが「ダウンロード済み」のツアーを開いたのに音声ファイルが欠けている、という事態を避けるために、Content Validation Bridge を実装した。

ステップ 1:アップデートを検知する

expo-application で、現在実行中のバージョンとローカルに保存した lastCheckedVersion を比較する。一致しなければ検証スキャンを走らせる。

import * as Application from "expo-application";
import { StorageKeys } from "../../types/storage.types";

export const useVersionCheck = () => {
  const currentVersion = Application.nativeApplicationVersion;

  const versionCheck = useCallback(async () => {
    const lastChecked = await getLocalData(StorageKeys.LAST_CHECKED_VERSION);

    if (lastChecked?.version !== currentVersion) {
      // An update occurred! Re-verify our offline files
      await recheckDownloadedContent(language, voice, dispatch);
      
      // Update our tracker
      await storeLocalData(StorageKeys.LAST_CHECKED_VERSION, { version: currentVersion });
    }
  }, [currentVersion]);
};

ステップ 2:ファイルシステムを検証する

recheckDownloadedContent 関数は、SQLite で「offline」とマークされたすべてのツアーを走査し、ファイルシステムを調べてアセットが実際に存在するかを確認する。ファイルが一つでも欠けていれば(ツアーのメイン画像や最初の音声など)、バックグラウンドで再ダウンロードを開始する。

import { File } from "expo-file-system";

export const checkMissingFiles = async (offlineTour: Tour): Promise<boolean> => {
  // Check the main header image
  const mainImage = new File(offlineTour.image.key);
  if (!mainImage.exists) return true;

  // Check every location-specific asset
  for (const location of offlineTour.locations) {
    const imageExists = new File(location.image.key);
    const audioExists = new File(location.audioKey);
    
    if (!imageExists.exists || !audioExists.exists) return true;
  }

  return false;
};

expo-location による自動発見の仕組み

Kuratour の醍醐味は「ハンズフリー」モードにある。旅行者にはスマホをポケットにしまって、ただ聴いていてほしい。物語は座標に応じて自動で再生される。そのために僕らはカスタムの useLocation フックを書き、権限の管理と高精度のバックグラウンド購読を担わせている。

位置情報の監視を実装する

Location.watchPositionAsync を使えば、数秒ごと、あるいはユーザーが1メートル動くだけでもアプリの状態を更新できる。歩行者向けガイドを密集した市街地で動かすには、この精度が欠かせない。

import * as Location from "expo-location";

export const useLocation = (tourModeActive: boolean) => {
  const [location, setLocation] = useState<LatLng | null>(null);

  useEffect(() => {
    let locationSubscription: any;

    const watchLocation = async () => {
      // 1. Request foreground permissions
      const { status } = await Location.requestForegroundPermissionsAsync();
      if (status !== "granted") return;

      // 2. Subscribe to high-accuracy updates
      locationSubscription = await Location.watchPositionAsync(
        {
          accuracy: Location.Accuracy.High,
          timeInterval: 5000, // Update every 5 seconds
          distanceInterval: 1, // Or every 1 meter
        },
        (newLocation) => {
          setLocation({
            latitude: newLocation.coords.latitude,
            longitude: newLocation.coords.longitude,
            bearing: newLocation.coords.heading || null,
          });
        }
      );
    };

    if (tourModeActive) {
      watchLocation();
    }

    return () => {
      if (locationSubscription) locationSubscription.remove();
    };
  }, [tourModeActive]);

  return { location };
};

expo-audio で階層化された音響を組み立てる

「Kuratour の体験」は文字を読み上げるだけのものではない。場の空気をつくることだ。僕らはモダンな expo-audio ライブラリを使い、位置ベースのナレーションを、かすかに流れるアンビエントトラックに重ねて映画的な感触にしている。

従来の音声実装とは違い、expo-audio では複数のプレイヤーインスタンスを作れる。ひとつで物語を、もうひとつで環境音を再生し、音量と同期を細かく制御できる。

以下は expo-audio を使った簡単な例だ。

import { createAudioPlayer, AudioModule } from "expo-audio";

export const TourAudioControls = ({ currentLocation, voice, language }) => {
  const [audioPlaying, setAudioPlaying] = useState(false);
  const [loadedSound, setLoadedSound] = useState<AudioPlayer | null>(null);
  
  const formatTime = (time: number) => {
    const minutes = Math.floor(time / 60000);
    const seconds = parseInt(((time % 60000) / 1000).toFixed(0));
    return minutes + ":" + (seconds < 10 ? "0" : "") + seconds;
  };

  const handleAudioState = async () => {
    // 1. Cleanup existing audio before loading a new stop
    if (loadedSound) {
      await loadedSound.pause();
      await loadedSound.remove(); // Essential for New Architecture memory management
    }

    try {
      // 2. Configure global audio behavior
      await AudioModule.setAudioModeAsync({
        interruptionMode: "duckOthers", // Narration ducks music/podcasts
        playsInSilentMode: true,
      });

      // 3. Create the player with a local or remote URI
      const player = createAudioPlayer({ 
        uri: getAudioUrl(currentLocation.audioKey) 
      });

      // 4. Layer background ambiance at a lower volume
      if (currentLocation.backgroundAudioKey) {
        const bgPlayer = createAudioPlayer({ uri: currentLocation.backgroundAudioKey });
        bgPlayer.volume = 0.2;
        bgPlayer.play();
      }

      setLoadedSound(player);
      player.play();
      setAudioPlaying(true);
    } catch (error) {
      console.error("Audio Load Error:", error);
    }
  };

  // 5. Track progress for the UI seek bar
  useEffect(() => {
    const interval = setInterval(() => {
      if (audioPlaying && loadedSound) {
        console.log(`Progress: ${loadedSound.currentTime} / ${loadedSound.duration}`);
      }
    }, 300);
    return () => clearInterval(interval);
  }, [audioPlaying, loadedSound]);
  
  // Simplified UI Render
return (
  <Animated.View style={[styles.container, { opacity: fadeAnim }]}>
    <View style={styles.headerContainer}>
      <Text style={styles.headerText}>{currentLocation.name}</Text>
      <IconButton icon="close" onPress={closeAudio} />
    </View>

    <View style={styles.audioControls}>
      <IconButton icon="rewind" onPress={rewind5s} />
      <IconButton 
        icon={audioPlaying ? "pause" : "play"} 
        size={36} 
        onPress={togglePlayback} 
      />
      <IconButton icon="fast-forward" onPress={fastForward5s} />
    </View>

    <View style={styles.audioInfo}>
      <Text>{formatTime(currentTime)}</Text>
      <View style={styles.statusBar}>
        <View style={{ ...styles.position, width: `${progress}%` }} />
      </View>
      <Text>{formatTime(duration)}</Text>
    </View>
  </Animated.View>
);
};

AI によるウォーキングツアー生成

Kuratour でいちばん楽しい機能のひとつが、AI 支援によるツアー作成だ。ユーザーが任意の都市名を入力すると、地図付きのルート、ナレーション原稿、音声ガイドが生成される。

  • AI が生成したテキストは多言語の音声に変換され、expo-audio で再生される。

  • ツアーは構造化データとして保存され、そのままオフラインで再生できる。

  • 14 言語に対応し、男性音声と女性音声を選べる。

パフォーマンスと安定性の監視

Kuratour は @sentry/react-native でエラーをリアルタイムに追跡し、パフォーマンスを監視している。Sentry のダッシュボードのおかげで、チームは特定のデバイスで起きる問題を素早く突き止めて修正できる。

ツアーオペレーターとともに拡大する

Kuratour の技術は現在、世界各地の複数のツアーオペレーターにホワイトラベルアプリを提供している。

オペレーターは専用の CMS から旅程を管理し、素材をアップロードし、新しい多言語コンテンツを数分で公開できる。技術的な知識は一切要らない。

Expo の動的な app.config.jsExpo's Services を組み合わせ、任意のオペレーター向けアプリを数分で生成できるホワイトラベルシステムを構築した。

ステップ1:オペレーターの切り替え

環境変数ひとつで、どの「フレーバー」のアプリをビルドするかを決める。

# To build for a specific partner
EXPO_PUBLIC_OPERATOR_NAME="Partner Name"

ステップ2:設定の自動生成

何十もの静的な app.json ファイルを保守する代わりに、generate-config.js スクリプトを使っている。スクリプトは環境変数を読み取り、集中管理された「マスターマップ」——プロジェクト ID、スプラッシュ画面の色、ストア URL をまとめたもの——に照合する。あとは app.config.js を自動で書き出し、eas.json を更新するだけだ。

// generate-config.js
const fs = require("fs");
const operatorName = process.env.EXPO_PUBLIC_OPERATOR_NAME;
const slug = operatorName.replace(/\s/g, "_").toLowerCase();

const partnerBranding = {
  "Partner_A": { projectId: "...", splash: "#502FAE", icon: "icon_a.png" },
  "Partner_B": { projectId: "...", splash: "#00A99D", icon: "icon_b.png" },
};

const configContent = `
export default {
  "expo": {
    "name": "${operatorName}",
    "slug": "${slug}",
    "icon": "./assets/branding/${slug}/icon.png",
    "ios": { "bundleIdentifier": "com.${slug}.ios.app" },
    "android": { "package": "com.${slug}.android.app" },
    "plugins": [
      ["expo-splash-screen", { 
        "backgroundColor": "${partnerBranding[slug].splash}",
        "image": "./assets/branding/${slug}/logo.png" 
      }]
    ],
    "extra": { "eas": { "projectId": "${partnerBranding[slug].projectId}" } }
  }
}`;

fs.writeFileSync("app.config.js", configContent);

ステップ3:テーマエンジンによる動的ブランディング

generate-config.js スクリプトがビルド時に operatorName を設定したあとは、集中管理された theme.ts ファイルひとつでアプリ全体の見た目と操作感を制御する。オペレーター名を対応する設定オブジェクトにマッピングすれば、メインブランドカラーから個別カテゴリのアイコン(たとえば「観光」と「宿泊」)まで、コンポーネントを一切書き換えずに差し替えられる。

import { DefaultTheme } from "react-native-paper";
import { getConfig } from "../config/app.config";

// 1. Grab the name injected during the build process
const operatorName = getConfig().operatorName.toLowerCase();

export const operatorThemes: Record<string, any> = {
  "default operator": {
    logo: require("./images/default/logo_primary.png"),
    colours: {
      primary: "#204074",
      secondary: "#2b569c",
      locations: {
        sightseeing: "#204074",
        "food and drink": "#FFC107",
        activity: "#2196F3",
        // ...
      },
    },
  },
  "premium partner": {
    logo: require("./images/premium_partner/logo.png"),
    colours: {
      primary: "#502FAE",
      secondary: "#7CE9BD",
      locations: {
        sightseeing: "#502FAE",
        "food and drink": "#F2B705",
        activity: "#00C4B8",
        // ...
      },
    },
  },
};

// 2. Export the active theme for use in StyleSheet and Providers
export const operatorTheme = operatorThemes[operatorName] || operatorThemes["default operator"];
export const operatorColours = operatorTheme.colours;

export const theme = {
  ...DefaultTheme,
  colors: {
    ...DefaultTheme.colors,
    ...operatorColours,
  },
};

このやり方の利点

  • ひとつのコードで無限のフレーバー: 保守するのは高品質なコンポーネント一式だけ。オーディオプレイヤーやマップビューを改善すれば、すべてのホワイトラベルパートナーに即座に反映される。

  • アセットのバンドル: ロゴはビルド設定に応じて require() されるため、最終的なバイナリにはそのオペレーターに関係するアセットしか含まれない。

  • ハイブリッドなデータ戦略: 視覚的なアイデンティティは速度とオフライン信頼性のためにビルド時に固定する。一方、ツアーの中身——原稿、ルート、座標——は Go API 経由で PostgreSQL データベースから取得する。おかげでツアー詳細をリアルタイムに更新でき、アプリストアの審査を再提出する必要もない。

次にやること

Kuratour のウォーキングツアーとドライブツアーを世界規模に広げる計画だ。同時に、ツアーオペレーター向けに「My Tour Experience」を立ち上げ、WhatsApp から離れて自前のカスタムアプリとバックエンドでツアーとコミュニケーションを管理できるようにする。

旅行代理店向けにポータルを用意し、コミッション方式で顧客に Kuratour を提供できるようにすることも検討している。

おわりに

Kuratour は、Expo のモダンな開発スタック、Expo の本番運用レベルのインフラ、AI によるパーソナライズを組み合わせると何ができるかを示している。オフラインファーストのアーキテクチャと階層化されたオーディオエンジンを優先した結果、できあがったのは単なる旅行アプリではなく、位置情報に反応するストーリーテリングのための堅牢なプラットフォームだ。14 言語、数十のホワイトラベルパートナーまでスケールする。

開発者にとって、このプロジェクトはあらためて示している——Expo は強力なアプリを構築するためのプラットフォームだ。新しい expo-audio モジュール、高性能な expo-location トラッキング、SQLite の信頼性があれば、複雑でメディアリッチなアプリを構築し、最も過酷な実環境でも安定して動かせる。

Kuratour を体験する

インスピレーションを探している開発者でも、次の旅に出ようとしている旅行者でも、この技術が実際に動くところを見てもらいたい。100 以上の目的地で、GPS に反応するストーリー、オフラインマップ、あなた専用に生成された AI ルートを体験できる。

今すぐ Kuratour をダウンロード

出典: Expo Blog← ホームへ戻る