🌊

javascriptを酷使してネイティブアプリのようなWebアプリを作ったら少しブラウザに可能性を感じた話

2022/12/22に公開

この記事は JavaScript Advent Calendar 2022 22日目の記事です。
もし、ジャンル違いでしたら申し訳ございません。

最初に

ネイティブアプリのようなWebアプリをjavascriptで酷使して作ったらブラウザに可能性を感じたので記事に起こしてみました。

この記事ではアプリの詳細は省き、技術的な事を中心に書いていきます。
もし技術ではなくアプリ自体に興味がある方はこちらからご覧ください。
https://zenn.dev/homing/articles/cb62d25b5b0cf1

技術

Vue.js 3系
TypeScript
p5.js
NoSleep.js

作るもの

https://www.youtube.com/watch?v=CSy01PJChZs&ab_channel=ほみcar%2Fpuyopuyo%2Fprogram
ハンドルの左側あたりで動いているアプリが今回作ったものです。
何をしているアプリかをざっくり説明すると、スマートフォンの加速度センサーから取得した値を使って複雑な計算を行い、その値を音で出力してリアルタイムに耳から認識する事ができるwebアプリです。

以降は要件と仕様をもとに進めていきます。

要件

iphoneとandroid端末で動くようにして欲しい
加速度センサーから取得した値が画面と音声にラグがなく出力されるようにしてほしい
運転しながらでも使えるようにして欲しい

仕様

iOSはSafariで動作してAndroidはChromeで動作する事
iOSとAndroidの固有の動作を制御して同一処理をさせる事
アプリ使用中はスリープ状態にならない事
加速度センサーで取得した値からノイズを除去させる事 ※1
スマホを軸にした加速度(※1)から車を軸にした加速度になるよう変換させる事
ユーザーの操作を無視して車を軸にした加速度の値を音で出力できる事
車を軸にした加速度の値を画面に出力できる事
音で出力した時に人間が現実世界と違和感を感じない事

以降は仕様の詳細です。

iOSはsafariで動作してAndroidはChromeで動作する事

iOSのブラウザでシェア率がもっとも高いのものはSafari、AndroidはChromeなのでこの2つを中心に開発を行いました。

iOSとAndroidの固有の動作を制御して同一処理になる事

今回の仕様を満たすアプリを作る上でiOSとandroidで動作が異なる所が3つあります。

1. iOSでセンサーの値を取得するには明示的な許可操作が必要

android側ではとくに許可は必要ありませんがiOS側は明示的な許可操作が必要です。

/**
 * センサーを有効にします。
 */
const enableSensor = async (): Promise<boolean> => {
  if (device === Device.ios) {
    // ios の場合は明示的に許可が必要
    const response = await (DeviceMotionEvent as any).requestPermission();
    if (response === "granted") {
      window.addEventListener("devicemotion", deviceAcceleration, false);
      return true;
    } else {
      return false;
    }
  } else {
    // android
    window.addEventListener("devicemotion", deviceAcceleration, false);
    return true;
  }
};

moriokalabさんの記事がとてもわかりやすかったです。
https://moriokalab.com/news/63

2. androidとiOSとで加速度センサーから取得した値の符号が逆になる

同条件で加速度を取得するとiOSはx, y, z = 1, -2, 3, Androidはx, y, z = -1, 2, -3と符号が逆になって取得されてしまいます。
なのでAndroidの場合のみ符号を逆転させて符号を合わせるようにしました。

yahooさんの記事がとてもわかりやすいです。
https://techblog.yahoo.co.jp/entry/2020120930052940/

DeviceMotionEventは加速度が変化した時のイベントです。
スマートフォンでのみイベントが発生します。

