qwen_asr_flask.py ダウンロード/コピー
qwen_asr_flask.py
qwen_asr_flask.py
1#!/usr/bin/env python3
2"""
3概要:
4 1リクエストにつき1つの推論プロセスを起動するOpenAI互換のQwen3-ASR APIです。
5詳細説明:
6 Flaskを使用してHTTPサーバーを構成し、音声ファイルのアップロードを受け付けます。
7 GPUメモリの枯渇を防ぐため、スレッドロックを用いて推論プロセスを直列化します。
8関連リンク:
9 qwen_asr_flask_usage
10"""
11
12from __future__ import annotations
13
14import json
15import os
16import subprocess
17import sys
18import tempfile
19import threading
20from pathlib import Path
21
22from flask import Flask, jsonify, request
23
24
25MODEL = os.environ.get("QWEN_ASR_MODEL", "Qwen/Qwen3-ASR-1.7B")
26RUNNER = Path(os.environ.get("QWEN_ASR_RUNNER", Path(__file__).with_name("qwen_asr_once.py")))
27UPLOAD_DIR = Path(os.environ.get("QWEN_ASR_UPLOAD_DIR", "/var/tmp/qwen-asr-upload"))
28TIMEOUT_SECONDS = int(os.environ.get("QWEN_ASR_TIMEOUT_SECONDS", "3600"))
29INFERENCE_LOCK = threading.Lock()
30
31
32def create_app() -> Flask:
33 """
34 概要:
35 Flaskアプリケーションインスタンスを作成して設定します。
36 詳細説明:
37 ファイルのアップロードサイズの制限を設定し、アップロードディレクトリを作成します。
38 ヘルスチェック、モデルリスト、音声文字起こしの各エンドポイントを内部で定義します。
39 戻り値:
40 :returns: 設定済みのFlaskアプリケーションインスタンス
41 :rtype: flask.Flask
42 """
43 app = Flask(__name__)
44 app.config["MAX_CONTENT_LENGTH"] = 1024 * 1024 * 1024 # 1 GiB upload limit
45 UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
46
47 @app.get("/health")
48 def health():
49 """
50 概要:
51 ヘルスチェック用のエンドポイントです。
52 戻り値:
53 :returns: 空文字列とHTTPステータスコード200のタプル
54 :rtype: tuple
55 """
56 return ("", 200)
57
58 @app.get("/v1/models")
59 def models():
60 """
61 概要:
62 利用可能なモデルのリストを返すエンドポイントです。
63 戻り値:
64 :returns: OpenAI互換のモデルリストを含むJSONレスポンス
65 :rtype: flask.Response
66 """
67 return jsonify({"object": "list", "data": [{"id": MODEL, "object": "model", "owned_by": "qwen-asr-cli"}]})
68
69 @app.post("/v1/audio/transcriptions")
70 def transcriptions():
71 """
72 概要:
73 音声ファイルを受け取り、文字起こし結果を返すエンドポイントです。
74 詳細説明:
75 アップロードされたファイルを一時ディレクトリに保存し、外部プロセスで推論スクリプトを実行します。
76 複数のリクエストが同時に来た場合は、スレッドロックにより順番に処理されます。
77 戻り値:
78 :returns: 文字起こしされたテキストと検出された言語を含むJSONレスポンス、またはエラーメッセージ
79 :rtype: tuple
80 """
81 uploaded = request.files.get("file")
82 if uploaded is None or not uploaded.filename:
83 return jsonify({"error": {"message": "multipart field 'file' is required"}}), 400
84 requested_model = request.form.get("model", MODEL)
85 if requested_model != MODEL:
86 return jsonify({"error": {"message": f"only model {MODEL!r} is available"}}), 400
87
88 suffix = Path(uploaded.filename).suffix.lower() or ".wav"
89 with tempfile.NamedTemporaryFile(dir=UPLOAD_DIR, suffix=suffix, delete=False) as temporary:
90 temporary_path = Path(temporary.name)
91 uploaded.save(temporary)
92 try:
93 # One lock is intentional: concurrent requests must not each load a
94 # 1.7B model on the same GPU. Later requests wait in the HTTP call.
95 with INFERENCE_LOCK:
96 completed = subprocess.run(
97 [sys.executable, str(RUNNER), "--audio", str(temporary_path), "--model", MODEL],
98 text=True,
99 stdout=subprocess.PIPE,
100 stderr=subprocess.PIPE,
101 timeout=TIMEOUT_SECONDS,
102 check=False,
103 )
104 if completed.returncode != 0:
105 message = (completed.stderr or completed.stdout or "Qwen-ASR runner failed").strip()
106 return jsonify({"error": {"message": message[-4000:]}}), 500
107 try:
108 result = json.loads(completed.stdout.strip().splitlines()[-1])
109 except (json.JSONDecodeError, IndexError) as exc:
110 return jsonify({"error": {"message": f"invalid runner output: {completed.stdout[-1000:]}"}}), 500
111 return jsonify({"text": result["text"], "language": result.get("language")})
112 except subprocess.TimeoutExpired:
113 return jsonify({"error": {"message": "ASR request timed out"}}), 504
114 finally:
115 temporary_path.unlink(missing_ok=True)
116
117 return app
118
119
120if __name__ == "__main__":
121 create_app().run(host="0.0.0.0", port=8001, threaded=False)