@minimalcorp/tsunagi 0.1.50 → 0.1.51
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.next/standalone/apps/web/.next/BUILD_ID +1 -1
- package/.next/standalone/apps/web/.next/build-manifest.json +3 -3
- package/.next/standalone/apps/web/.next/prerender-manifest.json +3 -3
- package/.next/standalone/apps/web/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/apps/web/.next/server/app/_global-error.rsc +1 -1
- package/.next/standalone/apps/web/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/apps/web/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/standalone/apps/web/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/.next/standalone/apps/web/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/.next/standalone/apps/web/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/apps/web/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/apps/web/.next/server/app/_not-found.html +1 -1
- package/.next/standalone/apps/web/.next/server/app/_not-found.rsc +2 -2
- package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
- package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
- package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/apps/web/.next/server/app/index.html +1 -1
- package/.next/standalone/apps/web/.next/server/app/index.rsc +2 -2
- package/.next/standalone/apps/web/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/apps/web/.next/server/app/index.segments/_full.segment.rsc +2 -2
- package/.next/standalone/apps/web/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/.next/standalone/apps/web/.next/server/app/index.segments/_index.segment.rsc +2 -2
- package/.next/standalone/apps/web/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/apps/web/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/apps/web/.next/server/app/settings/page.js.nft.json +1 -1
- package/.next/standalone/apps/web/.next/server/app/settings/page_client-reference-manifest.js +1 -1
- package/.next/standalone/apps/web/.next/server/app/settings.html +1 -1
- package/.next/standalone/apps/web/.next/server/app/settings.rsc +3 -3
- package/.next/standalone/apps/web/.next/server/app/settings.segments/_full.segment.rsc +3 -3
- package/.next/standalone/apps/web/.next/server/app/settings.segments/_head.segment.rsc +1 -1
- package/.next/standalone/apps/web/.next/server/app/settings.segments/_index.segment.rsc +2 -2
- package/.next/standalone/apps/web/.next/server/app/settings.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/apps/web/.next/server/app/settings.segments/settings/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/apps/web/.next/server/app/settings.segments/settings.segment.rsc +1 -1
- package/.next/standalone/apps/web/.next/server/app/tasks/[id]/page.js.nft.json +1 -1
- package/.next/standalone/apps/web/.next/server/app/tasks/[id]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0m9m3y5._.js +3 -0
- package/.next/standalone/apps/web/.next/server/chunks/ssr/_0bru-z.._.js +3 -0
- package/.next/standalone/apps/web/.next/server/chunks/ssr/_0ejfw_6._.js +3 -0
- package/.next/standalone/apps/web/.next/server/chunks/ssr/apps_web_src_app_settings_page_tsx_0i6itlb._.js +3 -1
- package/.next/standalone/apps/web/.next/server/chunks/ssr/apps_web_src_app_tasks_[id]_page_tsx_0jshsm2._.js +12 -12
- package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0s5.073._.js → node_modules_02g1ce1._.js} +1 -1
- package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0kuu0i_._.js → node_modules_09jfkt3._.js} +1 -1
- package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0rgnv~i._.js → node_modules_0c2_xkv._.js} +1 -1
- package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0g-5xh6._.js → node_modules_0j9ao3l._.js} +1 -1
- package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0-nk4sk._.js → node_modules_0k-a_k6._.js} +1 -1
- package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0wg2e2t._.js → node_modules_0ne_55f._.js} +1 -1
- package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0-yxz.k._.js → node_modules_0o9r9wn._.js} +1 -1
- package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_10vm5h.._.js → node_modules_0wrve0t._.js} +1 -1
- package/.next/standalone/apps/web/.next/server/middleware-build-manifest.js +3 -3
- package/.next/standalone/apps/web/.next/server/pages/404.html +1 -1
- package/.next/standalone/apps/web/.next/server/pages/500.html +1 -1
- package/.next/standalone/apps/web/.next/server/server-reference-manifest.js +1 -1
- package/.next/standalone/apps/web/.next/server/server-reference-manifest.json +1 -1
- package/.next/standalone/apps/web/.next/static/chunks/008lct.76g--1.js +1 -0
- package/.next/standalone/apps/web/.next/static/chunks/0gpkw.1e6kcpn.js +1 -0
- package/.next/standalone/apps/web/.next/static/chunks/0k4w860cwzuyy.css +4 -0
- package/.next/standalone/apps/web/.next/static/chunks/0v6gxm~ldhmma.js +1 -0
- package/.next/standalone/apps/web/.next/static/chunks/{0bhssqb0~3~7n.js → 0wv-.6vqubfpy.js} +12 -12
- package/.next/standalone/apps/web/.next/static/chunks/0xy-wfkab7_5n.js +7 -0
- package/dist/cli.js +0 -1
- package/dist/server/index.js +0 -5
- package/dist/server/lib/asr-models.d.ts +7 -0
- package/dist/server/lib/asr-models.js +51 -0
- package/dist/server/lib/process-port.js +1 -1
- package/dist/server/lib/voice-debug-log.d.ts +4 -4
- package/dist/server/lib/voice-debug-log.js +1 -1
- package/dist/server/lib/whisper-process.d.ts +19 -2
- package/dist/server/lib/whisper-process.js +123 -26
- package/dist/server/routes/whisper.js +37 -56
- package/dist/whisper-server/README.md +11 -3
- package/dist/whisper-server/download_model.py +8 -3
- package/dist/whisper-server/requirements.txt +1 -0
- package/dist/whisper-server/run.sh +7 -2
- package/dist/whisper-server/server.py +57 -29
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/.next/standalone/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0m52zsj._.js +0 -3
- package/.next/standalone/apps/web/.next/server/chunks/ssr/_0ydcfna._.js +0 -3
- package/.next/standalone/apps/web/.next/server/chunks/ssr/_0zu_05y._.js +0 -3
- package/.next/standalone/apps/web/.next/server/chunks/ssr/node_modules_0-bra0k._.js +0 -3
- package/.next/standalone/apps/web/.next/server/chunks/ssr/node_modules_lucide-react_dist_esm_icons_0qttwc4._.js +0 -3
- package/.next/standalone/apps/web/.next/static/chunks/0n.xodexdkf03.js +0 -1
- package/.next/standalone/apps/web/.next/static/chunks/0pg.320d4u8z5.js +0 -1
- package/.next/standalone/apps/web/.next/static/chunks/0plamn018wq.u.css +0 -4
- package/.next/standalone/apps/web/.next/static/chunks/0ywpi-uf71k8s.js +0 -1
- package/.next/standalone/apps/web/.next/static/chunks/12av8cpq.8slk.js +0 -5
- package/dist/llm-server/README.md +0 -27
- package/dist/llm-server/download_model.py +0 -14
- package/dist/llm-server/requirements.txt +0 -2
- package/dist/llm-server/run.sh +0 -59
- package/dist/server/lib/llm-process.d.ts +0 -29
- package/dist/server/lib/llm-process.js +0 -261
- package/dist/server/routes/llm.d.ts +0 -2
- package/dist/server/routes/llm.js +0 -23
- /package/.next/standalone/apps/web/.next/static/{w7do3hyV84oyognhRCQuA → 1z7N6ZhcQwVJOkcsbIi4M}/_buildManifest.js +0 -0
- /package/.next/standalone/apps/web/.next/static/{w7do3hyV84oyognhRCQuA → 1z7N6ZhcQwVJOkcsbIi4M}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/apps/web/.next/static/{w7do3hyV84oyognhRCQuA → 1z7N6ZhcQwVJOkcsbIi4M}/_ssgManifest.js +0 -0
|
@@ -3,6 +3,7 @@ import * as fs from 'node:fs';
|
|
|
3
3
|
import * as os from 'node:os';
|
|
4
4
|
import * as path from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { ASR_MODELS, findAsrModel, findAsrModelByRepo, getVoiceInputSettings, saveVoiceInputSettings, } from './asr-models.js';
|
|
6
7
|
import { killProcessOnPort } from './process-port.js';
|
|
7
8
|
import { findPython, isVenvUpToDate, PYTHON_NOT_FOUND_MESSAGE, writeVenvMarker, } from './python-venv.js';
|
|
8
9
|
const WHISPER_SERVER_URL = process.env.TSUNAGI_WHISPER_SERVER_URL || 'http://127.0.0.1:8765';
|
|
@@ -13,13 +14,13 @@ const WHISPER_PORT = 8765;
|
|
|
13
14
|
const TSUNAGI_WHISPER_DIR = path.join(os.homedir(), '.tsunagi', 'whisper');
|
|
14
15
|
const VENV_DIR = path.join(TSUNAGI_WHISPER_DIR, 'venv');
|
|
15
16
|
const HF_CACHE_DIR = path.join(TSUNAGI_WHISPER_DIR, 'cache');
|
|
16
|
-
const MODEL_CACHE_DIR = path.join(HF_CACHE_DIR, 'hub', 'models--mlx-community--whisper-large-v3-turbo');
|
|
17
|
-
// 実測値(2026-07時点、encoder+decoder等の合計)。多少の変動はあるがETA計算の目安として使う。
|
|
18
|
-
const EXPECTED_MODEL_BYTES = 1_614_000_000;
|
|
19
17
|
// starting_serverフェーズ(spawn後にhealthyになるまで待つ時間)の上限。
|
|
20
|
-
// 遅いマシンでのPython起動/import/Metal
|
|
18
|
+
// 遅いマシンでのPython起動/import/Metal初期化に加え、Qwen3-ASRは起動時にモデル(最大約4GB)を
|
|
19
|
+
// メモリへ読み込むため、そのばらつきを吸収できるよう120秒に設定。
|
|
21
20
|
// プロセスが起動途中でクラッシュした場合はこのタイムアウトを待たず即座に検知する。
|
|
22
|
-
const STARTUP_TIMEOUT_MS =
|
|
21
|
+
const STARTUP_TIMEOUT_MS = 120_000;
|
|
22
|
+
// モデル切り替え時、停止したサーバーのポートが空くまで待つ時間の上限。
|
|
23
|
+
const SHUTDOWN_TIMEOUT_MS = 15_000;
|
|
23
24
|
// クラッシュ時にエラーメッセージへ含めるstderrの上限文字数。
|
|
24
25
|
const STDERR_TAIL_MAX_CHARS = 4000;
|
|
25
26
|
// このファイルの位置から whisper-server を探す:
|
|
@@ -36,8 +37,12 @@ export function findWhisperServerDir() {
|
|
|
36
37
|
function venvPython() {
|
|
37
38
|
return path.join(VENV_DIR, 'bin', 'python3');
|
|
38
39
|
}
|
|
39
|
-
|
|
40
|
-
|
|
40
|
+
// huggingface_hubのキャッシュ構成(hub/models--{owner}--{name})に合わせたモデルの保存先。
|
|
41
|
+
function modelCacheDir(repo) {
|
|
42
|
+
return path.join(HF_CACHE_DIR, 'hub', `models--${repo.replace('/', '--')}`);
|
|
43
|
+
}
|
|
44
|
+
function isModelReady(repo) {
|
|
45
|
+
const snapshotsDir = path.join(modelCacheDir(repo), 'snapshots');
|
|
41
46
|
if (!fs.existsSync(snapshotsDir))
|
|
42
47
|
return false;
|
|
43
48
|
return fs.readdirSync(snapshotsDir).length > 0;
|
|
@@ -47,31 +52,53 @@ let downloadProgress;
|
|
|
47
52
|
let lastError;
|
|
48
53
|
let managedProcess = null;
|
|
49
54
|
let setupPromise = null;
|
|
55
|
+
// セットアップ中・tsunagi管理で起動中のモデル
|
|
56
|
+
let targetModel = null;
|
|
50
57
|
function sleep(ms) {
|
|
51
58
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
52
59
|
}
|
|
60
|
+
// 起動していれば読み込み中のモデルのリポジトリIDを返す(未起動ならnull)。
|
|
61
|
+
// tsunagi外で起動されたサーバーでも、どのモデルで動いているかを知るために/healthから取る。
|
|
53
62
|
async function checkHealth() {
|
|
54
63
|
try {
|
|
55
64
|
const response = await fetch(`${WHISPER_SERVER_URL}/health`, {
|
|
56
65
|
signal: AbortSignal.timeout(2000),
|
|
57
66
|
});
|
|
58
|
-
|
|
67
|
+
if (!response.ok)
|
|
68
|
+
return null;
|
|
69
|
+
const body = (await response.json().catch(() => ({})));
|
|
70
|
+
return { repo: typeof body.model === 'string' ? body.model : null };
|
|
59
71
|
}
|
|
60
72
|
catch {
|
|
61
|
-
return
|
|
73
|
+
return null;
|
|
62
74
|
}
|
|
63
75
|
}
|
|
64
76
|
export async function getWhisperServerStatus() {
|
|
65
77
|
const serverDir = findWhisperServerDir();
|
|
66
78
|
// セットアップ/起動フロー進行中はそのステップをそのまま報告する。
|
|
67
79
|
if (setupPromise) {
|
|
68
|
-
return {
|
|
80
|
+
return {
|
|
81
|
+
step: currentStep,
|
|
82
|
+
serverDir,
|
|
83
|
+
modelId: targetModel?.id ?? null,
|
|
84
|
+
downloadProgress,
|
|
85
|
+
error: lastError,
|
|
86
|
+
};
|
|
69
87
|
}
|
|
70
|
-
const
|
|
71
|
-
if (
|
|
72
|
-
return {
|
|
88
|
+
const health = await checkHealth();
|
|
89
|
+
if (health) {
|
|
90
|
+
return {
|
|
91
|
+
step: managedProcess ? 'running' : 'running_external',
|
|
92
|
+
serverDir,
|
|
93
|
+
modelId: (health.repo && findAsrModelByRepo(health.repo)?.id) ?? null,
|
|
94
|
+
};
|
|
73
95
|
}
|
|
74
|
-
return {
|
|
96
|
+
return {
|
|
97
|
+
step: lastError ? 'error' : 'not_running',
|
|
98
|
+
serverDir,
|
|
99
|
+
modelId: null,
|
|
100
|
+
error: lastError,
|
|
101
|
+
};
|
|
75
102
|
}
|
|
76
103
|
function runStep(cmd, args, cwd, env) {
|
|
77
104
|
return new Promise((resolve, reject) => {
|
|
@@ -87,8 +114,8 @@ function runStep(cmd, args, cwd, env) {
|
|
|
87
114
|
}
|
|
88
115
|
// モデルDL中(huggingface_hubがblobs/配下に *.incomplete を書き続ける)のファイルサイズを
|
|
89
116
|
// 定期的にポーリングし、進捗(%)と直近の転送速度からのETAを算出する。
|
|
90
|
-
async function trackModelDownload(child) {
|
|
91
|
-
const blobsDir = path.join(
|
|
117
|
+
async function trackModelDownload(child, model) {
|
|
118
|
+
const blobsDir = path.join(modelCacheDir(model.repo), 'blobs');
|
|
92
119
|
let lastBytes = 0;
|
|
93
120
|
let lastTime = Date.now();
|
|
94
121
|
let stopped = false;
|
|
@@ -111,7 +138,7 @@ async function trackModelDownload(child) {
|
|
|
111
138
|
const now = Date.now();
|
|
112
139
|
const elapsedSec = (now - lastTime) / 1000;
|
|
113
140
|
const bytesPerSec = elapsedSec > 0 ? (downloaded - lastBytes) / elapsedSec : 0;
|
|
114
|
-
const remaining =
|
|
141
|
+
const remaining = model.expectedBytes - downloaded;
|
|
115
142
|
// 転送が一時的に止まって見える(ポーリング間隔とディスク書き込みのタイミングがずれる)だけで
|
|
116
143
|
// 速度0になることがあるため、その場合は直前のETAを維持し「計算中...」への逆戻りを防ぐ。
|
|
117
144
|
const etaSeconds = bytesPerSec > 0
|
|
@@ -119,15 +146,21 @@ async function trackModelDownload(child) {
|
|
|
119
146
|
: (downloadProgress?.etaSeconds ?? null);
|
|
120
147
|
downloadProgress = {
|
|
121
148
|
downloadedBytes: downloaded,
|
|
122
|
-
totalBytes:
|
|
149
|
+
totalBytes: model.expectedBytes,
|
|
123
150
|
etaSeconds,
|
|
124
151
|
};
|
|
125
152
|
lastBytes = downloaded;
|
|
126
153
|
lastTime = now;
|
|
127
154
|
}
|
|
128
155
|
}
|
|
129
|
-
async function runSetupAndStart(dir) {
|
|
130
|
-
const hfEnv = {
|
|
156
|
+
async function runSetupAndStart(dir, model) {
|
|
157
|
+
const hfEnv = {
|
|
158
|
+
...process.env,
|
|
159
|
+
HF_HOME: HF_CACHE_DIR,
|
|
160
|
+
HF_HUB_DISABLE_XET: '1',
|
|
161
|
+
TSUNAGI_ASR_ENGINE: model.engine,
|
|
162
|
+
TSUNAGI_ASR_MODEL: model.repo,
|
|
163
|
+
};
|
|
131
164
|
const requirementsFile = path.join(dir, 'requirements.txt');
|
|
132
165
|
if (!isVenvUpToDate(VENV_DIR, requirementsFile)) {
|
|
133
166
|
currentStep = 'installing_deps';
|
|
@@ -140,16 +173,16 @@ async function runSetupAndStart(dir) {
|
|
|
140
173
|
await runStep(venvPython(), ['-m', 'pip', 'install', '-r', 'requirements.txt'], dir);
|
|
141
174
|
writeVenvMarker(VENV_DIR, requirementsFile);
|
|
142
175
|
}
|
|
143
|
-
if (!isModelReady()) {
|
|
176
|
+
if (!isModelReady(model.repo)) {
|
|
144
177
|
currentStep = 'downloading_model';
|
|
145
|
-
downloadProgress = { downloadedBytes: 0, totalBytes:
|
|
178
|
+
downloadProgress = { downloadedBytes: 0, totalBytes: model.expectedBytes, etaSeconds: null };
|
|
146
179
|
await new Promise((resolve, reject) => {
|
|
147
|
-
const child = spawn(venvPython(), ['download_model.py'], {
|
|
180
|
+
const child = spawn(venvPython(), ['download_model.py', model.repo], {
|
|
148
181
|
cwd: dir,
|
|
149
182
|
stdio: 'ignore',
|
|
150
183
|
env: hfEnv,
|
|
151
184
|
});
|
|
152
|
-
void trackModelDownload(child);
|
|
185
|
+
void trackModelDownload(child, model);
|
|
153
186
|
child.on('exit', (code) => {
|
|
154
187
|
if (code === 0)
|
|
155
188
|
resolve();
|
|
@@ -190,7 +223,8 @@ async function runSetupAndStart(dir) {
|
|
|
190
223
|
}
|
|
191
224
|
throw new Error(`Server did not become healthy within ${STARTUP_TIMEOUT_MS / 1000}s of starting`);
|
|
192
225
|
}
|
|
193
|
-
|
|
226
|
+
// 設定で選択中のモデルでセットアップ・起動する。
|
|
227
|
+
export async function startWhisperServer() {
|
|
194
228
|
if (setupPromise || managedProcess) {
|
|
195
229
|
return { started: false, error: 'Already starting/running' };
|
|
196
230
|
}
|
|
@@ -198,8 +232,16 @@ export function startWhisperServer() {
|
|
|
198
232
|
if (!dir) {
|
|
199
233
|
return { started: false, error: 'whisper-server directory not found' };
|
|
200
234
|
}
|
|
235
|
+
const { modelId } = await getVoiceInputSettings();
|
|
236
|
+
// getVoiceInputSettingsが一覧にあるIDだけを返すため必ず見つかる
|
|
237
|
+
const model = findAsrModel(modelId);
|
|
238
|
+
// 設定の読み込みを待つ間に別のリクエストで起動が始まった場合に備えて再確認する
|
|
239
|
+
if (setupPromise || managedProcess) {
|
|
240
|
+
return { started: false, error: 'Already starting/running' };
|
|
241
|
+
}
|
|
201
242
|
lastError = undefined;
|
|
202
|
-
|
|
243
|
+
targetModel = model;
|
|
244
|
+
setupPromise = runSetupAndStart(dir, model)
|
|
203
245
|
.catch((error) => {
|
|
204
246
|
currentStep = 'error';
|
|
205
247
|
lastError = error instanceof Error ? error.message : String(error);
|
|
@@ -213,6 +255,7 @@ export async function stopWhisperServer() {
|
|
|
213
255
|
if (managedProcess) {
|
|
214
256
|
managedProcess.kill();
|
|
215
257
|
managedProcess = null;
|
|
258
|
+
targetModel = null;
|
|
216
259
|
currentStep = 'not_running';
|
|
217
260
|
return { stopped: true };
|
|
218
261
|
}
|
|
@@ -234,3 +277,57 @@ export function stopWhisperServerOnExit() {
|
|
|
234
277
|
managedProcess?.kill();
|
|
235
278
|
managedProcess = null;
|
|
236
279
|
}
|
|
280
|
+
async function waitUntilStopped() {
|
|
281
|
+
const start = Date.now();
|
|
282
|
+
while (Date.now() - start < SHUTDOWN_TIMEOUT_MS) {
|
|
283
|
+
if (!(await checkHealth()))
|
|
284
|
+
return true;
|
|
285
|
+
await sleep(500);
|
|
286
|
+
}
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
export async function listAsrModels() {
|
|
290
|
+
const { modelId } = await getVoiceInputSettings();
|
|
291
|
+
return {
|
|
292
|
+
models: ASR_MODELS.map((m) => ({ ...m, installed: isModelReady(m.repo) })),
|
|
293
|
+
selectedModelId: modelId,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
// 使用するモデルを切り替える。サーバーが別のモデルで起動中なら、停止して
|
|
297
|
+
// 新しいモデルで起動し直す(未ダウンロードならダウンロードから行う)。
|
|
298
|
+
// 停止中の場合は設定の保存のみ行い、次回起動時にそのモデルを読み込む。
|
|
299
|
+
export async function selectAsrModel(modelId) {
|
|
300
|
+
if (!findAsrModel(modelId))
|
|
301
|
+
return { ok: false, error: `Unknown model: ${modelId}` };
|
|
302
|
+
if (setupPromise) {
|
|
303
|
+
return { ok: false, error: 'サーバーのセットアップ中はモデルを切り替えられません' };
|
|
304
|
+
}
|
|
305
|
+
await saveVoiceInputSettings({ modelId });
|
|
306
|
+
const status = await getWhisperServerStatus();
|
|
307
|
+
const running = status.step === 'running' || status.step === 'running_external';
|
|
308
|
+
if (!running || status.modelId === modelId)
|
|
309
|
+
return { ok: true, restarted: false };
|
|
310
|
+
const stopped = await stopWhisperServer();
|
|
311
|
+
if (!stopped.stopped)
|
|
312
|
+
return { ok: false, error: stopped.error ?? 'サーバーを停止できませんでした' };
|
|
313
|
+
if (!(await waitUntilStopped())) {
|
|
314
|
+
return { ok: false, error: 'サーバーの停止を確認できませんでした' };
|
|
315
|
+
}
|
|
316
|
+
const started = await startWhisperServer();
|
|
317
|
+
if (!started.started)
|
|
318
|
+
return { ok: false, error: started.error ?? 'サーバーを起動できませんでした' };
|
|
319
|
+
return { ok: true, restarted: true };
|
|
320
|
+
}
|
|
321
|
+
// ダウンロード済みのモデルを削除してディスク容量を空ける。
|
|
322
|
+
// 使用中(セットアップ中・起動中)のモデルは削除できない。
|
|
323
|
+
export async function deleteAsrModel(modelId) {
|
|
324
|
+
const model = findAsrModel(modelId);
|
|
325
|
+
if (!model)
|
|
326
|
+
return { ok: false, error: `Unknown model: ${modelId}` };
|
|
327
|
+
const status = await getWhisperServerStatus();
|
|
328
|
+
if (status.modelId === modelId) {
|
|
329
|
+
return { ok: false, error: '使用中のモデルは削除できません。先にサーバーを停止してください' };
|
|
330
|
+
}
|
|
331
|
+
fs.rmSync(modelCacheDir(model.repo), { recursive: true, force: true });
|
|
332
|
+
return { ok: true };
|
|
333
|
+
}
|
|
@@ -1,15 +1,8 @@
|
|
|
1
|
-
import { getWhisperServerStatus, startWhisperServer, stopWhisperServer, } from '../lib/whisper-process.js';
|
|
2
|
-
import { generateLlmCompletion } from '../lib/llm-process.js';
|
|
1
|
+
import { deleteAsrModel, getWhisperServerStatus, listAsrModels, selectAsrModel, startWhisperServer, stopWhisperServer, } from '../lib/whisper-process.js';
|
|
3
2
|
import { appendVoiceDebugLog } from '../lib/voice-debug-log.js';
|
|
4
|
-
//
|
|
5
|
-
// (
|
|
6
|
-
// プロセスの起動・停止のみ管理する。
|
|
3
|
+
// ローカル常駐の音声認識サーバー(apps/whisper-server)を指す。
|
|
4
|
+
// 読み込むモデル(Whisper / Qwen3-ASR)は起動時に whisper-process 側で指定する。
|
|
7
5
|
const WHISPER_SERVER_URL = process.env.TSUNAGI_WHISPER_SERVER_URL || 'http://127.0.0.1:8765';
|
|
8
|
-
// 文字起こし結果のLLM整形に使うシステムプロンプト(既定値)。Settings画面で
|
|
9
|
-
// 上書きされなかった場合に使う。同音異義語の誤変換(例:「句読点」→「句頭点」、
|
|
10
|
-
// 「誤字」→「語字」)を放置してしまう問題があったため、該当パターンを明示して
|
|
11
|
-
// 一語ずつ文脈と照合するよう指示している。
|
|
12
|
-
const DEFAULT_TRANSCRIPTION_CORRECTION_SYSTEM_PROMPT = '以下は音声認識(Whisper)による文字起こし結果です。元の言葉遣いや言い回し、文章構成はできるだけそのまま維持してください。行ってよいのは次の2つだけです。(1)句読点の追加。(2)音声認識特有の誤字の修正。日本語の音声認識では、発音が同じで意味の異なる漢字に変換されることがよくあります(例:「句読点」→「句頭点」、「誤字」→「語字」など)。一見すると自然な単語に見えても文脈に合わない場合は誤変換である可能性が高いため、一語ずつ文脈に合っているか確認し、明らかな誤りは正しい漢字に修正してください。要約・言い換え・フィラーの除去・文の並び替えなど、上記2つ以外の変更は行わないでください。修正後の文章のみを出力し、前置きや説明は不要です。';
|
|
13
6
|
export async function whisperRoutes(fastify) {
|
|
14
7
|
// GET /whisper/server/status
|
|
15
8
|
fastify.get('/whisper/server/status', async (_request, reply) => {
|
|
@@ -17,7 +10,7 @@ export async function whisperRoutes(fastify) {
|
|
|
17
10
|
});
|
|
18
11
|
// POST /whisper/server/start
|
|
19
12
|
fastify.post('/whisper/server/start', async (_request, reply) => {
|
|
20
|
-
const result = startWhisperServer();
|
|
13
|
+
const result = await startWhisperServer();
|
|
21
14
|
if (!result.started) {
|
|
22
15
|
return reply.status(409).send({ error: result.error });
|
|
23
16
|
}
|
|
@@ -31,6 +24,30 @@ export async function whisperRoutes(fastify) {
|
|
|
31
24
|
}
|
|
32
25
|
return reply.status(200).send(await getWhisperServerStatus());
|
|
33
26
|
});
|
|
27
|
+
// GET /whisper/models
|
|
28
|
+
fastify.get('/whisper/models', async (_request, reply) => {
|
|
29
|
+
return reply.status(200).send({ data: await listAsrModels() });
|
|
30
|
+
});
|
|
31
|
+
// PUT /whisper/models/selected
|
|
32
|
+
fastify.put('/whisper/models/selected', async (request, reply) => {
|
|
33
|
+
const modelId = request.body?.modelId;
|
|
34
|
+
if (typeof modelId !== 'string') {
|
|
35
|
+
return reply.status(400).send({ error: 'modelId is required' });
|
|
36
|
+
}
|
|
37
|
+
const result = await selectAsrModel(modelId);
|
|
38
|
+
if (!result.ok) {
|
|
39
|
+
return reply.status(409).send({ error: result.error });
|
|
40
|
+
}
|
|
41
|
+
return reply.status(200).send({ data: await listAsrModels() });
|
|
42
|
+
});
|
|
43
|
+
// DELETE /whisper/models/:modelId
|
|
44
|
+
fastify.delete('/whisper/models/:modelId', async (request, reply) => {
|
|
45
|
+
const result = await deleteAsrModel(request.params.modelId);
|
|
46
|
+
if (!result.ok) {
|
|
47
|
+
return reply.status(409).send({ error: result.error });
|
|
48
|
+
}
|
|
49
|
+
return reply.status(200).send({ data: await listAsrModels() });
|
|
50
|
+
});
|
|
34
51
|
// POST /whisper/transcribe
|
|
35
52
|
fastify.post('/whisper/transcribe', async (request, reply) => {
|
|
36
53
|
const file = await request.file();
|
|
@@ -41,25 +58,18 @@ export async function whisperRoutes(fastify) {
|
|
|
41
58
|
// whisper-serverのinitial_promptとしてそのまま転送する。
|
|
42
59
|
const promptField = file.fields.prompt;
|
|
43
60
|
const prompt = typeof promptField?.value === 'string' ? promptField.value : undefined;
|
|
44
|
-
// 発話中の途中経過表示用のリクエスト。確定前の暫定結果なので、
|
|
45
|
-
//
|
|
61
|
+
// 発話中の途中経過表示用のリクエスト。確定前の暫定結果なので、
|
|
62
|
+
// デバッグログはスキップする。
|
|
46
63
|
const interimField = file.fields.interim;
|
|
47
64
|
const interim = interimField?.value === 'true';
|
|
48
|
-
// ローカルLLMによる整形を使うかどうかはクライアント(Settingsの有効/無効フラグ)から渡される。
|
|
49
|
-
const useLlmField = file.fields.useLlm;
|
|
50
|
-
const useLlm = !interim && useLlmField?.value === 'true';
|
|
51
|
-
// LLM整形用システムプロンプトもSettings画面で編集・保存でき、指定があれば
|
|
52
|
-
// 既定のプロンプトの代わりに使う(空文字列の場合は既定のプロンプトを使う)。
|
|
53
|
-
const systemPromptField = file.fields.systemPrompt;
|
|
54
|
-
const systemPrompt = typeof systemPromptField?.value === 'string' && systemPromptField.value.trim()
|
|
55
|
-
? systemPromptField.value
|
|
56
|
-
: DEFAULT_TRANSCRIPTION_CORRECTION_SYSTEM_PROMPT;
|
|
57
65
|
const buffer = await file.toBuffer();
|
|
58
66
|
const body = new FormData();
|
|
59
67
|
body.append('file', new Blob([buffer]), file.filename);
|
|
60
68
|
if (prompt)
|
|
61
69
|
body.append('prompt', prompt);
|
|
62
70
|
let whisperText;
|
|
71
|
+
let model;
|
|
72
|
+
const startedAt = Date.now();
|
|
63
73
|
try {
|
|
64
74
|
const response = await fetch(`${WHISPER_SERVER_URL}/transcribe`, {
|
|
65
75
|
method: 'POST',
|
|
@@ -70,6 +80,7 @@ export async function whisperRoutes(fastify) {
|
|
|
70
80
|
}
|
|
71
81
|
const result = (await response.json());
|
|
72
82
|
whisperText = result.text;
|
|
83
|
+
model = result.model;
|
|
73
84
|
}
|
|
74
85
|
catch (error) {
|
|
75
86
|
fastify.log.error(error, 'Whisper transcription error');
|
|
@@ -79,44 +90,14 @@ export async function whisperRoutes(fastify) {
|
|
|
79
90
|
: 'Transcription failed. Is apps/whisper-server running?',
|
|
80
91
|
});
|
|
81
92
|
}
|
|
82
|
-
if (interim) {
|
|
83
|
-
return reply.status(200).send({ text: whisperText });
|
|
84
|
-
}
|
|
85
|
-
if (!useLlm || !whisperText) {
|
|
86
|
-
appendVoiceDebugLog({ whisperPrompt: prompt, whisperText, useLlm });
|
|
87
|
-
return reply.status(200).send({ text: whisperText });
|
|
88
|
-
}
|
|
89
|
-
// LLMによる整形が有効な場合のみ追加で問い合わせる。文字起こし自体は既に
|
|
90
|
-
// 成功しているため、ここで失敗しても文字起こし結果はそのまま返す
|
|
91
|
-
// (ユーザーの発話内容を無駄にしないことを、整形の成否より優先する)。
|
|
92
|
-
try {
|
|
93
|
-
const corrected = await generateLlmCompletion([
|
|
94
|
-
{ role: 'system', content: systemPrompt },
|
|
95
|
-
{ role: 'user', content: whisperText },
|
|
96
|
-
], Math.min(4096, Math.max(512, whisperText.length * 4)));
|
|
97
|
-
const correctedText = corrected.trim();
|
|
98
|
-
appendVoiceDebugLog({
|
|
99
|
-
whisperPrompt: prompt,
|
|
100
|
-
whisperText,
|
|
101
|
-
useLlm,
|
|
102
|
-
llmSystemPrompt: systemPrompt,
|
|
103
|
-
correctedText,
|
|
104
|
-
});
|
|
105
|
-
return reply.status(200).send({ text: correctedText });
|
|
106
|
-
}
|
|
107
|
-
catch (error) {
|
|
108
|
-
fastify.log.error(error, 'LLM correction failed, falling back to raw transcription');
|
|
93
|
+
if (!interim) {
|
|
109
94
|
appendVoiceDebugLog({
|
|
95
|
+
model,
|
|
96
|
+
elapsedMs: Date.now() - startedAt,
|
|
110
97
|
whisperPrompt: prompt,
|
|
111
98
|
whisperText,
|
|
112
|
-
useLlm,
|
|
113
|
-
llmSystemPrompt: systemPrompt,
|
|
114
|
-
llmError: error instanceof Error ? error.message : String(error),
|
|
115
|
-
});
|
|
116
|
-
return reply.status(200).send({
|
|
117
|
-
text: whisperText,
|
|
118
|
-
warning: 'ローカルLLMによる整形に失敗したため、文字起こし結果をそのまま使用しました',
|
|
119
99
|
});
|
|
120
100
|
}
|
|
101
|
+
return reply.status(200).send({ text: whisperText });
|
|
121
102
|
});
|
|
122
103
|
}
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
# whisper-server
|
|
2
2
|
|
|
3
|
-
ローカル音声入力用サーバー。
|
|
3
|
+
ローカル音声入力用サーバー。Apple Silicon GPU (MLX) で文字起こしする。
|
|
4
|
+
モデルは起動時の環境変数で切り替える(tsunagiのSettingsで選択したものが渡される)。
|
|
5
|
+
|
|
6
|
+
| `TSUNAGI_ASR_ENGINE` | `TSUNAGI_ASR_MODEL` の例 | 実装 |
|
|
7
|
+
| -------------------- | ------------------------------------------------------------------ | ------------- |
|
|
8
|
+
| `whisper` (既定) | `mlx-community/whisper-large-v3-turbo` | mlx-whisper |
|
|
9
|
+
| `qwen3-asr` | `mlx-community/Qwen3-ASR-1.7B-8bit`, `neosophie/Qwen3-ASR-1.7B-JA` | mlx-qwen3-asr |
|
|
10
|
+
|
|
11
|
+
選択肢の一覧は `apps/server/src/lib/asr-models.ts` で定義する。
|
|
4
12
|
tsunagi本体はこのサーバーにHTTPでプロキシするだけで、セットアップ・起動は行わない。
|
|
5
13
|
|
|
6
14
|
## 要件
|
|
@@ -17,7 +25,7 @@ cd apps/whisper-server
|
|
|
17
25
|
./run.sh
|
|
18
26
|
```
|
|
19
27
|
|
|
20
|
-
|
|
21
|
-
`http://127.0.0.1:8765
|
|
28
|
+
初回はvenvを作成し`requirements.txt`の依存関係(mlx-whisper, mlx-qwen3-asr, fastapi, uvicorn等)をインストールしてから起動する。
|
|
29
|
+
`http://127.0.0.1:8765`で待受。初回起動時に指定モデルを自動ダウンロードする。
|
|
22
30
|
|
|
23
31
|
起動している間、tsunagiのSettingsで音声入力を有効にすると利用できる。停止するとtsunagi側の音声入力はエラーになる。
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""音声認識モデルの重みを事前ダウンロードするだけのスクリプト。
|
|
2
|
+
|
|
3
|
+
使い方: python3 download_model.py <Hugging FaceのリポジトリID>
|
|
4
|
+
(省略時はWhisper large-v3-turbo)
|
|
2
5
|
|
|
3
6
|
サーバー起動(server.py)より前に呼ぶことで、初回の文字起こしリクエストで
|
|
4
7
|
突然数分待たされる、という事態を避ける。tsunagi(Node)側からはこのプロセスの
|
|
@@ -6,9 +9,11 @@
|
|
|
6
9
|
進捗を計算する想定なので、ここでは特別な進捗出力はしない。
|
|
7
10
|
"""
|
|
8
11
|
|
|
12
|
+
import sys
|
|
13
|
+
|
|
9
14
|
from huggingface_hub import snapshot_download
|
|
10
15
|
|
|
11
|
-
|
|
16
|
+
DEFAULT_MODEL_REPO = "mlx-community/whisper-large-v3-turbo"
|
|
12
17
|
|
|
13
18
|
if __name__ == "__main__":
|
|
14
|
-
snapshot_download(repo_id=
|
|
19
|
+
snapshot_download(repo_id=sys.argv[1] if len(sys.argv) > 1 else DEFAULT_MODEL_REPO)
|
|
@@ -31,7 +31,7 @@ find_python() {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
if ! PYTHON_BIN="$(find_python)"; then
|
|
34
|
-
echo "
|
|
34
|
+
echo "音声入力サーバーの起動に失敗: Python ${MIN_PYTHON_VERSION}以上が見つかりません。" >&2
|
|
35
35
|
echo "'brew install python@3.12' 等でインストールしてください。" >&2
|
|
36
36
|
exit 1
|
|
37
37
|
fi
|
|
@@ -49,8 +49,13 @@ if [ ! -d "$VENV_DIR" ] || [ "$(cat "$REQUIREMENTS_HASH_FILE" 2>/dev/null)" != "
|
|
|
49
49
|
echo "$CURRENT_HASH" > "$REQUIREMENTS_HASH_FILE"
|
|
50
50
|
fi
|
|
51
51
|
|
|
52
|
+
# 読み込むモデル。未指定ならWhisper。Qwen3-ASRを使う場合は例えば
|
|
53
|
+
# TSUNAGI_ASR_ENGINE=qwen3-asr TSUNAGI_ASR_MODEL=mlx-community/Qwen3-ASR-1.7B-8bit ./run.sh
|
|
54
|
+
export TSUNAGI_ASR_ENGINE="${TSUNAGI_ASR_ENGINE:-whisper}"
|
|
55
|
+
export TSUNAGI_ASR_MODEL="${TSUNAGI_ASR_MODEL:-mlx-community/whisper-large-v3-turbo}"
|
|
56
|
+
|
|
52
57
|
# サーバー起動前にモデルを予めダウンロードしておく(初回文字起こしで待たされないように)。
|
|
53
|
-
"$VENV_DIR/bin/python3" download_model.py
|
|
58
|
+
"$VENV_DIR/bin/python3" download_model.py "$TSUNAGI_ASR_MODEL"
|
|
54
59
|
|
|
55
60
|
# venvを ~/.tsunagi 配下へ移設する場合があるため、絶対パスが焼き込まれる
|
|
56
61
|
# コンソールスクリプト(bin/uvicorn等)ではなく `python3 -m` 経由で呼び出す。
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""ローカル音声認識サーバー (Apple Silicon GPU)。
|
|
2
|
+
|
|
3
|
+
読み込むモデルは起動時の環境変数で切り替える(tsunagiのSettingsで選択したものが渡される)。
|
|
4
|
+
- TSUNAGI_ASR_ENGINE: "whisper"(mlx-whisper) / "qwen3-asr"(mlx-qwen3-asr)
|
|
5
|
+
- TSUNAGI_ASR_MODEL: Hugging FaceのリポジトリID
|
|
2
6
|
|
|
3
7
|
ユーザーが手動でセットアップ・起動する常駐プロセス。モデルをメモリに保持し続け
|
|
4
8
|
リクエスト毎のロード待ちを避ける。tsunagi本体(Fastify)からHTTPでプロキシされる。
|
|
@@ -9,6 +13,7 @@
|
|
|
9
13
|
"""
|
|
10
14
|
|
|
11
15
|
import io
|
|
16
|
+
import os
|
|
12
17
|
import re
|
|
13
18
|
import threading
|
|
14
19
|
from typing import Optional
|
|
@@ -16,9 +21,20 @@ from typing import Optional
|
|
|
16
21
|
import av
|
|
17
22
|
import numpy as np
|
|
18
23
|
from fastapi import FastAPI, File, Form, UploadFile
|
|
19
|
-
import mlx_whisper
|
|
20
24
|
|
|
21
|
-
|
|
25
|
+
ENGINE = os.environ.get("TSUNAGI_ASR_ENGINE", "whisper")
|
|
26
|
+
MODEL = os.environ.get("TSUNAGI_ASR_MODEL", "mlx-community/whisper-large-v3-turbo")
|
|
27
|
+
if ENGINE not in ("whisper", "qwen3-asr"):
|
|
28
|
+
raise SystemExit(f"Unknown TSUNAGI_ASR_ENGINE: {ENGINE}")
|
|
29
|
+
|
|
30
|
+
if ENGINE == "whisper":
|
|
31
|
+
import mlx_whisper
|
|
32
|
+
else:
|
|
33
|
+
from mlx_qwen3_asr import Session
|
|
34
|
+
|
|
35
|
+
# Qwen3-ASRはモデルを保持するSessionを起動時に作り、リクエスト毎のロード待ちを避ける。
|
|
36
|
+
# 読み込み完了まで/healthが応答しないため、tsunagi側は読み込み完了を起動完了とみなせる。
|
|
37
|
+
_qwen_session = Session(model=MODEL)
|
|
22
38
|
# 無音・雑音区間で「ご視聴ありがとうございました」等の無関係な文章を自信満々に
|
|
23
39
|
# 生成してしまう(Whisper系モデルで知られたハルシネーション挙動)ことがあるため、
|
|
24
40
|
# no_speech_prob(無音である確率)がこの値を超えるセグメントは出力から除外する。
|
|
@@ -45,7 +61,7 @@ HALLUCINATION_PHRASES = {
|
|
|
45
61
|
}
|
|
46
62
|
_NORMALIZE_RE = re.compile(r"[\s、。,.,.!!??・…「」『』()()\[\]〜~♪]+")
|
|
47
63
|
|
|
48
|
-
#
|
|
64
|
+
# MLXの推論は同時実行を想定していないため直列化する。エンドポイント自体は
|
|
49
65
|
# 同期関数にしてスレッドプールで実行させ、推論中も/healthが応答できるようにする
|
|
50
66
|
# (途中経過の表示で文字起こしリクエストが連続しても、起動状態のポーリングが
|
|
51
67
|
# タイムアウトしてUIが「未起動」扱いになるのを防ぐ)。
|
|
@@ -56,7 +72,7 @@ app = FastAPI()
|
|
|
56
72
|
|
|
57
73
|
@app.get("/health")
|
|
58
74
|
async def health() -> dict:
|
|
59
|
-
return {"status": "ok", "model": MODEL}
|
|
75
|
+
return {"status": "ok", "engine": ENGINE, "model": MODEL}
|
|
60
76
|
|
|
61
77
|
|
|
62
78
|
def decode_to_16k_mono(data: bytes) -> np.ndarray:
|
|
@@ -80,40 +96,52 @@ def is_hallucination_phrase(text: str) -> bool:
|
|
|
80
96
|
return _NORMALIZE_RE.sub("", text) in HALLUCINATION_PHRASES
|
|
81
97
|
|
|
82
98
|
|
|
83
|
-
|
|
84
|
-
def transcribe(file: UploadFile = File(...), prompt: Optional[str] = Form(None)) -> dict:
|
|
85
|
-
data = file.file.read()
|
|
86
|
-
audio = decode_to_16k_mono(data)
|
|
87
|
-
if audio.size == 0:
|
|
88
|
-
return {"text": ""}
|
|
99
|
+
def transcribe_whisper(audio: np.ndarray, prompt: Optional[str]) -> str:
|
|
89
100
|
# initial_promptは文字起こしのスタイル(表記ゆれ・句読点・固有名詞など)を
|
|
90
101
|
# 誘導するヒントで、tsunagiのSettingsからユーザーが自由に設定できる。
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
)
|
|
102
|
+
result = mlx_whisper.transcribe(
|
|
103
|
+
audio,
|
|
104
|
+
path_or_hf_repo=MODEL,
|
|
105
|
+
language="ja",
|
|
106
|
+
initial_prompt=prompt or None,
|
|
107
|
+
# 前の窓の出力を次の窓のプロンプトに引き継ぐと、一度出たハルシネーションが
|
|
108
|
+
# 後続の窓へ連鎖しやすくなるため無効化する。
|
|
109
|
+
condition_on_previous_text=False,
|
|
110
|
+
word_timestamps=True,
|
|
111
|
+
hallucination_silence_threshold=HALLUCINATION_SILENCE_THRESHOLD,
|
|
112
|
+
)
|
|
103
113
|
|
|
104
114
|
# result["text"]はno_speech_prob等のフィルタを経ずに全セグメントを結合した
|
|
105
115
|
# ものなので使わず、セグメント単位でno_speech_probを見て自前で組み立て直す。
|
|
106
116
|
segments = result.get("segments")
|
|
107
117
|
if segments:
|
|
108
|
-
|
|
118
|
+
return "".join(
|
|
109
119
|
seg["text"]
|
|
110
120
|
for seg in segments
|
|
111
121
|
if seg.get("no_speech_prob", 0.0) <= NO_SPEECH_THRESHOLD
|
|
112
122
|
and not is_hallucination_phrase(seg["text"])
|
|
113
123
|
)
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
if is_hallucination_phrase(text):
|
|
117
|
-
text = ""
|
|
124
|
+
text = result.get("text", "")
|
|
125
|
+
return "" if is_hallucination_phrase(text) else text
|
|
118
126
|
|
|
119
|
-
|
|
127
|
+
|
|
128
|
+
def transcribe_qwen3_asr(audio: np.ndarray, prompt: Optional[str]) -> str:
|
|
129
|
+
# contextはシステムプロンプトに差し込まれる語彙ヒント(スペース区切りの単語列を想定)。
|
|
130
|
+
# SettingsのプロンプトをWhisperのinitial_promptと共通で使う。
|
|
131
|
+
result = _qwen_session.transcribe(audio, context=prompt or "", language="Japanese")
|
|
132
|
+
# Whisperのようなno_speech_probは無いため、定型文の完全一致除外のみ行う。
|
|
133
|
+
return "" if is_hallucination_phrase(result.text) else result.text
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@app.post("/transcribe")
|
|
137
|
+
def transcribe(file: UploadFile = File(...), prompt: Optional[str] = Form(None)) -> dict:
|
|
138
|
+
data = file.file.read()
|
|
139
|
+
audio = decode_to_16k_mono(data)
|
|
140
|
+
if audio.size == 0:
|
|
141
|
+
return {"text": "", "model": MODEL}
|
|
142
|
+
with _transcribe_lock:
|
|
143
|
+
if ENGINE == "whisper":
|
|
144
|
+
text = transcribe_whisper(audio, prompt)
|
|
145
|
+
else:
|
|
146
|
+
text = transcribe_qwen3_asr(audio, prompt)
|
|
147
|
+
return {"text": text.strip(), "model": MODEL}
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@minimalcorp/tsunagi",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.51",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@minimalcorp/tsunagi",
|
|
9
|
-
"version": "0.1.
|
|
9
|
+
"version": "0.1.51",
|
|
10
10
|
"license": "SEE LICENSE IN LICENSE",
|
|
11
11
|
"os": [
|
|
12
12
|
"darwin",
|
package/package.json
CHANGED