// 加速度センサーから値が取得できた時に呼ばれるイベント処理
const deviceAcceleration = (e: DeviceMotionEvent) => {
  // 加速度取得
  const acceleration = e.acceleration;
  if (acceleration === null) return;

  // AndroidとiOSでセンサーから取れる符号が逆になるのでiOSにあわせる
  const coefficient = this.device === Device.ios ? 1 : -1;
  const e_acceleration_x = acceleration.x ? acceleration.x * coefficient : 0;
  const e_acceleration_y = acceleration.y ? acceleration.y * coefficient : 0;
  const e_acceleration_z = acceleration.z ? acceleration.z * coefficient : 0;
};

3. iOSは音声ファイル再生時に1回目は無音で再生される

なぜこの仕様なのかはわからないですが、iOSはWeb Audio APIを使って音声ファイルを再生させると、1回目は無音で再生されてしまいます。
そのため、アプリ起動時に端末を識別して、iOSならすべての音声ファイルをユーザーに隠して再生しておく必要があります。

/**
 * セットアップ
 * ※ブラウザの仕様上クリックイベントから呼び出す事
 */
setup(): void {
  // iOSの場合は1回目は無音なので1度再生させる。
  if (this.device === Device.ios) {
    voiceData.voice_g_up_1.play()
    voiceData.voice_g_up_2.play()
    voiceData.voice_g_up_3.play()
    voiceData.voice_g_up_4.play()
    voiceData.voice_g_up_5.play()
    voiceData.voice_g_up_6.play()
    voiceData.voice_g_up_7.play()
    voiceData.voice_g_up_8.play()
    voiceData.voice_g_up_9.play()
    voiceData.voice_g_up_10.play()
    voiceData.voice_g_up_11.play()
    voiceData.voice_g_up_12.play()
    voiceData.voice_g_up_13.play()
    voiceData.voice_g_up_14.play()
  }
}

@pentamaniaさんの記事がとても参考になりました。
https://qiita.com/pentamania/items/2c568a9ec52148bbfd08

アプリ使用中はスリープ状態にならない事

スマホのブラウザは無操作状態で放置しているとスリープ状態になってしまいます。
今回のアプリは運転中にスマホスタンドで放置されて使われる想定のためスリープ状態になってしまっては困ります。
ユーザーに端末側の設定からスリープしないように設定してもらうのはありえないのでアプリ側で対応する必要があります。

スマホのブラウザは動画を再生している最中はスリープ状態にならないので、画面の見えない所で動画を常時再生する事でこの問題を解決しました。

実装にあたりこちらのライブラリを使わせていただきました。
https://github.com/richtr/NoSleep.js/

import NoSleep from "nosleep.js";
var noSleep = new NoSleep();
// クリックイベント発火後に動画を再生する
window.addEventListener(
  "click",
  function enableNoSleep() {
    document.removeEventListener("click", enableNoSleep, false);
    noSleep.enable();
  },
  false
);

加速度センサーから取得した値からノイズを除去させる事

センサーから取得した値をそのまま使用してしまうとノイズが多すぎてしまい、値を利用する側で正しく判別できずに誤動作してしまいます。
この問題を解決するために移動平均という一定期間の平均値を使用する方法を使いノイズを減らしました。
https://bellcurve.jp/statistics/course/12933.html
今回の仕様では過去8回分のデータを平均化するようにしました。

ちなみにですがiOSとAndroidの加速度センサーの周波数は60hzです。

// 状態
const state = reactive({
  gX: 0,
  gY: 0,
  gZ: 0,
});

// 移動平均を計算するためのログ
const g_x_log: number[] = [];
const g_y_log: number[] = [];
const g_z_log: number[] = [];
// 移動平均用の区間数
const deafult_moving_average_section_cnt = 8;

