search
LoginSignup
13

posted at

updated at

【個人開発】楽しみながら滑舌を鍛えることができるWebアプリ『早口言葉選手権』をリリースしました⚾🏏

hayakuchi-championship-toppage.png

早口言葉に音声入力とタイピングゲームの要素を組み合わせた、滑舌改善のためのWebサービスです。

▼サービスURL
https://hayakuchi-championship.com/
▼GitHub
https://github.com/tomo-kn/hayakuchi-championship
▼Twitterアカウント
https://twitter.com/tomokn5

※使用しているAPIの都合上、このサービスは現在、PCまたはAndroid端末のChromeブラウザでのみプレイ可能です。
※本サービスはコスト削減のため、22/11/3(木)をもってサービス終了いたしました。 プレイしていただいた皆様、誠にありがとうございました。

はじめに

こんにちは!駆け出しエンジニアのともと申します。
突然ですが、皆さんは自身の滑舌や話し方に自信はありますか?

僕は大学生の頃、歌が上手くなりたくてボイトレ教室に通っていて、その一環として話し方教室に行ったこともあります。
ただ、やはりお月謝が高くて続けるのは中々困難でした…。無料で楽しく続けられたらいいのに、と常々感じていました。

そこで、「滑舌や話し方を楽しく改善できるWebアプリができたら、結構需要があるのでは?」と考え、本サービスを作ることにしました。

サービス概要と使い方

1.Topページ

Topページから試合モード・練習モードのどちらかを選択できます。
hayakuchi-screenshot-top.png

2.試合ページ

ゲームが始まると60秒のカウントダウンが始まります。
お題が次々にランダムで出題され、発音の精度に応じて点数がもらえます。

  • ホームラン:2点
  • ヒット:1点
  • アウト:0点

また、3回連続でホームランを打つと、ボーナスとして制限時間が5秒追加されます。

hayakuchi-douga-6baisoku-saigo-entyo.gif

制限時間を使い切るか、アウトが3回重なったらゲーム終了です。

3.練習ページ

試合モードで出題されるお題を練習することができます。
練習モードでは録音を再生できるので、自分の発音を客観的に分析できます。

練習ページ 結果ページ

4.その他の機能

会員登録をしてログインをすれば、マイページを見ることができます。
マイページでは過去の試合結果や練習結果を振り返ることができます。

マイページ

また、試合モードのベストスコアはランキングに表示されるため、ライバルたちとスコアで競って楽しむことができます。

ランキング

ぜひランキング上位を目指してがんばってください!

使用技術

  • Ruby(3.1.2)
  • Ruby on Rails(6.1.6)
  • JavaScript
  • JQuery
  • AWS(ECS, ECR, Fargate, RDS, Route53, ALB, ACM, S3)
  • Docker
  • CircleCI
  • RSpec
  • MySQL

使用API

  • WebSpeechAPI(音声認識に使用)

主要gem

  • carrierwave-audio
  • fog-aws
  • bootstrap
  • jquery-rails

ER図

※ 現在、ER図を参考に追加機能を実装中

er.drawio.png

インフラ構成(アーキテクチャ)図

モダンなWeb系自社開発企業でよく使われている「AWS」「Docker」「CircleCI」をすべて活用してインフラを構築しました。
hayakuchi-AWS-architecture.png

開発期間

6月上旬~8月上旬の約2ヶ月間、時間にすると350~400時間くらいかと思われます。

苦労した点・工夫した点

インフラにかなり苦労した

今回、なるべく実務に近いインフラ環境で開発経験を積みたかったので、思い切って

  • AWS(Fargate)
  • Docker
  • CircleCI

これら3つを最初から導入することにしました。

必要な事前知識が多かったので、Railsチュートリアルを終わらした後、こちらのロードマップ通りに学習を進め、特にDockerについてはUdemyで評価の高いかめれおんさんの講座を用いて学習しました。

そこまで準備したにも関わらず、開発中は分からないことが多く、一つのエラーに10時間以上詰まってようやく解決したこともありました。
(具体的にはDockerfileやnginx.confで多くの時間を費やしました。)

Dockerfile,entrypoint.sh,nginx.confの最終的なコードは以下の通りです。

Dockerfile
FROM ruby:3.1.2

ENV LANG C.UTF-8
ENV TZ Asia/Tokyo
# debconf: delaying package configuration, since apt-utils is not installedを非表示
# ENV DEBCONF_NOWARNINGS=yes

WORKDIR /myapp
COPY Gemfile /myapp/Gemfile
COPY Gemfile.lock /myapp/Gemfile.lock

