私は日本語を勉強中ですが、趣味として韓国語の勉強もしています。
言語学習者にとって、最初の難関のひとつが「数字」です。そこで、数字を韓国語で自在に話せるようになりたいという思いから、練習用のアプリを作成しました。
1. はじめに
このアプリでは、指定した桁数のランダムな数字を自動生成し、その数字に対応する韓国語の表記と音声を表示・再生します。
使用方法:
- 数字の桁数を入力し、
TRY IT
をクリックすると、ランダムな数字が生成されます。 - 生成された数字を見ながら、韓国語で表記を書いたり、発音してみましょう。
- 発音を確認したい場合は、再生ボタンをクリックして音声を再生し、正しい発音を確認します。
- 表記を確認したい場合は、
Answer
ボタンをクリックします。 - もう一度練習するには、再度
TRY IT
をクリックします。
2. 使用したツール
アプリを作成する際に使用したツールは以下の通りです。
-
バックエンド:FastAPI
FastAPIを使用することで、ルータごとのテストが容易になり、将来的にデータベースの接続が必要になった際も簡単に拡張できます。 -
フロントエンド:Streamlit
短時間でUIを作成するために、データサイエンスの分野でよく使われているstreamlit
を選びました。また、Streamlit Cloudを使用すれば無料で簡単にデプロイできる点も魅力です。 -
コンテナ:Docker
アプリのポータビリティを確保するため、コンテナを使用しています。 -
パッケージ管理:Poetry
poetry
を使うことで、コンテナ内でのパッケージのインストールや依存関係の管理が簡単になります。
3. アプリの構成と機能
このアプリは、以下の2つの部分で構成されています:
部分 | 機能 |
---|---|
FastAPI サーバー |
数字の生成処理を行うAPIルーター |
Streamlit サーバー |
ユーザーがアプリを操作するインターフェース、処理結果の表示 |
それぞれの部分はコンテナに載せて実行する予定です。
また、APIサーバーには以下の機能を実現するルーターが必要とされています。
ルーター名 | リクエスト | レスポンス |
---|---|---|
数字作成 | 桁数(integer ) |
数字(integer ) |
韓国語表記生成 | 数字(integer ) |
韓国語表記(string ) |
音声生成 | 数字(integer ) |
生成した音声ファイルのパス |
UI側では、次の機能が必要と予想されています。
-
TRY IT
ボタン:数字の生成、および韓国語表記や音声の生成を開始するボタンです。次の練習を始める際にも、このボタンを使用します。 -
Answer
ボタン:韓国語の表記を表示するボタンです。 - 音声再生:数字の韓国語音声を再生するためのUIコンポーネントです。
- 桁入力フォーム:ユーザーが練習したい数字の桁数を指定するための入力フォームです。
これから、上記の機能を実現するためのコードについて説明していきます。
4. コードの説明
リポジトリ内のディレクトリ構成は以下の通りです。
korean_num/
├ resources/ # スクリーンショットなど、アプリ本体とは直接関係のないファイル
├ src/
│ └ __init__.py
│ └ app.py # APIサーバーのエントリーポイント
│ └ client.py # APIを呼び出すモジュール
│ └ config.py # 環境設定や定数を管理
│ └ router.py # ルーティング処理を定義
│ └ schemas.py # APIのリクエスト/レスポンス用のスキーマ
│ └ utils.py # 数字生成や韓国語表記生成のユーティリティ関数
│ └ main.py # ユーザーインターフェースの主要な処理
└ tests/ # テストコード
詳細なコードは、以下のリンクで確認できます:
API
上記の機能を実現するため、3つのルーターは主にrouter.py
に実装されています。
ルーター①:数字作成
指定された桁数に基づいてランダムな数字を生成する関数はutils.py
に記載されています。以下がそのコードです:
def generate_number(digit: int) -> int:
current_number = random.randint(10 ** (digit - 1), 10**digit - 1)
return current_number
ルーター:
@routers.get("/get_number", response_model=CreateNumResponseSchema)
def generate_num(digit: int = 4):
if digit > 5:
raise HTTPException(
status_code=403, detail="not available number (must be smaller than 100k)"
)
current_number = generate_number(digit)
print(current_number)
return CreateNumResponseSchema(current_number=current_number)
レスポンス用のスキーマ(CreateNumResponseSchema
)は、以下のようにschemas.py
内で定義されています。
class CreateNumResponseSchema(BaseModel):
current_number: int
ルーター②:韓国語表記生成
ルーター①で生成された数字を韓国語表記に変換するためのルーターを作成します。
まず、韓国語表記に変換するための変数を以下のようにutils.py
内で定義します。
def number_to_korean(number):
units = ["", "일", "이", "삼", "사", "오", "육", "칠", "팔", "구"]
tens = ["", "십", "백", "천", "만"]
if number == 0:
return "영"
if number < 10:
return units[number]
# Split the number into individual digits
digits = list(map(int, str(number)))
length = len(digits)
result = ""
for i, digit in enumerate(digits):
if digit != 0:
# Add the unit and tens part
result += units[digit] + tens[length - 1 - i]
# Remove any leading "일" before "십", "백", "천"
result = result.replace("일십", "십").replace("일백", "백").replace("일천", "천")
return result
簡単に説明すると、
- 21を入力すると、
이십일
が生成されます - 2100を入力すると、
이천일
ではなく、이천백
が生成されるようにします
ルーター②は以下のように実装します。
@routers.get("/display_knum", response_model=KoreanNumResponseSchema)
def display_knum(input_number: int):
if input_number > 10**5:
raise HTTPException(
status_code=403, detail="not available number (must be smaller than 100k)"
)
number_display = number_to_korean(input_number)
return {"display_knum": number_display}
レスポンス用のスキーマ(KoreanNumResponseSchema
)は、以下のようにschemas.py
内で定義されています。
class KoreanNumResponseSchema(BaseModel):
display_knum: str
ルーター③:数字の韓国語音声を生成
数字の韓国語音声を生成するためのルーターを作成します。
まず、以下の関数をutils.py
内に定義します:
def create_audio(create_audio_params: CreateAudioSchema) -> str:
"""Create an audio file (.mp3) for the input korean word. Based on Naver TTS API."""
# Create the directory if not exist:
if not create_audio_params.output_path.exists():
create_audio_params.output_path.mkdir(parents=True, exist_ok=True)
audio_filename = create_audio_params.output_path / "temp.mp3"
# Generate the audio file (.mp3):
text = str(create_audio_params.input_number)
tts = NaverTTS(text)
tts.save(audio_filename)
return str(audio_filename)
関数が受け取る変数のスキーマ(CreateAudioSchema
)は、以下のようにschemas.py
内で定義しています:
class CreateAudioSchema(BaseModel):
input_number: int
output_path: Path = conf.data_path
conf
については後ほど説明しますが、ここでは事前に定義した音声の出力場所を指定する変数として使用しています。
音声の生成には、NaverTTS
というパッケージを使っています。
このツールは完全に無料で利用でき、非常に優秀です。
以下のようにルーターを作成します。
@routers.post("/play_audios")
def play_audio(create_audio_request: CreateAudioSchema):
audio_filename = create_audio(create_audio_request)
return audio_filename
API呼び出し用モジュール
必須ではありませんが、UIから作成したルーターを呼び出す際に使用するモジュールをclient.py
として作成します。
class Client:
def __init__(self, conf=conf):
self.backend_url = conf.backend_url
def get(self, path: str, params: dict = {}):
url = f"{self.backend_url}/{self.clean_path(path)}"
with requests.get(url, params=params) as r:
return self._get_json_result(url, r)
def post(self, path: str, json: dict = {}):
url = f"{self.backend_url}/{self.clean_path(path)}"
with requests.post(url, json=json) as r:
return self._get_json_result(url, r)
def clean_path(self, path: str):
if path.startswith("http"):
raise Exception(
f"Wrong path {path}, use with api path directly (no http://xxx..xxx)."
)
if path.startswith("/"):
path = path[1:]
return path
def _get_json_result(self, url: str, r: Response, print_auth_error=True):
if r.status_code > 400 and r.status_code < 500:
if print_auth_error:
print(
f"Unauthorized call. Check your PAT token {r.text} - {r.url} - {url}"
)
try:
return r.json()
except Exception as e:
print(
f"API CALL ERROR - can't read json. status: {r.status_code} {r.text} - URL: {url} - {e}"
)
raise e
これにより、各エンドポイントからのレスポンスは自動的に整形され、UIでAPIを使用する際のコードが簡潔になります。
UIの作成
次に、上記のAPIエンドポイントを呼び出すためのUI画面を作成します(main.py
)。
streamlit
を使用して、効率的にUIを作成していきましょう。
streamlit
の特性として、操作を実行するたびに画面が自動で更新されます。これを防止するため、ルーターのレスポンスはst.session_state
に保存します。
例えば、
TRY IT
ボタンをクリックして数字を生成した場合、その次にAnswer
ボタンをクリックして韓国語表記を確認します。
しかし、Answer
をクリックすると画面が再度更新され、先ほど生成した数字が消えてしまいます。
このような問題を避けるため、状態をst.session_state
に保存して管理します。
コードの構成は以下の通りです。
# Initialize the client
db = Client()
# Function to generate a random number based on a specified digit
def get_number(digit):
response = db.get("get_number/", params={"digit": digit})
return response["current_number"]
# Function to generate the Korean number for display
def get_display_knum(number):
response = db.get("display_knum/", params={"input_number": number})
return response["display_knum"]
# Function to generate audio
def get_audio_path(number):
response = db.post("play_audios", json={"input_number": number})
return response.strip('"')
# Create a text input box
digit = st.number_input("자리", min_value=1, max_value=5, value=4)
if "action1" not in st.session_state:
st.session_state.action1 = False
# Display button for generating number, Korean word, and audio
if st.button("TRY IT"):
st.session_state.action1 = True
# Generate a random number based on a specified digit (e.g., for digit=2, generating 21. )
st.session_state.number = get_number(digit)
# Generate the Korean number for display (e.g., 이십일)
st.session_state.display_knum = get_display_knum(st.session_state.number)
# Generate the audio (.mp3 file)
st.session_state.audio_path = get_audio_path(st.session_state.number)
# Check if the TRY IT button was pressed
# Keep the random number showing on the page
if st.session_state.action1:
# Display the generated random number on UI (e.g., 21)
st.title(f"{st.session_state.number}")
# Allow the user to play the audio
st.audio(st.session_state.audio_path)
# Display the Korean word of the generated random number on UI (e.g., 21)
if st.button("Answer"):
st.write(f"{st.session_state.display_knum}")
環境変数を格納するモジュール
アプリの実行に必要な共通の変数を定義しておきます(config.py
)。
ここでは、環境変数を管理するためにpydantic_settings.BaseSettings
を使用します。
DIR_PATH = Path(__file__).resolve().parent.parent
HOST = os.getenv("HOST", "localhost")
class Config(BaseSettings):
root_path: Path = DIR_PATH
data_path: Path = DIR_PATH / "src" / "data"
backend_url: str = f"http://{HOST}:80"
DIR_PATH
はルートディレクトリのパスを指しています。
注目すべき点として、backend_url
にはFastAPIサーバーのURLが設定されています(ここではポート80を使用しています)。
APIのURL表記には、以下のコードで示される2つのパターンがあります。
HOST = os.getenv("HOST", "localhost")
実際にDockerコンテナーを立ち上げる際、ホスト名は後ほど紹介するdocker-compose
で環境変数として設定しているため、FastAPIのDockerサーバー名を使用しています。一方で、コンテナを起動せずにアプリを立ち上げる場合、APIのURLはlocalhost
に設定します(これにより、テストが簡単になります)。
また、data_path
は先ほど説明したルーター③(音声生成ルーター)で使用していた音声ファイルの出力先を指します。UIで音声を再生するときも、このパスを参照するように設定すれば良いでしょう。
5. アプリの起動
いよいよアプリ本体が完成しました。最後に、作動を確認するためにコンテナを使ってアプリを立ち上げてみます。
docker-compose
は以下のように作成します。
version: '3.8'
services:
api-server:
build:
context: .
dockerfile: Dockerfile
tty: true
volumes:
- ./src/data:/app/src/data # mount the audio output
ports:
- "80:80"
environment:
- HOST=api-server
streamlit:
build:
context: .
dockerfile: DockerfileStreamlit
tty: true
volumes:
- ./src/data:/app/src/data # mount the audio output
ports:
- "8501:8501"
environment:
- HOST=api-server
docker-compose
では、以下の2つのDockerfile
を使用しています。それぞれ、①FastAPIサーバー用(Dockerfile
)と、②Streamlitサーバー用(DockerfileStreamlit
)です。
基本的な流れは共通しており、作業フォルダの指定→コード本体のコピー→poetry
でパッケージのインストール→ポートの指定→サーバーの立ち上げ、という手順になります。
poetry
の詳細な説明はここでは省略しますが、以下に各サーバーで必要なパッケージをまとめています。
# pyproject.toml一部
[tool.poetry.dependencies]
python = "^3.11"
pydantic-settings = "^2.3.3"
pydantic = "^2.7.4"
[tool.poetry.group.fastapi.dependencies]
fastapi = "^0.111.0"
navertts = "^0.3.1"
[tool.poetry.group.streamlit.dependencies]
streamlit = "^1.35.0"
requests = "^2.32.3"
FastAPI
サーバー用のDockerfile
(Dockerfile
)は、以下のように定義しています。
FROM python:3.11-slim
# Configure the working directory
WORKDIR /app
RUN mkdir /app/src
# Copy the necessary files
COPY src/app.py /app/src/app.py
COPY src/config.py /app/src/config.py
COPY src/routers.py /app/src/routers.py
COPY src/schemas.py /app/src/schemas.py
COPY src/utils.py /app/src/utils.py
# Configure poetry-related files and install the dependencies
RUN pip install poetry
COPY pyproject.toml poetry.lock /app/
RUN poetry config virtualenvs.in-project true
RUN poetry install --with fastapi
# Expose port 80
EXPOSE 80
# Launch the api server at port 80
CMD ["poetry", "run", "uvicorn", "src.app:app", "--host", "0.0.0.0", "--port", "80", "--reload"]
Streamlit
サーバー用のDockerfile
(DockerfileStreamlit
)は、以下のように定義しています。
FROM python:3.11-slim
# Configure the working directory
WORKDIR /app
RUN mkdir /app/src
# Copy the necessary files
COPY src/client.py /app/src/client.py
COPY src/main.py /app/src/main.py
COPY src/config.py /app/src/config.py
# Configure poetry-related files and install the dependencies
RUN pip install poetry
COPY pyproject.toml poetry.lock /app/
RUN poetry config virtualenvs.in-project true
RUN poetry install --with streamlit
# Expose port
EXPOSE 8501
# Launch the api server at port 8501
CMD ["poetry", "run", "streamlit", "run", "src/main.py"]
最後に、以下のコマンドでアプリを実行します。
docker-compose up --build
streamlit
はポート8501を使用しているため、ブラウザでlocalhost:8501
にアクセスすると、アプリが動作していることを確認できるはずです!
6. おわりに
韓国語の数字を覚えるためのツールを作成しました。
ただし、Streamlit
でのデプロイにはファイル構成を公式サイトのフォーマット通りにしなければならないようです。今回は残念ながらデプロイは行いませんでしたが、将来的にはAWSなどを使ってデプロイする予定です。