// 加速度センサーから値が取得できた時に呼ばれるイベント処理
const deviceAcceleration = (e: DeviceMotionEvent) => {
  const acceleration = e.acceleration;
  if (acceleration === null) return;

  // AndroidとiOSでセンサーの値が+-逆になるのでその対応
  const coefficient = device === Device.ios ? 1 : -1;
  const e_acceleration_x = acceleration.x ? acceleration.x * coefficient : 0;
  const e_acceleration_y = acceleration.y ? acceleration.y * coefficient : 0;
  const e_acceleration_z = acceleration.z ? acceleration.z * coefficient : 0;

  // 加速度をGに変換
  const g_x = e_acceleration_x / 9.8;
  const g_y = e_acceleration_y / 9.8;
  const g_z = e_acceleration_z / 9.8;

  const moving_average_section_cnt = deafult_moving_average_section_cnt;
  // x軸に対して移動平均の計算を行う。
  if (g_x_log.length >= moving_average_section_cnt) {
    const avg =
      g_x_log.reduce((sum, element) => sum + element, 0) /
      moving_average_section_cnt;
    state.gX = avg;
    g_x_log.splice(0, 1);
  }
  g_x_log.push(g_x);

  // y軸に対して移動平均の計算を行う。
  if (g_y_log.length >= moving_average_section_cnt) {
    const avg =
      g_y_log.reduce((sum, element) => sum + element, 0) /
      moving_average_section_cnt;
    state.gY = avg;
    g_y_log.splice(0, 1);
  }
  g_y_log.push(g_y);

  // z軸に対して移動平均の計算を行う。
  if (g_z_log.length >= moving_average_section_cnt) {
    const avg =
      g_z_log.reduce((sum, element) => sum + element, 0) /
      moving_average_section_cnt;
    state.gZ = avg;
    g_z_log.splice(0, 1);
  }
  g_z_log.push(g_z);
};

スマホを軸にした加速度から車を軸にした加速度になるよう変換させる事

スマホの加速度センサーから得られるx, y, z軸の情報はスマートフォンを基準にした情報です。
このアプリで欲しい情報はスマホを軸にした加速度の情報ではなく、車を軸にした加速度の情報です。
そのためスマホの軸を車の軸にあわせて回転させる必要があります。

細かく話すとややこしいので、詳細はyahooさんの記事をご覧ください。
https://techblog.yahoo.co.jp/entry/2020120930052940/

回転処理を実装するとこんな感じになります。

/**
 * 3次元ベクトルの回転を行う。
 *
 * @param number $vector_x
 * @param number $vector_y
 * @param number $vector_z
 * @param number $angle_x
 * @param number $angle_y
 * @param number $angle_z
 * @return {x:number, y:number, z:number}
 */
const rotate3dVector = (
  vector_x: number,
  vector_y: number,
  vector_z: number,
  angle_x: number,
  angle_y: number,
  angle_z: number
): { x: number; y: number; z: number } => {
  // 3次元回転行列の公式が右回りなのでマイナス角度の場合は変換処理を挟む。
  // z軸は0-360度なので変換は不要。
  if (angle_x < 0) {
    angle_x = 360 + angle_x;
  }
  if (angle_y < 0) {
    angle_y = 360 + angle_y;
  }

  // 角度→ラジアンに変換
  const razian_x = angle_x * (Math.PI / 180);
  const razian_y = angle_y * (Math.PI / 180);
  const razian_z = angle_z * (Math.PI / 180);

  // x軸周りに右回転した座標を取得する表現行列
  const matrix_x = [
    [1, 0, 0],
    [0, Math.cos(razian_x), -Math.sin(razian_x)],
    [0, Math.sin(razian_x), Math.cos(razian_x)],
  ];

  // // y軸周り右回転した座標を取得する表現行列
  const matrix_y = [
    [Math.cos(razian_y), 0, Math.sin(razian_y)],
    [0, 1, 0],
    [-Math.sin(razian_y), 0, Math.cos(razian_y)],
  ];

  // z軸周りに右回転した座標を取得する表現行列
  const matrix_z = [
    [Math.cos(razian_z), -Math.sin(razian_z), 0],
    [Math.sin(razian_z), Math.cos(razian_z), 0],
    [0, 0, 1],
  ];

  /**
   * 回転行列を使ってベクトルの回転を行う。
   *
   * @param number[][] matrix
   * @param number[] vector
   * @return {x:number, y:number, z:number}
   */
  const calc = (
    matrix: number[][],
    vector: number[]
  ): { x: number; y: number; z: number } => {
    return {
      x:
        matrix[0][0] * vector[0] +
        matrix[0][1] * vector[1] +
        matrix[0][2] * vector[2],
      y:
        matrix[1][0] * vector[0] +
        matrix[1][1] * vector[1] +
        matrix[1][2] * vector[2],
      z:
        matrix[2][0] * vector[0] +
        matrix[2][1] * vector[1] +
        matrix[2][2] * vector[2],
    };
  };

  // x軸回りの回転
  let rotational_vector = calc(matrix_x, [vector_x, vector_y, vector_z]);
  // y軸回りの回転
  rotational_vector = calc(matrix_y, [
    rotational_vector.x,
    rotational_vector.y,
    rotational_vector.z,
  ]);
  // z軸回りの回転
  rotational_vector = calc(matrix_z, [
    rotational_vector.x,
    rotational_vector.y,
    rotational_vector.z,
  ]);

  return {
    x: rotational_vector.x,
    y: rotational_vector.y,
    z: rotational_vector.z,
  };
};