# yarnパッケージ管理ツールをインストール
RUN apt-get update && \
  apt-get install -y build-essential \
  curl apt-transport-https wget \
  libpq-dev \
  libgmp3-dev \
  libsox-fmt-all sox libchromaprint-dev \ 
  nginx \
  sudo && \
  curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - && \
  echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list && \
  apt-get update -qq && apt-get install -y yarn nodejs mariadb-client && \
  gem install bundler --update

RUN gem update --system

RUN yarn install --check-files
RUN bundle install

# nginx
RUN groupadd nginx
RUN useradd -g nginx nginx
ADD nginx/nginx.conf /etc/nginx/nginx.conf

COPY . /myapp
RUN mkdir -p tmp/sockets
RUN mkdir -p tmp/pids


# コンテナ起動時に実行させるスクリプトを追加
EXPOSE 80
RUN chmod +x /myapp/entrypoint.sh
RUN chmod +x /myapp/bin/*
CMD [ "sh", "/myapp/entrypoint.sh" ]
entrypoint.sh
#!/usr/bin/env bash

# アセットのプリコンパイル
bundle exec rails assets:precompile RAILS_ENV=production SECRET_KEY_BASE=placeholder
yarn cache clean
rm -rf node_modules tmp/cache

service nginx start
rm -f /myapp/tmp/pids/server.pid
cd /myapp

# DBの用意
bin/setup
bundle exec rake db:seed_fu
# sitemapの作成
bundle exec rake sitemap:refresh
# puma
bundle exec pumactl start
nginx.conf
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log error;
pid /var/log/nginx.pid;

events {
  worker_connections 1024;
}

http {
  include       /etc/nginx/mime.types;
  default_type  application/octet-stream;

  log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                    '$status $body_bytes_sent "$http_referer" '
                    '"$http_user_agent" "$http_x_forwarded_for"';

  access_log  /var/log/nginx/access.log  main;

  sendfile        on;
  keepalive_timeout  65;
  include /etc/nginx/conf.d/*.conf;

  upstream myapp {
    server unix:///myapp/tmp/sockets/puma.sock;
  }

  server {
    listen 80;
    server_name hayakuchi-championship.com;

    access_log /var/log/nginx/access.log;
    error_log  /var/log/nginx/error.log;

    root /myapp/public;

    location / {
      try_files $uri @app;
    }

    location @app {
      proxy_set_header    Host                $http_host;
      proxy_set_header    X-Real-IP           $remote_addr;
      proxy_set_header    X-Forwarded-Host    $host;
      proxy_set_header    X-Forwarded-For     $proxy_add_x_forwarded_for;
      proxy_set_header    X-Forwarded-Proto   $http_x_forwarded_proto;
      proxy_pass http://myapp;
    }

    location ~ ^/assets/ {
      expires 1y;
      add_header Cache-Control public;

      add_header ETag "";
    }

    client_max_body_size 100m;
    keepalive_timeout 5;
  }
}

今回、AWSのEC2ではなくECS(Fargate)を使ったことで、検索してもあまりいい情報が出てこないエラーが度々起こりました。
英語の情報も多くかなり大変でしたが、おかげでインフラに対する知識が未経験の割には蓄積できたので、諦めずに頑張って良かったです。

ユーザーに楽しんでもらえるような採点機能に仕上げた

本サービスが流行るか流行らないかは「試合モードがどのぐらい楽しいか」にかかっていると思っています。
そこで、試合モードの採点関数は、調整に調整を重ねて作り込みました。
一部を抜粋します。

app/javascript/packs/game.js
// 採点
function gradeText() {
  const accuracy = seido.innerHTML;
  const resultWord = kotoba.innerHTML.replace(/\s+/g, "");
  const sentenceWord = odai.innerHTML.replace(/\s+/g, "");

  trace("精度: " + accuracy);
  trace("あなたの言葉は、" + resultWord + "と聞こえました");

  // confidenceの威力を約7分の5にする
  const accuracyFixed = Number(accuracy) + ((1 - Number(accuracy)) / 3.5);
  trace("修正した精度: " + accuracyFixed);
  const scoreOriginal = Math.round((100 - levenshteinDistance(resultWord, sentenceWord)) * accuracyFixed * 10) / 10;
  trace("scoreOriginal: " + scoreOriginal);
  // misconversionの処理
  var scoreMisconversion = 0;
  if(gohenkan.innerHTML != "なし"){
    var sentenceMisconversionArray = gohenkan.innerHTML.split(',');
    var scoreMisconversionAll = [];
    for (let i = 1; i < sentenceMisconversionArray.length; i++) {
      var sentenceMisconversionWord = sentenceMisconversionArray[i].replace(/\s+/g, "");
      trace("誤変換ワード: " + sentenceMisconversionWord);
      scoreMisconversionAll.push(Math.round((100 - levenshteinDistance(resultWord, sentenceMisconversionWord)) * accuracyFixed * 10) / 10);
    }
    trace("scoreMisconversionAll: " + scoreMisconversionAll);
    var scoreMisconversion = Math.max(...scoreMisconversionAll);
    trace("scoreMisconversion: " + scoreMisconversion);
  };

  const score = Math.max(scoreOriginal, scoreMisconversion);
  trace("スコアは、" + score + "点です。");

  // 95点以上はホームラン、90点以上はヒット、90点未満はアウト
  if(score >= 95){
    trace("ホームラン!");
    gameScore += 2;
    homerunCount += 1;
    scoreTemporary.innerHTML = "Score: " + gameScore;
    batterImage.src = 'hayakuchi-championship-batter3.png';
    if(homerunCount <= 2){
      homerunSound.play();
    }
  } else if(score >= 90) {
    trace("ヒット");
    gameScore += 1;
    homerunCount = 0;
    scoreTemporary.innerHTML = "Score: " + gameScore;
    batterImage.src = 'hayakuchi-championship-batter2.png';
    hitSound.play();
  } else {
    trace("アウト…");
    outScore += 1;
    homerunCount = 0;
    if(outScore == 1){
      outTemporary.innerHTML = "  Out: " + "<span style='color:red'>●</span>";
      outSound.play();
    } else if(outScore == 2){
      outTemporary.innerHTML = "  Out: " + "<span style='color:red'>●●</span>";
      outSound.play();
    }
    batterImage.src = 'hayakuchi-championship-batter4.png';
  };
  // 3回連続ホームランの場合、残り時間に5秒追加のボーナス
  if(homerunCount == 3) {
    trace("3回連続ホームランボーナス!残り時間5秒追加!");
    homerunCount = 0;
    originTime += 5;
    homerunsSound.play();
    jsAnimation.classList.add('is-show');
    setTimeout(() => {
      jsAnimation.classList.remove('is-show');
    }, 2000)
  };
  // ゲームが続行中の場合、以下の処理を行う
  if(gameContinue) {
    // アウトが3回重なったらゲームセット関数を呼び出す
    if(outScore == 3) {
      gameSet();
    } else {
      // 次のお題を選び録音ボタンを裏側で押す
      selectSentence();
      rec.click();
    };
  };
};

//中略

// レーベンシュタイン距離の定義
function levenshteinDistance( str1, str2 ) { 
  var x = str1.length; 
  var y = str2.length; 

  var d = []; 
  for( var i = 0; i <= x; i++ ) { 
      d[i] = []; 
      d[i][0] = i; 
  } 
  for( var i = 0; i <= y; i++ ) { 
      d[0][i] = i; 
  } 

  var cost = 0; 
  for( var i = 1; i <= x; i++ ) { 
      for( var j = 1; j <= y; j++ ) { 
          cost = str1[i - 1] == str2[j - 1] ? 0 : 1; 

          d[i][j] = Math.min( d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + cost ); 
      }
  }
  return d[x][y];
};

採点関数の概要をお伝えすると、

  • レーベンシュタイン距離に応じて減点する
  • WebSpeechAPIのconfidenceを利用し、精度に応じて減点する
  • 理不尽な誤変換がある場合、あらかじめ誤変換リストを登録しておき、その誤変換リストを使って採点する

このような仕組みになっております。

なるべく理不尽を減らし、その上でゲーム性が高くなるような採点機能を導入することで、より多くの方々に楽しんでもらえればいいな、と思っています。

おわりに

今回はじめてちゃんとしたWebアプリを作成しましたが、とても楽しくて、前よりプログラミングが好きになりました。
今後も精進していきたいと思います。

「面白かった」「応援したい」と感じていただけましたら、ぜひLGTMやストックをしていただいたり、本記事や本サービスをTwitterにシェアしていただけますと幸いです。
また、本サービスは可能な範囲内でよりブラッシュアップしていく予定ですので、プレイしてみて「こういう機能がほしい」などご意見・ご要望がございましたら、ぜひコメントやDMでお聞かせください!

以上、最後まで読んでいただき、ありがとうございました!

▼サービスURL
https://hayakuchi-championship.com/
▼GitHub
https://github.com/tomo-kn/hayakuchi-championship
▼Twitterアカウント
https://twitter.com/tomokn5

追記(22.09.07):大手ボイトレ系YouTuberの方に取り上げていただきました~!🎉

チャンネル登録者数1万人を超えるYouTubeチャンネル「スピーチボイストレーニング / Zooming」さんにて、本アプリ『早口言葉選手権』を取り上げていただきました!!
こちらが実際の動画です!

楽しんでいただけたようで、ホッとしました(笑)

皆さんのご参加も、ぜひお待ちしております!

※本サービスはコスト削減のため、22/11/3(木)をもってサービス終了いたしました。 プレイしていただいた皆様、誠にありがとうございました。

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
What you can do with signing up
13