この回転処理が1秒間で7.5回(60hz/8)実行されることになります。

ユーザーの操作を無視して車を軸にした加速度の値を音で出力できる事

最近のブラウザはユーザー体験を損なわせないためにユーザー操作があるまでは音を鳴らせないようになっています。
今回の仕様だと、音を出力するタイミングは加速度センサーから値を取得してその値を整形した後になり、間にユーザー操作を挟まないのでブラウザの仕様を無視して音を鳴らせるようにする必要があります。
この仕様を実現するためにsetInterval関数を使います。

setinterval関数のコールバック関数で音を出力する処理を実装し、その処理をクリックイベントで呼び出せば、定期的に流れるコールバック関数はユーザー操作によって実行された扱いになり、自動的に音を鳴らし続ける事ができます。

実装では、回転処理をかけた値をグローバル変数で保持しておき、その値を判定して音を出力するようにしました。

// 重力加速度xの値
let g_x = 1.0;
/**
 * クリックイベントから呼び出す事
 */
setup(): void {
  // 0.15秒毎に音を処理を実行する。
  this.timer_id = window.setInterval(() => {
    switch (g_x) {
      case 0.1:
        // 0.1Gの音を出力
        audio_01G.play();
        break;
      case 0.2:
        // 0.2Gの音を出力
        audio_02G.play();
        break;
    }
  }, 150);
}

コールバック関数を呼び出す間隔は150msがちょうどよかったです。

車を軸にした加速度の値を画面に出力できる事


画面への出力にはp5.jsという電子アートライブラリを使わせていただきました。
https://github.com/processing/p5.js?files=1

丸っこい方を実装するとこんな感じになります。

長くて汚いので折りたたみます。
import { defineComponent, onMounted } from "vue";
import { circuit_max_g } from "@/core/constants";
import p5 from "p5";

export default defineComponent({
  name: "GCircle",
  props: {
    draw: {
      type: Boolean,
      required: true,
    },
    x: {
      type: Number,
      required: false,
      default: 187,
    },
    y: {
      type: Number,
      required: false,
      default: 187,
    },
  },
  setup(props) {
    const sketch = (p: p5) => {
      const canvas_width = 375;
      const canvas_height = 375;
      // gサークルの直径
      const g_circle_diameter = 342;
      // gオブジェクトの直径
      const g_object_diameter = 6;

      // 初期化
      p.setup = () => {
        const canvas = p.createCanvas(canvas_width, canvas_height);
        canvas.parent("p5Canvas");
        p.background(43, 53, 63);
        p.angleMode("degrees");
        p.frameRate(60);
        p.translate(canvas_width / 2, canvas_height / 2);
        initCanvas();
      };

      let rgbRed = 255;
      let rgbGreen = 255;
      let rgbBlue = 255;
      p.draw = () => {
        if (props.draw) {
          p.translate(canvas_width / 2, canvas_height / 2);
          // gオブジェクトを描画
          // 1.2~-1.2Gを342~0の範囲内に変換して描画
          p.fill(rgbRed, rgbGreen, rgbBlue);
          p.stroke(0);
          p.strokeWeight(1);

          p.rect(
            adjust_x(props.x),
            adjust_y(props.y),
            g_object_diameter,
            g_object_diameter
          );

          // x軸とy軸のgの和を表示
          p.fill(43, 53, 63);
          p.noStroke();
          p.rect(canvas_width / 2 - 32, (canvas_height / 2) * -1 + 3, 30, 18);
          p.fill(158, 158, 147);
          const g_xy =
            Math.ceil(Math.sqrt(props.x ** 2 + props.y ** 2) * 10) / 10;
          p.text(
            g_xy === 0 ? 0.0 : g_xy + "G",
            canvas_width / 2 - 32,
            (canvas_height / 2) * -1 + 13
          );

          if (rgbRed == 0) {
            initCanvas();
            rgbRed = 255;
            rgbGreen = 255;
          } else {
            rgbRed--;
            rgbGreen--;
          }
        }
      };

      // キャンバスの初期化
      const initCanvas = () => {
        // キャンバスの枠を描画
        p.fill(43, 53, 63);
        p.noStroke();
        p.strokeWeight(1);
        p.rect(
          -canvas_width / 2,
          -canvas_height / 2,
          canvas_width,
          canvas_height
        );

        p.noFill();
        p.stroke(158, 158, 147);
        // gサークルを描画
        p.ellipse(0, 0, g_circle_diameter);
        const num = circuit_max_g * 10;
        p.ellipse(0, 0, (g_circle_diameter * 12) / num);
        p.ellipse(0, 0, (g_circle_diameter * 10) / num);
        p.ellipse(0, 0, (g_circle_diameter * 8) / num);
        p.ellipse(0, 0, (g_circle_diameter * 6) / num);
        p.ellipse(0, 0, (g_circle_diameter * 4) / num);
        p.ellipse(0, 0, (g_circle_diameter * 2) / num);

        // gサークルの横線を描画
        p.line((g_circle_diameter / 2) * -1, 0, g_circle_diameter / 2, 0);
        // gサークルの縦線を描画
        p.line(0, (g_circle_diameter / 2) * -1, 0, g_circle_diameter / 2);

        // ラベルを描画
        p.fill(158, 158, 147);
        p.strokeWeight(0);
        const cicle_interval = g_circle_diameter / 2 / 7;

        p.text("1.4G", 0, cicle_interval * 7 * -1 + 5);
        p.text("1.2G", 0, cicle_interval * 6 * -1 + 5);
        p.text("1.0G", 0, cicle_interval * 5 * -1 + 5);
        p.text("0.8G", 0, cicle_interval * 4 * -1 + 5);
        p.text("0.6G", 0, cicle_interval * 3 * -1 + 5);
        p.text("0.4G", 0, cicle_interval * 2 * -1 + 5);
        p.text("0.2G", 0, cicle_interval * -1 + 5);

        p.text("前", 0 - 20, (canvas_height / 2) * -1 + 13);
        p.text("後", 0 - 20, canvas_height / 2 - 4);
        p.text("右", canvas_width / 2 - 15, -10);
        p.text("左", (canvas_width / 2) * -1 + 1, -10);

        // 時間軸を描画
        p.stroke(158, 158, 147);
        p.strokeWeight(1);
        for (let i = 0; i < 12; i++) {
          p.rotate(30);
          p.strokeWeight(2);
          p.line(
            0,
            -(g_circle_diameter / 2) - 3,
            0,
            -(g_circle_diameter / 2) + 2
          );
        }
      };

      // gからキャンバス用のx座標に変換
      const adjust_x = (g: number) => {
        // gオブジェクトの半径
        const g_object_radius = g_object_diameter / 2;
        return (g * (g_circle_diameter / 2)) / circuit_max_g - g_object_radius;
      };

      // gからキャンバス用のy座標に変換
      const adjust_y = (g: number) => {
        // gオブジェクトの半径
        const g_object_radius = g_object_diameter / 2;
        return (
          ((g * (g_circle_diameter / 2)) / circuit_max_g + g_object_radius) * -1
        );
      };
    };

    onMounted(() => {
      new p5(sketch);
    });

    return {};
  },
});

音で出力した時に人間が現実世界と違和感を感じない事

今回の仕様では「センサーから取得した値を音でリアルタイムに認知させる」事が重要になるのでラグ大きいと話になりません。
とっくの昔の情報を音で鳴らしても「?」となってしまいます。

センサーからの値を人間が音で認知するまでのフローは以下のとおりです。

  1. センサーから値を取得
  2. センサーの値から移動平均を求めてノイズを削除 ※
  3. ノイズを削除した値から、車の軸に合わせて回転処理をかける
  4. 回転処理をかけた値を音で出力 ※
  5. 音を人間が認知

※の2点がポイントです。

1. センサーの値から移動平均を求めてノイズを削除

移動平均のデータ数を増やすとノイズは減りますが、それ以降の処理に流すデータの総数が少なくなり精度が落ちてしまいます。ただ速度は向上します。
逆に移動平均のデータ数を減らしすぎるとノイズが増え、誤動作の原因になったり、それ以降の処理に流すデータ総数は増えてしまい、処理が重くなってしまいます。
このあたりは実際にアプリを現実世界で使いながら調整していく必要があります。

2. 回転処理をかけた値を音で出力

さきほどの「ユーザー操作を無視して音を出力させる」の都合で音声出力ロジックはsetIntervalのコールバック関数の中で動いています。
なので、音を出力できるタイミングはグローバル変数(回転処理をかけた値)が変更された後にsetIntervalのコールバック関数が実行されたタイミングであり、回転処理をかけた値が確定した直後に鳴らす事はできません。
単純にコールバック関数の呼び出し間隔を短くしてしまうとアプリが重くなってしまうので、こちらも同様に現実世界に合わせて調整していく必要があります。

3. 重い処理をどうするか

何も考えずに実装するとラグが酷すぎて使えたもんではありません。
1~5までのフローには書いていませんが「アプリ使用中はスリープ状態にならない事」と「車を軸にした加速度の値を画面に出力できる事」の処理も動いているのでそのあたりも重くなってしまう原因です。
また、音をスピーカーから出力する処理自体にもラグがあるので、そこに到達するまでの処理をとにかく軽くする必要があります。

色々試しましたが移動平均の過去データ数と、setIntervalの間隔を調整しただけでラグは気にならなくなりました。
javascriptは遅いイメージがあったのでコードの調整でそれなりに動いてしまった事に驚きました。
また、マルチスレッドをまったく考慮せずにここまで動く事にも驚きました。

webブラウザに少し可能性を感じた

このアプリではパフォーマンスを向上させるためにシングルスレッドな環境で、コードの書き方だけでパフォーマンスを向上させましたが、webassemblyやマルチスレッド化を行えばさらにパーフォマンスが上がると思うのでパーフォマンスの上限は無限大だと思います。
ただ、メンテナンス性という点では、iOS, Android, webブラウザを意識して作ってしまったせいで保守性が悪いです。
その辺を吸収できるフレームワーク的な物があれば保守性が上がりflutterのようなクロスプラットフォームと戦えるポテンシャルを秘めているのでは?と思いました。

最後に

完成したコードはこちらです。
https://github.com/ritogk/g-visualization

最後までお読みいただきありがとうございました。

GitHubで編集を提案

Discussion